Files
aza/AzA march 2026/aza_office_shell_v1.py

1555 lines
52 KiB
Python
Raw Normal View History

2026-05-04 21:34:19 +02:00
# -*- coding: utf-8 -*-
"""
AzA Office Hülle V1.2
======================
Aufbauend auf V1.1:
* Keine feste linke Arbeitsoptionen-Leiste mehr mehr Platz für den Inhalt.
* **Zahnrad** oben rechts öffnet/schließt ein kompaktes Einstellungs-Popup
(gleiche Akzentfarbe wie Start/Korrigieren/Diktat).
* Popup: Rechtsklick-Einfügen, Kommentare anzeigen, **Chat-Empfang**
(Auto-Option + einmaliges Öffnen/Anheben des Empfang-Fensters über
``_send_to_empfang``).
* **Erscheinungsbild**: dieselbe **Transparenz-Logik** wie in der klassischen
Hauptfenster-Kopfzeile (``_opacity_var_main``, ``MIN_OPACITY``, ``save_opacity``)
sowie Zugriff auf **alle Einstellungen** (``_open_settings``). Zusätzlich
weiterhin **Hell/Dunkel** für die Office-Hülle-Farbpalette (persistiert),
da das klassische ``basis14``-Hauptfenster keine globale Farb-Umschaltung hat nur
Transparenz und das separate Einstellungsfenster.
* Footer-Branding und Logo ca. **30 % größer** als in V1.1.
Technische Strategie
--------------------
Nach ``_build_ui`` blendet die Hülle die alten Hauptfenster-Kinder aus und
baut die Office-Oberfläche neu auf. Bestehende App-Methoden und Variablen
aus ``KGDesktopApp`` werden verdrahtet.
"""
from __future__ import annotations
import json
import os
import sys
import tkinter as tk
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
from typing import Callable, Dict, List, Optional
try:
from PIL import Image, ImageTk
_HAS_PIL = True
except Exception:
_HAS_PIL = False
try:
from aza_config import MIN_OPACITY
except Exception:
MIN_OPACITY = 0.4
try:
from aza_persistence import load_opacity, save_opacity
except Exception:
def load_opacity() -> float:
return 1.0
def save_opacity(_v: float) -> None:
pass
PREFS_FILENAME = "aza_office_shell_v11_prefs.json"
FF = "Segoe UI"
FONT_DEFAULT = (FF, 9)
FONT_BOLD = (FF, 9, "bold")
FONT_SECTION = (FF, 10, "bold")
FONT_BRAND = (FF, 18, "bold") # V1.1: 14 → +~29 %
FONT_BRAND_SUB = (FF, 12) # V1.1: 9 → +33 %
BTN_W_HEADER = 110
BTN_W_ACTION = 130
BTN_W_DOC = 150
BTN_W_SOAP = 158
BTN_H = 32
LOGO_PX = 57 # V1.1: 44 → ×1.295 ≈ +30 %
# Popover = gleiche Farbe wie Primärbuttons (ACCENT aus Palette)
POPOVER_TRACK = "#3D6F8D"
POPOVER_KNOB = "#FFFFFF"
POPOVER_KNOB_RING = "#E2EEF6"
PALETTE_LIGHT: Dict[str, str] = {
"BG": "#EAF2F7",
"SURFACE": "#FFFFFF",
"SURFACE_ALT": "#F4F8FB",
"BORDER": "#D6E2EB",
"TEXT": "#1A4D6D",
"TEXT_STRONG": "#0F3850",
"SUBTLE": "#5C7A8E",
"ACCENT": "#5B8DB3",
"ACCENT_HOVER": "#4A7A9E",
"ACCENT_PRESSED": "#3A6884",
"ACCENT_SOFT": "#E2EEF6",
"WARN": "#C2840F",
"RECORD": "#C0392B",
"RECORD_HOVER": "#A6291C",
"TEXT_AREA_BG": "#FFFFFF",
"TEXT_AREA_FG": "#1A4D6D",
}
PALETTE_DARK: Dict[str, str] = {
"BG": "#1B2E36",
"SURFACE": "#243B45",
"SURFACE_ALT": "#2D4652",
"BORDER": "#3D5866",
"TEXT": "#E8F2F7",
"TEXT_STRONG": "#FFFFFF",
"SUBTLE": "#9BB8C9",
"ACCENT": "#6BA3C4",
"ACCENT_HOVER": "#5A92B5",
"ACCENT_PRESSED": "#4A82A6",
"ACCENT_SOFT": "#2F4F5E",
"WARN": "#E0B456",
"RECORD": "#E74C3C",
"RECORD_HOVER": "#C0392B",
"TEXT_AREA_BG": "#1E323B",
"TEXT_AREA_FG": "#EAF4F8",
}
def _prefs_path() -> str:
try:
from aza_config import get_writable_data_dir
base = get_writable_data_dir()
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, PREFS_FILENAME)
def _load_dark_pref() -> bool:
try:
with open(_prefs_path(), encoding="utf-8") as fh:
data = json.load(fh)
return bool(data.get("dark_mode", False))
except Exception:
return False
def _save_dark_pref(dark: bool) -> None:
try:
path = _prefs_path()
data = {}
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
pass
data["dark_mode"] = dark
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
except Exception as exc:
print(f"[OfficeV1.2] Konnte Erscheinungsbild nicht speichern: {exc}")
class PillButton(tk.Canvas):
"""Pill-Button; Farben aus Palette-Dict."""
def __init__(
self,
parent,
text: str,
command: Optional[Callable] = None,
*,
kind: str = "default",
width: int = BTN_W_ACTION,
height: int = BTN_H,
weight: str = "normal",
tooltip: Optional[str] = None,
palette: Dict[str, str],
):
bg = parent.cget("bg") if hasattr(parent, "cget") else PALETTE_LIGHT["BG"]
super().__init__(parent, width=width, height=height, bg=bg,
highlightthickness=0, bd=0, cursor="hand2")
self._text = text
self._command = command
self._kind = kind
self._btn_w = width
self._btn_h = height
self._weight = weight
self._palette = palette
self._hover = False
self._press = 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:
from aza_ui_helpers import add_tooltip
add_tooltip(self, tooltip)
except Exception:
pass
self._draw()
def _p(self) -> Dict[str, str]:
return self._palette
def _colors(self):
p = self._p()
if self._kind == "primary":
fill = (p["ACCENT_PRESSED"] if self._press
else p["ACCENT_HOVER"] if self._hover else p["ACCENT"])
return fill, "white", fill
if self._kind == "danger":
fill = p["RECORD_HOVER"] if (self._hover or self._press) else p["RECORD"]
return fill, "white", fill
if self._kind == "ghost":
fill = p["ACCENT_SOFT"] if (self._hover or self._press) else p["SURFACE"]
border = p["ACCENT"] if (self._hover or self._press) else p["BORDER"]
return fill, p["TEXT"], border
fill = p["ACCENT_SOFT"] if (self._hover or self._press) else p["SURFACE_ALT"]
border = p["ACCENT"] if (self._hover or self._press) else p["BORDER"]
return fill, p["TEXT"], border
def _draw(self):
try:
self.delete("all")
w = max(self._btn_w, int(self.winfo_width() or 0))
h = max(self._btn_h, int(self.winfo_height() or 0))
fill, fg, border = self._colors()
r = max(2, min(8, h // 2))
self._round_rect(0, 0, w, h, r, fill=fill, outline=border)
self.create_text(w // 2, h // 2, text=self._text, fill=fg,
font=(FF, 9, 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)
def _on_enter(self, _e=None):
self._hover = True
self._draw()
def _on_leave(self, _e=None):
self._hover = False
self._press = False
self._draw()
def _on_press(self, _e=None):
self._press = True
self._draw()
def _on_release(self, _e=None):
was = self._press
self._press = False
self._draw()
if was and self._command:
try:
self._command()
except Exception as exc:
print(f"[OfficeV1.2] Aktion '{self._text}' fehlgeschlagen: {exc}")
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 kw:
try:
super().configure(**kw)
except Exception:
pass
config = configure
def set_text(self, t: str):
self.configure(text=t)
def set_palette_ref(self, palette: Dict[str, str]):
self._palette = palette
self._draw()
def set_font_size_scale(self, _s: float):
return None
def set_button_size_scale(self, _s: float):
return None
def set_font_scale(self, _s: float):
return None
class PopoverThemeSwitch(tk.Canvas):
"""Hell/Dunkel-Schalter für Office-Hülle (auf Akzent-Hintergrund)."""
def __init__(
self,
parent,
*,
width: int = 52,
height: int = 26,
is_dark: bool,
command: Callable[[], None],
bg_accent: str,
):
super().__init__(
parent, width=width, height=height, bg=bg_accent,
highlightthickness=0, bd=0, cursor="hand2",
)
self._is_dark = is_dark
self._command = command
self._bg_accent = bg_accent
self._track_w = width
self._track_h = height
self.bind("<Button-1>", self._on_click)
self.bind("<Configure>", lambda e: self._draw())
self._draw()
def _on_click(self, _e=None):
try:
self._command()
except Exception as exc:
print(f"[OfficeV1.2] Theme-Toggle: {exc}")
def set_dark(self, dark: bool):
self._is_dark = dark
self._draw()
def _draw(self):
try:
self.delete("all")
w = max(self._track_w, int(self.winfo_width() or 0))
h = max(self._track_h, int(self.winfo_height() or 0))
pad = 3
x1, y1, x2, y2 = pad, h // 2 - 8, w - pad, h // 2 + 8
r = 10
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,
]
self.create_polygon(pts, smooth=True, fill=POPOVER_TRACK, outline="")
knob_r = 9
cx = (w - pad - knob_r - 4) if self._is_dark else (pad + knob_r + 4)
cy = h // 2
self.create_oval(
cx - knob_r, cy - knob_r,
cx + knob_r, cy + knob_r,
fill=POPOVER_KNOB, outline=POPOVER_KNOB_RING, width=1,
)
except Exception:
pass
def _load_logo(size: int):
if not _HAS_PIL:
return None
cands: list[str] = [os.path.dirname(os.path.abspath(__file__))]
if getattr(sys, "frozen", False):
try:
cands.append(os.path.dirname(os.path.abspath(sys.executable)))
except Exception:
pass
m = getattr(sys, "_MEIPASS", "")
if m:
cands.append(m)
for d in cands:
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
def _safe_call(obj, attr: str):
fn = getattr(obj, attr, None)
if not callable(fn):
print(f"[OfficeV1.2] Methode '{attr}' nicht verfügbar.")
return
try:
fn()
except Exception as exc:
print(f"[OfficeV1.2] Aufruf '{attr}' fehlgeschlagen: {exc}")
class _OfficeShellV12:
def __init__(self, app):
self.app = app
self._logo_img = None
self._record_btn: Optional[PillButton] = None
self._korrigieren_btn: Optional[PillButton] = None
self._diktat_btn: Optional[PillButton] = None
self._license_lbl: Optional[tk.Label] = None
self._theme_switch_pop: Optional[PopoverThemeSwitch] = None
self._sidebar: Optional[tk.Frame] = None
self._sec_arb_open: bool = True
self._sec_ersch_open: bool = True
self._sec_arb_arrow: Optional[tk.Label] = None
self._sec_ersch_arrow: Optional[tk.Label] = None
self._sec_arb_body: Optional[tk.Frame] = None
self._sec_ersch_body: Optional[tk.Frame] = None
self._transcript_open: bool = False
self._kg_open: bool = True
self._main_fill: Optional[tk.Frame] = None
self._content: Optional[tk.Frame] = None
self._header_inner: Optional[tk.Frame] = None
self._header_bar: Optional[tk.Frame] = None
self._footer_bar: Optional[tk.Frame] = None
self._sep_top: Optional[tk.Frame] = None
self._sep_bottom: Optional[tk.Frame] = None
self._status_row_fr: Optional[tk.Frame] = None
self._palette: Dict[str, str] = {}
self._dark_mode: bool = False
self._pills: List[PillButton] = []
self._footer_tb: Optional[tk.Frame] = None
self._footer_logo_lbl: Optional[tk.Label] = None
self._footer_brand_title: Optional[tk.Label] = None
self._footer_brand_sub: Optional[tk.Label] = None
self._shell_labels: List[tk.Label] = []
# ── Öffentlich ────────────────────────────────────────────────────
def install(self):
app = self.app
self._dark_mode = _load_dark_pref()
self._palette = (
PALETTE_DARK.copy() if self._dark_mode else PALETTE_LIGHT.copy()
)
try:
from aza_version import APP_VERSION
app.title(f"AzA Office (v{APP_VERSION})")
except Exception:
app.title("AzA Office")
try:
app.configure(bg=self._palette["BG"])
except Exception:
pass
self._hide_legacy_children()
self._apply_ttk_theme()
self._build_footer()
self._build_header()
self._build_status_row()
self._build_main_fill()
if self._record_btn is not None:
app.btn_record = self._record_btn
if self._korrigieren_btn is not None:
app.btn_record_append = self._korrigieren_btn
if self._diktat_btn is not None:
app._btn_diktat_top = self._diktat_btn
try:
app.after(250, self._enforce_default_fonts)
except Exception:
pass
try:
ud = getattr(app, "_update_kg_detail_display", None)
if callable(ud):
ud()
except Exception:
pass
self._update_license_label()
try:
app.after(2500, self._periodic_license_refresh)
except Exception:
pass
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
# ── Hilfen ────────────────────────────────────────────────────────
def _register_pill(self, b: PillButton) -> PillButton:
self._pills.append(b)
return b
def _hide_legacy_children(self):
for child in list(self.app.winfo_children()):
for forget in ("pack_forget", "place_forget", "grid_forget"):
try:
getattr(child, forget)()
except Exception:
pass
def _apply_opacity_percent_str(self, val: str) -> None:
"""Wie ``on_opacity_main`` in ``basis14._build_ui`` (Transparenz)."""
app = self.app
try:
alpha = float(val) / 100.0
alpha = max(float(MIN_OPACITY), min(1.0, alpha))
app.attributes("-alpha", alpha)
save_opacity(alpha)
ov = getattr(app, "_opacity_var_main", None)
if ov is not None:
ov.set(round(alpha * 100))
sc = getattr(app, "_opacity_scale_main", None)
if sc is not None:
try:
sc.set(round(alpha * 100))
except Exception:
pass
except Exception:
pass
def _apply_ttk_theme(self):
p = self._palette
try:
style = ttk.Style(self.app)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure("TFrame", background=p["BG"])
style.configure("TLabel", background=p["BG"], foreground=p["TEXT"],
font=FONT_DEFAULT)
style.configure(
"TButton", background=p["ACCENT"], foreground="white",
padding=(10, 6), borderwidth=0, font=FONT_DEFAULT,
)
style.map("TButton", background=[
("active", p["ACCENT_HOVER"]),
("pressed", p["ACCENT_PRESSED"]),
])
style.configure(
"OfficePop.Horizontal.TScale",
troughcolor="#E2EEF6",
background=p["ACCENT"],
)
except Exception:
pass
def _apply_main_theme(self):
p = self._palette
app = self.app
try:
app.configure(bg=p["BG"])
except Exception:
pass
for fr in (self._header_inner, self._header_bar):
if fr is not None:
try:
fr.configure(bg=p["SURFACE"])
except Exception:
pass
if self._header_inner is not None:
try:
for ch in self._header_inner.winfo_children():
try:
if ch is self._gear_btn:
continue
ch.configure(bg=p["SURFACE"])
except Exception:
pass
except Exception:
pass
for fr in (self._main_fill, self._content, self._status_row_fr,
self._footer_bar):
if fr is not None:
try:
fr.configure(bg=p["BG"])
except Exception:
pass
if self._sep_top is not None:
try:
self._sep_top.configure(bg=p["BORDER"])
except Exception:
pass
if self._sep_bottom is not None:
try:
self._sep_bottom.configure(bg=p["BORDER"])
except Exception:
pass
try:
app.lbl_status.configure(bg=p["BG"], fg=p["SUBTLE"])
except Exception:
pass
if self._license_lbl is not None:
try:
mode = getattr(app, "license_mode", "demo")
self._license_lbl.configure(
bg=p["SURFACE"],
fg=p["ACCENT"] if mode == "active" else p["WARN"],
)
except Exception:
pass
self._apply_section_backgrounds(p["BG"])
self._apply_shell_labels(p)
self._apply_text_widgets(p)
if self._footer_tb is not None:
try:
self._footer_tb.configure(bg=p["BG"])
except Exception:
pass
if self._footer_logo_lbl is not None:
try:
self._footer_logo_lbl.configure(bg=p["BG"])
except Exception:
pass
if self._footer_brand_title is not None:
try:
self._footer_brand_title.configure(bg=p["BG"], fg=p["TEXT_STRONG"])
except Exception:
pass
if self._footer_brand_sub is not None:
try:
self._footer_brand_sub.configure(bg=p["BG"], fg=p["SUBTLE"])
except Exception:
pass
for pill in self._pills:
try:
pill.configure(bg=p["SURFACE"])
except Exception:
pass
pill.set_palette_ref(p)
app._soap_bg = p["BG"]
try:
app._rebuild_soap_section_controls()
except Exception:
pass
self._apply_ttk_theme()
if self._sidebar is not None:
try:
self._sidebar.configure(bg=p["ACCENT"])
except Exception:
pass
try:
self._build_sidebar_content()
except Exception:
pass
def _toggle_theme_main(self):
self._dark_mode = not self._dark_mode
_save_dark_pref(self._dark_mode)
self._palette = (
PALETTE_DARK.copy() if self._dark_mode else PALETTE_LIGHT.copy()
)
self._apply_main_theme()
def _apply_shell_labels(self, p: Dict[str, str]):
for w in self._shell_labels:
try:
w.configure(bg=p["BG"], fg=p["TEXT"])
except Exception:
pass
def _apply_section_backgrounds(self, bg: str):
sh = getattr(self, "_shell_section_frames", None)
if not sh:
return
for fr in sh:
try:
fr.configure(bg=bg)
except Exception:
pass
def _apply_text_widgets(self, p: Dict[str, str]):
for attr in ("txt_output", "txt_transcript"):
w = getattr(self.app, attr, None)
if w is None:
continue
try:
w.configure(
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"],
highlightbackground=p["BORDER"], insertbackground=p["TEXT_AREA_FG"],
)
except Exception:
pass
def _on_chat_empfang_toggle(self):
_safe_call(self.app, "_toggle_empfang_auto")
try:
self.app.after(80, lambda: _safe_call(self.app, "_send_to_empfang"))
except Exception:
pass
# ── (Legacy Popover-Code, in V1.2.1 nicht mehr verwendet) ─────────
def _destroy_settings_pop(self):
if self._settings_win is not None:
try:
self._settings_win.destroy()
except Exception:
pass
self._settings_win = None
self._theme_switch_pop = None
def _toggle_settings_pop(self):
if self._settings_win is not None:
try:
if self._settings_win.winfo_exists():
self._destroy_settings_pop()
return
except tk.TclError:
pass
self._show_settings_pop()
def _show_settings_pop(self):
self._destroy_settings_pop()
app = self.app
acc = self._palette["ACCENT"]
pop = tk.Toplevel(app)
pop.withdraw()
pop.configure(bg=acc)
try:
pop.transient(app)
except Exception:
pass
self._settings_win = pop
pad = dict(
bg=acc, fg="white", font=FONT_DEFAULT,
activebackground=acc, activeforeground="white",
selectcolor="#E2EEF6", highlightthickness=0,
bd=0, anchor="w",
)
outer = tk.Frame(pop, bg=acc, padx=14, pady=12)
outer.pack(fill="both", expand=True)
tk.Label(
outer, text="Arbeitsoptionen",
bg=acc, fg="white", font=(FF, 10, "bold"),
).pack(anchor="w", pady=(0, 8))
if getattr(app, "_rclick_paste_var", None) is None:
app._rclick_paste_var = tk.BooleanVar(master=app, value=True)
if getattr(app, "_kommentare_auto_var", None) is None:
app._kommentare_auto_var = tk.BooleanVar(master=app, value=False)
if getattr(app, "_empfang_auto_var", None) is None:
app._empfang_auto_var = tk.BooleanVar(master=app, value=False)
tk.Checkbutton(
outer, text="Rechtsklick = Einfügen",
variable=app._rclick_paste_var,
command=lambda: _safe_call(app, "_toggle_rclick_paste"),
**pad,
).pack(fill="x", pady=3)
tk.Checkbutton(
outer, text="Kommentare anzeigen",
variable=app._kommentare_auto_var,
command=lambda: _safe_call(app, "_toggle_kommentare_auto"),
**pad,
).pack(fill="x", pady=3)
tk.Checkbutton(
outer, text="Chat-Empfang",
variable=app._empfang_auto_var,
command=self._on_chat_empfang_toggle,
**pad,
).pack(fill="x", pady=3)
tk.Frame(outer, bg=acc, height=8).pack()
tk.Label(
outer, text="Erscheinungsbild",
bg=acc, fg="white", font=(FF, 10, "bold"),
).pack(anchor="w", pady=(4, 6))
tk.Label(
outer,
text="Fenster-Transparenz (wie Hauptfenster)",
bg=acc, fg="#E2EEF6", font=FONT_DEFAULT,
).pack(anchor="w")
ov = getattr(app, "_opacity_var_main", None)
if ov is None:
ov = tk.DoubleVar(master=app, value=round(load_opacity() * 100))
app._opacity_var_main = ov
row_op = tk.Frame(outer, bg=acc)
row_op.pack(fill="x", pady=(4, 2))
lbl_half = tk.Label(
row_op, text="", font=("Segoe UI Symbol", 14),
bg=acc, fg="white", cursor="hand2",
)
lbl_half.pack(side="left", padx=(0, 4))
lbl_half.bind(
"<Button-1>",
lambda e: self._apply_opacity_percent_str(str(int(MIN_OPACITY * 100))),
)
sc = ttk.Scale(
row_op,
from_=40,
to=100,
variable=ov,
orient="horizontal",
length=140,
command=self._apply_opacity_percent_str,
style="OfficePop.Horizontal.TScale",
)
sc.pack(side="left", fill="x", expand=True, padx=(0, 4))
lbl_full = tk.Label(
row_op, text="", font=("Segoe UI Symbol", 14),
bg=acc, fg="white", cursor="hand2",
)
lbl_full.pack(side="left")
lbl_full.bind("<Button-1>", lambda e: self._apply_opacity_percent_str("100"))
tk.Label(
outer,
text="Office-Hülle hell / dunkel",
bg=acc, fg="#E2EEF6", font=FONT_DEFAULT,
).pack(anchor="w", pady=(8, 4))
row_th = tk.Frame(outer, bg=acc)
row_th.pack(fill="x")
tk.Label(row_th, text="Hell", bg=acc, fg="white",
font=FONT_DEFAULT).pack(side="left", padx=(0, 6))
def _flip():
self._toggle_theme_main()
if self._theme_switch_pop:
self._theme_switch_pop.set_dark(self._dark_mode)
self._theme_switch_pop = PopoverThemeSwitch(
row_th,
is_dark=self._dark_mode,
command=_flip,
bg_accent=acc,
)
self._theme_switch_pop.pack(side="left")
tk.Label(row_th, text="Dunkel", bg=acc, fg="white",
font=FONT_DEFAULT).pack(side="left", padx=(6, 0))
tk.Frame(outer, bg=acc, height=6).pack()
link = tk.Label(
outer,
text="Weitere Einstellungen …",
bg=acc, fg="white", font=(FF, 9, "underline"),
cursor="hand2",
)
link.pack(anchor="w", pady=(4, 0))
link.bind("<Button-1>", lambda e: (_safe_call(app, "_open_settings"), self._destroy_settings_pop()))
pop.update_idletasks()
w_req = max(outer.winfo_reqwidth() + 28, 280)
h_req = outer.winfo_reqheight() + 24
if self._gear_btn is not None:
self._gear_btn.update_idletasks()
gx = self._gear_btn.winfo_rootx()
gy = self._gear_btn.winfo_rooty()
gh = self._gear_btn.winfo_height()
gw = self._gear_btn.winfo_width()
sw = pop.winfo_screenwidth()
sh = pop.winfo_screenheight()
px = min(max(8, gx + gw - w_req), sw - w_req - 8)
py = gy + gh + 6
if py + h_req > sh - 8:
py = max(8, gy - h_req - 6)
else:
px = app.winfo_rootx() + app.winfo_width() - w_req - 24
py = app.winfo_rooty() + 72
pop.geometry(f"{w_req}x{h_req}+{px}+{py}")
try:
pop.overrideredirect(True)
except Exception:
pass
try:
pop.deiconify()
pop.lift()
pop.attributes("-topmost", True)
pop.after(120, lambda: pop.attributes("-topmost", False))
except Exception:
pass
# ── Header / Body ─────────────────────────────────────────────────
def _build_header(self):
app = self.app
p = self._palette
self._header_bar = tk.Frame(app, bg=p["SURFACE"], bd=0,
highlightthickness=0)
self._header_bar.pack(side="top", fill="x")
self._sep_top = tk.Frame(app, bg=p["BORDER"], height=1)
self._sep_top.pack(side="top", fill="x")
self._header_inner = tk.Frame(self._header_bar, bg=p["SURFACE"])
self._header_inner.pack(fill="x", padx=18, pady=10)
left = tk.Frame(self._header_inner, bg=p["SURFACE"])
left.pack(side="left")
self._record_btn = self._register_pill(PillButton(
left, "⏺ Start",
command=lambda: _safe_call(app, "toggle_record"),
kind="primary", width=BTN_W_ACTION, weight="bold",
tooltip="Aufnahme starten / stoppen (Transkription)",
palette=p,
))
self._record_btn.pack(side="left", padx=(0, 8))
self._korrigieren_btn = self._register_pill(PillButton(
left, "⏺ Korrigieren",
command=lambda: _safe_call(app, "_toggle_record_append"),
kind="primary", width=BTN_W_ACTION, weight="bold",
tooltip="Korrektur-/Append-Aufnahme",
palette=p,
))
self._korrigieren_btn.pack(side="left", padx=(0, 8))
self._diktat_btn = self._register_pill(PillButton(
left, "Diktat",
command=lambda: _safe_call(app, "open_diktat_window"),
kind="primary", width=BTN_W_ACTION, weight="bold",
tooltip="Diktatfenster öffnen",
palette=p,
))
self._diktat_btn.pack(side="left")
right = tk.Frame(self._header_inner, bg=p["SURFACE"])
right.pack(side="right")
self._license_lbl = tk.Label(
right, text="Lizenz prüfen …", font=FONT_DEFAULT,
bg=p["SURFACE"], fg=p["SUBTLE"], cursor="hand2", padx=8, pady=4,
)
self._license_lbl.pack(side="left", padx=(0, 12))
self._license_lbl.bind(
"<Button-1>",
lambda e: _safe_call(app, "_show_activation_dialog"),
)
self._register_pill(PillButton(
right, "Profil",
command=lambda: _safe_call(app, "_show_profile_editor"),
kind="ghost", width=BTN_W_HEADER, palette=p,
tooltip="Profil bearbeiten",
)).pack(side="left", padx=(0, 6))
self._register_pill(PillButton(
right, "Aktivierung",
command=lambda: _safe_call(app, "_show_activation_dialog"),
kind="ghost", width=BTN_W_HEADER, palette=p,
tooltip="Aktivierungsdialog öffnen",
)).pack(side="left")
def _build_status_row(self):
app = self.app
p = self._palette
self._status_row_fr = tk.Frame(app, bg=p["BG"])
self._status_row_fr.pack(side="top", fill="x", padx=18, pady=(8, 4))
var = getattr(app, "status_var", None)
if var is None:
var = tk.StringVar(master=app, value="Bereit.")
app.status_var = var
lbl = tk.Label(self._status_row_fr, textvariable=var, bg=p["BG"],
fg=p["SUBTLE"], font=FONT_DEFAULT, anchor="w")
lbl.pack(side="left")
app.lbl_status = lbl
def _build_main_fill(self):
app = self.app
p = self._palette
self._main_fill = tk.Frame(app, bg=p["BG"])
self._main_fill.pack(side="top", fill="both", expand=True)
self._sidebar = tk.Frame(self._main_fill, bg=p["ACCENT"], width=220)
self._sidebar.pack(side="left", fill="y")
self._sidebar.pack_propagate(False)
self._build_sidebar_content()
self._content = tk.Frame(self._main_fill, bg=p["BG"])
self._content.pack(side="left", fill="both", expand=True)
self._shell_section_frames = []
self._build_transcript_section()
self._build_kg_section()
self._build_soap_section()
self._build_documents_section()
def _build_sidebar_content(self):
app = self.app
acc = self._palette["ACCENT"]
bar = self._sidebar
for w in list(bar.winfo_children()):
try:
w.destroy()
except Exception:
pass
cb_pad = dict(
bg=acc, fg="white", font=FONT_DEFAULT,
activebackground=acc, activeforeground="white",
selectcolor="#E2EEF6", highlightthickness=0,
bd=0, anchor="w",
)
if getattr(app, "_rclick_paste_var", None) is None:
app._rclick_paste_var = tk.BooleanVar(master=app, value=True)
if getattr(app, "_kommentare_auto_var", None) is None:
app._kommentare_auto_var = tk.BooleanVar(master=app, value=False)
if getattr(app, "_empfang_auto_var", None) is None:
app._empfang_auto_var = tk.BooleanVar(master=app, value=False)
# ── Sektion: Arbeitsoptionen ─────────────────────────────
head_arb = tk.Frame(bar, bg=acc, cursor="hand2")
head_arb.pack(fill="x", padx=10, pady=(14, 4))
self._sec_arb_arrow = tk.Label(
head_arb,
text=("" if self._sec_arb_open else ""),
bg=acc, fg="white", font=(FF, 9, "bold"),
cursor="hand2",
)
self._sec_arb_arrow.pack(side="left", padx=(0, 6))
ttl_arb = tk.Label(
head_arb, text="Arbeitsoptionen",
bg=acc, fg="white", font=(FF, 9, "bold"),
cursor="hand2",
)
ttl_arb.pack(side="left")
for _w in (head_arb, self._sec_arb_arrow, ttl_arb):
_w.bind("<Button-1>", lambda e: self._toggle_section_arb())
self._sec_arb_body = tk.Frame(bar, bg=acc)
tk.Checkbutton(
self._sec_arb_body, text="Rechtsklick = Einfügen",
variable=app._rclick_paste_var,
command=lambda: _safe_call(app, "_toggle_rclick_paste"),
**cb_pad,
).pack(fill="x", padx=12, pady=3)
tk.Checkbutton(
self._sec_arb_body, text="Kommentare anzeigen",
variable=app._kommentare_auto_var,
command=lambda: _safe_call(app, "_toggle_kommentare_auto"),
**cb_pad,
).pack(fill="x", padx=12, pady=3)
tk.Checkbutton(
self._sec_arb_body, text="Chat-Empfang",
variable=app._empfang_auto_var,
command=self._on_chat_empfang_toggle,
**cb_pad,
).pack(fill="x", padx=12, pady=3)
if self._sec_arb_open:
self._sec_arb_body.pack(fill="x")
tk.Frame(bar, bg=acc, height=10).pack()
# ── Sektion: Erscheinungsbild ────────────────────────────
head_ersch = tk.Frame(bar, bg=acc, cursor="hand2")
head_ersch.pack(fill="x", padx=10, pady=(2, 4))
self._sec_ersch_arrow = tk.Label(
head_ersch,
text=("" if self._sec_ersch_open else ""),
bg=acc, fg="white", font=(FF, 9, "bold"),
cursor="hand2",
)
self._sec_ersch_arrow.pack(side="left", padx=(0, 6))
ttl_ersch = tk.Label(
head_ersch, text="Erscheinungsbild",
bg=acc, fg="white", font=(FF, 9, "bold"),
cursor="hand2",
)
ttl_ersch.pack(side="left")
for _w in (head_ersch, self._sec_ersch_arrow, ttl_ersch):
_w.bind("<Button-1>", lambda e: self._toggle_section_ersch())
self._sec_ersch_body = tk.Frame(bar, bg=acc)
tk.Label(
self._sec_ersch_body, text="Fenster-Transparenz",
bg=acc, fg="#E2EEF6", font=FONT_DEFAULT,
).pack(anchor="w", padx=14)
ov = getattr(app, "_opacity_var_main", None)
if ov is None:
ov = tk.DoubleVar(master=app, value=round(load_opacity() * 100))
app._opacity_var_main = ov
row_op = tk.Frame(self._sec_ersch_body, bg=acc)
row_op.pack(fill="x", padx=12, pady=(2, 4))
lbl_half = tk.Label(
row_op, text="", font=("Segoe UI Symbol", 14),
bg=acc, fg="white", cursor="hand2",
)
lbl_half.pack(side="left", padx=(0, 4))
lbl_half.bind(
"<Button-1>",
lambda e: self._apply_opacity_percent_str(str(int(MIN_OPACITY * 100))),
)
sc = ttk.Scale(
row_op, from_=40, to=100, variable=ov,
orient="horizontal", length=120,
command=self._apply_opacity_percent_str,
style="OfficePop.Horizontal.TScale",
)
sc.pack(side="left", fill="x", expand=True, padx=(0, 4))
lbl_full = tk.Label(
row_op, text="", font=("Segoe UI Symbol", 14),
bg=acc, fg="white", cursor="hand2",
)
lbl_full.pack(side="left")
lbl_full.bind("<Button-1>", lambda e: self._apply_opacity_percent_str("100"))
tk.Label(
self._sec_ersch_body, text="Office-Hülle hell / dunkel",
bg=acc, fg="#E2EEF6", font=FONT_DEFAULT,
).pack(anchor="w", padx=14, pady=(8, 4))
row_th = tk.Frame(self._sec_ersch_body, bg=acc)
row_th.pack(fill="x", padx=12)
tk.Label(row_th, text="Hell", bg=acc, fg="white",
font=FONT_DEFAULT).pack(side="left", padx=(0, 6))
def _flip():
self._toggle_theme_main()
if self._theme_switch_pop is not None:
self._theme_switch_pop.set_dark(self._dark_mode)
self._theme_switch_pop = PopoverThemeSwitch(
row_th, is_dark=self._dark_mode, command=_flip, bg_accent=acc,
)
self._theme_switch_pop.pack(side="left")
tk.Label(row_th, text="Dunkel", bg=acc, fg="white",
font=FONT_DEFAULT).pack(side="left", padx=(6, 0))
tk.Frame(self._sec_ersch_body, bg=acc, height=8).pack()
link = tk.Label(
self._sec_ersch_body, text="Weitere Einstellungen …",
bg=acc, fg="white", font=(FF, 9, "underline"),
cursor="hand2",
)
link.pack(anchor="w", padx=14, pady=(2, 12))
link.bind("<Button-1>", lambda e: _safe_call(app, "_open_settings"))
if self._sec_ersch_open:
self._sec_ersch_body.pack(fill="x")
def _toggle_section_arb(self):
self._sec_arb_open = not self._sec_arb_open
if self._sec_arb_arrow is not None:
try:
self._sec_arb_arrow.configure(
text=("" if self._sec_arb_open else ""),
)
except Exception:
pass
if self._sec_arb_body is not None:
try:
if self._sec_arb_open:
self._sec_arb_body.pack(
fill="x",
before=self._sec_ersch_arrow.master if self._sec_ersch_arrow else None,
)
else:
self._sec_arb_body.pack_forget()
except Exception:
pass
def _toggle_section_ersch(self):
self._sec_ersch_open = not self._sec_ersch_open
if self._sec_ersch_arrow is not None:
try:
self._sec_ersch_arrow.configure(
text=("" if self._sec_ersch_open else ""),
)
except Exception:
pass
if self._sec_ersch_body is not None:
try:
if self._sec_ersch_open:
self._sec_ersch_body.pack(fill="x")
else:
self._sec_ersch_body.pack_forget()
except Exception:
pass
def _build_transcript_section(self):
app = self.app
p = self._palette
parent = self._content
wrap = tk.Frame(parent, bg=p["BG"])
wrap.pack(side="top", fill="x", padx=18, pady=(4, 4))
self._shell_section_frames.append(wrap)
head = tk.Frame(wrap, bg=p["BG"], cursor="hand2")
head.pack(fill="x")
self._shell_section_frames.extend([head])
arrow = tk.Label(
head, text="", bg=p["BG"], fg=p["TEXT"], font=FONT_SECTION,
padx=2, cursor="hand2",
)
arrow.pack(side="left")
title = tk.Label(head, text=" Transkript", bg=p["BG"], fg=p["TEXT"],
font=FONT_SECTION, cursor="hand2")
title.pack(side="left")
self._shell_labels.extend([arrow, title])
body = tk.Frame(wrap, bg=p["BG"])
self._shell_section_frames.append(body)
txt = ScrolledText(
body, wrap="word", font=FONT_DEFAULT,
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"], relief="flat", bd=0,
height=8, highlightthickness=1, highlightbackground=p["BORDER"],
padx=8, pady=6, insertbackground=p["TEXT_AREA_FG"],
)
txt.pack(fill="x", pady=(6, 0))
app.txt_transcript = txt
app._transcript_frame = body
app._transcript_collapsed = True
def _toggle(_e=None):
self._transcript_open = not self._transcript_open
if self._transcript_open:
body.pack(fill="x")
arrow.configure(text="")
app._transcript_collapsed = False
else:
body.pack_forget()
arrow.configure(text="")
app._transcript_collapsed = True
for w in (head, arrow, title):
w.bind("<Button-1>", _toggle)
def _build_kg_section(self):
app = self.app
p = self._palette
parent = self._content
wrap = tk.Frame(parent, bg=p["BG"])
wrap.pack(side="top", fill="both", expand=True, padx=18, pady=(8, 4))
self._shell_section_frames.append(wrap)
head = tk.Frame(wrap, bg=p["BG"])
head.pack(fill="x")
self._shell_section_frames.append(head)
toggle_box = tk.Frame(head, bg=p["BG"], cursor="hand2")
toggle_box.pack(side="left")
self._shell_section_frames.append(toggle_box)
kg_arrow = tk.Label(
toggle_box, text="", bg=p["BG"], fg=p["TEXT"],
font=FONT_SECTION, padx=2, cursor="hand2",
)
kg_arrow.pack(side="left")
title = tk.Label(
toggle_box, text=" Krankengeschichte", bg=p["BG"], fg=p["TEXT"],
font=FONT_SECTION, cursor="hand2",
)
title.pack(side="left")
self._shell_labels.extend([kg_arrow, title])
actions = tk.Frame(head, bg=p["BG"])
actions.pack(side="right")
self._shell_section_frames.append(actions)
btn_make = self._register_pill(PillButton(
actions, "KG erstellen",
command=lambda: _safe_call(app, "make_kg_from_text"),
kind="primary", width=BTN_W_ACTION, weight="bold",
tooltip="Krankengeschichte aus Transkript erstellen",
palette=p,
))
btn_make.pack(side="left", padx=(0, 6))
app.btn_make_kg = btn_make
btn_copy = self._register_pill(PillButton(
actions, "KG kopieren",
command=lambda: _safe_call(app, "copy_output"),
kind="ghost", width=BTN_W_ACTION, palette=p,
tooltip="Krankengeschichte in Zwischenablage kopieren",
))
btn_copy.pack(side="left", padx=(0, 6))
app.btn_copy = btn_copy
btn_kom = self._register_pill(PillButton(
actions, "Kommentare",
command=lambda: _safe_call(app, "_open_kommentare_fenster"),
kind="ghost", width=BTN_W_ACTION, palette=p,
tooltip="Medizinische Kurzkommentare zum KG-Inhalt",
))
btn_kom.pack(side="left")
app._btn_kommentare = btn_kom
body = tk.Frame(wrap, bg=p["BG"])
body.pack(fill="both", expand=True, pady=(6, 0))
self._shell_section_frames.append(body)
txt = ScrolledText(
body, wrap="word", font=FONT_DEFAULT,
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"], relief="flat", bd=0,
height=15, highlightthickness=1, highlightbackground=p["BORDER"],
padx=8, pady=6, insertbackground=p["TEXT_AREA_FG"],
)
txt.pack(fill="both", expand=True)
app.txt_output = txt
app._kg_frame = body
app._kg_collapsed = False
def _toggle(_e=None):
self._kg_open = not self._kg_open
if self._kg_open:
body.pack(fill="both", expand=True, pady=(6, 0))
kg_arrow.configure(text="")
app._kg_collapsed = False
else:
body.pack_forget()
kg_arrow.configure(text="")
app._kg_collapsed = True
for w in (toggle_box, kg_arrow, title):
w.bind("<Button-1>", _toggle)
def _build_soap_section(self):
app = self.app
p = self._palette
parent = self._content
wrap = tk.Frame(parent, bg=p["BG"])
wrap.pack(side="top", fill="x", padx=18, pady=(8, 4))
self._shell_section_frames.append(wrap)
soap_lbl = tk.Label(wrap, text="SOAP", bg=p["BG"], fg=p["TEXT"],
font=FONT_SECTION)
soap_lbl.pack(anchor="w")
self._shell_labels.append(soap_lbl)
sec_row = tk.Frame(wrap, bg=p["BG"])
sec_row.pack(fill="x", pady=(6, 0))
self._shell_section_frames.append(sec_row)
soap_inner = tk.Frame(sec_row, bg=p["BG"])
soap_inner.pack(side="left")
self._shell_section_frames.append(soap_inner)
app._soap_inner = soap_inner
app._soap_bg = p["BG"]
if not hasattr(app, "_soap_section_labels"):
app._soap_section_labels = {}
try:
app._rebuild_soap_section_controls()
except Exception as exc:
print(f"[OfficeV1.2] SOAP-Sektionen: {exc}")
act = tk.Frame(wrap, bg=p["BG"])
act.pack(fill="x", pady=(8, 0))
self._shell_section_frames.append(act)
btn_kuerz = self._register_pill(PillButton(
act, "Kürzer",
command=lambda: _safe_call(app, "_kg_kuerzer"),
kind="default", width=BTN_W_SOAP,
tooltip="Krankengeschichte kürzer fassen",
palette=p,
))
btn_kuerz.pack(side="left", padx=(0, 8))
app.btn_kg_kuerzer = btn_kuerz
btn_ausf = self._register_pill(PillButton(
act, "Ausführlicher",
command=lambda: _safe_call(app, "_kg_ausfuehrlicher"),
kind="default", width=BTN_W_SOAP,
tooltip="Krankengeschichte ausführlicher gestalten",
palette=p,
))
btn_ausf.pack(side="left", padx=(0, 8))
app.btn_kg_ausfuehrlicher = btn_ausf
btn_vor = self._register_pill(PillButton(
act, "Vorlage",
command=lambda: _safe_call(app, "_open_kg_vorlage"),
kind="default", width=BTN_W_SOAP,
tooltip="Vorlage für KG-Erstellung bearbeiten",
palette=p,
))
btn_vor.pack(side="left")
app.btn_kg_vorlage = btn_vor
def _build_documents_section(self):
app = self.app
p = self._palette
parent = self._content
wrap = tk.Frame(parent, bg=p["BG"])
wrap.pack(side="top", fill="x", padx=18, pady=(12, 4))
self._shell_section_frames.append(wrap)
doc_lbl = tk.Label(wrap, text="Dokumente", bg=p["BG"], fg=p["TEXT"],
font=FONT_SECTION)
doc_lbl.pack(anchor="w")
self._shell_labels.append(doc_lbl)
grid = tk.Frame(wrap, bg=p["BG"])
grid.pack(fill="x", pady=(6, 0))
self._shell_section_frames.append(grid)
items = [
("Brief", "open_brief_window"),
("Rezept", "open_rezept_window"),
("OP-Bericht", "open_op_bericht_window"),
("KOGU", "open_kogu_window"),
("Diskussion mit KI", "open_diskussion_window"),
("Arztzeugnis", "_open_arztzeugnis"),
("KI-Kontrolle", "open_ki_pruefen"),
("Korrektur", "open_pruefen_window"),
]
cols = 4
for i, (label, method) in enumerate(items):
r, c = divmod(i, cols)
b = self._register_pill(PillButton(
grid, label,
command=(lambda m=method: _safe_call(app, m)),
kind="default", width=BTN_W_DOC, palette=p,
tooltip=f"{label} öffnen",
))
b.grid(row=r, column=c,
padx=(0 if c == 0 else 8), pady=4, sticky="w")
def _build_footer(self):
app = self.app
p = self._palette
self._sep_bottom = tk.Frame(app, bg=p["BORDER"], height=1)
self._sep_bottom.pack(side="bottom", fill="x")
self._footer_bar = tk.Frame(app, bg=p["BG"])
self._footer_bar.pack(side="bottom", fill="x", padx=18, pady=12)
self._logo_img = _load_logo(LOGO_PX)
self._footer_logo_lbl = None
if self._logo_img is not None:
self._footer_logo_lbl = tk.Label(
self._footer_bar, image=self._logo_img, bg=p["BG"],
bd=0, highlightthickness=0,
)
self._footer_logo_lbl.image = self._logo_img
self._footer_logo_lbl.pack(side="left", padx=(0, 12))
tb = tk.Frame(self._footer_bar, bg=p["BG"])
tb.pack(side="left", anchor="w")
self._footer_tb = tb
self._footer_brand_title = tk.Label(
tb, text="AzA von Arzt zu Arzt", bg=p["BG"],
fg=p["TEXT_STRONG"], font=FONT_BRAND,
)
self._footer_brand_title.pack(anchor="w")
self._footer_brand_sub = tk.Label(
tb, text="Informatik zu fairen Preisen", bg=p["BG"],
fg=p["SUBTLE"], font=FONT_BRAND_SUB,
)
self._footer_brand_sub.pack(anchor="w")
def _periodic_license_refresh(self):
try:
self._update_license_label()
finally:
try:
self.app.after(15000, self._periodic_license_refresh)
except Exception:
pass
def _update_license_label(self):
if not self._license_lbl:
return
p = self._palette
try:
mode = getattr(self.app, "license_mode", "demo")
self._license_lbl.configure(
text=("Lizenz: aktiv" if mode == "active" else "Demo / Testversion"),
fg=p["ACCENT"] if mode == "active" else p["WARN"],
bg=p["SURFACE"],
)
except Exception:
pass
def _enforce_default_fonts(self):
for attr in ("txt_output", "txt_transcript"):
w = getattr(self.app, attr, None)
if w is None:
continue
try:
w.configure(font=FONT_DEFAULT)
except Exception:
pass
def apply_office_shell_v1(app) -> None:
"""Wendet die AzA Office Hülle V1.2 auf eine bestehende ``KGDesktopApp`` an.
Der Funktionsname bleibt aus Kompatibilität zu ``basis14.py`` erhalten.
"""
if getattr(app, "_aza_office_v1_installed", False):
return
try:
shell = _OfficeShellV12(app)
shell.install()
app._aza_office_v1 = shell
app._aza_office_v1_installed = True
except Exception as exc:
print(f"[OfficeV1.2] Installation fehlgeschlagen: {exc}")
import traceback
traceback.print_exc()
__all__ = ["apply_office_shell_v1"]