# -*- coding: utf-8 -*- """ AzA Desktop-Hülle ================= Saubere, professionelle Hauptoberfläche für AzA Office. Diese Hülle ersetzt die alte, chaotische Toolbar von ``KGDesktopApp`` (``basis14.py``) durch eine ruhige, medizinisch-saubere Layout-Schicht im Stil der Empfang-Oberfläche. Was die Hülle macht ------------------- * Versteckt die alte obere Button-Leiste (``top``-Frame mit dem Mix aus 👤/🔑/⊞/↺/Token-Anzeige/Slider/Status-Pills usw.). * Versteckt das doppelte Logo unten links (``_logo_frame``). * Versteckt den "Ablauf:"-Text unten (``_bottom_frame``). * Versteckt die alte Addon-/Modul-Spalte mit "An Empfang senden" – dieser Button wird stattdessen prominent im neuen Header platziert. * Baut eine neue klare Kopfzeile mit Logo + "AzA von Arzt zu Arzt" + "Informatik zu fairen Preisen" (oben links). * Baut eine neue Aktionsleiste mit einheitlichen Pill-Buttons: Aufnahme, Korrigieren, Audio importieren, Diktat, Notizen, Neu, An Empfang senden, Profil, Status, Hell/Dunkel, A−/A+, Transkript. * Bindet die zustandsbehafteten Knöpfe (Start/Stopp-Texte) so um, dass die bestehende Aufnahme-Logik (``toggle_record`` etc.) ohne Änderung weiter funktioniert. * Wendet ein ruhiges hell- und ein dezentes dunkel-Theme an. * Klappt das Transkript beim Start ein. * Unterdrückt das Developer-Status-Popup beim Start. Was die Hülle NICHT macht ------------------------- * Sie ersetzt KEINE produktive Logik. * Sie löscht KEINE Methoden, Mixins oder Dialoge. * Das Profil-Bearbeiten-Fenster (``_show_profile_editor``) bleibt 1:1. * KG-Editor, Transkript, SOAP, Dokumente, Textblöcke, Backend-Anbindung und alle Submodule (E-Mail, WhatsApp, Übersetzer, MedWork, ...) bleiben unverändert nutzbar. """ from __future__ import annotations import os import sys import tkinter as tk from tkinter import ttk from typing import Optional, Callable try: from PIL import Image, ImageTk _HAS_PIL = True except Exception: _HAS_PIL = False try: from aza_ui_helpers import RoundedButton, add_tooltip except Exception: RoundedButton = None # type: ignore add_tooltip = lambda *a, **k: None # type: ignore # ─── Empfang-Farbpalette (medizinisch, ruhig, professionell) ───────────────── # Hell (default): heller Hintergrund, kräftiges aber dezentes Empfang-Blau. LIGHT = dict( bg="#EAF2F7", # Hauptfenster-Hintergrund surface="#FFFFFF", # Karten / Header surface_alt="#F4F8FB", border="#D6E2EB", divider="#E0EAF0", text="#1A4D6D", # Primärtext text_strong="#0F3850", subtle="#5C7A8E", accent="#5B8DB3", # Empfang-Blau accent_hover="#4A7A9E", accent_pressed="#3A6884", accent_soft="#E2EEF6", success="#2E8B57", warn="#C2840F", danger="#C0392B", record="#C0392B", record_active="#A6291C", canvas_bg="#EAF2F7", ) # Dunkel: dezent dunkelblau, augenschonend, nicht reines Schwarz. DARK = dict( bg="#1F2A33", surface="#28333E", surface_alt="#2F3B47", border="#3A4A57", divider="#36454F", text="#E2ECF2", text_strong="#FFFFFF", subtle="#9DB3C2", accent="#7AB0D4", accent_hover="#92C2E0", accent_pressed="#5A92B6", accent_soft="#33414F", success="#5DC08C", warn="#E1AB4F", danger="#E07060", record="#E07060", record_active="#FF8B79", canvas_bg="#1F2A33", ) _FF = "Segoe UI" _FONT_BRAND_TITLE = (_FF, 16, "bold") _FONT_BRAND_SUB = (_FF, 9) _FONT_HEADER_LABEL = (_FF, 9) _FONT_FONTSTEP = (_FF, 9) _FONT_FONTSTEP_BTN = (_FF, 11, "bold") _PREFS_FILE = "aza_desktop_shell_prefs.json" # Einheitliche Standard-Maße der Pill-Buttons in der Aktionsleiste. _BTN_W_NORMAL = 130 _BTN_W_WIDE = 168 _BTN_W_ICON = 38 _BTN_H = 34 # ─── Persistenz für Theme + Schriftgröße ───────────────────────────────────── def _prefs_path() -> str: try: from aza_persistence import get_writable_data_dir return os.path.join(get_writable_data_dir(), _PREFS_FILE) except Exception: return os.path.join(os.path.dirname(os.path.abspath(__file__)), _PREFS_FILE) def _load_prefs() -> dict: import json try: p = _prefs_path() if os.path.isfile(p): with open(p, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return data except Exception: pass return {} def _save_prefs(prefs: dict) -> None: import json try: with open(_prefs_path(), "w", encoding="utf-8") as f: json.dump(prefs, f, indent=2, ensure_ascii=False) except Exception: pass # ─── Logo ──────────────────────────────────────────────────────────────────── def _load_logo(size: int) -> Optional[object]: if not _HAS_PIL: return None candidates: list[str] = [] try: candidates.append(os.path.dirname(os.path.abspath(__file__))) except Exception: pass if getattr(sys, "frozen", False): try: candidates.append(os.path.dirname(os.path.abspath(sys.executable))) except Exception: pass meipass = getattr(sys, "_MEIPASS", "") if meipass: candidates.append(meipass) for d in candidates: if not d: continue p = os.path.join(d, "logo.png") if not os.path.isfile(p): continue try: img = Image.open(p) if img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") resample = ( Image.Resampling.LANCZOS if hasattr(Image, "Resampling") else Image.LANCZOS ) img = img.resize((size, size), resample) return ImageTk.PhotoImage(img) except Exception: continue return None # ─── ttk-Style ─────────────────────────────────────────────────────────────── def _apply_ttk_theme(root: tk.Misc, t: dict) -> None: """Sorgt dafür, dass ttk.Frame/PanedWindow/Label im Hauptfenster sauber in den neuen Hintergrund integriert sind.""" try: style = ttk.Style(root) try: style.theme_use("clam") except tk.TclError: pass style.configure("TFrame", background=t["bg"]) style.configure("TPanedwindow", background=t["bg"]) try: style.configure("TPanedwindow.Sash", background=t["divider"], width=8) except tk.TclError: pass style.configure("TLabel", background=t["bg"], foreground=t["text"]) style.configure("TopBar.TFrame", background=t["surface"]) style.configure("StatusBar.TFrame", background=t["bg"]) style.configure("TranscriptBar.TFrame", background=t["bg"]) style.configure( "TButton", background=t["accent"], foreground="white", padding=(10, 6), borderwidth=0, ) style.map( "TButton", background=[("active", t["accent_hover"]), ("pressed", t["accent_pressed"])], ) except Exception: pass # ─── Pill-Button im neuen Stil ─────────────────────────────────────────────── class ShellButton(tk.Canvas): """Pill-Button mit einheitlicher Höhe für die neue Hülle. API ist absichtlich kompatibel zu ``RoundedButton``, damit zustands- behaftete Knöpfe (z. B. ``self.btn_record``) nahtlos getauscht werden können: ``configure(text=...)``, ``set_text``, ``set_font_size_scale``, ``set_button_size_scale`` werden unterstützt. """ def __init__(self, parent, text: str, command: Optional[Callable] = None, *, theme: dict, kind: str = "default", width: int = _BTN_W_NORMAL, height: int = _BTN_H, radius: int = 8, tooltip: Optional[str] = None, font_weight: str = "normal"): bg = parent.cget("bg") if hasattr(parent, "cget") else theme["surface"] super().__init__(parent, width=width, height=height, bg=bg, highlightthickness=0, bd=0, cursor="hand2") self._theme = theme self._kind = kind self._radius = radius self._text = text self._command = command self._base_w = width self._base_h = height self._scale_w = 1.0 self._scale_f = 1.0 self._font_size_base = 10 if font_weight == "normal" else 10 self._weight = "bold" if font_weight == "bold" else "normal" self._active = False self._pressed = False self.bind("", lambda e: self._draw()) self.bind("", self._on_enter) self.bind("", self._on_leave) self.bind("", self._on_press) self.bind("", self._on_release) if tooltip: try: add_tooltip(self, tooltip) except Exception: pass self._draw() # ── Farbschema je Variante ──────────────────────────────────────────── def _colors(self): t = self._theme if self._kind == "primary": bg = t["accent_pressed"] if self._pressed else ( t["accent_hover"] if self._active else t["accent"]) return bg, "white", bg if self._kind == "danger": bg = t["record_active"] if (self._active or self._pressed) else t["record"] return bg, "white", bg if self._kind == "ghost": bg = t["accent_soft"] if (self._active or self._pressed) else t["surface"] border = t["accent"] if (self._active or self._pressed) else t["border"] return bg, t["text"], border if self._kind == "icon": bg = t["accent_soft"] if (self._active or self._pressed) else t["surface"] border = t["accent"] if (self._active or self._pressed) else t["border"] return bg, t["text"], border bg = t["accent_soft"] if (self._active or self._pressed) else t["surface_alt"] border = t["accent"] if (self._active or self._pressed) else t["border"] return bg, t["text"], border def _draw(self): try: self.delete("all") w = max(int(self._base_w * self._scale_w), int(self.winfo_width())) h = max(int(self._base_h * self._scale_w), int(self.winfo_height())) if w <= 1: w = int(self._base_w * self._scale_w) if h <= 1: h = int(self._base_h * self._scale_w) fill, fg, border = self._colors() r = max(2, min(self._radius, h // 2)) self._round_rect(0, 0, w, h, r, fill=fill, outline=border) font_size = max(7, int(self._font_size_base * self._scale_f)) self.create_text( w // 2, h // 2, text=self._text, fill=fg, font=(_FF, font_size, self._weight), ) except Exception: pass def _round_rect(self, x1, y1, x2, y2, r, **kw): pts = [ x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1, ] return self.create_polygon(pts, smooth=True, **kw) # ── Events ──────────────────────────────────────────────────────────── def _on_enter(self, _e=None): self._active = True self._draw() def _on_leave(self, _e=None): self._active = False self._pressed = False self._draw() def _on_press(self, _e=None): self._pressed = True self._draw() def _on_release(self, _e=None): was_pressed = self._pressed self._pressed = False self._draw() if was_pressed and self._command: try: self._command() except Exception as exc: print(f"[Shell] Aktion '{self._text}' fehlgeschlagen: {exc}") # ── Kompatibilitäts-API zu RoundedButton ────────────────────────────── def configure(self, **kw): if "command" in kw: self._command = kw.pop("command") if "text" in kw: self._text = str(kw.pop("text")) self._draw() if "bg" in kw or "background" in kw: try: super().configure(bg=kw.pop("bg", kw.pop("background", None))) except Exception: pass if kw: try: super().configure(**kw) except Exception: pass config = configure def set_text(self, text: str): self.configure(text=text) def set_font_size_scale(self, scale: float): self._scale_f = max(0.4, min(2.0, float(scale))) self._draw() def set_button_size_scale(self, scale: float): self._scale_w = max(0.4, min(2.0, float(scale))) new_w = int(self._base_w * self._scale_w) new_h = int(self._base_h * self._scale_w) try: super().configure(width=new_w, height=new_h) except Exception: pass self._draw() def set_font_scale(self, scale: float): self.set_font_size_scale(scale) self.set_button_size_scale(scale) def apply_theme(self, theme: dict): self._theme = theme try: super().configure(bg=self.master.cget("bg")) except Exception: pass self._draw() # ─── Hülle ─────────────────────────────────────────────────────────────────── class _DesktopShell: def __init__(self, app): self.app = app prefs = _load_prefs() self._dark: bool = bool(prefs.get("dark_mode", False)) self._font_step: int = max(-2, min(4, int(prefs.get("font_step", 0)))) self._theme: dict = DARK.copy() if self._dark else LIGHT.copy() self._logo_img = None self._brand_title_lbl: Optional[tk.Label] = None self._brand_sub_lbl: Optional[tk.Label] = None self._logo_lbl: Optional[tk.Label] = None self._header_frame: Optional[tk.Frame] = None self._action_frame: Optional[tk.Frame] = None self._theme_btn: Optional[ShellButton] = None self._transcript_btn: Optional[ShellButton] = None self._font_label: Optional[tk.Label] = None self._all_shell_buttons: list[ShellButton] = [] self._connection_dot: Optional[tk.Canvas] = None self._connection_lbl: Optional[tk.Label] = None self._chk_widgets: list = [] # ── Public ─────────────────────────────────────────────────────────── def install(self) -> None: app = self.app try: app.title(self._title()) except Exception: pass self._hide_legacy_chrome() self._restyle_window() self._build_header() self._build_action_bar() self._reorder_main_layout() self._restyle_inner_panels() self._suppress_dev_status_popup() self._collapse_transcript_default() self._mirror_backend_status() self._apply_font_step(self._font_step, persist=False) # ── Titel / Theme ──────────────────────────────────────────────────── def _title(self) -> str: try: from aza_version import APP_VERSION return f"AzA Office (v{APP_VERSION})" except Exception: return "AzA Office" def _restyle_window(self): app = self.app t = self._theme try: app.configure(bg=t["bg"]) except Exception: pass _apply_ttk_theme(app, t) # ── Altes Chrome verstecken ────────────────────────────────────────── def _hide_legacy_chrome(self): """Versteckt die alte chaotische Toolbar, das Bottom-Logo und die Modul-Spalte mit dem alten 'An Empfang senden'-Button.""" app = self.app # Alte obere Toolbar (top-Frame) – komplett verstecken. try: top = getattr(app, "_btn_row_left", None) if top is not None and top.master is not None: top.master.pack_forget() except Exception: pass # Status-Zeile bleibt sichtbar, wird aber später neu eingeordnet. # Bottom-Frame ("Ablauf:"-Erklärtext) entfernen – Information ist # in der neuen Hülle implizit. try: if getattr(app, "_bottom_frame", None) is not None: app._bottom_frame.pack_forget() except Exception: pass # Doppeltes Branding-Logo unten ausblenden. try: if getattr(app, "_logo_frame", None) is not None: try: app._logo_frame.place_forget() except Exception: app._logo_frame.pack_forget() except Exception: pass # "Weitere Module" – die Empfang-Aktion wird in den neuen Header # gehoben. Die Spalte selbst bleibt funktional erhalten, der # Empfang-Knopf wird aber dort entfernt, um Doppelaktionen # zu vermeiden. rows = getattr(app, "_addon_button_rows", None) if isinstance(rows, dict) and "empfang" in rows: try: rows["empfang"].pack_forget() except Exception: pass # ── Header ─────────────────────────────────────────────────────────── def _build_header(self): app = self.app t = self._theme header = tk.Frame(app, bg=t["surface"], bd=0, highlightthickness=0) header.pack(side="top", fill="x", before=app.winfo_children()[0]) self._header_frame = header # untere Trennlinie sep = tk.Frame(app, bg=t["border"], height=1) sep.pack(side="top", fill="x", before=app.winfo_children()[1]) self._header_sep = sep inner = tk.Frame(header, bg=t["surface"]) inner.pack(fill="x", padx=18, pady=10) # Links: Branding brand = tk.Frame(inner, bg=t["surface"]) brand.pack(side="left") self._logo_img = _load_logo(size=44) if self._logo_img is not None: self._logo_lbl = tk.Label( brand, image=self._logo_img, bg=t["surface"], bd=0, highlightthickness=0, ) self._logo_lbl.pack(side="left", padx=(0, 12)) text_block = tk.Frame(brand, bg=t["surface"]) text_block.pack(side="left", anchor="w") self._brand_title_lbl = tk.Label( text_block, text="AzA von Arzt zu Arzt", font=_FONT_BRAND_TITLE, bg=t["surface"], fg=t["text_strong"], anchor="w", ) self._brand_title_lbl.pack(anchor="w") self._brand_sub_lbl = tk.Label( text_block, text="Informatik zu fairen Preisen", font=_FONT_BRAND_SUB, bg=t["surface"], fg=t["subtle"], anchor="w", ) self._brand_sub_lbl.pack(anchor="w") # Rechts: Verbindung + Kontoaktionen right = tk.Frame(inner, bg=t["surface"]) right.pack(side="right") # Verbindungs-Indikator (nutzt vorhandene App-Variable) conn_box = tk.Frame(right, bg=t["surface"]) conn_box.pack(side="left", padx=(0, 14)) self._connection_dot = tk.Canvas( conn_box, width=10, height=10, bg=t["surface"], highlightthickness=0, bd=0, ) self._connection_dot.pack(side="left", padx=(0, 6)) self._connection_lbl = tk.Label( conn_box, text="Verbindung wird geprüft …", font=_FONT_HEADER_LABEL, bg=t["surface"], fg=t["subtle"], ) self._connection_lbl.pack(side="left") self._add_header_btn( right, "Profil", lambda: self._safe_call(app, "_show_profile_editor"), tooltip="Profil bearbeiten", ) self._add_header_btn( right, "Aktivierung", lambda: self._safe_call(app, "_show_activation_dialog"), tooltip="Aktivierung verwalten", ) self._add_header_btn( right, "Status", lambda: self._safe_call(app, "_open_systemstatus"), tooltip="Systemstatus und Diagnose", ) self._add_header_btn( right, "Einstellungen", lambda: self._safe_call(app, "_open_settings"), tooltip="Einstellungen öffnen", ) self._add_header_btn( right, "Abonnement", lambda: self._safe_call(app, "_open_billing_portal_from_ui"), tooltip="Abonnement und Rechnungen verwalten", ) def _add_header_btn(self, parent, text: str, cmd, *, tooltip: str | None = None): b = ShellButton( parent, text, command=cmd, theme=self._theme, kind="ghost", width=110, height=30, tooltip=tooltip, ) b.pack(side="left", padx=(0, 6)) self._all_shell_buttons.append(b) return b # ── Action-Bar ─────────────────────────────────────────────────────── def _build_action_bar(self): app = self.app t = self._theme bar = tk.Frame(app, bg=t["bg"], bd=0, highlightthickness=0) # Direkt unter dem Header platzieren (Header + Separator sind als # erste zwei Kinder gepackt). bar.pack(side="top", fill="x", padx=18, pady=(10, 6)) self._action_frame = bar left = tk.Frame(bar, bg=t["bg"]) left.pack(side="left") right = tk.Frame(bar, bg=t["bg"]) right.pack(side="right") # Primäre Aufnahme-Aktionen – Re-Bind der zustandsbehafteten Knöpfe. new_record = ShellButton( left, "⏺ Aufnahme", command=self._delegate(app, "toggle_record"), theme=t, kind="danger", width=_BTN_W_NORMAL, height=_BTN_H, font_weight="bold", tooltip="Aufnahme starten / stoppen", ) new_record.pack(side="left", padx=(0, 6)) self._reassign_button(app, "btn_record", new_record) new_korrigieren = ShellButton( left, "⏺ Korrigieren", command=self._delegate(app, "_toggle_record_append"), theme=t, kind="danger", width=_BTN_W_NORMAL, height=_BTN_H, font_weight="bold", tooltip="Zusätzliches Diktat anhängen", ) new_korrigieren.pack(side="left", padx=(0, 12)) self._reassign_button(app, "btn_record_append", new_korrigieren) # Weitere primäre Aktionen – einheitliche Größe. for label, attr_method, tip in [ ("Audio importieren", "_import_and_transcribe_audio", "Audiodatei auswählen und transkribieren"), ("Diktat", "open_diktat_window", "Diktatfenster öffnen"), ("Notizen", "open_notizen_window", "Projekt-Notizen anzeigen"), ("Neu", "_new_session", "Neue Sitzung beginnen"), ]: b = ShellButton( left, label, command=self._delegate(app, attr_method), theme=t, kind="default", width=_BTN_W_NORMAL, height=_BTN_H, tooltip=tip, ) b.pack(side="left", padx=(0, 6)) self._all_shell_buttons.append(b) # Rechts: Transkript-Toggle, A−/A+, Hell/Dunkel, An Empfang senden self._transcript_btn = ShellButton( right, "Transkript einblenden", command=self._on_toggle_transcript, theme=t, kind="ghost", width=_BTN_W_WIDE, height=_BTN_H, tooltip="Transkript ein-/ausblenden", ) self._transcript_btn.pack(side="left", padx=(6, 6)) self._all_shell_buttons.append(self._transcript_btn) # Schriftgrößen-Stepper font_box = tk.Frame( right, bg=t["surface"], highlightthickness=1, highlightbackground=t["border"], ) font_box.pack(side="left", padx=(0, 6)) self._font_box = font_box btn_minus = tk.Label( font_box, text="A−", font=_FONT_FONTSTEP_BTN, bg=t["surface"], fg=t["text"], padx=10, pady=4, cursor="hand2", ) btn_minus.pack(side="left") btn_minus.bind("", lambda e: self._on_font_step(-1)) try: add_tooltip(btn_minus, "Schrift verkleinern") except Exception: pass self._font_label = tk.Label( font_box, text=self._font_step_label(), font=_FONT_FONTSTEP, bg=t["surface"], fg=t["subtle"], padx=4, pady=4, width=8, anchor="center", ) self._font_label.pack(side="left") btn_plus = tk.Label( font_box, text="A+", font=_FONT_FONTSTEP_BTN, bg=t["surface"], fg=t["text"], padx=10, pady=4, cursor="hand2", ) btn_plus.pack(side="left") btn_plus.bind("", lambda e: self._on_font_step(+1)) try: add_tooltip(btn_plus, "Schrift vergrößern") except Exception: pass self._font_minus, self._font_plus = btn_minus, btn_plus self._theme_btn = ShellButton( right, "Dunkel" if not self._dark else "Hell", command=self._on_toggle_theme, theme=t, kind="ghost", width=80, height=_BTN_H, tooltip="Hell-/Dunkel-Modus", ) self._theme_btn.pack(side="left", padx=(0, 6)) self._all_shell_buttons.append(self._theme_btn) send = ShellButton( right, "An Empfang senden", command=self._delegate(app, "_send_to_empfang"), theme=t, kind="primary", width=_BTN_W_WIDE, height=_BTN_H, font_weight="bold", tooltip="Aktuelle Krankengeschichte an den Empfang übergeben", ) send.pack(side="left") self._all_shell_buttons.append(send) # ── Hauptbereich neu einordnen ─────────────────────────────────────── def _reorder_main_layout(self): """Bringt die produktive UI (Status-Zeile + Paned Window) sauber unter Header und Aktionsleiste, ohne das innere Layout zu zerstören.""" app = self.app # Status-Zeile direkt unter der Action-Bar zeigen, danach Paned. try: if getattr(app, "_status_row", None) is not None: app._status_row.pack_forget() app._status_row.pack(fill="x", padx=18, pady=(0, 4)) except Exception: pass try: if getattr(app, "paned", None) is not None: app.paned.pack_forget() app.paned.pack(fill="both", expand=True, padx=18, pady=(0, 12)) except Exception: pass # ── Innere Panels in Empfang-Look bringen ──────────────────────────── def _restyle_inner_panels(self): """Setzt die Hintergrund- und Textfarben der wichtigsten Container und Labels in der ursprünglichen UI auf das neue Theme. Wir berühren bewusst nur Frames und Labels – die fertigen ``RoundedButton``-Knöpfe in der KG-Spalte (KG erstellen, Brief, SOAP-Pfeile, Textblöcke) behalten ihre Form und Funktion, damit die produktiven Aktionen unverändert nutzbar bleiben.""" app = self.app t = self._theme try: app.configure(bg=t["bg"]) except Exception: pass for attr in ( "lbl_status", "_lbl_build", "_token_label", "_backend_status_label", ): w = getattr(app, attr, None) if w is None: continue try: w.configure(bg=t["bg"]) except Exception: pass for attr in ( "_transcript_toggle_label", "_kg_toggle_label", "_addon_toggle_label", "_soap_toggle_label", "_dokumente_toggle_label", "_textbloecke_toggle_label", ): w = getattr(app, attr, None) if w is None: continue try: w.configure(bg=t["bg"], fg=t["text"]) except Exception: pass # Status-Zeile / Build-Label try: if getattr(app, "_status_row", None) is not None: app._status_row.configure(style="StatusBar.TFrame") except Exception: pass # Checkbuttons (Kommentare/Empfang anzeigen, Rechtsklick-Paste) for attr in ("_chk_kommentare_auto", "_chk_empfang_auto", "_rclick_cb"): w = getattr(app, attr, None) if w is None: continue try: w.configure( bg=t["bg"], activebackground=t["bg"], fg=t["text"], selectcolor=t["bg"], ) self._chk_widgets.append(w) except Exception: pass # ── Verhalten / Aktionen ───────────────────────────────────────────── def _suppress_dev_status_popup(self): app = self.app try: existing = getattr(app, "_dev_status_window", None) if existing is not None and existing.winfo_exists(): existing.destroy() except Exception: pass app._dev_status_window = None def _collapse_transcript_default(self): app = self.app try: if getattr(app, "_transcript_collapsed", False): self._sync_transcript_btn() return if hasattr(app, "_toggle_transcript_collapse"): app._toggle_transcript_collapse() else: frame = getattr(app, "_transcript_frame", None) if frame is not None: frame.pack_forget() app._transcript_collapsed = True self._sync_transcript_btn() except Exception: pass def _sync_transcript_btn(self): if not self._transcript_btn: return collapsed = bool(getattr(self.app, "_transcript_collapsed", True)) self._transcript_btn.set_text( "Transkript einblenden" if collapsed else "Transkript ausblenden" ) def _on_toggle_transcript(self): app = self.app try: if hasattr(app, "_toggle_transcript_collapse"): app._toggle_transcript_collapse() else: frame = getattr(app, "_transcript_frame", None) if frame is None: return if getattr(app, "_transcript_collapsed", False): frame.pack(fill="both", expand=True) app._transcript_collapsed = False else: frame.pack_forget() app._transcript_collapsed = True finally: self._sync_transcript_btn() def _on_toggle_theme(self): self._dark = not self._dark self._theme = DARK.copy() if self._dark else LIGHT.copy() prefs = _load_prefs() prefs["dark_mode"] = self._dark _save_prefs(prefs) self._reapply_theme() if self._theme_btn: self._theme_btn.set_text("Hell" if self._dark else "Dunkel") def _reapply_theme(self): app = self.app t = self._theme try: app.configure(bg=t["bg"]) except Exception: pass _apply_ttk_theme(app, t) for attr in ("_header_frame",): w = getattr(self, attr, None) if w is not None: try: w.configure(bg=t["surface"]) self._recolor_descendants(w, bg=t["surface"], fg=t["text"]) except Exception: pass try: if self._header_sep is not None: self._header_sep.configure(bg=t["border"]) except Exception: pass try: if self._action_frame is not None: self._action_frame.configure(bg=t["bg"]) self._recolor_descendants(self._action_frame, bg=t["bg"], fg=t["text"]) except Exception: pass try: self._brand_title_lbl.configure(bg=t["surface"], fg=t["text_strong"]) self._brand_sub_lbl.configure(bg=t["surface"], fg=t["subtle"]) if self._logo_lbl is not None: self._logo_lbl.configure(bg=t["surface"]) except Exception: pass try: self._font_box.configure(bg=t["surface"], highlightbackground=t["border"]) self._font_minus.configure(bg=t["surface"], fg=t["text"]) self._font_plus.configure(bg=t["surface"], fg=t["text"]) self._font_label.configure(bg=t["surface"], fg=t["subtle"]) except Exception: pass try: self._connection_dot.configure(bg=t["surface"]) self._connection_lbl.configure(bg=t["surface"], fg=t["subtle"]) except Exception: pass for b in self._all_shell_buttons: try: b.apply_theme(t) except Exception: pass for w in self._chk_widgets: try: w.configure( bg=t["bg"], activebackground=t["bg"], fg=t["text"], selectcolor=t["bg"], ) except Exception: pass # Innere Panels mitziehen self._restyle_inner_panels() self._mirror_backend_status(force=True) def _recolor_descendants(self, parent, *, bg: str, fg: str): try: for child in parent.winfo_children(): if isinstance(child, tk.Frame): try: child.configure(bg=bg) except Exception: pass self._recolor_descendants(child, bg=bg, fg=fg) elif isinstance(child, tk.Label): try: child.configure(bg=bg) except Exception: pass except Exception: pass # ── Schriftgröße ────────────────────────────────────────────────────── _FONT_DELTA = 0.10 def _font_step_label(self) -> str: if self._font_step == 0: return "Standard" sign = "+" if self._font_step > 0 else "−" return f"{sign}{abs(self._font_step)}" def _on_font_step(self, delta: int): new_step = max(-2, min(4, self._font_step + delta)) if new_step == self._font_step: return self._font_step = new_step self._apply_font_step(new_step, persist=True) def _apply_font_step(self, step: int, *, persist: bool): app = self.app try: base = 1.0 try: from aza_config import FIXED_FONT_SCALE base = float(FIXED_FONT_SCALE) except Exception: pass scale = max(0.4, base + step * self._FONT_DELTA) if hasattr(app, "_apply_font_scale_global"): app._apply_font_scale_global(scale) elif hasattr(app, "_apply_font_scale"): app._apply_font_scale(scale) except Exception as exc: print(f"[Shell] Schriftskalierung fehlgeschlagen: {exc}") try: if self._font_label is not None: self._font_label.configure(text=self._font_step_label()) except Exception: pass if persist: prefs = _load_prefs() prefs["font_step"] = step _save_prefs(prefs) # ── Verbindungsanzeige (spiegelt _backend_status_var) ──────────────── def _mirror_backend_status(self, *, force: bool = False): app = self.app try: var = getattr(app, "_backend_status_var", None) if var is None: return if not hasattr(self, "_last_status_text"): self._last_status_text = None txt = "" try: txt = str(var.get()) except Exception: pass if force or txt != self._last_status_text: self._last_status_text = txt self._render_connection(txt) except Exception: pass try: app.after(1500, self._mirror_backend_status) except Exception: pass def _render_connection(self, txt: str): t = self._theme if not self._connection_dot or not self._connection_lbl: return low = txt.lower() if any(k in low for k in ("verbund", "online", "ok")): color, label = t["success"], "Verbunden" elif "geprüft" in low or "prüf" in low or "warte" in low: color, label = t["warn"], txt or "Verbindung wird geprüft …" else: color = t["danger"] label = txt if txt else "Nicht verbunden" try: self._connection_dot.delete("all") self._connection_dot.create_oval( 1, 1, 9, 9, fill=color, outline=color, ) self._connection_lbl.configure(text=label, fg=color) except Exception: pass # ── Helfer ─────────────────────────────────────────────────────────── def _delegate(self, obj, attr: str) -> Callable: def _do(): self._safe_call(obj, attr) return _do @staticmethod def _safe_call(obj, attr: str): fn = getattr(obj, attr, None) if not callable(fn): return try: fn() except Exception as exc: print(f"[Shell] Aufruf '{attr}' fehlgeschlagen: {exc}") def _reassign_button(self, app, attr_name: str, new_btn: ShellButton): """Ersetzt einen bestehenden RoundedButton-Attributverweis durch unseren neuen Pill-Button. State-Updates aus der App (``self.btn_record.configure(text=...)``) treffen damit den sichtbaren neuen Button. Der alte Knopf bleibt weiterhin als Python-Objekt bestehen (er sitzt im versteckten alten Top-Frame), wird aber nicht mehr angezeigt und bekommt keine Klicks mehr.""" try: scalable = getattr(app, "_scalable_widgets", None) old = getattr(app, attr_name, None) if isinstance(scalable, list) and old in scalable: scalable.remove(old) except Exception: pass try: setattr(app, attr_name, new_btn) except Exception: return self._all_shell_buttons.append(new_btn) try: scalable = getattr(app, "_scalable_widgets", None) if isinstance(scalable, list): scalable.append(new_btn) except Exception: pass # ─── Öffentliche API ───────────────────────────────────────────────────────── def apply_desktop_shell(app) -> None: """Wendet die saubere AzA-Desktop-Hülle auf eine bestehende ``KGDesktopApp`` an. Idempotent.""" if getattr(app, "_aza_shell_installed", False): return try: shell = _DesktopShell(app) shell.install() app._aza_shell = shell app._aza_shell_installed = True except Exception as exc: print(f"[Shell] Installation fehlgeschlagen: {exc}") import traceback traceback.print_exc() __all__ = ["apply_desktop_shell"]