flextool 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|