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
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 676 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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 |
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||