flextool 4.0.0__py3-none-any.whl

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