Skip to content

Commit 08d2490

Browse files
committed
ui: help_window incremental improvement
1 parent df333a6 commit 08d2490

File tree

1 file changed

+152
-126
lines changed

1 file changed

+152
-126
lines changed

help_window.py

Lines changed: 152 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,86 @@
11
from __future__ import annotations
22

3-
from pathlib import Path
4-
from collections.abc import Callable
5-
3+
import platform
4+
import re
5+
import shutil
6+
import subprocess
7+
import sys
68
import tkinter as tk
7-
from tkinter import ttk, messagebox
9+
from collections.abc import Callable
10+
from pathlib import Path
11+
from tkinter import messagebox, ttk
812
from tkinter.scrolledtext import ScrolledText
913

1014
from workbench_core import resolve_tool_path
1115

16+
# --- Constants for better maintainability ---
17+
_TOC_WIDTH = 30
18+
_SEARCH_HIGHLIGHT_TAG = "search_hit"
19+
_BOOKMARK_HIGHLIGHT_TAG = "bookmark_hit"
20+
21+
# --- Global variables for app metadata ---
1222
_APP_NAME = "YT Audio Workbench"
1323
_VERSION = "0.0"
1424

1525

1626
def set_app_meta(app_name: str, version: str) -> None:
27+
"""Sets the application name and version for the help/about dialogs."""
1728
global _APP_NAME, _VERSION
1829
_APP_NAME, _VERSION = app_name, version
1930

2031

2132
def _center_on_screen(win: tk.Toplevel) -> None:
33+
"""Centers a Toplevel window on the screen."""
2234
try:
2335
win.update_idletasks()
24-
w = win.winfo_width() or 800
25-
h = win.winfo_height() or 600
36+
w = win.winfo_width()
37+
h = win.winfo_height()
2638
sw = win.winfo_screenwidth()
2739
sh = win.winfo_screenheight()
2840
x = int((sw - w) / 2)
2941
y = int((sh - h) / 2)
30-
win.geometry(f"{w}x{h}+{x}+{y}")
31-
except Exception:
42+
win.geometry(f"+{x}+{y}")
43+
except tk.TclError:
3244
pass
3345

3446

3547
def _tool_info(cmd: str, args: list[str]) -> list[str]:
36-
import shutil, subprocess, sys, os
37-
38-
first_line = "(not found)"
39-
p = shutil.which(cmd) or resolve_tool_path(cmd)
40-
if not p:
41-
# Some additional platform-specific locations
42-
if os.name == "nt":
43-
for base in [
44-
os.environ.get("LOCALAPPDATA", ""),
45-
os.environ.get("ProgramFiles", ""),
46-
os.environ.get("ProgramFiles(x86)", ""),
47-
]:
48-
for sub in ["yt-dlp", "FFmpeg", "mp3gain"]:
49-
cand = os.path.join(
50-
base, sub, cmd + (".exe" if not cmd.endswith(".exe") else "")
51-
)
52-
if os.path.exists(cand):
53-
p = cand
54-
break
55-
if p:
56-
break
57-
elif sys.platform == "darwin":
58-
for base in ["/opt/homebrew/bin", "/usr/local/bin"]:
59-
cand = os.path.join(base, cmd)
60-
if os.path.exists(cand):
61-
p = cand
62-
break
63-
else:
64-
for base in ["/usr/bin", "/usr/local/bin"]:
65-
cand = os.path.join(base, cmd)
66-
if os.path.exists(cand):
67-
p = cand
68-
break
69-
70-
out: list[str] = [f"{cmd} path: {p or '(not found)'}"]
48+
"""Gathers version information for a given command-line tool."""
49+
path = shutil.which(cmd) or resolve_tool_path(cmd)
50+
out: list[str] = [f"{cmd} path: {path or '(not found)'}"]
51+
52+
if not path:
53+
return out
7154
try:
72-
if p:
73-
proc = subprocess.run(
74-
[p] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=2
75-
)
76-
first_line = proc.stdout.splitlines()[0].strip() if proc.stdout else "(no output)"
55+
proc = subprocess.run(
56+
[path] + args,
57+
stdout=subprocess.PIPE,
58+
stderr=subprocess.STDOUT,
59+
text=True,
60+
timeout=3,
61+
encoding='utf-8',
62+
)
63+
first_line = proc.stdout.splitlines()[0].strip() if proc.stdout else "(no output)"
7764
out.append(f"{cmd} version: {first_line}")
78-
except Exception as e: # pragma: no cover - purely diagnostic
65+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
7966
out.append(f"{cmd} version: error: {e}")
8067
return out
8168

8269

8370
def _copy_diagnostics(parent: tk.Misc, get_text: Callable[[str, str], str]) -> None:
84-
import platform, sys
85-
71+
"""Collects and copies system/tool diagnostic info to the clipboard."""
8672
lines: list[str] = [
8773
f"{_APP_NAME} v{_VERSION}".strip(),
8874
f"Python: {sys.version.splitlines()[0]}",
8975
f"OS: {platform.system()} {platform.release()}",
9076
]
91-
for tool, args in [
77+
tool_checks = [
9278
("yt-dlp", ["--version"]),
9379
("ffmpeg", ["-version"]),
9480
("ffprobe", ["-version"]),
9581
("mp3gain", ["-v"]),
96-
]:
82+
]
83+
for tool, args in tool_checks:
9784
lines.extend(_tool_info(tool, args))
9885

9986
try:
@@ -104,8 +91,12 @@ def _copy_diagnostics(parent: tk.Misc, get_text: Callable[[str, str], str]) -> N
10491
get_text("help.diag_copied_msg", "Diagnostic info copied to clipboard."),
10592
parent=parent,
10693
)
107-
except Exception:
108-
pass
94+
except tk.TclError:
95+
messagebox.showwarning(
96+
get_text("help.diag_copy_failed_title", "Clipboard Error"),
97+
get_text("help.diag_copy_failed_msg", "Could not copy to clipboard."),
98+
parent=parent,
99+
)
109100

110101

111102
def open_help_window(
@@ -114,99 +105,139 @@ def open_help_window(
114105
get_text: Callable[[str, str], str],
115106
section: str | None = None,
116107
) -> None:
117-
"""Open a help window rendering the HELP.md text; simple ToC based on headings."""
108+
"""Opens a help window rendering the HELP.md text with a navigable ToC."""
118109
top = tk.Toplevel(parent)
119-
top.title(get_text("dialog.help.title", f"Help — {_APP_NAME}").format(app=_APP_NAME))
110+
title = get_text("dialog.help.title", f"Help — {_APP_NAME}")
111+
top.title(title.format(app=_APP_NAME))
120112
top.geometry("940x640")
121113
top.transient(parent)
122114

123-
container = ttk.Frame(top, padding=6)
115+
container = ttk.Frame(top, padding=8)
124116
container.pack(fill="both", expand=True)
117+
container.rowconfigure(1, weight=1)
118+
container.columnconfigure(0, weight=1)
119+
120+
search_frame = ttk.Frame(container)
121+
search_frame.grid(row=0, column=0, sticky="ew", pady=(0, 8))
122+
ttk.Label(search_frame, text="Search:").pack(side="left")
123+
query_var = tk.StringVar()
124+
query_entry = ttk.Entry(search_frame, textvariable=query_var)
125+
query_entry.pack(side="left", padx=6, fill="x", expand=True)
125126

126-
searchf = ttk.Frame(container)
127-
searchf.pack(fill="x")
128-
ttk.Label(searchf, text="Search:").pack(side="left")
129-
qvar = tk.StringVar()
130-
q = ttk.Entry(searchf, textvariable=qvar, width=50)
131-
q.pack(side="left", padx=6)
127+
body_frame = ttk.Frame(container)
128+
body_frame.grid(row=1, column=0, sticky="nsew")
129+
body_frame.rowconfigure(0, weight=1)
130+
body_frame.columnconfigure(1, weight=1)
132131

133-
body = ttk.Frame(container)
134-
body.pack(fill="both", expand=True)
132+
toc_listbox = tk.Listbox(body_frame, width=_TOC_WIDTH)
133+
toc_listbox.grid(row=0, column=0, sticky="ns", padx=(0, 8))
135134

136-
toc = tk.Listbox(body, width=28)
137-
toc.pack(side="left", fill="y", padx=(0, 8))
135+
text_widget = ScrolledText(body_frame, wrap="word", padx=5, pady=5)
136+
text_widget.grid(row=0, column=1, sticky="nsew")
138137

139-
txt = ScrolledText(body, wrap="word")
140-
txt.pack(side="right", fill="both", expand=True)
138+
text_widget.tag_configure(_SEARCH_HIGHLIGHT_TAG, background="yellow", foreground="black")
139+
text_widget.tag_configure(_BOOKMARK_HIGHLIGHT_TAG, background="#e0e8f0")
141140

142-
# Load Markdown and build anchors (very simple headings-based ToC)
143141
try:
144142
content = help_path.read_text(encoding="utf-8")
145-
except Exception as e:
146-
txt.insert("1.0", f"Failed to load HELP.md: {e}")
143+
lines = content.splitlines()
144+
text_widget.insert("1.0", content)
145+
except OSError as e:
146+
text_widget.insert("1.0", f"Failed to load help file '{help_path}':\n\n{e}")
147147
_center_on_screen(top)
148148
top.grab_set()
149-
top.wait_window()
150149
return
151150

152151
anchors: list[tuple[str, int, int]] = []
153-
lines = content.splitlines()
152+
heading_pattern = re.compile(r"^(#+)\s+(.*)")
154153
for i, line in enumerate(lines, start=1):
155-
if line.startswith("#"):
156-
depth = len(line) - len(line.lstrip("#"))
157-
title = line.lstrip("#").strip()
154+
match = heading_pattern.match(line)
155+
if match:
156+
depth = len(match.group(1))
157+
title = match.group(2).strip()
158158
anchors.append((title, i, depth))
159-
txt.insert("1.0", content)
159+
indent = " " * (depth - 1)
160+
toc_listbox.insert("end", f"{indent}{title}")
160161

161-
for title, _ln, depth in anchors:
162-
toc.insert("end", (" " * (depth - 1)) + title)
163-
164-
def do_search(*_):
165-
term = qvar.get().strip().lower()
162+
def do_search(start_pos: str = "1.0"):
163+
text_widget.tag_remove(_SEARCH_HIGHLIGHT_TAG, "1.0", "end")
164+
term = query_var.get()
166165
if not term:
166+
return None
167+
pos = text_widget.search(term, start_pos, stopindex="end", nocase=True)
168+
if pos:
169+
end_pos = f"{pos}+{len(term)}c"
170+
text_widget.tag_add(_SEARCH_HIGHLIGHT_TAG, pos, end_pos)
171+
text_widget.see(pos)
172+
query_entry.focus_set()
173+
return pos
174+
if start_pos == "1.0":
175+
messagebox.showinfo("Search", f"Term not found: '{term}'", parent=top)
176+
return None
177+
178+
def find_next():
179+
last_hit = text_widget.tag_ranges(_SEARCH_HIGHLIGHT_TAG)
180+
start_pos = last_hit[1] if last_hit else "1.0"
181+
if not do_search(start_pos):
182+
do_search("1.0")
183+
184+
find_next_button = ttk.Button(search_frame, text="Find Next", command=find_next)
185+
find_next_button.pack(side="left", padx=6)
186+
query_entry.bind("<Return>", lambda e: find_next())
187+
188+
def jump_to_selection(event=None):
189+
# 1. Get the currently selected item in the listbox.
190+
selections = toc_listbox.curselection()
191+
if not selections:
167192
return
168-
text = txt.get("1.0", "end-1c").lower()
169-
pos = text.find(term)
170-
if pos >= 0:
171-
line = txt.get("1.0", f"{pos}c").count("\n") + 1
172-
txt.see(f"{line}.0")
173-
txt.tag_remove("sel", "1.0", "end")
174-
txt.tag_add("sel", f"{line}.0", f"{line}.0 lineend")
175-
176-
q.bind("<Return>", do_search)
177-
178-
def jump(_e=None, target: str | None = None):
179-
if target is None:
180-
sel = toc.curselection()
181-
if not sel:
182-
return
183-
idx = sel[0]
184-
_title, lineno, _depth = anchors[idx]
185-
else:
186-
lineno = None
187-
for title, ln, _depth in anchors:
188-
if title.lower().startswith(target.lower()):
189-
lineno = ln
190-
break
191-
if lineno is None:
192-
return
193-
txt.see(f"{lineno}.0")
194-
txt.tag_remove("sel", "1.0", "end")
195-
txt.tag_add("sel", f"{lineno}.0", f"{lineno}.0 lineend")
196-
197-
toc.bind("<<ListboxSelect>>", jump)
193+
194+
# 2. Clear ALL previous highlights for a clean slate.
195+
text_widget.tag_remove(_BOOKMARK_HIGHLIGHT_TAG, "1.0", "end")
196+
text_widget.tag_remove(_SEARCH_HIGHLIGHT_TAG, "1.0", "end")
197+
198+
# 3. Get the line number for the selected bookmark.
199+
idx = selections[0]
200+
_title, lineno, _depth = anchors[idx]
201+
line_index = f"{lineno}.0"
202+
203+
# 4. Apply the new highlight to the line.
204+
text_widget.tag_add(_BOOKMARK_HIGHLIGHT_TAG, line_index, f"{line_index} lineend")
205+
206+
# --- NEW RELIABLE SCROLL-TO-TOP LOGIC ---
207+
# A. First, scroll to the VERY BOTTOM to ensure the target is above the viewport.
208+
text_widget.yview_moveto(1.0)
209+
210+
# B. Now, use .see(), which will be forced to place the line at the top.
211+
text_widget.see(line_index)
212+
213+
# C. Ensure the UI updates smoothly.
214+
text_widget.update_idletasks()
215+
216+
# This crucial line connects listbox clicks to the jump_to_selection function.
217+
toc_listbox.bind("<<ListboxSelect>>", jump_to_selection)
218+
219+
def jump_to_section_by_name(name: str):
220+
for i, (title, _ln, _depth) in enumerate(anchors):
221+
if title.lower().strip() == name.lower().strip():
222+
toc_listbox.selection_clear(0, "end")
223+
toc_listbox.selection_set(i)
224+
toc_listbox.activate(i)
225+
jump_to_selection()
226+
break
227+
198228
if section:
199-
jump(target=section)
229+
top.after(100, lambda: jump_to_section_by_name(section))
200230

201231
_center_on_screen(top)
202232
top.grab_set()
203-
top.wait_window()
204233

205234

206235
def show_about_dialog(
207-
parent: tk.Misc, help_path: Path, get_text: Callable[[str, str], str]
236+
parent: tk.Misc,
237+
help_path: Path,
238+
get_text: Callable[[str, str], str],
208239
) -> None:
209-
"""Restore original About layout: app + version, two left buttons, Close on right."""
240+
"""Shows the About dialog with diagnostic and help buttons."""
210241
top = tk.Toplevel(parent)
211242
top.title(get_text("dialog.about.title", "About"))
212243
top.resizable(False, False)
@@ -216,11 +247,9 @@ def show_about_dialog(
216247
frm.pack(fill="both", expand=True)
217248

218249
ttk.Label(frm, text=_APP_NAME, font=("TkDefaultFont", 12, "bold")).pack(anchor="w")
219-
ttk.Label(
220-
frm, text=get_text("dialog.about.version", "Version: {version}").format(version=_VERSION)
221-
).pack(anchor="w", pady=(0, 8))
250+
version_text = get_text("dialog.about.version", "Version: {version}")
251+
ttk.Label(frm, text=version_text.format(version=_VERSION)).pack(anchor="w", pady=(0, 8))
222252

223-
# Buttons row: Copy diagnostics & Open Help→Troubleshooting on the left, Close on the right
224253
row = ttk.Frame(frm)
225254
row.pack(fill="x", pady=(4, 0))
226255

@@ -232,7 +261,7 @@ def show_about_dialog(
232261

233262
ttk.Button(
234263
row,
235-
text=get_text("dialog.about.open_troubleshooting", "Open Help → Troubleshooting"),
264+
text=get_text("dialog.about.open_troubleshooting", "Help → Troubleshooting"),
236265
command=lambda: open_help_window(parent, help_path, get_text, section="Troubleshooting"),
237266
).pack(side="left", padx=6)
238267

@@ -241,6 +270,3 @@ def show_about_dialog(
241270
)
242271

243272
_center_on_screen(top)
244-
# Non-modal like the original; comment next two lines back in if you prefer modal
245-
# top.grab_set()
246-
# top.wait_window()

0 commit comments

Comments
 (0)