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:
Executable
+96
@@ -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()
|
||||
Reference in New Issue
Block a user