app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
"""Narration panel for the KiCad tour.
Styled to match the Adom Desktop Tauri app exactly:
Colors: desktop/src/style.css CSS custom properties
Fonts: Segoe UI (body) + Cascadia Code (mono)
Layout: bg-primary page, bg-secondary panels, bg-tertiary hover
Runs standalone with an HTTP server so Claude can push updates:
python narration.py [--port 8799]
HTTP API:
POST /update {"step":1,"total":19,"title":"...","description":"...",
"act":"...","status":"...","status_color":"#3fb950",
"data":"...","data_color":"#3fb950"}
POST /quit Close the panel
GET /health {"status":"ok"}
"""
import argparse
import ctypes
import json
import sys
import threading
import tkinter as tk
from http.server import HTTPServer, BaseHTTPRequestHandler
from tkinter import ttk
# ── Colors — exact match to desktop/src/style.css :root vars ─────
BG_PRIMARY = "#0d1117" # --bg-primary (page background)
BG_SECONDARY = "#161b22" # --bg-secondary (panels, toolbars)
BG_TERTIARY = "#21262d" # --bg-tertiary (hover, elevated)
BORDER = "#30363d" # --border
TEXT_PRIMARY = "#e6edf3" # --text-primary
TEXT_SECONDARY = "#8b949e" # --text-secondary
TEXT_MUTED = "#6e7681" # --text-muted
ACCENT = "#58a6ff" # --accent
GREEN = "#3fb950" # --green
RED = "#f85149" # --red
YELLOW = "#d29922" # --yellow
ORANGE = "#db6d28" # --orange
# Brand accent
TEAL = "#00b8b1"
TEAL_BRIGHT = "#00e6dc"
PURPLE = "#8C6BF7"
PURPLE_LIGHT = "#C5B3FF"
BLUE = ACCENT
BLUE_LIGHT = "#64ABFF"
# Backward-compat aliases
FG = TEXT_PRIMARY
FG_DIM = TEXT_MUTED
FG_SECONDARY = TEXT_SECONDARY
# ── Typography ──────────────────────────────────────────────────
FONT_TITLE = ("Segoe UI", 18, "bold")
FONT_H2 = ("Segoe UI", 15, "bold")
FONT_ACT = ("Segoe UI", 11, "bold")
FONT_DESC = ("Segoe UI", 13)
FONT_MONO = ("Cascadia Code", 12)
FONT_STATUS = ("Segoe UI", 13)
FONT_BTN = ("Segoe UI", 13)
FONT_SMALL = ("Segoe UI", 12)
FONT_TAG = ("Segoe UI", 11, "bold")
class NarrationPanel:
"""Always-on-top narration panel — styled identical to Adom Desktop."""
def __init__(self, width: int = 480, on_quit=None):
self.width = width
self._on_quit = on_quit
self.root = tk.Tk()
self.root.title("Gallia Tour")
self.root.configure(bg=BG_PRIMARY)
self.root.attributes("-topmost", True)
# Position on the right side of the screen, accounting for taskbar.
# Use tkinter's own screen dimensions (logical pixels) so geometry
# values are consistent regardless of DPI awareness.
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
# Subtract space for the taskbar (~50 logical px) and titlebar (~32 px)
# to guarantee the Quit button is fully visible.
panel_h = screen_h - 80
x = screen_w - width
self.root.geometry(f"{width}x{panel_h}+{x}+0")
self.root.resizable(False, False)
self.root.protocol("WM_DELETE_WINDOW", self._quit)
self._build_ui()
# Apply dark mode to the native Windows titlebar via DWM API.
# This gives us a dark titlebar that matches the app without
# removing it (which caused the invisible-window bug).
self.root.update_idletasks()
self._apply_dark_titlebar()
def _apply_dark_titlebar(self):
"""Use DWM API to render the native titlebar in dark mode (Win10 1809+)."""
try:
hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id())
# DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Windows 10 1809+)
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
value = ctypes.c_int(1) # TRUE = dark mode
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE,
ctypes.byref(value), ctypes.sizeof(value),
)
except Exception:
pass # Fall back to default titlebar if DWM not available
def _build_ui(self):
# ── Status bar (matches #top-bar status group) ───
status_bar = tk.Frame(self.root, bg=BG_SECONDARY, padx=16, pady=8)
status_bar.pack(fill="x")
# Green dot + step counter (like the Connected indicator)
dot_canvas = tk.Canvas(
status_bar, width=14, height=14,
bg=BG_SECONDARY, highlightthickness=0,
)
dot_canvas.pack(side="left", padx=(0, 8))
self._dot = dot_canvas
self._dot_id = dot_canvas.create_oval(2, 2, 12, 12, fill=GREEN, outline="")
self._step_label = tk.Label(
status_bar, text="Gallia Feature Tour", font=FONT_SMALL,
bg=BG_SECONDARY, fg=TEXT_SECONDARY,
)
self._step_label.pack(side="left")
self._act_tag = tk.Label(
status_bar, text="", font=FONT_TAG,
bg="#1a1a33", fg="#a78bfa",
padx=6, pady=1,
)
self._act_tag.pack(side="right")
# Border
tk.Frame(self.root, bg=BORDER, height=1).pack(fill="x")
# ── Progress bar ─────────────────────────────
prog_frame = tk.Frame(self.root, bg=BG_PRIMARY, padx=16)
prog_frame.pack(fill="x", pady=(8, 0))
style = ttk.Style()
style.theme_use("default")
style.configure(
"Tour.Horizontal.TProgressbar",
troughcolor=BG_TERTIARY,
background=TEAL,
thickness=4,
)
self._progress = ttk.Progressbar(
prog_frame, style="Tour.Horizontal.TProgressbar",
length=self.width - 32, mode="determinate",
)
self._progress.pack(fill="x")
# ── Content area ─────────────────────────────
content_frame = tk.Frame(self.root, bg=BG_PRIMARY, padx=16, pady=12)
content_frame.pack(fill="x")
self._section_label = tk.Label(
content_frame, text="FEATURE TOUR", font=FONT_H2,
bg=BG_PRIMARY, fg=TEXT_SECONDARY,
)
self._section_label.pack(anchor="w")
self._title_label = tk.Label(
content_frame, text="Waiting for Claude...", font=FONT_TITLE,
bg=BG_PRIMARY, fg=TEXT_PRIMARY, anchor="w",
wraplength=self.width - 32, justify="left",
)
self._title_label.pack(fill="x", pady=(8, 0))
tk.Frame(self.root, bg=BORDER, height=1).pack(fill="x", padx=16, pady=8)
self._desc_label = tk.Label(
self.root, text="The tour will begin shortly.\nClaude is analyzing your setup...",
font=FONT_DESC,
bg=BG_PRIMARY, fg=TEXT_SECONDARY, padx=16, anchor="nw",
wraplength=self.width - 32, justify="left",
)
self._desc_label.pack(fill="x")
# ── Status line ──────────────────────────────
self._status_label = tk.Label(
self.root, text="", font=FONT_STATUS,
bg=BG_PRIMARY, fg=GREEN, padx=16, anchor="w",
)
self._status_label.pack(fill="x", pady=(8, 0))
# ── Data area (matches #activity-log) ────────
data_outer = tk.Frame(self.root, bg=BG_PRIMARY, padx=16, pady=8)
data_outer.pack(fill="both", expand=True)
data_border = tk.Frame(data_outer, bg=BORDER, padx=1, pady=1)
data_border.pack(fill="both", expand=True)
self._data_text = tk.Text(
data_border, font=FONT_MONO,
bg=BG_SECONDARY, fg=TEXT_PRIMARY,
insertbackground=GREEN,
selectbackground=BG_TERTIARY,
relief="flat", wrap="word",
padx=8, pady=8,
borderwidth=0, highlightthickness=0,
)
self._data_text.pack(fill="both", expand=True)
self._data_text.config(state="disabled")
# ── Button bar (matches #stats-footer) ───────
tk.Frame(self.root, bg=BORDER, height=1).pack(fill="x", side="bottom")
btn_frame = tk.Frame(self.root, bg=BG_SECONDARY, pady=8, padx=16)
btn_frame.pack(fill="x", side="bottom")
tk.Button(
btn_frame, text="Quit Tour", command=self._quit,
font=FONT_BTN, bg="#da3633", fg="#ffffff",
activebackground="#b62324", activeforeground="#ffffff",
relief="flat", padx=14, pady=6, cursor="hand2",
borderwidth=1, highlightthickness=0,
).pack(side="right")
# "Powered by Claude" label
tk.Label(
btn_frame, text="Powered by Claude",
font=FONT_SMALL, bg=BG_SECONDARY, fg=TEXT_MUTED,
).pack(side="left")
# ── Public API (thread-safe via root.after) ───────────────────
def set_step(self, number: int, total: int, title: str, description: str,
act: str = ""):
self._step_label.config(text=f"Step {number} of {total}")
self._progress["maximum"] = total
self._progress["value"] = number
if act:
self._act_tag.config(text=act.upper())
self._title_label.config(text=title)
self._desc_label.config(text=description)
self._status_label.config(text="")
self._data_text.config(state="normal")
self._data_text.delete("1.0", "end")
self._data_text.config(state="disabled")
def set_status(self, text: str, color: str = GREEN):
self._status_label.config(text=text, fg=color)
def set_data(self, text: str):
self._data_text.config(state="normal")
self._data_text.delete("1.0", "end")
self._data_text.insert("1.0", text)
self._data_text.config(state="disabled")
def set_data_color(self, color: str):
self._data_text.config(fg=color)
def apply_update(self, data: dict):
"""Apply a dict of updates from the HTTP API."""
if "step" in data and "total" in data:
self.set_step(
data["step"], data["total"],
data.get("title", ""), data.get("description", ""),
data.get("act", ""),
)
else:
if "title" in data:
self._title_label.config(text=data["title"])
if "description" in data:
self._desc_label.config(text=data["description"])
if "act" in data:
self._act_tag.config(text=data["act"].upper())
if "status" in data:
color = data.get("status_color", GREEN)
self.set_status(data["status"], color)
if "data" in data:
self.set_data(data["data"])
if "data_color" in data:
self.set_data_color(data["data_color"])
# ── HTTP server for Claude to push updates ────────────────────
def start_server(self, port: int = 8799):
"""Start the HTTP control server on a background thread."""
panel = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/update":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
panel.root.after(0, lambda: panel.apply_update(body))
self._respond(200, {"ok": True})
elif self.path == "/quit":
self._respond(200, {"ok": True})
panel.root.after(100, panel.root.destroy)
else:
self._respond(404, {"error": "not found"})
def do_GET(self):
if self.path == "/health":
self._respond(200, {"status": "ok", "port": port})
else:
self._respond(404, {"error": "not found"})
def _respond(self, code, data):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def log_message(self, fmt, *args):
pass # Suppress request logging
server = HTTPServer(("127.0.0.1", port), Handler)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
return server
# ── Internal ──────────────────────────────────────────────────
def _quit(self):
if self._on_quit:
self._on_quit()
self.root.destroy()
# ── Standalone mode ──────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Gallia Tour narration panel")
parser.add_argument("--port", type=int, default=8799,
help="HTTP server port (default: 8799)")
parser.add_argument("--width", type=int, default=480,
help="Panel width in pixels (default: 480)")
args = parser.parse_args()
panel = NarrationPanel(width=args.width)
panel.start_server(port=args.port)
print(f"Panel server listening on http://127.0.0.1:{args.port}")
panel.root.mainloop()