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,159 @@
|
|
|
1
|
+
"""Non-modal log window for output action subprocesses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
import tkinter.font as tkfont
|
|
8
|
+
from tkinter import ttk
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OutputLogWindow(tk.Toplevel):
|
|
13
|
+
"""Non-modal window that displays the command and live stdout of a subprocess.
|
|
14
|
+
|
|
15
|
+
The window stays open after the process finishes so the user can
|
|
16
|
+
review the output. Two buttons are provided:
|
|
17
|
+
|
|
18
|
+
* **Close** -- closes the window without stopping the process.
|
|
19
|
+
* **Stop and close** -- kills the process and closes the window.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self, parent: tk.Misc, title: str, on_abort: Callable[[], None] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(parent)
|
|
26
|
+
self.title(title)
|
|
27
|
+
self.transient(parent)
|
|
28
|
+
|
|
29
|
+
self._process: subprocess.Popen | None = None
|
|
30
|
+
self._on_abort = on_abort
|
|
31
|
+
self._aborted = False
|
|
32
|
+
|
|
33
|
+
# ── Font metrics ─────────────────────────────────────────
|
|
34
|
+
mono_font = tkfont.nametofont("TkFixedFont")
|
|
35
|
+
default_font = tkfont.nametofont("TkDefaultFont")
|
|
36
|
+
cw = default_font.measure("0")
|
|
37
|
+
lh = default_font.metrics("linespace")
|
|
38
|
+
|
|
39
|
+
# ── Window sizing ────────────────────────────────────────
|
|
40
|
+
screen_w = self.winfo_screenwidth()
|
|
41
|
+
screen_h = self.winfo_screenheight()
|
|
42
|
+
taskbar_margin = lh * 4
|
|
43
|
+
win_h = screen_h - taskbar_margin
|
|
44
|
+
win_w = 1800
|
|
45
|
+
x = max(0, screen_w - win_w)
|
|
46
|
+
self.geometry(f"{win_w}x{win_h}+{x}+0")
|
|
47
|
+
self.minsize(cw * 50, lh * 10)
|
|
48
|
+
|
|
49
|
+
self.columnconfigure(0, weight=1)
|
|
50
|
+
self.rowconfigure(0, weight=1)
|
|
51
|
+
|
|
52
|
+
# ── Text widget ─────────────────────────────────────────
|
|
53
|
+
text_frame = ttk.Frame(self)
|
|
54
|
+
text_frame.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 4))
|
|
55
|
+
text_frame.columnconfigure(0, weight=1)
|
|
56
|
+
text_frame.rowconfigure(0, weight=1)
|
|
57
|
+
|
|
58
|
+
self._text = tk.Text(text_frame, wrap="word", font=mono_font)
|
|
59
|
+
self._text.grid(row=0, column=0, sticky="nsew")
|
|
60
|
+
self._text.bind("<Key>", self._on_key_press)
|
|
61
|
+
|
|
62
|
+
vscroll = ttk.Scrollbar(text_frame, orient="vertical", command=self._text.yview)
|
|
63
|
+
self._text.configure(yscrollcommand=vscroll.set)
|
|
64
|
+
vscroll.grid(row=0, column=1, sticky="ns")
|
|
65
|
+
|
|
66
|
+
# ── Buttons ──────────────────────────────────────────────
|
|
67
|
+
btn_frame = ttk.Frame(self, padding=(8, 4, 8, 8))
|
|
68
|
+
btn_frame.grid(row=1, column=0, sticky="ew")
|
|
69
|
+
|
|
70
|
+
if on_abort is not None:
|
|
71
|
+
self._stop_close_btn = ttk.Button(
|
|
72
|
+
btn_frame, text="Abort", command=self._on_abort_clicked,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
self._stop_close_btn = ttk.Button(
|
|
76
|
+
btn_frame, text="Stop and close", command=self._on_stop_and_close,
|
|
77
|
+
)
|
|
78
|
+
self._stop_close_btn.pack(side="left", padx=(0, 10))
|
|
79
|
+
|
|
80
|
+
self._close_btn = ttk.Button(
|
|
81
|
+
btn_frame, text="Close", command=self._on_close,
|
|
82
|
+
)
|
|
83
|
+
self._close_btn.pack(side="right")
|
|
84
|
+
|
|
85
|
+
if on_abort is not None:
|
|
86
|
+
self.protocol("WM_DELETE_WINDOW", self._on_abort_clicked)
|
|
87
|
+
else:
|
|
88
|
+
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
89
|
+
|
|
90
|
+
# ── Public API ───────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def set_process(self, proc: subprocess.Popen) -> None:
|
|
93
|
+
"""Register the subprocess so Stop can kill it."""
|
|
94
|
+
self._process = proc
|
|
95
|
+
|
|
96
|
+
def append_line(self, line: str) -> None:
|
|
97
|
+
"""Append a line of text. Must be called from the main thread."""
|
|
98
|
+
if not self.winfo_exists():
|
|
99
|
+
return
|
|
100
|
+
at_bottom = self._text.yview()[1] >= 0.99
|
|
101
|
+
self._text.insert("end", line + "\n")
|
|
102
|
+
if at_bottom:
|
|
103
|
+
self._text.see("end")
|
|
104
|
+
|
|
105
|
+
def mark_finished(self, success: bool) -> None:
|
|
106
|
+
"""Mark the process as finished — update button states."""
|
|
107
|
+
if not self.winfo_exists():
|
|
108
|
+
return
|
|
109
|
+
self._process = None
|
|
110
|
+
self._stop_close_btn.configure(state="disabled")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_aborted(self) -> bool:
|
|
114
|
+
"""Whether the user clicked Abort."""
|
|
115
|
+
return self._aborted
|
|
116
|
+
|
|
117
|
+
def minimize(self) -> None:
|
|
118
|
+
"""Minimize the window to the taskbar.
|
|
119
|
+
|
|
120
|
+
Transient windows cannot be iconified, so we first remove the
|
|
121
|
+
transient binding, then iconify normally.
|
|
122
|
+
"""
|
|
123
|
+
self.transient('') # remove transient parent → gets own taskbar entry
|
|
124
|
+
self.iconify()
|
|
125
|
+
|
|
126
|
+
# ── Event handlers ───────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def _on_key_press(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
|
|
129
|
+
"""Allow Ctrl+C/A but block other input."""
|
|
130
|
+
if event.state & 0x4 and event.keysym in ("c", "C", "a", "A"):
|
|
131
|
+
return None
|
|
132
|
+
return "break"
|
|
133
|
+
|
|
134
|
+
def _on_close(self) -> None:
|
|
135
|
+
"""Close the window without stopping the process."""
|
|
136
|
+
self.destroy()
|
|
137
|
+
|
|
138
|
+
def _on_abort_clicked(self) -> None:
|
|
139
|
+
"""Kill the process, signal abort, and close the window."""
|
|
140
|
+
if self._process is not None:
|
|
141
|
+
try:
|
|
142
|
+
self._process.kill()
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
self._process = None
|
|
146
|
+
self._aborted = True
|
|
147
|
+
if self._on_abort is not None:
|
|
148
|
+
self._on_abort()
|
|
149
|
+
self.destroy()
|
|
150
|
+
|
|
151
|
+
def _on_stop_and_close(self) -> None:
|
|
152
|
+
"""Kill the process and close the window."""
|
|
153
|
+
if self._process is not None:
|
|
154
|
+
try:
|
|
155
|
+
self._process.kill()
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
self._process = None
|
|
159
|
+
self.destroy()
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Cross-platform helper functions for opening files and launching external tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import tkinter as tk
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _clean_env() -> dict[str, str]:
|
|
20
|
+
"""Return a copy of os.environ without Python virtualenv variables.
|
|
21
|
+
|
|
22
|
+
LibreOffice and other desktop apps bundle their own Python and crash
|
|
23
|
+
(std::bad_alloc) if they inherit PYTHONPATH/VIRTUAL_ENV from the
|
|
24
|
+
parent process. This only affects the child process — the current
|
|
25
|
+
Python process keeps its full environment.
|
|
26
|
+
"""
|
|
27
|
+
env = {
|
|
28
|
+
k: v for k, v in os.environ.items()
|
|
29
|
+
if k not in ("PYTHONPATH", "VIRTUAL_ENV", "VIRTUAL_ENV_DISABLE_PROMPT",
|
|
30
|
+
"PYTHONHOME", "CONDA_PREFIX", "CONDA_DEFAULT_ENV")
|
|
31
|
+
}
|
|
32
|
+
if "VIRTUAL_ENV" in os.environ:
|
|
33
|
+
venv_bin = os.path.join(os.environ["VIRTUAL_ENV"], "bin")
|
|
34
|
+
env["PATH"] = os.pathsep.join(
|
|
35
|
+
p for p in os.environ.get("PATH", "").split(os.pathsep)
|
|
36
|
+
if p != venv_bin
|
|
37
|
+
)
|
|
38
|
+
return env
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def open_file_in_default_app(filepath: Path) -> None:
|
|
42
|
+
"""Open a file using the OS default application.
|
|
43
|
+
|
|
44
|
+
Windows: os.startfile
|
|
45
|
+
macOS: subprocess.Popen(['open', str(filepath)])
|
|
46
|
+
Linux: subprocess.Popen(['xdg-open', str(filepath)])
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
OSError: If the file does not exist or the platform command fails.
|
|
50
|
+
"""
|
|
51
|
+
filepath = Path(filepath)
|
|
52
|
+
if not filepath.exists():
|
|
53
|
+
raise OSError(f"File does not exist: {filepath}")
|
|
54
|
+
|
|
55
|
+
if sys.platform == "win32":
|
|
56
|
+
os.startfile(str(filepath)) # type: ignore[attr-defined]
|
|
57
|
+
elif sys.platform == "darwin":
|
|
58
|
+
subprocess.Popen(["open", str(filepath)])
|
|
59
|
+
else:
|
|
60
|
+
subprocess.Popen(
|
|
61
|
+
["xdg-open", str(filepath)],
|
|
62
|
+
start_new_session=True,
|
|
63
|
+
env=_clean_env(),
|
|
64
|
+
stdout=subprocess.DEVNULL,
|
|
65
|
+
stderr=subprocess.DEVNULL,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def open_folder(dirpath: Path) -> None:
|
|
70
|
+
"""Open a folder in the OS file manager.
|
|
71
|
+
|
|
72
|
+
Windows: os.startfile
|
|
73
|
+
macOS: subprocess.Popen(['open', str(dirpath)])
|
|
74
|
+
Linux: subprocess.Popen(['xdg-open', str(dirpath)])
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
OSError: If the directory does not exist.
|
|
78
|
+
"""
|
|
79
|
+
dirpath = Path(dirpath)
|
|
80
|
+
if not dirpath.is_dir():
|
|
81
|
+
raise OSError(f"Directory does not exist: {dirpath}")
|
|
82
|
+
|
|
83
|
+
if sys.platform == "win32":
|
|
84
|
+
os.startfile(str(dirpath)) # type: ignore[attr-defined]
|
|
85
|
+
elif sys.platform == "darwin":
|
|
86
|
+
subprocess.Popen(["open", str(dirpath)])
|
|
87
|
+
else:
|
|
88
|
+
subprocess.Popen(
|
|
89
|
+
["xdg-open", str(dirpath)],
|
|
90
|
+
start_new_session=True,
|
|
91
|
+
env=_clean_env(),
|
|
92
|
+
stdout=subprocess.DEVNULL,
|
|
93
|
+
stderr=subprocess.DEVNULL,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def open_spine_db_editor(db_url: str) -> subprocess.Popen | None:
|
|
98
|
+
"""Launch spine-db-editor as a subprocess.
|
|
99
|
+
|
|
100
|
+
Command: ``spine-db-editor <db_url>``
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The :class:`subprocess.Popen` object, or ``None`` if
|
|
104
|
+
``spine-db-editor`` is not found on the system PATH.
|
|
105
|
+
"""
|
|
106
|
+
exe = shutil.which("spine-db-editor")
|
|
107
|
+
if exe is None:
|
|
108
|
+
logger.warning("spine-db-editor not found on PATH")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
proc = subprocess.Popen([exe, db_url])
|
|
113
|
+
return proc
|
|
114
|
+
except OSError:
|
|
115
|
+
logger.warning("Failed to launch spine-db-editor", exc_info=True)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_process_dpi_awareness() -> None:
|
|
120
|
+
"""Mark the process DPI-aware on Windows, before the first Tk window.
|
|
121
|
+
|
|
122
|
+
MUST be called **before** the Tk root (HWND) exists — Windows only
|
|
123
|
+
honours ``SetProcessDpiAwareness`` while the process owns no windows.
|
|
124
|
+
Called afterwards it is a silent no-op, leaving the process DPI-
|
|
125
|
+
*unaware*: the OS then hands Tk a virtualized, bitmap-upscaled (blurry)
|
|
126
|
+
screen and lies about its size. Setting awareness here makes Tk render
|
|
127
|
+
at native pixels and report the true DPI, which ``apply_dpi_scaling``
|
|
128
|
+
then converts into the matching ``tk scaling``. No-op off Windows
|
|
129
|
+
(macOS handles Retina internally; Linux scales via ``tk scaling``).
|
|
130
|
+
"""
|
|
131
|
+
if sys.platform != "win32":
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
import ctypes
|
|
135
|
+
# 1 = PROCESS_SYSTEM_DPI_AWARE.
|
|
136
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(1) # type: ignore[attr-defined]
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def apply_dpi_scaling(root: tk.Tk) -> float:
|
|
142
|
+
"""Detect the OS display scale factor and apply it to tkinter.
|
|
143
|
+
|
|
144
|
+
Must be called **before** any widgets or themes are created so that
|
|
145
|
+
default fonts (and everything derived from them) pick up the correct
|
|
146
|
+
size.
|
|
147
|
+
|
|
148
|
+
Resolution order:
|
|
149
|
+
|
|
150
|
+
1. ``FLEXTOOL_DPI`` environment variable — explicit user override
|
|
151
|
+
(e.g. ``FLEXTOOL_DPI=144``) for setups where auto-detection fails.
|
|
152
|
+
2. On Windows, ``ctypes`` queries the system DPI directly (the process
|
|
153
|
+
must already be DPI-aware via :func:`set_process_dpi_awareness`,
|
|
154
|
+
called before the Tk root existed) and sets ``tk scaling`` to
|
|
155
|
+
``dpi / 72`` so fonts match the OS scale.
|
|
156
|
+
3. On Linux/X11, ``Xft.dpi`` (set by GNOME/KDE/Xfce) is the most
|
|
157
|
+
reliable hint, followed by ``GDK_SCALE`` and finally an
|
|
158
|
+
``xrandr``-derived DPI. The xrandr fallback uses the monitor
|
|
159
|
+
under the cursor at startup (which is where the WM will open
|
|
160
|
+
the window on every desktop I've tested), and falls back to
|
|
161
|
+
the primary monitor when the cursor's monitor reports a clearly
|
|
162
|
+
bogus physical size (e.g. virtual / mirrored displays often
|
|
163
|
+
report 0mm or copy a real monitor's dimensions verbatim).
|
|
164
|
+
4. On macOS, Tk handles Retina internally — nothing to do.
|
|
165
|
+
|
|
166
|
+
Once-at-startup is intentional. ``tk scaling`` doesn't re-flow
|
|
167
|
+
widgets that are already laid out, so changing it mid-session
|
|
168
|
+
would only partially update the UI.
|
|
169
|
+
|
|
170
|
+
A best-effort per-monitor handler in :class:`MainWindow` reapplies
|
|
171
|
+
``setup_fonts`` when ``winfo_fpixels('1i')`` swings by more than 10 %,
|
|
172
|
+
so text rescales on monitor changes; widget positions still need a
|
|
173
|
+
restart to fully re-flow.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
The ratio by which tk scaling was increased (1.0 means no change).
|
|
177
|
+
Use this to rescale theme fonts that use hardcoded pixel sizes.
|
|
178
|
+
"""
|
|
179
|
+
if sys.platform == "darwin":
|
|
180
|
+
return 1.0
|
|
181
|
+
|
|
182
|
+
dpi: float | None = None
|
|
183
|
+
|
|
184
|
+
# Explicit override always wins.
|
|
185
|
+
flextool_dpi = os.environ.get("FLEXTOOL_DPI")
|
|
186
|
+
if flextool_dpi:
|
|
187
|
+
try:
|
|
188
|
+
dpi = float(flextool_dpi)
|
|
189
|
+
except ValueError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
if dpi is None and sys.platform == "win32":
|
|
193
|
+
# Awareness is set earlier (set_process_dpi_awareness, before the Tk
|
|
194
|
+
# root existed); here we only read the now-truthful system DPI.
|
|
195
|
+
try:
|
|
196
|
+
import ctypes
|
|
197
|
+
dpi = ctypes.windll.user32.GetDpiForSystem() # type: ignore[attr-defined]
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
elif dpi is None:
|
|
201
|
+
# Linux / X11: try xrdb for Xft.dpi (set by GNOME, KDE, Xfce)
|
|
202
|
+
try:
|
|
203
|
+
out = subprocess.check_output(
|
|
204
|
+
["xrdb", "-query"], text=True, timeout=2, stderr=subprocess.DEVNULL,
|
|
205
|
+
)
|
|
206
|
+
for line in out.splitlines():
|
|
207
|
+
if line.startswith("Xft.dpi:"):
|
|
208
|
+
dpi = float(line.split(":", 1)[1].strip())
|
|
209
|
+
break
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
# Fallback: GDK_SCALE environment variable (integer scale factor)
|
|
213
|
+
if dpi is None:
|
|
214
|
+
gdk_scale = os.environ.get("GDK_SCALE")
|
|
215
|
+
if gdk_scale:
|
|
216
|
+
try:
|
|
217
|
+
dpi = 96.0 * float(gdk_scale)
|
|
218
|
+
except ValueError:
|
|
219
|
+
pass
|
|
220
|
+
# Last resort: ask xrandr which monitor the cursor is on and
|
|
221
|
+
# use that monitor's DPI. Falls through to primary if the
|
|
222
|
+
# cursor's monitor is suspect (mirrored / virtual / headless).
|
|
223
|
+
if dpi is None:
|
|
224
|
+
try:
|
|
225
|
+
cursor_x, cursor_y = root.winfo_pointerxy()
|
|
226
|
+
except Exception:
|
|
227
|
+
cursor_x, cursor_y = (None, None)
|
|
228
|
+
dpi = _xrandr_dpi_for_window(cursor_x, cursor_y)
|
|
229
|
+
|
|
230
|
+
if dpi is not None and dpi > 0:
|
|
231
|
+
current_scaling = float(root.tk.call("tk", "scaling"))
|
|
232
|
+
if sys.platform == "win32":
|
|
233
|
+
# Pin ``tk scaling`` to the documented pixels-per-point for the
|
|
234
|
+
# real DPI. When the process is DPI-aware Tk usually pre-sets
|
|
235
|
+
# this to the same value already (so the call is a harmless
|
|
236
|
+
# no-op); when it doesn't (or ``FLEXTOOL_DPI`` overrides), this
|
|
237
|
+
# is what makes point-sized fonts track the OS scale instead of
|
|
238
|
+
# rendering tiny. Either way the result is deterministic, not
|
|
239
|
+
# dependent on Tk's startup guess.
|
|
240
|
+
#
|
|
241
|
+
# The factor we RETURN is NOT ``new/current``: on a DPI-aware
|
|
242
|
+
# box ``current`` already equals ``new`` (Tk read the true DPI),
|
|
243
|
+
# so that ratio collapses to ~1.0 and ``scale_theme_fonts`` would
|
|
244
|
+
# never touch sv_ttk's pixel-sized fonts — they'd stay frozen at
|
|
245
|
+
# their 96-DPI baseline while every point font doubled (tiny body
|
|
246
|
+
# text + huge headings on hi-DPI). Return the *absolute* factor
|
|
247
|
+
# that lifts those 96-DPI-baseline pixel fonts to the current DPI
|
|
248
|
+
# (``dpi/96``), so the pixel and point worlds scale together.
|
|
249
|
+
new_scaling = dpi / 72.0
|
|
250
|
+
root.tk.call("tk", "scaling", new_scaling)
|
|
251
|
+
return new_scaling / (96.0 / 72.0)
|
|
252
|
+
# Linux/X11: desktop apps treat 96 DPI as "scale = 1.0", and a
|
|
253
|
+
# 0.85 fudge keeps sv_ttk's heavier metrics in line with
|
|
254
|
+
# surrounding Qt/GTK content. Only raise scaling, never shrink it
|
|
255
|
+
# below tk's default on systems that already got it right.
|
|
256
|
+
new_scaling = (dpi / 96.0) * 0.85
|
|
257
|
+
if new_scaling > current_scaling:
|
|
258
|
+
root.tk.call("tk", "scaling", new_scaling)
|
|
259
|
+
return new_scaling / current_scaling
|
|
260
|
+
return 1.0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def log_dpi_diagnostics(root: tk.Tk) -> None:
|
|
264
|
+
"""Log ground-truth DPI / font-scaling values for support diagnosis.
|
|
265
|
+
|
|
266
|
+
Call this once at startup AFTER the theme and fonts are configured (so
|
|
267
|
+
the theme's own fonts exist). Emitted at WARNING so the single line lands
|
|
268
|
+
in ``flextool_gui.log`` regardless of the configured level.
|
|
269
|
+
|
|
270
|
+
The point is to stop guessing: it reveals the real display DPI, whether
|
|
271
|
+
the process is genuinely DPI-aware, the effective ``tk scaling``, and —
|
|
272
|
+
crucially — that the sv_ttk theme paints widgets with PIXEL-sized fonts
|
|
273
|
+
(negative ``size``) that ``tk scaling`` cannot affect. That distinguishes
|
|
274
|
+
a 100%-scale no-op from a silent DPI-awareness failure in one run.
|
|
275
|
+
"""
|
|
276
|
+
import tkinter.font as tkfont
|
|
277
|
+
parts: list[str] = []
|
|
278
|
+
|
|
279
|
+
if sys.platform == "win32":
|
|
280
|
+
try:
|
|
281
|
+
import ctypes
|
|
282
|
+
try:
|
|
283
|
+
aw = ctypes.c_int(-1)
|
|
284
|
+
ctypes.windll.shcore.GetProcessDpiAwareness( # type: ignore[attr-defined]
|
|
285
|
+
None, ctypes.byref(aw),
|
|
286
|
+
)
|
|
287
|
+
awareness = aw.value # 0=unaware 1=system 2=per-monitor
|
|
288
|
+
except Exception:
|
|
289
|
+
awareness = -1
|
|
290
|
+
try:
|
|
291
|
+
sysdpi: object = ctypes.windll.user32.GetDpiForSystem() # type: ignore[attr-defined]
|
|
292
|
+
except Exception:
|
|
293
|
+
sysdpi = "err"
|
|
294
|
+
parts.append(f"awareness={awareness}(0=unaware,1=system,2=permon)")
|
|
295
|
+
parts.append(f"GetDpiForSystem={sysdpi}")
|
|
296
|
+
except Exception:
|
|
297
|
+
parts.append("win_dpi=err")
|
|
298
|
+
|
|
299
|
+
for label, getter in (
|
|
300
|
+
("tk_scaling", lambda: f"{float(root.tk.call('tk', 'scaling')):.4f}"),
|
|
301
|
+
("fpixels_1i", lambda: f"{float(root.winfo_fpixels('1i')):.1f}"),
|
|
302
|
+
("screen", lambda: f"{root.winfo_screenwidth()}x{root.winfo_screenheight()}"),
|
|
303
|
+
):
|
|
304
|
+
try:
|
|
305
|
+
parts.append(f"{label}={getter()}")
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
for name in ("TkDefaultFont", "TkHeadingFont", "TkFixedFont"):
|
|
310
|
+
try:
|
|
311
|
+
sz = tkfont.nametofont(name).cget("size")
|
|
312
|
+
parts.append(f"{name}={sz}{'pt' if sz > 0 else 'px'}")
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
try:
|
|
316
|
+
sv = tkfont.Font(root=root, name="SunValleyBodyFont", exists=True)
|
|
317
|
+
parts.append(f"SunValleyBodyFont={sv.cget('size')}px(scaling-immune)")
|
|
318
|
+
except Exception:
|
|
319
|
+
parts.append("SunValleyBodyFont=absent")
|
|
320
|
+
|
|
321
|
+
parts.append(f"FLEXTOOL_DPI={os.environ.get('FLEXTOOL_DPI', '<auto>')}")
|
|
322
|
+
logger.warning("DPI-DIAG: %s", " ".join(parts))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _xrandr_monitors() -> list[dict]:
|
|
326
|
+
"""Parse all connected monitors from xrandr.
|
|
327
|
+
|
|
328
|
+
Returns a list of dicts with keys: name, primary, x, y, w, h,
|
|
329
|
+
mm_w, mm_h, dpi. Empty list if xrandr is unavailable.
|
|
330
|
+
"""
|
|
331
|
+
import re
|
|
332
|
+
try:
|
|
333
|
+
out = subprocess.check_output(
|
|
334
|
+
["xrandr"], text=True, timeout=2, stderr=subprocess.DEVNULL,
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
return []
|
|
338
|
+
monitors: list[dict] = []
|
|
339
|
+
# Match e.g. "DP-0 connected primary 3840x2160+1920+0 ... 700mm x 390mm"
|
|
340
|
+
pattern = re.compile(
|
|
341
|
+
r"^(\S+)\s+connected\s+(primary\s+)?(\d+)x(\d+)\+(\d+)\+(\d+)"
|
|
342
|
+
r".*?(\d+)mm\s+x\s+(\d+)mm",
|
|
343
|
+
)
|
|
344
|
+
for line in out.splitlines():
|
|
345
|
+
m = pattern.match(line)
|
|
346
|
+
if not m:
|
|
347
|
+
continue
|
|
348
|
+
name = m.group(1)
|
|
349
|
+
primary = bool(m.group(2))
|
|
350
|
+
w, h, x, y = int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6))
|
|
351
|
+
mm_w, mm_h = int(m.group(7)), int(m.group(8))
|
|
352
|
+
dpi = (w / mm_w) * 25.4 if mm_w > 0 else 0.0
|
|
353
|
+
monitors.append({
|
|
354
|
+
"name": name, "primary": primary,
|
|
355
|
+
"x": x, "y": y, "w": w, "h": h,
|
|
356
|
+
"mm_w": mm_w, "mm_h": mm_h, "dpi": dpi,
|
|
357
|
+
})
|
|
358
|
+
return monitors
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _xrandr_dpi_for_window(x: int | None, y: int | None) -> float | None:
|
|
362
|
+
"""DPI for the monitor at (x, y); falls back to primary when sensible.
|
|
363
|
+
|
|
364
|
+
A monitor is "suspect" when its physical-size readings are 0
|
|
365
|
+
(virtual / mirrored / headless) or when its derived DPI is < 80
|
|
366
|
+
(impossibly low — typically a virtual buffer claiming a real
|
|
367
|
+
monitor's mm dimensions but at a fraction of the resolution).
|
|
368
|
+
Suspect monitors are skipped in favour of the primary or the
|
|
369
|
+
highest-DPI real monitor.
|
|
370
|
+
"""
|
|
371
|
+
monitors = _xrandr_monitors()
|
|
372
|
+
if not monitors:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
def is_real(m: dict) -> bool:
|
|
376
|
+
return m["mm_w"] > 0 and m["dpi"] >= 80.0
|
|
377
|
+
|
|
378
|
+
chosen = None
|
|
379
|
+
if x is not None and y is not None:
|
|
380
|
+
for m in monitors:
|
|
381
|
+
if (m["x"] <= x < m["x"] + m["w"]
|
|
382
|
+
and m["y"] <= y < m["y"] + m["h"]):
|
|
383
|
+
chosen = m
|
|
384
|
+
break
|
|
385
|
+
if chosen is not None and is_real(chosen):
|
|
386
|
+
return chosen["dpi"]
|
|
387
|
+
|
|
388
|
+
primary = next((m for m in monitors if m["primary"]), None)
|
|
389
|
+
if primary is not None and is_real(primary):
|
|
390
|
+
return primary["dpi"]
|
|
391
|
+
|
|
392
|
+
real = [m for m in monitors if is_real(m)]
|
|
393
|
+
if real:
|
|
394
|
+
# Pick the highest-DPI real monitor as the safest default —
|
|
395
|
+
# users on hi-DPI displays would rather have slightly oversized
|
|
396
|
+
# widgets than unreadable ones.
|
|
397
|
+
return max(real, key=lambda m: m["dpi"])["dpi"]
|
|
398
|
+
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def scale_theme_fonts(root: tk.Tk, factor: float) -> None:
|
|
403
|
+
"""Rescale hardcoded theme fonts (e.g. sv_ttk's SunValley* fonts).
|
|
404
|
+
|
|
405
|
+
sv_ttk defines its own fonts with absolute pixel sizes that do not
|
|
406
|
+
respond to ``tk scaling``. This function finds all ``SunValley*``
|
|
407
|
+
fonts and multiplies their size by *factor*.
|
|
408
|
+
|
|
409
|
+
Must be called **after** ``sv_ttk.set_theme()`` (which creates the
|
|
410
|
+
fonts).
|
|
411
|
+
"""
|
|
412
|
+
if factor <= 1.0:
|
|
413
|
+
return
|
|
414
|
+
import tkinter.font as tkfont
|
|
415
|
+
|
|
416
|
+
for name in tkfont.names(root):
|
|
417
|
+
if name.startswith("SunValley"):
|
|
418
|
+
font = tkfont.Font(root=root, name=name, exists=True)
|
|
419
|
+
current_size = font.cget("size")
|
|
420
|
+
# Negative size = pixels, positive = points; scale either way
|
|
421
|
+
font.configure(size=round(current_size * factor))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
from matplotlib.figure import Figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _figure_size_bytes(fig: Figure) -> int:
|
|
10
|
+
"""Estimate memory usage of a matplotlib Figure in bytes."""
|
|
11
|
+
try:
|
|
12
|
+
renderer = fig.canvas.get_renderer()
|
|
13
|
+
# Rendered bitmap size: width * height * 4 (RGBA)
|
|
14
|
+
w, h = int(renderer.width), int(renderer.height)
|
|
15
|
+
return w * h * 4
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
# Fallback: estimate from figure size in inches * dpi
|
|
19
|
+
dpi = fig.get_dpi() or 100
|
|
20
|
+
w_in, h_in = fig.get_size_inches()
|
|
21
|
+
return int(w_in * dpi) * int(h_in * dpi) * 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlotCache:
|
|
25
|
+
"""Memory-bounded LRU cache for matplotlib Figure objects.
|
|
26
|
+
|
|
27
|
+
*max_bytes* sets the memory limit (default 0.5 GB). Figures are
|
|
28
|
+
evicted oldest-first when the total estimated memory exceeds the
|
|
29
|
+
limit.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_bytes: int = 512 * 1024 * 1024):
|
|
33
|
+
self._max_bytes = max_bytes
|
|
34
|
+
self._cache: OrderedDict[tuple, Figure] = OrderedDict()
|
|
35
|
+
self._sizes: dict[tuple, int] = {}
|
|
36
|
+
self._total_bytes: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def max_gb(self) -> float:
|
|
40
|
+
"""Current limit in GB."""
|
|
41
|
+
return self._max_bytes / (1024 ** 3)
|
|
42
|
+
|
|
43
|
+
@max_gb.setter
|
|
44
|
+
def max_gb(self, value: float) -> None:
|
|
45
|
+
"""Set limit in GB and evict if necessary."""
|
|
46
|
+
self._max_bytes = int(value * 1024 ** 3)
|
|
47
|
+
self._evict_to_limit()
|
|
48
|
+
|
|
49
|
+
def get(self, key: tuple) -> Figure | None:
|
|
50
|
+
if key in self._cache:
|
|
51
|
+
self._cache.move_to_end(key)
|
|
52
|
+
return self._cache[key]
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def put(self, key: tuple, fig: Figure) -> None:
|
|
56
|
+
fig_bytes = _figure_size_bytes(fig)
|
|
57
|
+
|
|
58
|
+
if key in self._cache:
|
|
59
|
+
# Update existing entry
|
|
60
|
+
old_bytes = self._sizes.get(key, 0)
|
|
61
|
+
self._total_bytes -= old_bytes
|
|
62
|
+
self._cache.move_to_end(key)
|
|
63
|
+
self._cache[key] = fig
|
|
64
|
+
self._sizes[key] = fig_bytes
|
|
65
|
+
self._total_bytes += fig_bytes
|
|
66
|
+
else:
|
|
67
|
+
self._cache[key] = fig
|
|
68
|
+
self._sizes[key] = fig_bytes
|
|
69
|
+
self._total_bytes += fig_bytes
|
|
70
|
+
|
|
71
|
+
self._evict_to_limit()
|
|
72
|
+
|
|
73
|
+
def _evict_to_limit(self) -> None:
|
|
74
|
+
"""Evict oldest entries until total size is within the limit."""
|
|
75
|
+
while self._total_bytes > self._max_bytes and self._cache:
|
|
76
|
+
evicted_key, evicted_fig = self._cache.popitem(last=False)
|
|
77
|
+
evicted_bytes = self._sizes.pop(evicted_key, 0)
|
|
78
|
+
self._total_bytes -= evicted_bytes
|
|
79
|
+
evicted_fig.clf()
|
|
80
|
+
plt.close(evicted_fig)
|
|
81
|
+
|
|
82
|
+
def clear(self) -> None:
|
|
83
|
+
for fig in self._cache.values():
|
|
84
|
+
fig.clf()
|
|
85
|
+
plt.close(fig)
|
|
86
|
+
self._cache.clear()
|
|
87
|
+
self._sizes.clear()
|
|
88
|
+
self._total_bytes = 0
|