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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. 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()