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,1377 @@
1
+ """Non-modal Toplevel window for managing FlexTool scenario executions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import tkinter as tk
8
+ from tkinter import ttk, messagebox
9
+
10
+ from flextool.gui.execution_manager import ExecutionManager, JobStatus, JobType
11
+ from flextool.gui.hover_tooltip import attach_tooltip
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Status icons for the scenarios list.
16
+ # Restricted to glyphs that render in the default Windows Tk fonts: the
17
+ # check/ballot marks (U+2713/2717) and the Geometric Shapes block (U+25xx).
18
+ # Emoji-range/Math glyphs (U+23F3 hourglass, U+2610 ballot box) rendered as
19
+ # missing-glyph boxes on Windows, so RUNNING/PENDING use Geometric Shapes.
20
+ _STATUS_ICONS: dict[JobStatus, str] = {
21
+ JobStatus.SUCCESS: "\u2713", # ✓
22
+ JobStatus.FAILED: "\u2717", # ✗
23
+ JobStatus.RUNNING: "\u25b6", # running / active
24
+ JobStatus.PENDING: "\u25a1", # queued / empty square
25
+ JobStatus.KILLED: "\u2717", # ✗
26
+ }
27
+
28
+
29
+ def _format_status_text(status: dict) -> str:
30
+ """Format the dispatcher status dict as a single-line label."""
31
+ base = (
32
+ f"{status['running']}/{status['max_threads']} threads | "
33
+ f"{status['used_gb']:.1f}/{status['total_gb']:.1f} GB"
34
+ )
35
+ if not status.get('memory_guard', True):
36
+ base += " | Memory guard OFF"
37
+ if status['pending'] > 0:
38
+ if status['thread_limited']:
39
+ base += " | Thread limited"
40
+ elif status['memory_limited']:
41
+ base += " | Memory limited"
42
+ return base
43
+
44
+
45
+ def _limit_status_color(status: dict, theme: str) -> str | None:
46
+ """Return a foreground colour when pending jobs are being held back.
47
+
48
+ ``theme`` is the user's selected theme ("dark" / "light" / "os"); "os"
49
+ is treated as dark to match ``main_window``'s sv_ttk fallback. Returns
50
+ ``None`` when no limit is active so the caller can clear the override.
51
+ """
52
+ # A disabled memory guard is a standing safety warning, independent of
53
+ # whether any job is currently being held back.
54
+ guard_off = not status.get('memory_guard', True)
55
+ held = status.get('pending', 0) > 0 and (
56
+ status.get('thread_limited') or status.get('memory_limited')
57
+ )
58
+ if not (guard_off or held):
59
+ return None
60
+ return "#c62828" if theme == "light" else "#ff6b6b"
61
+
62
+
63
+ class ExecutionWindow(tk.Toplevel):
64
+ """Non-modal window for monitoring and controlling scenario executions.
65
+
66
+ This window coexists with the main window -- it never calls
67
+ ``grab_set()`` or ``wait_window()``.
68
+ """
69
+
70
+ _FORCE_TIP = (
71
+ "Start the selected queued scenario(s) even when the queue says\n"
72
+ "\"Memory limited\". The memory estimate is a precaution based on\n"
73
+ "earlier runs and can be too conservative — forcing tells FlexTool\n"
74
+ "to admit the job anyway, ahead of the reserve check.\n"
75
+ "\n"
76
+ "Click again to cancel the force flag while the job is still queued.\n"
77
+ "\n"
78
+ "Forcing only bypasses the start-time reserve check; it does not\n"
79
+ "raise the parallel-job limit, and the memory watchdog still runs —\n"
80
+ "if the system genuinely runs out of memory a forced job can still\n"
81
+ "be killed. The normal memory limiting resumes automatically once\n"
82
+ "the forced scenarios finish."
83
+ )
84
+
85
+ def __init__(
86
+ self,
87
+ parent: tk.Tk,
88
+ execution_mgr: ExecutionManager,
89
+ global_settings=None,
90
+ ) -> None:
91
+ super().__init__(parent)
92
+ self.title("Execution Jobs")
93
+
94
+ self._mgr = execution_mgr
95
+ self._global_settings = global_settings
96
+ self._viewed_job_id: int | None = None
97
+ # Track how many stdout lines we have already displayed per job
98
+ # so that we only append new lines on each poll cycle.
99
+ self._displayed_counts: dict[int, int] = {}
100
+ # Guard flag to suppress <<TreeviewSelect>> events fired by
101
+ # programmatic selection_set inside _refresh_job_list.
102
+ self._refreshing_list: bool = False
103
+ # Coalesce the per-stdout-line refresh requests into at most one
104
+ # rebuild per idle cycle. Without this, a streaming job floods the
105
+ # event loop with after() callbacks and starves mouse/keyboard input
106
+ # (the log becomes unselectable / uncopyable while output streams).
107
+ self._refresh_scheduled: bool = False
108
+ # Log colouring: whether the lines currently streaming belong to a
109
+ # HiGHS solver-output block (greyed until the next phase-progress
110
+ # row). Reset whenever the viewed job changes (log re-rendered from
111
+ # scratch), so the state machine stays in sync with the text widget.
112
+ self._highs_block_active: bool = False
113
+
114
+ # ── Font metrics for DPI-aware sizing ────────────────────────
115
+ from flextool.gui.ui_metrics import get_metrics
116
+ _metrics = get_metrics(self)
117
+ cw: int = _metrics.cw
118
+ lh: int = _metrics.lh
119
+ self._char_width = cw
120
+ # Use the named-font string so live size changes reach tk.Text.
121
+ self._mono_font = "TkFixedFont"
122
+
123
+ # ── Window sizing & positioning ────────────────────────────────
124
+ self._line_height = lh
125
+ self.minsize(cw * 70, lh * 20)
126
+
127
+ # Get main window position and dimensions
128
+ # Note: parent is the MainWindow (tk.Tk instance)
129
+ # We need to call update_idletasks() on parent to get accurate geometry
130
+ parent.update_idletasks()
131
+ main_x = parent.winfo_x()
132
+ main_y = parent.winfo_y()
133
+ main_w = parent.winfo_width()
134
+ screen_w = parent.winfo_screenwidth()
135
+ screen_h = parent.winfo_screenheight()
136
+
137
+ # Confine the default placement to the monitor the main window is on.
138
+ # winfo_screenwidth/height span ALL monitors, so sizing to them
139
+ # stretches this window across every display in a multi-monitor
140
+ # setup. Park this window on the main window's monitor first, then
141
+ # probe that monitor's work area (see current_monitor_bounds).
142
+ from flextool.gui.ui_metrics import current_monitor_bounds
143
+ self.geometry(f"+{main_x}+{main_y}")
144
+ bounds = current_monitor_bounds(self)
145
+ # Reserve taskbar/dock space: screeninfo reports the full monitor
146
+ # rectangle, so the margin is subtracted here for both paths.
147
+ taskbar_margin = self._line_height * 4 if hasattr(self, '_line_height') else 80
148
+ if bounds is not None:
149
+ mon_x, mon_y, mon_w, mon_h = bounds
150
+ usable_h = mon_h - taskbar_margin
151
+ else:
152
+ # Fallback: no per-monitor info — use the virtual desktop
153
+ # (no worse than the old behaviour).
154
+ mon_x, mon_y, mon_w, mon_h = 0, 0, screen_w, screen_h
155
+ usable_h = mon_h - taskbar_margin
156
+
157
+ # Tile to the right of the main window only when the right-hand gap is
158
+ # genuinely roomy — at least half the monitor. The old bar (cw*70, the
159
+ # bare-minimum window width) tiled into a cramped sliver whenever the
160
+ # main window left any gap at all; on a single 1920-wide display that
161
+ # right strip is too narrow to be useful. Below the bar (the common
162
+ # case, incl. a maximized main window that would otherwise push this
163
+ # off the right edge into an unrecoverable black box) we overlap the
164
+ # main window instead, leaving the leftmost ~10% uncovered so the menu
165
+ # stays reachable; that position is always fully on-screen.
166
+ mon_right = mon_x + mon_w
167
+ exec_x = main_x + main_w
168
+ right_room = mon_right - exec_x
169
+ if exec_x >= mon_x and right_room >= mon_w // 2:
170
+ self.geometry(f"{right_room}x{usable_h}+{exec_x}+{mon_y}")
171
+ else:
172
+ left_gap = mon_x + int(mon_w * 0.10)
173
+ exec_w = mon_right - left_gap
174
+ self.geometry(f"{exec_w}x{usable_h}+{left_gap}+{mon_y}")
175
+
176
+ # Restore the saved geometry for the current monitor configuration
177
+ # (the user's last manual placement on this layout). Overrides the
178
+ # default placement above when present.
179
+ if self._global_settings is not None:
180
+ from flextool.gui.ui_metrics import monitor_signature, resolve_saved_geometry
181
+ clamped = resolve_saved_geometry(
182
+ self._global_settings.exec_jobs_geometry,
183
+ monitor_signature(self),
184
+ self._global_settings.exec_jobs_layout_cw,
185
+ cw,
186
+ screen_w, screen_h,
187
+ min_w=cw * 70, min_h=lh * 20,
188
+ )
189
+ if clamped is not None:
190
+ try:
191
+ self.geometry(clamped)
192
+ except tk.TclError:
193
+ pass
194
+
195
+ self.columnconfigure(0, weight=1)
196
+ self.rowconfigure(1, weight=1)
197
+
198
+ # ── Top row: max parallel executions ─────────────────────────
199
+ top_frame = ttk.Frame(self, padding=(10, 5))
200
+ top_frame.grid(row=0, column=0, sticky="e")
201
+
202
+ ttk.Label(top_frame, text="Max. parallel executions:").pack(side="left", padx=(0, 5))
203
+
204
+ cpu = os.cpu_count() or 2
205
+ # ProjectSettings.max_workers is the source of truth. The
206
+ # ExecutionManager already seeds itself from it at construction
207
+ # time, so just clamp into the spinbox range.
208
+ initial = max(1, min(cpu * 2, self._mgr.max_workers))
209
+ self._max_workers_var = tk.IntVar(value=initial)
210
+ self._max_workers_spin = ttk.Spinbox(
211
+ top_frame,
212
+ from_=1,
213
+ to=cpu * 2,
214
+ textvariable=self._max_workers_var,
215
+ width=4,
216
+ command=self._on_max_workers_changed,
217
+ )
218
+ self._max_workers_spin.pack(side="left")
219
+ # Sync on Enter key and focus-out (command only fires on arrow clicks)
220
+ self._max_workers_spin.bind("<Return>", lambda e: self._on_max_workers_changed())
221
+ self._max_workers_spin.bind("<FocusOut>", lambda e: self._on_max_workers_changed())
222
+ # Also sync the spinbox to the current manager value
223
+ self._max_workers_var.set(self._mgr.max_workers)
224
+
225
+ # Cores per job
226
+ ttk.Label(top_frame, text=" Cores per job:").pack(side="left", padx=(15, 5))
227
+
228
+ limits = self._mgr.execution_limits
229
+ initial_cores = limits.max_cores_per_job if limits.max_cores_per_job > 0 else 1
230
+ self._cores_per_job_var = tk.IntVar(value=initial_cores)
231
+ self._cores_per_job_spin = ttk.Spinbox(
232
+ top_frame,
233
+ from_=1,
234
+ to=cpu * 2,
235
+ textvariable=self._cores_per_job_var,
236
+ width=4,
237
+ command=self._on_cores_per_job_changed,
238
+ )
239
+ self._cores_per_job_spin.pack(side="left")
240
+ self._cores_per_job_spin.bind("<Return>", lambda e: self._on_cores_per_job_changed())
241
+ self._cores_per_job_spin.bind("<FocusOut>", lambda e: self._on_cores_per_job_changed())
242
+
243
+ # Memory budget per job (soft target; used to pick a victim under global pressure)
244
+ mem_label = ttk.Label(top_frame, text=" Memory budget/job (GB, 0=auto):")
245
+ mem_label.pack(side="left", padx=(15, 5))
246
+
247
+ initial_cap = limits.memory_cap_per_job_gb
248
+ self._mem_cap_var = tk.DoubleVar(value=initial_cap)
249
+ self._mem_cap_spin = ttk.Spinbox(
250
+ top_frame,
251
+ from_=0.0,
252
+ to=4096.0,
253
+ increment=1.0,
254
+ textvariable=self._mem_cap_var,
255
+ width=6,
256
+ command=self._on_mem_cap_changed,
257
+ )
258
+ self._mem_cap_spin.pack(side="left")
259
+ self._mem_cap_spin.bind("<Return>", lambda e: self._on_mem_cap_changed())
260
+ self._mem_cap_spin.bind("<FocusOut>", lambda e: self._on_mem_cap_changed())
261
+
262
+ _budget_tip = (
263
+ "Default budget for each job. Soft target — jobs are allowed to\n"
264
+ "exceed it when the system has memory to spare. Only enforced\n"
265
+ "when free RAM drops below the reserve or swap grows past the\n"
266
+ "allowance: the watchdog then kills whichever running job is\n"
267
+ "most over its budget.\n"
268
+ "\n"
269
+ "0 = auto: after a scenario has been run successfully, its\n"
270
+ "measured peak (×1.5 safety margin) becomes its budget for the\n"
271
+ "next run, persisted in the project's settings.yaml. Scenarios\n"
272
+ "that haven't been run yet fall back to:\n"
273
+ " (system memory − reserve) ÷ max parallel jobs."
274
+ )
275
+ attach_tooltip(mem_label, _budget_tip)
276
+ attach_tooltip(self._mem_cap_spin, _budget_tip)
277
+
278
+ # Memory guard master switch (session-only; never persisted)
279
+ self._memory_guard_var = tk.BooleanVar(value=self._mgr.memory_guard_enabled)
280
+ self._memory_guard_chk = ttk.Checkbutton(
281
+ top_frame,
282
+ text=" Memory guard",
283
+ variable=self._memory_guard_var,
284
+ command=self._on_memory_guard_toggled,
285
+ )
286
+ self._memory_guard_chk.pack(side="left", padx=(15, 5))
287
+
288
+ _guard_tip = (
289
+ "Lets the watchdog KILL a running scenario when the system\n"
290
+ "genuinely runs out of memory (this session only — never saved;\n"
291
+ "back ON next time you open the project).\n"
292
+ "\n"
293
+ "ON (default): if free RAM drops below Min free RAM and swap\n"
294
+ "grows past Allow swap, the most over-budget run is killed to\n"
295
+ "keep the machine alive.\n"
296
+ "\n"
297
+ "OFF: nothing is ever killed for memory. This does NOT change\n"
298
+ "how many runs start — the memory limit on starting still\n"
299
+ "applies (Min free RAM stays in effect). To start a specific\n"
300
+ "queued scenario past that limit, use \"Force start selected\"\n"
301
+ "instead, as a deliberate per-job decision. Allow swap below\n"
302
+ "becomes non-binding (greyed out) since it only ever gated\n"
303
+ "killing.\n"
304
+ "\n"
305
+ "With the guard off the OS may still keep the machine alive by\n"
306
+ "swapping, but expect a considerable slow-down, and a true\n"
307
+ "runaway can OOM-crash FlexTool or other applications."
308
+ )
309
+ attach_tooltip(self._memory_guard_chk, _guard_tip)
310
+
311
+ # Min free RAM
312
+ min_free_label = ttk.Label(top_frame, text=" Min free RAM (GB):")
313
+ min_free_label.pack(side="left", padx=(15, 5))
314
+
315
+ initial_reserve = limits.system_reserve_gb
316
+ self._min_free_var = tk.DoubleVar(value=initial_reserve)
317
+ self._min_free_spin = ttk.Spinbox(
318
+ top_frame,
319
+ from_=0.0,
320
+ to=64.0,
321
+ increment=1.0,
322
+ textvariable=self._min_free_var,
323
+ width=5,
324
+ command=self._on_min_free_changed,
325
+ )
326
+ self._min_free_spin.pack(side="left")
327
+ self._min_free_spin.bind("<Return>", lambda e: self._on_min_free_changed())
328
+ self._min_free_spin.bind("<FocusOut>", lambda e: self._on_min_free_changed())
329
+
330
+ _min_free_tip = (
331
+ "Floor for system free memory. The watchdog only acts when\n"
332
+ "BOTH this floor is breached AND the swap allowance is\n"
333
+ "exhausted. As long as free RAM stays above this threshold,\n"
334
+ "no job is killed.\n"
335
+ "\n"
336
+ "Together with the swap allowance, this keeps FlexTool from\n"
337
+ "triggering a system-wide OOM that would take down other\n"
338
+ "applications (browser, editor, etc.)."
339
+ )
340
+ attach_tooltip(min_free_label, _min_free_tip)
341
+ attach_tooltip(self._min_free_spin, _min_free_tip)
342
+
343
+ # Allow swap
344
+ swap_label = self._swap_label = ttk.Label(top_frame, text=" Allow swap (GB):")
345
+ swap_label.pack(side="left", padx=(15, 5))
346
+
347
+ initial_swap = limits.swap_allowance_gb
348
+ self._swap_allow_var = tk.DoubleVar(value=initial_swap)
349
+ self._swap_allow_spin = ttk.Spinbox(
350
+ top_frame,
351
+ from_=0.0,
352
+ to=512.0,
353
+ increment=1.0,
354
+ textvariable=self._swap_allow_var,
355
+ width=5,
356
+ command=self._on_swap_allow_changed,
357
+ )
358
+ self._swap_allow_spin.pack(side="left")
359
+ self._swap_allow_spin.bind("<Return>", lambda e: self._on_swap_allow_changed())
360
+ self._swap_allow_spin.bind("<FocusOut>", lambda e: self._on_swap_allow_changed())
361
+
362
+ _swap_tip = (
363
+ "How much swap growth to tolerate while free RAM is below\n"
364
+ "the reserve. Measured as growth since FlexTool started\n"
365
+ "(pre-existing system swap is ignored).\n"
366
+ "\n"
367
+ "Plenty of free RAM ⇒ swap growth is harmless and never\n"
368
+ "triggers a kill. Only when BOTH free RAM dips below the\n"
369
+ "reserve AND swap growth has reached this allowance does\n"
370
+ "the watchdog kill the most-over-budget running job.\n"
371
+ "\n"
372
+ "0 = no swap headroom: kill as soon as free RAM dips\n"
373
+ "below the reserve. Allowing swap (>0) gives the system\n"
374
+ "room to absorb pressure while large models complete, but\n"
375
+ "they will run much slower because pages move between\n"
376
+ "RAM and disk."
377
+ )
378
+ attach_tooltip(swap_label, _swap_tip)
379
+ attach_tooltip(self._swap_allow_spin, _swap_tip)
380
+
381
+ # Grey out the reserve/swap controls if the guard starts off
382
+ # (carried over from a prior window in the same session).
383
+ self._update_guard_dependent_states()
384
+
385
+ # ── Horizontal PanedWindow for Jobs / Progress ─────────────────
386
+ self._paned = ttk.PanedWindow(self, orient="horizontal")
387
+ self._paned.grid(row=1, column=0, sticky="nsew", padx=10, pady=5)
388
+ # Don't let the Jobs pane shrink below this — ttk.PanedWindow
389
+ # with weight=0 on the left and weight=1 on the right will
390
+ # squeeze the Jobs pane to near-zero when the window is first
391
+ # resized narrower than the Progress pane's minimum and then
392
+ # widened again.
393
+ self._jobs_min_width: int = cw * 30
394
+ # First-open default: 3× the minimum, so the Jobs columns fit
395
+ # without horizontal scrolling. Overridden by the saved sash
396
+ # (exec_jobs_sash) when there is one.
397
+ self._jobs_default_width: int = cw * 90
398
+ self._paned.bind("<Configure>", self._enforce_jobs_min_width)
399
+
400
+ # ── Jobs list (left) ──────────────────────────────────────────
401
+ left_frame = ttk.LabelFrame(self._paned, text="Jobs", padding=5)
402
+ self._paned.add(left_frame, weight=0)
403
+ left_frame.rowconfigure(0, weight=1)
404
+ left_frame.columnconfigure(0, weight=1)
405
+
406
+ self._job_tree = ttk.Treeview(
407
+ left_frame,
408
+ columns=("status", "source", "scenario", "peak", "timestamp"),
409
+ show="headings",
410
+ selectmode="extended",
411
+ )
412
+ self._job_tree.heading("status", text="")
413
+ self._job_tree.heading("source", text="#")
414
+ self._job_tree.heading("scenario", text="Scenario")
415
+ self._job_tree.heading("peak", text="Peak GB")
416
+ self._job_tree.heading("timestamp", text="Timestamp")
417
+
418
+ self._job_tree.column("status", width=cw * 4, minwidth=cw * 4, stretch=False)
419
+ self._job_tree.column("source", width=cw * 4, minwidth=cw * 3, stretch=False)
420
+ self._job_tree.column("scenario", width=cw * 20, minwidth=cw * 10, stretch=True)
421
+ self._job_tree.column("peak", width=int(cw * 8.8), minwidth=int(cw * 8.8), stretch=False)
422
+ self._job_tree.column("timestamp", width=int(cw * 23.4), minwidth=int(cw * 23.4), stretch=False)
423
+
424
+ self._job_tree.grid(row=0, column=0, sticky="nsew")
425
+
426
+ job_scroll = ttk.Scrollbar(left_frame, orient="vertical", command=self._job_tree.yview)
427
+ job_scroll.grid(row=0, column=1, sticky="ns")
428
+ self._setup_autohide_scrollbar(self._job_tree, job_scroll)
429
+
430
+ self._job_tree.bind("<<TreeviewSelect>>", self._on_job_selected)
431
+ self._job_tree.bind("<B1-Motion>", self._on_drag_select)
432
+ # Right-click context menu (Button-2 on macOS, Button-3 elsewhere)
433
+ self._context_menu = tk.Menu(self._job_tree, tearoff=0)
434
+ self._job_tree.bind("<Button-3>", self._on_tree_context_menu)
435
+ self._job_tree.bind("<Button-2>", self._on_tree_context_menu)
436
+
437
+ # ── Progress panel (right) ───────────────────────────────────
438
+ right_frame = ttk.LabelFrame(self._paned, text="Progress", padding=5)
439
+ self._paned.add(right_frame, weight=1)
440
+ right_frame.rowconfigure(0, weight=1)
441
+ right_frame.columnconfigure(0, weight=1)
442
+
443
+ self._output_text = tk.Text(
444
+ right_frame,
445
+ wrap="none",
446
+ font=self._mono_font,
447
+ )
448
+ self._output_text.grid(row=0, column=0, sticky="nsew")
449
+ # Keep the widget editable (state='normal') so text selection and
450
+ # Ctrl+C work reliably on all platforms, but block keyboard input.
451
+ self._output_text.bind("<Key>", self._on_key_press)
452
+ self._configure_log_tags()
453
+
454
+ # Right-click context menu (Copy / Select all) on the log.
455
+ self._output_menu = tk.Menu(self._output_text, tearoff=0)
456
+ self._output_menu.add_command(label="Copy", command=self._on_copy_log)
457
+ self._output_menu.add_command(label="Select all", command=self._on_select_all_log)
458
+ self._output_text.bind("<Button-3>", self._show_output_menu)
459
+
460
+ out_vscroll = ttk.Scrollbar(right_frame, orient="vertical", command=self._output_text.yview)
461
+ self._output_text.configure(yscrollcommand=out_vscroll.set)
462
+ out_vscroll.grid(row=0, column=1, sticky="ns")
463
+
464
+ out_hscroll = ttk.Scrollbar(right_frame, orient="horizontal", command=self._output_text.xview)
465
+ self._output_text.configure(xscrollcommand=out_hscroll.set)
466
+ out_hscroll.grid(row=1, column=0, sticky="ew")
467
+
468
+ # ── Buttons row ──────────────────────────────────────────────
469
+ btn_frame = ttk.Frame(self, padding=(10, 5, 10, 10))
470
+ btn_frame.grid(row=2, column=0, sticky="ew")
471
+
472
+ col = 0
473
+ self._pause_btn = ttk.Button(
474
+ btn_frame, text="Pause executions", command=self._on_pause_toggle
475
+ )
476
+ self._pause_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
477
+
478
+ col += 1
479
+ move_frame = ttk.Frame(btn_frame)
480
+ move_frame.grid(row=0, column=col, rowspan=2, padx=(0, 10))
481
+
482
+ self._move_up_btn = ttk.Button(
483
+ move_frame, text="\u25b2", width=3, command=self._on_move_up
484
+ )
485
+ self._move_up_btn.grid(row=0, column=0, padx=(0, 2))
486
+ ttk.Label(move_frame, text="(PgUp)").grid(row=0, column=1, padx=(0, 4))
487
+
488
+ self._move_down_btn = ttk.Button(
489
+ move_frame, text="\u25bc", width=3, command=self._on_move_down
490
+ )
491
+ self._move_down_btn.grid(row=1, column=0, padx=(0, 2))
492
+ ttk.Label(move_frame, text="(PgDn)").grid(row=1, column=1, padx=(0, 4))
493
+
494
+ col += 1
495
+ self._force_btn = ttk.Button(
496
+ btn_frame, text="Force start selected", command=self._on_force_selected
497
+ )
498
+ self._force_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
499
+ attach_tooltip(self._force_btn, self._FORCE_TIP)
500
+
501
+ col += 1
502
+ self._kill_btn = ttk.Button(
503
+ btn_frame, text="Kill selected", command=self._on_kill_selected
504
+ )
505
+ self._kill_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
506
+
507
+ col += 1
508
+ self._remove_btn = ttk.Button(
509
+ btn_frame, text="Remove selected", command=self._on_remove_selected
510
+ )
511
+ self._remove_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
512
+
513
+ col += 1
514
+ self._kill_all_btn = ttk.Button(btn_frame, text="Kill all", command=self._on_kill_all)
515
+ self._kill_all_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
516
+
517
+ col += 1
518
+ self._status_label = ttk.Label(btn_frame, text="", anchor="center")
519
+ self._status_label.grid(row=0, column=col, rowspan=2, padx=15, sticky="ew")
520
+ btn_frame.columnconfigure(col, weight=1) # status label stretches
521
+
522
+ col += 1
523
+ self._copy_btn = ttk.Button(
524
+ btn_frame, text="Copy log text", command=self._on_copy_log
525
+ )
526
+ self._copy_btn.grid(row=0, column=col, rowspan=2, padx=(0, 10), sticky="ns")
527
+
528
+ col += 1
529
+ self._close_btn = ttk.Button(btn_frame, text="Close", command=self._on_close_attempt)
530
+ self._close_btn.grid(row=0, column=col, rowspan=2, sticky="ns")
531
+
532
+ # ── Keyboard shortcuts for move ──────────────────────────────
533
+ self.bind("<Prior>", lambda e: self._on_move_up())
534
+ self.bind("<Next>", lambda e: self._on_move_down())
535
+
536
+ # ── Window close handler ─────────────────────────────────────
537
+ self.protocol("WM_DELETE_WINDOW", self._on_close_attempt)
538
+
539
+ # ── Restore saved sash position (or apply default) ───────────
540
+ self.after(50, self._restore_sash)
541
+
542
+ # ── Initial population and start polling ─────────────────────
543
+ self._refresh_job_list()
544
+ self._update_button_states()
545
+ self._poll_updates()
546
+
547
+ # ------------------------------------------------------------------
548
+ # Auto-hide scrollbar helper
549
+ # ------------------------------------------------------------------
550
+
551
+ @staticmethod
552
+ def _setup_autohide_scrollbar(
553
+ tree: ttk.Treeview,
554
+ scrollbar: ttk.Scrollbar,
555
+ ) -> None:
556
+ """Configure *scrollbar* to appear only when *tree* content overflows."""
557
+ grid_info: dict = scrollbar.grid_info()
558
+
559
+ def _on_scroll_set(first: str, last: str) -> None:
560
+ scrollbar.set(first, last)
561
+ if float(first) <= 0.0 and float(last) >= 1.0:
562
+ scrollbar.grid_remove()
563
+ else:
564
+ scrollbar.grid(**grid_info)
565
+
566
+ tree.configure(yscrollcommand=_on_scroll_set)
567
+ scrollbar.grid_remove()
568
+
569
+ # ------------------------------------------------------------------
570
+ # Drag-to-select
571
+ # ------------------------------------------------------------------
572
+
573
+ def _on_drag_select(self, event: tk.Event) -> None: # type: ignore[type-arg]
574
+ """Extend selection to the item under the cursor during B1 drag."""
575
+ item = self._job_tree.identify_row(event.y)
576
+ if item:
577
+ self._job_tree.selection_add(item)
578
+
579
+ # ------------------------------------------------------------------
580
+ # Max workers
581
+ # ------------------------------------------------------------------
582
+
583
+ def _persist_project_settings(self, field_name: str) -> None:
584
+ """Write the project's settings.yaml after a spinbox edit."""
585
+ try:
586
+ from flextool.gui.settings_io import save_project_settings
587
+ save_project_settings(self._mgr.project_path, self._mgr.settings)
588
+ except Exception:
589
+ logger.warning(
590
+ "Could not persist %s to settings.yaml", field_name, exc_info=True,
591
+ )
592
+
593
+ def _on_max_workers_changed(self) -> None:
594
+ """Sync the spinbox value to the ExecutionManager and persist it."""
595
+ try:
596
+ val = self._max_workers_var.get()
597
+ except tk.TclError:
598
+ return
599
+ if val < 1:
600
+ val = 1
601
+ self._max_workers_var.set(val)
602
+ self._mgr.max_workers = val
603
+ self._mgr.settings.max_workers = val
604
+ self._persist_project_settings("max_workers")
605
+
606
+ def _on_cores_per_job_changed(self) -> None:
607
+ """Sync cores-per-job to the project settings."""
608
+ try:
609
+ val = self._cores_per_job_var.get()
610
+ except tk.TclError:
611
+ return
612
+ if val < 1:
613
+ val = 1
614
+ self._cores_per_job_var.set(val)
615
+ self._mgr.settings.execution_limits.max_cores_per_job = val
616
+ self._persist_project_settings("max_cores_per_job")
617
+
618
+ def _on_mem_cap_changed(self) -> None:
619
+ """Sync memory budget/job to the project settings."""
620
+ try:
621
+ val = float(self._mem_cap_var.get())
622
+ except (tk.TclError, ValueError):
623
+ return
624
+ if val < 0:
625
+ val = 0.0
626
+ self._mem_cap_var.set(val)
627
+ self._mgr.settings.execution_limits.memory_cap_per_job_gb = val
628
+ self._persist_project_settings("memory_cap_per_job_gb")
629
+
630
+ def _on_min_free_changed(self) -> None:
631
+ """Sync min-free-RAM threshold to the project settings."""
632
+ try:
633
+ val = float(self._min_free_var.get())
634
+ except (tk.TclError, ValueError):
635
+ return
636
+ if val < 0:
637
+ val = 0.0
638
+ self._min_free_var.set(val)
639
+ self._mgr.settings.execution_limits.system_reserve_gb = val
640
+ self._persist_project_settings("system_reserve_gb")
641
+
642
+ def _on_swap_allow_changed(self) -> None:
643
+ """Sync swap-allowance threshold to the project settings."""
644
+ try:
645
+ val = float(self._swap_allow_var.get())
646
+ except (tk.TclError, ValueError):
647
+ return
648
+ if val < 0:
649
+ val = 0.0
650
+ self._swap_allow_var.set(val)
651
+ self._mgr.settings.execution_limits.swap_allowance_gb = val
652
+ self._persist_project_settings("swap_allowance_gb")
653
+
654
+ # ------------------------------------------------------------------
655
+ # Job list display
656
+ # ------------------------------------------------------------------
657
+
658
+ def _refresh_job_list(self) -> None:
659
+ """Rebuild the job Treeview from the ExecutionManager's job list."""
660
+ jobs = self._mgr.get_jobs()
661
+
662
+ try:
663
+ status = self._mgr.get_execution_status()
664
+ theme = (
665
+ self._global_settings.theme
666
+ if self._global_settings is not None else "dark"
667
+ )
668
+ color = _limit_status_color(status, theme)
669
+ self._status_label.config(
670
+ text=_format_status_text(status),
671
+ foreground=color if color is not None else "",
672
+ )
673
+ except (tk.TclError, AttributeError):
674
+ pass # window may be tearing down
675
+
676
+ # Remember current selection so we can restore it
677
+ prev_selection = set(self._job_tree.selection())
678
+
679
+ # Configure tags for failed/killed rows (bright red) and forced
680
+ # pending rows (amber — they will bypass the memory-limit check).
681
+ self._job_tree.tag_configure("failed", foreground="#ff3333")
682
+ self._job_tree.tag_configure("forced", foreground="#e0a800")
683
+
684
+ # Suppress <<TreeviewSelect>> events while we rebuild the tree
685
+ self._refreshing_list = True
686
+ try:
687
+ # Clear tree
688
+ for item in self._job_tree.get_children():
689
+ self._job_tree.delete(item)
690
+
691
+ # Repopulate
692
+ restore_iids: list[str] = []
693
+ for job in jobs:
694
+ icon = _STATUS_ICONS.get(job.status, "?")
695
+ ts = job.finish_timestamp if job.status in (JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.KILLED) else ""
696
+ iid = str(job.job_id)
697
+
698
+ if job.job_type == JobType.SCENARIO:
699
+ source_col = str(job.source_number)
700
+ name_col = job.scenario_name
701
+ else:
702
+ source_col = ""
703
+ name_col = job.display_name
704
+
705
+ tags: tuple[str, ...] = ()
706
+ if job.status in (JobStatus.FAILED, JobStatus.KILLED):
707
+ tags = ("failed",)
708
+ elif job.status == JobStatus.PENDING and getattr(job, "force_start", False):
709
+ tags = ("forced",)
710
+
711
+ peak_col = f"{job.peak_rss_mb / 1024:.1f}" if job.peak_rss_mb > 0 else ""
712
+ if peak_col and getattr(job, 'killed_for_memory', False):
713
+ peak_col += " (!)"
714
+
715
+ self._job_tree.insert(
716
+ "",
717
+ "end",
718
+ iid=iid,
719
+ values=(icon, source_col, name_col, peak_col, ts),
720
+ tags=tags,
721
+ )
722
+ if iid in prev_selection:
723
+ restore_iids.append(iid)
724
+
725
+ # Restore selection
726
+ if restore_iids:
727
+ self._job_tree.selection_set(restore_iids)
728
+ else:
729
+ if prev_selection:
730
+ pass
731
+ finally:
732
+ self._refreshing_list = False
733
+
734
+ def _on_job_selected(self, _event: tk.Event) -> None: # type: ignore[type-arg]
735
+ """Handle selection change in the job tree.
736
+
737
+ With multi-select enabled, we show the log for the first selected
738
+ job in the progress panel.
739
+ """
740
+ # Ignore events triggered programmatically during list refresh
741
+ if self._refreshing_list:
742
+ return
743
+
744
+ selection = self._job_tree.selection()
745
+ if selection:
746
+ try:
747
+ first_id = int(selection[0])
748
+ except (ValueError, IndexError):
749
+ first_id = None
750
+ else:
751
+ first_id = None
752
+
753
+ if first_id == self._viewed_job_id:
754
+ return
755
+
756
+ self._viewed_job_id = first_id
757
+
758
+ # Switching to a different job: clear the text widget and reset its
759
+ # displayed count so all lines are re-inserted from scratch. The
760
+ # colour state machine restarts with the re-render.
761
+ self._output_text.delete("1.0", "end")
762
+ self._highs_block_active = False
763
+ if first_id is not None:
764
+ self._displayed_counts[first_id] = 0
765
+ self._update_output_display()
766
+
767
+ # ------------------------------------------------------------------
768
+ # Output display
769
+ # ------------------------------------------------------------------
770
+
771
+ # Keys that move the cursor / extend the selection without mutating the
772
+ # text — allowed through so Shift+arrow (and friends) select as usual.
773
+ _NAV_KEYSYMS = frozenset({
774
+ "Left", "Right", "Up", "Down", "Home", "End", "Prior", "Next",
775
+ "Shift_L", "Shift_R", "Control_L", "Control_R",
776
+ })
777
+
778
+ def _on_copy_log(self) -> None:
779
+ """Copy the current selection (or the whole log if nothing is
780
+ selected) to the clipboard."""
781
+ try:
782
+ text = self._output_text.get("sel.first", "sel.last")
783
+ except tk.TclError:
784
+ text = self._output_text.get("1.0", "end-1c")
785
+ if not text:
786
+ return
787
+ self.clipboard_clear()
788
+ self.clipboard_append(text)
789
+
790
+ def _on_select_all_log(self) -> None:
791
+ """Select the entire log and focus the widget."""
792
+ self._output_text.tag_add("sel", "1.0", "end-1c")
793
+ self._output_text.focus_set()
794
+
795
+ def _show_output_menu(self, event: tk.Event) -> None: # type: ignore[type-arg]
796
+ """Pop up the log context menu at the pointer."""
797
+ try:
798
+ self._output_menu.tk_popup(event.x_root, event.y_root)
799
+ finally:
800
+ self._output_menu.grab_release()
801
+
802
+ def _on_key_press(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
803
+ """Block text-mutating keys in the read-only output widget while
804
+ still allowing copy (Ctrl+C), select-all (Ctrl+A), and keyboard
805
+ navigation / selection (arrows, Home/End, PgUp/PgDn, with Shift)."""
806
+ if event.state & 0x4 and event.keysym in ("c", "C", "a", "A"):
807
+ return None # Ctrl+C / Ctrl+A — let it propagate
808
+ if event.keysym in self._NAV_KEYSYMS:
809
+ return None # navigation / selection — does not modify the text
810
+ return "break" # suppress everything else (typing, paste, delete, …)
811
+
812
+ @staticmethod
813
+ def _blend(base16: tuple[int, int, int], target_hex: str, frac: float) -> str:
814
+ """Blend a 16-bit ``(r, g, b)`` base colour ``frac`` of the way toward
815
+ ``target_hex`` (``#rrggbb``) and return an ``#rrggbb`` string.
816
+
817
+ ``base16`` comes from ``winfo_rgb`` (each channel 0–65535), so the
818
+ result tracks the widget's *actual* theme background/foreground:
819
+ blending a near-black dark-mode base toward grey lightens it, while
820
+ blending a near-white light-mode base toward grey darkens it — the
821
+ same call gives the right "touch" of tint in either theme.
822
+ """
823
+ tr = int(target_hex[1:3], 16) * 257
824
+ tg = int(target_hex[3:5], 16) * 257
825
+ tb = int(target_hex[5:7], 16) * 257
826
+ r = int(base16[0] + (tr - base16[0]) * frac)
827
+ g = int(base16[1] + (tg - base16[1]) * frac)
828
+ b = int(base16[2] + (tb - base16[2]) * frac)
829
+ return f"#{r // 256:02x}{g // 256:02x}{b // 256:02x}"
830
+
831
+ def _configure_log_tags(self) -> None:
832
+ """Configure the colour tags for the log, derived from the widget's
833
+ live theme colours so they read in both dark and light modes.
834
+
835
+ Backgrounds are subtle tints of the real base background (kept light
836
+ so the text stays readable); foregrounds are pulled partway toward a
837
+ saturated hue so they stand out on either base.
838
+ """
839
+ txt = self._output_text
840
+ try:
841
+ base_bg = txt.winfo_rgb(txt.cget("background"))
842
+ base_fg = txt.winfo_rgb(txt.cget("foreground"))
843
+ except tk.TclError:
844
+ return # colours unavailable (widget not realised) — stay plain
845
+ # Backgrounds — a faint wash of the base toward the accent hue.
846
+ txt.tag_configure("cmd", background=self._blend(base_bg, "#ffd000", 0.16))
847
+ txt.tag_configure("highs", background=self._blend(base_bg, "#808080", 0.14))
848
+ txt.tag_configure(
849
+ "solvestart",
850
+ background=self._blend(base_bg, "#33cc33", 0.18),
851
+ foreground=self._blend(base_fg, "#4aa3ff", 0.55),
852
+ )
853
+ # Foregrounds — readable accent colours on either theme.
854
+ txt.tag_configure("mem", foreground=self._blend(base_fg, "#4aa3ff", 0.55))
855
+ txt.tag_configure("warn", foreground=self._blend(base_fg, "#ff3b30", 0.70))
856
+ # Keep the selection highlight on top of every custom tag — these are
857
+ # created after the built-in ``sel`` tag and would otherwise out-rank
858
+ # it, painting their background over the selection so a drag-select
859
+ # shows no visible highlight.
860
+ txt.tag_raise("sel")
861
+
862
+ def _classify_log_line(self, element: str) -> list[str]:
863
+ """Return the colour tags for one log *element* (one ``stdout_lines``
864
+ entry — usually a single visual line, but the command echo is one
865
+ multi-line element).
866
+
867
+ A small state machine tracks the HiGHS solver-output block so its
868
+ whole console dump greys out until the next phase-progress row.
869
+ """
870
+ low = element.lower()
871
+ # Command echo — a multi-line bash block (yellow wash).
872
+ if "\\\n" in element or element.startswith("systemd-run"):
873
+ self._highs_block_active = False
874
+ return ["cmd"]
875
+ # Phase-progress timer/memory rows (header + data) — these also end
876
+ # any preceding HiGHS block.
877
+ if " | " in element and (
878
+ "GB" in element or "MB" in element
879
+ or "memory" in element or "swap" in element
880
+ ):
881
+ self._highs_block_active = False
882
+ return ["mem"]
883
+ # New-solve marker (green wash + blue text — same family as the rows).
884
+ if element.startswith("Solve start:"):
885
+ self._highs_block_active = False
886
+ return ["solvestart"]
887
+ # HiGHS console dump opens here and greys until the next phase row.
888
+ if element.startswith("Running HiGHS"):
889
+ self._highs_block_active = True
890
+ tags: list[str] = []
891
+ if self._highs_block_active:
892
+ # Whole block greys, blank lines included, so the solver dump
893
+ # reads as one contiguous panel (the block ends at the next
894
+ # phase/timer row, after which blanks are regular again).
895
+ tags.append("highs")
896
+ if "warning" in low or "error" in low:
897
+ tags.append("warn")
898
+ return tags
899
+
900
+ def _update_output_display(self) -> None:
901
+ """Append new stdout lines for the currently viewed job.
902
+
903
+ Only lines that haven't been displayed yet are inserted. If the
904
+ user has scrolled up to read earlier output, auto-scroll is
905
+ suppressed so the viewport stays put.
906
+ """
907
+ job_id = self._viewed_job_id
908
+ if job_id is None:
909
+ return
910
+
911
+ lines = self._mgr.get_stdout(job_id)
912
+ already_shown = self._displayed_counts.get(job_id, 0)
913
+ new_count = len(lines)
914
+ if new_count <= already_shown:
915
+ return
916
+
917
+ # Check whether the view is currently at the bottom *before*
918
+ # inserting new text.
919
+ at_bottom = self._output_text.yview()[1] >= 0.99
920
+
921
+ # Append only the new lines, tagging each for theme-aware colouring.
922
+ new_lines = lines[already_shown:]
923
+ for line in new_lines:
924
+ # The engine prints a ``total_cost.val = …`` line for CLI/test
925
+ # consumers that parse stdout; it's redundant in the GUI (the
926
+ # solver already reports the objective) and lands mid-table, so
927
+ # drop it from the displayed log. The raw stdout is untouched.
928
+ if line.startswith("total_cost.val ="):
929
+ continue
930
+ was_active = self._highs_block_active
931
+ tags = self._classify_log_line(line)
932
+ now_active = self._highs_block_active
933
+ if "highs" in tags and not line.strip():
934
+ # In-block blank: grey it provisionally, but mark it pending —
935
+ # a blank that turns out to be *trailing* (the separator before
936
+ # the timer table) must revert to regular once the block ends.
937
+ self._output_text.insert("end", line + "\n", ("highs", "pending_blank"))
938
+ continue
939
+ self._output_text.insert("end", line + "\n", tuple(tags))
940
+ if "highs" in tags:
941
+ # Real dump content after the blanks → they were internal;
942
+ # keep them grey, just clear the pending marker.
943
+ self._output_text.tag_remove("pending_blank", "1.0", "end")
944
+ elif was_active and not now_active:
945
+ # This row closed the HiGHS block → any pending blanks were
946
+ # trailing; strip the grey back off them.
947
+ ranges = self._output_text.tag_ranges("pending_blank")
948
+ for k in range(0, len(ranges), 2):
949
+ self._output_text.tag_remove("highs", ranges[k], ranges[k + 1])
950
+ self._output_text.tag_remove("pending_blank", "1.0", "end")
951
+ self._displayed_counts[job_id] = new_count
952
+
953
+ # Auto-scroll only when the user is already at (or near) the bottom
954
+ if at_bottom:
955
+ self._output_text.see("end")
956
+
957
+ # ------------------------------------------------------------------
958
+ # Button states
959
+ # ------------------------------------------------------------------
960
+
961
+ def _get_selected_job_ids(self) -> list[int]:
962
+ """Return job IDs for all selected items in the tree."""
963
+ ids: list[int] = []
964
+ for iid in self._job_tree.selection():
965
+ try:
966
+ ids.append(int(iid))
967
+ except (ValueError, IndexError):
968
+ pass
969
+ return ids
970
+
971
+ def _update_button_states(self) -> None:
972
+ """Enable/disable buttons based on current execution state."""
973
+ jobs = self._mgr.get_jobs()
974
+ has_pending_or_running = any(
975
+ j.status in (JobStatus.PENDING, JobStatus.RUNNING) for j in jobs
976
+ )
977
+ # Pause only applies to the scenario scheduler
978
+ has_pending_scenarios = any(
979
+ j.status == JobStatus.PENDING
980
+ for j in jobs if j.job_type == JobType.SCENARIO
981
+ )
982
+ is_paused = self._mgr.is_paused
983
+
984
+ selected_ids = set(self._get_selected_job_ids())
985
+ job_by_id = {j.job_id: j for j in jobs}
986
+
987
+ # Pause/Continue toggle: enabled when there are pending scenario jobs
988
+ if has_pending_scenarios or is_paused:
989
+ self._pause_btn.configure(state="normal")
990
+ if is_paused:
991
+ self._pause_btn.configure(text="Continue executions")
992
+ else:
993
+ self._pause_btn.configure(text="Pause executions")
994
+ else:
995
+ self._pause_btn.configure(state="disabled", text="Pause executions")
996
+
997
+ # Kill: enabled if any selected job is running or pending
998
+ can_kill = any(
999
+ job_by_id.get(jid) is not None
1000
+ and job_by_id[jid].status in (JobStatus.RUNNING, JobStatus.PENDING)
1001
+ for jid in selected_ids
1002
+ )
1003
+ self._kill_btn.configure(state="normal" if can_kill else "disabled")
1004
+
1005
+ # Force start: enabled if any selected job is a pending scenario
1006
+ can_force = any(
1007
+ job_by_id.get(jid) is not None
1008
+ and job_by_id[jid].job_type == JobType.SCENARIO
1009
+ and job_by_id[jid].status == JobStatus.PENDING
1010
+ for jid in selected_ids
1011
+ )
1012
+ self._force_btn.configure(state="normal" if can_force else "disabled")
1013
+
1014
+ # Remove: enabled if any selected job is pending, finished, or killed
1015
+ # (anything except a still-running job, which must be killed first).
1016
+ can_remove = any(
1017
+ job_by_id.get(jid) is not None
1018
+ and job_by_id[jid].status in (
1019
+ JobStatus.PENDING, JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.KILLED
1020
+ )
1021
+ for jid in selected_ids
1022
+ )
1023
+ self._remove_btn.configure(state="normal" if can_remove else "disabled")
1024
+
1025
+ # Kill all: enabled if there are running or pending jobs
1026
+ self._kill_all_btn.configure(
1027
+ state="normal" if has_pending_or_running else "disabled"
1028
+ )
1029
+
1030
+ # Close: only enabled when nothing is running or pending
1031
+ self._close_btn.configure(
1032
+ state="normal" if not has_pending_or_running else "disabled"
1033
+ )
1034
+
1035
+ # ------------------------------------------------------------------
1036
+ # Periodic polling
1037
+ # ------------------------------------------------------------------
1038
+
1039
+ def _poll_updates(self) -> None:
1040
+ """Periodically refresh the job list and output display."""
1041
+ if not self.winfo_exists():
1042
+ return
1043
+
1044
+ # Auto-select newly created auxiliary jobs
1045
+ pending_id = self._mgr._pending_select_job_id
1046
+ if pending_id is not None:
1047
+ self._mgr._pending_select_job_id = None
1048
+ self._refresh_job_list()
1049
+ self.select_job(pending_id)
1050
+ else:
1051
+ self._refresh_job_list()
1052
+
1053
+ self._update_output_display()
1054
+ self._update_button_states()
1055
+
1056
+ self.after(500, self._poll_updates)
1057
+
1058
+ # ------------------------------------------------------------------
1059
+ # Thread-safe refresh (called from ExecutionManager callbacks)
1060
+ # ------------------------------------------------------------------
1061
+
1062
+ def schedule_refresh(self) -> None:
1063
+ """Schedule a job list refresh on the main thread, coalescing bursts.
1064
+
1065
+ Safe to call from any thread. Repeated calls before the pending
1066
+ refresh runs are collapsed into a single rebuild, so a streaming job
1067
+ (one call per output line) cannot flood the event loop.
1068
+ """
1069
+ if not self.winfo_exists() or self._refresh_scheduled:
1070
+ return
1071
+ self._refresh_scheduled = True
1072
+ self.after_idle(self._do_scheduled_refresh)
1073
+
1074
+ def _do_scheduled_refresh(self) -> None:
1075
+ self._refresh_scheduled = False
1076
+ if self.winfo_exists():
1077
+ self._refresh_job_list()
1078
+
1079
+ def select_job(self, job_id: int) -> None:
1080
+ """Select and show the log for the job with *job_id*.
1081
+
1082
+ If the tree hasn't been populated with this job yet, a refresh is
1083
+ triggered first.
1084
+ """
1085
+ iid = str(job_id)
1086
+ if not self._job_tree.exists(iid):
1087
+ self._refresh_job_list()
1088
+ if self._job_tree.exists(iid):
1089
+ self._job_tree.selection_set(iid)
1090
+ self._job_tree.see(iid)
1091
+
1092
+ # ------------------------------------------------------------------
1093
+ # Button handlers
1094
+ # ------------------------------------------------------------------
1095
+
1096
+ def _on_pause_toggle(self) -> None:
1097
+ """Toggle between pausing and continuing execution."""
1098
+ if self._mgr.is_paused:
1099
+ self._mgr.resume()
1100
+ else:
1101
+ self._mgr.pause()
1102
+ self.after(100, self._update_button_states)
1103
+
1104
+ def _on_kill_selected(self) -> None:
1105
+ """Kill all selected running or pending jobs.
1106
+
1107
+ Pending jobs that have not started execution are automatically
1108
+ removed from the list.
1109
+ """
1110
+ selected_ids = self._get_selected_job_ids()
1111
+ if not selected_ids:
1112
+ return
1113
+
1114
+ jobs = self._mgr.get_jobs()
1115
+ job_by_id = {j.job_id: j for j in jobs}
1116
+
1117
+ for jid in selected_ids:
1118
+ job = job_by_id.get(jid)
1119
+ if job is None:
1120
+ continue
1121
+ if job.status == JobStatus.RUNNING:
1122
+ self._mgr.kill_job(jid)
1123
+ elif job.status == JobStatus.PENDING:
1124
+ # Pending jobs that were never executed: remove immediately
1125
+ self._mgr.remove_job(jid)
1126
+
1127
+ self._refresh_job_list()
1128
+ self._update_button_states()
1129
+
1130
+ def _on_remove_selected(self) -> None:
1131
+ """Remove all selected pending, finished, or killed jobs from the list.
1132
+
1133
+ Running jobs are left alone (they must be killed first); ``remove_job``
1134
+ ignores them.
1135
+ """
1136
+ selected_ids = self._get_selected_job_ids()
1137
+ if not selected_ids:
1138
+ return
1139
+
1140
+ for jid in selected_ids:
1141
+ self._mgr.remove_job(jid)
1142
+
1143
+ self._refresh_job_list()
1144
+ self._update_button_states()
1145
+
1146
+ def _selected_forceable_jobs(self) -> list[int]:
1147
+ """Job IDs of selected jobs that are pending scenarios (forceable)."""
1148
+ jobs = {j.job_id: j for j in self._mgr.get_jobs()}
1149
+ out: list[int] = []
1150
+ for jid in self._get_selected_job_ids():
1151
+ job = jobs.get(jid)
1152
+ if (
1153
+ job is not None
1154
+ and job.job_type == JobType.SCENARIO
1155
+ and job.status == JobStatus.PENDING
1156
+ ):
1157
+ out.append(jid)
1158
+ return out
1159
+
1160
+ def _on_force_selected(self) -> None:
1161
+ """Toggle the force-start flag on the selected pending scenario(s).
1162
+
1163
+ If any selected pending scenario is not yet forced, force them all;
1164
+ if they are all already forced, clear the flag (cancel the force).
1165
+ """
1166
+ forceable = self._selected_forceable_jobs()
1167
+ if not forceable:
1168
+ return
1169
+ jobs = {j.job_id: j for j in self._mgr.get_jobs()}
1170
+ # Force unless every target is already forced, in which case unforce.
1171
+ target = not all(jobs[jid].force_start for jid in forceable)
1172
+ for jid in forceable:
1173
+ self._mgr.set_force_start(jid, target)
1174
+ self._refresh_job_list()
1175
+ self._update_button_states()
1176
+
1177
+ def _on_tree_context_menu(self, event: tk.Event) -> None: # type: ignore[type-arg]
1178
+ """Pop up the per-scenario right-click menu under the cursor.
1179
+
1180
+ Right-clicking a row that isn't part of the current selection
1181
+ selects just that row first (matching common file-manager
1182
+ behaviour); right-clicking inside an existing multi-selection
1183
+ keeps it so the action applies to all selected rows.
1184
+ """
1185
+ iid = self._job_tree.identify_row(event.y)
1186
+ if iid and iid not in self._job_tree.selection():
1187
+ self._job_tree.selection_set(iid)
1188
+ self._job_tree.focus(iid)
1189
+
1190
+ jobs = {j.job_id: j for j in self._mgr.get_jobs()}
1191
+ selected = [jobs[jid] for jid in self._get_selected_job_ids() if jid in jobs]
1192
+
1193
+ def _state(predicate) -> str:
1194
+ return "normal" if any(predicate(j) for j in selected) else "disabled"
1195
+
1196
+ can_remove = _state(lambda j: j.status in (
1197
+ JobStatus.PENDING, JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.KILLED
1198
+ ))
1199
+ can_force = _state(lambda j: (
1200
+ j.job_type == JobType.SCENARIO and j.status == JobStatus.PENDING
1201
+ ))
1202
+ can_kill = _state(lambda j: j.status in (JobStatus.RUNNING, JobStatus.PENDING))
1203
+
1204
+ # Pause is the global scheduler toggle (no per-job pause exists).
1205
+ has_pending_scenarios = any(
1206
+ j.status == JobStatus.PENDING and j.job_type == JobType.SCENARIO
1207
+ for j in jobs.values()
1208
+ )
1209
+ is_paused = self._mgr.is_paused
1210
+ pause_label = "Continue executions" if is_paused else "Pause executions"
1211
+ pause_state = "normal" if (has_pending_scenarios or is_paused) else "disabled"
1212
+
1213
+ menu = self._context_menu
1214
+ menu.delete(0, "end")
1215
+ menu.add_command(label="Force start", command=self._on_force_selected, state=can_force)
1216
+ menu.add_command(label=pause_label, command=self._on_pause_toggle, state=pause_state)
1217
+ menu.add_command(label="Kill", command=self._on_kill_selected, state=can_kill)
1218
+ menu.add_separator()
1219
+ menu.add_command(label="Remove", command=self._on_remove_selected, state=can_remove)
1220
+ try:
1221
+ menu.tk_popup(event.x_root, event.y_root)
1222
+ finally:
1223
+ menu.grab_release()
1224
+
1225
+ def _on_memory_guard_toggled(self) -> None:
1226
+ """Apply the memory-guard master switch (session-only, not persisted)."""
1227
+ enabled = bool(self._memory_guard_var.get())
1228
+ self._mgr.memory_guard_enabled = enabled
1229
+ self._update_guard_dependent_states()
1230
+ self._refresh_job_list()
1231
+
1232
+ def _update_guard_dependent_states(self) -> None:
1233
+ """Grey out Allow swap when the memory guard is off.
1234
+
1235
+ Allow swap is consulted only by the watchdog, which is dormant while
1236
+ the guard is off, so the control would be misleading if left active.
1237
+ Min free RAM stays enabled: it still governs how many runs start
1238
+ (the admission check honours the reserve regardless of the guard).
1239
+ """
1240
+ on = bool(self._memory_guard_var.get())
1241
+ try:
1242
+ self._swap_allow_spin.configure(state="normal" if on else "disabled")
1243
+ except tk.TclError:
1244
+ pass
1245
+ try:
1246
+ self._swap_label.state(["!disabled"] if on else ["disabled"])
1247
+ except tk.TclError:
1248
+ pass
1249
+
1250
+ def _on_kill_all(self) -> None:
1251
+ """Kill all running processes and cancel pending jobs.
1252
+
1253
+ Pending jobs that have not been executed are auto-removed.
1254
+ """
1255
+ jobs = self._mgr.get_jobs()
1256
+ pending_ids = [j.job_id for j in jobs if j.status == JobStatus.PENDING]
1257
+
1258
+ self._mgr.kill_all()
1259
+
1260
+ # Remove pending jobs (they were never executed)
1261
+ for jid in pending_ids:
1262
+ self._mgr.remove_job(jid)
1263
+
1264
+ self._refresh_job_list()
1265
+ self._update_button_states()
1266
+
1267
+ def _on_move_up(self) -> None:
1268
+ """Move the selected pending job one position earlier in the queue."""
1269
+ selected_ids = self._get_selected_job_ids()
1270
+ if len(selected_ids) != 1:
1271
+ return
1272
+ self._mgr.move_pending_up(selected_ids[0])
1273
+ self._refresh_job_list()
1274
+ # Re-select the moved item
1275
+ iid = str(selected_ids[0])
1276
+ if self._job_tree.exists(iid):
1277
+ self._job_tree.selection_set(iid)
1278
+
1279
+ def _on_move_down(self) -> None:
1280
+ """Move the selected pending job one position later in the queue."""
1281
+ selected_ids = self._get_selected_job_ids()
1282
+ if len(selected_ids) != 1:
1283
+ return
1284
+ self._mgr.move_pending_down(selected_ids[0])
1285
+ self._refresh_job_list()
1286
+ # Re-select the moved item
1287
+ iid = str(selected_ids[0])
1288
+ if self._job_tree.exists(iid):
1289
+ self._job_tree.selection_set(iid)
1290
+
1291
+ def _restore_sash(self) -> None:
1292
+ """Restore the saved Jobs/Progress sash position, clamped to the
1293
+ current pane width so a value saved at a different DPI cannot
1294
+ collapse the Progress pane to zero. When no saved value exists,
1295
+ apply ``_jobs_default_width`` so the Jobs columns are visible
1296
+ on first open.
1297
+ """
1298
+ from flextool.gui.ui_metrics import clamp_sash, rescale_pixels
1299
+ try:
1300
+ self.update_idletasks()
1301
+ paned_total = self._paned.winfo_width()
1302
+ saved = 0
1303
+ saved_cw = 0
1304
+ if self._global_settings is not None:
1305
+ saved = self._global_settings.exec_jobs_sash
1306
+ saved_cw = self._global_settings.exec_jobs_layout_cw
1307
+ if saved > 0:
1308
+ target = clamp_sash(
1309
+ rescale_pixels(saved, saved_cw, self._char_width),
1310
+ paned_total,
1311
+ min_px=self._jobs_min_width,
1312
+ )
1313
+ else:
1314
+ target = clamp_sash(
1315
+ self._jobs_default_width,
1316
+ paned_total,
1317
+ min_px=self._jobs_min_width,
1318
+ )
1319
+ if target > 0:
1320
+ self._paned.sashpos(0, target)
1321
+ except (tk.TclError, IndexError):
1322
+ pass
1323
+
1324
+ def _enforce_jobs_min_width(self, _event: tk.Event) -> None:
1325
+ """Keep the Jobs pane from collapsing during window resize.
1326
+
1327
+ The Jobs pane has ``weight=0`` so that extra horizontal space goes
1328
+ to the Progress pane; but on Linux/ttk a narrow-then-wide window
1329
+ resize cycle can leave the sash near zero. This clamps the sash
1330
+ back to a readable minimum whenever the pane window is laid out.
1331
+ """
1332
+ try:
1333
+ if self._paned.sashpos(0) < self._jobs_min_width:
1334
+ # Only clamp if the overall pane is wide enough to honour it.
1335
+ if self._paned.winfo_width() > self._jobs_min_width + 50:
1336
+ self._paned.sashpos(0, self._jobs_min_width)
1337
+ except (tk.TclError, IndexError):
1338
+ pass
1339
+
1340
+ def _save_sash(self) -> None:
1341
+ """Save the current sash position to global settings."""
1342
+ if self._global_settings is None:
1343
+ return
1344
+ try:
1345
+ self._global_settings.exec_jobs_sash = self._paned.sashpos(0)
1346
+ except (tk.TclError, IndexError):
1347
+ pass
1348
+ self._global_settings.exec_jobs_layout_cw = self._char_width
1349
+ # Persist the window placement keyed by monitor configuration so it
1350
+ # reopens where the user left it on *this* layout, without reusing a
1351
+ # docked offset on a single-monitor setup (and vice-versa).
1352
+ try:
1353
+ from flextool.gui.ui_metrics import monitor_signature
1354
+ self._global_settings.exec_jobs_geometry[monitor_signature(self)] = (
1355
+ self.geometry()
1356
+ )
1357
+ except tk.TclError:
1358
+ pass
1359
+ try:
1360
+ from flextool.gui.project_utils import get_projects_dir
1361
+ from flextool.gui.settings_io import save_global_settings
1362
+ save_global_settings(get_projects_dir(), self._global_settings)
1363
+ except Exception:
1364
+ pass
1365
+
1366
+ def _on_close_attempt(self) -> None:
1367
+ """Handle the close button or window manager close request."""
1368
+ if self._mgr.has_pending_or_running():
1369
+ messagebox.showwarning(
1370
+ "Jobs still active",
1371
+ "There are running or pending jobs.\n"
1372
+ "Please use 'Kill all' or 'Wind down' first.",
1373
+ parent=self,
1374
+ )
1375
+ return
1376
+ self._save_sash()
1377
+ self.destroy()