From 91bac353b49edf3e72c283dd9fcdb26d572912ee Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:12:33 +0200 Subject: [PATCH] Keep user config out of the install dir; frameless window with working resize Three related fixes after the notes feature landed: Config location: build_and_deploy.ps1 wipes its target directory before every deploy, and app_dir() had just been pointed at that same directory -- so every rebuild silently deleted the user's config and the app rebuilt it empty from the board, losing all notes. Config now lives in %APPDATA%\VersaPadViewer (roaming), separate from the install dir, and the build script additionally rescues any versapad_config*.json it finds in the target so legacy installs survive an upgrade. Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes window manipulation) and move the mode checkboxes up next to the title, which reclaims two full rows of header height that the taller note cards had eaten. Removing the title bar also removes the resize borders, so add a grip -- anchored with place() to the window corner rather than packed after the content, which would push it out of view exactly when the window is too small and the grip is needed. Default geometry grown to fit the taller cards, and empty encoder note lines are no longer rendered at all. Note truncation: the note area was a fixed 34px and cut longer notes mid-word; it now takes the remaining card height. --- build_and_deploy.ps1 | 19 +++++ desktop_viewer.py | 170 +++++++++++++++++++++++++++++++------------ versapad_combined.py | 1 + versapad_data.py | 31 ++++++-- 4 files changed, 165 insertions(+), 56 deletions(-) diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index bdf1608..0accd2c 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -83,10 +83,29 @@ try { throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." } + # Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config + # liegt zwar inzwischen woanders (Roaming-AppData, siehe + # versapad_data.app_dir()), aeltere Installationen haben sie aber noch + # hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit + # (am 2026-08-15 genau so passiert, alle Notizen weg). + $userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue + $rescued = @() + foreach ($f in $userData) { + $tmp = Join-Path $env:TEMP $f.Name + Copy-Item $f.FullName $tmp -Force + $rescued += @{ Tmp = $tmp; Name = $f.Name } + Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow + } + if (Test-Path $localDir) { Remove-Item $localDir -Recurse -Force } Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + + foreach ($r in $rescued) { + Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force + Remove-Item $r.Tmp -Force + } Remove-Item $buildSrc -Recurse -Force Write-Host "" diff --git a/desktop_viewer.py b/desktop_viewer.py index d604da5..a27e191 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -55,6 +55,9 @@ WARN_RED = "#e0895a" POLL_MS = 1500 CARD_W, CARD_H = 150, 114 +# Untergrenze beim Ziehen am Anfasser -- knapp unter der Groesse, die das +# 4x5-Grid + Encoder mindestens brauchen, damit nichts abgeschnitten wird. +MIN_W, MIN_H = 700, 500 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 @@ -124,7 +127,11 @@ class VersaPadViewer(tk.Tk): super().__init__() self.title("VersaPad Steuermatrix") self.configure(bg=BG) - self.geometry("760x760") + # Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile + # fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114) + # reichten die alten 760px nicht mehr: Encoder und der Groessen- + # Anfasser lagen unterhalb des Fensterrands und waren unerreichbar. + self.geometry("790x960") self.profile = 0 self._mtimes = {} self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt @@ -151,55 +158,66 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - header = tk.Frame(self, bg=BG) - header.pack(fill="x", padx=20, pady=(18, 4)) - tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, - font=("Segoe UI", 15, "bold")).pack(anchor="w") - self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, - fg=TEXT_DIM, font=("Segoe UI", 9)) - self.header_sub.pack(anchor="w") + # Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE + # ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine + # Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten- + # Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest). + # Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und + # die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins + # Tray, wie vorher schon das X der echten Titelleiste). + self.overrideredirect(True) - info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), - cursor="hand2") - info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") + self.toggles_row = header = tk.Frame(self, bg=BG) + header.pack(fill="x", padx=20, pady=(10, 6)) + title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, + font=("Segoe UI", 11, "bold")) + title.pack(side="left", anchor="w", padx=(0, 16)) + + for widget in (header, title): + widget.bind("", self._start_move) + widget.bind("", self._on_move) + + for text in ("✕", "—"): + btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="hand2", padx=6) + btn.pack(side="right") + btn.bind("", lambda e: self._hide_to_tray()) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT)) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT_DIM)) + + info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12), + cursor="hand2", padx=6) + info_btn.pack(side="right", padx=(0, 8)) info_btn.bind("", lambda e: self._show_mcp_info()) info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("", lambda e: info_btn.configure(fg=TEXT_DIM)) - self.tabs = tk.Frame(self, bg=BG) - self.tabs.pack(fill="x", padx=20, pady=(12, 10)) - self.tab_buttons = {} - for p in range(vp.NUM_PROFILES): - btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, - font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") - btn.pack(side="left", padx=(0, 8)) - btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) - btn.bind("", lambda e, prof=p: self._rename_tab(prof)) - self.tab_buttons[p] = btn - - toggles_row = tk.Frame(self, bg=BG) - toggles_row.pack(fill="x", padx=20, pady=(0, 6)) + # Modus-Umschalter direkt neben der Ueberschrift statt in eigener + # Zeile -- spart eine komplette Zeile Fensterhoehe. self.sync_check = tk.Checkbutton( - toggles_row, text="Live-Sync mit Board", variable=self.live_sync, + header, text="Live-Sync", variable=self.live_sync, command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) self.sync_check.pack(side="left") - self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) - self.sync_status.pack(side="left", padx=(8, 20)) + self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.sync_status.pack(side="left", padx=(4, 14)) self.edit_check = tk.Checkbutton( - toggles_row, text="Programmiermodus", variable=self.editing, + header, text="Programmiermodus", variable=self.editing, command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) - self.edit_check.pack(side="left", padx=(0, 20)) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) + self.edit_check.pack(side="left", padx=(0, 14)) self.always_on_top = tk.BooleanVar(value=False) self.topmost_check = tk.Checkbutton( - toggles_row, text="Immer im Vordergrund", variable=self.always_on_top, + header, text="Immer im Vordergrund", variable=self.always_on_top, command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) self.topmost_check.pack(side="left") + # Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im + # Programmiermodus (siehe _on_toggle_editing), direkt unter den + # Checkboxen statt weit unten zwischen Tabs und Matrix. self.prog_row = tk.Frame(self, bg=BG) for text, cmd in ( ("Vom Board laden", self._load_from_board), @@ -209,21 +227,52 @@ class VersaPadViewer(tk.Tk): ): tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, activeforeground="#fff", relief="flat", - padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) + padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6)) + self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.prog_status.pack(side="left", padx=(8, 0)) # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) + # Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am + # Fensterkopf -- naeher an dem, was sie auswaehlen. + self.tabs = tk.Frame(self, bg=BG) + self.tabs.pack(fill="x", padx=20, pady=(8, 8)) + self.tab_buttons = {} + for p in range(vp.NUM_PROFILES): + btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, + font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") + btn.pack(side="left", padx=(0, 8)) + btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) + btn.bind("", lambda e, prof=p: self._rename_tab(prof)) + self.tab_buttons[p] = btn + self.grid_frame = tk.Frame(self, bg=BG) - self.grid_frame.pack(padx=20, pady=(8, 0)) + self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w") tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) - self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) - self.footer.pack(anchor="w", padx=20, pady=(20, 10)) + # Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter + # Titelleiste (overrideredirect) entfernt Windows auch die + # Fensterraender, an denen man sonst zieht -- ohne diesen Griff + # liesse sich das Fenster gar nicht mehr skalieren. + footer_row = tk.Frame(self, bg=BG) + footer_row.pack(fill="x", padx=20, pady=(14, 8)) + self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.footer.pack(side="left", anchor="w") + + # Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des + # gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild, + # sobald das Fenster kleiner als der Inhalt ist -- also genau dann, + # wenn man ihn zum Vergroessern braucht. + grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="sizing") + grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1) + grip.bind("", self._start_resize) + grip.bind("", self._on_resize) + grip.bind("", lambda e: grip.configure(fg=TEXT)) + grip.bind("", lambda e: grip.configure(fg=TEXT_DIM)) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) @@ -327,6 +376,25 @@ class VersaPadViewer(tk.Tk): def _on_toggle_topmost(self): self.attributes("-topmost", self.always_on_top.get()) + # ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ── + + def _start_move(self, event): + self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y()) + + def _on_move(self, event): + dx, dy = self._drag_origin + self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}") + + def _start_resize(self, event): + self._resize_origin = (event.x_root, event.y_root, + self.winfo_width(), self.winfo_height()) + + def _on_resize(self, event): + x0, y0, w0, h0 = self._resize_origin + width = max(MIN_W, w0 + (event.x_root - x0)) + height = max(MIN_H, h0 + (event.y_root - y0)) + self.geometry(f"{width}x{height}") + # ── Live-Sync mit dem Board ──────────────────────────────────── def _on_toggle_sync(self): @@ -428,12 +496,10 @@ class VersaPadViewer(tk.Tk): self.combined = vcomb.default_combined() else: self.combined = vcomb.default_combined() - self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) - self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") + self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row) else: self.sync_check.configure(state="normal") self.prog_row.pack_forget() - self.header_sub.configure(text="pollt Config-JSONs alle 1.5s") self._update_tab_labels() self._render() @@ -643,16 +709,18 @@ class VersaPadViewer(tk.Tk): tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, font=("Segoe UI", 10, "normal" if empty else "bold"), wraplength=CARD_W - 20, justify="left", anchor="nw").place( - x=10, y=26, width=CARD_W - 20, height=30) + x=10, y=24, width=CARD_W - 20, height=28) + # Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) -- + # bei 34px wurden laengere Notizen mitten im Wort abgeschnitten. tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place( - x=10, y=58, width=CARD_W - 20, height=34) + x=10, y=53, width=CARD_W - 20, height=CARD_H - 69) anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), anchor="sw").place( - x=10, y=CARD_H - 18, width=CARD_W - 20, height=14) + x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: card.configure(cursor="hand2") @@ -678,15 +746,21 @@ class VersaPadViewer(tk.Tk): top.pack(fill="x") tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") - note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), - wraplength=150, justify="left", anchor="w") - note_label.pack(fill="x") + # Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt -- + # ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile + # Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild. + note_label = None + if note: + note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), + wraplength=150, justify="left", anchor="w") + note_label.pack(fill="x") if editable: row.configure(cursor="hand2") handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) row.bind("", handler) top.bind("", handler) - note_label.bind("", handler) + if note_label is not None: + note_label.bind("", handler) for child in top.winfo_children(): child.bind("", handler) diff --git a/versapad_combined.py b/versapad_combined.py index ea96a41..da1f145 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -122,6 +122,7 @@ def to_binary(combined): def save_file(combined, path=DEFAULT_PATH): + os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(combined, f, indent=2, ensure_ascii=False) return path diff --git a/versapad_data.py b/versapad_data.py index 8c4380a..58c122f 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -11,17 +11,32 @@ import sys NUM_PROFILES = 3 +APP_NAME = "VersaPadViewer" + def app_dir(): """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, - siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) - das Installationsverzeichnis neben der .exe, sonst der Ordner dieses - Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter - Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/ - installieren, ohne Pfade von Hand anzupassen.""" - if getattr(sys, "frozen", False): - return os.path.dirname(sys.executable) - return os.path.dirname(os.path.abspath(__file__)) + siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung + abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und + unter jedem Benutzer. + + Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die + gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen + dieselbe Datei sehen, sonst laufen zwei Configs auseinander und + Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist + am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der + MCP-Server). + + Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\ + VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das + Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) -- + laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen + (am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und + Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die + Config.""" + base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") + or os.path.join(os.path.expanduser("~"), ".local", "share")) + return os.path.join(base, APP_NAME) # CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind