645 lines
23 KiB
Python
645 lines
23 KiB
Python
# -*- 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 und in Krankengeschichte umwandeln",
|
||
"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":
|
||
# Nur Fallback wenn logo.png fehlt (Kachel nutzt sonst echtes Logo als PhotoImage)
|
||
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
|
||
self._kg_tile_icon = None # gleiches logo.png wie Header, 38×38 für AzA-Office-Kachel
|
||
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
|
||
_pil = Image.open(logo_path)
|
||
if _pil.mode not in ("RGB", "RGBA"):
|
||
_pil = _pil.convert("RGBA")
|
||
self._logo_img = ImageTk.PhotoImage(
|
||
_pil.resize((82, 82), Image.Resampling.LANCZOS),
|
||
master=self,
|
||
)
|
||
self._kg_tile_icon = ImageTk.PhotoImage(
|
||
_pil.resize((_ICON_SZ, _ICON_SZ), Image.Resampling.LANCZOS),
|
||
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, 19, "bold"), fg="#1a4d6d", bg=BG,
|
||
cursor="hand2")
|
||
aza_lbl.pack(anchor="w")
|
||
aza_lbl.bind("<Double-Button-1>", self._open_admin)
|
||
tk.Label(title_block, text="von Arzt zu Arzt",
|
||
font=(FONT_FAMILY, 11), fg="#1a4d6d", 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))
|
||
|
||
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
|
||
bg=icon_color, highlightthickness=0, cursor=_cursor)
|
||
icon_cv.pack(side="left")
|
||
if mod_key == "kg" and getattr(self, "_kg_tile_icon", None) is not None:
|
||
icon_cv.create_image(
|
||
_ICON_SZ // 2, _ICON_SZ // 2,
|
||
image=self._kg_tile_icon,
|
||
)
|
||
else:
|
||
_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, ""
|