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/dnd.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/gui/dnd.py
|
|
3
|
+
|
|
4
|
+
Defines the Windows file drag-and-drop binding.
|
|
5
|
+
Currently effective on Windows only; on other platforms bind_file_drop is a no-op.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bind_file_drop(widget, callback) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Bind a file drop event to a Tk widget.
|
|
14
|
+
|
|
15
|
+
On drop, calls callback(path: str, extra_files: int). No-op on non-Windows
|
|
16
|
+
platforms.
|
|
17
|
+
"""
|
|
18
|
+
if not sys.platform.startswith('win'):
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
_bind_windows_file_drop(widget, callback)
|
|
22
|
+
except (OSError, AttributeError, ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bind_windows_file_drop(widget, callback) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Implement file drop on Windows by subclassing the window procedure.
|
|
29
|
+
|
|
30
|
+
Uses the WM_DROPFILES message and the DragQueryFileW API.
|
|
31
|
+
"""
|
|
32
|
+
import ctypes
|
|
33
|
+
from ctypes import wintypes
|
|
34
|
+
|
|
35
|
+
WM_DROPFILES = 0x0233
|
|
36
|
+
GWL_WNDPROC = -4
|
|
37
|
+
|
|
38
|
+
widget.update_idletasks()
|
|
39
|
+
hwnd = widget.winfo_id()
|
|
40
|
+
|
|
41
|
+
shell32 = ctypes.windll.shell32
|
|
42
|
+
user32 = ctypes.windll.user32
|
|
43
|
+
|
|
44
|
+
WNDPROC = ctypes.WINFUNCTYPE(
|
|
45
|
+
wintypes.LRESULT,
|
|
46
|
+
wintypes.HWND,
|
|
47
|
+
wintypes.UINT,
|
|
48
|
+
wintypes.WPARAM,
|
|
49
|
+
wintypes.LPARAM,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if hasattr(user32, 'GetWindowLongPtrW'):
|
|
53
|
+
get_wndproc = user32.GetWindowLongPtrW
|
|
54
|
+
set_wndproc = user32.SetWindowLongPtrW
|
|
55
|
+
else:
|
|
56
|
+
get_wndproc = user32.GetWindowLongW
|
|
57
|
+
set_wndproc = user32.SetWindowLongW
|
|
58
|
+
|
|
59
|
+
old_wndproc = get_wndproc(hwnd, GWL_WNDPROC)
|
|
60
|
+
|
|
61
|
+
def py_drop_handler(hwnd, msg, wparam, lparam):
|
|
62
|
+
"""Handle WM_DROPFILES: extract the dropped file paths and callback with the first one."""
|
|
63
|
+
if msg == WM_DROPFILES:
|
|
64
|
+
count = shell32.DragQueryFileW(wparam, 0xFFFFFFFF, None, 0)
|
|
65
|
+
paths: list[str] = []
|
|
66
|
+
for i in range(count):
|
|
67
|
+
buf = ctypes.create_unicode_buffer(260)
|
|
68
|
+
needed = shell32.DragQueryFileW(wparam, i, buf, len(buf))
|
|
69
|
+
if needed >= len(buf) - 1:
|
|
70
|
+
buf = ctypes.create_unicode_buffer(needed + 1)
|
|
71
|
+
shell32.DragQueryFileW(wparam, i, buf, len(buf))
|
|
72
|
+
paths.append(buf.value)
|
|
73
|
+
shell32.DragFinish(wparam)
|
|
74
|
+
if paths:
|
|
75
|
+
callback(paths[0], max(0, len(paths) - 1))
|
|
76
|
+
return 0
|
|
77
|
+
return user32.CallWindowProcW(old_wndproc, hwnd, msg, wparam, lparam)
|
|
78
|
+
|
|
79
|
+
new_wndproc = WNDPROC(py_drop_handler)
|
|
80
|
+
set_wndproc(hwnd, GWL_WNDPROC, new_wndproc)
|
|
81
|
+
shell32.DragAcceptFiles(hwnd, True)
|
|
82
|
+
|
|
83
|
+
widget._treeing_drop_old_wndproc = old_wndproc
|
|
84
|
+
widget._treeing_drop_wndproc = new_wndproc
|
|
85
|
+
widget._treeing_drop_hwnd = hwnd
|
|
86
|
+
widget._treeing_drop_user32 = user32
|
|
87
|
+
widget._treeing_drop_shell32 = shell32
|
|
88
|
+
widget._treeing_drop_gwl = GWL_WNDPROC
|
|
89
|
+
|
|
90
|
+
def _on_destroy(event) -> None:
|
|
91
|
+
"""On window destruction, restore the original WNDPROC and revoke drag-drop to avoid dangling callbacks."""
|
|
92
|
+
w = event.widget
|
|
93
|
+
drop_hwnd = getattr(w, '_treeing_drop_hwnd', None)
|
|
94
|
+
if drop_hwnd is None:
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
drop_user32 = w._treeing_drop_user32
|
|
98
|
+
drop_shell32 = w._treeing_drop_shell32
|
|
99
|
+
if hasattr(drop_user32, 'SetWindowLongPtrW'):
|
|
100
|
+
set_proc = drop_user32.SetWindowLongPtrW
|
|
101
|
+
get_proc = drop_user32.GetWindowLongPtrW
|
|
102
|
+
else:
|
|
103
|
+
set_proc = drop_user32.SetWindowLongW
|
|
104
|
+
get_proc = drop_user32.GetWindowLongW
|
|
105
|
+
current = get_proc(drop_hwnd, w._treeing_drop_gwl)
|
|
106
|
+
if current == w._treeing_drop_wndproc:
|
|
107
|
+
set_proc(drop_hwnd, w._treeing_drop_gwl, w._treeing_drop_old_wndproc)
|
|
108
|
+
drop_shell32.DragAcceptFiles(drop_hwnd, False)
|
|
109
|
+
except (OSError, AttributeError, ValueError):
|
|
110
|
+
pass
|
|
111
|
+
for attr in (
|
|
112
|
+
'_treeing_drop_old_wndproc',
|
|
113
|
+
'_treeing_drop_wndproc',
|
|
114
|
+
'_treeing_drop_hwnd',
|
|
115
|
+
'_treeing_drop_user32',
|
|
116
|
+
'_treeing_drop_shell32',
|
|
117
|
+
'_treeing_drop_gwl',
|
|
118
|
+
):
|
|
119
|
+
try:
|
|
120
|
+
delattr(w, attr)
|
|
121
|
+
except AttributeError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
widget.bind('<Destroy>', _on_destroy, add='+')
|
treeing/gui/icon.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/gui/icon.py
|
|
3
|
+
|
|
4
|
+
Defines the window-icon logic.
|
|
5
|
+
Supports Windows (.ico), macOS (.icns) and a PNG fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import tkinter as tk
|
|
10
|
+
|
|
11
|
+
from ..config import _resource_dir
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_window_icon(root: tk.Misc) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Set the window and (macOS) Dock icon.
|
|
17
|
+
|
|
18
|
+
Tk cannot use an emoji string directly; a resource file must be loaded.
|
|
19
|
+
"""
|
|
20
|
+
assets = _resource_dir() / "assets"
|
|
21
|
+
|
|
22
|
+
if sys.platform == "darwin":
|
|
23
|
+
icns = assets / "icon.icns"
|
|
24
|
+
if icns.is_file():
|
|
25
|
+
try:
|
|
26
|
+
root.iconbitmap(str(icns))
|
|
27
|
+
return
|
|
28
|
+
except tk.TclError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
if sys.platform.startswith("win"):
|
|
32
|
+
ico = assets / "icon.ico"
|
|
33
|
+
if ico.is_file():
|
|
34
|
+
try:
|
|
35
|
+
root.iconbitmap(str(ico))
|
|
36
|
+
return
|
|
37
|
+
except tk.TclError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
png = assets / "icon.png"
|
|
41
|
+
if not png.is_file():
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
img = tk.PhotoImage(master=root, file=str(png))
|
|
45
|
+
except (tk.TclError, RuntimeError):
|
|
46
|
+
return
|
|
47
|
+
root.iconphoto(True, img)
|
|
48
|
+
root._treeing_icon = img # keep a reference so it is not garbage-collected
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def apply_window_icon(widget: tk.Misc, *, icon_source: tk.Misc | None = None) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Give a Toplevel sub-window a title-bar icon matching the main window.
|
|
54
|
+
|
|
55
|
+
Reuses the PhotoImage already loaded on icon_source or a parent window to
|
|
56
|
+
avoid reloading; falls back to loading from the resource directory.
|
|
57
|
+
"""
|
|
58
|
+
if icon_source is not None and getattr(icon_source, '_treeing_icon', None) is not None:
|
|
59
|
+
widget.iconphoto(True, icon_source._treeing_icon)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
candidate = icon_source or widget
|
|
63
|
+
while candidate is not None:
|
|
64
|
+
if getattr(candidate, '_treeing_icon', None) is not None:
|
|
65
|
+
widget.iconphoto(True, candidate._treeing_icon)
|
|
66
|
+
return
|
|
67
|
+
candidate = getattr(candidate, 'master', None)
|
|
68
|
+
|
|
69
|
+
set_window_icon(widget)
|
treeing/gui/preview.py
ADDED
treeing/gui/settings.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/gui/settings.py
|
|
3
|
+
|
|
4
|
+
Defines GUI user-preference persistence.
|
|
5
|
+
Saves/loads the last generate directory, font size, import encoding and other
|
|
6
|
+
settings to ~/.treeing/settings.json.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_SETTINGS_PATH = Path.home() / '.treeing' / 'settings.json'
|
|
13
|
+
_DEFAULT_FONT_SIZE = 10
|
|
14
|
+
_DEFAULT_IMPORT_ENCODING = 'utf-8'
|
|
15
|
+
_DEFAULT_IMPORT_ENCODINGS = (
|
|
16
|
+
'utf-8', 'utf-16', 'cp1252', 'iso-8859-1', 'mac-roman',
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_settings() -> dict:
|
|
21
|
+
"""Load user settings; return an empty dict on failure."""
|
|
22
|
+
try:
|
|
23
|
+
if _SETTINGS_PATH.is_file():
|
|
24
|
+
return json.loads(_SETTINGS_PATH.read_text(encoding='utf-8'))
|
|
25
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
26
|
+
pass
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_settings(data: dict) -> None:
|
|
31
|
+
"""Save user settings, creating the parent directory as needed."""
|
|
32
|
+
try:
|
|
33
|
+
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
_SETTINGS_PATH.write_text(
|
|
35
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
36
|
+
encoding='utf-8',
|
|
37
|
+
)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_font_size(settings: dict | None = None) -> int:
|
|
43
|
+
"""Return a valid font size in the range 8-24; fall back to the default for invalid values."""
|
|
44
|
+
raw = (settings or load_settings()).get('font_size', _DEFAULT_FONT_SIZE)
|
|
45
|
+
try:
|
|
46
|
+
size = int(raw)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return _DEFAULT_FONT_SIZE
|
|
49
|
+
return max(8, min(size, 24))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_last_generate_dir(settings: dict | None = None) -> str | None:
|
|
53
|
+
"""Return the directory used for the last generation, or None when it no longer exists."""
|
|
54
|
+
path = (settings or load_settings()).get('last_generate_dir')
|
|
55
|
+
if path and Path(path).is_dir():
|
|
56
|
+
return path
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_import_encodings(settings: dict | None = None) -> list[str]:
|
|
61
|
+
"""Dropdown preset list; settings.json's import_encodings may add or remove entries; empty falls back to the built-in default."""
|
|
62
|
+
raw = (settings or load_settings()).get('import_encodings')
|
|
63
|
+
if isinstance(raw, list) and raw:
|
|
64
|
+
seen: list[str] = []
|
|
65
|
+
for item in raw:
|
|
66
|
+
if isinstance(item, str):
|
|
67
|
+
enc = item.strip()
|
|
68
|
+
if enc and enc not in seen:
|
|
69
|
+
seen.append(enc)
|
|
70
|
+
if seen:
|
|
71
|
+
return seen
|
|
72
|
+
return list(_DEFAULT_IMPORT_ENCODINGS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_import_encoding(settings: dict | None = None) -> str:
|
|
76
|
+
"""Return the last chosen import encoding; default utf-8."""
|
|
77
|
+
raw = (settings or load_settings()).get('import_encoding', _DEFAULT_IMPORT_ENCODING)
|
|
78
|
+
if isinstance(raw, str) and raw.strip():
|
|
79
|
+
return raw.strip()
|
|
80
|
+
return _DEFAULT_IMPORT_ENCODING
|
treeing/gui/tooltip.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/gui/tooltip.py
|
|
3
|
+
|
|
4
|
+
Defines the hover-tooltip component.
|
|
5
|
+
Supports showing explanatory text on Labels, buttons and grouped checkboxes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from ..config import get_string
|
|
12
|
+
|
|
13
|
+
_DEFAULT_WRAP = 420
|
|
14
|
+
_WINDOW_MARGIN = 8
|
|
15
|
+
_DEFAULT_H_OFFSET = 16
|
|
16
|
+
_RIGHT_HALF_INSET = 8
|
|
17
|
+
_TOOLTIP_OFFSET = 4
|
|
18
|
+
_TooltipPosition = Literal['below', 'above']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolTip:
|
|
22
|
+
"""
|
|
23
|
+
A tooltip component that shows explanatory text on hover.
|
|
24
|
+
Supports custom position (below/above), wrap width and a root window.
|
|
25
|
+
|
|
26
|
+
MING changed the Toplevel to "lazy-create + reuse": each ToolTip instance
|
|
27
|
+
holds at most one Toplevel, deiconified on hover and withdrawn on leave,
|
|
28
|
+
instead of creating and destroying one each time. This works around
|
|
29
|
+
macOS-arm, where repeatedly creating/destroying override-redirect windows
|
|
30
|
+
leaves white rectangles (ghost frames) because WindowServer cannot clear
|
|
31
|
+
them in time.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
widget,
|
|
37
|
+
text: str,
|
|
38
|
+
*,
|
|
39
|
+
root: tk.Misc | None = None,
|
|
40
|
+
wraplength: int = _DEFAULT_WRAP,
|
|
41
|
+
position: _TooltipPosition = 'below',
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialise the tooltip and bind the widget's Enter/Leave events.
|
|
45
|
+
|
|
46
|
+
`root` is used to compute the tooltip position; when omitted, the
|
|
47
|
+
top-level window holding the widget is used. No window is created
|
|
48
|
+
here; the real creation happens on first _show.
|
|
49
|
+
"""
|
|
50
|
+
self.widget = widget
|
|
51
|
+
self.text = text
|
|
52
|
+
self.root = root
|
|
53
|
+
self.wraplength = wraplength
|
|
54
|
+
self.position = position
|
|
55
|
+
self.tip_window: tk.Toplevel | None = None
|
|
56
|
+
self._label: tk.Label | None = None
|
|
57
|
+
widget.bind('<Enter>', self._show, add='+')
|
|
58
|
+
widget.bind('<Leave>', self._hide, add='+')
|
|
59
|
+
|
|
60
|
+
def _ensure_window(self, root: tk.Misc) -> tk.Toplevel:
|
|
61
|
+
"""
|
|
62
|
+
Lazily create and reuse a single Toplevel window.
|
|
63
|
+
|
|
64
|
+
On first call, creates the Toplevel, sets it topmost, and packs a
|
|
65
|
+
Label inside; later calls return the existing window. The window
|
|
66
|
+
persists; _hide only withdraws it (no destroy), avoiding the ghost
|
|
67
|
+
frames caused by repeated create/destroy on macOS-arm.
|
|
68
|
+
"""
|
|
69
|
+
if self.tip_window is not None:
|
|
70
|
+
return self.tip_window
|
|
71
|
+
|
|
72
|
+
tw = tk.Toplevel(root)
|
|
73
|
+
tw.withdraw()
|
|
74
|
+
tw.wm_overrideredirect(True)
|
|
75
|
+
# Topmost keeps the tooltip above the main window; some platforms /
|
|
76
|
+
# window managers do not support this attribute, so failures are
|
|
77
|
+
# silently ignored and do not affect display.
|
|
78
|
+
try:
|
|
79
|
+
tw.wm_attributes('-topmost', True)
|
|
80
|
+
except tk.TclError:
|
|
81
|
+
pass
|
|
82
|
+
self._label = tk.Label(
|
|
83
|
+
tw,
|
|
84
|
+
text=self.text,
|
|
85
|
+
justify=tk.LEFT,
|
|
86
|
+
background='#ffffe0',
|
|
87
|
+
relief=tk.SOLID,
|
|
88
|
+
borderwidth=1,
|
|
89
|
+
padx=8,
|
|
90
|
+
pady=6,
|
|
91
|
+
wraplength=self.wraplength,
|
|
92
|
+
)
|
|
93
|
+
self._label.pack()
|
|
94
|
+
self.tip_window = tw
|
|
95
|
+
return tw
|
|
96
|
+
|
|
97
|
+
def _place_tooltip(self, root: tk.Misc, tw: tk.Toplevel) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Compute the tooltip's screen coordinates and move the window.
|
|
100
|
+
|
|
101
|
+
Horizontal: if the widget is to the right of the centre line, right-align
|
|
102
|
+
with a left inset; otherwise left-align; then clamp within the root
|
|
103
|
+
window's left/right edges.
|
|
104
|
+
Vertical: above or below per `position`; if the target side does not fit
|
|
105
|
+
(above the top or below the bottom of the screen), flip to the other
|
|
106
|
+
side so the tooltip never leaves the visible area.
|
|
107
|
+
|
|
108
|
+
Height prefers winfo_reqheight because, while the window is withdrawn,
|
|
109
|
+
winfo_height may return 1 on macOS-arm Tk (not actually mapped), which
|
|
110
|
+
would make an "above" tooltip stick to the widget top and overflow
|
|
111
|
+
downward over the button.
|
|
112
|
+
"""
|
|
113
|
+
root.update_idletasks()
|
|
114
|
+
tw.update_idletasks()
|
|
115
|
+
|
|
116
|
+
root_x = root.winfo_rootx()
|
|
117
|
+
root_w = root.winfo_width()
|
|
118
|
+
root_right = root_x + root_w
|
|
119
|
+
|
|
120
|
+
wx = self.widget.winfo_rootx()
|
|
121
|
+
wy = self.widget.winfo_rooty()
|
|
122
|
+
ww = max(self.widget.winfo_width(), 1)
|
|
123
|
+
wh = max(self.widget.winfo_height(), 1)
|
|
124
|
+
|
|
125
|
+
tip_w = max(_to_int(tw.winfo_reqwidth(), 0), _to_int(tw.winfo_width(), 0), 1)
|
|
126
|
+
tip_h = max(_to_int(tw.winfo_reqheight(), 0), _to_int(tw.winfo_height(), 0), 1)
|
|
127
|
+
|
|
128
|
+
window_cx = root_x + root_w // 2
|
|
129
|
+
widget_cx = wx + ww // 2
|
|
130
|
+
|
|
131
|
+
if widget_cx >= window_cx:
|
|
132
|
+
x = wx + ww - tip_w - _RIGHT_HALF_INSET
|
|
133
|
+
else:
|
|
134
|
+
x = wx + _DEFAULT_H_OFFSET
|
|
135
|
+
|
|
136
|
+
x = max(root_x + _WINDOW_MARGIN, min(x, root_right - tip_w - _WINDOW_MARGIN))
|
|
137
|
+
|
|
138
|
+
y_above = wy - tip_h - _TOOLTIP_OFFSET
|
|
139
|
+
y_below = wy + wh + _TOOLTIP_OFFSET
|
|
140
|
+
|
|
141
|
+
if self.position == 'above':
|
|
142
|
+
y = y_above
|
|
143
|
+
if y < _WINDOW_MARGIN:
|
|
144
|
+
y = y_below
|
|
145
|
+
else:
|
|
146
|
+
y = y_below
|
|
147
|
+
screen_h = _safe_screen_height(root)
|
|
148
|
+
if screen_h and y + tip_h > screen_h - _WINDOW_MARGIN:
|
|
149
|
+
y = y_above
|
|
150
|
+
|
|
151
|
+
tw.wm_geometry(f'+{x}+{y}')
|
|
152
|
+
|
|
153
|
+
def _show(self, _event=None) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Show the tooltip when the mouse enters the widget.
|
|
156
|
+
|
|
157
|
+
Reuses the existing window; refreshes the Label if the text changed
|
|
158
|
+
(may happen when one instance is bound to multiple widgets). Positions
|
|
159
|
+
before deiconify, so it does not appear at the default position and
|
|
160
|
+
then jump (which causes flicker / ghost frames).
|
|
161
|
+
"""
|
|
162
|
+
if not self.text:
|
|
163
|
+
return
|
|
164
|
+
root = self.root or self.widget.winfo_toplevel()
|
|
165
|
+
tw = self._ensure_window(root)
|
|
166
|
+
if self._label is not None:
|
|
167
|
+
self._label.configure(text=self.text)
|
|
168
|
+
self._place_tooltip(root, tw)
|
|
169
|
+
try:
|
|
170
|
+
tw.deiconify()
|
|
171
|
+
except tk.TclError:
|
|
172
|
+
# Silently skip this show when the parent window has been destroyed.
|
|
173
|
+
self.tip_window = None
|
|
174
|
+
self._label = None
|
|
175
|
+
|
|
176
|
+
def _hide(self, _event=None) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Hide the tooltip when the mouse leaves the widget.
|
|
179
|
+
|
|
180
|
+
Withdraw only, never destroy: the window stays for the next hover,
|
|
181
|
+
which from the source eliminates the ghost frames caused by repeatedly
|
|
182
|
+
destroying override-redirect windows on macOS-arm.
|
|
183
|
+
"""
|
|
184
|
+
if self.tip_window is not None:
|
|
185
|
+
try:
|
|
186
|
+
self.tip_window.withdraw()
|
|
187
|
+
except tk.TclError:
|
|
188
|
+
self.tip_window = None
|
|
189
|
+
self._label = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _to_int(value, fallback: int) -> int:
|
|
193
|
+
"""
|
|
194
|
+
Safely convert a Tk geometry value to int, returning fallback on failure.
|
|
195
|
+
|
|
196
|
+
Under mocks or abnormal Tk states, winfo_* may return a non-int (MagicMock,
|
|
197
|
+
empty string, etc.); int() would raise TypeError. This wrapper guards the
|
|
198
|
+
coordinate calculations so they never abort.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
return int(value)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
return fallback
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _safe_screen_height(root: tk.Misc) -> int | None:
|
|
207
|
+
"""
|
|
208
|
+
Return the screen height of the root window's screen, or None on failure / non-int.
|
|
209
|
+
|
|
210
|
+
Tk may raise TclError for winfo_screenheight in some abnormal states, and
|
|
211
|
+
under mocks the return value is not an int; callers skip the below-flip
|
|
212
|
+
check in that case.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
value = root.winfo_screenheight()
|
|
216
|
+
except tk.TclError:
|
|
217
|
+
return None
|
|
218
|
+
if isinstance(value, int):
|
|
219
|
+
return value
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def bind_tooltip(
|
|
224
|
+
widget,
|
|
225
|
+
string_key: str,
|
|
226
|
+
*,
|
|
227
|
+
root: tk.Misc | None = None,
|
|
228
|
+
position: _TooltipPosition = 'below',
|
|
229
|
+
**fmt,
|
|
230
|
+
) -> ToolTip:
|
|
231
|
+
"""Bind a tooltip with text from strings.json to a widget."""
|
|
232
|
+
return ToolTip(
|
|
233
|
+
widget, get_string(string_key, **fmt), root=root, position=position,
|
|
234
|
+
)
|
treeing/gui_entry.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""treeing/gui_entry.py
|
|
2
|
+
|
|
3
|
+
PyInstaller GUI entry point.
|
|
4
|
+
Creates the Tk root and starts TreeingApp, catching ConfigError so the packaged app still exits cleanly.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from treeing.config import ConfigError, get_ui_string
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_gui() -> int:
|
|
12
|
+
"""
|
|
13
|
+
Create the Tk main window and run TreeingApp.
|
|
14
|
+
|
|
15
|
+
If the current environment is missing Tcl/Tk, print an error message and
|
|
16
|
+
return 1, rather than raising an opaque TclError.
|
|
17
|
+
"""
|
|
18
|
+
import tkinter as tk
|
|
19
|
+
|
|
20
|
+
from treeing.gui.app import TreeingApp
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
root = tk.Tk()
|
|
24
|
+
except tk.TclError as exc:
|
|
25
|
+
print(get_ui_string('gui_err_tcl_missing'), file=sys.stderr)
|
|
26
|
+
print(f"Underlying error: {exc}", file=sys.stderr)
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
TreeingApp(root)
|
|
30
|
+
root.mainloop()
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == '__main__':
|
|
35
|
+
try:
|
|
36
|
+
sys.exit(run_gui())
|
|
37
|
+
except ConfigError as e:
|
|
38
|
+
print(str(e), file=sys.stderr)
|
|
39
|
+
sys.exit(1)
|
treeing/main.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""treeing/main.py
|
|
3
|
+
|
|
4
|
+
Unified entry script for development.
|
|
5
|
+
Starts the GUI with no arguments, or runs the CLI with arguments; a ConfigError is caught and reported with a non-zero exit code.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from treeing.cli.main import cli_main
|
|
10
|
+
from treeing.config import ConfigError
|
|
11
|
+
from treeing.gui_entry import run_gui
|
|
12
|
+
|
|
13
|
+
if __name__ == '__main__':
|
|
14
|
+
try:
|
|
15
|
+
if len(sys.argv) > 1:
|
|
16
|
+
sys.exit(cli_main())
|
|
17
|
+
else:
|
|
18
|
+
run_gui()
|
|
19
|
+
except ConfigError as e:
|
|
20
|
+
print(str(e), file=sys.stderr)
|
|
21
|
+
sys.exit(1)
|
treeing/path_checks.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""treeing/path_checks.py
|
|
2
|
+
|
|
3
|
+
Defines output-path validation logic.
|
|
4
|
+
Provides `verify_output_is_directory` and `verify_output_writable`,
|
|
5
|
+
shared by the CLI and GUI, to ensure the target directory is legal and writable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import get_string
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_output_is_directory(output: str | Path) -> str | None:
|
|
18
|
+
"""
|
|
19
|
+
Check whether the output path is legal.
|
|
20
|
+
|
|
21
|
+
Returns an error message if the path exists and is a file; otherwise None.
|
|
22
|
+
"""
|
|
23
|
+
target = Path(output).expanduser()
|
|
24
|
+
if target.exists() and target.is_file():
|
|
25
|
+
return get_string('cli_output_is_file', path=target)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _nearest_existing(path: Path) -> Path | None:
|
|
30
|
+
"""Walk upward from path to the nearest existing parent directory; return None if none exists."""
|
|
31
|
+
probe = path
|
|
32
|
+
while not probe.exists():
|
|
33
|
+
parent = probe.parent
|
|
34
|
+
if parent == probe:
|
|
35
|
+
return None
|
|
36
|
+
probe = parent
|
|
37
|
+
return probe
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _probe_writable_dir(dir_path: Path, report_path: Path) -> str | None:
|
|
41
|
+
"""
|
|
42
|
+
Actually probe whether a directory is writable.
|
|
43
|
+
|
|
44
|
+
Checks os.access first, then tries to create a temporary directory as a
|
|
45
|
+
second safeguard. MING added the temporary-directory test because some
|
|
46
|
+
mount/network paths report writable via access but fail on real writes.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
resolved = dir_path.resolve()
|
|
50
|
+
except OSError:
|
|
51
|
+
return get_string('cli_output_not_writable', path=report_path)
|
|
52
|
+
|
|
53
|
+
if not resolved.is_dir():
|
|
54
|
+
return get_string('cli_output_not_writable', path=report_path)
|
|
55
|
+
|
|
56
|
+
if not os.access(resolved, os.W_OK):
|
|
57
|
+
return get_string('cli_output_not_writable', path=resolved)
|
|
58
|
+
if os.name != 'nt' and not os.access(resolved, os.X_OK):
|
|
59
|
+
return get_string('cli_output_not_writable', path=resolved)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
test_dir = tempfile.mkdtemp(prefix='.treeing-write-test-', dir=str(resolved))
|
|
63
|
+
os.rmdir(test_dir)
|
|
64
|
+
except OSError:
|
|
65
|
+
return get_string('cli_output_not_writable', path=report_path)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_output_writable(output: str | Path) -> str | None:
|
|
70
|
+
"""
|
|
71
|
+
Check whether the output path is ultimately writable.
|
|
72
|
+
|
|
73
|
+
If the target directory does not exist, walk up to the nearest existing
|
|
74
|
+
parent before probing.
|
|
75
|
+
"""
|
|
76
|
+
err = verify_output_is_directory(output)
|
|
77
|
+
if err:
|
|
78
|
+
return err
|
|
79
|
+
|
|
80
|
+
target = Path(output).expanduser()
|
|
81
|
+
if target.exists():
|
|
82
|
+
return _probe_writable_dir(target, target)
|
|
83
|
+
|
|
84
|
+
existing = _nearest_existing(target)
|
|
85
|
+
if existing is None:
|
|
86
|
+
return get_string('cli_output_not_writable', path=output)
|
|
87
|
+
return _probe_writable_dir(existing, target)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"config_err_missing": "Could not find config file strings.json: {path}",
|
|
3
|
+
"config_err_invalid_json": "strings.json has invalid JSON: {path} ({error})",
|
|
4
|
+
"config_err_read": "Could not read config file strings.json: {path} ({error})",
|
|
5
|
+
"config_err_not_object": "strings.json root must be a JSON object: {path}",
|
|
6
|
+
"config_err_bootstrap_missing": "Could not load the bootstrap strings file strings.bootstrap.json: {path}"
|
|
7
|
+
}
|