1118 lines
41 KiB
Python
1118 lines
41 KiB
Python
# -*- 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"]
|