Give the app an icon and a place in the application menu

icon.svg is the source; make_icons.py rasterises it into assets/icon-<size>.png
at the six sizes a window manager asks for. Tk 8.6 reads PNG but not SVG, so
the icon has to ship pre-rendered, and the glyph is laid on a rounded plate in
the app's own colours — light on dark stays legible against both a light and a
dark dock, which slate grey on nothing does not.

ui.py loads whichever of those files exist and passes them all to iconphoto(),
letting the WM pick per context (title bar, alt-tab, dock). The images are kept
on the instance because Tk holds only a weak reference to a PhotoImage and the
icon blanks out when it is collected. A missing or unreadable file is ignored
rather than allowed to fail a launch.

It also passes className="lektor-ai", which lands in WM_CLASS. Without it Tk
calls itself "Tk" and that is what the dock shows on hover; the readable name
comes from the desktop entry, which matches the window through StartupWMClass.

install_desktop.sh writes ~/.local/share/applications/lektor-ai.desktop and
copies the icons into the hicolor theme, so the app appears in the menu and can
be pinned. Everything it installs lives under ~/.local and comes back out with
--uninstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
This commit is contained in:
2026-08-31 21:14:36 +02:00
co-authored by Claude Opus 5
parent 4a9b05de66
commit d350b314ad
10 changed files with 184 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 48 48" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><path fill="currentColor" fill-rule="evenodd" d="M2 1.5a.5.5 0 0 0-.5.5v44a.5.5 0 0 0 .5.5c5.623 0 8.656-.668 9.894-1.033c.617-.182.974-.75.974-1.343V39.69l2.673.323a3.5 3.5 0 0 0 3.908-3.174l.59-6.844c1.703-.44 2.974-1.043 3.845-1.563c1.151-.686 1.227-2.126.512-3.101l-3.953-5.39C20.238 9.742 12.625 1.5 2 1.5m11 15a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-3 0v-1a1.5 1.5 0 0 1 1.5-1.5m25.766 25.259a15.15 15.15 0 0 0 0-21.518a1.75 1.75 0 0 1 2.468-2.482c7.355 7.312 7.355 19.17 0 26.482a1.75 1.75 0 0 1-2.468-2.482m-4.813-3.934c3.396-3.751 3.396-9.899 0-13.65a1.75 1.75 0 1 1 2.594-2.35c4.604 5.086 4.604 13.265 0 18.35a1.75 1.75 0 1 1-2.594-2.35M28.307 27.78c1.924 1.793 1.924 4.647 0 6.44a1.75 1.75 0 1 0 2.386 2.56c3.41-3.177 3.41-8.383 0-11.56a1.75 1.75 0 1 0-2.386 2.56" clip-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+55
View File
@@ -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/<size>/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" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Lektor AI
Comment=Polish AI voiceover queue
Exec=$HERE/start_lektor_ui.sh
Icon=lektor-ai
Terminal=false
Categories=AudioVideo;Audio;
StartupWMClass=Lektor-ai
DESKTOP
chmod +x "$ENTRY"
echo "Installed $ENTRY"
fi
# Harmless if the tools are missing; the dock picks the change up either way.
update-desktop-database "$APPS" 2>/dev/null || true
gtk-update-icon-cache -f -t "$ICONS" 2>/dev/null || true
Executable
+96
View File
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" '
f'viewBox="0 0 {PLATE} {PLATE}">\n'
f' <rect width="{PLATE}" height="{PLATE}" rx="{RADIUS}" ry="{RADIUS}" '
f'fill="{BG}"/>\n'
f' <g transform="translate({tx:.4f} {ty:.4f}) scale({s:.6f})">\n'
f' <path fill="{FG}" fill-rule="evenodd" clip-rule="evenodd" '
f'd="{d}"/>\n'
f' </g>\n</svg>\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()
+32 -1
View File
@@ -45,6 +45,11 @@ import lektor_ai_comfy as core # noqa: E402 (path set above)
CLI = HERE / "lektor_ai_comfy.py" 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 ─────────────────────────────────────── # ── Palette, sampled from the mock-up ───────────────────────────────────────
BG = "#3c3c3c" # body BG = "#3c3c3c" # body
BAR = "#2b2b2b" # top strip BAR = "#2b2b2b" # top strip
@@ -1061,7 +1066,11 @@ class SimplePrompt(tk.Toplevel):
class LektorUI(tk.Tk): class LektorUI(tk.Tk):
def __init__(self): 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.title("lektor_ai — voiceover queue")
self.geometry("1180x900") self.geometry("1180x900")
self.minsize(1000, 600) self.minsize(1000, 600)
@@ -1080,6 +1089,7 @@ class LektorUI(tk.Tk):
self.out_q = queue.Queue() self.out_q = queue.Queue()
self._in_redraw = False self._in_redraw = False
self._set_icon()
self._style() self._style()
self._build() self._build()
self.after(80, self._drain_output) 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(" 'sudo apt install python3-tk', then run with plain python3.")
self.log("Add videos with 'Add videos...' or the + button, then press GENERATE.") 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 ───────────────────────────────────────────────────────────── # ── theming ─────────────────────────────────────────────────────────────
def _style(self): def _style(self):
st = ttk.Style(self) st = ttk.Style(self)