treeing 1.0.1__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.
treeing/gui/app.py ADDED
@@ -0,0 +1,886 @@
1
+ """
2
+ treeing/gui/app.py
3
+
4
+ Defines the GUI main window and interaction logic.
5
+ Handles input editing, tree preview, generation confirmation, warning
6
+ display, drag-and-drop import, and settings persistence.
7
+ """
8
+
9
+ import sys
10
+ import tkinter as tk
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from tkinter import filedialog, messagebox, scrolledtext, ttk
14
+
15
+ from .. import __version__
16
+ from ..config import get_string, get_ui_string
17
+ from ..core.constants import DOT_ROOT, VIRTUAL_NODE_NAMES
18
+ from ..core.generator import create_from_tree, iter_nodes
19
+ from ..core.parser import build_tree
20
+ from ..path_checks import verify_output_is_directory, verify_output_writable
21
+ from .dnd import bind_file_drop
22
+ from .icon import apply_window_icon, set_window_icon
23
+ from .preview import format_preview_label
24
+ from .settings import (
25
+ get_font_size,
26
+ get_import_encoding,
27
+ get_import_encodings,
28
+ get_last_generate_dir,
29
+ load_settings,
30
+ save_settings,
31
+ )
32
+ from .tooltip import bind_tooltip
33
+
34
+ _WARNINGS_DIALOG_LIMIT = 8
35
+ _DEFAULT_FONT_FAMILY = 'Courier'
36
+ _TREE_TOGGLE_PX = 22
37
+ _TREE_TOGGLE_ICON_PX = 10
38
+ _PANE_MINSIZE = 220
39
+ _DEFAULT_SASH_RATIO = 0.5
40
+ _DEFAULT_WIDTH = 950
41
+ _DEFAULT_HEIGHT = 650
42
+ _MIN_WIDTH = 720
43
+ _MIN_HEIGHT = 520
44
+ _SCREEN_MARGIN = 16
45
+ # Vertically around 2/5 of the available height (still horizontally centred),
46
+ # to reduce obstruction by the taskbar / Dock at the bottom.
47
+ _VERTICAL_ALIGN_NUM = 2
48
+ _VERTICAL_ALIGN_DEN = 5
49
+
50
+
51
+ def compute_initial_window_geometry(
52
+ *,
53
+ screen_width: int,
54
+ screen_height: int,
55
+ default_width: int = _DEFAULT_WIDTH,
56
+ default_height: int = _DEFAULT_HEIGHT,
57
+ min_width: int = _MIN_WIDTH,
58
+ min_height: int = _MIN_HEIGHT,
59
+ margin: int = _SCREEN_MARGIN,
60
+ ) -> str:
61
+ """
62
+ Compute the window geometry for first display.
63
+
64
+ Centred horizontally; vertically around 2/5 of the available height (to
65
+ reduce obstruction by the taskbar / Dock at the bottom). Width and height
66
+ are clamped to the available screen area and never exceed it.
67
+ """
68
+ max_w = max(min_width, screen_width - margin * 2)
69
+ max_h = max(min_height, screen_height - margin * 2)
70
+ w = max(min_width, min(default_width, max_w))
71
+ h = max(min_height, min(default_height, max_h))
72
+ x = max(0, (screen_width - w) // 2)
73
+ free_h = screen_height - h
74
+ if free_h <= 0:
75
+ y = 0
76
+ else:
77
+ y = max(margin, free_h * _VERTICAL_ALIGN_NUM // _VERTICAL_ALIGN_DEN)
78
+ y = min(y, free_h)
79
+ return f'{w}x{h}+{x}+{y}'
80
+
81
+
82
+ _ABOUT_DIALOG_WIDTH = 520
83
+ _ABOUT_DIALOG_HEIGHT = 400
84
+ _WARNINGS_DIALOG_WIDTH = 640
85
+ _WARNINGS_DIALOG_HEIGHT = 360
86
+
87
+
88
+ def center_toplevel_over_parent(
89
+ parent: tk.Misc,
90
+ win: tk.Misc,
91
+ *,
92
+ width: int,
93
+ height: int,
94
+ ) -> None:
95
+ """Centre a Toplevel inside the parent window's client area."""
96
+ parent.update_idletasks()
97
+ win.update_idletasks()
98
+ px = parent.winfo_rootx()
99
+ py = parent.winfo_rooty()
100
+ pw = parent.winfo_width()
101
+ ph = parent.winfo_height()
102
+ x = px + max(0, (pw - width) // 2)
103
+ y = py + max(0, (ph - height) // 2)
104
+ win.geometry(f'{width}x{height}+{x}+{y}')
105
+
106
+
107
+ def create_dialog_toplevel(
108
+ parent: tk.Misc,
109
+ *,
110
+ title: str,
111
+ icon_source: tk.Misc | None = None,
112
+ minsize: tuple[int, int] | None = None,
113
+ ) -> tk.Toplevel:
114
+ """Create a hidden dialogue first, to avoid it flashing in the top-left before being moved."""
115
+ win = tk.Toplevel(parent)
116
+ win.withdraw()
117
+ win.title(title)
118
+ win.transient(parent)
119
+ if minsize is not None:
120
+ win.minsize(*minsize)
121
+ if icon_source is not None:
122
+ apply_window_icon(win, icon_source=icon_source)
123
+ return win
124
+
125
+
126
+ def reveal_centered_toplevel(
127
+ parent: tk.Misc,
128
+ win: tk.Misc,
129
+ *,
130
+ width: int,
131
+ height: int,
132
+ ) -> None:
133
+ """Centre the dialogue over the parent before showing it, then grab focus modally."""
134
+ center_toplevel_over_parent(parent, win, width=width, height=height)
135
+ win.deiconify()
136
+ win.lift(parent)
137
+ win.grab_set()
138
+ win.focus_force()
139
+
140
+
141
+ def _tree_toggle_icon_font() -> tuple[str, int]:
142
+ """
143
+ Return the icon font used by the expand/collapse button.
144
+
145
+ Symbol-font support varies by platform, so MING picks a common font per
146
+ platform to make sure ▼/▶ render.
147
+ """
148
+ if sys.platform.startswith('win'):
149
+ return ('Segoe UI Symbol', _TREE_TOGGLE_ICON_PX)
150
+ if sys.platform == 'darwin':
151
+ return ('Apple Symbols', _TREE_TOGGLE_ICON_PX + 1)
152
+ return ('DejaVu Sans', _TREE_TOGGLE_ICON_PX)
153
+
154
+
155
+ class TreeingApp:
156
+ """
157
+ Main GUI application class.
158
+
159
+ Handles window layout, widget binding, the parse/generate flow,
160
+ drag-and-drop import, and settings persistence. MING split window
161
+ initialisation into __init__, create_widgets and _present_window so the
162
+ layout is ready before display, avoiding the window jumping from its
163
+ default size to the target size.
164
+ """
165
+
166
+ def __init__(self, root, *, show: bool = True):
167
+ """
168
+ Initialise the app: load settings, create widgets, bind events.
169
+
170
+ With show=False (used in tests) the window is not actually shown.
171
+ """
172
+ self.root = root
173
+ if show:
174
+ root.withdraw()
175
+
176
+ self.root.title(get_string("app_title"))
177
+ set_window_icon(self.root)
178
+ self.root.minsize(_MIN_WIDTH, _MIN_HEIGHT)
179
+ self.root.geometry(f'{_DEFAULT_WIDTH}x{_DEFAULT_HEIGHT}')
180
+
181
+ self._settings = load_settings()
182
+ self._font_size = get_font_size(self._settings)
183
+ self._last_generate_dir = get_last_generate_dir(self._settings)
184
+
185
+ self.current_tree: list = []
186
+ self.warnings: list[str] = []
187
+ self._tooltips: list = []
188
+ self._action_buttons: list[ttk.Button] = []
189
+ self._busy = False
190
+ self._preview_all_expanded = True
191
+
192
+ self.create_widgets()
193
+ self._bind_shortcuts()
194
+ self._bind_close_protocol()
195
+ bind_file_drop(self.text_input, self._on_file_dropped)
196
+
197
+ if show:
198
+ self._present_window()
199
+
200
+ # ------------------------------------------------------------------ UI build
201
+
202
+ def _tip(self, widget, string_key: str, *, above: bool = False) -> None:
203
+ """Bind a tooltip to a widget and keep a reference so it is not collected early."""
204
+ self._tooltips.append(
205
+ bind_tooltip(
206
+ widget, string_key, root=self.root,
207
+ position='above' if above else 'below',
208
+ )
209
+ )
210
+
211
+ def create_widgets(self):
212
+ """
213
+ Build the main interface layout.
214
+
215
+ Two panes (input + preview) on the left and right; below them the
216
+ options bar, action bar and status bar.
217
+ """
218
+ self.main_pane = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
219
+ self.main_pane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
220
+
221
+ left_frame = ttk.Frame(self.main_pane)
222
+ self.main_pane.add(left_frame, weight=1)
223
+ self._build_input_pane(left_frame)
224
+
225
+ right_frame = ttk.Frame(self.main_pane)
226
+ self.main_pane.add(right_frame, weight=1)
227
+ self._build_preview_pane(right_frame)
228
+
229
+ self._build_options_bar()
230
+ self._build_action_bar()
231
+ self._build_status_bar()
232
+
233
+ def _present_window(self) -> None:
234
+ """
235
+ Show the window only after layout is ready.
236
+
237
+ Avoids flashing a small default window at startup.
238
+ """
239
+ self.root.update_idletasks()
240
+ self._balance_main_pane()
241
+ self.root.geometry(compute_initial_window_geometry(
242
+ screen_width=self.root.winfo_screenwidth(),
243
+ screen_height=self.root.winfo_screenheight(),
244
+ ))
245
+ self.root.deiconify()
246
+
247
+ def _balance_main_pane(self) -> None:
248
+ """
249
+ At startup, try to split the input and preview panes evenly.
250
+
251
+ Stops the Text default column width from pushing the sash too far.
252
+ """
253
+ self.root.update_idletasks()
254
+ total = self.main_pane.winfo_width()
255
+ if total < 2 * _PANE_MINSIZE:
256
+ return
257
+ self.main_pane.sashpos(0, int(total * _DEFAULT_SASH_RATIO))
258
+
259
+ def _mono_font(self) -> tuple[str, int]:
260
+ """Return the monospace font for the current font size, used by the input box and warning dialogue."""
261
+ return (_DEFAULT_FONT_FAMILY, self._font_size)
262
+
263
+ def _build_input_pane(self, parent: ttk.Frame) -> None:
264
+ """
265
+ Build the left input area: a label plus a horizontally/vertically scrollable Text.
266
+ """
267
+ parent.columnconfigure(0, weight=1)
268
+ parent.rowconfigure(1, weight=1)
269
+
270
+ input_label = ttk.Label(parent, text=get_string("gui_label_input"))
271
+ input_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 2))
272
+ self._tip(input_label, "gui_tooltip_input")
273
+
274
+ text_frame = ttk.Frame(parent)
275
+ text_frame.grid(row=1, column=0, sticky="nsew")
276
+ text_frame.grid_rowconfigure(0, weight=1)
277
+ text_frame.grid_columnconfigure(0, weight=1)
278
+ self.text_input = tk.Text(
279
+ text_frame, wrap=tk.NONE, font=self._mono_font(), width=1, height=1,
280
+ )
281
+ text_vsb = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=self.text_input.yview)
282
+ text_hsb = ttk.Scrollbar(text_frame, orient=tk.HORIZONTAL, command=self.text_input.xview)
283
+ self.text_input.configure(yscrollcommand=text_vsb.set, xscrollcommand=text_hsb.set)
284
+ self.text_input.grid(row=0, column=0, sticky="nsew")
285
+ text_vsb.grid(row=0, column=1, sticky="ns")
286
+ text_hsb.grid(row=1, column=0, sticky="ew")
287
+
288
+ def _build_preview_pane(self, parent: ttk.Frame) -> None:
289
+ """
290
+ Build the right preview area: a toolbar (expand/collapse button + label) and a Treeview.
291
+ """
292
+ parent.columnconfigure(0, weight=1)
293
+ parent.rowconfigure(1, weight=1)
294
+
295
+ toolbar = ttk.Frame(parent)
296
+ toolbar.grid(row=0, column=0, sticky='w', pady=(0, 2))
297
+
298
+ toggle_wrap = tk.Frame(toolbar, width=_TREE_TOGGLE_PX, height=_TREE_TOGGLE_PX)
299
+ toggle_wrap.pack(side=tk.LEFT, padx=(0, 4))
300
+ toggle_wrap.pack_propagate(False)
301
+ self.tree_toggle_btn = tk.Button(
302
+ toggle_wrap,
303
+ command=self.toggle_tree_expand,
304
+ font=_tree_toggle_icon_font(),
305
+ relief=tk.GROOVE,
306
+ borderwidth=1,
307
+ padx=0,
308
+ pady=0,
309
+ highlightthickness=0,
310
+ cursor='hand2',
311
+ )
312
+ self.tree_toggle_btn.place(
313
+ relx=0.5, rely=0.5, anchor='center',
314
+ width=_TREE_TOGGLE_PX - 2, height=_TREE_TOGGLE_PX - 2,
315
+ )
316
+ self._tip(self.tree_toggle_btn, "gui_tooltip_toggle_tree")
317
+ self._sync_tree_toggle_btn()
318
+
319
+ preview_label = ttk.Label(toolbar, text=get_string("gui_label_preview"))
320
+ preview_label.pack(side=tk.LEFT, anchor=tk.W)
321
+ self._tip(preview_label, "gui_tooltip_preview")
322
+
323
+ tree_frame = ttk.Frame(parent)
324
+ tree_frame.grid(row=1, column=0, sticky='nsew')
325
+ tree_frame.columnconfigure(0, weight=1)
326
+ tree_frame.rowconfigure(0, weight=1)
327
+
328
+ self.tree_view = ttk.Treeview(tree_frame)
329
+ tree_vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tree_view.yview)
330
+ tree_hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=self.tree_view.xview)
331
+ self.tree_view.configure(yscrollcommand=tree_vsb.set, xscrollcommand=tree_hsb.set)
332
+ self.tree_view.grid(row=0, column=0, sticky='nsew')
333
+ tree_vsb.grid(row=0, column=1, sticky='ns')
334
+ tree_hsb.grid(row=1, column=0, sticky='ew')
335
+
336
+ def _build_options_bar(self) -> None:
337
+ """
338
+ Build the options bar (second-version layout): indent unit, encoding, font size, allow nested, abort on conflict, strict directories, disable auto-repair.
339
+ """
340
+ self.options_frame = ttk.Frame(self.root)
341
+ self.options_frame.pack(fill=tk.X, padx=5, pady=(0, 2))
342
+
343
+ row1 = ttk.Frame(self.options_frame)
344
+ row1.pack(fill=tk.X)
345
+ row2 = ttk.Frame(self.options_frame)
346
+ row2.pack(fill=tk.X, pady=(2, 0))
347
+
348
+ indent_label = ttk.Label(row1, text=get_string("gui_label_indent_unit"))
349
+ indent_label.pack(side=tk.LEFT, padx=(0, 4))
350
+ self._tip(indent_label, "gui_tooltip_indent_unit", above=True)
351
+ self.indent_unit_var = tk.StringVar(value="")
352
+ indent_entry = ttk.Entry(row1, textvariable=self.indent_unit_var, width=6)
353
+ indent_entry.pack(side=tk.LEFT, padx=(0, 12))
354
+
355
+ encoding_label = ttk.Label(row1, text=get_string("gui_label_encoding"))
356
+ encoding_label.pack(side=tk.LEFT, padx=(0, 4))
357
+ self._tip(encoding_label, "gui_tooltip_encoding", above=True)
358
+ self._import_encodings = get_import_encodings(self._settings)
359
+ self.encoding_var = tk.StringVar(value=get_import_encoding(self._settings))
360
+ self.encoding_combo = ttk.Combobox(
361
+ row1,
362
+ textvariable=self.encoding_var,
363
+ values=self._import_encodings,
364
+ width=10,
365
+ )
366
+ self.encoding_combo.pack(side=tk.LEFT, padx=(0, 12))
367
+ self.encoding_combo.bind('<<ComboboxSelected>>', lambda _e: self._on_encoding_changed())
368
+ self.encoding_combo.bind('<FocusOut>', lambda _e: self._on_encoding_changed())
369
+
370
+ font_label = ttk.Label(row1, text=get_string("gui_label_font_size"))
371
+ font_label.pack(side=tk.LEFT, padx=(0, 4))
372
+ self._tip(font_label, "gui_tooltip_font_size", above=True)
373
+ self.font_size_var = tk.StringVar(value=str(self._font_size))
374
+ font_spin = ttk.Spinbox(
375
+ row1, from_=8, to=24, width=4, textvariable=self.font_size_var,
376
+ command=self._apply_font_size,
377
+ )
378
+ font_spin.pack(side=tk.LEFT, padx=2)
379
+ font_spin.bind('<Return>', lambda _e: self._apply_font_size())
380
+ font_spin.bind('<FocusOut>', lambda _e: self._apply_font_size())
381
+
382
+ self.allow_nested_var = tk.BooleanVar(value=False)
383
+ allow_nested_cb = ttk.Checkbutton(
384
+ row2, text=get_string("gui_label_allow_nested"), variable=self.allow_nested_var,
385
+ )
386
+ allow_nested_cb.pack(side=tk.LEFT, padx=2)
387
+ self._tip(allow_nested_cb, "gui_tooltip_allow_nested", above=True)
388
+
389
+ self.fail_on_conflict_var = tk.BooleanVar(value=False)
390
+ fail_on_conflict_cb = ttk.Checkbutton(
391
+ row2, text=get_string("gui_label_fail_on_conflict"), variable=self.fail_on_conflict_var,
392
+ )
393
+ fail_on_conflict_cb.pack(side=tk.LEFT, padx=2)
394
+ self._tip(fail_on_conflict_cb, "gui_tooltip_fail_on_conflict", above=True)
395
+
396
+ self.strict_dirs_var = tk.BooleanVar(value=False)
397
+ strict_dirs_cb = ttk.Checkbutton(
398
+ row2, text=get_string("gui_label_strict_dirs"), variable=self.strict_dirs_var,
399
+ )
400
+ strict_dirs_cb.pack(side=tk.LEFT, padx=2)
401
+ self._tip(strict_dirs_cb, "gui_tooltip_strict_dirs", above=True)
402
+
403
+ self.no_fix_var = tk.BooleanVar(value=False)
404
+ no_fix_cb = ttk.Checkbutton(
405
+ row2, text=get_string("gui_label_no_fix"), variable=self.no_fix_var,
406
+ )
407
+ no_fix_cb.pack(side=tk.LEFT, padx=2)
408
+ self._tip(no_fix_cb, "gui_tooltip_no_fix", above=True)
409
+
410
+ def _build_action_bar(self) -> None:
411
+ """
412
+ Build the action bar (second-version layout): import, clear, parse, generate, view warnings, exit, about.
413
+ """
414
+ self.action_frame = ttk.Frame(self.root)
415
+ self.action_frame.pack(fill=tk.X, padx=5, pady=5)
416
+
417
+ import_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_import"), command=self.import_file)
418
+ import_btn.pack(side=tk.LEFT, padx=2)
419
+ self._tip(import_btn, "gui_tooltip_import", above=True)
420
+ self._action_buttons.append(import_btn)
421
+
422
+ clear_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_clear"), command=self.clear_input)
423
+ clear_btn.pack(side=tk.LEFT, padx=(2, 8))
424
+ self._tip(clear_btn, "gui_tooltip_clear", above=True)
425
+ self._action_buttons.append(clear_btn)
426
+
427
+ parse_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_parse"), command=self.parse_tree)
428
+ parse_btn.pack(side=tk.LEFT, padx=2)
429
+ self._tip(parse_btn, "gui_tooltip_parse", above=True)
430
+ self._action_buttons.append(parse_btn)
431
+
432
+ generate_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_generate"), command=self.generate_fs)
433
+ generate_btn.pack(side=tk.LEFT, padx=2)
434
+ self._tip(generate_btn, "gui_tooltip_generate", above=True)
435
+ self._action_buttons.append(generate_btn)
436
+
437
+ self.warnings_btn = ttk.Button(
438
+ self.action_frame, text=get_string("gui_btn_view_warnings"),
439
+ command=self.show_all_warnings, state=tk.DISABLED,
440
+ )
441
+ self.warnings_btn.pack(side=tk.LEFT, padx=2)
442
+ self._tip(self.warnings_btn, "gui_tooltip_view_warnings", above=True)
443
+
444
+ exit_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_exit"), command=self._on_exit)
445
+ exit_btn.pack(side=tk.RIGHT, padx=2)
446
+ self._tip(exit_btn, "gui_tooltip_exit", above=True)
447
+
448
+ about_btn = ttk.Button(self.action_frame, text=get_string("gui_btn_about"), command=self.show_about)
449
+ about_btn.pack(side=tk.RIGHT, padx=2)
450
+ self._tip(about_btn, "gui_tooltip_about", above=True)
451
+
452
+ def _build_status_bar(self) -> None:
453
+ """Build the status bar (second-version layout); double-click opens the warning list."""
454
+ self.status_var = tk.StringVar()
455
+ self.status_var.set(get_string("gui_status_ready"))
456
+ status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
457
+ status_bar.pack(fill=tk.X, side=tk.BOTTOM, padx=5, pady=(2, 5))
458
+ status_bar.bind('<Double-Button-1>', lambda _e: self.show_all_warnings())
459
+
460
+ # ------------------------------------------------------------------ binding & state
461
+
462
+ def _bind_shortcuts(self) -> None:
463
+ """Bind common shortcuts: Ctrl+O import, Ctrl+Enter parse, Ctrl+G generate, Ctrl+L clear."""
464
+ self.root.bind('<Control-o>', lambda _e: self.import_file())
465
+ self.root.bind('<Control-O>', lambda _e: self.import_file())
466
+ self.root.bind('<Control-Return>', lambda _e: self.parse_tree())
467
+ self.root.bind('<Control-g>', lambda _e: self.generate_fs())
468
+ self.root.bind('<Control-G>', lambda _e: self.generate_fs())
469
+ self.root.bind('<Control-l>', lambda _e: self.clear_input())
470
+ self.root.bind('<Control-L>', lambda _e: self.clear_input())
471
+
472
+ def _bind_close_protocol(self) -> None:
473
+ """Bind the window close protocol; ask first when there is unsaved input."""
474
+ self.root.protocol('WM_DELETE_WINDOW', self._on_exit)
475
+
476
+ @contextmanager
477
+ def _busy_guard(self):
478
+ """Context manager: set busy on entry, restore on exit, to prevent re-entry during parse/generate."""
479
+ self._set_busy(True)
480
+ try:
481
+ yield
482
+ finally:
483
+ self._set_busy(False)
484
+
485
+ def _set_busy(self, busy: bool) -> None:
486
+ """Set the busy state, disabling/enabling the action buttons and the warnings button."""
487
+ self._busy = busy
488
+ state = tk.DISABLED if busy else tk.NORMAL
489
+ for btn in self._action_buttons:
490
+ btn.configure(state=state)
491
+ self._update_warnings_button()
492
+
493
+ def _update_warnings_button(self) -> None:
494
+ """Update the "view warnings" button state based on the busy state and whether warnings exist."""
495
+ if self._busy:
496
+ self.warnings_btn.configure(state=tk.DISABLED)
497
+ elif self.warnings:
498
+ self.warnings_btn.configure(state=tk.NORMAL)
499
+ else:
500
+ self.warnings_btn.configure(state=tk.DISABLED)
501
+
502
+ def _persist_settings(self) -> None:
503
+ """Write the current font size, last generate directory and import encoding back to settings.json."""
504
+ self._settings['font_size'] = self._font_size
505
+ if self._last_generate_dir:
506
+ self._settings['last_generate_dir'] = self._last_generate_dir
507
+ enc = self.encoding_var.get().strip()
508
+ if isinstance(enc, str) and enc:
509
+ self._settings['import_encoding'] = enc
510
+ save_settings(self._settings)
511
+
512
+ def _on_encoding_changed(self) -> None:
513
+ """Save the encoding dropdown change immediately, so it persists across launches."""
514
+ enc = self.encoding_var.get().strip()
515
+ if isinstance(enc, str) and enc:
516
+ self._settings['import_encoding'] = enc
517
+ save_settings(self._settings)
518
+
519
+ def _read_import_encoding(self) -> str:
520
+ """Read the currently selected import encoding, falling back to utf-8 when empty."""
521
+ return self.encoding_var.get().strip() or 'utf-8'
522
+
523
+ def _apply_font_size(self) -> None:
524
+ """Apply the font-size input, update the input box font, and persist."""
525
+ self._font_size = get_font_size({'font_size': self.font_size_var.get()})
526
+ self.font_size_var.set(str(self._font_size))
527
+ self.text_input.configure(font=self._mono_font())
528
+ self._persist_settings()
529
+
530
+ def _read_input_text(self) -> str:
531
+ """Read the input box content, stripping trailing newlines."""
532
+ raw = self.text_input.get(1.0, tk.END)
533
+ if raw.endswith('\n'):
534
+ raw = raw[:-1]
535
+ return raw
536
+
537
+ def _has_unsaved_input(self) -> bool:
538
+ """Return whether the input box still has unsaved / uncleared text."""
539
+ return bool(self._read_input_text().strip())
540
+
541
+ def _on_exit(self) -> None:
542
+ """Check for unsaved input before closing; on confirm, persist settings and exit."""
543
+ if self._has_unsaved_input():
544
+ if not messagebox.askyesno(
545
+ get_string("gui_title_close_confirm"),
546
+ get_string("gui_msg_close_confirm"),
547
+ ):
548
+ return
549
+ self._persist_settings()
550
+ self.root.quit()
551
+
552
+ def _on_file_dropped(self, path: str, extra_files: int = 0) -> None:
553
+ """Drag-and-drop import: read the first file's content; ignore the rest with a notice."""
554
+ if self._busy:
555
+ return
556
+ dropped = Path(path)
557
+ if not dropped.is_file():
558
+ return
559
+ try:
560
+ encoding = self._read_import_encoding()
561
+ content = dropped.read_text(encoding=encoding)
562
+ except Exception as e:
563
+ messagebox.showerror(get_string("gui_title_import_error"), str(e))
564
+ return
565
+ self.text_input.delete(1.0, tk.END)
566
+ self.text_input.insert(tk.END, content)
567
+ status = get_string("gui_status_imported", filename=dropped.name)
568
+ if extra_files > 0:
569
+ status += get_string("gui_status_drop_extra_ignored", count=extra_files)
570
+ self.status_var.set(status)
571
+ if messagebox.askyesno(
572
+ get_string("gui_title_import_parse"),
573
+ get_string("gui_msg_import_parse"),
574
+ ):
575
+ self.parse_tree()
576
+
577
+ # ------------------------------------------------------------------ file & input
578
+
579
+ def import_file(self) -> None:
580
+ """Open a file chooser, read the text file into the input box, and ask whether to parse."""
581
+ if self._busy:
582
+ return
583
+ filetypes = [
584
+ (get_string("gui_filetype_text"), get_string("gui_filetype_text_pattern")),
585
+ (get_string("gui_filetype_all"), get_string("gui_filetype_all_pattern")),
586
+ ]
587
+ file_path = filedialog.askopenfilename(
588
+ title=get_string("gui_file_dialog_title_open"),
589
+ filetypes=filetypes,
590
+ )
591
+ if not file_path:
592
+ return
593
+ try:
594
+ encoding = self._read_import_encoding()
595
+ with open(file_path, encoding=encoding) as f:
596
+ content = f.read()
597
+ self.text_input.delete(1.0, tk.END)
598
+ self.text_input.insert(tk.END, content)
599
+ self.status_var.set(get_string("gui_status_imported", filename=Path(file_path).name))
600
+ except Exception as e:
601
+ messagebox.showerror(get_string("gui_title_import_error"), str(e))
602
+ return
603
+
604
+ if messagebox.askyesno(
605
+ get_string("gui_title_import_parse"),
606
+ get_string("gui_msg_import_parse"),
607
+ ):
608
+ self.parse_tree()
609
+
610
+ def clear_input(self) -> None:
611
+ """Clear the input box, preview area, current tree and warning state."""
612
+ if self._busy:
613
+ return
614
+ self.text_input.delete(1.0, tk.END)
615
+ self.tree_view.delete(*self.tree_view.get_children())
616
+ self.current_tree = []
617
+ self.warnings = []
618
+ self._update_warnings_button()
619
+ self.status_var.set(get_string("gui_status_cleared"))
620
+
621
+ # ------------------------------------------------------------------ parse & preview
622
+
623
+ def _build_tree_from_input(self) -> tuple[list, list[str]]:
624
+ """
625
+ Parse the tree from the input box.
626
+
627
+ Empty input returns an empty tree and an empty warning list; an empty
628
+ indent-unit field triggers auto-detection.
629
+ """
630
+ raw = self._read_input_text()
631
+ if not raw.strip():
632
+ return [], []
633
+ indent_raw = self.indent_unit_var.get().strip()
634
+ indent_unit = int(indent_raw) if indent_raw.isdigit() and int(indent_raw) > 0 else None
635
+ return build_tree(
636
+ raw.splitlines(),
637
+ auto_fix=not self.no_fix_var.get(),
638
+ indent_unit=indent_unit,
639
+ strict_dirs=self.strict_dirs_var.get(),
640
+ )
641
+
642
+ def show_about(self) -> None:
643
+ """Pop up the about dialogue, showing version and project information."""
644
+ win = create_dialog_toplevel(
645
+ self.root,
646
+ title=get_string("gui_title_about"),
647
+ icon_source=self.root,
648
+ minsize=(400, 300),
649
+ )
650
+
651
+ frame = ttk.Frame(win, padding=12)
652
+ frame.pack(fill=tk.BOTH, expand=True)
653
+
654
+ text = scrolledtext.ScrolledText(frame, wrap=tk.WORD, font=('', 10))
655
+ text.pack(fill=tk.BOTH, expand=True, pady=(0, 8))
656
+ text.insert(tk.END, get_ui_string("gui_msg_about", version=__version__))
657
+ text.configure(state=tk.DISABLED)
658
+
659
+ ttk.Button(frame, text=get_string("gui_btn_close"), command=win.destroy).pack()
660
+ reveal_centered_toplevel(
661
+ self.root, win,
662
+ width=_ABOUT_DIALOG_WIDTH, height=_ABOUT_DIALOG_HEIGHT,
663
+ )
664
+
665
+ def show_all_warnings(self) -> None:
666
+ """Pop up a dialogue with all current warnings; show a notice when there are none."""
667
+ if not self.warnings:
668
+ messagebox.showinfo(
669
+ get_string("gui_title_warnings"),
670
+ get_string("gui_msg_no_warnings"),
671
+ )
672
+ return
673
+ win = create_dialog_toplevel(
674
+ self.root,
675
+ title=get_string("gui_title_warnings"),
676
+ icon_source=self.root,
677
+ minsize=(480, 280),
678
+ )
679
+
680
+ frame = ttk.Frame(win, padding=12)
681
+ frame.pack(fill=tk.BOTH, expand=True)
682
+
683
+ text = scrolledtext.ScrolledText(frame, wrap=tk.WORD, font=self._mono_font())
684
+ text.pack(fill=tk.BOTH, expand=True, pady=(0, 8))
685
+ text.insert(tk.END, '\n'.join(self.warnings))
686
+ text.configure(state=tk.DISABLED)
687
+
688
+ ttk.Button(frame, text=get_string("gui_btn_close"), command=win.destroy).pack()
689
+ reveal_centered_toplevel(
690
+ self.root, win,
691
+ width=_WARNINGS_DIALOG_WIDTH, height=_WARNINGS_DIALOG_HEIGHT,
692
+ )
693
+
694
+ def _show_warning_dialog(self, title_key: str, msg_key: str, warnings: list[str], **fmt) -> None:
695
+ """Show the first N warnings via messagebox; prompt for the "view all warnings" dialogue for the rest."""
696
+ if not warnings:
697
+ return
698
+ warn_msg = get_string(
699
+ msg_key, count=len(warnings),
700
+ warnings_text='\n'.join(warnings[:_WARNINGS_DIALOG_LIMIT]), **fmt,
701
+ )
702
+ extra = len(warnings) - _WARNINGS_DIALOG_LIMIT
703
+ if extra > 0:
704
+ warn_msg += '\n' + get_string("gui_msg_parse_warning_more", extra=extra)
705
+ warn_msg += '\n' + get_string("gui_msg_view_all_warnings_hint")
706
+ messagebox.showwarning(get_string(title_key), warn_msg)
707
+
708
+ def parse_tree(self) -> None:
709
+ """Parse the input box content, refresh the preview tree and warnings button, and pop up warnings as needed."""
710
+ if self._busy:
711
+ return
712
+ with self._busy_guard():
713
+ if not self._read_input_text().strip():
714
+ self.tree_view.delete(*self.tree_view.get_children())
715
+ self.current_tree = []
716
+ self.warnings = []
717
+ self._update_warnings_button()
718
+ messagebox.showinfo(
719
+ get_string("gui_title_no_input"), get_string("gui_msg_no_input_content"),
720
+ )
721
+ return
722
+
723
+ tree, warnings = self._build_tree_from_input()
724
+ self.current_tree = tree
725
+ self.warnings = warnings
726
+ self._update_warnings_button()
727
+
728
+ if not tree:
729
+ self.current_tree = []
730
+ self.tree_view.delete(*self.tree_view.get_children())
731
+ messagebox.showinfo(
732
+ get_string("gui_title_no_input"), get_string("core_warn_empty_input"),
733
+ )
734
+ return
735
+
736
+ self._show_warning_dialog("gui_title_parse_warning", "gui_msg_parse_warning", warnings)
737
+ self.display_tree(tree)
738
+ node_count = sum(1 for _ in iter_nodes(tree))
739
+ self.status_var.set(get_string("gui_status_parsed", count=node_count, warnings=len(warnings)))
740
+
741
+ def display_tree(self, tree, parent="") -> None:
742
+ """Clear the preview area and refill it from tree, fully expanded by default."""
743
+ self.tree_view.delete(*self.tree_view.get_children())
744
+ self._populate_tree(tree, parent)
745
+ self._preview_all_expanded = True
746
+ self._sync_tree_toggle_btn()
747
+
748
+ def _sync_tree_toggle_btn(self) -> None:
749
+ """Icon for the current expand state: ▼ expanded, ▶ collapsed."""
750
+ key = (
751
+ "gui_btn_tree_icon_expanded" if self._preview_all_expanded
752
+ else "gui_btn_tree_icon_collapsed"
753
+ )
754
+ self.tree_toggle_btn.configure(text=get_string(key))
755
+
756
+ def toggle_tree_expand(self) -> None:
757
+ """Toggle the preview area between fully expanded and fully collapsed."""
758
+ if self._preview_all_expanded:
759
+ self.collapse_all_tree()
760
+ self._preview_all_expanded = False
761
+ else:
762
+ self.expand_all_tree()
763
+ self._preview_all_expanded = True
764
+ self._sync_tree_toggle_btn()
765
+
766
+ def expand_all_tree(self) -> None:
767
+ """Expand every node in the preview tree."""
768
+ def walk(item: str) -> None:
769
+ self.tree_view.item(item, open=True)
770
+ for child in self.tree_view.get_children(item):
771
+ walk(child)
772
+ for item in self.tree_view.get_children():
773
+ walk(item)
774
+
775
+ def _populate_tree(self, tree, parent) -> None:
776
+ """Recursively insert nodes into the Treeview; virtual nodes get a grey italic label."""
777
+ if not hasattr(self, '_tag_configured'):
778
+ self.tree_view.tag_configure('virtual', foreground='grey', font=('', 9, 'italic'))
779
+ self._tag_configured = True
780
+
781
+ allow_nested = self.allow_nested_var.get()
782
+ for node in tree:
783
+ name = node['name']
784
+ is_virtual = name in VIRTUAL_NODE_NAMES
785
+ display_name = format_preview_label(node, allow_nested=allow_nested)
786
+ item = self.tree_view.insert(parent, 'end', text=display_name, open=True)
787
+ if is_virtual or name == DOT_ROOT:
788
+ self.tree_view.item(item, tags=('virtual',))
789
+ self._populate_tree(node.get('children', []), item)
790
+
791
+ def collapse_all_tree(self) -> None:
792
+ """Collapse every node in the preview tree."""
793
+ def walk(item: str) -> None:
794
+ for child in self.tree_view.get_children(item):
795
+ walk(child)
796
+ self.tree_view.item(item, open=False)
797
+ for item in self.tree_view.get_children():
798
+ walk(item)
799
+
800
+ # ------------------------------------------------------------------ generate
801
+
802
+ def generate_fs(self) -> None:
803
+ """Open a directory chooser, then create directories and files on disk after confirmation."""
804
+ if self._busy:
805
+ return
806
+ tree, parse_warnings = self._build_tree_from_input()
807
+ if not tree:
808
+ if parse_warnings:
809
+ messagebox.showinfo(
810
+ get_string("gui_title_no_input"), get_string("core_warn_empty_input"),
811
+ )
812
+ else:
813
+ messagebox.showinfo(
814
+ get_string("gui_title_no_input"), get_string("gui_msg_no_input_content"),
815
+ )
816
+ return
817
+
818
+ with self._busy_guard():
819
+ self.current_tree = tree
820
+ self.warnings = parse_warnings
821
+ self._update_warnings_button()
822
+ self.display_tree(tree)
823
+
824
+ dialog_kw: dict = {'title': get_string("gui_file_dialog_title_generate")}
825
+ if self._last_generate_dir:
826
+ dialog_kw['initialdir'] = self._last_generate_dir
827
+ target_dir = filedialog.askdirectory(**dialog_kw)
828
+ if not target_dir:
829
+ return
830
+
831
+ root_path = Path(target_dir)
832
+ output_err = verify_output_is_directory(root_path)
833
+ if output_err:
834
+ messagebox.showerror(get_string("gui_title_generate_error"), output_err)
835
+ self.status_var.set(get_string("gui_status_gen_failed", error=output_err))
836
+ return
837
+
838
+ writable_err = verify_output_writable(root_path)
839
+ if writable_err:
840
+ messagebox.showerror(get_string("gui_title_generate_error"), writable_err)
841
+ self.status_var.set(get_string("gui_status_gen_failed", error=writable_err))
842
+ return
843
+
844
+ node_count = sum(1 for _ in iter_nodes(self.current_tree))
845
+ if not messagebox.askyesno(
846
+ get_string("gui_title_generate_confirm"),
847
+ get_string("gui_msg_generate_confirm", path=root_path, count=node_count),
848
+ ):
849
+ return
850
+
851
+ try:
852
+ self._last_generate_dir = str(root_path)
853
+ self._persist_settings()
854
+
855
+ gen_warnings: list[str] = []
856
+ create_from_tree(
857
+ self.current_tree,
858
+ root_path,
859
+ dry_run=False,
860
+ warnings=gen_warnings,
861
+ allow_nested_names=self.allow_nested_var.get(),
862
+ fail_on_conflict=self.fail_on_conflict_var.get(),
863
+ )
864
+ self.warnings = list(parse_warnings) + gen_warnings
865
+ self._update_warnings_button()
866
+
867
+ if gen_warnings:
868
+ self._show_warning_dialog(
869
+ "gui_title_gen_warning", "gui_msg_gen_warning", gen_warnings, path=root_path,
870
+ )
871
+ summary = gen_warnings[0]
872
+ if len(gen_warnings) > 1:
873
+ summary += get_string("gui_msg_parse_warning_more", extra=len(gen_warnings) - 1)
874
+ self.status_var.set(get_string(
875
+ "gui_status_generated_with_warnings",
876
+ path=root_path, count=len(self.warnings), summary=summary,
877
+ ))
878
+ else:
879
+ messagebox.showinfo(
880
+ get_string("gui_title_gen_success"),
881
+ get_string("gui_msg_gen_success_content", path=root_path),
882
+ )
883
+ self.status_var.set(get_string("gui_status_generated", path=root_path))
884
+ except Exception as e:
885
+ messagebox.showerror(get_string("gui_title_generate_error"), str(e))
886
+ self.status_var.set(get_string("gui_status_gen_failed", error=str(e)))