Files
aza/AzA march 2026 - Kopie (21)/aza_desktop_shell.py

1118 lines
41 KiB
Python
Raw Normal View History

2026-05-05 23:36:13 +02:00
# -*- 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("<Configure>", lambda e: self._draw())
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
self.bind("<ButtonPress-1>", self._on_press)
self.bind("<ButtonRelease-1>", 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("<Button-1>", 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("<Button-1>", 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"]