diff --git a/assets/icon-128.png b/assets/icon-128.png new file mode 100644 index 0000000..57d93c7 Binary files /dev/null and b/assets/icon-128.png differ diff --git a/assets/icon-16.png b/assets/icon-16.png new file mode 100644 index 0000000..02840db Binary files /dev/null and b/assets/icon-16.png differ diff --git a/assets/icon-256.png b/assets/icon-256.png new file mode 100644 index 0000000..a8c0b9f Binary files /dev/null and b/assets/icon-256.png differ diff --git a/assets/icon-32.png b/assets/icon-32.png new file mode 100644 index 0000000..85d7b42 Binary files /dev/null and b/assets/icon-32.png differ diff --git a/assets/icon-48.png b/assets/icon-48.png new file mode 100644 index 0000000..e42d133 Binary files /dev/null and b/assets/icon-48.png differ diff --git a/assets/icon-64.png b/assets/icon-64.png new file mode 100644 index 0000000..f1cd829 Binary files /dev/null and b/assets/icon-64.png differ diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..ff34bdc --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/install_desktop.sh b/install_desktop.sh new file mode 100755 index 0000000..29c7965 --- /dev/null +++ b/install_desktop.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# install_desktop.sh — put "Lektor AI" in the application menu and the dock. +# +# Two things live outside this folder afterwards, both under ~/.local and both +# removable with --uninstall: +# +# ~/.local/share/applications/lektor-ai.desktop +# ~/.local/share/icons/hicolor//apps/lektor-ai.png +# +# The desktop entry matches the running window through StartupWMClass, which is +# the WM_CLASS that ui.py sets. Without the match the dock falls back to the raw +# WM_CLASS for its hover label. +# +set -euo pipefail + +HERE="$(dirname "$(readlink -f "$0")")" +APPS="$HOME/.local/share/applications" +ICONS="$HOME/.local/share/icons/hicolor" +ENTRY="$APPS/lektor-ai.desktop" +SIZES=(16 32 48 64 128 256) + +if [[ "${1:-}" == "--uninstall" ]]; then + rm -f "$ENTRY" + for s in "${SIZES[@]}"; do + rm -f "$ICONS/${s}x${s}/apps/lektor-ai.png" + done + echo "Removed the desktop entry and its icons." +else + for s in "${SIZES[@]}"; do + src="$HERE/assets/icon-$s.png" + [[ -f "$src" ]] || continue + mkdir -p "$ICONS/${s}x${s}/apps" + cp "$src" "$ICONS/${s}x${s}/apps/lektor-ai.png" + done + + mkdir -p "$APPS" + cat > "$ENTRY" </dev/null || true +gtk-update-icon-cache -f -t "$ICONS" 2>/dev/null || true diff --git a/make_icons.py b/make_icons.py new file mode 100755 index 0000000..45502b5 --- /dev/null +++ b/make_icons.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Rasterise icon.svg into the PNG sizes ui.py loads for the window icon. + +Tk 8.6 reads PNG but not SVG, so the icon ships pre-rendered in assets/. The +glyph is laid on a rounded plate in the app's own colours: light on dark keeps +it legible against both a light and a dark dock, which the bare SVG (slate grey +on nothing) is not. + +Needs inkscape for the render and Pillow for the downscales. Run it after +editing icon.svg, then ./install_desktop.sh to refresh the dock's copies. +""" +import re +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SRC = HERE / "icon.svg" +OUT = HERE / "assets" +SIZES = (256, 128, 64, 48, 32, 16) + +PLATE = 64 # plate side, in the units the composite is drawn in +GLYPH = 44 # how much of that side the glyph's longest edge covers +RADIUS = 14 # corner rounding of the plate +BG = "#2b2b2b" # ui.BAR +FG = "#ededed" # ui.FG + + +def glyph_box(svg): + """Bounding box of the drawing, in viewBox units. + + inkscape answers in the file's own pixel units, which are viewBox units + only when width and viewBox agree — so convert. + """ + out = subprocess.run(["inkscape", "--query-all", str(svg)], + capture_output=True, text=True, check=True).stdout + line = next(l for l in out.splitlines() if l.startswith("svg")) + x, y, w, h = (float(v) for v in line.split(",")[1:5]) + + text = svg.read_text() + width = float(re.search(r'\swidth="([\d.]+)"', text).group(1)) + vb_w = float(re.search(r'viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+)', text).group(1)) + u = vb_w / width + return x * u, y * u, w * u, h * u + + +def main(): + if not SRC.is_file(): + sys.exit(f"{SRC.name} not found") + if not shutil.which("inkscape"): + sys.exit("inkscape not found — needed to rasterise the SVG") + try: + from PIL import Image + except ImportError: + sys.exit("Pillow not found — needed to downscale the render") + + d = re.search(r'\sd="([^"]+)"', SRC.read_text()).group(1) + x, y, w, h = glyph_box(SRC) + + # Scale the glyph to GLYPH along its longest edge, then centre what that + # leaves. The offsets cancel the bbox origin, which is not at 0,0. + s = GLYPH / max(w, h) + tx = (PLATE - w * s) / 2 - x * s + ty = (PLATE - h * s) / 2 - y * s + + OUT.mkdir(exist_ok=True) + composite = OUT / "_composite.svg" + composite.write_text( + f'\n' + f' \n' + f' \n' + f' \n' + f' \n\n') + + master = OUT / "_master.png" + subprocess.run(["inkscape", "--export-type=png", + f"--export-filename={master}", "-w", "512", "-h", "512", + str(composite)], check=True, capture_output=True) + + img = Image.open(master).convert("RGBA") + for size in SIZES: + img.resize((size, size), Image.LANCZOS).save(OUT / f"icon-{size}.png") + + composite.unlink() + master.unlink() + print(f"glyph bbox {w:.2f}x{h:.2f} at ({x:.2f}, {y:.2f}) " + f"-> scale {s:.3f}, offset ({tx:.2f}, {ty:.2f})") + print("wrote " + ", ".join(f"icon-{s}.png" for s in SIZES) + f" in {OUT}") + + +if __name__ == "__main__": + main() diff --git a/ui.py b/ui.py index 6be8896..910eb2a 100644 --- a/ui.py +++ b/ui.py @@ -45,6 +45,11 @@ import lektor_ai_comfy as core # noqa: E402 (path set above) CLI = HERE / "lektor_ai_comfy.py" +# Window icon. Tk 8.6 reads PNG but not SVG, so assets/ holds sizes rendered +# from icon.svg; the WM picks whichever fits its title bar, alt-tab and dock. +ICON_DIR = HERE / "assets" +ICON_SIZES = (256, 128, 64, 48, 32, 16) + # ── Palette, sampled from the mock-up ─────────────────────────────────────── BG = "#3c3c3c" # body BAR = "#2b2b2b" # top strip @@ -1061,7 +1066,11 @@ class SimplePrompt(tk.Toplevel): class LektorUI(tk.Tk): def __init__(self): - super().__init__() + # className lands in WM_CLASS; without it Tk reports itself as "Tk" and + # that is what the dock shows on hover. Tk lowercases everything past + # the first letter, so this is kept as a plain identifier — the pretty + # name comes from lektor-ai.desktop, which matches on StartupWMClass. + super().__init__(className="lektor-ai") self.title("lektor_ai — voiceover queue") self.geometry("1180x900") self.minsize(1000, 600) @@ -1080,6 +1089,7 @@ class LektorUI(tk.Tk): self.out_q = queue.Queue() self._in_redraw = False + self._set_icon() self._style() self._build() self.after(80, self._drain_output) @@ -1093,6 +1103,27 @@ class LektorUI(tk.Tk): self.log(" 'sudo apt install python3-tk', then run with plain python3.") self.log("Add videos with 'Add videos...' or the + button, then press GENERATE.") + # ── window icon ───────────────────────────────────────────────────────── + def _set_icon(self): + """Icon for the title bar, alt-tab and the dock. + + Kept on self because Tk holds only a weak reference to PhotoImages — + drop it and the icon blanks out. Missing or unreadable files are not + worth failing a launch over, so any trouble here is simply ignored. + """ + self._icons = [] + for size in ICON_SIZES: + path = ICON_DIR / f"icon-{size}.png" + if not path.is_file(): + continue + try: + self._icons.append(tk.PhotoImage(file=str(path))) + except tk.TclError: + pass + if self._icons: + # True: also the default for dialogs opened later. + self.iconphoto(True, *self._icons) + # ── theming ───────────────────────────────────────────────────────────── def _style(self): st = ttk.Style(self)