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,473 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
import tkinter.font as tkfont
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tkinter import ttk
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Prefixes for directory/file entries in the Treeview
|
|
14
|
+
_DIR_PREFIX = "\U0001f4c1 " # folder emoji
|
|
15
|
+
_FILE_PREFIX = "\U0001f4c4 " # page emoji
|
|
16
|
+
# Fallback plain-text prefixes if emoji cause issues
|
|
17
|
+
_DIR_PREFIX_PLAIN = "[DIR] "
|
|
18
|
+
_FILE_PREFIX_PLAIN = " "
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _format_mtime(timestamp: float) -> str:
|
|
22
|
+
"""Format a modification timestamp as DD.MM.YY hh:mm."""
|
|
23
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
24
|
+
return dt.strftime("%d.%m.%y %H:%M")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_extensions(pattern: str) -> set[str]:
|
|
28
|
+
"""Parse a filetype pattern string like ``'*.xlsx *.sqlite'`` into a set of lowercase extensions.
|
|
29
|
+
|
|
30
|
+
Returns an empty set for wildcard patterns (``'*'``, ``'*.*'``).
|
|
31
|
+
"""
|
|
32
|
+
extensions: set[str] = set()
|
|
33
|
+
for part in pattern.split():
|
|
34
|
+
part = part.strip()
|
|
35
|
+
if part in ("*", "*.*"):
|
|
36
|
+
return set() # wildcard = show all
|
|
37
|
+
if part.startswith("*."):
|
|
38
|
+
extensions.add(part[1:].lower()) # e.g. ".xlsx"
|
|
39
|
+
return extensions
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FilePickerDialog(tk.Toplevel):
|
|
43
|
+
"""Custom file picker with last-modified column and sorting.
|
|
44
|
+
|
|
45
|
+
After the dialog closes, access ``result`` for the selected path(s).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
parent: tk.Misc,
|
|
51
|
+
title: str = "Select File",
|
|
52
|
+
initialdir: str | Path = ".",
|
|
53
|
+
filetypes: list[tuple[str, str]] | None = None,
|
|
54
|
+
multiple: bool = False,
|
|
55
|
+
width: int | None = None,
|
|
56
|
+
height: int | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Args:
|
|
60
|
+
parent: Parent window.
|
|
61
|
+
title: Dialog title.
|
|
62
|
+
initialdir: Starting directory.
|
|
63
|
+
filetypes: List of (description, pattern) like
|
|
64
|
+
``[("Excel files", "*.xlsx"), ("All files", "*")]``.
|
|
65
|
+
multiple: Allow selecting multiple files.
|
|
66
|
+
width: Dialog width in pixels (``None`` = auto from parent).
|
|
67
|
+
height: Dialog height in pixels (``None`` = auto).
|
|
68
|
+
"""
|
|
69
|
+
super().__init__(parent)
|
|
70
|
+
self.title(title)
|
|
71
|
+
|
|
72
|
+
self._multiple = multiple
|
|
73
|
+
self._result: Path | list[Path] | None = None
|
|
74
|
+
|
|
75
|
+
# Resolve initial directory
|
|
76
|
+
init = Path(initialdir).resolve()
|
|
77
|
+
if not init.is_dir():
|
|
78
|
+
init = Path.home()
|
|
79
|
+
self._current_dir = init
|
|
80
|
+
|
|
81
|
+
# File type filters
|
|
82
|
+
if filetypes:
|
|
83
|
+
self._filetypes = filetypes
|
|
84
|
+
else:
|
|
85
|
+
self._filetypes = [("All files", "*")]
|
|
86
|
+
|
|
87
|
+
# Sorting state: (column_id, ascending)
|
|
88
|
+
self._sort_col: str = "name"
|
|
89
|
+
self._sort_asc: bool = True
|
|
90
|
+
|
|
91
|
+
# Detect whether emoji render acceptably (best-effort)
|
|
92
|
+
self._use_emoji = True
|
|
93
|
+
try:
|
|
94
|
+
default_font = tkfont.nametofont("TkDefaultFont")
|
|
95
|
+
# If the font can measure the emoji without error, assume it works
|
|
96
|
+
default_font.measure(_DIR_PREFIX)
|
|
97
|
+
except Exception:
|
|
98
|
+
self._use_emoji = False
|
|
99
|
+
|
|
100
|
+
# ── Modal behaviour ──────────────────────────────────────
|
|
101
|
+
self.transient(parent)
|
|
102
|
+
self.grab_set()
|
|
103
|
+
|
|
104
|
+
# ── Dialog size ──────────────────────────────────────────
|
|
105
|
+
# Em-derive defaults so the picker scales with the user's font size.
|
|
106
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
107
|
+
_m = get_metrics(self)
|
|
108
|
+
_default_w = _m.cw * 70
|
|
109
|
+
_default_h = _m.lh * 25
|
|
110
|
+
_min_w = _m.cw * 40
|
|
111
|
+
_min_h = _m.lh * 15
|
|
112
|
+
if width is None:
|
|
113
|
+
try:
|
|
114
|
+
width = parent.winfo_width()
|
|
115
|
+
except Exception:
|
|
116
|
+
width = _default_w
|
|
117
|
+
if width < _min_w:
|
|
118
|
+
width = _default_w
|
|
119
|
+
if height is None:
|
|
120
|
+
try:
|
|
121
|
+
height = int(parent.winfo_screenheight() * 0.6)
|
|
122
|
+
except Exception:
|
|
123
|
+
height = _default_h
|
|
124
|
+
if height < _min_h:
|
|
125
|
+
height = _default_h
|
|
126
|
+
|
|
127
|
+
self.geometry(f"{width}x{height}")
|
|
128
|
+
self.resizable(True, True)
|
|
129
|
+
self.minsize(_min_w, _min_h)
|
|
130
|
+
|
|
131
|
+
self._build_widgets()
|
|
132
|
+
self._populate()
|
|
133
|
+
|
|
134
|
+
# Close via window-manager "X"
|
|
135
|
+
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
136
|
+
|
|
137
|
+
# ── Keyboard bindings ────────────────────────────────────
|
|
138
|
+
self.bind("<Return>", lambda e: self._on_ok())
|
|
139
|
+
self.bind("<Escape>", lambda e: self._on_cancel())
|
|
140
|
+
|
|
141
|
+
# Centre on parent
|
|
142
|
+
self.update_idletasks()
|
|
143
|
+
try:
|
|
144
|
+
px = parent.winfo_rootx()
|
|
145
|
+
py = parent.winfo_rooty()
|
|
146
|
+
pw = parent.winfo_width()
|
|
147
|
+
ph = parent.winfo_height()
|
|
148
|
+
except Exception:
|
|
149
|
+
px, py, pw, ph = 100, 100, 800, 600
|
|
150
|
+
w = self.winfo_width()
|
|
151
|
+
h = self.winfo_height()
|
|
152
|
+
x = px + (pw - w) // 2
|
|
153
|
+
y = py + (ph - h) // 2
|
|
154
|
+
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
|
155
|
+
|
|
156
|
+
# Block until closed
|
|
157
|
+
parent.wait_window(self)
|
|
158
|
+
|
|
159
|
+
# ── Public API ───────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def result(self) -> Path | list[Path] | None:
|
|
163
|
+
"""Selected file path(s), or ``None`` if cancelled."""
|
|
164
|
+
return self._result
|
|
165
|
+
|
|
166
|
+
# ── Widget construction ──────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def _build_widgets(self) -> None:
|
|
169
|
+
pad = dict(padx=8, pady=4)
|
|
170
|
+
|
|
171
|
+
# ── Location bar ─────────────────────────────────────────
|
|
172
|
+
loc_frame = ttk.Frame(self)
|
|
173
|
+
loc_frame.pack(fill="x", **pad)
|
|
174
|
+
|
|
175
|
+
ttk.Label(loc_frame, text="Location:").pack(side="left")
|
|
176
|
+
self._loc_var = tk.StringVar(value=str(self._current_dir))
|
|
177
|
+
self._loc_entry = ttk.Entry(loc_frame, textvariable=self._loc_var)
|
|
178
|
+
self._loc_entry.pack(side="left", fill="x", expand=True, padx=(5, 0))
|
|
179
|
+
self._loc_entry.bind("<Return>", self._on_location_enter)
|
|
180
|
+
|
|
181
|
+
ttk.Button(
|
|
182
|
+
loc_frame, text="\u25b2", width=3,
|
|
183
|
+
command=lambda: self._navigate_to(self._current_dir.parent),
|
|
184
|
+
).pack(side="left", padx=(4, 0))
|
|
185
|
+
|
|
186
|
+
# ── Treeview ─────────────────────────────────────────────
|
|
187
|
+
tree_frame = ttk.Frame(self)
|
|
188
|
+
tree_frame.pack(fill="both", expand=True, **pad)
|
|
189
|
+
|
|
190
|
+
select_mode = "extended" if self._multiple else "browse"
|
|
191
|
+
self._tree = ttk.Treeview(
|
|
192
|
+
tree_frame,
|
|
193
|
+
columns=("name", "modified"),
|
|
194
|
+
show="headings",
|
|
195
|
+
selectmode=select_mode,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Column headings
|
|
199
|
+
self._tree.heading(
|
|
200
|
+
"name",
|
|
201
|
+
text="Name \u25b2",
|
|
202
|
+
command=lambda: self._on_sort("name"),
|
|
203
|
+
)
|
|
204
|
+
self._tree.heading(
|
|
205
|
+
"modified",
|
|
206
|
+
text="Last Modified",
|
|
207
|
+
command=lambda: self._on_sort("modified"),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Column widths — em-derived so they scale with the user's font size
|
|
211
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
212
|
+
cw = get_metrics(self).cw
|
|
213
|
+
self._tree.column("name", width=cw * 35, minwidth=cw * 15, stretch=True)
|
|
214
|
+
self._tree.column("modified", width=cw * 14, minwidth=cw * 10, stretch=False)
|
|
215
|
+
|
|
216
|
+
# Scrollbar
|
|
217
|
+
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
|
|
218
|
+
self._tree.configure(yscrollcommand=vsb.set)
|
|
219
|
+
|
|
220
|
+
self._tree.pack(side="left", fill="both", expand=True)
|
|
221
|
+
vsb.pack(side="right", fill="y")
|
|
222
|
+
|
|
223
|
+
# Events
|
|
224
|
+
self._tree.bind("<Double-1>", self._on_double_click)
|
|
225
|
+
self._tree.bind("<<TreeviewSelect>>", self._on_selection_changed)
|
|
226
|
+
|
|
227
|
+
# ── File type filter ─────────────────────────────────────
|
|
228
|
+
filter_frame = ttk.Frame(self)
|
|
229
|
+
filter_frame.pack(fill="x", **pad)
|
|
230
|
+
|
|
231
|
+
ttk.Label(filter_frame, text="File type:").pack(side="left")
|
|
232
|
+
|
|
233
|
+
# Build display strings for the combobox
|
|
234
|
+
self._filter_display: list[str] = []
|
|
235
|
+
for desc, pattern in self._filetypes:
|
|
236
|
+
if pattern in ("*", "*.*"):
|
|
237
|
+
self._filter_display.append(f"{desc}")
|
|
238
|
+
else:
|
|
239
|
+
self._filter_display.append(f"{desc} ({pattern})")
|
|
240
|
+
|
|
241
|
+
self._filter_var = tk.StringVar(value=self._filter_display[0])
|
|
242
|
+
self._filter_combo = ttk.Combobox(
|
|
243
|
+
filter_frame,
|
|
244
|
+
textvariable=self._filter_var,
|
|
245
|
+
values=self._filter_display,
|
|
246
|
+
state="readonly",
|
|
247
|
+
width=40,
|
|
248
|
+
)
|
|
249
|
+
self._filter_combo.pack(side="left", fill="x", expand=True, padx=(5, 0))
|
|
250
|
+
self._filter_combo.bind("<<ComboboxSelected>>", lambda e: self._populate())
|
|
251
|
+
|
|
252
|
+
# ── Buttons ──────────────────────────────────────────────
|
|
253
|
+
btn_frame = ttk.Frame(self)
|
|
254
|
+
btn_frame.pack(fill="x", padx=8, pady=(4, 8))
|
|
255
|
+
|
|
256
|
+
self._cancel_btn = ttk.Button(
|
|
257
|
+
btn_frame, text="Cancel", command=self._on_cancel
|
|
258
|
+
)
|
|
259
|
+
self._cancel_btn.pack(side="right", padx=(5, 0))
|
|
260
|
+
|
|
261
|
+
self._ok_btn = ttk.Button(
|
|
262
|
+
btn_frame, text="Ok", command=self._on_ok, state="disabled"
|
|
263
|
+
)
|
|
264
|
+
self._ok_btn.pack(side="right")
|
|
265
|
+
|
|
266
|
+
# ── Directory listing ────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def _get_active_extensions(self) -> set[str]:
|
|
269
|
+
"""Return the set of allowed extensions based on current filter selection."""
|
|
270
|
+
idx = 0
|
|
271
|
+
current = self._filter_var.get()
|
|
272
|
+
for i, display in enumerate(self._filter_display):
|
|
273
|
+
if display == current:
|
|
274
|
+
idx = i
|
|
275
|
+
break
|
|
276
|
+
_, pattern = self._filetypes[idx]
|
|
277
|
+
return _parse_extensions(pattern)
|
|
278
|
+
|
|
279
|
+
def _populate(self) -> None:
|
|
280
|
+
"""Read the current directory and fill the Treeview."""
|
|
281
|
+
self._tree.delete(*self._tree.get_children())
|
|
282
|
+
self._loc_var.set(str(self._current_dir))
|
|
283
|
+
|
|
284
|
+
extensions = self._get_active_extensions()
|
|
285
|
+
|
|
286
|
+
dirs: list[tuple[str, float]] = []
|
|
287
|
+
files: list[tuple[str, float]] = []
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
with os.scandir(self._current_dir) as it:
|
|
291
|
+
for entry in it:
|
|
292
|
+
try:
|
|
293
|
+
if entry.is_dir(follow_symlinks=True):
|
|
294
|
+
stat = entry.stat(follow_symlinks=True)
|
|
295
|
+
dirs.append((entry.name, stat.st_mtime))
|
|
296
|
+
elif entry.is_file(follow_symlinks=True):
|
|
297
|
+
# Apply extension filter
|
|
298
|
+
if extensions:
|
|
299
|
+
suffix = Path(entry.name).suffix.lower()
|
|
300
|
+
if suffix not in extensions:
|
|
301
|
+
continue
|
|
302
|
+
stat = entry.stat(follow_symlinks=True)
|
|
303
|
+
files.append((entry.name, stat.st_mtime))
|
|
304
|
+
except (OSError, PermissionError):
|
|
305
|
+
# Skip entries we cannot stat
|
|
306
|
+
continue
|
|
307
|
+
except (OSError, PermissionError) as exc:
|
|
308
|
+
logger.warning("Cannot read directory %s: %s", self._current_dir, exc)
|
|
309
|
+
|
|
310
|
+
# Sort according to current sort state
|
|
311
|
+
dirs = self._sort_entries(dirs)
|
|
312
|
+
files = self._sort_entries(files)
|
|
313
|
+
|
|
314
|
+
dir_prefix = _DIR_PREFIX if self._use_emoji else _DIR_PREFIX_PLAIN
|
|
315
|
+
file_prefix = _FILE_PREFIX if self._use_emoji else _FILE_PREFIX_PLAIN
|
|
316
|
+
|
|
317
|
+
# ".." entry for going up
|
|
318
|
+
if self._current_dir.parent != self._current_dir:
|
|
319
|
+
self._tree.insert(
|
|
320
|
+
"",
|
|
321
|
+
"end",
|
|
322
|
+
iid="__parent__",
|
|
323
|
+
values=(f"{dir_prefix}..", ""),
|
|
324
|
+
tags=("dir",),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Directories first
|
|
328
|
+
for name, mtime in dirs:
|
|
329
|
+
display_name = f"{dir_prefix}{name}"
|
|
330
|
+
self._tree.insert(
|
|
331
|
+
"",
|
|
332
|
+
"end",
|
|
333
|
+
values=(display_name, _format_mtime(mtime)),
|
|
334
|
+
tags=("dir",),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Then files
|
|
338
|
+
for name, mtime in files:
|
|
339
|
+
display_name = f"{file_prefix}{name}"
|
|
340
|
+
self._tree.insert(
|
|
341
|
+
"",
|
|
342
|
+
"end",
|
|
343
|
+
values=(display_name, _format_mtime(mtime)),
|
|
344
|
+
tags=("file",),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
self._update_ok_state()
|
|
348
|
+
self._update_sort_headings()
|
|
349
|
+
|
|
350
|
+
def _sort_entries(
|
|
351
|
+
self, entries: list[tuple[str, float]]
|
|
352
|
+
) -> list[tuple[str, float]]:
|
|
353
|
+
"""Sort a list of (name, mtime) tuples according to the current sort state."""
|
|
354
|
+
if self._sort_col == "name":
|
|
355
|
+
entries.sort(key=lambda e: e[0].lower(), reverse=not self._sort_asc)
|
|
356
|
+
else: # modified
|
|
357
|
+
entries.sort(key=lambda e: e[1], reverse=not self._sort_asc)
|
|
358
|
+
return entries
|
|
359
|
+
|
|
360
|
+
# ── Sorting ──────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
def _on_sort(self, col: str) -> None:
|
|
363
|
+
"""Toggle sort order for the given column."""
|
|
364
|
+
if self._sort_col == col:
|
|
365
|
+
self._sort_asc = not self._sort_asc
|
|
366
|
+
else:
|
|
367
|
+
self._sort_col = col
|
|
368
|
+
self._sort_asc = True
|
|
369
|
+
self._populate()
|
|
370
|
+
|
|
371
|
+
def _update_sort_headings(self) -> None:
|
|
372
|
+
"""Update column heading text to show the sort indicator."""
|
|
373
|
+
arrow = " \u25b2" if self._sort_asc else " \u25bc"
|
|
374
|
+
for col_id, label in [("name", "Name"), ("modified", "Last Modified")]:
|
|
375
|
+
if col_id == self._sort_col:
|
|
376
|
+
self._tree.heading(col_id, text=f"{label}{arrow}")
|
|
377
|
+
else:
|
|
378
|
+
self._tree.heading(col_id, text=label)
|
|
379
|
+
|
|
380
|
+
# ── Navigation ───────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def _navigate_to(self, path: Path) -> None:
|
|
383
|
+
"""Change the current directory and refresh."""
|
|
384
|
+
resolved = path.resolve()
|
|
385
|
+
if resolved.is_dir():
|
|
386
|
+
self._current_dir = resolved
|
|
387
|
+
self._populate()
|
|
388
|
+
|
|
389
|
+
def _on_location_enter(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
390
|
+
"""Handle Enter key in the location bar."""
|
|
391
|
+
typed = self._loc_var.get().strip()
|
|
392
|
+
if typed:
|
|
393
|
+
candidate = Path(typed)
|
|
394
|
+
if candidate.is_dir():
|
|
395
|
+
self._navigate_to(candidate)
|
|
396
|
+
else:
|
|
397
|
+
self._loc_var.set(str(self._current_dir))
|
|
398
|
+
|
|
399
|
+
def _on_double_click(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
400
|
+
"""Handle double-click on a Treeview item."""
|
|
401
|
+
sel = self._tree.selection()
|
|
402
|
+
if not sel:
|
|
403
|
+
return
|
|
404
|
+
item = sel[0]
|
|
405
|
+
tags = self._tree.item(item, "tags")
|
|
406
|
+
values = self._tree.item(item, "values")
|
|
407
|
+
|
|
408
|
+
if "dir" in tags:
|
|
409
|
+
# Navigate into directory
|
|
410
|
+
raw_name = values[0]
|
|
411
|
+
name = self._strip_prefix(raw_name)
|
|
412
|
+
if name == "..":
|
|
413
|
+
self._navigate_to(self._current_dir.parent)
|
|
414
|
+
else:
|
|
415
|
+
self._navigate_to(self._current_dir / name)
|
|
416
|
+
elif "file" in tags:
|
|
417
|
+
# Select file and confirm
|
|
418
|
+
self._on_ok()
|
|
419
|
+
|
|
420
|
+
# ── Selection ────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
def _on_selection_changed(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
423
|
+
"""Enable/disable Ok button based on selection."""
|
|
424
|
+
self._update_ok_state()
|
|
425
|
+
|
|
426
|
+
def _update_ok_state(self) -> None:
|
|
427
|
+
"""Enable Ok only when at least one file is selected."""
|
|
428
|
+
has_file_selected = False
|
|
429
|
+
for item in self._tree.selection():
|
|
430
|
+
tags = self._tree.item(item, "tags")
|
|
431
|
+
if "file" in tags:
|
|
432
|
+
has_file_selected = True
|
|
433
|
+
break
|
|
434
|
+
self._ok_btn.configure(state="normal" if has_file_selected else "disabled")
|
|
435
|
+
|
|
436
|
+
# ── Actions ──────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
def _on_ok(self) -> None:
|
|
439
|
+
"""Confirm selection and close."""
|
|
440
|
+
selected_paths: list[Path] = []
|
|
441
|
+
for item in self._tree.selection():
|
|
442
|
+
tags = self._tree.item(item, "tags")
|
|
443
|
+
if "file" not in tags:
|
|
444
|
+
continue
|
|
445
|
+
values = self._tree.item(item, "values")
|
|
446
|
+
name = self._strip_prefix(values[0])
|
|
447
|
+
selected_paths.append(self._current_dir / name)
|
|
448
|
+
|
|
449
|
+
if not selected_paths:
|
|
450
|
+
return # nothing to do, Ok should be disabled anyway
|
|
451
|
+
|
|
452
|
+
if self._multiple:
|
|
453
|
+
self._result = selected_paths
|
|
454
|
+
else:
|
|
455
|
+
self._result = selected_paths[0]
|
|
456
|
+
|
|
457
|
+
self.grab_release()
|
|
458
|
+
self.destroy()
|
|
459
|
+
|
|
460
|
+
def _on_cancel(self) -> None:
|
|
461
|
+
"""Cancel and close."""
|
|
462
|
+
self._result = None
|
|
463
|
+
self.grab_release()
|
|
464
|
+
self.destroy()
|
|
465
|
+
|
|
466
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
def _strip_prefix(self, display_name: str) -> str:
|
|
469
|
+
"""Remove the directory/file prefix from a display name."""
|
|
470
|
+
for prefix in (_DIR_PREFIX, _FILE_PREFIX, _DIR_PREFIX_PLAIN, _FILE_PREFIX_PLAIN):
|
|
471
|
+
if display_name.startswith(prefix):
|
|
472
|
+
return display_name[len(prefix):]
|
|
473
|
+
return display_name.strip()
|