1
1
from __future__ import annotations
2
2
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
6
8
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
8
12
from tkinter .scrolledtext import ScrolledText
9
13
10
14
from workbench_core import resolve_tool_path
11
15
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 ---
12
22
_APP_NAME = "YT Audio Workbench"
13
23
_VERSION = "0.0"
14
24
15
25
16
26
def set_app_meta (app_name : str , version : str ) -> None :
27
+ """Sets the application name and version for the help/about dialogs."""
17
28
global _APP_NAME , _VERSION
18
29
_APP_NAME , _VERSION = app_name , version
19
30
20
31
21
32
def _center_on_screen (win : tk .Toplevel ) -> None :
33
+ """Centers a Toplevel window on the screen."""
22
34
try :
23
35
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 ()
26
38
sw = win .winfo_screenwidth ()
27
39
sh = win .winfo_screenheight ()
28
40
x = int ((sw - w ) / 2 )
29
41
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 :
32
44
pass
33
45
34
46
35
47
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
71
54
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)"
77
64
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 :
79
66
out .append (f"{ cmd } version: error: { e } " )
80
67
return out
81
68
82
69
83
70
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."""
86
72
lines : list [str ] = [
87
73
f"{ _APP_NAME } v{ _VERSION } " .strip (),
88
74
f"Python: { sys .version .splitlines ()[0 ]} " ,
89
75
f"OS: { platform .system ()} { platform .release ()} " ,
90
76
]
91
- for tool , args in [
77
+ tool_checks = [
92
78
("yt-dlp" , ["--version" ]),
93
79
("ffmpeg" , ["-version" ]),
94
80
("ffprobe" , ["-version" ]),
95
81
("mp3gain" , ["-v" ]),
96
- ]:
82
+ ]
83
+ for tool , args in tool_checks :
97
84
lines .extend (_tool_info (tool , args ))
98
85
99
86
try :
@@ -104,8 +91,12 @@ def _copy_diagnostics(parent: tk.Misc, get_text: Callable[[str, str], str]) -> N
104
91
get_text ("help.diag_copied_msg" , "Diagnostic info copied to clipboard." ),
105
92
parent = parent ,
106
93
)
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
+ )
109
100
110
101
111
102
def open_help_window (
@@ -114,99 +105,139 @@ def open_help_window(
114
105
get_text : Callable [[str , str ], str ],
115
106
section : str | None = None ,
116
107
) -> 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 ."""
118
109
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 ))
120
112
top .geometry ("940x640" )
121
113
top .transient (parent )
122
114
123
- container = ttk .Frame (top , padding = 6 )
115
+ container = ttk .Frame (top , padding = 8 )
124
116
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 )
125
126
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 )
132
131
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 ) )
135
134
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" )
138
137
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" )
141
140
142
- # Load Markdown and build anchors (very simple headings-based ToC)
143
141
try :
144
142
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 } " )
147
147
_center_on_screen (top )
148
148
top .grab_set ()
149
- top .wait_window ()
150
149
return
151
150
152
151
anchors : list [tuple [str , int , int ]] = []
153
- lines = content . splitlines ( )
152
+ heading_pattern = re . compile ( r"^(#+)\s+(.*)" )
154
153
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 ()
158
158
anchors .append ((title , i , depth ))
159
- txt .insert ("1.0" , content )
159
+ indent = " " * (depth - 1 )
160
+ toc_listbox .insert ("end" , f"{ indent } { title } " )
160
161
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 ()
166
165
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 :
167
192
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
+
198
228
if section :
199
- jump ( target = section )
229
+ top . after ( 100 , lambda : jump_to_section_by_name ( section ) )
200
230
201
231
_center_on_screen (top )
202
232
top .grab_set ()
203
- top .wait_window ()
204
233
205
234
206
235
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 ],
208
239
) -> None :
209
- """Restore original About layout: app + version, two left buttons, Close on right ."""
240
+ """Shows the About dialog with diagnostic and help buttons."""
210
241
top = tk .Toplevel (parent )
211
242
top .title (get_text ("dialog.about.title" , "About" ))
212
243
top .resizable (False , False )
@@ -216,11 +247,9 @@ def show_about_dialog(
216
247
frm .pack (fill = "both" , expand = True )
217
248
218
249
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 ))
222
252
223
- # Buttons row: Copy diagnostics & Open Help→Troubleshooting on the left, Close on the right
224
253
row = ttk .Frame (frm )
225
254
row .pack (fill = "x" , pady = (4 , 0 ))
226
255
@@ -232,7 +261,7 @@ def show_about_dialog(
232
261
233
262
ttk .Button (
234
263
row ,
235
- text = get_text ("dialog.about.open_troubleshooting" , "Open Help → Troubleshooting" ),
264
+ text = get_text ("dialog.about.open_troubleshooting" , "Help → Troubleshooting" ),
236
265
command = lambda : open_help_window (parent , help_path , get_text , section = "Troubleshooting" ),
237
266
).pack (side = "left" , padx = 6 )
238
267
@@ -241,6 +270,3 @@ def show_about_dialog(
241
270
)
242
271
243
272
_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