Files
aza/AzA march 2026/aza_desktop_shell.py
2026-05-04 21:34:19 +02:00

1118 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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"]