|
1 |
| - |
2 | 1 | from __future__ import annotations
|
3 |
| -import platform, subprocess, sys |
4 |
| -from workbench_core import resolve_tool_path |
| 2 | + |
| 3 | +import subprocess |
| 4 | +import sys |
5 | 5 | from pathlib import Path
|
| 6 | +from typing import Callable, Optional |
| 7 | + |
6 | 8 | import tkinter as tk
|
7 |
| -from tkinter import ttk, messagebox |
| 9 | +from tkinter import messagebox, ttk |
8 | 10 | from tkinter.scrolledtext import ScrolledText
|
9 | 11 |
|
| 12 | +from workbench_core import resolve_tool_path |
| 13 | + |
10 | 14 | APP_NAME = "YT Audio Workbench"
|
11 | 15 | VERSION = ""
|
12 | 16 |
|
13 | 17 | __all__ = ["open_help_window", "show_about_dialog", "set_app_meta"]
|
14 | 18 |
|
15 |
| -def _center_on_screen(top: tk.Toplevel): |
16 |
| - try: |
17 |
| - top.update_idletasks() |
18 |
| - w = top.winfo_width() or 800 |
19 |
| - h = top.winfo_height() or 600 |
20 |
| - sw = top.winfo_screenwidth() |
21 |
| - sh = top.winfo_screenheight() |
22 |
| - x = int((sw - w) / 2) |
23 |
| - y = int((sh - h) / 2) |
24 |
| - top.geometry(f"{w}x{h}+{x}+{y}") |
25 |
| - except Exception: |
26 |
| - pass |
27 |
| - |
28 | 19 |
|
29 | 20 | def set_app_meta(app_name: str, version: str) -> None:
|
30 |
| - """Set application metadata used by the Help/About windows.""" |
31 | 21 | global APP_NAME, VERSION
|
32 |
| - APP_NAME, VERSION = app_name, version |
| 22 | + APP_NAME = app_name or APP_NAME |
| 23 | + VERSION = version or VERSION |
| 24 | + |
33 | 25 |
|
34 |
| -def open_help_window(parent: tk.Misc, help_path: Path, get_text, section: str | None = None): |
| 26 | +def _center_on_screen(win: tk.Toplevel) -> None: |
| 27 | + win.update_idletasks() |
| 28 | + w = win.winfo_width() |
| 29 | + h = win.winfo_height() |
| 30 | + x = (win.winfo_screenwidth() // 2) - (w // 2) |
| 31 | + y = (win.winfo_screenheight() // 2) - (h // 2) |
| 32 | + win.geometry(f"{w}x{h}+{x}+{y}") |
| 33 | + |
| 34 | + |
| 35 | +def open_help_window( |
| 36 | + parent: tk.Misc, |
| 37 | + help_path: Path, |
| 38 | + get_text: Callable[[str, str], str], |
| 39 | + section: Optional[str] = None, |
| 40 | +) -> None: |
35 | 41 | """Open a help window rendering the HELP.md text; simple ToC based on headings."""
|
36 |
| - top = tk.Toplevel(parent); top.title(get_text("dialog.help.title", f"Help — {APP_NAME}").format(app=APP_NAME)); top.geometry("940x680") |
| 42 | + top = tk.Toplevel(parent) |
| 43 | + top.title(get_text("dialog.help.title", f"Help — {APP_NAME}").format(app=APP_NAME)) |
| 44 | + top.geometry("940x700") |
37 | 45 | top.transient(parent)
|
38 |
| - container = ttk.Frame(top, padding=6); container.pack(fill="both", expand=True) |
39 |
| - searchf = ttk.Frame(container); searchf.pack(fill="x") |
40 |
| - ttk.Label(searchf, text="Search:").pack(side="left") |
41 |
| - qvar = tk.StringVar(); q = ttk.Entry(searchf, textvariable=qvar, width=50); q.pack(side="left", padx=6) |
42 |
| - body = ttk.Frame(container); body.pack(fill="both", expand=True) |
43 |
| - toc = tk.Listbox(body, width=28); toc.pack(side="left", fill="y", padx=(0,8)) |
44 |
| - txt = ScrolledText(body, wrap="word"); txt.pack(side="right", fill="both", expand=True) |
45 |
| - |
46 |
| - def do_search(*_): |
| 46 | + |
| 47 | + container = ttk.Frame(top, padding=6) |
| 48 | + container.pack(fill="both", expand=True) |
| 49 | + |
| 50 | + # -- search bar |
| 51 | + searchf = ttk.Frame(container) |
| 52 | + searchf.pack(fill="x") |
| 53 | + ttk.Label(searchf, text=get_text("dialog.help.search", "Search:")).pack(side="left") |
| 54 | + qvar = tk.StringVar() |
| 55 | + q = ttk.Entry(searchf, textvariable=qvar, width=50) |
| 56 | + q.pack(side="left", padx=6) |
| 57 | + |
| 58 | + # -- main body (toc + text) |
| 59 | + body = ttk.Frame(container) |
| 60 | + body.pack(fill="both", expand=True) |
| 61 | + |
| 62 | + toc = tk.Listbox(body, width=28) |
| 63 | + toc.pack(side="left", fill="y", padx=(0, 8)) |
| 64 | + |
| 65 | + txt = ScrolledText(body, wrap="word") |
| 66 | + txt.pack(side="right", fill="both", expand=True) |
| 67 | + |
| 68 | + def do_search(*_args: object) -> None: |
47 | 69 | term = qvar.get().strip().lower()
|
48 |
| - if not term: return |
| 70 | + if not term: |
| 71 | + return |
49 | 72 | content = txt.get("1.0", "end-1c").lower()
|
50 | 73 | pos = content.find(term)
|
51 | 74 | if pos >= 0:
|
52 |
| - index = txt.index(f"1.0+{pos}c"); line = index.split(".")[0] |
53 |
| - txt.see(f"{line}.0"); txt.tag_remove("sel","1.0","end"); txt.tag_add("sel", f"{line}.0", f"{line}.0 lineend") |
| 75 | + index = txt.index(f"1.0+{pos}c") |
| 76 | + line = index.split(".")[0] |
| 77 | + txt.see(f"{line}.0") |
| 78 | + txt.tag_remove("sel", "1.0", "end") |
| 79 | + txt.tag_add("sel", f"{line}.0", f"{line}.0 lineend") |
| 80 | + |
54 | 81 | q.bind("<Return>", do_search)
|
55 | 82 |
|
| 83 | + # -- load help |
56 | 84 | try:
|
57 |
| - raw = Path(help_path).read_text(encoding="utf-8") |
| 85 | + raw = Path(help_path).read_text(encoding="utf-8", errors="ignore") |
58 | 86 | except Exception:
|
59 | 87 | raw = "# Help\n\n" + get_text("dialog.help.not_found", "Help file not found.")
|
60 |
| - txt.insert("end", raw); txt.config(state="disabled") |
| 88 | + txt.insert("end", raw) |
| 89 | + txt.config(state="disabled") |
61 | 90 |
|
62 | 91 | _center_on_screen(top)
|
63 | 92 |
|
64 |
| - lines = raw.splitlines(); anchors = [] |
65 |
| - for i, line in enumerate(lines, start=1): |
| 93 | + # -- build toc |
| 94 | + anchors: list[tuple[str, int, int]] = [] |
| 95 | + for i, line in enumerate(raw.splitlines(), start=1): |
66 | 96 | if line.startswith("#"):
|
67 | 97 | depth = len(line) - len(line.lstrip("#"))
|
68 | 98 | title = line.lstrip("#").strip()
|
69 |
| - anchors.append((title, i, depth)); toc.insert("end", (" "*(depth-1)) + title) |
| 99 | + anchors.append((title, i, depth)) |
| 100 | + toc.insert("end", (" " * (depth - 1)) + title) |
70 | 101 |
|
71 |
| - def jump(evt=None, target=None): |
| 102 | + def jump(_evt: Optional[tk.Event] = None, target: Optional[str] = None) -> None: |
| 103 | + lineno: Optional[int] |
72 | 104 | if target is None:
|
73 |
| - sel = toc.curselection(); |
74 |
| - if not sel: return |
75 |
| - idx = sel[0]; _, lineno, _ = anchors[idx] |
| 105 | + sel = toc.curselection() |
| 106 | + if not sel: |
| 107 | + return |
| 108 | + idx = sel[0] |
| 109 | + _, lineno, _ = anchors[idx] |
76 | 110 | else:
|
77 | 111 | lineno = None
|
78 |
| - for title, ln, _ in anchors: |
79 |
| - if title.lower().startswith(target.lower()): lineno = ln; break |
80 |
| - if lineno is None: return |
81 |
| - txt.see(f"{lineno}.0"); txt.tag_remove("sel","1.0","end"); txt.tag_add("sel", f"{lineno}.0", f"{lineno}.0 lineend") |
| 112 | + t_lower = target.lower() |
| 113 | + for title, ln, _depth in anchors: |
| 114 | + if title.lower().startswith(t_lower): |
| 115 | + lineno = ln |
| 116 | + break |
| 117 | + if lineno is None: |
| 118 | + return |
| 119 | + txt.see(f"{lineno}.0") |
| 120 | + txt.tag_remove("sel", "1.0", "end") |
| 121 | + txt.tag_add("sel", f"{lineno}.0", f"{lineno}.0 lineend") |
| 122 | + |
82 | 123 | toc.bind("<<ListboxSelect>>", jump)
|
83 |
| - if section: jump(target=section) |
| 124 | + if section: |
| 125 | + jump(target=section) |
| 126 | + |
| 127 | + |
| 128 | +def show_about_dialog(parent: tk.Misc, _help_path: Path, get_text: Callable[[str, str], str]) -> None: |
| 129 | + top = tk.Toplevel(parent) |
| 130 | + top.title(get_text("dialog.about.title", "About")) |
| 131 | + top.resizable(False, False) |
| 132 | + |
| 133 | + frm = ttk.Frame(top, padding=12) |
| 134 | + frm.pack(fill="both", expand=True) |
84 | 135 |
|
85 |
| -def show_about_dialog(parent: tk.Misc, help_path: Path, get_text): |
86 |
| - top = tk.Toplevel(parent); top.title(get_text("dialog.about.title", "About")); top.resizable(False, False) |
87 |
| - frm = ttk.Frame(top, padding=12); frm.pack(fill="both", expand=True) |
88 | 136 | ttk.Label(frm, text=APP_NAME, font=("TkDefaultFont", 12, "bold")).pack(anchor="w")
|
89 |
| - ttk.Label(frm, text=get_text("dialog.about.version", "Version: {version}").format(version=VERSION)).pack(anchor="w", pady=(0,8)) |
90 |
| - ttk.Button(frm, text="Copy diagnostic info", command=lambda: _copy_diag(top)).pack(side="left") |
91 |
| - ttk.Button(frm, text="Open Help → Troubleshooting", command=lambda: open_help_window(parent, help_path, section="Troubleshooting")).pack(side="left", padx=6) |
92 |
| - ttk.Button(frm, text="Close", command=top.destroy).pack(side="right") |
| 137 | + ttk.Label(frm, text=get_text("dialog.about.version", "Version: {version}").format(version=VERSION)).pack( |
| 138 | + anchor="w", pady=(0, 8) |
| 139 | + ) |
| 140 | + |
| 141 | + info = ttk.Label( |
| 142 | + frm, |
| 143 | + text=get_text( |
| 144 | + "dialog.about.blurb", |
| 145 | + "A reliability-first Python GUI for yt-dlp and ffmpeg, designed for " |
| 146 | + "creating high-quality, tagged, and normalized MP3 archives from YouTube.", |
| 147 | + ), |
| 148 | + wraplength=420, |
| 149 | + justify="left", |
| 150 | + ) |
| 151 | + info.pack(anchor="w", pady=(0, 8)) |
| 152 | + |
| 153 | + def _tool_info(cmd: str, version_args: list[str]) -> list[str]: |
| 154 | + out: list[str] = [] |
| 155 | + path = resolve_tool_path(cmd) |
| 156 | + if not path: |
| 157 | + out.append(f"{cmd}: not found") |
| 158 | + return out |
| 159 | + out.append(f"{cmd}: {path}") |
| 160 | + try: |
| 161 | + p = subprocess.run([path, *version_args], capture_output=True, text=True, check=False) |
| 162 | + first = ( |
| 163 | + p.stdout.splitlines()[0] |
| 164 | + if p.stdout.strip() |
| 165 | + else (p.stderr.splitlines()[0] if p.stderr.strip() else "(no output)") |
| 166 | + ) |
| 167 | + out.append(f"{cmd} version: {first}") |
| 168 | + except Exception as e: |
| 169 | + out.append(f"{cmd} version: error: {e}") |
| 170 | + return out |
| 171 | + |
| 172 | + def _copy_diagnostics() -> None: |
| 173 | + lines: list[str] = [] |
| 174 | + for tool, args in [ |
| 175 | + ("yt-dlp", ["--version"]), |
| 176 | + ("ffmpeg", ["-version"]), |
| 177 | + ("ffprobe", ["-version"]), |
| 178 | + ("mp3gain", ["-v"]), |
| 179 | + ]: |
| 180 | + lines.extend(_tool_info(tool, args)) |
| 181 | + try: |
| 182 | + parent.clipboard_clear() |
| 183 | + parent.clipboard_append("\n".join(lines)) |
| 184 | + except Exception: |
| 185 | + pass |
| 186 | + messagebox.showinfo( |
| 187 | + get_text("dialog.copied.title", "Diagnostics copied"), |
| 188 | + get_text("dialog.copied.body", "Diagnostic info copied to clipboard."), |
| 189 | + ) |
| 190 | + |
| 191 | + btns = ttk.Frame(frm) |
| 192 | + btns.pack(fill="x", pady=(8, 0)) |
| 193 | + ttk.Button(btns, text=get_text("dialog.about.copy_diagnostics", "Copy diagnostics"), command=_copy_diagnostics).pack( |
| 194 | + side="left" |
| 195 | + ) |
| 196 | + ttk.Button(btns, text=get_text("dialog.about.close", "Close"), command=top.destroy).pack(side="right") |
93 | 197 |
|
94 | 198 | _center_on_screen(top)
|
95 |
| - |
96 |
| -def _copy_diag(parent: tk.Misc): |
97 |
| - lines = [] |
98 |
| - lines.append(f"{APP_NAME} {VERSION}".strip()) |
99 |
| - lines.append(f"Python: {sys.version.splitlines()[0]}") |
100 |
| - lines.append(f"OS: {platform.system()} {platform.release()}") |
101 |
| - for tool, args in [("yt-dlp", ["--version"]),("ffmpeg", ["-version"]),("ffprobe", ["-version"]),("mp3gain", ["-v"])]: |
102 |
| - lines.extend(_tool_info(tool, args)) |
103 |
| - try: parent.clipboard_clear(); parent.clipboard_append("\n".join(lines)) |
104 |
| - except Exception: pass |
105 |
| - messagebox.showinfo(get_text("dialog.copied.title", "Diagnostics copied"), get_text("dialog.copied.body", "Diagnostic info copied to clipboard.")) |
106 |
| - |
107 |
| -def _find_tool_path(cmd: str) -> str | None: |
108 |
| - import shutil, os, sys |
109 |
| - # 1) PATH |
110 |
| - p = shutil.which(cmd) |
111 |
| - if p: |
112 |
| - return p |
113 |
| - # 2) Windows common locations |
114 |
| - if os.name == "nt": |
115 |
| - candidates = [] |
116 |
| - local = os.environ.get("LOCALAPPDATA","") |
117 |
| - userprofile = os.environ.get("USERPROFILE","") |
118 |
| - program_files = os.environ.get("ProgramFiles","") |
119 |
| - program_files_x86 = os.environ.get("ProgramFiles(x86)","") |
120 |
| - # WinGet shim |
121 |
| - if local: |
122 |
| - candidates.append(os.path.join(local, "Microsoft", "WinGet", "Links", f"{cmd}.exe")) |
123 |
| - # Chocolatey shims |
124 |
| - candidates.append(os.path.join(os.environ.get("ProgramData","C:\\ProgramData"), "chocolatey", "bin", f"{cmd}.exe")) |
125 |
| - # Scoop shims |
126 |
| - if userprofile: |
127 |
| - candidates.append(os.path.join(userprofile, "scoop", "shims", f"{cmd}.exe")) |
128 |
| - # App-specific installs |
129 |
| - if cmd.lower() == "mp3gain": |
130 |
| - if program_files_x86: |
131 |
| - candidates.append(os.path.join(program_files_x86, "MP3Gain", "mp3gain.exe")) |
132 |
| - if program_files: |
133 |
| - candidates.append(os.path.join(program_files, "MP3Gain", "mp3gain.exe")) |
134 |
| - # ffmpeg/ffprobe common dir |
135 |
| - for base in [program_files, program_files_x86]: |
136 |
| - if base: |
137 |
| - candidates.append(os.path.join(base, "FFmpeg", "bin", f"{cmd}.exe")) |
138 |
| - for c in candidates: |
139 |
| - if c and os.path.exists(c): |
140 |
| - return c |
141 |
| - # 3) macOS/Homebrew typical |
142 |
| - if sys.platform == "darwin": |
143 |
| - for pth in ["/opt/homebrew/bin","/usr/local/bin"]: |
144 |
| - cand = os.path.join(pth, cmd) |
145 |
| - if os.path.exists(cand): |
146 |
| - return cand |
147 |
| - # 4) Linux common |
148 |
| - for pth in ["/usr/bin","/usr/local/bin"]: |
149 |
| - cand = os.path.join(pth, cmd) |
150 |
| - if os.path.exists(cand): |
151 |
| - return cand |
152 |
| - return None |
153 |
| - |
154 |
| -def _tool_info(cmd: str, version_args: list[str]) -> list[str]: |
155 |
| - import shutil |
156 |
| - out = []; path = resolve_tool_path(cmd) |
157 |
| - if not path: |
158 |
| - out.append(f"{cmd}: not found"); return out |
159 |
| - out.append(f"{cmd}: {path}") |
160 |
| - try: |
161 |
| - p = subprocess.run([path, *version_args], capture_output=True, text=True, timeout=3) |
162 |
| - first = p.stdout.splitlines()[0] if p.stdout.strip() else (p.stderr.splitlines()[0] if p.stderr.strip() else "(no output)") |
163 |
| - out.append(f"{cmd} version: {first}") |
164 |
| - except Exception as e: out.append(f"{cmd} version: error: {e}") |
165 |
| - return out |
|
0 commit comments