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,2184 @@
|
|
|
1
|
+
"""Non-modal color/order picker for a project's ``plot_settings.yaml``.
|
|
2
|
+
|
|
3
|
+
This is the GUI editor that replaces the old plain-text
|
|
4
|
+
``PlotSettingsEditor``. It treats the project ``plot_settings.yaml`` as a
|
|
5
|
+
plain STRUCTURED data file (pyyaml load -> dict -> dump with
|
|
6
|
+
``sort_keys=False``). The window is **non-modal** so it can be used at the
|
|
7
|
+
same time as the result viewer: every user edit writes the file **live**
|
|
8
|
+
(debounced) and calls an ``on_apply`` callback (the viewer re-renders = live
|
|
9
|
+
preview) while the window stays open — there is no Apply button.
|
|
10
|
+
|
|
11
|
+
The window loads the file and renders a two-pane layout: a LEFT
|
|
12
|
+
``ttk.Treeview`` selector lists every present section (fixed order, not
|
|
13
|
+
reorderable) and the RIGHT pane shows the selected section's reorderable
|
|
14
|
+
``ttk.Treeview``, each entry with a composite ``[pos][neg]`` swatch image
|
|
15
|
+
next to its name. Editing interactions:
|
|
16
|
+
reorder (drag + Alt-arrow), per-row color dialog (double-click), and
|
|
17
|
+
multi-level **Undo/Redo** over the in-memory working dict — each mutation
|
|
18
|
+
schedules a debounced live write + ``on_apply``. **Close** keeps all
|
|
19
|
+
changes (flushes the pending live write); **Cancel** reverts to the on-open
|
|
20
|
+
file text. On open, the entity tabs are seeded additively from the solved
|
|
21
|
+
``output_parquet`` dispatch (falling back to the project input DB(s) only
|
|
22
|
+
when there is no dispatch output yet), with newly-discovered entries given a
|
|
23
|
+
one-time std-dev order (see ``_order_new_names_by_std``); this on-open
|
|
24
|
+
discovery does not itself write the file (only user actions do).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import colorsys
|
|
31
|
+
import copy
|
|
32
|
+
import logging
|
|
33
|
+
import struct
|
|
34
|
+
import tkinter as tk
|
|
35
|
+
import zlib
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from tkinter import ttk
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
from flextool.scenario_comparison.plot_settings_seed import dump_plot_settings
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Entity classes, in the order they should appear as tabs.
|
|
48
|
+
_ENTITY_CLASSES = ("nodeGroup", "flowGroup", "unit", "connection", "node")
|
|
49
|
+
# Category subsections, in tab order.
|
|
50
|
+
_CATEGORY_SECTIONS = ("costs", "node_flows", "nodegroup_flows", "dispatch")
|
|
51
|
+
|
|
52
|
+
# The ``("categories", "dispatch")`` category is ENGINE-FIXED order (see the
|
|
53
|
+
# module docstring for the stack): its special columns are pinned by the
|
|
54
|
+
# engine — positive specials at the top, negative specials at the bottom — with
|
|
55
|
+
# the movable flowGroup entity bands stacked near the zero axis in between. In
|
|
56
|
+
# the picker it is therefore rendered as a NON-reorderable list that mirrors
|
|
57
|
+
# that stack, with a synthetic, immovable divider row marking where the flow
|
|
58
|
+
# bands stack. ``_DIVIDER_TAG`` tags the divider row (a Treeview tag used for
|
|
59
|
+
# its distinct highlight); ``_DIVIDER_TEXT`` is the display-only label — it is
|
|
60
|
+
# NEVER a key in ``self._data`` and must never be treated as an entity.
|
|
61
|
+
_DISPATCH_SECTION = ("categories", "dispatch")
|
|
62
|
+
_DIVIDER_TAG = "flowgroups_divider"
|
|
63
|
+
_DIVIDER_TEXT = "flowGroups"
|
|
64
|
+
# Sentinel marking the divider's position in the dispatch display sequence.
|
|
65
|
+
_DIVIDER = object()
|
|
66
|
+
|
|
67
|
+
# Swatch geometry (pixels). Entity rows show two boxes (positive | gap |
|
|
68
|
+
# negative); categories / scenarios show one. ``_SWATCH_GAP`` separates the
|
|
69
|
+
# two boxes; ``_SWATCH_PAD_L`` / ``_SWATCH_PAD_R`` inset the boxes from the
|
|
70
|
+
# (indicator-less) cell edge and from the row name.
|
|
71
|
+
_SWATCH_H = 14
|
|
72
|
+
_SWATCH_W = 14 # width of ONE box
|
|
73
|
+
_SWATCH_GAP = 4 # transparent gap between the positive and negative boxes
|
|
74
|
+
_SWATCH_PAD_L = 3 # small left inset
|
|
75
|
+
_SWATCH_PAD_R = 6 # gap after the boxes, before the row name
|
|
76
|
+
_SWATCH_BORDER = (0x80, 0x80, 0x80) # gray 1px frame so swatches read on any bg
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _swatch_width(two_box: bool) -> int:
|
|
80
|
+
"""Total swatch image width for a two-box (entity) or single row."""
|
|
81
|
+
boxes = _SWATCH_W * 2 + _SWATCH_GAP if two_box else _SWATCH_W
|
|
82
|
+
return _SWATCH_PAD_L + boxes + _SWATCH_PAD_R
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _to_rgb255(value) -> tuple[int, int, int]:
|
|
86
|
+
"""Best-effort parse of a YAML color value to an ``(r, g, b)`` 0..255 tuple.
|
|
87
|
+
|
|
88
|
+
Accepts ``#RRGGBB`` hex strings, ``[r, g, b]`` lists (0..1 floats or
|
|
89
|
+
0..255 ints), and matplotlib named colors (e.g. ``crimson``, ``aqua``
|
|
90
|
+
used by the dispatch defaults). Falls back to a neutral mid-gray for
|
|
91
|
+
anything unparseable so a row always shows a swatch.
|
|
92
|
+
"""
|
|
93
|
+
# Hex / list path reuses the canonical plot-side parser (0..1 floats).
|
|
94
|
+
from flextool.plot_outputs.color_template import _parse_color_value
|
|
95
|
+
|
|
96
|
+
rgb = _parse_color_value(value)
|
|
97
|
+
if rgb is None and isinstance(value, str):
|
|
98
|
+
# Named colors (matplotlib) — only the dispatch defaults use these.
|
|
99
|
+
try:
|
|
100
|
+
import matplotlib.colors as mcolors
|
|
101
|
+
|
|
102
|
+
rgb = mcolors.to_rgb(value.strip())
|
|
103
|
+
except (ValueError, ImportError):
|
|
104
|
+
rgb = None
|
|
105
|
+
if rgb is None:
|
|
106
|
+
return (0x99, 0x99, 0x99)
|
|
107
|
+
return (
|
|
108
|
+
max(0, min(255, round(rgb[0] * 255))),
|
|
109
|
+
max(0, min(255, round(rgb[1] * 255))),
|
|
110
|
+
max(0, min(255, round(rgb[2] * 255))),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_pos_neg(value) -> tuple[object, object | None]:
|
|
115
|
+
"""Split an entity entry into ``(pos_value, neg_value_or_None)``.
|
|
116
|
+
|
|
117
|
+
A bare color -> ``(color, None)`` (pos == neg). A mapping
|
|
118
|
+
``{color, neg_color}`` -> ``(color, neg_color)``; a mapping with only
|
|
119
|
+
``color`` -> ``(color, None)``.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(value, dict):
|
|
122
|
+
return value.get("color"), value.get("neg_color")
|
|
123
|
+
return value, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _to_hex(value) -> str:
|
|
127
|
+
"""Normalize any YAML color value to a lowercase ``#RRGGBB`` hex string.
|
|
128
|
+
|
|
129
|
+
Reuses the same best-effort parser as the swatches so a named color
|
|
130
|
+
(e.g. ``lime``) or an ``[r, g, b]`` list becomes a concrete hex the
|
|
131
|
+
color chooser can seed its initial swatch from.
|
|
132
|
+
"""
|
|
133
|
+
return "#%02x%02x%02x" % _to_rgb255(value)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _photo_from_rgb(arr: np.ndarray) -> tk.PhotoImage:
|
|
137
|
+
"""Build a Tk ``PhotoImage`` from an ``(H, W, 3)`` uint8 RGB array.
|
|
138
|
+
|
|
139
|
+
Encodes a minimal truecolor PNG (numpy + stdlib ``zlib``/``struct``,
|
|
140
|
+
no Pillow) and hands the base64 to Tk, which reads PNG natively from
|
|
141
|
+
8.6 on. ~2 ms for a 168x168 square, fast enough for live hue drags.
|
|
142
|
+
"""
|
|
143
|
+
h, w, _ = arr.shape
|
|
144
|
+
rows = np.empty((h, 1 + w * 3), "uint8")
|
|
145
|
+
rows[:, 0] = 0 # PNG filter type 0 (none) per scanline
|
|
146
|
+
rows[:, 1:] = np.ascontiguousarray(arr).reshape(h, w * 3)
|
|
147
|
+
|
|
148
|
+
def _chunk(typ: bytes, data: bytes) -> bytes:
|
|
149
|
+
return (
|
|
150
|
+
struct.pack(">I", len(data)) + typ + data
|
|
151
|
+
+ struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
png = (
|
|
155
|
+
b"\x89PNG\r\n\x1a\n"
|
|
156
|
+
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
157
|
+
+ _chunk(b"IDAT", zlib.compress(rows.tobytes(), 6))
|
|
158
|
+
+ _chunk(b"IEND", b"")
|
|
159
|
+
)
|
|
160
|
+
return tk.PhotoImage(data=base64.b64encode(png))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sv_square_photo(hue: float, size: int) -> tk.PhotoImage:
|
|
164
|
+
"""Saturation (x, 0→1) × Value (y, top 1 → bottom 0) square at *hue*."""
|
|
165
|
+
ss = np.linspace(0.0, 1.0, size)[None, :].repeat(size, 0)
|
|
166
|
+
vv = np.linspace(1.0, 0.0, size)[:, None].repeat(size, 1)
|
|
167
|
+
i = int(hue * 6.0) % 6
|
|
168
|
+
f = hue * 6.0 - int(hue * 6.0)
|
|
169
|
+
p = vv * (1.0 - ss)
|
|
170
|
+
q = vv * (1.0 - f * ss)
|
|
171
|
+
t = vv * (1.0 - (1.0 - f) * ss)
|
|
172
|
+
rgb = [
|
|
173
|
+
(vv, t, p), (q, vv, p), (p, vv, t),
|
|
174
|
+
(p, q, vv), (t, p, vv), (vv, p, q),
|
|
175
|
+
][i]
|
|
176
|
+
arr = (np.stack(rgb, -1) * 255.0 + 0.5).astype("uint8")
|
|
177
|
+
return _photo_from_rgb(arr)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _hue_bar_photo(width: int, height: int) -> tk.PhotoImage:
|
|
181
|
+
"""Vertical full-saturation/value hue ramp (top 0 → bottom 1)."""
|
|
182
|
+
hues = np.linspace(0.0, 1.0, height)
|
|
183
|
+
rgb = np.array([colorsys.hsv_to_rgb(h, 1.0, 1.0) for h in hues])
|
|
184
|
+
arr = (np.repeat(rgb[:, None, :], width, 1) * 255.0 + 0.5).astype("uint8")
|
|
185
|
+
return _photo_from_rgb(arr)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _ColorChooser(ttk.Frame):
|
|
189
|
+
"""An embedded HSV color picker (no OS dialog, no Pillow).
|
|
190
|
+
|
|
191
|
+
A saturation/value square + a vertical hue slider (both drag-pickable)
|
|
192
|
+
with a live "new" preview and editable Hex / R,G,B / H,S,V fields. The
|
|
193
|
+
canonical state is ``(h, s, v)`` floats in ``0..1``; RGB/Hex are derived.
|
|
194
|
+
|
|
195
|
+
Callbacks:
|
|
196
|
+
|
|
197
|
+
* ``on_change(hex)`` — fired on EVERY change (user or programmatic);
|
|
198
|
+
used by the dialog to mirror a linked negative and update previews.
|
|
199
|
+
* ``on_user_edit()`` — fired only on USER-initiated changes (square /
|
|
200
|
+
slider / entry commit); used by the dialog to break the link when the
|
|
201
|
+
user edits the negative.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
SQ = 168 # square edge (px)
|
|
205
|
+
HUE_W = 18 # hue-bar width (px)
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
parent: tk.Misc,
|
|
210
|
+
initial_hex: str,
|
|
211
|
+
on_change: Callable[[str], None] | None = None,
|
|
212
|
+
on_user_edit: Callable[[], None] | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
super().__init__(parent)
|
|
215
|
+
self._on_change = on_change
|
|
216
|
+
self._on_user_edit = on_user_edit
|
|
217
|
+
self._sq_img: tk.PhotoImage | None = None
|
|
218
|
+
self._hue_img: tk.PhotoImage | None = None
|
|
219
|
+
|
|
220
|
+
r, g, b = (c / 255.0 for c in _to_rgb255(initial_hex))
|
|
221
|
+
self._h, self._s, self._v = colorsys.rgb_to_hsv(r, g, b)
|
|
222
|
+
|
|
223
|
+
# ── Square + hue canvases ─────────────────────────────────
|
|
224
|
+
self._sq = tk.Canvas(
|
|
225
|
+
self, width=self.SQ, height=self.SQ, highlightthickness=1,
|
|
226
|
+
highlightbackground="#808080", cursor="crosshair",
|
|
227
|
+
)
|
|
228
|
+
self._sq.grid(row=0, column=0, rowspan=8, sticky="nw")
|
|
229
|
+
self._hue = tk.Canvas(
|
|
230
|
+
self, width=self.HUE_W, height=self.SQ, highlightthickness=1,
|
|
231
|
+
highlightbackground="#808080", cursor="sb_v_double_arrow",
|
|
232
|
+
)
|
|
233
|
+
self._hue.grid(row=0, column=1, rowspan=8, sticky="nw", padx=(6, 10))
|
|
234
|
+
|
|
235
|
+
self._sq.bind("<ButtonPress-1>", self._on_square_drag)
|
|
236
|
+
self._sq.bind("<B1-Motion>", self._on_square_drag)
|
|
237
|
+
self._hue.bind("<ButtonPress-1>", self._on_hue_drag)
|
|
238
|
+
self._hue.bind("<B1-Motion>", self._on_hue_drag)
|
|
239
|
+
|
|
240
|
+
# ── Fields column ─────────────────────────────────────────
|
|
241
|
+
fields = ttk.Frame(self)
|
|
242
|
+
fields.grid(row=0, column=2, sticky="nw")
|
|
243
|
+
|
|
244
|
+
self._preview = tk.Label(
|
|
245
|
+
fields, width=10, height=2, relief="solid", borderwidth=1,
|
|
246
|
+
)
|
|
247
|
+
self._preview.grid(row=0, column=0, columnspan=2, sticky="w",
|
|
248
|
+
pady=(0, 8))
|
|
249
|
+
|
|
250
|
+
self._entries: dict[str, ttk.Entry] = {}
|
|
251
|
+
|
|
252
|
+
def _row(rownum: int, label: str, key: str, commit) -> None:
|
|
253
|
+
ttk.Label(fields, text=label).grid(
|
|
254
|
+
row=rownum, column=0, sticky="e", padx=(0, 4), pady=1,
|
|
255
|
+
)
|
|
256
|
+
ent = ttk.Entry(fields, width=10)
|
|
257
|
+
ent.grid(row=rownum, column=1, sticky="w", pady=1)
|
|
258
|
+
ent.bind("<Return>", commit)
|
|
259
|
+
ent.bind("<FocusOut>", commit)
|
|
260
|
+
self._entries[key] = ent
|
|
261
|
+
|
|
262
|
+
_row(1, "Hex", "hex", self._commit_hex)
|
|
263
|
+
_row(2, "R", "r", self._commit_rgb)
|
|
264
|
+
_row(3, "G", "g", self._commit_rgb)
|
|
265
|
+
_row(4, "B", "b", self._commit_rgb)
|
|
266
|
+
_row(5, "H°", "h", self._commit_hsv)
|
|
267
|
+
_row(6, "S%", "s", self._commit_hsv)
|
|
268
|
+
_row(7, "V%", "v", self._commit_hsv)
|
|
269
|
+
|
|
270
|
+
self._render_hue_bar()
|
|
271
|
+
self._render_square()
|
|
272
|
+
self._sync_all()
|
|
273
|
+
|
|
274
|
+
# ── Public API ────────────────────────────────────────────────
|
|
275
|
+
def get_hex(self) -> str:
|
|
276
|
+
r, g, b = colorsys.hsv_to_rgb(self._h, self._s, self._v)
|
|
277
|
+
return "#%02x%02x%02x" % (
|
|
278
|
+
round(r * 255), round(g * 255), round(b * 255),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def set_hex(self, hex_str: str, *, user: bool = False) -> None:
|
|
282
|
+
"""Set the color from a hex string (programmatic mirror by default)."""
|
|
283
|
+
r, g, b = (c / 255.0 for c in _to_rgb255(hex_str))
|
|
284
|
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
285
|
+
self._apply_hsv(h, s, v, user=user, redraw_square=True)
|
|
286
|
+
|
|
287
|
+
# ── Rendering ─────────────────────────────────────────────────
|
|
288
|
+
def _render_square(self) -> None:
|
|
289
|
+
self._sq_img = _sv_square_photo(self._h, self.SQ)
|
|
290
|
+
self._sq.delete("img")
|
|
291
|
+
self._sq.create_image(0, 0, anchor="nw", image=self._sq_img, tags="img")
|
|
292
|
+
self._sq.tag_lower("img")
|
|
293
|
+
self._draw_square_marker()
|
|
294
|
+
|
|
295
|
+
def _render_hue_bar(self) -> None:
|
|
296
|
+
self._hue_img = _hue_bar_photo(self.HUE_W, self.SQ)
|
|
297
|
+
self._hue.delete("img")
|
|
298
|
+
self._hue.create_image(0, 0, anchor="nw", image=self._hue_img, tags="img")
|
|
299
|
+
self._hue.tag_lower("img")
|
|
300
|
+
self._draw_hue_marker()
|
|
301
|
+
|
|
302
|
+
def _draw_square_marker(self) -> None:
|
|
303
|
+
x = self._s * (self.SQ - 1)
|
|
304
|
+
y = (1.0 - self._v) * (self.SQ - 1)
|
|
305
|
+
self._sq.delete("marker")
|
|
306
|
+
for col, rad in (("#000000", 6), ("#ffffff", 5)):
|
|
307
|
+
self._sq.create_oval(
|
|
308
|
+
x - rad, y - rad, x + rad, y + rad,
|
|
309
|
+
outline=col, width=1, tags="marker",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _draw_hue_marker(self) -> None:
|
|
313
|
+
y = self._h * (self.SQ - 1)
|
|
314
|
+
self._hue.delete("marker")
|
|
315
|
+
self._hue.create_rectangle(
|
|
316
|
+
0, y - 2, self.HUE_W, y + 2,
|
|
317
|
+
outline="#000000", width=1, tags="marker",
|
|
318
|
+
)
|
|
319
|
+
self._hue.create_rectangle(
|
|
320
|
+
1, y - 1, self.HUE_W - 1, y + 1,
|
|
321
|
+
outline="#ffffff", width=1, tags="marker",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _update_preview(self) -> None:
|
|
325
|
+
self._preview.configure(background=self.get_hex())
|
|
326
|
+
|
|
327
|
+
# ── Entry sync (no recursion: set via delete/insert) ──────────
|
|
328
|
+
def _set_entry(self, key: str, text: str) -> None:
|
|
329
|
+
ent = self._entries[key]
|
|
330
|
+
had = str(ent.cget("state"))
|
|
331
|
+
ent.configure(state="normal")
|
|
332
|
+
ent.delete(0, "end")
|
|
333
|
+
ent.insert(0, text)
|
|
334
|
+
ent.configure(state=had)
|
|
335
|
+
|
|
336
|
+
def _sync_entries(self) -> None:
|
|
337
|
+
r, g, b = colorsys.hsv_to_rgb(self._h, self._s, self._v)
|
|
338
|
+
self._set_entry("hex", self.get_hex())
|
|
339
|
+
self._set_entry("r", str(round(r * 255)))
|
|
340
|
+
self._set_entry("g", str(round(g * 255)))
|
|
341
|
+
self._set_entry("b", str(round(b * 255)))
|
|
342
|
+
self._set_entry("h", str(round(self._h * 360)))
|
|
343
|
+
self._set_entry("s", str(round(self._s * 100)))
|
|
344
|
+
self._set_entry("v", str(round(self._v * 100)))
|
|
345
|
+
|
|
346
|
+
def _sync_all(self) -> None:
|
|
347
|
+
self._draw_square_marker()
|
|
348
|
+
self._draw_hue_marker()
|
|
349
|
+
self._update_preview()
|
|
350
|
+
self._sync_entries()
|
|
351
|
+
|
|
352
|
+
# ── Central update ────────────────────────────────────────────
|
|
353
|
+
def _apply_hsv(
|
|
354
|
+
self, h: float, s: float, v: float, *,
|
|
355
|
+
user: bool, redraw_square: bool,
|
|
356
|
+
) -> None:
|
|
357
|
+
self._h = min(1.0, max(0.0, h))
|
|
358
|
+
self._s = min(1.0, max(0.0, s))
|
|
359
|
+
self._v = min(1.0, max(0.0, v))
|
|
360
|
+
if redraw_square:
|
|
361
|
+
self._render_square()
|
|
362
|
+
self._sync_all()
|
|
363
|
+
if self._on_change is not None:
|
|
364
|
+
self._on_change(self.get_hex())
|
|
365
|
+
if user and self._on_user_edit is not None:
|
|
366
|
+
self._on_user_edit()
|
|
367
|
+
|
|
368
|
+
# ── Canvas interaction ────────────────────────────────────────
|
|
369
|
+
def _on_square_drag(self, event: tk.Event) -> None:
|
|
370
|
+
s = min(1.0, max(0.0, event.x / (self.SQ - 1)))
|
|
371
|
+
v = 1.0 - min(1.0, max(0.0, event.y / (self.SQ - 1)))
|
|
372
|
+
self._apply_hsv(self._h, s, v, user=True, redraw_square=False)
|
|
373
|
+
|
|
374
|
+
def _on_hue_drag(self, event: tk.Event) -> None:
|
|
375
|
+
h = min(1.0, max(0.0, event.y / (self.SQ - 1)))
|
|
376
|
+
self._apply_hsv(h, self._s, self._v, user=True, redraw_square=True)
|
|
377
|
+
|
|
378
|
+
# ── Entry commits ─────────────────────────────────────────────
|
|
379
|
+
def _commit_hex(self, _event=None) -> None:
|
|
380
|
+
raw = self._entries["hex"].get().strip()
|
|
381
|
+
rgb = _parse_hex_or_none(raw)
|
|
382
|
+
if rgb is None:
|
|
383
|
+
self._sync_entries() # revert invalid text
|
|
384
|
+
return
|
|
385
|
+
h, s, v = colorsys.rgb_to_hsv(*(c / 255.0 for c in rgb))
|
|
386
|
+
self._apply_hsv(h, s, v, user=True, redraw_square=True)
|
|
387
|
+
|
|
388
|
+
def _commit_rgb(self, _event=None) -> None:
|
|
389
|
+
try:
|
|
390
|
+
r = _clamp_int(self._entries["r"].get(), 0, 255)
|
|
391
|
+
g = _clamp_int(self._entries["g"].get(), 0, 255)
|
|
392
|
+
b = _clamp_int(self._entries["b"].get(), 0, 255)
|
|
393
|
+
except ValueError:
|
|
394
|
+
self._sync_entries()
|
|
395
|
+
return
|
|
396
|
+
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
|
397
|
+
self._apply_hsv(h, s, v, user=True, redraw_square=True)
|
|
398
|
+
|
|
399
|
+
def _commit_hsv(self, _event=None) -> None:
|
|
400
|
+
try:
|
|
401
|
+
h = _clamp_int(self._entries["h"].get(), 0, 360) / 360.0
|
|
402
|
+
s = _clamp_int(self._entries["s"].get(), 0, 100) / 100.0
|
|
403
|
+
v = _clamp_int(self._entries["v"].get(), 0, 100) / 100.0
|
|
404
|
+
except ValueError:
|
|
405
|
+
self._sync_entries()
|
|
406
|
+
return
|
|
407
|
+
self._apply_hsv(h, s, v, user=True, redraw_square=True)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _parse_hex_or_none(text: str) -> tuple[int, int, int] | None:
|
|
411
|
+
"""Parse ``#rrggbb`` / ``rrggbb`` to an ``(r, g, b)`` tuple, else None."""
|
|
412
|
+
s = text.strip().lstrip("#")
|
|
413
|
+
if len(s) != 6:
|
|
414
|
+
return None
|
|
415
|
+
try:
|
|
416
|
+
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
|
417
|
+
except ValueError:
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _clamp_int(text: str, lo: int, hi: int) -> int:
|
|
422
|
+
"""Parse *text* to an int clamped to ``[lo, hi]`` (raises on non-int)."""
|
|
423
|
+
return min(hi, max(lo, int(float(text.strip()))))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class ColorPickerDialog(tk.Toplevel):
|
|
427
|
+
"""Modal embedded color editor for one row.
|
|
428
|
+
|
|
429
|
+
Hosts an embedded :class:`_ColorChooser` (saturation/value square + hue
|
|
430
|
+
slider + numeric fields). With ``single=False`` (the default, for entity
|
|
431
|
+
rows) it shows TWO choosers side by side — left **positive**, right
|
|
432
|
+
**negative** — under a **"Link negative to positive"** checkbox:
|
|
433
|
+
|
|
434
|
+
* **linked** (checked): the negative mirrors the positive (and its value
|
|
435
|
+
is ignored on OK). Default for a bare ``"#color"`` entry. The panel
|
|
436
|
+
stays interactive — see below.
|
|
437
|
+
* **unlinked** (unchecked): positive and negative are independent.
|
|
438
|
+
Default for a ``{color, neg_color}`` entry.
|
|
439
|
+
|
|
440
|
+
The negative panel is never greyed out: while linked its value has no
|
|
441
|
+
meaning, but the moment the user edits it that is taken as intent to
|
|
442
|
+
separate, so it **auto-breaks the link** (unchecks the box). Re-checking
|
|
443
|
+
RE-LINKS (``neg := pos``).
|
|
444
|
+
|
|
445
|
+
With ``single=True`` (categories / scenarios — one color, no negative)
|
|
446
|
+
only the positive chooser is shown; the negative panel and the link
|
|
447
|
+
checkbox are hidden.
|
|
448
|
+
|
|
449
|
+
The result is read from :pyattr:`result` after ``wait_window``:
|
|
450
|
+
``(pos_hex, neg_hex_or_None)`` on OK (``neg_hex`` is ``None`` when single
|
|
451
|
+
or linked, else the explicit negative), or ``None`` on Cancel.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def __init__(
|
|
455
|
+
self,
|
|
456
|
+
parent: tk.Misc,
|
|
457
|
+
name: str,
|
|
458
|
+
pos_hex: str,
|
|
459
|
+
neg_hex: str,
|
|
460
|
+
linked: bool,
|
|
461
|
+
*,
|
|
462
|
+
single: bool = False,
|
|
463
|
+
) -> None:
|
|
464
|
+
super().__init__(parent)
|
|
465
|
+
self.title(f"Color — {name}")
|
|
466
|
+
self.transient(parent)
|
|
467
|
+
self.resizable(False, False)
|
|
468
|
+
|
|
469
|
+
self._single = single
|
|
470
|
+
self._linked = tk.BooleanVar(value=True if single else linked)
|
|
471
|
+
self.result: tuple[str, str | None] | None = None
|
|
472
|
+
|
|
473
|
+
body = ttk.Frame(self, padding=12)
|
|
474
|
+
body.grid(row=0, column=0, sticky="nsew")
|
|
475
|
+
|
|
476
|
+
if not single:
|
|
477
|
+
ttk.Checkbutton(
|
|
478
|
+
body,
|
|
479
|
+
text="Link negative to positive",
|
|
480
|
+
variable=self._linked,
|
|
481
|
+
command=self._on_link_toggle,
|
|
482
|
+
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
|
|
483
|
+
|
|
484
|
+
panels = ttk.Frame(body)
|
|
485
|
+
panels.grid(row=1, column=0, columnspan=2, sticky="nw")
|
|
486
|
+
|
|
487
|
+
pos_panel = ttk.Frame(panels)
|
|
488
|
+
pos_panel.grid(row=0, column=0, sticky="nw")
|
|
489
|
+
if not single:
|
|
490
|
+
ttk.Label(pos_panel, text="Positive").grid(
|
|
491
|
+
row=0, column=0, sticky="w", pady=(0, 2),
|
|
492
|
+
)
|
|
493
|
+
self._pos_chooser = _ColorChooser(
|
|
494
|
+
pos_panel, _to_hex(pos_hex), on_change=self._on_pos_change,
|
|
495
|
+
)
|
|
496
|
+
self._pos_chooser.grid(row=1, column=0, sticky="nw")
|
|
497
|
+
|
|
498
|
+
self._neg_chooser: _ColorChooser | None = None
|
|
499
|
+
if not single:
|
|
500
|
+
neg_panel = ttk.Frame(panels)
|
|
501
|
+
neg_panel.grid(row=0, column=1, sticky="nw", padx=(16, 0))
|
|
502
|
+
ttk.Label(neg_panel, text="Negative").grid(
|
|
503
|
+
row=0, column=0, sticky="w", pady=(0, 2),
|
|
504
|
+
)
|
|
505
|
+
self._neg_chooser = _ColorChooser(
|
|
506
|
+
neg_panel, _to_hex(neg_hex),
|
|
507
|
+
on_user_edit=self._on_neg_user_edit,
|
|
508
|
+
)
|
|
509
|
+
self._neg_chooser.grid(row=1, column=0, sticky="nw")
|
|
510
|
+
|
|
511
|
+
# OK / Cancel.
|
|
512
|
+
btns = ttk.Frame(self, padding=(12, 0, 12, 12))
|
|
513
|
+
btns.grid(row=1, column=0, sticky="ew")
|
|
514
|
+
ttk.Button(btns, text="Cancel", command=self._on_cancel).pack(
|
|
515
|
+
side="right", padx=(5, 0),
|
|
516
|
+
)
|
|
517
|
+
self._ok_button = ttk.Button(
|
|
518
|
+
btns, text="OK", command=self._on_ok, default="active",
|
|
519
|
+
)
|
|
520
|
+
self._ok_button.pack(side="right")
|
|
521
|
+
|
|
522
|
+
if not single:
|
|
523
|
+
self._apply_link_state()
|
|
524
|
+
|
|
525
|
+
self.bind("<Return>", lambda _e: self._on_ok())
|
|
526
|
+
self.bind("<Escape>", lambda _e: self._on_cancel())
|
|
527
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
528
|
+
self._grab_when_viewable()
|
|
529
|
+
|
|
530
|
+
def _grab_when_viewable(self) -> None:
|
|
531
|
+
"""Acquire the modal grab as soon as the window is mapped.
|
|
532
|
+
|
|
533
|
+
``grab_set`` on a not-yet-mapped Toplevel raises ``"grab failed:
|
|
534
|
+
window not viewable"`` under some window managers (the Toplevel is
|
|
535
|
+
created but the WM hasn't mapped it yet). Retry on the Tk event
|
|
536
|
+
loop until it maps, then take keyboard focus on OK (the Enter
|
|
537
|
+
target). Guarded against a window destroyed before it ever mapped.
|
|
538
|
+
"""
|
|
539
|
+
if not self.winfo_exists():
|
|
540
|
+
return
|
|
541
|
+
try:
|
|
542
|
+
self.grab_set()
|
|
543
|
+
except tk.TclError:
|
|
544
|
+
self.after(20, self._grab_when_viewable)
|
|
545
|
+
return
|
|
546
|
+
try:
|
|
547
|
+
self._ok_button.focus_set()
|
|
548
|
+
except tk.TclError:
|
|
549
|
+
pass
|
|
550
|
+
|
|
551
|
+
# ── Link logic ────────────────────────────────────────────────
|
|
552
|
+
def _on_pos_change(self, hex_str: str) -> None:
|
|
553
|
+
"""Mirror the positive onto a linked negative (programmatic)."""
|
|
554
|
+
if (
|
|
555
|
+
not self._single
|
|
556
|
+
and self._linked.get()
|
|
557
|
+
and self._neg_chooser is not None
|
|
558
|
+
):
|
|
559
|
+
self._neg_chooser.set_hex(hex_str, user=False)
|
|
560
|
+
|
|
561
|
+
def _on_neg_user_edit(self) -> None:
|
|
562
|
+
"""A user edit to the negative separates the two colors → unlink."""
|
|
563
|
+
if self._linked.get():
|
|
564
|
+
self._linked.set(False)
|
|
565
|
+
self._apply_link_state()
|
|
566
|
+
|
|
567
|
+
def _on_link_toggle(self) -> None:
|
|
568
|
+
"""Checkbox toggled: re-check mirrors neg:=pos; uncheck frees neg."""
|
|
569
|
+
self._apply_link_state()
|
|
570
|
+
|
|
571
|
+
def _apply_link_state(self) -> None:
|
|
572
|
+
"""When linked, mirror the negative onto the positive.
|
|
573
|
+
|
|
574
|
+
The negative panel stays interactive even while linked: its value
|
|
575
|
+
is simply ignored on OK (linked → neg is None), and the moment the
|
|
576
|
+
user touches it the link auto-breaks (see ``_on_neg_user_edit``).
|
|
577
|
+
"""
|
|
578
|
+
if self._neg_chooser is None:
|
|
579
|
+
return
|
|
580
|
+
if self._linked.get():
|
|
581
|
+
self._neg_chooser.set_hex(
|
|
582
|
+
self._pos_chooser.get_hex(), user=False,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# ── Result ────────────────────────────────────────────────────
|
|
586
|
+
def _on_ok(self) -> None:
|
|
587
|
+
pos = self._pos_chooser.get_hex()
|
|
588
|
+
if self._single or self._linked.get() or self._neg_chooser is None:
|
|
589
|
+
self.result = (pos, None)
|
|
590
|
+
else:
|
|
591
|
+
self.result = (pos, self._neg_chooser.get_hex())
|
|
592
|
+
self.destroy()
|
|
593
|
+
|
|
594
|
+
def _on_cancel(self) -> None:
|
|
595
|
+
self.result = None
|
|
596
|
+
self.destroy()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class PlotSettingsPicker(tk.Toplevel):
|
|
600
|
+
"""Non-modal color/order picker for a project ``plot_settings.yaml``.
|
|
601
|
+
|
|
602
|
+
Parameters
|
|
603
|
+
----------
|
|
604
|
+
parent:
|
|
605
|
+
Owning widget; the window is ``transient`` to it but NOT modal
|
|
606
|
+
(no ``grab_set``), so the result viewer stays usable.
|
|
607
|
+
settings_path:
|
|
608
|
+
Path to the project ``plot_settings.yaml`` (already seeded by the
|
|
609
|
+
caller via ``seed_plot_settings``).
|
|
610
|
+
on_apply:
|
|
611
|
+
Optional zero-arg callback invoked after every live write
|
|
612
|
+
(debounced, on each user edit), on Close, and after a Cancel
|
|
613
|
+
restore, so an opener with a live preview (the result viewer)
|
|
614
|
+
re-renders with the current on-disk colors. Pass ``None`` for no
|
|
615
|
+
live preview (the PNG batch dialog).
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
# Debounce for the live write + re-render: a burst of edits (e.g. a
|
|
619
|
+
# hue drag committed through several handler calls) collapses into one
|
|
620
|
+
# write after the last change settles.
|
|
621
|
+
_LIVE_UPDATE_MS = 250
|
|
622
|
+
|
|
623
|
+
def __init__(
|
|
624
|
+
self,
|
|
625
|
+
parent: tk.Misc,
|
|
626
|
+
settings_path: Path,
|
|
627
|
+
on_apply: Callable[[], None] | None = None,
|
|
628
|
+
) -> None:
|
|
629
|
+
super().__init__(parent)
|
|
630
|
+
self.title("Colors and order")
|
|
631
|
+
self._settings_path = Path(settings_path)
|
|
632
|
+
self._on_apply = on_apply
|
|
633
|
+
# Pending debounced live-update ``after`` id (None = none pending).
|
|
634
|
+
self._live_after_id: str | None = None
|
|
635
|
+
|
|
636
|
+
# Non-modal: transient (stacks with the parent, no taskbar entry on
|
|
637
|
+
# some WMs) but NO grab_set / wait_window — the viewer stays live.
|
|
638
|
+
self.transient(parent)
|
|
639
|
+
|
|
640
|
+
# Strong references to every PhotoImage so Tk does not GC them
|
|
641
|
+
# (a GC'd image renders blank in the Treeview cell).
|
|
642
|
+
self._swatches: list[tk.PhotoImage] = []
|
|
643
|
+
|
|
644
|
+
# Per-tree reorder bookkeeping: maps a Treeview to the
|
|
645
|
+
# ``(*keys,)`` path of its section in the working dict (e.g.
|
|
646
|
+
# ``("entities", "unit")`` or ``("scenarios",)``) so a reorder can
|
|
647
|
+
# rewrite exactly that section. Drag state distinguishes a MOVE
|
|
648
|
+
# drag (press began on an already-selected row → move the whole
|
|
649
|
+
# selection) from a native draw-select drag (press on an unselected
|
|
650
|
+
# row → let ttk extend the selection). ``_drag_anchor`` is the
|
|
651
|
+
# pressed row; ``_drag_moved`` records whether the drag actually
|
|
652
|
+
# reordered (so a click-without-move can collapse the selection).
|
|
653
|
+
self._tree_section: dict[ttk.Treeview, tuple[str, ...]] = {}
|
|
654
|
+
self._drag_move: dict[ttk.Treeview, bool] = {}
|
|
655
|
+
self._drag_anchor: dict[ttk.Treeview, str | None] = {}
|
|
656
|
+
self._drag_moved: dict[ttk.Treeview, bool] = {}
|
|
657
|
+
# Whether a tree's rows carry a positive AND negative color
|
|
658
|
+
# (entities) or a single color (categories / scenarios). Drives
|
|
659
|
+
# the double-click edit dialog and the swatch rebuild.
|
|
660
|
+
self._tree_composite: dict[ttk.Treeview, bool] = {}
|
|
661
|
+
# Per-row PhotoImage references after an in-place swatch rebuild,
|
|
662
|
+
# keyed by ``(tree, item_id)`` so the replacement survives GC
|
|
663
|
+
# (the originals in ``self._swatches`` stay too).
|
|
664
|
+
self._row_swatches: dict[tuple[ttk.Treeview, str], tk.PhotoImage] = {}
|
|
665
|
+
# Trees rendered in a FIXED (engine-pinned) order — currently only the
|
|
666
|
+
# dispatch category. Their rows are never reorderable (drag / Alt-move
|
|
667
|
+
# are no-ops) and never synced back to ``self._data``; the specials'
|
|
668
|
+
# colors stay editable. ``_divider_items`` holds every synthetic
|
|
669
|
+
# divider row as ``(tree, item_id)`` so it can be recognised (and
|
|
670
|
+
# skipped) everywhere a tree row maps back to an entity.
|
|
671
|
+
self._fixed_order_trees: set[ttk.Treeview] = set()
|
|
672
|
+
self._divider_items: set[tuple[ttk.Treeview, str]] = set()
|
|
673
|
+
# Lazily-built italic font for the divider row (None until first use);
|
|
674
|
+
# a copy of TkDefaultFont so it tracks the theme's default face/size.
|
|
675
|
+
self._divider_font: object | None = None
|
|
676
|
+
|
|
677
|
+
# Working state = parsed dict; snapshot the original TEXT for Cancel.
|
|
678
|
+
self._original_text = self._read_text()
|
|
679
|
+
self._data = self._parse(self._original_text)
|
|
680
|
+
# Populate the scenarios section from the project's output folders so
|
|
681
|
+
# the always-shown scenarios tab is useful before any comparison run.
|
|
682
|
+
# Add-only; Cancel still restores the on-disk text untouched.
|
|
683
|
+
self._merge_scenarios_from_folders()
|
|
684
|
+
# Populate the entity sections from the input DB(s) ∪ the dispatch
|
|
685
|
+
# output aggregates so every drawn entity — including processGroup
|
|
686
|
+
# aggregates that live only in solved output (e.g. ``Fossil``) — is
|
|
687
|
+
# listable/colorable on open, without a manual "Refresh from DB".
|
|
688
|
+
# Add-only; Cancel still restores the on-disk text untouched.
|
|
689
|
+
self._merge_entities_from_sources()
|
|
690
|
+
|
|
691
|
+
# Dispatch-aggregator flowGroup set (display filter only — never
|
|
692
|
+
# touches ``self._data``): parquet ``group_aggregate`` names ∪ input-DB
|
|
693
|
+
# ``flow_aggregator`` ∈ {dispatch_plots_only, both}. Computed ONCE here
|
|
694
|
+
# and reused by every (re)build (including undo/redo) so the flowGroup
|
|
695
|
+
# tab lists only dispatch aggregators; a non-aggregator flowGroup stays
|
|
696
|
+
# in the file but is not drawn. Guarded like the merge above so a
|
|
697
|
+
# discovery failure can never break the window opening — an empty set
|
|
698
|
+
# then disables the filter (all flowGroups show).
|
|
699
|
+
try:
|
|
700
|
+
self._dispatch_flowgroup_aggregators = (
|
|
701
|
+
self._compute_dispatch_flowgroup_aggregators()
|
|
702
|
+
)
|
|
703
|
+
except Exception as exc: # never let discovery break the window open
|
|
704
|
+
logger.warning("flowGroup aggregator discovery on open failed: %s", exc)
|
|
705
|
+
self._dispatch_flowgroup_aggregators = set()
|
|
706
|
+
|
|
707
|
+
# In-memory edit history (deep copies of ``self._data``). Every
|
|
708
|
+
# mutator calls ``_snapshot()`` (push pre-edit state, clear redo)
|
|
709
|
+
# before it changes ``self._data``; undo/redo move states between
|
|
710
|
+
# the two stacks and rebuild every tab from ``self._data``. This
|
|
711
|
+
# history is independent of the on-disk Cancel snapshot above.
|
|
712
|
+
self._undo_stack: list[dict] = []
|
|
713
|
+
self._redo_stack: list[dict] = []
|
|
714
|
+
# Set by undo/redo button construction; refreshed by
|
|
715
|
+
# ``_update_history_buttons`` to reflect stack emptiness.
|
|
716
|
+
self._undo_button: ttk.Button | None = None
|
|
717
|
+
self._redo_button: ttk.Button | None = None
|
|
718
|
+
|
|
719
|
+
# ── Sizing ────────────────────────────────────────────────
|
|
720
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
721
|
+
|
|
722
|
+
_metrics = get_metrics(self)
|
|
723
|
+
cw = _metrics.cw
|
|
724
|
+
lh = _metrics.lh
|
|
725
|
+
# The two-pane layout (a narrow left category selector + one entity
|
|
726
|
+
# list) needs only ~half the old notebook width, so the default is
|
|
727
|
+
# much narrower. Width clamps to the screen (40px margin); height is
|
|
728
|
+
# a fixed 80% of the screen, positioned with a 10% top/bottom margin
|
|
729
|
+
# and centred horizontally.
|
|
730
|
+
_margin = 40
|
|
731
|
+
_w = min(cw * 59, self.winfo_screenwidth() - _margin)
|
|
732
|
+
_h = int(self.winfo_screenheight() * 0.8)
|
|
733
|
+
_y = int(self.winfo_screenheight() * 0.1)
|
|
734
|
+
_x = max(0, (self.winfo_screenwidth() - _w) // 2)
|
|
735
|
+
self.geometry(f"{_w}x{_h}+{_x}+{_y}")
|
|
736
|
+
self.resizable(True, True)
|
|
737
|
+
self.minsize(cw * 22, lh * 16)
|
|
738
|
+
|
|
739
|
+
self.columnconfigure(0, weight=1)
|
|
740
|
+
self.rowconfigure(0, weight=1)
|
|
741
|
+
|
|
742
|
+
self._tree_style = self._make_tree_style()
|
|
743
|
+
|
|
744
|
+
# ── Two-pane category-list + entity-list layout ───────────
|
|
745
|
+
# A horizontal ``PanedWindow`` (user-resizable sash): the LEFT pane is
|
|
746
|
+
# a narrow, single-select category selector (fixed order — a selector
|
|
747
|
+
# only, never reorderable); the RIGHT pane holds one entity Treeview
|
|
748
|
+
# per category, of which only the selected category's tree is shown.
|
|
749
|
+
self._paned = ttk.PanedWindow(self, orient="horizontal")
|
|
750
|
+
self._paned.grid(row=0, column=0, sticky="nsew", padx=10, pady=(10, 4))
|
|
751
|
+
|
|
752
|
+
left = ttk.Frame(self._paned)
|
|
753
|
+
left.columnconfigure(0, weight=1)
|
|
754
|
+
left.rowconfigure(0, weight=1)
|
|
755
|
+
self._category_list = ttk.Treeview(
|
|
756
|
+
left, show="tree", selectmode="browse",
|
|
757
|
+
)
|
|
758
|
+
self._category_list.grid(row=0, column=0, sticky="nsew")
|
|
759
|
+
_cat_scroll = ttk.Scrollbar(
|
|
760
|
+
left, orient="vertical", command=self._category_list.yview,
|
|
761
|
+
)
|
|
762
|
+
_cat_scroll.grid(row=0, column=1, sticky="ns")
|
|
763
|
+
self._category_list.configure(yscrollcommand=_cat_scroll.set)
|
|
764
|
+
# Pure selector: selection swaps the visible right-pane tree. NO
|
|
765
|
+
# drag / Alt-arrow / move bindings are attached here.
|
|
766
|
+
self._category_list.bind(
|
|
767
|
+
"<<TreeviewSelect>>", self._on_category_select,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Right pane: a container holding every category's entity tree; only
|
|
771
|
+
# the selected one is shown (grid / grid_remove) so per-tree state
|
|
772
|
+
# persists across selections.
|
|
773
|
+
self._entity_container = ttk.Frame(self._paned)
|
|
774
|
+
self._entity_container.columnconfigure(0, weight=1)
|
|
775
|
+
self._entity_container.rowconfigure(0, weight=1)
|
|
776
|
+
|
|
777
|
+
self._paned.add(left, weight=0)
|
|
778
|
+
self._paned.add(self._entity_container, weight=1)
|
|
779
|
+
|
|
780
|
+
# Category-selector state (exposed for tests): display order + the
|
|
781
|
+
# per-category entity tree / frame / left-list item, and the title of
|
|
782
|
+
# the currently-shown category.
|
|
783
|
+
self._category_order: list[str] = []
|
|
784
|
+
self._category_trees: dict[str, ttk.Treeview] = {}
|
|
785
|
+
self._category_frames: dict[str, ttk.Frame] = {}
|
|
786
|
+
self._category_items: dict[str, str] = {}
|
|
787
|
+
self._current_category: str | None = None
|
|
788
|
+
|
|
789
|
+
self._build_panes()
|
|
790
|
+
|
|
791
|
+
# ── Buttons ───────────────────────────────────────────────
|
|
792
|
+
# Buttons are CREATED in tab-traversal order (Undo → Redo → Cancel →
|
|
793
|
+
# Close) — Tk's focus traversal follows the parent's child order.
|
|
794
|
+
# ``grid`` then places them visually (left cluster / flexible spacer /
|
|
795
|
+
# right cluster), decoupling the on-screen layout from the traversal
|
|
796
|
+
# order. Edits now write the file LIVE (debounced), so there is no
|
|
797
|
+
# Apply button; Refresh is gone too — the on-open discovery already
|
|
798
|
+
# seeds every listable entity add-only (stale entries are harmless).
|
|
799
|
+
btn_frame = ttk.Frame(self)
|
|
800
|
+
btn_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=(4, 10))
|
|
801
|
+
btn_frame.columnconfigure(2, weight=1) # spacer between clusters
|
|
802
|
+
|
|
803
|
+
self._undo_button = ttk.Button(
|
|
804
|
+
btn_frame, text="Undo", command=self._on_undo,
|
|
805
|
+
)
|
|
806
|
+
self._undo_button.grid(row=0, column=0)
|
|
807
|
+
self._redo_button = ttk.Button(
|
|
808
|
+
btn_frame, text="Redo", command=self._on_redo,
|
|
809
|
+
)
|
|
810
|
+
self._redo_button.grid(row=0, column=1, padx=(5, 0))
|
|
811
|
+
ttk.Button(
|
|
812
|
+
btn_frame, text="Cancel", command=self._on_cancel,
|
|
813
|
+
).grid(row=0, column=3, padx=(5, 0))
|
|
814
|
+
ttk.Button(
|
|
815
|
+
btn_frame, text="Close", command=self._on_close,
|
|
816
|
+
).grid(row=0, column=4, padx=(5, 0))
|
|
817
|
+
self._update_history_buttons()
|
|
818
|
+
|
|
819
|
+
# ── Keyboard-shortcut hint strip (two rows) ───────────────
|
|
820
|
+
ttk.Label(
|
|
821
|
+
self,
|
|
822
|
+
text=(
|
|
823
|
+
"Enter: edit selected row "
|
|
824
|
+
"Alt+↑ / Alt+↓: move row drag: reorder\n"
|
|
825
|
+
"Changes apply live "
|
|
826
|
+
"Ctrl+Z: undo Ctrl+Y: redo Esc: close (keeps changes)"
|
|
827
|
+
),
|
|
828
|
+
foreground="gray",
|
|
829
|
+
anchor="w",
|
|
830
|
+
justify="left",
|
|
831
|
+
).grid(row=2, column=0, sticky="ew", padx=12, pady=(0, 8))
|
|
832
|
+
|
|
833
|
+
# Edits are written live, so Escape and the window ``X`` KEEP the
|
|
834
|
+
# user's changes (flush the pending write + close) — only the
|
|
835
|
+
# explicit Cancel button reverts to the on-open text.
|
|
836
|
+
self.bind("<Escape>", lambda _e: self._on_close())
|
|
837
|
+
self.bind("<Control-z>", lambda _e: self._on_undo())
|
|
838
|
+
self.bind("<Control-y>", lambda _e: self._on_redo())
|
|
839
|
+
self.bind("<Control-Shift-Z>", lambda _e: self._on_redo())
|
|
840
|
+
self.protocol("WM_DELETE_WINDOW", lambda: self._on_close())
|
|
841
|
+
|
|
842
|
+
# ── File I/O ──────────────────────────────────────────────────
|
|
843
|
+
def _read_text(self) -> str:
|
|
844
|
+
try:
|
|
845
|
+
return self._settings_path.read_text(encoding="utf-8")
|
|
846
|
+
except OSError as exc:
|
|
847
|
+
logger.warning("Cannot read %s: %s", self._settings_path, exc)
|
|
848
|
+
return ""
|
|
849
|
+
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _parse(text: str) -> dict:
|
|
852
|
+
try:
|
|
853
|
+
parsed = yaml.safe_load(text)
|
|
854
|
+
except yaml.YAMLError as exc:
|
|
855
|
+
logger.warning("Cannot parse plot_settings.yaml: %s", exc)
|
|
856
|
+
return {}
|
|
857
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
858
|
+
|
|
859
|
+
def _write(self) -> None:
|
|
860
|
+
"""Dump the working dict back to the project file."""
|
|
861
|
+
self._settings_path.write_text(
|
|
862
|
+
dump_plot_settings(self._data), encoding="utf-8",
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# ── Swatches ──────────────────────────────────────────────────
|
|
866
|
+
def _make_swatch(self, pos, neg=None, reserve_neg: bool = False) -> tk.PhotoImage:
|
|
867
|
+
"""Build a swatch ``PhotoImage`` for one row.
|
|
868
|
+
|
|
869
|
+
Two fixed columns of boxes: the LEFT (positive) box is always drawn;
|
|
870
|
+
the RIGHT (negative) box is drawn only when *neg* is set. On entity
|
|
871
|
+
rows pass ``reserve_neg=True`` so the image is always two boxes wide
|
|
872
|
+
even when *neg* is ``None`` — the negative half is left transparent,
|
|
873
|
+
which keeps every row's name text aligned and makes the negative box
|
|
874
|
+
appear/disappear in a fixed column. Categories / scenarios (no
|
|
875
|
+
negative concept) pass ``reserve_neg=False`` for a single box.
|
|
876
|
+
|
|
877
|
+
The returned image is appended to ``self._swatches`` so it survives
|
|
878
|
+
garbage collection (a GC'd image renders blank in the cell).
|
|
879
|
+
"""
|
|
880
|
+
pos_rgb = _to_rgb255(pos)
|
|
881
|
+
has_neg = neg is not None
|
|
882
|
+
two_box = has_neg or reserve_neg
|
|
883
|
+
width = _swatch_width(two_box)
|
|
884
|
+
img = tk.PhotoImage(width=width, height=_SWATCH_H)
|
|
885
|
+
|
|
886
|
+
def _fill(x0: int, x1: int, rgb: tuple[int, int, int]) -> None:
|
|
887
|
+
color = "#%02x%02x%02x" % rgb
|
|
888
|
+
border = "#%02x%02x%02x" % _SWATCH_BORDER
|
|
889
|
+
for x in range(x0, x1):
|
|
890
|
+
for y in range(_SWATCH_H):
|
|
891
|
+
edge = (
|
|
892
|
+
x == x0 or x == x1 - 1
|
|
893
|
+
or y == 0 or y == _SWATCH_H - 1
|
|
894
|
+
)
|
|
895
|
+
img.put(border if edge else color, (x, y))
|
|
896
|
+
|
|
897
|
+
_fill(_SWATCH_PAD_L, _SWATCH_PAD_L + _SWATCH_W, pos_rgb)
|
|
898
|
+
if has_neg:
|
|
899
|
+
nx = _SWATCH_PAD_L + _SWATCH_W + _SWATCH_GAP
|
|
900
|
+
_fill(nx, nx + _SWATCH_W, _to_rgb255(neg))
|
|
901
|
+
# else: a reserved-but-unset negative box stays transparent, so it is
|
|
902
|
+
# invisible while the column (and name alignment) is kept.
|
|
903
|
+
|
|
904
|
+
self._swatches.append(img)
|
|
905
|
+
return img
|
|
906
|
+
|
|
907
|
+
# ── Edit history (undo / redo) ────────────────────────────────
|
|
908
|
+
def _snapshot(self) -> None:
|
|
909
|
+
"""Push the PRE-edit ``self._data`` onto the undo stack.
|
|
910
|
+
|
|
911
|
+
Called by EVERY mutator (color write-back, reorder sync)
|
|
912
|
+
before it changes ``self._data``, so the undo stack always holds the
|
|
913
|
+
states to roll back to. A fresh edit invalidates any redo history,
|
|
914
|
+
so the redo stack is cleared here. States are deep copies so later
|
|
915
|
+
in-place edits to ``self._data`` cannot mutate them.
|
|
916
|
+
"""
|
|
917
|
+
self._undo_stack.append(copy.deepcopy(self._data))
|
|
918
|
+
self._redo_stack.clear()
|
|
919
|
+
self._update_history_buttons()
|
|
920
|
+
|
|
921
|
+
def _on_undo(self) -> None:
|
|
922
|
+
"""Roll back one edit: restore the last pre-edit ``self._data``."""
|
|
923
|
+
if not self._undo_stack:
|
|
924
|
+
return
|
|
925
|
+
self._redo_stack.append(copy.deepcopy(self._data))
|
|
926
|
+
self._data = self._undo_stack.pop()
|
|
927
|
+
self._rebuild_panes()
|
|
928
|
+
self._update_history_buttons()
|
|
929
|
+
self._schedule_live_update()
|
|
930
|
+
|
|
931
|
+
def _on_redo(self) -> None:
|
|
932
|
+
"""Re-apply one undone edit."""
|
|
933
|
+
if not self._redo_stack:
|
|
934
|
+
return
|
|
935
|
+
self._undo_stack.append(copy.deepcopy(self._data))
|
|
936
|
+
self._data = self._redo_stack.pop()
|
|
937
|
+
self._rebuild_panes()
|
|
938
|
+
self._update_history_buttons()
|
|
939
|
+
self._schedule_live_update()
|
|
940
|
+
|
|
941
|
+
def _update_history_buttons(self) -> None:
|
|
942
|
+
"""Enable/disable Undo/Redo to reflect each stack's emptiness."""
|
|
943
|
+
if self._undo_button is not None:
|
|
944
|
+
self._undo_button.configure(
|
|
945
|
+
state="normal" if self._undo_stack else "disabled",
|
|
946
|
+
)
|
|
947
|
+
if self._redo_button is not None:
|
|
948
|
+
self._redo_button.configure(
|
|
949
|
+
state="normal" if self._redo_stack else "disabled",
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
def _rebuild_panes(self) -> None:
|
|
953
|
+
"""Discard every entity tree + left-list row and rebuild from
|
|
954
|
+
``self._data``.
|
|
955
|
+
|
|
956
|
+
Used by undo/redo so the displayed trees + swatches exactly
|
|
957
|
+
match the (possibly replaced) working dict — order, colors, and row
|
|
958
|
+
presence. All per-tree bookkeeping and swatch references are reset
|
|
959
|
+
so stale rows/images cannot leak across a rebuild. The currently
|
|
960
|
+
selected category is preserved when it still exists, else the first
|
|
961
|
+
category is selected (``_build_panes`` already selects the first).
|
|
962
|
+
"""
|
|
963
|
+
prev = self._current_category
|
|
964
|
+
for frame in self._category_frames.values():
|
|
965
|
+
frame.destroy()
|
|
966
|
+
# Clear category bookkeeping BEFORE deleting the left rows so a
|
|
967
|
+
# delete-driven ``<<TreeviewSelect>>`` finds no frames and no-ops.
|
|
968
|
+
self._category_order = []
|
|
969
|
+
self._category_trees = {}
|
|
970
|
+
self._category_frames = {}
|
|
971
|
+
self._category_items = {}
|
|
972
|
+
self._current_category = None
|
|
973
|
+
for item in self._category_list.get_children(""):
|
|
974
|
+
self._category_list.delete(item)
|
|
975
|
+
self._tree_section.clear()
|
|
976
|
+
self._drag_move.clear()
|
|
977
|
+
self._drag_anchor.clear()
|
|
978
|
+
self._drag_moved.clear()
|
|
979
|
+
self._tree_composite.clear()
|
|
980
|
+
self._row_swatches.clear()
|
|
981
|
+
self._fixed_order_trees.clear()
|
|
982
|
+
self._divider_items.clear()
|
|
983
|
+
self._swatches.clear()
|
|
984
|
+
self._build_panes()
|
|
985
|
+
if prev is not None and prev in self._category_trees:
|
|
986
|
+
self._show_category(prev)
|
|
987
|
+
|
|
988
|
+
# ── Pane construction ─────────────────────────────────────────
|
|
989
|
+
def _make_tree_style(self) -> str:
|
|
990
|
+
"""Return a Treeview style whose item layout drops the disclosure
|
|
991
|
+
indicator, so leaf rows start flush-left (no ~18px indent) — the
|
|
992
|
+
swatch carries its own small inset instead. Falls back to the
|
|
993
|
+
default ``"Treeview"`` style if the layout override is rejected.
|
|
994
|
+
"""
|
|
995
|
+
name = "PlotSettingsPicker.Treeview"
|
|
996
|
+
style = ttk.Style(self)
|
|
997
|
+
try:
|
|
998
|
+
style.layout(name, style.layout("Treeview"))
|
|
999
|
+
style.layout(
|
|
1000
|
+
f"{name}.Item",
|
|
1001
|
+
[("Treeitem.padding", {"sticky": "nswe", "children": [
|
|
1002
|
+
("Treeitem.image", {"side": "left", "sticky": ""}),
|
|
1003
|
+
("Treeitem.text", {"side": "left", "sticky": ""}),
|
|
1004
|
+
]})],
|
|
1005
|
+
)
|
|
1006
|
+
except tk.TclError:
|
|
1007
|
+
return "Treeview"
|
|
1008
|
+
return name
|
|
1009
|
+
|
|
1010
|
+
def _build_panes(self) -> None:
|
|
1011
|
+
"""Populate the left category selector and the right entity trees.
|
|
1012
|
+
|
|
1013
|
+
Entity-class categories (nodeGroup / flowGroup / unit / connection /
|
|
1014
|
+
node) are ALWAYS shown — even when empty — so it is visible that a
|
|
1015
|
+
class has no entities (the on-open discovery seeds any it can). The
|
|
1016
|
+
scenarios category is ALWAYS shown too, and last. Category
|
|
1017
|
+
subsections appear only when present. The first category (nodeGroup)
|
|
1018
|
+
is selected by default.
|
|
1019
|
+
"""
|
|
1020
|
+
entities = self._data.get("entities")
|
|
1021
|
+
entities = entities if isinstance(entities, dict) else {}
|
|
1022
|
+
for cls in _ENTITY_CLASSES:
|
|
1023
|
+
section = entities.get(cls)
|
|
1024
|
+
rows = list(section.items()) if isinstance(section, dict) else []
|
|
1025
|
+
self._add_category(
|
|
1026
|
+
title=cls,
|
|
1027
|
+
rows=rows,
|
|
1028
|
+
composite=True,
|
|
1029
|
+
section_path=("entities", cls),
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
categories = self._data.get("categories")
|
|
1033
|
+
if isinstance(categories, dict):
|
|
1034
|
+
for name in _CATEGORY_SECTIONS:
|
|
1035
|
+
section = categories.get(name)
|
|
1036
|
+
if isinstance(section, dict) and section:
|
|
1037
|
+
self._add_category(
|
|
1038
|
+
title=name,
|
|
1039
|
+
rows=list(section.items()),
|
|
1040
|
+
composite=False,
|
|
1041
|
+
section_path=("categories", name),
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Scenarios is ALWAYS shown, and last — users rarely edit it, but it
|
|
1045
|
+
# must be discoverable so scenario-comparison colors are editable even
|
|
1046
|
+
# before a comparison run (populated from output folders on open).
|
|
1047
|
+
scenarios = self._data.get("scenarios")
|
|
1048
|
+
scenarios = scenarios if isinstance(scenarios, dict) else {}
|
|
1049
|
+
self._add_category(
|
|
1050
|
+
title="scenarios",
|
|
1051
|
+
rows=list(scenarios.items()),
|
|
1052
|
+
composite=False,
|
|
1053
|
+
section_path=("scenarios",),
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Default selection = the first category (nodeGroup).
|
|
1057
|
+
if self._category_order:
|
|
1058
|
+
self._show_category(self._category_order[0])
|
|
1059
|
+
|
|
1060
|
+
def _add_category(
|
|
1061
|
+
self,
|
|
1062
|
+
title: str,
|
|
1063
|
+
rows: list[tuple[str, object]],
|
|
1064
|
+
composite: bool,
|
|
1065
|
+
section_path: tuple[str, ...],
|
|
1066
|
+
) -> None:
|
|
1067
|
+
"""Register a category: append it to the LEFT selector and build its
|
|
1068
|
+
RIGHT-pane entity Treeview (initially hidden except the first).
|
|
1069
|
+
|
|
1070
|
+
The entity tree is built exactly as the old notebook tab was — same
|
|
1071
|
+
widgets, same per-tree bookkeeping, same drag / Alt-arrow / edit
|
|
1072
|
+
bindings — so all reorder / color-edit behaviour is unchanged; only
|
|
1073
|
+
the container differs (a swapped grid cell instead of a notebook tab).
|
|
1074
|
+
"""
|
|
1075
|
+
# Display filter (rows only — ``self._data`` is never touched, so the
|
|
1076
|
+
# file still round-trips every flowGroup): the flowGroup tab lists ONLY
|
|
1077
|
+
# dispatch aggregators (parquet ``group_aggregate`` ∪ input-DB
|
|
1078
|
+
# ``flow_aggregator`` ∈ {dispatch_plots_only, both}). When the
|
|
1079
|
+
# aggregator set is empty (no DB and no parquet aggregates discovered —
|
|
1080
|
+
# e.g. a pre-solve project with an unreadable DB) the filter is disabled
|
|
1081
|
+
# so we never wrongly hide every flowGroup.
|
|
1082
|
+
aggregators = getattr(self, "_dispatch_flowgroup_aggregators", None)
|
|
1083
|
+
if section_path == ("entities", "flowGroup") and aggregators:
|
|
1084
|
+
rows = [(name, value) for name, value in rows if name in aggregators]
|
|
1085
|
+
|
|
1086
|
+
# Left selector row (pure selector — no reorder bindings attached).
|
|
1087
|
+
item = self._category_list.insert("", "end", text=title)
|
|
1088
|
+
self._category_items[title] = item
|
|
1089
|
+
|
|
1090
|
+
# Right-pane entity tree (same construction as the old tab).
|
|
1091
|
+
frame = ttk.Frame(self._entity_container)
|
|
1092
|
+
frame.columnconfigure(0, weight=1)
|
|
1093
|
+
frame.rowconfigure(0, weight=1)
|
|
1094
|
+
|
|
1095
|
+
tree = ttk.Treeview(
|
|
1096
|
+
frame, show="tree", selectmode="extended", style=self._tree_style,
|
|
1097
|
+
)
|
|
1098
|
+
tree.grid(row=0, column=0, sticky="nsew")
|
|
1099
|
+
|
|
1100
|
+
vscroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
|
|
1101
|
+
vscroll.grid(row=0, column=1, sticky="ns")
|
|
1102
|
+
tree.configure(yscrollcommand=vscroll.set)
|
|
1103
|
+
|
|
1104
|
+
hscroll = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview)
|
|
1105
|
+
hscroll.grid(row=1, column=0, sticky="ew")
|
|
1106
|
+
tree.configure(xscrollcommand=hscroll.set)
|
|
1107
|
+
|
|
1108
|
+
if section_path == _DISPATCH_SECTION:
|
|
1109
|
+
# Render the stack top→bottom with the flowGroups divider between
|
|
1110
|
+
# the positive and negative specials. The specials are reorderable
|
|
1111
|
+
# WITHIN each sign group (positives among themselves above the
|
|
1112
|
+
# divider, negatives among themselves below), but a row may not
|
|
1113
|
+
# cross the divider and the divider itself never moves — enforced in
|
|
1114
|
+
# the reorder paths via the divider row index (see
|
|
1115
|
+
# ``_clamp_insert_for_divider``). The tree is therefore NOT added
|
|
1116
|
+
# to ``_fixed_order_trees``.
|
|
1117
|
+
self._insert_dispatch_rows(tree, rows)
|
|
1118
|
+
else:
|
|
1119
|
+
for name, value in rows:
|
|
1120
|
+
if composite:
|
|
1121
|
+
pos, neg = _resolve_pos_neg(value)
|
|
1122
|
+
image = self._make_swatch(pos, neg, reserve_neg=True)
|
|
1123
|
+
else:
|
|
1124
|
+
image = self._make_swatch(value, None)
|
|
1125
|
+
tree.insert("", "end", text=str(name), image=image)
|
|
1126
|
+
|
|
1127
|
+
# Register for reordering and wire drag + keyboard moves.
|
|
1128
|
+
self._tree_section[tree] = section_path
|
|
1129
|
+
self._drag_move[tree] = False
|
|
1130
|
+
self._drag_anchor[tree] = None
|
|
1131
|
+
self._drag_moved[tree] = False
|
|
1132
|
+
self._tree_composite[tree] = composite
|
|
1133
|
+
tree.bind("<ButtonPress-1>", self._on_drag_start)
|
|
1134
|
+
tree.bind("<B1-Motion>", self._on_drag_motion)
|
|
1135
|
+
tree.bind("<ButtonRelease-1>", self._on_drag_end)
|
|
1136
|
+
tree.bind("<Alt-Up>", self._on_key_move_up)
|
|
1137
|
+
tree.bind("<Alt-Down>", self._on_key_move_down)
|
|
1138
|
+
tree.bind("<Double-Button-1>", self._on_row_double_click)
|
|
1139
|
+
tree.bind("<Return>", self._on_row_return)
|
|
1140
|
+
tree.bind("<FocusIn>", self._on_tree_focus_in)
|
|
1141
|
+
|
|
1142
|
+
# All frames share the one container cell; built hidden and shown on
|
|
1143
|
+
# selection (grid / grid_remove) so per-tree state survives swaps.
|
|
1144
|
+
frame.grid(row=0, column=0, sticky="nsew")
|
|
1145
|
+
frame.grid_remove()
|
|
1146
|
+
|
|
1147
|
+
self._category_order.append(title)
|
|
1148
|
+
self._category_trees[title] = tree
|
|
1149
|
+
self._category_frames[title] = frame
|
|
1150
|
+
|
|
1151
|
+
# ── Dispatch category: engine-fixed order + flowGroups divider ────
|
|
1152
|
+
@staticmethod
|
|
1153
|
+
def _order_dispatch_rows(
|
|
1154
|
+
rows: list[tuple[str, object]],
|
|
1155
|
+
) -> list[object]:
|
|
1156
|
+
"""Order the dispatch section rows into the two sign groups + divider.
|
|
1157
|
+
|
|
1158
|
+
The dispatch plot's special columns split into a POSITIVE group (above
|
|
1159
|
+
the zero axis) and a NEGATIVE group (below it); the flowGroups divider
|
|
1160
|
+
marks the boundary where the movable flowGroup bands stack. A special
|
|
1161
|
+
can be reordered WITHIN its sign group but never crosses the divider (a
|
|
1162
|
+
positive special is always positive). So the picker renders the
|
|
1163
|
+
positive specials, then the divider sentinel, then the negative
|
|
1164
|
+
specials — each group in the FILE's top-to-bottom key order, which is
|
|
1165
|
+
the visual top-to-bottom stack order the engine honors
|
|
1166
|
+
(``categories.dispatch`` order → ``special_order`` in
|
|
1167
|
+
``dispatch_data._order_dispatch_columns``). Only rows actually present
|
|
1168
|
+
are emitted. Any entry that is neither a positive nor a negative
|
|
1169
|
+
special (defensive — should not occur) is appended just BEFORE the
|
|
1170
|
+
divider so it never masquerades as a negative band.
|
|
1171
|
+
|
|
1172
|
+
Returns a display sequence of ``(name, value)`` tuples with the
|
|
1173
|
+
:data:`_DIVIDER` sentinel marking the divider position.
|
|
1174
|
+
"""
|
|
1175
|
+
from flextool.scenario_comparison.constants import (
|
|
1176
|
+
NEGATIVE_SPECIAL,
|
|
1177
|
+
POSITIVE_SPECIAL,
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
present = dict(rows)
|
|
1181
|
+
pos_set = set(POSITIVE_SPECIAL)
|
|
1182
|
+
neg_set = set(NEGATIVE_SPECIAL)
|
|
1183
|
+
# Preserve the file's key order within each sign group (that IS the
|
|
1184
|
+
# visual top-to-bottom order the engine renders).
|
|
1185
|
+
pos = [n for n, _v in rows if n in pos_set]
|
|
1186
|
+
neg = [n for n, _v in rows if n in neg_set]
|
|
1187
|
+
# Any unclassified defensive extras keep their raw file order.
|
|
1188
|
+
unknown = [n for n, _v in rows if n not in pos_set and n not in neg_set]
|
|
1189
|
+
|
|
1190
|
+
seq: list[object] = [(n, present[n]) for n in pos]
|
|
1191
|
+
seq += [(n, present[n]) for n in unknown]
|
|
1192
|
+
seq.append(_DIVIDER)
|
|
1193
|
+
seq += [(n, present[n]) for n in neg]
|
|
1194
|
+
return seq
|
|
1195
|
+
|
|
1196
|
+
def _insert_dispatch_rows(
|
|
1197
|
+
self, tree: ttk.Treeview, rows: list[tuple[str, object]],
|
|
1198
|
+
) -> None:
|
|
1199
|
+
"""Insert the fixed-order dispatch rows plus the flowGroups divider.
|
|
1200
|
+
|
|
1201
|
+
Real special rows carry a single-box swatch (dispatch is not
|
|
1202
|
+
composite) and are color-editable; the synthetic divider row carries
|
|
1203
|
+
the distinct ``_DIVIDER_TAG`` highlight, no swatch, and is recorded in
|
|
1204
|
+
``self._divider_items`` so it is skipped everywhere a row maps back to
|
|
1205
|
+
an entity (it is display-only — never written to ``self._data``).
|
|
1206
|
+
"""
|
|
1207
|
+
self._configure_divider_tag(tree)
|
|
1208
|
+
for entry in self._order_dispatch_rows(rows):
|
|
1209
|
+
if entry is _DIVIDER:
|
|
1210
|
+
iid = tree.insert(
|
|
1211
|
+
"", "end", text=_DIVIDER_TEXT, tags=(_DIVIDER_TAG,),
|
|
1212
|
+
)
|
|
1213
|
+
self._divider_items.add((tree, iid))
|
|
1214
|
+
else:
|
|
1215
|
+
name, value = entry
|
|
1216
|
+
image = self._make_swatch(value, None)
|
|
1217
|
+
tree.insert("", "end", text=str(name), image=image)
|
|
1218
|
+
|
|
1219
|
+
def _configure_divider_tag(self, tree: ttk.Treeview) -> None:
|
|
1220
|
+
"""Style the divider tag: a contrasting band + italic text.
|
|
1221
|
+
|
|
1222
|
+
A muted blue-gray background reads as a divider against the default
|
|
1223
|
+
(light) ttk Treeview row background in both the classic and modern
|
|
1224
|
+
themes. The italic font is a copy of ``TkDefaultFont`` (so it tracks
|
|
1225
|
+
the theme's default face / size); font creation is guarded so a Tk
|
|
1226
|
+
without font introspection still gets the background highlight.
|
|
1227
|
+
"""
|
|
1228
|
+
if self._divider_font is None:
|
|
1229
|
+
try:
|
|
1230
|
+
import tkinter.font as tkfont
|
|
1231
|
+
|
|
1232
|
+
base = tkfont.nametofont("TkDefaultFont")
|
|
1233
|
+
self._divider_font = tkfont.Font(**base.actual())
|
|
1234
|
+
self._divider_font.configure(slant="italic")
|
|
1235
|
+
except tk.TclError:
|
|
1236
|
+
self._divider_font = "" # sentinel: font unavailable
|
|
1237
|
+
opts = {"background": "#c8d4e0", "foreground": "#1a1a1a"}
|
|
1238
|
+
if self._divider_font:
|
|
1239
|
+
opts["font"] = self._divider_font
|
|
1240
|
+
tree.tag_configure(_DIVIDER_TAG, **opts)
|
|
1241
|
+
|
|
1242
|
+
def _is_divider(self, tree: ttk.Treeview, item: str) -> bool:
|
|
1243
|
+
"""True iff *item* in *tree* is the synthetic flowGroups divider row."""
|
|
1244
|
+
return (tree, item) in self._divider_items
|
|
1245
|
+
|
|
1246
|
+
def _divider_index(self, tree: ttk.Treeview) -> int | None:
|
|
1247
|
+
"""Row index of *tree*'s flowGroups divider, or ``None`` if it has none.
|
|
1248
|
+
|
|
1249
|
+
Used by the reorder paths to keep a special on its own side of the
|
|
1250
|
+
divider (positives above, negatives below) and to keep the divider
|
|
1251
|
+
itself pinned. A tree without a divider (every non-dispatch tree)
|
|
1252
|
+
returns ``None``, so the sign-group clamp is a no-op there.
|
|
1253
|
+
"""
|
|
1254
|
+
for i, iid in enumerate(tree.get_children("")):
|
|
1255
|
+
if (tree, iid) in self._divider_items:
|
|
1256
|
+
return i
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
def _clamp_insert_for_divider(
|
|
1260
|
+
self,
|
|
1261
|
+
tree: ttk.Treeview,
|
|
1262
|
+
order: list[str],
|
|
1263
|
+
selected: list[str],
|
|
1264
|
+
remaining: list[str],
|
|
1265
|
+
insert_at: int,
|
|
1266
|
+
) -> int:
|
|
1267
|
+
"""Clamp *insert_at* so the moved block stays on its side of the divider.
|
|
1268
|
+
|
|
1269
|
+
*insert_at* is an index into *remaining* (the non-selected rows, which
|
|
1270
|
+
always include the divider). A block of positive specials (currently
|
|
1271
|
+
above the divider) is clamped so it lands at or above the divider's slot
|
|
1272
|
+
in *remaining*; a block of negatives (below) is clamped so it lands just
|
|
1273
|
+
below it. A move that would cross is thus clamped to the boundary (no
|
|
1274
|
+
crossing), never rejected. The divider is never in *selected*, so it
|
|
1275
|
+
never moves. No-op for trees without a divider.
|
|
1276
|
+
"""
|
|
1277
|
+
div_idx = self._divider_index(tree)
|
|
1278
|
+
if div_idx is None:
|
|
1279
|
+
return insert_at
|
|
1280
|
+
div_iid = order[div_idx]
|
|
1281
|
+
if div_iid not in remaining:
|
|
1282
|
+
return insert_at # divider defensively selected — leave untouched
|
|
1283
|
+
div_rem_idx = remaining.index(div_iid)
|
|
1284
|
+
sel_idx = [order.index(r) for r in selected]
|
|
1285
|
+
if not sel_idx:
|
|
1286
|
+
return insert_at
|
|
1287
|
+
if max(sel_idx) < div_idx:
|
|
1288
|
+
# Positives: keep the block at/above the divider's slot.
|
|
1289
|
+
return min(insert_at, div_rem_idx)
|
|
1290
|
+
if min(sel_idx) > div_idx:
|
|
1291
|
+
# Negatives: keep the block just below the divider's slot.
|
|
1292
|
+
return max(insert_at, div_rem_idx + 1)
|
|
1293
|
+
return insert_at
|
|
1294
|
+
|
|
1295
|
+
def _show_category(self, title: str) -> None:
|
|
1296
|
+
"""Show *title*'s entity tree, hiding the previously shown one.
|
|
1297
|
+
|
|
1298
|
+
Also reflects the selection in the left selector so a programmatic
|
|
1299
|
+
show (default open, undo/redo restore) and a user click stay in sync.
|
|
1300
|
+
"""
|
|
1301
|
+
if title not in self._category_frames:
|
|
1302
|
+
return
|
|
1303
|
+
if (
|
|
1304
|
+
self._current_category is not None
|
|
1305
|
+
and self._current_category != title
|
|
1306
|
+
and self._current_category in self._category_frames
|
|
1307
|
+
):
|
|
1308
|
+
self._category_frames[self._current_category].grid_remove()
|
|
1309
|
+
self._category_frames[title].grid()
|
|
1310
|
+
self._current_category = title
|
|
1311
|
+
item = self._category_items.get(title)
|
|
1312
|
+
if (
|
|
1313
|
+
item is not None
|
|
1314
|
+
and self._category_list.exists(item)
|
|
1315
|
+
and self._category_list.selection() != (item,)
|
|
1316
|
+
):
|
|
1317
|
+
self._category_list.selection_set(item)
|
|
1318
|
+
|
|
1319
|
+
def _on_category_select(self, _event=None) -> None:
|
|
1320
|
+
"""Left-list selection changed → show that category's entity tree."""
|
|
1321
|
+
sel = self._category_list.selection()
|
|
1322
|
+
if not sel:
|
|
1323
|
+
return
|
|
1324
|
+
self._show_category(self._category_list.item(sel[0], "text"))
|
|
1325
|
+
|
|
1326
|
+
# ── Reordering (drag + keyboard) ──────────────────────────────
|
|
1327
|
+
def _selected_rows(self, tree: ttk.Treeview) -> list[str]:
|
|
1328
|
+
"""Current selection in top-to-bottom row order (focus as fallback)."""
|
|
1329
|
+
sel = set(tree.selection())
|
|
1330
|
+
if not sel:
|
|
1331
|
+
f = tree.focus()
|
|
1332
|
+
if f:
|
|
1333
|
+
sel = {f}
|
|
1334
|
+
return [r for r in tree.get_children("") if r in sel]
|
|
1335
|
+
|
|
1336
|
+
def _reorder_selection_block(
|
|
1337
|
+
self, tree: ttk.Treeview, insert_at: int,
|
|
1338
|
+
) -> bool:
|
|
1339
|
+
"""Place the selected rows as a contiguous block at *insert_at*.
|
|
1340
|
+
|
|
1341
|
+
*insert_at* is an index into the rows that are NOT selected (so the
|
|
1342
|
+
block is dropped between them). Preserves each row's value, keeps
|
|
1343
|
+
the selection, and returns True iff the order actually changed.
|
|
1344
|
+
"""
|
|
1345
|
+
order = list(tree.get_children(""))
|
|
1346
|
+
# The flowGroups divider is display-only: never part of a moved block
|
|
1347
|
+
# (so an arrow-key selection that landed on it can't drag it).
|
|
1348
|
+
selected = [
|
|
1349
|
+
r for r in self._selected_rows(tree)
|
|
1350
|
+
if not self._is_divider(tree, r)
|
|
1351
|
+
]
|
|
1352
|
+
if not selected:
|
|
1353
|
+
return False
|
|
1354
|
+
remaining = [r for r in order if r not in set(selected)]
|
|
1355
|
+
# Keep the block on its side of the divider (no-op without a divider).
|
|
1356
|
+
insert_at = self._clamp_insert_for_divider(
|
|
1357
|
+
tree, order, selected, remaining, insert_at,
|
|
1358
|
+
)
|
|
1359
|
+
insert_at = max(0, min(insert_at, len(remaining)))
|
|
1360
|
+
new_order = remaining[:insert_at] + selected + remaining[insert_at:]
|
|
1361
|
+
if new_order == order:
|
|
1362
|
+
return False
|
|
1363
|
+
for i, row in enumerate(new_order):
|
|
1364
|
+
tree.move(row, "", i)
|
|
1365
|
+
tree.selection_set(selected)
|
|
1366
|
+
tree.focus(selected[0])
|
|
1367
|
+
return True
|
|
1368
|
+
|
|
1369
|
+
def _on_drag_start(self, event: tk.Event) -> str | None:
|
|
1370
|
+
"""Begin a MOVE drag whenever the press lands on a row.
|
|
1371
|
+
|
|
1372
|
+
Pressing an already-selected row moves the whole selection.
|
|
1373
|
+
Pressing an UNSELECTED row (no Shift/Ctrl) first collapses the
|
|
1374
|
+
selection onto that row, then moves it — so a drag started on a
|
|
1375
|
+
not-yet-selected item picks it up, as users expect, instead of
|
|
1376
|
+
ttk's native range draw-select. Either way we return ``"break"``
|
|
1377
|
+
to own the selection and keep it intact for the drag.
|
|
1378
|
+
|
|
1379
|
+
Shift/Ctrl presses are left to ttk's default handler so extend /
|
|
1380
|
+
toggle multi-selection still works (the user builds a selection,
|
|
1381
|
+
then drags it as a block). A press on empty space is native too.
|
|
1382
|
+
"""
|
|
1383
|
+
tree = event.widget
|
|
1384
|
+
if tree not in self._tree_section:
|
|
1385
|
+
return None
|
|
1386
|
+
# Fixed-order (dispatch) trees never reorder — let ttk's native
|
|
1387
|
+
# ButtonPress run (selection / focus) but prime no move drag.
|
|
1388
|
+
if tree in self._fixed_order_trees:
|
|
1389
|
+
self._drag_move[tree] = False
|
|
1390
|
+
self._drag_moved[tree] = False
|
|
1391
|
+
self._drag_anchor[tree] = None
|
|
1392
|
+
return None
|
|
1393
|
+
row = tree.identify_row(event.y) or None
|
|
1394
|
+
self._drag_moved[tree] = False
|
|
1395
|
+
# The flowGroups divider is display-only — never selected or dragged.
|
|
1396
|
+
# Swallow the press (``"break"``) so ttk's native handler does not
|
|
1397
|
+
# select it, and prime no move.
|
|
1398
|
+
if row and self._is_divider(tree, row):
|
|
1399
|
+
self._drag_move[tree] = False
|
|
1400
|
+
self._drag_anchor[tree] = None
|
|
1401
|
+
return "break"
|
|
1402
|
+
# Shift (0x0001) / Control (0x0004) → let ttk extend/toggle select.
|
|
1403
|
+
if getattr(event, "state", 0) & 0x0005:
|
|
1404
|
+
self._drag_move[tree] = False
|
|
1405
|
+
self._drag_anchor[tree] = None
|
|
1406
|
+
return None
|
|
1407
|
+
if row:
|
|
1408
|
+
if row not in set(tree.selection()):
|
|
1409
|
+
tree.selection_set(row)
|
|
1410
|
+
tree.focus(row)
|
|
1411
|
+
# Returning "break" suppresses ttk's default ButtonPress handler,
|
|
1412
|
+
# which is what normally gives the tree KEYBOARD focus — without
|
|
1413
|
+
# this the Alt-Up / Alt-Down reorder bindings (which require the
|
|
1414
|
+
# tree to hold keyboard focus) never fire after a click.
|
|
1415
|
+
tree.focus_set()
|
|
1416
|
+
self._drag_move[tree] = True
|
|
1417
|
+
self._drag_anchor[tree] = row
|
|
1418
|
+
return "break"
|
|
1419
|
+
self._drag_move[tree] = False
|
|
1420
|
+
self._drag_anchor[tree] = None
|
|
1421
|
+
return None
|
|
1422
|
+
|
|
1423
|
+
def _on_drag_motion(self, event: tk.Event) -> None:
|
|
1424
|
+
"""Move the selection to follow the cursor (move drags only)."""
|
|
1425
|
+
tree = event.widget
|
|
1426
|
+
if tree in self._fixed_order_trees:
|
|
1427
|
+
return # engine-fixed order — no reorder drag
|
|
1428
|
+
if not self._drag_move.get(tree):
|
|
1429
|
+
return # native draw-select drag — do not interfere
|
|
1430
|
+
target = tree.identify_row(event.y)
|
|
1431
|
+
if not target:
|
|
1432
|
+
return
|
|
1433
|
+
order = list(tree.get_children(""))
|
|
1434
|
+
selected = set(self._selected_rows(tree))
|
|
1435
|
+
target_idx = order.index(target)
|
|
1436
|
+
# Number of non-selected rows above the hovered row = the insertion
|
|
1437
|
+
# slot that lands the block JUST ABOVE the target.
|
|
1438
|
+
above = sum(1 for r in order[:target_idx] if r not in selected)
|
|
1439
|
+
# Decide above/below from the cursor's position within the hovered
|
|
1440
|
+
# row: upper half drops before it, lower half after. Using the row
|
|
1441
|
+
# midpoint (not an unconditional "+1") is what makes the TOP slot
|
|
1442
|
+
# reachable — hovering the upper half of the first row gives
|
|
1443
|
+
# insert_at == 0. When the row has no geometry (scrolled off), fall
|
|
1444
|
+
# back to "below" (the previous behaviour).
|
|
1445
|
+
if target in selected:
|
|
1446
|
+
insert_at = above
|
|
1447
|
+
else:
|
|
1448
|
+
bbox = tree.bbox(target)
|
|
1449
|
+
drop_below = True
|
|
1450
|
+
if bbox:
|
|
1451
|
+
_bx, by, _bw, bh = bbox
|
|
1452
|
+
drop_below = event.y >= by + bh / 2
|
|
1453
|
+
insert_at = above + 1 if drop_below else above
|
|
1454
|
+
if self._reorder_selection_block(tree, insert_at):
|
|
1455
|
+
self._drag_moved[tree] = True
|
|
1456
|
+
tree.see(target)
|
|
1457
|
+
|
|
1458
|
+
def _on_drag_end(self, event: tk.Event) -> None:
|
|
1459
|
+
"""Finish a drag: persist a move, or collapse a no-move click."""
|
|
1460
|
+
tree = event.widget
|
|
1461
|
+
was_move = self._drag_move.get(tree)
|
|
1462
|
+
self._drag_move[tree] = False
|
|
1463
|
+
if not was_move:
|
|
1464
|
+
return
|
|
1465
|
+
if self._drag_moved.get(tree):
|
|
1466
|
+
section_path = self._tree_section.get(tree)
|
|
1467
|
+
if section_path is not None:
|
|
1468
|
+
self._sync_section_order_from_tree(section_path, tree)
|
|
1469
|
+
else:
|
|
1470
|
+
# A plain click on a selected row (no drag) collapses the
|
|
1471
|
+
# multi-selection down to that one row, as a normal click would.
|
|
1472
|
+
anchor = self._drag_anchor.get(tree)
|
|
1473
|
+
if anchor and tree.exists(anchor):
|
|
1474
|
+
tree.selection_set(anchor)
|
|
1475
|
+
tree.focus(anchor)
|
|
1476
|
+
|
|
1477
|
+
def _on_key_move_up(self, event: tk.Event) -> str:
|
|
1478
|
+
"""Alt+Up: move the selected row(s) up one position."""
|
|
1479
|
+
return self._key_move(event.widget, -1)
|
|
1480
|
+
|
|
1481
|
+
def _on_key_move_down(self, event: tk.Event) -> str:
|
|
1482
|
+
"""Alt+Down: move the selected row(s) down one position."""
|
|
1483
|
+
return self._key_move(event.widget, +1)
|
|
1484
|
+
|
|
1485
|
+
def _key_move(self, tree: ttk.Treeview, delta: int) -> str:
|
|
1486
|
+
"""Shift the selected row(s) by ``delta`` as a group; persist order.
|
|
1487
|
+
|
|
1488
|
+
Returns ``"break"`` so Tk's default Alt-arrow handling does not
|
|
1489
|
+
also fire.
|
|
1490
|
+
"""
|
|
1491
|
+
if tree not in self._tree_section:
|
|
1492
|
+
return "break"
|
|
1493
|
+
# Fixed-order trees are engine-pinned — Alt-move is a no-op.
|
|
1494
|
+
if tree in self._fixed_order_trees:
|
|
1495
|
+
return "break"
|
|
1496
|
+
order = list(tree.get_children(""))
|
|
1497
|
+
# The flowGroups divider never moves and is never part of the block;
|
|
1498
|
+
# drop it from the selection so it can't be Alt-moved even when it holds
|
|
1499
|
+
# focus from arrow-key navigation.
|
|
1500
|
+
selected = [
|
|
1501
|
+
r for r in self._selected_rows(tree)
|
|
1502
|
+
if not self._is_divider(tree, r)
|
|
1503
|
+
]
|
|
1504
|
+
if not selected:
|
|
1505
|
+
return "break"
|
|
1506
|
+
sset = set(selected)
|
|
1507
|
+
first = order.index(selected[0])
|
|
1508
|
+
last = order.index(selected[-1])
|
|
1509
|
+
if delta < 0 and first == 0:
|
|
1510
|
+
return "break"
|
|
1511
|
+
if delta > 0 and last == len(order) - 1:
|
|
1512
|
+
return "break"
|
|
1513
|
+
# Unselected rows above the block; the block jumps one such row
|
|
1514
|
+
# up (insert_at - 1) or down (insert_at + 1) among the others.
|
|
1515
|
+
before = sum(1 for r in order[:first] if r not in sset)
|
|
1516
|
+
insert_at = before - 1 if delta < 0 else before + 1
|
|
1517
|
+
if self._reorder_selection_block(tree, insert_at):
|
|
1518
|
+
tree.see(selected[0] if delta < 0 else selected[-1])
|
|
1519
|
+
self._sync_section_order_from_tree(self._tree_section[tree], tree)
|
|
1520
|
+
return "break"
|
|
1521
|
+
|
|
1522
|
+
def _sync_section_order_from_tree(
|
|
1523
|
+
self, section_path: tuple[str, ...], tree: ttk.Treeview,
|
|
1524
|
+
) -> None:
|
|
1525
|
+
"""Rebuild ``section_path`` in the working dict to match the tree.
|
|
1526
|
+
|
|
1527
|
+
The new dict preserves each entry's VALUE (bare color or
|
|
1528
|
+
``{color, neg_color}``) and only reorders the keys to match the
|
|
1529
|
+
tree's current top-to-bottom row order. Centralised so 6.5's
|
|
1530
|
+
undo can reuse it.
|
|
1531
|
+
"""
|
|
1532
|
+
# Resolve the existing section mapping (the value source of truth).
|
|
1533
|
+
section = self._data
|
|
1534
|
+
for key in section_path:
|
|
1535
|
+
if not isinstance(section, dict):
|
|
1536
|
+
return
|
|
1537
|
+
section = section.get(key)
|
|
1538
|
+
if not isinstance(section, dict):
|
|
1539
|
+
return
|
|
1540
|
+
|
|
1541
|
+
# Tree row order, by name (column #0 text). Skip any synthetic
|
|
1542
|
+
# divider row so its display-only label ("flowGroups") can never leak
|
|
1543
|
+
# into the working dict as an entity (defensive — fixed-order trees
|
|
1544
|
+
# are never synced, but a stray divider must not round-trip).
|
|
1545
|
+
ordered_names = [
|
|
1546
|
+
tree.item(iid, "text")
|
|
1547
|
+
for iid in tree.get_children("")
|
|
1548
|
+
if not self._is_divider(tree, iid)
|
|
1549
|
+
]
|
|
1550
|
+
# Rebuild preserving values; keep any keys not represented as rows
|
|
1551
|
+
# (defensive — should not happen) appended in their original order.
|
|
1552
|
+
rebuilt: dict[str, object] = {}
|
|
1553
|
+
for name in ordered_names:
|
|
1554
|
+
if name in section:
|
|
1555
|
+
rebuilt[name] = section[name]
|
|
1556
|
+
for name, value in section.items():
|
|
1557
|
+
if name not in rebuilt:
|
|
1558
|
+
rebuilt[name] = value
|
|
1559
|
+
|
|
1560
|
+
# A reorder that did not actually change the key order (e.g. a press
|
|
1561
|
+
# + release with no motion) is a no-op — do not record an undo step.
|
|
1562
|
+
if list(rebuilt.keys()) == list(section.keys()):
|
|
1563
|
+
return
|
|
1564
|
+
|
|
1565
|
+
# Record the pre-edit state, then write back into the parent
|
|
1566
|
+
# container so the change is in-place for the live write / Close dump.
|
|
1567
|
+
self._snapshot()
|
|
1568
|
+
parent = self._data
|
|
1569
|
+
for key in section_path[:-1]:
|
|
1570
|
+
parent = parent[key]
|
|
1571
|
+
parent[section_path[-1]] = rebuilt
|
|
1572
|
+
self._schedule_live_update()
|
|
1573
|
+
|
|
1574
|
+
# ── Color editing (double-click / Enter) ──────────────────────
|
|
1575
|
+
def _restore_tree_focus(self, tree: ttk.Treeview, item: str) -> None:
|
|
1576
|
+
"""Return keyboard focus to *tree* and re-select/refocus *item*.
|
|
1577
|
+
|
|
1578
|
+
Called after a color dialog closes so the focus lands back in the
|
|
1579
|
+
list (not lost to the window) and Enter can edit the next row.
|
|
1580
|
+
"""
|
|
1581
|
+
tree.focus_set()
|
|
1582
|
+
if item and tree.exists(item):
|
|
1583
|
+
tree.selection_set(item)
|
|
1584
|
+
tree.focus(item)
|
|
1585
|
+
tree.see(item)
|
|
1586
|
+
|
|
1587
|
+
def _on_tree_focus_in(self, event: tk.Event) -> None:
|
|
1588
|
+
"""Activate a row when the tree gains focus (e.g. via Tab).
|
|
1589
|
+
|
|
1590
|
+
Without an active row the arrow / Alt-arrow keys have nothing to act
|
|
1591
|
+
on. Tk's item-focus persists across focus changes, so reuse the
|
|
1592
|
+
last-active row when it still exists, otherwise activate the first.
|
|
1593
|
+
"""
|
|
1594
|
+
tree = event.widget
|
|
1595
|
+
if tree not in self._tree_section:
|
|
1596
|
+
return
|
|
1597
|
+
item = tree.focus()
|
|
1598
|
+
if not item or not tree.exists(item):
|
|
1599
|
+
children = tree.get_children("")
|
|
1600
|
+
if not children:
|
|
1601
|
+
return
|
|
1602
|
+
item = children[0]
|
|
1603
|
+
tree.focus(item)
|
|
1604
|
+
tree.selection_set(item)
|
|
1605
|
+
tree.see(item)
|
|
1606
|
+
|
|
1607
|
+
def _on_row_return(self, event: tk.Event) -> str:
|
|
1608
|
+
"""Enter on a row opens its color editor (keyboard parity with the
|
|
1609
|
+
double-click)."""
|
|
1610
|
+
tree = event.widget
|
|
1611
|
+
if tree not in self._tree_section:
|
|
1612
|
+
return ""
|
|
1613
|
+
item = tree.focus() or (
|
|
1614
|
+
tree.selection()[0] if tree.selection() else ""
|
|
1615
|
+
)
|
|
1616
|
+
if not item:
|
|
1617
|
+
return "break"
|
|
1618
|
+
self._edit_row_color(tree, item)
|
|
1619
|
+
return "break"
|
|
1620
|
+
|
|
1621
|
+
def _on_row_double_click(self, event: tk.Event) -> str:
|
|
1622
|
+
"""Open the color editor for the double-clicked row.
|
|
1623
|
+
|
|
1624
|
+
Resolves the row under the cursor; a double-click on empty space is
|
|
1625
|
+
ignored. Returns ``"break"`` so the click is consumed and does not
|
|
1626
|
+
feed the drag/select bindings from 6.3.
|
|
1627
|
+
"""
|
|
1628
|
+
tree = event.widget
|
|
1629
|
+
if tree not in self._tree_section:
|
|
1630
|
+
return ""
|
|
1631
|
+
item = tree.identify_row(event.y)
|
|
1632
|
+
if not item:
|
|
1633
|
+
return "break"
|
|
1634
|
+
# A double-click must not leave a stale move-drag primed.
|
|
1635
|
+
self._drag_move[tree] = False
|
|
1636
|
+
self._edit_row_color(tree, item)
|
|
1637
|
+
return "break"
|
|
1638
|
+
|
|
1639
|
+
def _section_dict(self, section_path: tuple[str, ...]) -> dict | None:
|
|
1640
|
+
"""Resolve a section path to its mapping in the working dict."""
|
|
1641
|
+
section = self._data
|
|
1642
|
+
for key in section_path:
|
|
1643
|
+
if not isinstance(section, dict):
|
|
1644
|
+
return None
|
|
1645
|
+
section = section.get(key)
|
|
1646
|
+
return section if isinstance(section, dict) else None
|
|
1647
|
+
|
|
1648
|
+
def _edit_row_color(self, tree: ttk.Treeview, item: str) -> None:
|
|
1649
|
+
"""Open the appropriate color dialog and write the result back."""
|
|
1650
|
+
# The synthetic flowGroups divider is display-only — never editable.
|
|
1651
|
+
if self._is_divider(tree, item):
|
|
1652
|
+
return
|
|
1653
|
+
section_path = self._tree_section[tree]
|
|
1654
|
+
section = self._section_dict(section_path)
|
|
1655
|
+
if section is None:
|
|
1656
|
+
return
|
|
1657
|
+
name = tree.item(item, "text")
|
|
1658
|
+
if name not in section:
|
|
1659
|
+
return
|
|
1660
|
+
value = section[name]
|
|
1661
|
+
|
|
1662
|
+
if self._tree_composite.get(tree, False):
|
|
1663
|
+
self._edit_entity_color(tree, item, section, name, value)
|
|
1664
|
+
else:
|
|
1665
|
+
self._edit_single_color(tree, item, section, name, value)
|
|
1666
|
+
|
|
1667
|
+
def _edit_entity_color(
|
|
1668
|
+
self,
|
|
1669
|
+
tree: ttk.Treeview,
|
|
1670
|
+
item: str,
|
|
1671
|
+
section: dict,
|
|
1672
|
+
name: str,
|
|
1673
|
+
value,
|
|
1674
|
+
) -> None:
|
|
1675
|
+
"""Edit a pos/neg entity entry via the modal lock dialog."""
|
|
1676
|
+
pos_val, neg_val = _resolve_pos_neg(value)
|
|
1677
|
+
# A bare entry (no explicit negative) opens LINKED.
|
|
1678
|
+
linked = neg_val is None
|
|
1679
|
+
pos_hex = _to_hex(pos_val)
|
|
1680
|
+
neg_hex = _to_hex(neg_val if neg_val is not None else pos_val)
|
|
1681
|
+
|
|
1682
|
+
dialog = ColorPickerDialog(self, name, pos_hex, neg_hex, linked)
|
|
1683
|
+
self.wait_window(dialog)
|
|
1684
|
+
self._restore_tree_focus(tree, item)
|
|
1685
|
+
if dialog.result is None:
|
|
1686
|
+
return # Cancel: no change.
|
|
1687
|
+
|
|
1688
|
+
new_pos, new_neg = dialog.result
|
|
1689
|
+
# Record the pre-edit state before mutating the working dict.
|
|
1690
|
+
self._snapshot()
|
|
1691
|
+
if new_neg is None:
|
|
1692
|
+
section[name] = new_pos
|
|
1693
|
+
self._rebuild_row_swatch(tree, item, new_pos, None)
|
|
1694
|
+
else:
|
|
1695
|
+
section[name] = {"color": new_pos, "neg_color": new_neg}
|
|
1696
|
+
self._rebuild_row_swatch(tree, item, new_pos, new_neg)
|
|
1697
|
+
self._schedule_live_update()
|
|
1698
|
+
|
|
1699
|
+
def _edit_single_color(
|
|
1700
|
+
self,
|
|
1701
|
+
tree: ttk.Treeview,
|
|
1702
|
+
item: str,
|
|
1703
|
+
section: dict,
|
|
1704
|
+
name: str,
|
|
1705
|
+
value,
|
|
1706
|
+
) -> None:
|
|
1707
|
+
"""Edit a single-color (category / scenario) entry.
|
|
1708
|
+
|
|
1709
|
+
Uses the same embedded picker as entities but in single mode (the
|
|
1710
|
+
negative chooser + link checkbox are hidden), so categories /
|
|
1711
|
+
scenarios get the same look without a negative concept.
|
|
1712
|
+
"""
|
|
1713
|
+
cur = _to_hex(value)
|
|
1714
|
+
dialog = ColorPickerDialog(self, name, cur, cur, True, single=True)
|
|
1715
|
+
self.wait_window(dialog)
|
|
1716
|
+
self._restore_tree_focus(tree, item)
|
|
1717
|
+
if dialog.result is None:
|
|
1718
|
+
return # Cancel: no change.
|
|
1719
|
+
new_color = dialog.result[0]
|
|
1720
|
+
# Record the pre-edit state before mutating the working dict.
|
|
1721
|
+
self._snapshot()
|
|
1722
|
+
section[name] = new_color
|
|
1723
|
+
self._rebuild_row_swatch(tree, item, new_color, None)
|
|
1724
|
+
self._schedule_live_update()
|
|
1725
|
+
|
|
1726
|
+
def _rebuild_row_swatch(
|
|
1727
|
+
self,
|
|
1728
|
+
tree: ttk.Treeview,
|
|
1729
|
+
item: str,
|
|
1730
|
+
pos,
|
|
1731
|
+
neg,
|
|
1732
|
+
) -> None:
|
|
1733
|
+
"""Rebuild and re-attach a row's swatch image in place."""
|
|
1734
|
+
image = self._make_swatch(
|
|
1735
|
+
pos, neg, reserve_neg=self._tree_composite.get(tree, False),
|
|
1736
|
+
)
|
|
1737
|
+
# Keep a per-row reference so the replacement is not GC'd (the
|
|
1738
|
+
# superseded image stays referenced in ``self._swatches`` too, but
|
|
1739
|
+
# is no longer displayed).
|
|
1740
|
+
self._row_swatches[(tree, item)] = image
|
|
1741
|
+
tree.item(item, image=image)
|
|
1742
|
+
|
|
1743
|
+
# ── Entity discovery (on-open seeding) ────────────────────────
|
|
1744
|
+
def _discover_input_dbs(self) -> list[str]:
|
|
1745
|
+
"""Return ``sqlite:///`` URLs for the project's input/intermediate DBs.
|
|
1746
|
+
|
|
1747
|
+
The project root is the settings file's parent directory. Every
|
|
1748
|
+
``*.sqlite`` under ``<project>/input_sources`` and
|
|
1749
|
+
``<project>/intermediate`` is a candidate input DB; URLs are
|
|
1750
|
+
returned in a stable (sorted) order so the union is deterministic.
|
|
1751
|
+
"""
|
|
1752
|
+
project_root = self._settings_path.parent
|
|
1753
|
+
urls: list[str] = []
|
|
1754
|
+
for sub in ("input_sources", "intermediate"):
|
|
1755
|
+
db_dir = project_root / sub
|
|
1756
|
+
if not db_dir.is_dir():
|
|
1757
|
+
continue
|
|
1758
|
+
for path in sorted(db_dir.glob("*.sqlite")):
|
|
1759
|
+
if path.is_file():
|
|
1760
|
+
urls.append(f"sqlite:///{path}")
|
|
1761
|
+
return urls
|
|
1762
|
+
|
|
1763
|
+
def _discover_scenario_names(self) -> list[str]:
|
|
1764
|
+
"""Executed-scenario folder names under ``<project>/output_parquet``.
|
|
1765
|
+
|
|
1766
|
+
These folder names are the scenario identities comparison plots key
|
|
1767
|
+
off (the folder name, including any ``_<n>`` source suffix), so using
|
|
1768
|
+
them verbatim makes picker-set colors line up with the plot labels.
|
|
1769
|
+
``_``-prefixed manifest dirs are skipped; order is stable (sorted).
|
|
1770
|
+
"""
|
|
1771
|
+
parquet_dir = self._settings_path.parent / "output_parquet"
|
|
1772
|
+
if not parquet_dir.is_dir():
|
|
1773
|
+
return []
|
|
1774
|
+
return [
|
|
1775
|
+
entry.name
|
|
1776
|
+
for entry in sorted(parquet_dir.iterdir())
|
|
1777
|
+
if entry.is_dir() and not entry.name.startswith("_")
|
|
1778
|
+
]
|
|
1779
|
+
|
|
1780
|
+
def _merge_scenarios_from_folders(self) -> None:
|
|
1781
|
+
"""Add newly discovered scenario folder names to the working
|
|
1782
|
+
``scenarios`` section with a default palette color (add-only — never
|
|
1783
|
+
prunes, never overwrites an existing/edited color). Called on open so
|
|
1784
|
+
the always-shown scenarios tab is immediately useful.
|
|
1785
|
+
"""
|
|
1786
|
+
names = self._discover_scenario_names()
|
|
1787
|
+
if not names:
|
|
1788
|
+
return
|
|
1789
|
+
from flextool.scenario_comparison.config_builder import (
|
|
1790
|
+
assign_palette_colors,
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
scenarios = self._data.get("scenarios")
|
|
1794
|
+
scenarios = scenarios if isinstance(scenarios, dict) else {}
|
|
1795
|
+
new_names = [n for n in names if n not in scenarios]
|
|
1796
|
+
if not new_names:
|
|
1797
|
+
return
|
|
1798
|
+
for name, color in assign_palette_colors(new_names).items():
|
|
1799
|
+
scenarios[name] = color
|
|
1800
|
+
self._data["scenarios"] = scenarios
|
|
1801
|
+
|
|
1802
|
+
def _discover_dispatch_sources(
|
|
1803
|
+
self,
|
|
1804
|
+
) -> tuple[dict[str, set[str]], dict[str, float]]:
|
|
1805
|
+
"""Discover dispatch entities + per-entity std dev from output parquet.
|
|
1806
|
+
|
|
1807
|
+
PRIMARY discovery source: the solved ``output_parquet`` — i.e. exactly
|
|
1808
|
+
what the dispatch plots draw. For every executed-scenario folder
|
|
1809
|
+
(``_``-prefixed manifest dirs skipped) the dispatch bundle is loaded
|
|
1810
|
+
with :func:`~flextool.scenario_comparison.dispatch_data.load_dispatch_bundle`
|
|
1811
|
+
(the same load sequence the result viewer uses), then:
|
|
1812
|
+
|
|
1813
|
+
* :func:`~flextool.scenario_comparison.config_builder.discover_dispatch_entities`
|
|
1814
|
+
classifies the discovered names into ``nodeGroup`` / ``flowGroup`` /
|
|
1815
|
+
``unit`` / ``connection`` (processGroup ``group_aggregate`` names —
|
|
1816
|
+
e.g. ``Fossil`` — land in ``flowGroup``), unioned across folders;
|
|
1817
|
+
* :meth:`_accumulate_dispatch_std` runs
|
|
1818
|
+
:func:`~flextool.scenario_comparison.dispatch_data.prepare_dispatch_data`
|
|
1819
|
+
per dispatch node_group and records a per-entity std dev (max across
|
|
1820
|
+
node_groups / scenarios) used to order newly-discovered entries.
|
|
1821
|
+
|
|
1822
|
+
The ``node`` class is intentionally NOT discovered here:
|
|
1823
|
+
``discover_dispatch_entities`` omits nodes (node colors are
|
|
1824
|
+
dataset-coupled), so the node tab stays file-driven.
|
|
1825
|
+
|
|
1826
|
+
Robust by construction: a missing ``output_parquet``, empty / partial
|
|
1827
|
+
scenario folders, unreadable parquet, a scenario with no dispatch
|
|
1828
|
+
groups, and per-node_group prepare failures are all skipped — never
|
|
1829
|
+
raising. Returns ``({class -> names}, {entity -> std})`` with empty
|
|
1830
|
+
sets/map when nothing is discoverable.
|
|
1831
|
+
"""
|
|
1832
|
+
parquet_dir = self._settings_path.parent / "output_parquet"
|
|
1833
|
+
per_class: dict[str, set[str]] = {
|
|
1834
|
+
"nodeGroup": set(),
|
|
1835
|
+
"flowGroup": set(),
|
|
1836
|
+
"unit": set(),
|
|
1837
|
+
"connection": set(),
|
|
1838
|
+
}
|
|
1839
|
+
std_by_entity: dict[str, float] = {}
|
|
1840
|
+
if not parquet_dir.is_dir():
|
|
1841
|
+
return per_class, std_by_entity
|
|
1842
|
+
|
|
1843
|
+
from flextool.scenario_comparison.config_builder import (
|
|
1844
|
+
discover_dispatch_entities,
|
|
1845
|
+
)
|
|
1846
|
+
from flextool.scenario_comparison.dispatch_data import (
|
|
1847
|
+
load_dispatch_bundle,
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
for scen_dir in sorted(parquet_dir.iterdir()):
|
|
1851
|
+
if not scen_dir.is_dir() or scen_dir.name.startswith("_"):
|
|
1852
|
+
continue
|
|
1853
|
+
scenario = scen_dir.name
|
|
1854
|
+
try:
|
|
1855
|
+
bundle = load_dispatch_bundle(parquet_dir, scenario)
|
|
1856
|
+
except Exception as exc: # unreadable / partial parquet bundle
|
|
1857
|
+
logger.warning(
|
|
1858
|
+
"Cannot load dispatch bundle for %s: %s", scenario, exc,
|
|
1859
|
+
)
|
|
1860
|
+
continue
|
|
1861
|
+
if bundle is None:
|
|
1862
|
+
continue
|
|
1863
|
+
mappings, results = bundle
|
|
1864
|
+
try:
|
|
1865
|
+
discovered = discover_dispatch_entities(mappings, [])
|
|
1866
|
+
except Exception as exc:
|
|
1867
|
+
logger.warning(
|
|
1868
|
+
"discover_dispatch_entities failed for %s: %s",
|
|
1869
|
+
scenario, exc,
|
|
1870
|
+
)
|
|
1871
|
+
discovered = {}
|
|
1872
|
+
for cls in per_class:
|
|
1873
|
+
per_class[cls].update(discovered.get(cls, []))
|
|
1874
|
+
self._accumulate_dispatch_std(
|
|
1875
|
+
mappings, results, scenario, std_by_entity,
|
|
1876
|
+
)
|
|
1877
|
+
return per_class, std_by_entity
|
|
1878
|
+
|
|
1879
|
+
def _accumulate_dispatch_std(
|
|
1880
|
+
self, mappings, results, scenario: str,
|
|
1881
|
+
std_by_entity: dict[str, float],
|
|
1882
|
+
) -> None:
|
|
1883
|
+
"""Fold per-entity dispatch std devs from one scenario into the map.
|
|
1884
|
+
|
|
1885
|
+
For each dispatch node_group (the ``group`` values of
|
|
1886
|
+
``mappings.dispatch_groups``) the scenario's dispatch DataFrame is
|
|
1887
|
+
prepared exactly as the plots build it (``config_order=None``). Each
|
|
1888
|
+
column's std dev (``series.std()``) is attributed to the base entity
|
|
1889
|
+
name — any ``_pos`` / ``_neg`` split suffix stripped — and accumulated
|
|
1890
|
+
as the MAX across occurrences (node_groups / scenarios) so a per-entity
|
|
1891
|
+
value is stable. ``NaN`` std devs (degenerate single-point series) and
|
|
1892
|
+
any prepare failure are skipped, never raising.
|
|
1893
|
+
"""
|
|
1894
|
+
dispatch_groups = getattr(mappings, "dispatch_groups", None)
|
|
1895
|
+
if (
|
|
1896
|
+
dispatch_groups is None
|
|
1897
|
+
or dispatch_groups.empty
|
|
1898
|
+
or "group" not in dispatch_groups.columns
|
|
1899
|
+
):
|
|
1900
|
+
return
|
|
1901
|
+
|
|
1902
|
+
from flextool.scenario_comparison.dispatch_data import (
|
|
1903
|
+
prepare_dispatch_data,
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
for node_group in dispatch_groups["group"].unique():
|
|
1907
|
+
try:
|
|
1908
|
+
df_dispatch, _ = prepare_dispatch_data(
|
|
1909
|
+
results, mappings, scenario, str(node_group),
|
|
1910
|
+
config_order=None,
|
|
1911
|
+
)
|
|
1912
|
+
except Exception as exc:
|
|
1913
|
+
logger.warning(
|
|
1914
|
+
"prepare_dispatch_data failed (%s / %s): %s",
|
|
1915
|
+
scenario, node_group, exc,
|
|
1916
|
+
)
|
|
1917
|
+
continue
|
|
1918
|
+
if df_dispatch is None or df_dispatch.empty:
|
|
1919
|
+
continue
|
|
1920
|
+
for col in df_dispatch.columns:
|
|
1921
|
+
base = str(col)
|
|
1922
|
+
for suffix in ("_pos", "_neg"):
|
|
1923
|
+
if base.endswith(suffix):
|
|
1924
|
+
base = base[: -len(suffix)]
|
|
1925
|
+
break
|
|
1926
|
+
try:
|
|
1927
|
+
std = float(df_dispatch[col].std())
|
|
1928
|
+
except (TypeError, ValueError):
|
|
1929
|
+
continue
|
|
1930
|
+
if std != std: # NaN
|
|
1931
|
+
continue
|
|
1932
|
+
prev = std_by_entity.get(base)
|
|
1933
|
+
if prev is None or std > prev:
|
|
1934
|
+
std_by_entity[base] = std
|
|
1935
|
+
|
|
1936
|
+
def _order_new_names_by_std(
|
|
1937
|
+
self, new_names, std_by_entity: dict[str, float],
|
|
1938
|
+
) -> list[str]:
|
|
1939
|
+
"""Order newly-discovered entity names by std dev (one-time default).
|
|
1940
|
+
|
|
1941
|
+
Matches ``_order_dispatch_columns``' remaining / std-dev buckets, which
|
|
1942
|
+
sort ASCENDING by std dev with the name as a deterministic secondary
|
|
1943
|
+
key (``sorted(..., key=lambda c: (col_std.get(c, 0), c))``), so the
|
|
1944
|
+
persisted order of new entries equals the stacking order the dispatch
|
|
1945
|
+
render would have produced. Names with no computed std dev (not a
|
|
1946
|
+
dispatch entity, or the DB fallback path where no std is available)
|
|
1947
|
+
sort LAST, by name.
|
|
1948
|
+
"""
|
|
1949
|
+
def key(name: str):
|
|
1950
|
+
std = std_by_entity.get(name)
|
|
1951
|
+
if std is None:
|
|
1952
|
+
return (1, 0.0, name) # no std → after std-bearing, by name
|
|
1953
|
+
return (0, std, name)
|
|
1954
|
+
|
|
1955
|
+
return sorted(new_names, key=key)
|
|
1956
|
+
|
|
1957
|
+
def _discover_all_entities(
|
|
1958
|
+
self,
|
|
1959
|
+
) -> tuple[dict[str, set[str]], dict[str, float]]:
|
|
1960
|
+
"""Per-class entity names + per-entity std dev for the on-open merge.
|
|
1961
|
+
|
|
1962
|
+
Source of truth for the add-only on-open merge
|
|
1963
|
+
(:meth:`_merge_entities_from_sources`), so the discovery policy lives
|
|
1964
|
+
in one place.
|
|
1965
|
+
|
|
1966
|
+
* **PRIMARY** — dispatch entities discovered from the solved
|
|
1967
|
+
``output_parquet`` (:meth:`_discover_dispatch_sources`). This is
|
|
1968
|
+
what the plots actually draw, and it carries the std devs used to
|
|
1969
|
+
order new entries. Node is not covered here (file-driven).
|
|
1970
|
+
* **FALLBACK** — only when there is NO dispatch output to discover
|
|
1971
|
+
from (e.g. the pre-solve PNG-batch dialog on a project that hasn't
|
|
1972
|
+
solved yet) is the input DB consulted (:meth:`_discover_input_dbs`
|
|
1973
|
+
+ :meth:`_fetch_entity_union`). The DB fallback yields no std devs
|
|
1974
|
+
(new entries then order by name).
|
|
1975
|
+
|
|
1976
|
+
Returns ``({class -> set of names}, {entity -> std})``. Never raises
|
|
1977
|
+
for a missing ``output_parquet`` or DB (both contribute nothing).
|
|
1978
|
+
"""
|
|
1979
|
+
per_class, std_by_entity = self._discover_dispatch_sources()
|
|
1980
|
+
if any(per_class.values()):
|
|
1981
|
+
return per_class, std_by_entity
|
|
1982
|
+
# Fallback: input DB(s). No std devs → new entries order by name.
|
|
1983
|
+
return self._fetch_entity_union(self._discover_input_dbs()), {}
|
|
1984
|
+
|
|
1985
|
+
def _merge_entities_from_sources(self) -> None:
|
|
1986
|
+
"""Additively seed entities from the solved dispatch output (DB fallback).
|
|
1987
|
+
|
|
1988
|
+
Called on open so the entity tabs are populated immediately (fixes
|
|
1989
|
+
the symptom where entities only appeared after a manual "Refresh",
|
|
1990
|
+
and where output-only aggregates like ``Fossil`` were never listable).
|
|
1991
|
+
Discovery is parquet-first with a DB fallback
|
|
1992
|
+
(:meth:`_discover_all_entities`). **Add-only**: newly discovered
|
|
1993
|
+
names (per class) are appended, ORDERED among themselves by std dev
|
|
1994
|
+
(:meth:`_order_new_names_by_std` — a one-time default matching the
|
|
1995
|
+
dispatch render's stacking order) with a default palette color
|
|
1996
|
+
(:func:`assign_palette_colors`); existing entries — including any
|
|
1997
|
+
edited color and the current order — are never touched, and nothing
|
|
1998
|
+
is pruned. Node is not seeded from parquet (file-driven; see
|
|
1999
|
+
:meth:`_discover_dispatch_sources`).
|
|
2000
|
+
|
|
2001
|
+
Mutates ``self._data['entities']`` in memory only and records NO undo
|
|
2002
|
+
step; Cancel reverts via the on-disk text snapshot, exactly like
|
|
2003
|
+
:meth:`_merge_scenarios_from_folders`. Any discovery failure (e.g. a
|
|
2004
|
+
corrupt input DB) is logged and skipped so opening the picker is never
|
|
2005
|
+
broken.
|
|
2006
|
+
"""
|
|
2007
|
+
try:
|
|
2008
|
+
discovered, std_by_entity = self._discover_all_entities()
|
|
2009
|
+
except Exception as exc: # never let discovery break the window open
|
|
2010
|
+
logger.warning("Entity discovery on open failed: %s", exc)
|
|
2011
|
+
return
|
|
2012
|
+
if not any(discovered.values()):
|
|
2013
|
+
return
|
|
2014
|
+
|
|
2015
|
+
from flextool.scenario_comparison.config_builder import (
|
|
2016
|
+
assign_palette_colors,
|
|
2017
|
+
)
|
|
2018
|
+
|
|
2019
|
+
entities = self._data.get("entities")
|
|
2020
|
+
entities = entities if isinstance(entities, dict) else {}
|
|
2021
|
+
changed = False
|
|
2022
|
+
for cls, names in discovered.items():
|
|
2023
|
+
if not names:
|
|
2024
|
+
continue
|
|
2025
|
+
section = entities.get(cls)
|
|
2026
|
+
section = section if isinstance(section, dict) else {}
|
|
2027
|
+
# Add-only: append names not already present, ordered by std dev;
|
|
2028
|
+
# keep existing entries' order + values untouched.
|
|
2029
|
+
new_names = self._order_new_names_by_std(
|
|
2030
|
+
[n for n in names if n not in section], std_by_entity,
|
|
2031
|
+
)
|
|
2032
|
+
if not new_names:
|
|
2033
|
+
continue
|
|
2034
|
+
for name, color in assign_palette_colors(new_names).items():
|
|
2035
|
+
section[name] = color
|
|
2036
|
+
entities[cls] = section
|
|
2037
|
+
changed = True
|
|
2038
|
+
if changed:
|
|
2039
|
+
self._data["entities"] = entities
|
|
2040
|
+
|
|
2041
|
+
def _compute_dispatch_flowgroup_aggregators(self) -> set[str]:
|
|
2042
|
+
"""flowGroups that are dispatch aggregators (for the display filter).
|
|
2043
|
+
|
|
2044
|
+
The set is the UNION of:
|
|
2045
|
+
|
|
2046
|
+
* the parquet ``group_aggregate`` flowGroup names discovered from the
|
|
2047
|
+
solved ``output_parquet`` (:meth:`_discover_dispatch_sources` —
|
|
2048
|
+
exactly the aggregates the dispatch plots draw, e.g. ``Fossil``), and
|
|
2049
|
+
* every input-DB flowGroup whose ``flow_aggregator`` parameter is
|
|
2050
|
+
``dispatch_plots_only`` or ``both``
|
|
2051
|
+
(:func:`~flextool.scenario_comparison.input_entity_colors.fetch_flow_aggregator_flowgroups`,
|
|
2052
|
+
one DB open each over :meth:`_discover_input_dbs`).
|
|
2053
|
+
|
|
2054
|
+
Each source is individually guarded so one failing does not lose the
|
|
2055
|
+
other; a total failure yields an empty set, which
|
|
2056
|
+
:meth:`_add_category` treats as "do not filter" (show all flowGroups).
|
|
2057
|
+
"""
|
|
2058
|
+
aggregators: set[str] = set()
|
|
2059
|
+
try:
|
|
2060
|
+
per_class, _std = self._discover_dispatch_sources()
|
|
2061
|
+
aggregators |= set(per_class.get("flowGroup", set()))
|
|
2062
|
+
except Exception as exc: # parquet discovery must not break the filter
|
|
2063
|
+
logger.warning("Dispatch flowGroup discovery failed: %s", exc)
|
|
2064
|
+
|
|
2065
|
+
from flextool.scenario_comparison.input_entity_colors import (
|
|
2066
|
+
fetch_flow_aggregator_flowgroups,
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
for url in self._discover_input_dbs():
|
|
2070
|
+
try:
|
|
2071
|
+
aggregators |= fetch_flow_aggregator_flowgroups(url)
|
|
2072
|
+
except Exception as exc: # per-DB read must not break the filter
|
|
2073
|
+
logger.warning(
|
|
2074
|
+
"flow_aggregator read failed for %s: %s", url, exc,
|
|
2075
|
+
)
|
|
2076
|
+
return aggregators
|
|
2077
|
+
|
|
2078
|
+
def _fetch_entity_union(self, db_urls: list[str]) -> dict[str, set[str]]:
|
|
2079
|
+
"""Union per-class entity names across every input DB (one open each).
|
|
2080
|
+
|
|
2081
|
+
Reuses
|
|
2082
|
+
:func:`flextool.scenario_comparison.input_entity_colors.fetch_entities_by_class`
|
|
2083
|
+
— one :class:`DatabaseMapping` open per DB — and unions the returned
|
|
2084
|
+
per-class name sets across DBs.
|
|
2085
|
+
"""
|
|
2086
|
+
from flextool.scenario_comparison.input_entity_colors import (
|
|
2087
|
+
RELEVANT_ENTITY_CLASSES,
|
|
2088
|
+
fetch_entities_by_class,
|
|
2089
|
+
)
|
|
2090
|
+
|
|
2091
|
+
union: dict[str, set[str]] = {
|
|
2092
|
+
cls: set() for cls in RELEVANT_ENTITY_CLASSES
|
|
2093
|
+
}
|
|
2094
|
+
for url in db_urls:
|
|
2095
|
+
per_db = fetch_entities_by_class(url)
|
|
2096
|
+
for cls, names in per_db.items():
|
|
2097
|
+
union[cls].update(names)
|
|
2098
|
+
return union
|
|
2099
|
+
|
|
2100
|
+
# NOTE: there is deliberately no "Refresh from DB" action any more. The
|
|
2101
|
+
# on-open discovery (:meth:`_merge_entities_from_sources`) already seeds
|
|
2102
|
+
# every listable entity add-only; the manual add+prune refresh was
|
|
2103
|
+
# dropped along with its button. Add-only leaves at most a few stale
|
|
2104
|
+
# entries in the file, which are harmless (an unused color/order key never
|
|
2105
|
+
# drawn), so nothing prunes them — the simplicity is worth it.
|
|
2106
|
+
|
|
2107
|
+
# ── Live update (debounced) ───────────────────────────────────
|
|
2108
|
+
def _schedule_live_update(self) -> None:
|
|
2109
|
+
"""Debounce a live write + re-render after a user mutation.
|
|
2110
|
+
|
|
2111
|
+
Cancels any pending update and schedules a fresh one, so a burst of
|
|
2112
|
+
edits (e.g. a color-drag committed through several handler calls)
|
|
2113
|
+
collapses into a single write once the changes settle. Guarded
|
|
2114
|
+
against a destroyed window (the ``after`` call raises then).
|
|
2115
|
+
"""
|
|
2116
|
+
if self._live_after_id is not None:
|
|
2117
|
+
try:
|
|
2118
|
+
self.after_cancel(self._live_after_id)
|
|
2119
|
+
except tk.TclError:
|
|
2120
|
+
pass
|
|
2121
|
+
self._live_after_id = None
|
|
2122
|
+
try:
|
|
2123
|
+
self._live_after_id = self.after(
|
|
2124
|
+
self._LIVE_UPDATE_MS, self._flush_live_update,
|
|
2125
|
+
)
|
|
2126
|
+
except tk.TclError:
|
|
2127
|
+
self._live_after_id = None
|
|
2128
|
+
|
|
2129
|
+
def _flush_live_update(self) -> None:
|
|
2130
|
+
"""Write the working dict to disk and re-render the live preview.
|
|
2131
|
+
|
|
2132
|
+
This is the debounced live commit: it never touches
|
|
2133
|
+
``self._original_text`` (the on-open baseline), so a later Cancel can
|
|
2134
|
+
still revert every change made since the dialog opened.
|
|
2135
|
+
"""
|
|
2136
|
+
self._live_after_id = None
|
|
2137
|
+
self._write()
|
|
2138
|
+
if self._on_apply is not None:
|
|
2139
|
+
self._on_apply()
|
|
2140
|
+
|
|
2141
|
+
# ── Buttons ───────────────────────────────────────────────────
|
|
2142
|
+
def _on_close(self) -> None:
|
|
2143
|
+
"""Flush any pending live write, then close — KEEPING all changes.
|
|
2144
|
+
|
|
2145
|
+
Bound to the Close button, the window ``X``, and Escape. Because
|
|
2146
|
+
edits are written live, closing must not discard them: cancel the
|
|
2147
|
+
pending debounce, write the final in-memory ``self._data`` (including
|
|
2148
|
+
entities discovered on open), re-render, and destroy.
|
|
2149
|
+
"""
|
|
2150
|
+
if self._live_after_id is not None:
|
|
2151
|
+
try:
|
|
2152
|
+
self.after_cancel(self._live_after_id)
|
|
2153
|
+
except tk.TclError:
|
|
2154
|
+
pass
|
|
2155
|
+
self._live_after_id = None
|
|
2156
|
+
self._write()
|
|
2157
|
+
if self._on_apply is not None:
|
|
2158
|
+
self._on_apply()
|
|
2159
|
+
self.destroy()
|
|
2160
|
+
|
|
2161
|
+
def _on_cancel(self) -> None:
|
|
2162
|
+
"""Revert to the on-open file text, revert the preview, and close.
|
|
2163
|
+
|
|
2164
|
+
Cancels the pending live write, restores the byte-for-byte on-open
|
|
2165
|
+
text (undoing every live write made since the dialog opened), and
|
|
2166
|
+
re-renders so the preview matches.
|
|
2167
|
+
"""
|
|
2168
|
+
if self._live_after_id is not None:
|
|
2169
|
+
try:
|
|
2170
|
+
self.after_cancel(self._live_after_id)
|
|
2171
|
+
except tk.TclError:
|
|
2172
|
+
pass
|
|
2173
|
+
self._live_after_id = None
|
|
2174
|
+
try:
|
|
2175
|
+
self._settings_path.write_text(
|
|
2176
|
+
self._original_text, encoding="utf-8",
|
|
2177
|
+
)
|
|
2178
|
+
except OSError as exc:
|
|
2179
|
+
logger.warning(
|
|
2180
|
+
"Cannot restore %s on cancel: %s", self._settings_path, exc,
|
|
2181
|
+
)
|
|
2182
|
+
if self._on_apply is not None:
|
|
2183
|
+
self._on_apply()
|
|
2184
|
+
self.destroy()
|