Files
aza/AzA march 2026/aza_launcher.py

633 lines
23 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 Launcher / Startseite.
Premium-Medizinprodukt-Design mit 6 Modulkacheln, KI-Kapazitätsanzeige
und verstecktem Admin-Zugang (Doppelklick auf Logo).
"""
import os
import sys
import tkinter as tk
from aza_config import (
LAUNCHER_MODULES,
LAUNCHER_MODULE_LABELS,
LAUNCHER_DISABLED_MODULES,
)
from aza_persistence import (
load_launcher_prefs,
save_launcher_prefs,
get_remaining_tokens,
get_capacity_fraction,
estimated_reports_remaining,
is_capacity_low,
)
from aza_ui_helpers import save_toplevel_geometry, load_toplevel_geometry
from aza_style import (
BG, CARD_BG, CARD_HOVER_BG, CARD_BORDER, CARD_HOVER_BORDER,
ACCENT, TEXT, SUBTLE,
CAPACITY_BLUE, TURQUOISE, WARNING_AMBER, DANGER,
FONT_FAMILY, SPACING,
format_number_de,
)
_MODULE_DESCRIPTIONS = {
"ki": "Medizinische Fragen stellen,\nBefunde besprechen, Zweitmeinung einholen",
"kg": "Diktat aufnehmen, transkribieren\nund Krankengeschichte erstellen",
"empfang": "Empfangs-Chat, Aufgaben\nund Praxis-Kommunikation",
"notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag",
"translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen",
"medwork_chat": "Kollegialer Austausch mit\nAerzten und Fachpersonal",
"praxis_chat": "Nachrichten und Aufgaben\nim eigenen Praxisteam",
}
_ICON_BLUE = "#5B8DB3"
_MODULE_ICON_COLORS = {
"ki": _ICON_BLUE,
"kg": _ICON_BLUE,
"empfang": _ICON_BLUE,
"notizen": _ICON_BLUE,
"translator": _ICON_BLUE,
"medwork_chat": _ICON_BLUE,
"praxis_chat": _ICON_BLUE,
}
_ICON_SZ = 38
def _draw_module_icon(c: tk.Canvas, key: str):
"""Draw a minimal white line-art icon (38×38) for the given module."""
s = _ICON_SZ
m = s // 2
fg = "#FFFFFF"
if key == "ki":
c.create_polygon(m, 7, m + 5, m, m, s - 7, m - 5, m,
fill=fg, outline="")
c.create_polygon(7, m, m, m - 5, s - 7, m, m, m + 5,
fill=fg, outline="")
elif key == "kg":
c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg)
elif key == "notizen":
c.create_oval(m - 5, 7, m + 5, 19, outline=fg, width=2)
c.create_arc(m - 9, 13, m + 9, 27, start=180, extent=180,
outline=fg, width=2, style="arc")
c.create_line(m, 27, m, s - 9, fill=fg, width=2)
c.create_line(m - 5, s - 9, m + 5, s - 9, fill=fg, width=2)
elif key == "translator":
c.create_rectangle(7, 8, m + 1, s - 10, outline=fg, width=1.5)
c.create_rectangle(m - 1, 10, s - 7, s - 8, outline=fg, width=1.5)
c.create_text(14, m, text="A",
font=("Segoe UI", 9, "bold"), fill=fg)
c.create_text(s - 14, m + 1, text="\u6587",
font=("Segoe UI", 8), fill=fg)
elif key == "medwork_chat":
nodes = [(m, 9), (9, s - 11), (s - 9, s - 11)]
for i in range(3):
for j in range(i + 1, 3):
c.create_line(*nodes[i], *nodes[j], fill=fg, width=1.5)
for x, y in nodes:
c.create_oval(x - 4, y - 4, x + 4, y + 4, fill=fg, outline="")
elif key == "empfang":
c.create_rectangle(8, 10, s - 8, s - 8, outline=fg, width=2)
c.create_line(8, 10, m, m + 2, fill=fg, width=2)
c.create_line(s - 8, 10, m, m + 2, fill=fg, width=2)
elif key == "praxis_chat":
c.create_oval(8, 7, s - 8, s - 13, outline=fg, width=2)
bg = c.cget("bg")
c.create_polygon(12, s - 14, 10, s - 7, 18, s - 14,
fill=bg, outline=bg)
c.create_line(12, s - 14, 10, s - 7, fill=fg, width=2)
c.create_line(10, s - 7, 17, s - 14, fill=fg, width=2)
_GRID_COLS = 2
_BAR_H = 6
_WIN_W = 620
_WIN_MIN_W = 520
_WIN_MIN_H = 500
class _Tooltip:
"""Dezentes Hover-Tooltip im Glas-Stil."""
def __init__(self, widget, text: str):
self._widget = widget
self._text = text
self._tip = None
widget.bind("<Enter>", self._show, add="+")
widget.bind("<Leave>", self._hide, add="+")
def _show(self, event):
if self._tip:
return
x = self._widget.winfo_rootx() + 20
y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
self._tip = tw = tk.Toplevel(self._widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tw.configure(bg="#2D3436")
tk.Label(
tw, text=self._text,
font=(FONT_FAMILY, 9), fg="#F0F0F0", bg="#2D3436",
padx=12, pady=8, justify="left",
).pack()
def _hide(self, event):
if self._tip:
self._tip.destroy()
self._tip = None
def update_text(self, text: str):
self._text = text
class AzaLauncher(tk.Tk):
"""Premium-Startseite mit Modulauswahl, KI-Kapazität und verstecktem Admin."""
def __init__(self):
super().__init__()
self.title("AzA-Cockpit")
self.configure(bg=BG)
self.resizable(True, True)
self.minsize(_WIN_MIN_W, _WIN_MIN_H)
# AppUserModelID bereits im __main__ gesetzt (vor Fenster-Erstellung)
for _d in [os.path.dirname(os.path.abspath(__file__)),
getattr(sys, "_MEIPASS", ""),
os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else ""]:
if _d:
_ip = os.path.join(_d, "logo.ico")
if os.path.isfile(_ip):
try:
self.iconbitmap(_ip)
except Exception:
pass
break
self.attributes("-topmost", True)
self._logo_img = None
try:
import sys as _sys
_search = []
if getattr(_sys, "frozen", False):
_exe = os.path.dirname(os.path.abspath(_sys.executable))
_search.append(_exe)
_search.append(os.path.join(_exe, "_internal"))
_search.append(os.path.dirname(os.path.abspath(__file__)))
logo_path = None
for _d in _search:
_p = os.path.join(_d, "logo.png")
if os.path.isfile(_p):
logo_path = _p
break
if logo_path:
from PIL import Image, ImageTk
img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS)
self._logo_img = ImageTk.PhotoImage(img, master=self)
except Exception:
pass
self._selected_module = None
prefs = load_launcher_prefs()
self._auto_open_var = tk.BooleanVar(value=prefs.get("auto_open", False))
self._build_ui()
self._apply_geometry()
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _apply_geometry(self):
saved_geom = load_toplevel_geometry("launcher")
if saved_geom:
try:
self.geometry(saved_geom)
return
except Exception:
pass
self.update_idletasks()
req_w = max(self.winfo_reqwidth(), _WIN_W)
req_h = self.winfo_reqheight() + 20
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
win_w = min(req_w, sw - 40)
win_h = min(req_h, sh - 80)
self.minsize(_WIN_MIN_W, min(win_h, _WIN_MIN_H))
x = (sw - win_w) // 2
y = max(20, (sh - win_h) // 2)
self.geometry(f"{win_w}x{win_h}+{x}+{y}")
# ── UI Aufbau ──────────────────────────────────────────────────────────────
def _build_ui(self):
outer = tk.Frame(self, bg=BG)
outer.pack(fill="both", expand=True, padx=36, pady=(28, 20))
# ── Header ──
header = tk.Frame(outer, bg=BG)
header.pack(fill="x")
title_row = tk.Frame(header, bg=BG)
title_row.pack(fill="x")
if self._logo_img:
try:
logo_lbl = tk.Label(title_row, image=self._logo_img, bg=BG, cursor="hand2")
logo_lbl.image = self._logo_img
logo_lbl.pack(side="left", padx=(0, 14))
logo_lbl.bind("<Double-Button-1>", self._open_admin)
except tk.TclError:
self._logo_img = None
title_block = tk.Frame(title_row, bg=BG)
title_block.pack(side="left", anchor="w")
aza_lbl = tk.Label(title_block, text="AzA",
font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG,
cursor="hand2")
aza_lbl.pack(anchor="w")
aza_lbl.bind("<Double-Button-1>", self._open_admin)
tk.Label(title_block, text="Medizinischer KI-Arbeitsplatz",
font=(FONT_FAMILY, 10), fg=SUBTLE, bg=BG
).pack(anchor="w")
self._build_capacity_bar(header)
# ── Separator ──
tk.Frame(outer, bg="#E2E8F0", height=1).pack(fill="x", pady=(14, 16))
# ── Card Grid ──
grid = tk.Frame(outer, bg=BG)
grid.pack(fill="both", expand=True)
for c in range(_GRID_COLS):
grid.columnconfigure(c, weight=1, uniform="col")
num_rows = (len(LAUNCHER_MODULES) + _GRID_COLS - 1) // _GRID_COLS
for r in range(num_rows):
grid.rowconfigure(r, weight=1)
_CARD_GRAYS = ["#f2f4f6", "#f5f7f9", "#f7f8fa", "#f9fafb", "#fafbfc", "#fbfcfd"]
for i, mod_key in enumerate(LAUNCHER_MODULES):
row, col = divmod(i, _GRID_COLS)
card_bg = _CARD_GRAYS[min(i, len(_CARD_GRAYS) - 1)]
card = self._create_card(grid, mod_key, card_bg=card_bg)
card.grid(row=row, column=col,
padx=SPACING // 2, pady=SPACING // 2,
sticky="nsew")
# Auto-Open-Bereich vorlaeufig deaktiviert (Endlosschlaufen-Problem)
# ── Footer ──
_FOOTER_BG = "#EDF2F7"
footer_wrap = tk.Frame(outer, bg=_FOOTER_BG, highlightthickness=0)
footer_wrap.pack(fill="x", pady=(16, 0), ipady=8)
footer = tk.Frame(footer_wrap, bg=_FOOTER_BG)
footer.pack(fill="x", padx=12)
lbl_status = tk.Label(
footer, text="Systemstatus",
font=(FONT_FAMILY, 9, "bold"), fg=ACCENT, bg=_FOOTER_BG,
cursor="hand2",
)
lbl_status.pack(side="left")
lbl_status.bind("<Button-1>", self._open_systemstatus)
self._build_key_status(footer, _FOOTER_BG)
try:
from aza_version import APP_VERSION
tk.Label(
footer, text=f"v{APP_VERSION}",
font=(FONT_FAMILY, 8), fg="#A0AEC0", bg=_FOOTER_BG,
).pack(side="right")
except Exception:
pass
# ── Auto-Open Bereich ──────────────────────────────────────────────────────
def _build_auto_open_section(self, parent):
prefs = load_launcher_prefs()
default_mod = prefs.get("default_module", "")
section = tk.Frame(parent, bg=BG)
section.pack(fill="x", pady=(14, 0))
tk.Frame(section, bg="#E2E8F0", height=1).pack(fill="x", pady=(0, 10))
row1 = tk.Frame(section, bg=BG)
row1.pack(fill="x")
tk.Checkbutton(
row1,
text="Nächste Auswahl als Standardstart merken",
variable=self._auto_open_var,
font=(FONT_FAMILY, 9), fg=TEXT, bg=BG,
activebackground=BG, selectcolor=CARD_BG,
command=self._on_auto_open_toggle,
).pack(side="left")
row2 = tk.Frame(section, bg=BG)
row2.pack(fill="x", pady=(4, 0))
self._auto_open_status = tk.Label(row2, font=(FONT_FAMILY, 9), bg=BG)
self._auto_open_status.pack(side="left")
self._auto_open_reset = tk.Label(
row2, text="Zurücksetzen",
font=(FONT_FAMILY, 8, "underline"), fg=ACCENT, bg=BG,
cursor="hand2",
)
self._auto_open_reset.bind("<Button-1>", self._reset_auto_open)
self._auto_open_help = tk.Label(
section, font=(FONT_FAMILY, 8), fg=SUBTLE, bg=BG,
)
self._auto_open_help.pack(anchor="w", pady=(2, 0))
self._update_auto_open_display(default_mod)
def _update_auto_open_display(self, mod_key: str):
if mod_key and mod_key in LAUNCHER_MODULES:
label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key)
self._auto_open_status.configure(
text=f"Standardstart: {label}",
fg=ACCENT,
)
self._auto_open_reset.pack(side="left", padx=(10, 0))
self._auto_open_help.configure(
text="Dieses Modul öffnet sich beim nächsten Programmstart automatisch.",
)
else:
self._auto_open_status.configure(
text="Kein Standardstart festgelegt",
fg=SUBTLE,
)
self._auto_open_reset.pack_forget()
self._auto_open_help.configure(
text="Aktivieren Sie die Option und wählen Sie ein Modul, um einen Standardstart festzulegen.",
)
def _on_auto_open_toggle(self):
if not self._auto_open_var.get():
save_launcher_prefs("", False)
self._update_auto_open_display("")
def _reset_auto_open(self, event=None):
self._auto_open_var.set(False)
save_launcher_prefs("", False)
self._update_auto_open_display("")
# ── Admin-Zugang ──────────────────────────────────────────────────────────
def _open_systemstatus(self, event=None):
try:
from aza_systemstatus import show_systemstatus
show_systemstatus(self)
except Exception:
pass
def _open_admin(self, event=None):
try:
from aza_admin import show_admin_login, show_admin_panel
if show_admin_login(self):
show_admin_panel(self)
self._refresh_capacity()
except Exception:
pass
def _refresh_capacity(self):
"""Aktualisiert Kapazitätsanzeige nach Admin-Aktion."""
try:
remaining = get_remaining_tokens()
pct = get_capacity_fraction()
low = is_capacity_low()
est = estimated_reports_remaining()
self._cap_label.configure(
text=f"Ihre KI-Kapazität: {format_number_de(remaining)} Einheiten verbleibend",
fg=WARNING_AMBER if low else SUBTLE,
)
self._cap_canvas.delete("all")
self._cap_canvas.update_idletasks()
self._draw_gradient_bar(self._cap_canvas, pct, _BAR_H)
except Exception:
pass
# ── KI-Kapazitätsanzeige ──────────────────────────────────────────────────
def _build_capacity_bar(self, parent):
frame = tk.Frame(parent, bg=BG)
frame.pack(fill="x", pady=(12, 0))
remaining = get_remaining_tokens()
pct = get_capacity_fraction()
low = is_capacity_low()
est = estimated_reports_remaining()
label_text = f"KI-Kapazität: {format_number_de(remaining)} Einheiten"
color = WARNING_AMBER if low else "#A0AEC0"
self._cap_label = tk.Label(
frame, text=label_text,
font=(FONT_FAMILY, 8), fg=color, bg=BG, anchor="e",
)
self._cap_label.pack(anchor="e", pady=(0, 3))
self._cap_canvas = canvas = tk.Canvas(
frame, height=_BAR_H, bg=BG, highlightthickness=0,
)
canvas.pack(fill="x")
canvas.bind("<Configure>", lambda e: self._draw_gradient_bar(canvas, pct, _BAR_H))
tooltip_text = (
f"Entspricht ca. {est} weiteren Berichten\n"
"(Basierend auf Ihrem Durchschnittsverbrauch)"
)
if low:
tooltip_text += (
"\n\n\u26A0 Kapazität fast aufgebraucht.\n"
"Guthaben unter aza-medwork.ch nachfüllen."
)
_Tooltip(canvas, tooltip_text)
_Tooltip(self._cap_label, tooltip_text)
@staticmethod
def _draw_gradient_bar(canvas, pct: float, bar_h: int):
canvas.delete("all")
w = canvas.winfo_width()
if w <= 1:
return
filled_w = max(0, min(w, int(w * pct)))
canvas.create_rectangle(0, 0, w, bar_h, fill="#EDF2F7", outline="")
if filled_w <= 0:
return
if pct <= 0.02:
canvas.create_rectangle(0, 0, filled_w, bar_h, fill=DANGER, outline="")
elif pct <= 0.10:
canvas.create_rectangle(0, 0, filled_w, bar_h, fill=WARNING_AMBER, outline="")
else:
r1, g1, b1 = 0x00, 0x78, 0xD7
r2, g2, b2 = 0x00, 0xCE, 0xC9
steps = min(filled_w, 120)
step_w = filled_w / steps
for i in range(steps):
t = i / max(1, steps - 1)
r = int(r1 + (r2 - r1) * t)
g = int(g1 + (g2 - g1) * t)
b = int(b1 + (b2 - b1) * t)
x1 = int(i * step_w)
x2 = int((i + 1) * step_w)
canvas.create_rectangle(
x1, 0, x2, bar_h,
fill=f"#{r:02x}{g:02x}{b:02x}", outline="",
)
# ── Key-Status ────────────────────────────────────────────────────────────
def _build_key_status(self, parent, bg_color=None):
bg = bg_color or BG
try:
from security_vault import has_vault_key, get_masked_key
if has_vault_key():
masked = get_masked_key()
tk.Label(
parent,
text=f"Schlüssel aktiv ({masked})",
font=(FONT_FAMILY, 8), fg="#00B894", bg=bg,
).pack(side="left", padx=(12, 0))
except Exception:
pass
# ── Card Rendering ────────────────────────────────────────────────────────
def _create_card(self, parent, mod_key: str, card_bg: str = None) -> tk.Frame:
label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key)
desc = _MODULE_DESCRIPTIONS.get(mod_key, "")
is_disabled = mod_key in LAUNCHER_DISABLED_MODULES
icon_color = "#B0BEC5" if is_disabled else _MODULE_ICON_COLORS.get(mod_key, ACCENT)
_cbg = "#ECEFF1" if is_disabled else (card_bg or CARD_BG)
_cursor = "arrow" if is_disabled else "hand2"
_text_fg = "#90A4AE" if is_disabled else TEXT
_desc_fg = "#B0BEC5" if is_disabled else SUBTLE
card = tk.Frame(parent, bg=_cbg, cursor=_cursor,
highlightthickness=1, highlightbackground=CARD_BORDER)
inner = tk.Frame(card, bg=_cbg, cursor=_cursor)
inner.pack(fill="both", expand=True, padx=20, pady=18)
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
top_row.pack(fill="x", pady=(0, 10))
if mod_key == "kg" and self._logo_img:
icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor)
icon_lbl.image = self._logo_img
icon_lbl.pack(side="left")
else:
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
bg=icon_color, highlightthickness=0, cursor=_cursor)
icon_cv.pack(side="left")
_draw_module_icon(icon_cv, mod_key)
lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),
fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor)
lbl_title.pack(anchor="w")
if desc:
lbl_desc = tk.Label(inner, text=desc, font=(FONT_FAMILY, 9),
fg=_desc_fg, bg=_cbg, anchor="w",
justify="left", cursor=_cursor)
lbl_desc.pack(anchor="w", pady=(4, 0))
if is_disabled:
tk.Label(inner, text="Bald verf\u00fcgbar", font=(FONT_FAMILY, 8, "italic"),
fg="#B0BEC5", bg=_cbg).pack(anchor="w", pady=(4, 0))
return card
def on_enter(e):
card.configure(highlightbackground=CARD_HOVER_BORDER, highlightthickness=2)
for w in _deep_children(card):
try:
if isinstance(w, tk.Canvas):
continue
w.configure(bg=CARD_HOVER_BG)
except tk.TclError:
pass
def on_leave(e):
card.configure(highlightbackground=CARD_BORDER, highlightthickness=1)
for w in _deep_children(card):
try:
if isinstance(w, tk.Canvas):
continue
w.configure(bg=CARD_BG)
except tk.TclError:
pass
def on_click(e, key=mod_key):
self._select(key)
for w in _deep_children(card):
w.bind("<Enter>", on_enter)
w.bind("<Leave>", on_leave)
w.bind("<Button-1>", on_click)
return card
# ── Modul-Auswahl / Schliessen ────────────────────────────────────────────
def _save_geom(self):
try:
save_toplevel_geometry("launcher", self.geometry())
except Exception:
pass
def _select(self, mod_key: str):
self._selected_module = mod_key
if self._auto_open_var.get():
save_launcher_prefs(mod_key, True)
else:
save_launcher_prefs("", False)
self._save_geom()
self.destroy()
def _on_close(self):
self._selected_module = None
self._save_geom()
self.destroy()
def run(self) -> str | None:
self.mainloop()
return self._selected_module
def _deep_children(widget):
"""Widget + alle verschachtelten Kinder."""
result = [widget]
for child in widget.winfo_children():
result.extend(_deep_children(child))
return result
def should_skip_launcher() -> tuple[bool, str]:
"""Standardstart vorlaeufig deaktiviert (Endlosschlaufen-Problem).
Alte gespeicherte Werte werden ignoriert."""
try:
prefs = load_launcher_prefs()
if prefs.get("auto_open"):
prefs["auto_open"] = False
prefs["default_module"] = ""
save_launcher_prefs("", False)
except Exception:
pass
return False, ""