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,299 @@
|
|
|
1
|
+
"""Modal dialog for picking parameter_group names to include in an export.
|
|
2
|
+
|
|
3
|
+
Used by :class:`AddDialog` when the user adds an empty FlexTool input
|
|
4
|
+
Excel: the dialog shows every parameter_group from the source DB as a
|
|
5
|
+
checkbox row, with the *required* groups highlighted at the top and a
|
|
6
|
+
hover tooltip explaining why they matter.
|
|
7
|
+
|
|
8
|
+
Theming
|
|
9
|
+
-------
|
|
10
|
+
The dialog reads the active ttk theme's background/foreground colours
|
|
11
|
+
(via :class:`ttk.Style`) and applies them to the plain ``tk`` widgets it
|
|
12
|
+
uses for the row list, so it stays readable under both light and dark
|
|
13
|
+
themes — the rest of the FlexTool GUI uses the same pattern.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import tkinter as tk
|
|
18
|
+
from tkinter import messagebox, ttk
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
from flextool.gui.hover_tooltip import attach_tooltip
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_REQUIRED_TOOLTIP = (
|
|
25
|
+
"Required for a functioning FlexTool model.\n"
|
|
26
|
+
"You can still uncheck this — a working model can be assembled by "
|
|
27
|
+
"combining data from multiple input sources in Spine Toolbox."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Highlight tints chosen to read well on top of light and dark theme
|
|
31
|
+
# surfaces. Both keep enough luminance for the forced-black text on
|
|
32
|
+
# required rows to stay legible.
|
|
33
|
+
_HIGHLIGHT_LIGHT = "#fff4c2"
|
|
34
|
+
_HIGHLIGHT_DARK = "#e8d488"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GroupPickerDialog(tk.Toplevel):
|
|
38
|
+
"""Modal picker for parameter_group selection.
|
|
39
|
+
|
|
40
|
+
After the dialog closes, ``self.result`` is one of:
|
|
41
|
+
|
|
42
|
+
* ``None`` — user cancelled, do not export.
|
|
43
|
+
* ``"all"`` — every group was checked; caller should pass
|
|
44
|
+
``include_groups=None`` to ``export_to_excel`` (preserves the
|
|
45
|
+
original unfiltered semantics, including any ungrouped params).
|
|
46
|
+
* ``list[str]`` — the explicitly selected group names.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
parent: tk.Misc,
|
|
52
|
+
groups: Sequence[dict],
|
|
53
|
+
required: Sequence[str],
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
self.title("Choose parameter groups")
|
|
57
|
+
self.transient(parent)
|
|
58
|
+
self.grab_set()
|
|
59
|
+
self.resizable(False, True)
|
|
60
|
+
|
|
61
|
+
self.result: list[str] | str | None = None
|
|
62
|
+
|
|
63
|
+
# Pull theme colours so plain tk widgets match dark / light mode.
|
|
64
|
+
style = ttk.Style(self)
|
|
65
|
+
self._theme_bg = (
|
|
66
|
+
style.lookup("TFrame", "background")
|
|
67
|
+
or self.cget("background")
|
|
68
|
+
)
|
|
69
|
+
self._theme_fg = style.lookup("TLabel", "foreground") or "black"
|
|
70
|
+
self._highlight_bg = (
|
|
71
|
+
_HIGHLIGHT_DARK
|
|
72
|
+
if _is_dark_color(self, self._theme_bg)
|
|
73
|
+
else _HIGHLIGHT_LIGHT
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Build display order: required first (in YAML order), then
|
|
77
|
+
# remaining groups sorted by priority then name.
|
|
78
|
+
by_name = {g["name"]: g for g in groups}
|
|
79
|
+
required_present = [name for name in required if name in by_name]
|
|
80
|
+
required_set = set(required_present)
|
|
81
|
+
remaining = [g for g in groups if g["name"] not in required_set]
|
|
82
|
+
remaining.sort(key=lambda g: (g.get("priority") or 999, g["name"]))
|
|
83
|
+
ordered: list[dict] = [by_name[n] for n in required_present] + remaining
|
|
84
|
+
|
|
85
|
+
self._vars: dict[str, tk.BooleanVar] = {
|
|
86
|
+
g["name"]: tk.BooleanVar(value=True) for g in ordered
|
|
87
|
+
}
|
|
88
|
+
self._all_names: list[str] = [g["name"] for g in ordered]
|
|
89
|
+
|
|
90
|
+
self._build_ui(ordered, required_set)
|
|
91
|
+
|
|
92
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
93
|
+
self._center_on_parent(parent)
|
|
94
|
+
parent.wait_window(self)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# UI construction
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _build_ui(
|
|
101
|
+
self, ordered: list[dict], required_set: set[str],
|
|
102
|
+
) -> None:
|
|
103
|
+
# Outer container — ttk.Frame inherits theme automatically.
|
|
104
|
+
outer = ttk.Frame(self, padding=10)
|
|
105
|
+
outer.pack(fill="both", expand=True)
|
|
106
|
+
|
|
107
|
+
intro = ttk.Label(
|
|
108
|
+
outer,
|
|
109
|
+
text=(
|
|
110
|
+
"Select which parameter groups to include in the new Excel "
|
|
111
|
+
"file.\nHighlighted rows are required for a functioning "
|
|
112
|
+
"FlexTool model."
|
|
113
|
+
),
|
|
114
|
+
justify="left",
|
|
115
|
+
)
|
|
116
|
+
intro.pack(anchor="w", pady=(0, 8))
|
|
117
|
+
|
|
118
|
+
# Bulk action row
|
|
119
|
+
bulk = ttk.Frame(outer)
|
|
120
|
+
bulk.pack(fill="x", pady=(0, 6))
|
|
121
|
+
ttk.Button(
|
|
122
|
+
bulk, text="Select all", command=self._on_select_all,
|
|
123
|
+
).pack(side="left", padx=(0, 4))
|
|
124
|
+
ttk.Button(
|
|
125
|
+
bulk, text="Required only",
|
|
126
|
+
command=lambda: self._on_required_only(required_set),
|
|
127
|
+
).pack(side="left", padx=(0, 4))
|
|
128
|
+
ttk.Button(
|
|
129
|
+
bulk, text="Clear all", command=self._on_clear_all,
|
|
130
|
+
).pack(side="left")
|
|
131
|
+
|
|
132
|
+
# Scrollable group list — uses tk.Frame/Canvas so we can colour
|
|
133
|
+
# the highlight rows. Background pulled from the ttk theme.
|
|
134
|
+
list_frame = tk.Frame(
|
|
135
|
+
outer, bg=self._theme_bg, highlightthickness=1,
|
|
136
|
+
highlightbackground="#888888",
|
|
137
|
+
)
|
|
138
|
+
list_frame.pack(fill="both", expand=True)
|
|
139
|
+
|
|
140
|
+
# Canvas size derived from font metrics so the dialog scales with
|
|
141
|
+
# the user's font size instead of being pinned at 360×570 px.
|
|
142
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
143
|
+
_m = get_metrics(self)
|
|
144
|
+
canvas = tk.Canvas(
|
|
145
|
+
list_frame, bg=self._theme_bg, highlightthickness=0,
|
|
146
|
+
width=_m.cw * 36, height=_m.lh * 30,
|
|
147
|
+
)
|
|
148
|
+
canvas.pack(side="left", fill="both", expand=True)
|
|
149
|
+
|
|
150
|
+
sb = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
|
151
|
+
sb.pack(side="right", fill="y")
|
|
152
|
+
canvas.configure(yscrollcommand=sb.set)
|
|
153
|
+
|
|
154
|
+
rows_frame = tk.Frame(canvas, bg=self._theme_bg)
|
|
155
|
+
canvas.create_window((0, 0), window=rows_frame, anchor="nw")
|
|
156
|
+
|
|
157
|
+
def _on_rows_configure(_event: tk.Event) -> None: # type: ignore[type-arg]
|
|
158
|
+
canvas.configure(scrollregion=canvas.bbox("all"))
|
|
159
|
+
|
|
160
|
+
rows_frame.bind("<Configure>", _on_rows_configure)
|
|
161
|
+
|
|
162
|
+
# Mouse wheel scroll
|
|
163
|
+
def _on_mousewheel(event: tk.Event) -> None: # type: ignore[type-arg]
|
|
164
|
+
canvas.yview_scroll(-int(event.delta / 120), "units")
|
|
165
|
+
|
|
166
|
+
canvas.bind("<Enter>", lambda _e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
|
|
167
|
+
canvas.bind("<Leave>", lambda _e: canvas.unbind_all("<MouseWheel>"))
|
|
168
|
+
|
|
169
|
+
# Render rows
|
|
170
|
+
for g in ordered:
|
|
171
|
+
self._make_row(rows_frame, g, g["name"] in required_set)
|
|
172
|
+
|
|
173
|
+
# OK / Cancel
|
|
174
|
+
btn_row = ttk.Frame(outer)
|
|
175
|
+
btn_row.pack(fill="x", pady=(10, 0))
|
|
176
|
+
ttk.Button(btn_row, text="Cancel", command=self._on_cancel).pack(side="right")
|
|
177
|
+
ttk.Button(btn_row, text="OK", command=self._on_ok).pack(side="right", padx=(0, 6))
|
|
178
|
+
|
|
179
|
+
def _make_row(
|
|
180
|
+
self, parent: tk.Widget, group: dict, is_required: bool,
|
|
181
|
+
) -> None:
|
|
182
|
+
bg = self._highlight_bg if is_required else self._theme_bg
|
|
183
|
+
# On highlight rows we also override the foreground so text stays
|
|
184
|
+
# readable on the yellow tint regardless of the parent theme.
|
|
185
|
+
fg = "#000000" if is_required else self._theme_fg
|
|
186
|
+
|
|
187
|
+
row = tk.Frame(parent, bg=bg)
|
|
188
|
+
row.pack(fill="x")
|
|
189
|
+
|
|
190
|
+
# Color swatch
|
|
191
|
+
swatch_color = _normalise_color(group.get("color"))
|
|
192
|
+
swatch_frame = tk.Frame(row, bg=bg, width=22, height=22)
|
|
193
|
+
swatch_frame.pack_propagate(False)
|
|
194
|
+
swatch_frame.pack(side="left", padx=(8, 6), pady=2)
|
|
195
|
+
swatch = tk.Frame(
|
|
196
|
+
swatch_frame, bg=swatch_color or "#cccccc",
|
|
197
|
+
highlightthickness=1, highlightbackground="#888888",
|
|
198
|
+
)
|
|
199
|
+
swatch.place(x=4, y=4, width=14, height=14)
|
|
200
|
+
|
|
201
|
+
cb = tk.Checkbutton(
|
|
202
|
+
row,
|
|
203
|
+
text=group["name"],
|
|
204
|
+
variable=self._vars[group["name"]],
|
|
205
|
+
bg=bg, fg=fg,
|
|
206
|
+
activebackground=bg, activeforeground=fg,
|
|
207
|
+
selectcolor=bg, # checkbox indicator interior
|
|
208
|
+
highlightthickness=0,
|
|
209
|
+
anchor="w",
|
|
210
|
+
)
|
|
211
|
+
cb.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
|
212
|
+
|
|
213
|
+
if is_required:
|
|
214
|
+
attach_tooltip(row, _REQUIRED_TOOLTIP)
|
|
215
|
+
attach_tooltip(cb, _REQUIRED_TOOLTIP)
|
|
216
|
+
attach_tooltip(swatch, _REQUIRED_TOOLTIP)
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# Button handlers
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def _on_select_all(self) -> None:
|
|
223
|
+
for v in self._vars.values():
|
|
224
|
+
v.set(True)
|
|
225
|
+
|
|
226
|
+
def _on_clear_all(self) -> None:
|
|
227
|
+
for v in self._vars.values():
|
|
228
|
+
v.set(False)
|
|
229
|
+
|
|
230
|
+
def _on_required_only(self, required_set: set[str]) -> None:
|
|
231
|
+
for name, v in self._vars.items():
|
|
232
|
+
v.set(name in required_set)
|
|
233
|
+
|
|
234
|
+
def _on_ok(self) -> None:
|
|
235
|
+
selected = [n for n in self._all_names if self._vars[n].get()]
|
|
236
|
+
if not selected:
|
|
237
|
+
messagebox.showwarning(
|
|
238
|
+
"No groups selected",
|
|
239
|
+
"Select at least one group, or press Cancel.",
|
|
240
|
+
parent=self,
|
|
241
|
+
)
|
|
242
|
+
return
|
|
243
|
+
if len(selected) == len(self._all_names):
|
|
244
|
+
self.result = "all"
|
|
245
|
+
else:
|
|
246
|
+
self.result = selected
|
|
247
|
+
self.destroy()
|
|
248
|
+
|
|
249
|
+
def _on_cancel(self) -> None:
|
|
250
|
+
self.result = None
|
|
251
|
+
self.destroy()
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# Layout helpers
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _center_on_parent(self, parent: tk.Misc) -> None:
|
|
258
|
+
self.update_idletasks()
|
|
259
|
+
try:
|
|
260
|
+
pw = parent.winfo_width()
|
|
261
|
+
ph = parent.winfo_height()
|
|
262
|
+
px = parent.winfo_rootx()
|
|
263
|
+
py = parent.winfo_rooty()
|
|
264
|
+
except tk.TclError:
|
|
265
|
+
return
|
|
266
|
+
dw = self.winfo_width()
|
|
267
|
+
dh = self.winfo_height()
|
|
268
|
+
self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _normalise_color(value: str | None) -> str | None:
|
|
272
|
+
"""Coerce a hex colour string to '#rrggbb' form for Tk, else None."""
|
|
273
|
+
if not value:
|
|
274
|
+
return None
|
|
275
|
+
s = str(value).strip()
|
|
276
|
+
if not s:
|
|
277
|
+
return None
|
|
278
|
+
if s.startswith("#"):
|
|
279
|
+
return s
|
|
280
|
+
if len(s) in (6, 8) and all(c in "0123456789abcdefABCDEF" for c in s):
|
|
281
|
+
return f"#{s[:6]}"
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_dark_color(widget: tk.Widget, color: str) -> bool:
|
|
286
|
+
"""Return True when *color* is a dark surface (so we should use a
|
|
287
|
+
dark-theme highlight tint).
|
|
288
|
+
|
|
289
|
+
Uses Tk's built-in colour parser so it accepts hex, rgb tuples,
|
|
290
|
+
and X11 / system colour names. Falls back to "light" on parse
|
|
291
|
+
failure.
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
r, g, b = widget.winfo_rgb(color)
|
|
295
|
+
except tk.TclError:
|
|
296
|
+
return False
|
|
297
|
+
# winfo_rgb returns 16-bit channels (0..65535).
|
|
298
|
+
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
299
|
+
return luminance < 32768
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Consent dialog for migrating externally-referenced FlexTool databases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ask_external_migration_consent(
|
|
11
|
+
parent: tk.Misc,
|
|
12
|
+
external_files: list[tuple[str, Path, int, int]],
|
|
13
|
+
) -> Literal["in_place", "copy_to_project", "cancel"]:
|
|
14
|
+
"""Ask the user how to migrate external (non-project) input source files.
|
|
15
|
+
|
|
16
|
+
``external_files`` is a list of ``(source_name, abs_path,
|
|
17
|
+
current_version, target_version)`` tuples; all entries share the
|
|
18
|
+
same target version.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
``"in_place"`` to migrate the external files where they live,
|
|
22
|
+
``"copy_to_project"`` to copy them into the project folder and
|
|
23
|
+
migrate the copies, or ``"cancel"`` to abort.
|
|
24
|
+
"""
|
|
25
|
+
target_version = external_files[0][3]
|
|
26
|
+
|
|
27
|
+
dlg = tk.Toplevel(parent)
|
|
28
|
+
dlg.title("External database migration")
|
|
29
|
+
dlg.transient(parent)
|
|
30
|
+
dlg.grab_set()
|
|
31
|
+
dlg.resizable(False, False)
|
|
32
|
+
|
|
33
|
+
result: Literal["in_place", "copy_to_project", "cancel"] = "cancel"
|
|
34
|
+
|
|
35
|
+
msg = (
|
|
36
|
+
"The following input source file(s) are stored OUTSIDE the "
|
|
37
|
+
f"project folder and need to be migrated to FlexTool DB "
|
|
38
|
+
f"version {target_version}:"
|
|
39
|
+
)
|
|
40
|
+
lbl = ttk.Label(dlg, text=msg, wraplength=480, justify="left")
|
|
41
|
+
lbl.pack(padx=16, pady=(16, 8), anchor="w")
|
|
42
|
+
|
|
43
|
+
list_frame = ttk.Frame(dlg, borderwidth=1, relief="sunken")
|
|
44
|
+
list_frame.pack(padx=16, pady=(0, 8), fill="x")
|
|
45
|
+
|
|
46
|
+
height = min(6, len(external_files))
|
|
47
|
+
listbox = tk.Listbox(list_frame, height=height, activestyle="none")
|
|
48
|
+
h_scroll = ttk.Scrollbar(
|
|
49
|
+
list_frame, orient="horizontal", command=listbox.xview,
|
|
50
|
+
)
|
|
51
|
+
listbox.configure(xscrollcommand=h_scroll.set)
|
|
52
|
+
|
|
53
|
+
for source_name, abs_path, current_version, _t in external_files:
|
|
54
|
+
listbox.insert(
|
|
55
|
+
"end",
|
|
56
|
+
f"{source_name} — {abs_path} "
|
|
57
|
+
f"(v{current_version} → v{target_version})",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
listbox.pack(side="top", fill="x")
|
|
61
|
+
h_scroll.pack(side="bottom", fill="x")
|
|
62
|
+
|
|
63
|
+
note = ttk.Label(
|
|
64
|
+
dlg,
|
|
65
|
+
text="Migration modifies the file in place. Choose how to proceed:",
|
|
66
|
+
wraplength=480,
|
|
67
|
+
justify="left",
|
|
68
|
+
)
|
|
69
|
+
note.pack(padx=16, pady=(0, 8), anchor="w")
|
|
70
|
+
|
|
71
|
+
btn_frame = ttk.Frame(dlg)
|
|
72
|
+
btn_frame.pack(padx=16, pady=(4, 16))
|
|
73
|
+
|
|
74
|
+
def _choose(choice: Literal["in_place", "copy_to_project", "cancel"]) -> None:
|
|
75
|
+
nonlocal result
|
|
76
|
+
result = choice
|
|
77
|
+
dlg.destroy()
|
|
78
|
+
|
|
79
|
+
ttk.Button(
|
|
80
|
+
btn_frame, text="Migrate in place",
|
|
81
|
+
command=lambda: _choose("in_place"),
|
|
82
|
+
).pack(side="left", padx=4)
|
|
83
|
+
ttk.Button(
|
|
84
|
+
btn_frame, text="Copy all to project and migrate",
|
|
85
|
+
command=lambda: _choose("copy_to_project"),
|
|
86
|
+
).pack(side="left", padx=4)
|
|
87
|
+
ttk.Button(
|
|
88
|
+
btn_frame, text="Cancel",
|
|
89
|
+
command=lambda: _choose("cancel"),
|
|
90
|
+
).pack(side="left", padx=4)
|
|
91
|
+
|
|
92
|
+
dlg.protocol("WM_DELETE_WINDOW", lambda: _choose("cancel"))
|
|
93
|
+
dlg.bind("<Escape>", lambda _e: _choose("cancel"))
|
|
94
|
+
|
|
95
|
+
# Center on parent
|
|
96
|
+
dlg.update_idletasks()
|
|
97
|
+
pw = parent.winfo_width()
|
|
98
|
+
ph = parent.winfo_height()
|
|
99
|
+
px = parent.winfo_rootx()
|
|
100
|
+
py = parent.winfo_rooty()
|
|
101
|
+
dw = dlg.winfo_width()
|
|
102
|
+
dh = dlg.winfo_height()
|
|
103
|
+
dlg.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
|
|
104
|
+
|
|
105
|
+
parent.wait_window(dlg)
|
|
106
|
+
return result
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Modal progress dialog for the automatic FlexTool DB migration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import queue
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
|
|
8
|
+
# Mirrors flextool.gui.main_window._SPINNER_FRAMES; duplicated to keep this
|
|
9
|
+
# dialog free of dependencies on main_window.
|
|
10
|
+
_SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] # rotating circle (Geometric Shapes; render on Windows Tk, unlike the old U+29D6/7 hourglasses)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MigrationProgressDialog(tk.Toplevel):
|
|
14
|
+
"""Modal progress dialog for the automatic DB migration.
|
|
15
|
+
|
|
16
|
+
The caller runs the migration in a worker thread. This dialog drives
|
|
17
|
+
the spinner from the Tk main thread via ``after`` and exposes:
|
|
18
|
+
|
|
19
|
+
- ``update_status(text: str)`` — thread-safe; schedules a label
|
|
20
|
+
update on the main thread.
|
|
21
|
+
- ``mark_finished()`` — thread-safe; schedules dialog close.
|
|
22
|
+
- ``cancel_requested`` — read-only ``bool`` property; True after
|
|
23
|
+
the user clicked Cancel (worker polls this via ``cancel_check``).
|
|
24
|
+
|
|
25
|
+
The dialog is shown by constructing it and then calling
|
|
26
|
+
``parent.wait_window(self)``. It does not spawn the worker thread
|
|
27
|
+
itself — the caller is responsible for that.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
parent: tk.Misc,
|
|
33
|
+
title: str = "Migrating database…",
|
|
34
|
+
initial_status: str = "Preparing…",
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(parent)
|
|
37
|
+
self.title(title)
|
|
38
|
+
self.transient(parent)
|
|
39
|
+
self.resizable(False, False)
|
|
40
|
+
|
|
41
|
+
self._cancel_requested: bool = False
|
|
42
|
+
self._finished: bool = False
|
|
43
|
+
# The ``after`` ids must be read/written from the main thread only.
|
|
44
|
+
self._spinner_after_id: str | None = None
|
|
45
|
+
self._pump_after_id: str | None = None
|
|
46
|
+
self._spinner_idx: int = 0
|
|
47
|
+
# Worker threads enqueue updates here; a main-thread pump applies them.
|
|
48
|
+
# tkinter is not thread-safe (even ``after`` fails off the main thread
|
|
49
|
+
# on macOS), so the public methods must not touch Tk.
|
|
50
|
+
self._ui_queue: queue.Queue = queue.Queue()
|
|
51
|
+
|
|
52
|
+
self._status_var = tk.StringVar(value=initial_status)
|
|
53
|
+
status_lbl = ttk.Label(
|
|
54
|
+
self,
|
|
55
|
+
textvariable=self._status_var,
|
|
56
|
+
wraplength=440,
|
|
57
|
+
justify="left",
|
|
58
|
+
)
|
|
59
|
+
status_lbl.pack(padx=16, pady=(16, 8), anchor="w", fill="x")
|
|
60
|
+
|
|
61
|
+
self._spinner_var = tk.StringVar(value=_SPINNER_FRAMES[0])
|
|
62
|
+
spinner_lbl = ttk.Label(
|
|
63
|
+
self,
|
|
64
|
+
textvariable=self._spinner_var,
|
|
65
|
+
font=("TkDefaultFont", 16),
|
|
66
|
+
justify="left",
|
|
67
|
+
)
|
|
68
|
+
spinner_lbl.pack(padx=16, pady=(0, 8), anchor="w")
|
|
69
|
+
|
|
70
|
+
note_lbl = ttk.Label(
|
|
71
|
+
self,
|
|
72
|
+
text=(
|
|
73
|
+
"The interface is locked until the migration finishes. "
|
|
74
|
+
"Detailed progress is shown in the Execution window. "
|
|
75
|
+
"Click Cancel to stop after the current step — a cancelled "
|
|
76
|
+
"database is restored to its original state."
|
|
77
|
+
),
|
|
78
|
+
wraplength=440,
|
|
79
|
+
justify="left",
|
|
80
|
+
)
|
|
81
|
+
note_lbl.pack(padx=16, pady=(0, 8), anchor="w", fill="x")
|
|
82
|
+
|
|
83
|
+
btn_frame = ttk.Frame(self)
|
|
84
|
+
btn_frame.pack(padx=16, pady=(4, 16), fill="x")
|
|
85
|
+
|
|
86
|
+
self._cancel_btn = ttk.Button(
|
|
87
|
+
btn_frame, text="Cancel", command=self._on_cancel,
|
|
88
|
+
)
|
|
89
|
+
self._cancel_btn.pack(side="right")
|
|
90
|
+
|
|
91
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
92
|
+
self.bind("<Escape>", lambda _e: self._on_cancel())
|
|
93
|
+
|
|
94
|
+
# Center on parent
|
|
95
|
+
self.update_idletasks()
|
|
96
|
+
pw = parent.winfo_width()
|
|
97
|
+
ph = parent.winfo_height()
|
|
98
|
+
px = parent.winfo_rootx()
|
|
99
|
+
py = parent.winfo_rooty()
|
|
100
|
+
dw = self.winfo_width()
|
|
101
|
+
dh = self.winfo_height()
|
|
102
|
+
self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
|
|
103
|
+
|
|
104
|
+
# Kick off the spinner animation and the worker-update pump.
|
|
105
|
+
self._spinner_after_id = self.after(200, self._tick_spinner)
|
|
106
|
+
self._pump_after_id = self.after(50, self._pump_queue)
|
|
107
|
+
|
|
108
|
+
# Grab input once the window is actually mapped. ``grab_set`` raises
|
|
109
|
+
# "grab failed: window not viewable" if called before Tk has mapped
|
|
110
|
+
# the Toplevel — which happens when this dialog is constructed from
|
|
111
|
+
# inside another Tk callback (e.g. the project-combo selection).
|
|
112
|
+
self._install_grab()
|
|
113
|
+
|
|
114
|
+
def _install_grab(self) -> None:
|
|
115
|
+
"""Grab input once the window is viewable, retrying on the main thread."""
|
|
116
|
+
if self._finished or not self.winfo_exists():
|
|
117
|
+
return
|
|
118
|
+
if self.winfo_viewable():
|
|
119
|
+
try:
|
|
120
|
+
self.grab_set()
|
|
121
|
+
except tk.TclError:
|
|
122
|
+
pass
|
|
123
|
+
return
|
|
124
|
+
self.after(50, self._install_grab)
|
|
125
|
+
|
|
126
|
+
# ── Public API (thread-safe wrappers) ───────────────────────────
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def cancel_requested(self) -> bool:
|
|
130
|
+
"""Whether the user has requested cancellation."""
|
|
131
|
+
return self._cancel_requested
|
|
132
|
+
|
|
133
|
+
def update_status(self, text: str) -> None:
|
|
134
|
+
"""Thread-safe: enqueue a status-label update (no Tk call here)."""
|
|
135
|
+
self._ui_queue.put(("status", text))
|
|
136
|
+
|
|
137
|
+
def mark_finished(self) -> None:
|
|
138
|
+
"""Thread-safe: enqueue dialog close (no Tk call here)."""
|
|
139
|
+
self._ui_queue.put(("finish", None))
|
|
140
|
+
|
|
141
|
+
# ── Main-thread helpers ─────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def _pump_queue(self) -> None:
|
|
144
|
+
"""Apply queued worker updates on the main thread, then reschedule.
|
|
145
|
+
|
|
146
|
+
Drains the whole queue each tick, applying only the most recent status
|
|
147
|
+
(intermediate ones are stale) and closing if a finish was requested.
|
|
148
|
+
"""
|
|
149
|
+
if self._finished:
|
|
150
|
+
return
|
|
151
|
+
latest_status: str | None = None
|
|
152
|
+
finish = False
|
|
153
|
+
try:
|
|
154
|
+
while True:
|
|
155
|
+
kind, payload = self._ui_queue.get_nowait()
|
|
156
|
+
if kind == "status":
|
|
157
|
+
latest_status = payload
|
|
158
|
+
elif kind == "finish":
|
|
159
|
+
finish = True
|
|
160
|
+
except queue.Empty:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
if latest_status is not None:
|
|
164
|
+
self._set_status(latest_status)
|
|
165
|
+
if finish:
|
|
166
|
+
self._finish()
|
|
167
|
+
return
|
|
168
|
+
self._pump_after_id = self.after(50, self._pump_queue)
|
|
169
|
+
|
|
170
|
+
def _set_status(self, text: str) -> None:
|
|
171
|
+
if self._finished:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
self._status_var.set(text)
|
|
175
|
+
except tk.TclError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _finish(self) -> None:
|
|
179
|
+
if self._finished:
|
|
180
|
+
return
|
|
181
|
+
self._finished = True
|
|
182
|
+
if self._spinner_after_id is not None:
|
|
183
|
+
try:
|
|
184
|
+
self.after_cancel(self._spinner_after_id)
|
|
185
|
+
except tk.TclError:
|
|
186
|
+
pass
|
|
187
|
+
self._spinner_after_id = None
|
|
188
|
+
if self._pump_after_id is not None:
|
|
189
|
+
try:
|
|
190
|
+
self.after_cancel(self._pump_after_id)
|
|
191
|
+
except tk.TclError:
|
|
192
|
+
pass
|
|
193
|
+
self._pump_after_id = None
|
|
194
|
+
try:
|
|
195
|
+
self.grab_release()
|
|
196
|
+
except tk.TclError:
|
|
197
|
+
pass
|
|
198
|
+
try:
|
|
199
|
+
self.destroy()
|
|
200
|
+
except tk.TclError:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _tick_spinner(self) -> None:
|
|
204
|
+
if self._finished:
|
|
205
|
+
return
|
|
206
|
+
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
|
|
207
|
+
try:
|
|
208
|
+
self._spinner_var.set(_SPINNER_FRAMES[self._spinner_idx])
|
|
209
|
+
except tk.TclError:
|
|
210
|
+
return
|
|
211
|
+
self._spinner_after_id = self.after(200, self._tick_spinner)
|
|
212
|
+
|
|
213
|
+
def _on_cancel(self) -> None:
|
|
214
|
+
if self._cancel_requested:
|
|
215
|
+
return
|
|
216
|
+
self._cancel_requested = True
|
|
217
|
+
try:
|
|
218
|
+
self._cancel_btn.configure(
|
|
219
|
+
state="disabled", text="Cancelling…",
|
|
220
|
+
)
|
|
221
|
+
except tk.TclError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def destroy(self) -> None: # noqa: D401 - override for cleanup
|
|
225
|
+
if self._spinner_after_id is not None:
|
|
226
|
+
try:
|
|
227
|
+
self.after_cancel(self._spinner_after_id)
|
|
228
|
+
except tk.TclError:
|
|
229
|
+
pass
|
|
230
|
+
self._spinner_after_id = None
|
|
231
|
+
if self._pump_after_id is not None:
|
|
232
|
+
try:
|
|
233
|
+
self.after_cancel(self._pump_after_id)
|
|
234
|
+
except tk.TclError:
|
|
235
|
+
pass
|
|
236
|
+
self._pump_after_id = None
|
|
237
|
+
super().destroy()
|