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/__init__.py +7 -0
- treeing/_release_defaults.py +3 -0
- treeing/assets/icon.icns +0 -0
- treeing/assets/icon.ico +0 -0
- treeing/assets/icon.png +0 -0
- treeing/cli/__init__.py +5 -0
- treeing/cli/confirm.py +197 -0
- treeing/cli/help_text.py +311 -0
- treeing/cli/io.py +72 -0
- treeing/cli/main.py +427 -0
- treeing/cli/report.py +104 -0
- treeing/cli_entry.py +16 -0
- treeing/config.py +205 -0
- treeing/core/__init__.py +15 -0
- treeing/core/constants.py +20 -0
- treeing/core/generator.py +567 -0
- treeing/core/parser.py +407 -0
- treeing/core/preview.py +98 -0
- treeing/gui/__init__.py +5 -0
- treeing/gui/app.py +886 -0
- treeing/gui/dnd.py +124 -0
- treeing/gui/icon.py +69 -0
- treeing/gui/preview.py +9 -0
- treeing/gui/settings.py +80 -0
- treeing/gui/tooltip.py +234 -0
- treeing/gui_entry.py +39 -0
- treeing/main.py +21 -0
- treeing/path_checks.py +87 -0
- treeing/strings.bootstrap.json +7 -0
- treeing/strings.json +222 -0
- treeing-1.0.1.dist-info/METADATA +112 -0
- treeing-1.0.1.dist-info/RECORD +36 -0
- treeing-1.0.1.dist-info/WHEEL +5 -0
- treeing-1.0.1.dist-info/entry_points.txt +3 -0
- treeing-1.0.1.dist-info/licenses/LICENSE +21 -0
- treeing-1.0.1.dist-info/top_level.txt +1 -0
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)))
|