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')
+
+ 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)