flextool 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
import tkinter.font as tkfont
|
|
7
|
+
from tkinter import ttk, messagebox
|
|
8
|
+
|
|
9
|
+
from flextool.gui.project_utils import (
|
|
10
|
+
create_project,
|
|
11
|
+
get_projects_dir,
|
|
12
|
+
list_projects,
|
|
13
|
+
rename_project,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectDialog(tk.Toplevel):
|
|
20
|
+
"""Modal dialog for creating, selecting, renaming, and deleting projects.
|
|
21
|
+
|
|
22
|
+
After the dialog closes:
|
|
23
|
+
* ``self.result`` holds the selected project name (Open clicked) or
|
|
24
|
+
``None`` if the user cancelled.
|
|
25
|
+
* ``self.deleted_names`` lists projects that were deleted while the
|
|
26
|
+
dialog was open. The caller can use it to check whether the
|
|
27
|
+
currently-open project was removed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, parent: tk.Misc, current_project: str | None = None) -> None:
|
|
31
|
+
super().__init__(parent)
|
|
32
|
+
self.title("Project menu")
|
|
33
|
+
self.result: str | None = None
|
|
34
|
+
self.deleted_names: list[str] = []
|
|
35
|
+
self._current_project = current_project
|
|
36
|
+
|
|
37
|
+
# ── Modal behaviour ─────────────────────────────────────────
|
|
38
|
+
self.transient(parent)
|
|
39
|
+
self.grab_set()
|
|
40
|
+
|
|
41
|
+
# ── Font metrics for DPI-aware sizing ──────────────────────
|
|
42
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
43
|
+
_metrics = get_metrics(self)
|
|
44
|
+
cw: int = _metrics.cw
|
|
45
|
+
lh: int = _metrics.lh
|
|
46
|
+
|
|
47
|
+
# ── Dialog size ─────────────────────────────────────────────
|
|
48
|
+
self.geometry(f"{cw * 70}x{lh * 30}")
|
|
49
|
+
self.minsize(cw * 50, lh * 20)
|
|
50
|
+
|
|
51
|
+
self._rename_entry: tk.Entry | None = None
|
|
52
|
+
|
|
53
|
+
self._build_widgets()
|
|
54
|
+
self._populate_projects()
|
|
55
|
+
|
|
56
|
+
# Close via window-manager "X"
|
|
57
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
58
|
+
|
|
59
|
+
# Centre on parent
|
|
60
|
+
self.update_idletasks()
|
|
61
|
+
px = parent.winfo_rootx()
|
|
62
|
+
py = parent.winfo_rooty()
|
|
63
|
+
pw = parent.winfo_width()
|
|
64
|
+
ph = parent.winfo_height()
|
|
65
|
+
w = self.winfo_width()
|
|
66
|
+
h = self.winfo_height()
|
|
67
|
+
x = px + (pw - w) // 2
|
|
68
|
+
y = py + (ph - h) // 2
|
|
69
|
+
self.geometry(f"+{x}+{y}")
|
|
70
|
+
|
|
71
|
+
# Block until closed
|
|
72
|
+
parent.wait_window(self)
|
|
73
|
+
|
|
74
|
+
# ── Widget construction ─────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _build_widgets(self) -> None:
|
|
77
|
+
pad = dict(padx=10, pady=5)
|
|
78
|
+
|
|
79
|
+
# -- New project section --
|
|
80
|
+
new_frame = ttk.LabelFrame(self, text="Create an empty project", padding=8)
|
|
81
|
+
new_frame.pack(fill="x", **pad)
|
|
82
|
+
|
|
83
|
+
entry_row = ttk.Frame(new_frame)
|
|
84
|
+
entry_row.pack(fill="x")
|
|
85
|
+
|
|
86
|
+
ttk.Label(entry_row, text="Name:").pack(side="left")
|
|
87
|
+
self._new_name_var = tk.StringVar()
|
|
88
|
+
self._new_name_entry = ttk.Entry(entry_row, textvariable=self._new_name_var, width=30)
|
|
89
|
+
self._new_name_entry.pack(side="left", padx=(5, 0), fill="x", expand=True)
|
|
90
|
+
|
|
91
|
+
self._create_btn = ttk.Button(new_frame, text="Create", command=self._on_create)
|
|
92
|
+
self._create_btn.pack(anchor="w", pady=(5, 0))
|
|
93
|
+
|
|
94
|
+
# Allow Enter in the entry to create
|
|
95
|
+
self._new_name_entry.bind("<Return>", lambda _e: self._on_create())
|
|
96
|
+
|
|
97
|
+
# -- Existing projects section --
|
|
98
|
+
existing_frame = ttk.LabelFrame(self, text="Existing projects", padding=8)
|
|
99
|
+
existing_frame.pack(fill="both", expand=True, **pad)
|
|
100
|
+
|
|
101
|
+
list_frame = ttk.Frame(existing_frame)
|
|
102
|
+
list_frame.pack(fill="both", expand=True)
|
|
103
|
+
list_frame.columnconfigure(0, weight=1)
|
|
104
|
+
list_frame.rowconfigure(0, weight=1)
|
|
105
|
+
|
|
106
|
+
self._project_listbox = tk.Listbox(list_frame, selectmode="browse", activestyle="dotbox")
|
|
107
|
+
self._project_listbox.grid(row=0, column=0, sticky="nsew")
|
|
108
|
+
|
|
109
|
+
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._project_listbox.yview)
|
|
110
|
+
self._project_listbox.configure(yscrollcommand=scrollbar.set)
|
|
111
|
+
scrollbar.grid(row=0, column=1, sticky="ns")
|
|
112
|
+
|
|
113
|
+
# Double-click opens
|
|
114
|
+
self._project_listbox.bind("<Double-Button-1>", lambda _e: self._on_open())
|
|
115
|
+
# F2 triggers inline rename
|
|
116
|
+
self._project_listbox.bind("<F2>", lambda _e: self._start_inline_rename())
|
|
117
|
+
|
|
118
|
+
# Enable/disable Ok when listbox selection changes
|
|
119
|
+
self._project_listbox.bind("<<ListboxSelect>>", lambda _e: self._update_ok_state())
|
|
120
|
+
|
|
121
|
+
# -- Bottom buttons --
|
|
122
|
+
btn_frame = ttk.Frame(self)
|
|
123
|
+
btn_frame.pack(fill="x", **pad)
|
|
124
|
+
|
|
125
|
+
self._open_btn = ttk.Button(btn_frame, text="Open the selected project", command=self._on_open, state="disabled")
|
|
126
|
+
self._open_btn.pack(side="left")
|
|
127
|
+
|
|
128
|
+
self._delete_btn = ttk.Button(
|
|
129
|
+
btn_frame,
|
|
130
|
+
text="Delete",
|
|
131
|
+
command=self._on_delete_clicked,
|
|
132
|
+
state="disabled",
|
|
133
|
+
)
|
|
134
|
+
self._delete_btn.pack(side="left", padx=(10, 0))
|
|
135
|
+
|
|
136
|
+
self._cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self._on_cancel)
|
|
137
|
+
self._cancel_btn.pack(side="right")
|
|
138
|
+
|
|
139
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _update_ok_state(self) -> None:
|
|
142
|
+
"""Enable Open/Delete buttons when a project is selected."""
|
|
143
|
+
if self._project_listbox.curselection():
|
|
144
|
+
self._open_btn.configure(state="normal")
|
|
145
|
+
self._delete_btn.configure(state="normal")
|
|
146
|
+
else:
|
|
147
|
+
self._open_btn.configure(state="disabled")
|
|
148
|
+
self._delete_btn.configure(state="disabled")
|
|
149
|
+
|
|
150
|
+
def _populate_projects(self) -> None:
|
|
151
|
+
"""Refresh the listbox with the current project list."""
|
|
152
|
+
self._project_listbox.delete(0, "end")
|
|
153
|
+
for name in list_projects():
|
|
154
|
+
self._project_listbox.insert("end", name)
|
|
155
|
+
|
|
156
|
+
def _selected_project(self) -> str | None:
|
|
157
|
+
"""Return the currently selected project name, or None."""
|
|
158
|
+
sel = self._project_listbox.curselection()
|
|
159
|
+
if not sel:
|
|
160
|
+
return None
|
|
161
|
+
return self._project_listbox.get(sel[0])
|
|
162
|
+
|
|
163
|
+
# ── Event handlers ──────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _on_create(self) -> None:
|
|
166
|
+
name = self._new_name_var.get().strip()
|
|
167
|
+
if not name:
|
|
168
|
+
messagebox.showwarning("Invalid name", "Please enter a project name.", parent=self)
|
|
169
|
+
return
|
|
170
|
+
# Disallow names with path separators or other problematic chars
|
|
171
|
+
if "/" in name or "\\" in name:
|
|
172
|
+
messagebox.showwarning(
|
|
173
|
+
"Invalid name",
|
|
174
|
+
"Project name must not contain '/' or '\\'.",
|
|
175
|
+
parent=self,
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
# Check for duplicate before creating
|
|
179
|
+
if (get_projects_dir() / name).exists():
|
|
180
|
+
messagebox.showwarning(
|
|
181
|
+
"Already exists",
|
|
182
|
+
f"A project named '{name}' already exists.",
|
|
183
|
+
parent=self,
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
create_project(name)
|
|
188
|
+
except OSError as exc:
|
|
189
|
+
messagebox.showerror("Error", str(exc), parent=self)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self._populate_projects()
|
|
193
|
+
# Select the newly created project in the listbox
|
|
194
|
+
items = self._project_listbox.get(0, "end")
|
|
195
|
+
try:
|
|
196
|
+
idx = list(items).index(name)
|
|
197
|
+
self._project_listbox.selection_clear(0, "end")
|
|
198
|
+
self._project_listbox.selection_set(idx)
|
|
199
|
+
self._project_listbox.see(idx)
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
self._new_name_var.set("")
|
|
203
|
+
self._update_ok_state()
|
|
204
|
+
|
|
205
|
+
def _on_open(self) -> None:
|
|
206
|
+
name = self._selected_project()
|
|
207
|
+
if name is None:
|
|
208
|
+
messagebox.showinfo("No selection", "Please select a project first.", parent=self)
|
|
209
|
+
return
|
|
210
|
+
self.result = name
|
|
211
|
+
self.grab_release()
|
|
212
|
+
self.destroy()
|
|
213
|
+
|
|
214
|
+
def _on_cancel(self) -> None:
|
|
215
|
+
self.result = None
|
|
216
|
+
self.grab_release()
|
|
217
|
+
self.destroy()
|
|
218
|
+
|
|
219
|
+
# ── Delete project ──────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def _on_delete_clicked(self) -> None:
|
|
222
|
+
"""Confirm and recursively delete the selected project directory."""
|
|
223
|
+
name = self._selected_project()
|
|
224
|
+
if name is None:
|
|
225
|
+
return
|
|
226
|
+
if not self._confirm_delete(name):
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
project_path = get_projects_dir() / name
|
|
230
|
+
try:
|
|
231
|
+
shutil.rmtree(project_path)
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
logger.warning("Failed to delete project %r", name, exc_info=True)
|
|
234
|
+
messagebox.showerror(
|
|
235
|
+
"Delete failed",
|
|
236
|
+
f"Could not delete project '{name}':\n\n{exc}",
|
|
237
|
+
parent=self,
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
self.deleted_names.append(name)
|
|
242
|
+
self._populate_projects()
|
|
243
|
+
self._update_ok_state()
|
|
244
|
+
|
|
245
|
+
def _confirm_delete(self, name: str) -> bool:
|
|
246
|
+
"""Show a custom confirmation dialog; return True if user confirms.
|
|
247
|
+
|
|
248
|
+
The confirmation uses a capital-letters warning line and a
|
|
249
|
+
regular-text explanation that the deletion is recursive
|
|
250
|
+
(including any imported input databases). Default focus is
|
|
251
|
+
Cancel so an accidental Enter doesn't trigger deletion.
|
|
252
|
+
"""
|
|
253
|
+
dlg = tk.Toplevel(self)
|
|
254
|
+
dlg.title("Delete project")
|
|
255
|
+
dlg.transient(self)
|
|
256
|
+
dlg.grab_set()
|
|
257
|
+
dlg.resizable(False, False)
|
|
258
|
+
|
|
259
|
+
# Sizing — match the parent dialog's DPI-aware metric helper.
|
|
260
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
261
|
+
_m = get_metrics(dlg)
|
|
262
|
+
cw, lh = _m.cw, _m.lh
|
|
263
|
+
wrap_px = cw * 60
|
|
264
|
+
|
|
265
|
+
confirmed = {"value": False}
|
|
266
|
+
|
|
267
|
+
body = ttk.Frame(dlg, padding=(cw * 2, lh))
|
|
268
|
+
body.pack(fill="both", expand=True)
|
|
269
|
+
|
|
270
|
+
warning_font = tkfont.Font(font="TkDefaultFont")
|
|
271
|
+
warning_font.configure(weight="bold", size=warning_font.cget("size") + 2)
|
|
272
|
+
|
|
273
|
+
ttk.Label(
|
|
274
|
+
body,
|
|
275
|
+
text="ALL PROJECT DATA WILL BE PERMANENTLY DELETED",
|
|
276
|
+
font=warning_font,
|
|
277
|
+
foreground="#b00020",
|
|
278
|
+
wraplength=wrap_px,
|
|
279
|
+
justify="left",
|
|
280
|
+
).pack(anchor="w", pady=(0, lh))
|
|
281
|
+
|
|
282
|
+
ttk.Label(
|
|
283
|
+
body,
|
|
284
|
+
text=(
|
|
285
|
+
"This includes input databases (Excel, SQLite) that were "
|
|
286
|
+
"copied or imported into the project folder, as well as "
|
|
287
|
+
"all output results, plots, settings, and any other files "
|
|
288
|
+
"under the project directory."
|
|
289
|
+
),
|
|
290
|
+
wraplength=wrap_px,
|
|
291
|
+
justify="left",
|
|
292
|
+
).pack(anchor="w")
|
|
293
|
+
|
|
294
|
+
ttk.Label(
|
|
295
|
+
body,
|
|
296
|
+
text=f"Project: {name}",
|
|
297
|
+
wraplength=wrap_px,
|
|
298
|
+
justify="left",
|
|
299
|
+
).pack(anchor="w", pady=(lh, 0))
|
|
300
|
+
|
|
301
|
+
# -- Buttons --
|
|
302
|
+
btn_row = ttk.Frame(dlg, padding=(cw * 2, 0, cw * 2, lh))
|
|
303
|
+
btn_row.pack(fill="x")
|
|
304
|
+
|
|
305
|
+
def _do_delete() -> None:
|
|
306
|
+
confirmed["value"] = True
|
|
307
|
+
dlg.grab_release()
|
|
308
|
+
dlg.destroy()
|
|
309
|
+
|
|
310
|
+
def _do_cancel() -> None:
|
|
311
|
+
confirmed["value"] = False
|
|
312
|
+
dlg.grab_release()
|
|
313
|
+
dlg.destroy()
|
|
314
|
+
|
|
315
|
+
# Try to use a danger style if the theme defines one; fall back
|
|
316
|
+
# silently to the default button style.
|
|
317
|
+
delete_btn = ttk.Button(btn_row, text="Delete", command=_do_delete)
|
|
318
|
+
try:
|
|
319
|
+
ttk.Style().configure("Danger.TButton", foreground="#b00020")
|
|
320
|
+
delete_btn.configure(style="Danger.TButton")
|
|
321
|
+
except tk.TclError:
|
|
322
|
+
pass
|
|
323
|
+
delete_btn.pack(side="left")
|
|
324
|
+
|
|
325
|
+
cancel_btn = ttk.Button(btn_row, text="Cancel", command=_do_cancel)
|
|
326
|
+
cancel_btn.pack(side="right")
|
|
327
|
+
|
|
328
|
+
dlg.protocol("WM_DELETE_WINDOW", _do_cancel)
|
|
329
|
+
dlg.bind("<Escape>", lambda _e: _do_cancel())
|
|
330
|
+
# Default focus = Cancel (defensive — Enter should not delete).
|
|
331
|
+
cancel_btn.focus_set()
|
|
332
|
+
|
|
333
|
+
# Centre on parent dialog.
|
|
334
|
+
dlg.update_idletasks()
|
|
335
|
+
px = self.winfo_rootx()
|
|
336
|
+
py = self.winfo_rooty()
|
|
337
|
+
pw = self.winfo_width()
|
|
338
|
+
ph = self.winfo_height()
|
|
339
|
+
w = dlg.winfo_width()
|
|
340
|
+
h = dlg.winfo_height()
|
|
341
|
+
x = px + (pw - w) // 2
|
|
342
|
+
y = py + (ph - h) // 2
|
|
343
|
+
dlg.geometry(f"+{x}+{y}")
|
|
344
|
+
|
|
345
|
+
self.wait_window(dlg)
|
|
346
|
+
return confirmed["value"]
|
|
347
|
+
|
|
348
|
+
# ── Inline rename ───────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def _start_inline_rename(self) -> None:
|
|
351
|
+
"""Overlay an Entry widget on the selected listbox item for renaming."""
|
|
352
|
+
sel = self._project_listbox.curselection()
|
|
353
|
+
if not sel:
|
|
354
|
+
return
|
|
355
|
+
index = sel[0]
|
|
356
|
+
old_name = self._project_listbox.get(index)
|
|
357
|
+
|
|
358
|
+
# Cancel any previous rename entry
|
|
359
|
+
self._cancel_inline_rename()
|
|
360
|
+
|
|
361
|
+
# Position the entry over the listbox item
|
|
362
|
+
bbox = self._project_listbox.bbox(index)
|
|
363
|
+
if bbox is None:
|
|
364
|
+
return
|
|
365
|
+
x, y, width, height = bbox
|
|
366
|
+
|
|
367
|
+
entry = tk.Entry(self._project_listbox, width=0)
|
|
368
|
+
entry.place(x=x, y=y, width=max(width, self._project_listbox.winfo_width()), height=height)
|
|
369
|
+
entry.insert(0, old_name)
|
|
370
|
+
entry.select_range(0, "end")
|
|
371
|
+
entry.focus_set()
|
|
372
|
+
|
|
373
|
+
self._rename_entry = entry
|
|
374
|
+
self._rename_index = index
|
|
375
|
+
self._rename_old_name = old_name
|
|
376
|
+
|
|
377
|
+
entry.bind("<Return>", lambda _e: self._finish_inline_rename())
|
|
378
|
+
entry.bind("<Escape>", lambda _e: self._cancel_inline_rename())
|
|
379
|
+
entry.bind("<FocusOut>", lambda _e: self._cancel_inline_rename())
|
|
380
|
+
|
|
381
|
+
def _finish_inline_rename(self) -> None:
|
|
382
|
+
if self._rename_entry is None:
|
|
383
|
+
return
|
|
384
|
+
new_name = self._rename_entry.get().strip()
|
|
385
|
+
old_name = self._rename_old_name
|
|
386
|
+
self._rename_entry.destroy()
|
|
387
|
+
self._rename_entry = None
|
|
388
|
+
|
|
389
|
+
if not new_name or new_name == old_name:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
rename_project(old_name, new_name)
|
|
394
|
+
except FileExistsError:
|
|
395
|
+
messagebox.showwarning(
|
|
396
|
+
"Already exists",
|
|
397
|
+
f"A project named '{new_name}' already exists.",
|
|
398
|
+
parent=self,
|
|
399
|
+
)
|
|
400
|
+
return
|
|
401
|
+
except FileNotFoundError:
|
|
402
|
+
messagebox.showerror(
|
|
403
|
+
"Not found",
|
|
404
|
+
f"Project '{old_name}' no longer exists.",
|
|
405
|
+
parent=self,
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
except OSError as exc:
|
|
409
|
+
messagebox.showerror("Error", str(exc), parent=self)
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
self._populate_projects()
|
|
413
|
+
# Re-select the renamed project
|
|
414
|
+
items = self._project_listbox.get(0, "end")
|
|
415
|
+
try:
|
|
416
|
+
idx = list(items).index(new_name)
|
|
417
|
+
self._project_listbox.selection_clear(0, "end")
|
|
418
|
+
self._project_listbox.selection_set(idx)
|
|
419
|
+
self._project_listbox.see(idx)
|
|
420
|
+
except ValueError:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
def _cancel_inline_rename(self) -> None:
|
|
424
|
+
if self._rename_entry is not None:
|
|
425
|
+
self._rename_entry.destroy()
|
|
426
|
+
self._rename_entry = None
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Modal dialog for the in-app "Update FlexTool" action."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from tkinter import ttk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UpdateDialog(tk.Toplevel):
|
|
11
|
+
"""Ask the user to confirm a FlexTool self-update.
|
|
12
|
+
|
|
13
|
+
Exposes these attributes after it closes:
|
|
14
|
+
|
|
15
|
+
- ``proceed`` -- ``True`` if the user clicked Update.
|
|
16
|
+
- ``include_toolbox`` -- ``True`` if Spine Toolbox should be installed
|
|
17
|
+
alongside FlexTool (the ``[toolbox]`` extra).
|
|
18
|
+
- ``check_on_startup`` -- the (possibly changed) "check for updates on
|
|
19
|
+
startup" preference; persisted whether or not the user updated.
|
|
20
|
+
- ``update_available`` -- the latest known availability (possibly
|
|
21
|
+
refreshed by the in-dialog Check button); ``None`` if never determined.
|
|
22
|
+
|
|
23
|
+
The **Update** button is disabled when we know there is no update; the
|
|
24
|
+
**Check** button re-queries on demand (useful when the startup check is
|
|
25
|
+
off) and re-enables Update if the situation changed.
|
|
26
|
+
|
|
27
|
+
Show it by constructing it and calling ``parent.wait_window(self)``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
parent: tk.Misc,
|
|
33
|
+
*,
|
|
34
|
+
install_description: str,
|
|
35
|
+
is_git: bool,
|
|
36
|
+
default_toolbox: bool,
|
|
37
|
+
check_on_startup: bool,
|
|
38
|
+
update_available: bool | None,
|
|
39
|
+
check_fn: Callable[[], bool],
|
|
40
|
+
post_to_main: Callable[..., None],
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(parent)
|
|
43
|
+
self.title("Update FlexTool")
|
|
44
|
+
self.transient(parent)
|
|
45
|
+
self.resizable(False, False)
|
|
46
|
+
|
|
47
|
+
self.proceed: bool = False
|
|
48
|
+
self.include_toolbox: bool = default_toolbox
|
|
49
|
+
self.check_on_startup: bool = check_on_startup
|
|
50
|
+
self.update_available: bool | None = update_available
|
|
51
|
+
self._initial_check_on_startup: bool = check_on_startup
|
|
52
|
+
self._check_fn = check_fn
|
|
53
|
+
self._post_to_main = post_to_main
|
|
54
|
+
|
|
55
|
+
self._toolbox_var = tk.BooleanVar(value=default_toolbox)
|
|
56
|
+
self._check_startup_var = tk.BooleanVar(value=check_on_startup)
|
|
57
|
+
self._status_var = tk.StringVar()
|
|
58
|
+
|
|
59
|
+
body = ttk.Frame(self, padding=16)
|
|
60
|
+
body.pack(fill="both", expand=True)
|
|
61
|
+
|
|
62
|
+
ttk.Label(
|
|
63
|
+
body,
|
|
64
|
+
text="Update FlexTool to the latest version.",
|
|
65
|
+
font=("TkDefaultFont", 11, "bold"),
|
|
66
|
+
).pack(anchor="w")
|
|
67
|
+
|
|
68
|
+
method = (
|
|
69
|
+
"git pull + editable reinstall" if is_git else "pip upgrade from PyPI"
|
|
70
|
+
)
|
|
71
|
+
ttk.Label(
|
|
72
|
+
body,
|
|
73
|
+
text=f"Current install: {install_description}\nUpdate method: {method}.",
|
|
74
|
+
wraplength=440,
|
|
75
|
+
justify="left",
|
|
76
|
+
).pack(anchor="w", pady=(8, 8))
|
|
77
|
+
|
|
78
|
+
ttk.Label(
|
|
79
|
+
body, textvariable=self._status_var, wraplength=440, justify="left",
|
|
80
|
+
).pack(anchor="w", pady=(0, 8))
|
|
81
|
+
|
|
82
|
+
ttk.Checkbutton(
|
|
83
|
+
body, text="Install Spine Toolbox", variable=self._toolbox_var,
|
|
84
|
+
).pack(anchor="w")
|
|
85
|
+
ttk.Label(
|
|
86
|
+
body,
|
|
87
|
+
text=(
|
|
88
|
+
"Spine Toolbox is a large optional dependency. It is required "
|
|
89
|
+
"to open .sqlite input sources in the Spine DB Editor. Leave "
|
|
90
|
+
"unchecked if you do not need it."
|
|
91
|
+
),
|
|
92
|
+
wraplength=440,
|
|
93
|
+
justify="left",
|
|
94
|
+
foreground="#888888",
|
|
95
|
+
).pack(anchor="w", padx=(24, 0), pady=(0, 8))
|
|
96
|
+
|
|
97
|
+
ttk.Label(
|
|
98
|
+
body,
|
|
99
|
+
text=(
|
|
100
|
+
"FlexTool must be restarted after an update. Progress is shown "
|
|
101
|
+
"in the Execution window."
|
|
102
|
+
),
|
|
103
|
+
wraplength=440,
|
|
104
|
+
justify="left",
|
|
105
|
+
).pack(anchor="w", pady=(0, 8))
|
|
106
|
+
|
|
107
|
+
ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(0, 8))
|
|
108
|
+
ttk.Checkbutton(
|
|
109
|
+
body,
|
|
110
|
+
text="Check for updates on startup",
|
|
111
|
+
variable=self._check_startup_var,
|
|
112
|
+
).pack(anchor="w", pady=(0, 8))
|
|
113
|
+
|
|
114
|
+
btns = ttk.Frame(body)
|
|
115
|
+
btns.pack(fill="x", pady=(4, 0))
|
|
116
|
+
# Check on the left; right-aligned left→right: Update | OK | Cancel.
|
|
117
|
+
# Update = save settings and upgrade; OK = save settings only; Cancel =
|
|
118
|
+
# close without changing anything.
|
|
119
|
+
self._check_btn = ttk.Button(btns, text="Check", command=self._on_check)
|
|
120
|
+
self._check_btn.pack(side="left")
|
|
121
|
+
ttk.Button(btns, text="Cancel", command=self._on_cancel).pack(side="right")
|
|
122
|
+
ttk.Button(btns, text="OK", command=self._on_ok).pack(
|
|
123
|
+
side="right", padx=(0, 8)
|
|
124
|
+
)
|
|
125
|
+
self._update_btn = ttk.Button(btns, text="Update", command=self._on_update)
|
|
126
|
+
self._update_btn.pack(side="right", padx=(0, 8))
|
|
127
|
+
|
|
128
|
+
self._render_availability()
|
|
129
|
+
|
|
130
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
131
|
+
self.bind("<Escape>", lambda _e: self._on_cancel())
|
|
132
|
+
|
|
133
|
+
self.grab_set()
|
|
134
|
+
self.update_idletasks()
|
|
135
|
+
pw, ph = parent.winfo_width(), parent.winfo_height()
|
|
136
|
+
px, py = parent.winfo_rootx(), parent.winfo_rooty()
|
|
137
|
+
dw, dh = self.winfo_width(), self.winfo_height()
|
|
138
|
+
self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
|
|
139
|
+
|
|
140
|
+
# ── Availability state ──────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def _render_availability(self) -> None:
|
|
143
|
+
"""Reflect ``update_available`` in the status label and Update button."""
|
|
144
|
+
if self.update_available is True:
|
|
145
|
+
self._status_var.set("Status: a newer version is available.")
|
|
146
|
+
self._set_update_enabled(True)
|
|
147
|
+
elif self.update_available is False:
|
|
148
|
+
self._status_var.set("Status: you are up to date.")
|
|
149
|
+
self._set_update_enabled(False)
|
|
150
|
+
else:
|
|
151
|
+
self._status_var.set(
|
|
152
|
+
"Status: not checked — click Check to look for updates."
|
|
153
|
+
)
|
|
154
|
+
self._set_update_enabled(True)
|
|
155
|
+
|
|
156
|
+
def _set_update_enabled(self, enabled: bool) -> None:
|
|
157
|
+
try:
|
|
158
|
+
self._update_btn.configure(state="normal" if enabled else "disabled")
|
|
159
|
+
except tk.TclError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def _on_check(self) -> None:
|
|
163
|
+
"""Query for an update off the UI thread; update state when it returns."""
|
|
164
|
+
self._check_btn.configure(state="disabled")
|
|
165
|
+
self._status_var.set("Status: checking…")
|
|
166
|
+
|
|
167
|
+
def _worker() -> None:
|
|
168
|
+
try:
|
|
169
|
+
available = bool(self._check_fn())
|
|
170
|
+
except Exception:
|
|
171
|
+
available = False
|
|
172
|
+
self._post_to_main(self._apply_check_result, available)
|
|
173
|
+
|
|
174
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
175
|
+
|
|
176
|
+
def _apply_check_result(self, available: bool) -> None:
|
|
177
|
+
"""Apply a Check result on the main thread."""
|
|
178
|
+
if not self.winfo_exists():
|
|
179
|
+
return
|
|
180
|
+
self.update_available = available
|
|
181
|
+
self._render_availability()
|
|
182
|
+
try:
|
|
183
|
+
self._check_btn.configure(state="normal")
|
|
184
|
+
except tk.TclError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# ── Button handlers ─────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def _on_update(self) -> None:
|
|
190
|
+
self.proceed = True
|
|
191
|
+
self.include_toolbox = bool(self._toolbox_var.get())
|
|
192
|
+
self.check_on_startup = bool(self._check_startup_var.get())
|
|
193
|
+
self._close()
|
|
194
|
+
|
|
195
|
+
def _on_ok(self) -> None:
|
|
196
|
+
"""Save the settings (e.g. the startup-check toggle) without updating."""
|
|
197
|
+
self.proceed = False
|
|
198
|
+
self.check_on_startup = bool(self._check_startup_var.get())
|
|
199
|
+
self._close()
|
|
200
|
+
|
|
201
|
+
def _on_cancel(self) -> None:
|
|
202
|
+
"""Close without applying any change (discard the toggle edits)."""
|
|
203
|
+
self.proceed = False
|
|
204
|
+
self.check_on_startup = self._initial_check_on_startup
|
|
205
|
+
self._close()
|
|
206
|
+
|
|
207
|
+
def _close(self) -> None:
|
|
208
|
+
try:
|
|
209
|
+
self.grab_release()
|
|
210
|
+
except tk.TclError:
|
|
211
|
+
pass
|
|
212
|
+
self.destroy()
|