4922 lines
218 KiB
Plaintext
4922 lines
218 KiB
Plaintext
# -*- coding: utf-8 -*-
|
||
"""
|
||
KG-Diktat Desktop (Aufnahme -> Transkription -> Krankengeschichte)
|
||
===============================================================
|
||
Modulare Architektur – Hauptdatei mit Imports aus:
|
||
aza_config, aza_prompts, aza_ui_helpers, aza_persistence, aza_audio,
|
||
aza_todo_mixin, aza_text_windows_mixin, aza_diktat_mixin,
|
||
aza_settings_mixin, aza_ordner_mixin, aza_arbeitsplan_mixin
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import json
|
||
import sys
|
||
import time
|
||
import hashlib
|
||
from difflib import SequenceMatcher
|
||
import threading
|
||
import tempfile
|
||
import wave
|
||
from datetime import datetime, timedelta
|
||
import tkinter as tk
|
||
import tkinter.font as tkfont
|
||
from tkinter import ttk, messagebox, simpledialog
|
||
from tkinter.scrolledtext import ScrolledText
|
||
|
||
from dotenv import load_dotenv
|
||
from openai import OpenAI
|
||
import requests as _requests
|
||
|
||
# Windows: für Einfügen in externes Fenster (Word, Notepad etc.)
|
||
if sys.platform == "win32":
|
||
try:
|
||
import ctypes
|
||
_user32 = ctypes.windll.user32
|
||
except Exception:
|
||
_user32 = None
|
||
else:
|
||
_user32 = None
|
||
|
||
# Globaler Autotext in Windows (Word, Editor, überall): pynput für Tastatur-Hook
|
||
try:
|
||
from pynput.keyboard import Controller as KbdController, Key, KeyCode, Listener as KbdListener
|
||
_HAS_PYNPUT = True
|
||
except ImportError:
|
||
_HAS_PYNPUT = False
|
||
|
||
# ─── Modul-Imports ───
|
||
from aza_config import *
|
||
from aza_config import (_ALL_WINDOWS, _SOAP_SECTIONS, _SOAP_LABELS,
|
||
NUM_SOAP_PRESETS, NUM_BRIEF_PRESETS)
|
||
from aza_prompts import *
|
||
from aza_persistence import *
|
||
from aza_persistence import _win_clipboard_set, _win_clipboard_get
|
||
from aza_ui_helpers import *
|
||
from aza_audio import AudioRecorder
|
||
from aza_todo_mixin import TodoMixin
|
||
from aza_text_windows_mixin import TextWindowsMixin
|
||
from aza_diktat_mixin import AzaDiktatMixin
|
||
from aza_settings_mixin import AzaSettingsMixin
|
||
from aza_ordner_mixin import AzaOrdnerMixin
|
||
from aza_arbeitsplan_mixin import AzaArbeitsplanMixin
|
||
|
||
|
||
|
||
class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettingsMixin, AzaOrdnerMixin, AzaArbeitsplanMixin):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("KI Assistent PRAXIS LINDENGUT AG")
|
||
|
||
# Logo als Icon setzen (Titelleiste ~71x71 Pixel)
|
||
try:
|
||
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png")
|
||
if os.path.exists(logo_path):
|
||
from PIL import Image
|
||
|
||
# Bild laden (weißer Hintergrund bleibt!)
|
||
img = Image.open(logo_path)
|
||
|
||
# Für Titelleiste: ~71 Pixel
|
||
icon_img = img.resize((57, 57), Image.Resampling.LANCZOS)
|
||
|
||
# Speichere temporär als .ico (Windows braucht .ico für iconphoto)
|
||
import tempfile
|
||
temp_ico = tempfile.NamedTemporaryFile(suffix='.ico', delete=False)
|
||
icon_img.save(temp_ico.name, format='ICO')
|
||
temp_ico.close()
|
||
|
||
# Setze als Fenster-Icon
|
||
self.iconbitmap(temp_ico.name)
|
||
|
||
# Aufräumen
|
||
try:
|
||
os.unlink(temp_ico.name)
|
||
except:
|
||
pass
|
||
except Exception as e:
|
||
print(f"Logo konnte nicht geladen werden: {e}")
|
||
|
||
MAIN_MIN_W, MAIN_MIN_H = 750, 650 # Mindestgröße: alle Buttons sichtbar (angepasst für unterschiedliche DPI)
|
||
self.minsize(MAIN_MIN_W, MAIN_MIN_H)
|
||
|
||
# Gespeicherte Größe/Position, Sash und Transkript-Höhe laden oder Standard
|
||
saved = load_window_geometry()
|
||
self._saved_sash = None
|
||
self._saved_sash_transcript = None
|
||
if saved:
|
||
w = max(MAIN_MIN_W, saved[0])
|
||
h = max(MAIN_MIN_H, saved[1])
|
||
x, y = saved[2], saved[3]
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
if len(saved) >= 5 and saved[4] is not None:
|
||
self._saved_sash = saved[4]
|
||
if len(saved) >= 6 and saved[5] is not None:
|
||
self._saved_sash_transcript = saved[5]
|
||
else:
|
||
self.geometry(f"{DEFAULT_WINDOW_WIDTH}x{DEFAULT_WINDOW_HEIGHT}")
|
||
center_window(self, DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
||
|
||
# Fenster immer im Vordergrund
|
||
self.attributes("-topmost", True)
|
||
|
||
self._last_external_hwnd = None
|
||
self.bind("<FocusOut>", self._on_focus_out_for_external_paste)
|
||
|
||
self._geometry_save_after_id = None
|
||
self.bind("<Configure>", self._on_configure)
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
# Benutzerprofil laden und Login
|
||
self._user_profile = load_user_profile()
|
||
if not self._user_profile.get("name") or self._user_profile.get("password_hash"):
|
||
self.withdraw()
|
||
self._show_login_dialog()
|
||
self.deiconify()
|
||
|
||
load_dotenv()
|
||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||
self.client = OpenAI(api_key=api_key) if api_key else None
|
||
self.api_key_present = bool(api_key)
|
||
|
||
self.recorder = AudioRecorder()
|
||
self.is_recording = False
|
||
self._recording_mode = "new"
|
||
self.last_wav_path = None
|
||
self._last_brief_text = ""
|
||
self._last_rezept_text = ""
|
||
self._last_kogu_text = ""
|
||
|
||
self._timer_sec = 0
|
||
self._timer_running = False
|
||
self._phase = ""
|
||
|
||
self._autotext_data = load_autotext()
|
||
self._autotext_global_buffer = []
|
||
self._autotext_injecting = [False]
|
||
self._autotext_focus_in_app = [False]
|
||
if _HAS_PYNPUT and sys.platform == "win32":
|
||
threading.Thread(target=self._run_global_autotext_listener, daemon=True).start()
|
||
|
||
self.configure(bg="#B9ECFA")
|
||
style = ttk.Style(self)
|
||
try:
|
||
style.theme_use("clam")
|
||
except tk.TclError:
|
||
pass
|
||
style.configure("TFrame", background="#B9ECFA")
|
||
style.configure("TPanedwindow", background="#B9ECFA")
|
||
try:
|
||
style.configure("TPanedwindow.Sash", background="#B9ECFA", width=8)
|
||
except tk.TclError:
|
||
pass
|
||
style.configure("TLabel", background="#B9ECFA", foreground="#1a4d6d")
|
||
style.configure("TopBar.TFrame", background="#B9ECFA")
|
||
style.configure("StatusBar.TFrame", background="#B9ECFA")
|
||
style.configure("TranscriptBar.TFrame", background="#B9ECFA")
|
||
style.configure(
|
||
"TButton",
|
||
background="#7EC8E3",
|
||
foreground="#1a4d6d",
|
||
padding=(10, 6),
|
||
borderwidth=0,
|
||
)
|
||
style.map("TButton", background=[("active", "#5AB9E8"), ("pressed", "#4AA5D4")])
|
||
style.configure(
|
||
"Primary.TButton",
|
||
background="#2196F3",
|
||
foreground="white",
|
||
padding=(12, 16),
|
||
)
|
||
style.map("Primary.TButton", background=[("active", "#1E88D4"), ("pressed", "#1976D2")])
|
||
|
||
self._build_ui()
|
||
|
||
try:
|
||
self.attributes("-alpha", load_opacity())
|
||
except Exception:
|
||
pass
|
||
self.after(100, self._restore_sash)
|
||
self.after(1500, self._check_old_kg_entries)
|
||
|
||
# Automatische Aktualisierung der Token-Nutzung beim Start (nach 3 Sekunden)
|
||
self.after(3000, lambda: threading.Thread(target=self._fetch_and_update_usage, daemon=True).start())
|
||
|
||
self._window_registry.add(self)
|
||
|
||
if not self.api_key_present:
|
||
messagebox.showwarning(
|
||
"OPENAI_API_KEY fehlt",
|
||
"Ich finde keinen OPENAI_API_KEY.\n\n"
|
||
"Lege im selben Ordner wie dieses Script eine Datei '.env' an mit:\n"
|
||
"OPENAI_API_KEY=sk-...\n\n"
|
||
"Oder setze die Umgebungsvariable OPENAI_API_KEY.\n"
|
||
)
|
||
|
||
def _build_ui(self):
|
||
try:
|
||
def_font = tkfont.nametofont("TkDefaultFont")
|
||
font_size = max(10, def_font.actual()["size"]) # Mindestens Größe 10 für bessere Lesbarkeit
|
||
self._text_font = (def_font.actual()["family"], font_size)
|
||
except Exception:
|
||
self._text_font = ("Segoe UI", 10)
|
||
|
||
# Liste aller skalierbaren Widgets (Buttons, Labels, Text-Widgets)
|
||
self._scalable_widgets = []
|
||
self._scalable_text_widgets = []
|
||
|
||
top = ttk.Frame(self, padding=10, style="TopBar.TFrame")
|
||
top.pack(fill="x")
|
||
|
||
self.model_var = tk.StringVar(value=load_saved_model())
|
||
|
||
self._btn_row_left = ttk.Frame(top)
|
||
self._btn_row_left.pack(side="left")
|
||
|
||
self.btn_record = RoundedButton(
|
||
self._btn_row_left, "⏺ Start", command=self.toggle_record,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=100, height=40,
|
||
canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_record.lock_color = True
|
||
self.btn_record.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_record)
|
||
|
||
self.btn_record_append = RoundedButton(
|
||
self._btn_row_left, "⏺ Korrigieren", command=self._toggle_record_append,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=90, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_record_append.lock_color = True
|
||
self.btn_record_append.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_record_append)
|
||
|
||
self._btn_diktat_top = RoundedButton(
|
||
self._btn_row_left, "Diktat", command=self.open_diktat_window,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
|
||
width=60, height=28, canvas_bg="#95D6ED",
|
||
)
|
||
self._btn_diktat_top.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_diktat_top)
|
||
|
||
self.btn_new = RoundedButton(
|
||
self._btn_row_left, "Neu", command=self._new_session,
|
||
width=50, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_new.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_new)
|
||
|
||
self._btn_minimize = RoundedButton(
|
||
self._btn_row_left, "−", command=self._toggle_minimize,
|
||
width=28, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self._btn_minimize.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_minimize)
|
||
|
||
self._btn_tile_windows = RoundedButton(
|
||
self._btn_row_left, "⊞", command=self.arrange_windows_top,
|
||
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_tile_windows.pack(side="left", padx=(3, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_tile_windows)
|
||
|
||
self._btn_reset_pos = RoundedButton(
|
||
self._btn_row_left, "↺", command=self._reset_window_positions,
|
||
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_reset_pos.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_reset_pos)
|
||
|
||
self._btn_profile = RoundedButton(
|
||
self._btn_row_left, "👤", command=self._show_profile_editor,
|
||
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_profile.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_profile)
|
||
|
||
self._top_right = ttk.Frame(top, style="TopBar.TFrame")
|
||
self._top_right.pack(side="right", anchor="n")
|
||
|
||
# Token-Anzeige (zeigt echten OpenAI-Verbrauch)
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50) # Standard: $50
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label = ttk.Label(
|
||
self._top_right,
|
||
text=f"📊 {remaining_percent}%",
|
||
font=("Segoe UI", 9),
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d"),
|
||
cursor="hand2"
|
||
)
|
||
self._token_label.pack(side="left", padx=(0, 8))
|
||
remaining = max(0, budget_dollars - used_dollars)
|
||
tooltip_text = f"Guthaben: {remaining_percent}% (${remaining:.2f} von ${budget_dollars:.2f})\n\n"
|
||
tooltip_text += f"Verbraucht: ${used_dollars:.2f}\n\n"
|
||
tooltip_text += "100% = Volles Budget verfügbar\n0% = Budget aufgebraucht\n\n"
|
||
tooltip_text += "Linksklick: Echte Daten von OpenAI laden\nRechtsklick: Budget einstellen"
|
||
add_tooltip(self._token_label, tooltip_text)
|
||
|
||
# Token-Label anklickbar machen
|
||
def refresh_usage(e=None):
|
||
self.set_status("Lade Verbrauch von OpenAI...")
|
||
threading.Thread(target=self._fetch_and_update_usage, daemon=True).start()
|
||
|
||
def open_budget(e=None):
|
||
self._open_budget_settings()
|
||
|
||
self._token_label.bind("<Button-1>", refresh_usage)
|
||
self._token_label.bind("<Button-3>", open_budget)
|
||
|
||
# KEINE REGLER MEHR - Fixierte optimale Größen (Schrift: 60%, Buttons: 140%)
|
||
# Wende fixierte Werte direkt an
|
||
self._apply_font_scale_global(FIXED_FONT_SCALE)
|
||
self._apply_button_scale_global(FIXED_BUTTON_SCALE)
|
||
|
||
# Transparenz-Regler
|
||
self._opacity_var_main = tk.DoubleVar(value=round(load_opacity() * 100))
|
||
|
||
def on_opacity_main(val):
|
||
try:
|
||
alpha = float(val) / 100.0
|
||
alpha = max(MIN_OPACITY, min(1.0, alpha))
|
||
self.attributes("-alpha", alpha)
|
||
save_opacity(alpha)
|
||
except Exception:
|
||
pass
|
||
|
||
opacity_lbl_half = tk.Label(self._top_right, text="◐", font=("Segoe UI", 7),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
opacity_lbl_half.pack(side="left", padx=(0, 1))
|
||
opacity_lbl_half.bind("<Button-1>", lambda e: (self._opacity_var_main.set(round(MIN_OPACITY * 100)),
|
||
on_opacity_main(str(MIN_OPACITY * 100))))
|
||
opacity_lbl_half.bind("<Enter>", lambda e: opacity_lbl_half.configure(fg="#1a4d6d"))
|
||
opacity_lbl_half.bind("<Leave>", lambda e: opacity_lbl_half.configure(fg="#7AAFC8"))
|
||
add_tooltip(opacity_lbl_half, f"Maximale Transparenz ({int(MIN_OPACITY*100)}%)")
|
||
|
||
try:
|
||
s = ttk.Style(self)
|
||
s.configure("MainOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
|
||
except Exception:
|
||
pass
|
||
self._opacity_scale_main = ttk.Scale(
|
||
self._top_right,
|
||
from_=40, to=100,
|
||
variable=self._opacity_var_main,
|
||
orient="horizontal",
|
||
length=50,
|
||
command=on_opacity_main,
|
||
style="MainOpacity.Horizontal.TScale",
|
||
)
|
||
self._opacity_scale_main.pack(side="left")
|
||
add_tooltip(self._opacity_scale_main, "Transparenz anpassen:\nLinks = durchsichtig, Rechts = undurchsichtig")
|
||
|
||
opacity_lbl_sun = tk.Label(self._top_right, text="☀", font=("Segoe UI", 7),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
opacity_lbl_sun.pack(side="left", padx=(1, 8))
|
||
opacity_lbl_sun.bind("<Button-1>", lambda e: (self._opacity_var_main.set(100),
|
||
on_opacity_main("100")))
|
||
opacity_lbl_sun.bind("<Enter>", lambda e: opacity_lbl_sun.configure(fg="#1a4d6d"))
|
||
opacity_lbl_sun.bind("<Leave>", lambda e: opacity_lbl_sun.configure(fg="#7AAFC8"))
|
||
add_tooltip(opacity_lbl_sun, "Voll sichtbar (100%)")
|
||
|
||
self.btn_settings = RoundedButton(
|
||
self._top_right, "⚙", command=self._open_settings, width=36, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_settings.pack(side="right", anchor="n")
|
||
self._scalable_widgets.append(self.btn_settings)
|
||
add_tooltip(self.btn_settings, "Einstellungen öffnen")
|
||
|
||
self._status_row = ttk.Frame(self, padding=(16, 4), style="StatusBar.TFrame")
|
||
self._status_row.pack(fill="x")
|
||
self.status_var = tk.StringVar(value="Bereit.")
|
||
self.lbl_status = tk.Label(
|
||
self._status_row, textvariable=self.status_var, fg="#BD4500", bg="#B9ECFA", font=self._text_font
|
||
)
|
||
self.lbl_status.pack(side="left")
|
||
self._apply_status_color()
|
||
|
||
self.paned = ttk.PanedWindow(self, orient="horizontal")
|
||
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||
self.paned.bind("<Configure>", self._on_paned_configure)
|
||
self._minimized = False
|
||
self._aza_minimize = self._toggle_minimize
|
||
self._aza_is_minimized = lambda: self._minimized
|
||
self._aza_windows = set()
|
||
self._window_registry = set()
|
||
self._btn_brief_mini = None
|
||
|
||
left = ttk.Frame(self.paned, padding=10)
|
||
right = ttk.Frame(self.paned, padding=10)
|
||
self.paned.add(left, weight=1)
|
||
self.paned.add(right, weight=1)
|
||
|
||
left_top = ttk.Frame(left)
|
||
left_top.pack(fill="x", pady=(0, 4))
|
||
self.btn_copy_transcript = RoundedButton(
|
||
left_top, "Transkript kopieren", command=self.copy_transcript,
|
||
width=160, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_copy_transcript.pack(anchor="center")
|
||
|
||
# Vertikales PanedWindow für Transkript (verstellbare Höhe)
|
||
self.paned_transcript = ttk.PanedWindow(left, orient="vertical")
|
||
self.paned_transcript.pack(fill="both", expand=True)
|
||
self.paned_transcript.bind("<Configure>", self._on_paned_configure)
|
||
|
||
# Sash (Trennbalken) sichtbar machen
|
||
style = ttk.Style()
|
||
style.configure("Sash", sashthickness=8, background="#7EC8E3")
|
||
|
||
top_left = ttk.Frame(self.paned_transcript)
|
||
bottom_left = ttk.Frame(self.paned_transcript, style="TranscriptBar.TFrame")
|
||
self.paned_transcript.add(top_left, weight=1)
|
||
self.paned_transcript.add(bottom_left, weight=0)
|
||
|
||
# Frame für Label + Schriftgrößen-Spinbox
|
||
transcript_header = ttk.Frame(top_left)
|
||
transcript_header.pack(fill="x", anchor="w")
|
||
ttk.Label(transcript_header, text="Transkript:").pack(side="left")
|
||
|
||
trans_frame = ttk.Frame(top_left)
|
||
trans_frame.pack(fill="both", expand=True)
|
||
self.txt_transcript = ScrolledText(
|
||
trans_frame, wrap="word", font=self._text_font, bg="#e1f6fc"
|
||
)
|
||
self.txt_transcript.pack(fill="both", expand=True)
|
||
self._bind_textblock_pending(self.txt_transcript)
|
||
|
||
# Schriftgrößen-Spinbox für Transkript
|
||
add_text_font_size_control(transcript_header, self.txt_transcript, initial_size=10, bg_color="#B9ECFA", save_key="main_transcript")
|
||
|
||
# --- Buttons in bottom_left (unterhalb des Trennbalkens) ---
|
||
trans_btn_row = ttk.Frame(bottom_left, padding=(0, 4, 0, 0))
|
||
trans_btn_row.pack(fill="x")
|
||
RoundedButton(
|
||
trans_btn_row, "Ordner", command=self.open_ordner_window,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=180, height=38, canvas_bg="#B9ECFA",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Anchor für Add-on (immer vorhanden, bleibt links)
|
||
self._addon_anchor = ttk.Frame(bottom_left)
|
||
self._addon_anchor.pack(fill="x")
|
||
|
||
# Add-on-Container (Übersetzer etc.) – ein-/ausblendbar über Einstellungen
|
||
self._addon_container = ttk.Frame(bottom_left)
|
||
addon_inner = ttk.Frame(self._addon_container)
|
||
addon_inner.pack(fill="x")
|
||
|
||
# Add-on Header mit Toggle-Pfeil
|
||
addon_header_frame = ttk.Frame(addon_inner)
|
||
addon_header_frame.pack(fill="x", pady=(0, 4))
|
||
|
||
# Toggle-Pfeil und Label
|
||
self._addon_collapsed = False
|
||
self._addon_toggle_label = tk.Label(addon_header_frame, text="\u25BC Add-on (provisorisch):",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
|
||
cursor="hand2")
|
||
self._addon_toggle_label.pack(anchor="center")
|
||
self._addon_toggle_label.bind("<Button-1>", self._toggle_addon_collapse)
|
||
|
||
# Container für die Buttons (zum Ein-/Ausblenden)
|
||
self._addon_buttons_container = ttk.Frame(addon_inner)
|
||
self._addon_buttons_container.pack(fill="x")
|
||
|
||
# Speichere Button-Rows für späteres Ein-/Ausblenden
|
||
self._addon_button_rows = {}
|
||
|
||
uebersetzer_row = ttk.Frame(self._addon_buttons_container, padding=(0, 0, 0, 0))
|
||
self._addon_button_rows["uebersetzer"] = uebersetzer_row
|
||
uebersetzer_row.pack(fill="x")
|
||
RoundedButton(
|
||
uebersetzer_row, "Übersetzer (provisorisch)", command=self._open_uebersetzer,
|
||
bg="#A8B8C0", fg="#1a4d6d", active_bg="#98A8B0", width=180, height=38, canvas_bg="#A8B8C0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# E-Mail Button
|
||
email_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["email"] = email_row
|
||
email_row.pack(fill="x")
|
||
RoundedButton(
|
||
email_row, "E-Mail", command=self._open_email,
|
||
bg="#B8C8D0", fg="#1a4d6d", active_bg="#A8B8C0", width=180, height=38, canvas_bg="#B8C8D0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Autotext Button
|
||
autotext_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["autotext"] = autotext_row
|
||
autotext_row.pack(fill="x")
|
||
RoundedButton(
|
||
autotext_row, "Autotext", command=self._open_autotext_dialog,
|
||
bg="#CADFE8", fg="#1a4d6d", active_bg="#BAD0E0", width=180, height=38, canvas_bg="#CADFE8",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# WhatsApp Button
|
||
whatsapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["whatsapp"] = whatsapp_row
|
||
whatsapp_row.pack(fill="x")
|
||
RoundedButton(
|
||
whatsapp_row, "WhatsApp", command=self._open_whatsapp,
|
||
bg="#D8E8F0", fg="#1a4d6d", active_bg="#C8D8E8", width=180, height=38, canvas_bg="#D8E8F0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# DocApp Button
|
||
docapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["docapp"] = docapp_row
|
||
docapp_row.pack(fill="x")
|
||
RoundedButton(
|
||
docapp_row, "MedWork", command=self._open_docapp,
|
||
bg="#E8F4F8", fg="#1a4d6d", active_bg="#D8E8F0", width=180, height=38, canvas_bg="#E8F4F8",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# To-do Button
|
||
todo_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["todo"] = todo_row
|
||
todo_row.pack(fill="x")
|
||
RoundedButton(
|
||
todo_row, "To-do", command=self._open_todo_window,
|
||
bg="#F0F8FB", fg="#1a4d6d", active_bg="#E0F0F5", width=180, height=38, canvas_bg="#F0F8FB",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Arbeitsplan Button
|
||
arbeitsplan_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["arbeitsplan"] = arbeitsplan_row
|
||
arbeitsplan_row.pack(fill="x")
|
||
RoundedButton(
|
||
arbeitsplan_row, "Arbeitsplan", command=self._open_arbeitsplan_window,
|
||
bg="#F8FCFE", fg="#1a4d6d", active_bg="#EAF4F8", width=180, height=38, canvas_bg="#F8FCFE",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Initiales Ein-/Ausblenden der Buttons basierend auf Einstellungen
|
||
self._update_addon_buttons_visibility()
|
||
|
||
self._addon_container.pack(fill="x", before=self._addon_anchor)
|
||
if not self._autotext_data.get("addon_visible", True):
|
||
self._addon_container.pack_forget()
|
||
self._addon_spacer_frame = self._addon_anchor # Referenz für Textblöcke (pack before)
|
||
|
||
right_top = ttk.Frame(right)
|
||
right_top.pack(fill="x", pady=(0, 4))
|
||
self.btn_make_kg = RoundedButton(
|
||
right_top, "KG erstellen", command=self.make_kg_from_text,
|
||
width=90, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_make_kg.pack(side="left", padx=(4, 0))
|
||
self.btn_copy = RoundedButton(
|
||
right_top, "KG kopieren", command=self.copy_output,
|
||
width=80, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_copy.pack(side="left", padx=(4, 0))
|
||
|
||
# Vertikales PanedWindow für KG (verstellbare Höhe)
|
||
self.paned_kg = ttk.PanedWindow(right, orient="vertical")
|
||
self.paned_kg.pack(fill="both", expand=True)
|
||
self.paned_kg.bind("<Configure>", self._on_paned_configure)
|
||
|
||
top_right = ttk.Frame(self.paned_kg)
|
||
bottom_right = ttk.Frame(self.paned_kg)
|
||
self.paned_kg.add(top_right, weight=1)
|
||
self.paned_kg.add(bottom_right, weight=0)
|
||
|
||
# Frame für Label + Schriftgrößen-Spinbox
|
||
kg_header = ttk.Frame(top_right)
|
||
kg_header.pack(fill="x", anchor="w")
|
||
ttk.Label(kg_header, text="Krankengeschichte:").pack(side="left")
|
||
|
||
self.txt_output = ScrolledText(
|
||
top_right, wrap="word", height=22, font=self._text_font, bg="#F5FCFF"
|
||
)
|
||
self.txt_output.pack(fill="both", expand=True)
|
||
self._bind_textblock_pending(self.txt_output)
|
||
self._bind_kg_section_copy(self.txt_output)
|
||
|
||
# Schriftgrößen-Spinbox für KG
|
||
add_text_font_size_control(kg_header, self.txt_output, initial_size=10, bg_color="#B9ECFA", save_key="main_kg")
|
||
|
||
# ─── SOAP-Abschnitts-Steuerung (A±, S±, O±, B±, D±, T±, P±) – einklappbar ───
|
||
_SOAP_BG = "#B9ECFA"
|
||
soap_toggle_frame = tk.Frame(bottom_right, bg=_SOAP_BG)
|
||
soap_toggle_frame.pack(fill="x")
|
||
|
||
self._soap_collapsed = self._autotext_data.get("soap_collapsed", False)
|
||
self._soap_toggle_label = tk.Label(
|
||
soap_toggle_frame,
|
||
text="\u25B6 SOAP:" if self._soap_collapsed else "\u25BC SOAP:",
|
||
font=("Segoe UI", 10), bg=_SOAP_BG, fg="#1a4d6d", cursor="hand2")
|
||
self._soap_toggle_label.pack(anchor="w")
|
||
self._soap_toggle_label.bind("<Button-1>", self._toggle_soap_collapse)
|
||
|
||
soap_ctrl_bar = tk.Frame(bottom_right, bg=_SOAP_BG, padx=4, pady=4)
|
||
self._soap_container = soap_ctrl_bar
|
||
if not self._soap_collapsed:
|
||
soap_ctrl_bar.pack(fill="x")
|
||
|
||
self._soap_anchor = tk.Frame(bottom_right, bg=_SOAP_BG, height=0)
|
||
self._soap_anchor.pack(fill="x")
|
||
self._soap_section_labels = {}
|
||
self._soap_section_levels = load_soap_section_levels()
|
||
|
||
self._soap_inner = tk.Frame(soap_ctrl_bar, bg=_SOAP_BG)
|
||
self._soap_inner.pack(anchor="center")
|
||
self._soap_bg = _SOAP_BG
|
||
self._rebuild_soap_section_controls()
|
||
|
||
# ─── KG-Bearbeitungs-Buttons (Kürzer / Ausführlicher / Vorlage) ───
|
||
# Zeile 1: gleiche Farbe wie KG erstellen (#7EC8E3)
|
||
_row1_bg, _row1_active = "#7EC8E3", "#6CB8D3"
|
||
kg_edit_bar = tk.Frame(soap_ctrl_bar, bg="#B9ECFA", pady=4)
|
||
kg_edit_bar.pack(fill="x")
|
||
self.btn_kg_kuerzer = RoundedButton(
|
||
kg_edit_bar, "Kürzer", command=self._kg_kuerzer,
|
||
width=120, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_kuerzer.pack(side="left")
|
||
self.btn_kg_ausfuehrlicher = RoundedButton(
|
||
kg_edit_bar, "Ausführlicher", command=self._kg_ausfuehrlicher,
|
||
width=140, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_ausfuehrlicher.pack(side="left", padx=(8, 0))
|
||
self.btn_kg_vorlage = RoundedButton(
|
||
kg_edit_bar, "Vorlage", command=self._open_kg_vorlage,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_vorlage.pack(side="left", padx=(8, 0))
|
||
self._update_kg_detail_display()
|
||
|
||
# ─── Dokumente-Gruppe (Brief, Rezept, … Korrektur) – einklappbar ───
|
||
dokumente_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
dokumente_header_frame.pack(fill="x", pady=(6, 0))
|
||
|
||
self._dokumente_collapsed = self._autotext_data.get("dokumente_collapsed", False)
|
||
self._dokumente_toggle_label = tk.Label(
|
||
dokumente_header_frame,
|
||
text="\u25B6 Dokumente:" if self._dokumente_collapsed else "\u25BC Dokumente:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
|
||
self._dokumente_toggle_label.pack(anchor="w")
|
||
self._dokumente_toggle_label.bind("<Button-1>", self._toggle_dokumente_collapse)
|
||
|
||
self._dokumente_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._dokumente_container.pack(fill="x")
|
||
|
||
self._dokumente_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._dokumente_anchor.pack(fill="x")
|
||
|
||
# Zeile 2 (+15% heller): Brief, Rezept, OP-Bericht
|
||
_row2_bg, _row2_active = "#97D3E9", "#87C3D9"
|
||
letter_bar = ttk.Frame(self._dokumente_container, padding=(0, 4), style="TranscriptBar.TFrame")
|
||
letter_bar.pack(fill="x")
|
||
self.btn_letter = RoundedButton(
|
||
letter_bar, "Brief", command=self.open_brief_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_letter.pack(side="left")
|
||
self.btn_recipe = RoundedButton(
|
||
letter_bar, "Rezept", command=self.open_rezept_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_recipe.pack(side="left", padx=(8, 0))
|
||
self.btn_op_bericht = RoundedButton(
|
||
letter_bar, "OP-Bericht", command=self.open_op_bericht_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_op_bericht.pack(side="left", padx=(8, 0))
|
||
|
||
# Zeile 3 (+30% heller): KOGU, Diskussion mit KI, Arztzeugnis
|
||
_row3_bg, _row3_active = "#B0DEEF", "#A0CEDF"
|
||
kogu_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
|
||
kogu_bar.pack(fill="x")
|
||
self.btn_kogu = RoundedButton(
|
||
kogu_bar, "KOGU", command=self.open_kogu_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_kogu.pack(side="left")
|
||
self.btn_diskussion = RoundedButton(
|
||
kogu_bar, "Diskussion mit KI", command=self.open_diskussion_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_diskussion.pack(side="left", padx=(8, 0))
|
||
self.btn_arztzeugnis = RoundedButton(
|
||
kogu_bar, "Arztzeugnis", command=self._open_arztzeugnis,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_arztzeugnis.pack(side="left", padx=(8, 0))
|
||
|
||
# Zeile 4 (+45% heller): KI-Kontrolle, Korrektur
|
||
_row4_bg, _row4_active = "#C9E9F5", "#B9D9E5"
|
||
korrektur_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
|
||
korrektur_bar.pack(fill="x")
|
||
self.btn_ki_pruefen = RoundedButton(
|
||
korrektur_bar, "KI-Kontrolle", command=self.open_ki_pruefen,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
|
||
)
|
||
self.btn_ki_pruefen.pack(side="left")
|
||
self.btn_korrektur = RoundedButton(
|
||
korrektur_bar, "Korrektur", command=self.open_pruefen_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
|
||
)
|
||
self.btn_korrektur.pack(side="left", padx=(8, 0))
|
||
|
||
if self._dokumente_collapsed:
|
||
self._dokumente_container.pack_forget()
|
||
|
||
# Textblöcke – unterhalb der Button-Reihen (rechte Seite unten)
|
||
textbloecke_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
textbloecke_header_frame.pack(fill="x", pady=(6, 0))
|
||
|
||
self._textbloecke_collapsed = self._autotext_data.get("textbloecke_collapsed", False)
|
||
self._textbloecke_toggle_label = tk.Label(textbloecke_header_frame,
|
||
text="▶ Textblöcke:" if self._textbloecke_collapsed else "▼ Textblöcke:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
|
||
cursor="hand2")
|
||
self._textbloecke_toggle_label.pack(anchor="w")
|
||
self._textbloecke_toggle_label.bind("<Button-1>", self._toggle_textbloecke_collapse)
|
||
|
||
self._textbloecke_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._textbloecke_container.pack(fill="x")
|
||
|
||
self._textbloecke_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._textbloecke_anchor.pack(fill="x")
|
||
|
||
self._textbloecke_data = load_textbloecke()
|
||
self._removed_textbloecke = []
|
||
self._last_focused_text_widget = None
|
||
self._last_insert_index = "1.0"
|
||
self._textblock_drag_data = {"text": None, "active": False}
|
||
self._just_dropped_on_textblock = False
|
||
self._textbloecke_buttons = []
|
||
self._textbloecke_rows_frame = ttk.Frame(self._textbloecke_container)
|
||
self._textbloecke_rows_frame.pack(fill="x")
|
||
self._rebuild_textblock_buttons()
|
||
|
||
plus_minus_row = ttk.Frame(self._textbloecke_container, padding=(0, 4, 0, 0))
|
||
plus_minus_row.pack(fill="x")
|
||
pm_inner = ttk.Frame(plus_minus_row)
|
||
pm_inner.pack(anchor="center")
|
||
RoundedButton(pm_inner, "+", command=self._add_textblock, width=28, height=24, canvas_bg="#B9ECFA",
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left", padx=(0, 4))
|
||
RoundedButton(pm_inner, "−", command=self._remove_textblock, width=28, height=24, canvas_bg="#B9ECFA",
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left")
|
||
|
||
if self._textbloecke_collapsed:
|
||
self._textbloecke_container.pack_forget()
|
||
|
||
self.bind_all("<B1-Motion>", self._textblock_on_drag_motion)
|
||
self.bind_all("<ButtonRelease-1>", self._on_global_drag_release)
|
||
|
||
if not self._autotext_data.get("textbloecke_visible", True):
|
||
self._textbloecke_container.pack_forget()
|
||
|
||
self._bottom_frame = ttk.Frame(self, padding=(10, 0, 10, 10), style="StatusBar.TFrame")
|
||
self._bottom_frame.pack(fill="x")
|
||
ttk.Label(
|
||
self._bottom_frame,
|
||
text="Ablauf: ⏺ Start → ⏹ Stopp → automatische Transkription → automatische KG. "
|
||
"⏺ Korrigieren ergänzt bestehende KG. „KG erstellen“ erzeugt KG aus Text."
|
||
).pack(side="left", anchor="w")
|
||
|
||
add_resize_grip(self)
|
||
|
||
# GANZ LINKS & WEITER OBEN: "AzA" Text + Logo (über dem Resize-Grip!)
|
||
logo_frame = tk.Frame(self, bg="#B9ECFA")
|
||
logo_frame.place(relx=0.01, rely=0.97, anchor="sw") # Ganz links unten, aber höher
|
||
|
||
# Logo ZUERST (links)
|
||
try:
|
||
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png")
|
||
if os.path.exists(logo_path):
|
||
from PIL import Image, ImageTk
|
||
img = Image.open(logo_path)
|
||
img_small = img.resize((62, 62), Image.Resampling.LANCZOS)
|
||
self.bottom_logo_photo = ImageTk.PhotoImage(img_small)
|
||
|
||
logo_label = tk.Label(logo_frame, image=self.bottom_logo_photo, bg="#B9ECFA")
|
||
logo_label.pack(side="left", padx=(0, 10))
|
||
except Exception as e:
|
||
print(f"Bottom-Logo konnte nicht geladen werden: {e}")
|
||
|
||
# Text-Teil DANACH (rechts vom Logo)
|
||
text_frame = tk.Frame(logo_frame, bg="#B9ECFA")
|
||
text_frame.pack(side="left")
|
||
|
||
aza_label1 = tk.Label(text_frame, text="AzA von Arzt zu Arzt",
|
||
bg="#B9ECFA", fg="#1a4d6d",
|
||
font=("Segoe UI", 19, "bold")) # 24 → 19 (20% kleiner)
|
||
aza_label1.pack(anchor="w")
|
||
|
||
aza_label2 = tk.Label(text_frame, text="Informatik zu fairen Preisen",
|
||
bg="#B9ECFA", fg="#1a4d6d",
|
||
font=("Segoe UI", 11)) # 21 → 11 (50% kleiner)
|
||
aza_label2.pack(anchor="w")
|
||
|
||
|
||
# Initiale Schriftgrößen- und Button-Größen-Anwendung beim Start
|
||
self.after(100, lambda: self._apply_font_scale(load_font_scale()))
|
||
self.after(150, lambda: self._apply_button_scale(load_button_scale()))
|
||
|
||
# To-Do-Liste automatisch öffnen, wenn in Einstellungen aktiviert (Standard: an)
|
||
if (self._autotext_data.get("todo_auto_open", True)
|
||
and self._autotext_data.get("addon_buttons", {}).get("todo", True)):
|
||
self.after(500, self._open_todo_window)
|
||
|
||
def _on_configure(self, event):
|
||
"""Nach Verschieben/Resize: Größe und Position mit Verzögerung speichern (nur Hauptfenster)."""
|
||
if event.widget is not self:
|
||
return
|
||
if self._geometry_save_after_id:
|
||
self.after_cancel(self._geometry_save_after_id)
|
||
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
|
||
|
||
def _on_paned_configure(self, event):
|
||
"""Nach Verschieben eines Teilers (Breite oder Transkript/KG-Höhe): mit Verzögerung speichern."""
|
||
if event.widget is not self.paned and event.widget is not self.paned_transcript and event.widget is not self.paned_kg:
|
||
return
|
||
if self._geometry_save_after_id:
|
||
self.after_cancel(self._geometry_save_after_id)
|
||
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
|
||
|
||
def _restore_sash(self):
|
||
"""Stellt gespeicherte Sash-Positionen (Breite Transkript/KG, Höhe Transkript und KG) wieder her. Standard: Transkript 1/3 der Breite."""
|
||
# Gespeicherte PanedWindow-Positionen laden
|
||
paned_positions = load_paned_positions()
|
||
|
||
try:
|
||
w = self.winfo_width()
|
||
if w < 200:
|
||
w = 800
|
||
sash = self._saved_sash if self._saved_sash is not None else (w // 3)
|
||
sash = max(150, min(sash, w - 150))
|
||
self.paned.sashpos(0, sash)
|
||
except Exception:
|
||
pass
|
||
|
||
# Transkript vertikale Position wiederherstellen
|
||
try:
|
||
left_h = self.paned_transcript.winfo_height()
|
||
if left_h < 100:
|
||
left_h = 400
|
||
|
||
# Versuche gespeicherte Position zu laden
|
||
saved_transcript_pos = paned_positions.get("transcript_vertical")
|
||
if saved_transcript_pos is not None:
|
||
sash_v = max(80, min(saved_transcript_pos, max(80, left_h - 80)))
|
||
elif self._saved_sash_transcript is not None:
|
||
sash_v = max(80, min(self._saved_sash_transcript, max(80, left_h - 80)))
|
||
else:
|
||
sash_v = min(150, max(80, left_h - 80))
|
||
self.paned_transcript.sashpos(0, sash_v)
|
||
except Exception:
|
||
pass
|
||
|
||
# Krankengeschichte vertikale Position wiederherstellen
|
||
try:
|
||
right_h = self.paned_kg.winfo_height()
|
||
if right_h < 100:
|
||
right_h = 400
|
||
|
||
# Versuche gespeicherte Position zu laden
|
||
saved_kg_pos = paned_positions.get("kg_vertical")
|
||
if saved_kg_pos is not None:
|
||
sash_v = max(120, min(saved_kg_pos, max(120, right_h - 80)))
|
||
else:
|
||
# Standard: ca. 70% für KG Text, 30% für Buttons
|
||
sash_v = max(200, int(right_h * 0.70))
|
||
self.paned_kg.sashpos(0, sash_v)
|
||
except Exception:
|
||
pass
|
||
|
||
def _save_window_geometry(self):
|
||
self._geometry_save_after_id = None
|
||
if getattr(self, "_minimized", False):
|
||
return
|
||
try:
|
||
w, h = self.winfo_width(), self.winfo_height()
|
||
x, y = self.winfo_x(), self.winfo_y()
|
||
if w >= 400 and h >= 300:
|
||
sash = None
|
||
sash_transcript = None
|
||
try:
|
||
sash = self.paned.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
sash_transcript = self.paned_transcript.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
save_window_geometry(w, h, x, y, sash, sash_transcript)
|
||
|
||
# Auch die vertikalen PanedWindow-Positionen speichern
|
||
paned_positions = {}
|
||
try:
|
||
paned_positions["transcript_vertical"] = self.paned_transcript.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
paned_positions["kg_vertical"] = self.paned_kg.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
|
||
if paned_positions:
|
||
save_paned_positions(paned_positions)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_font_scale(self, scale: float):
|
||
"""Wendet nur den Schriftgrößen-Skalierungsfaktor auf Text-Widgets an."""
|
||
try:
|
||
# Aktualisiere Text-Widgets (ScrolledText, Text, Label)
|
||
# Basis-Größe 16, damit bei Scale 0.3-0.8 die Schrift lesbar bleibt
|
||
base_size = 16
|
||
new_size = max(5, int(base_size * scale))
|
||
new_font = (self._text_font[0], new_size)
|
||
|
||
# Aktualisiere Status-Label
|
||
if hasattr(self, 'lbl_status'):
|
||
self.lbl_status.configure(font=new_font)
|
||
|
||
# Aktualisiere Text-Widgets
|
||
for txt_widget in [self.txt_transcript, self.txt_output]:
|
||
if txt_widget and txt_widget.winfo_exists():
|
||
txt_widget.configure(font=new_font)
|
||
|
||
# Skaliere Schrift in allen RoundedButtons
|
||
for widget in self.winfo_children():
|
||
self._scale_font_recursive(widget, scale)
|
||
|
||
except Exception as e:
|
||
pass
|
||
|
||
def _apply_font_scale_global(self, scale: float):
|
||
"""Wendet Schriftgrößen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_font_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_fonts(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale(self, scale: float):
|
||
"""Wendet nur den Button-Größen-Skalierungsfaktor an."""
|
||
try:
|
||
for widget in self.winfo_children():
|
||
self._scale_button_size_recursive(widget, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale_global(self, scale: float):
|
||
"""Wendet Button-Größen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_button_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_buttons(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def update_token_display(self):
|
||
"""Aktualisiert die Token-Anzeige (Budget-basiert)."""
|
||
try:
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50)
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label.configure(
|
||
text=f"📊 {remaining_percent}%",
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d")
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _fetch_and_update_usage(self):
|
||
"""Ruft echte Verbrauchs-Daten von OpenAI ab (läuft in Thread) - OHNE Statusmeldungen."""
|
||
try:
|
||
usage_data = fetch_openai_usage(self.client)
|
||
|
||
if usage_data and usage_data.get("success"):
|
||
used_dollars = usage_data.get("used_dollars", 0)
|
||
save_token_usage(used_dollars=used_dollars)
|
||
# KEINE Statusmeldung mehr - stört den Benutzer
|
||
self.after(0, self.update_token_display)
|
||
# KEINE Fehlermeldungen mehr bei API-Problemen
|
||
except Exception:
|
||
pass # Fehler still ignorieren
|
||
|
||
def _open_budget_settings(self):
|
||
"""Dialog zum Einstellen des monatlichen Budgets."""
|
||
token_data = load_token_usage()
|
||
current_budget = token_data.get("budget_dollars", 50)
|
||
|
||
new_budget = simpledialog.askfloat(
|
||
"Monatliches Budget",
|
||
"Ihr monatliches OpenAI-Budget in Dollar:\n(z.B. 50 für $50/Monat)",
|
||
initialvalue=current_budget,
|
||
minvalue=1,
|
||
maxvalue=10000
|
||
)
|
||
|
||
if new_budget:
|
||
save_token_usage(budget_dollars=new_budget)
|
||
self.update_token_display()
|
||
self.set_status(f"Budget auf ${new_budget:.2f} gesetzt.")
|
||
|
||
def _scale_font_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Schriftgröße in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_font_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_button_size_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Button-Größe in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_button_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_button_size_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_widget_recursive(self, widget, scale: float):
|
||
"""Legacy-Methode für Rückwärtskompatibilität."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_widget_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _check_old_kg_entries(self):
|
||
"""Prüft KG-Einträge älter als 2 Wochen: bei Bestätigung löschen (oder automatisch, wenn Einstellung aktiv)."""
|
||
try:
|
||
old = get_old_kg_entries(14)
|
||
if not old:
|
||
return
|
||
auto = getattr(self, "_autotext_data", {}).get("kg_auto_delete_old", False)
|
||
if auto:
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge älter als 2 Wochen automatisch gelöscht.")
|
||
return
|
||
if messagebox.askyesno(
|
||
"KG-Aufräumen",
|
||
f"Es gibt {len(old)} KG-Einträge älter als 2 Wochen.\n\nSollen diese gelöscht werden, um Speicher zu schonen?",
|
||
):
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge gelöscht.")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close(self):
|
||
"""Beim Schließen: KG/Brief/Rezept/KOGU lesen und in Ablage speichern (JSON + .txt), dann schließen."""
|
||
def _str(w):
|
||
if w is None:
|
||
return ""
|
||
if hasattr(w, "get"):
|
||
try:
|
||
s = w.get("1.0", "end")
|
||
return (s if isinstance(s, str) else str(s)).strip()
|
||
except Exception:
|
||
return ""
|
||
return (str(w)).strip()
|
||
kg_text = _str(self.txt_output)
|
||
brief_text = _str(getattr(self, "_last_brief_text", None))
|
||
rezept_text = _str(getattr(self, "_last_rezept_text", None))
|
||
kogu_text = _str(getattr(self, "_last_kogu_text", None))
|
||
try:
|
||
if kg_text:
|
||
save_to_ablage("KG", kg_text)
|
||
if brief_text:
|
||
save_to_ablage("Briefe", brief_text)
|
||
if rezept_text:
|
||
save_to_ablage("Rezepte", rezept_text)
|
||
if kogu_text:
|
||
save_to_ablage("Kostengutsprachen", kogu_text)
|
||
except Exception:
|
||
pass
|
||
self._save_window_geometry()
|
||
try:
|
||
save_button_heat()
|
||
except Exception:
|
||
pass
|
||
diktat_win = getattr(self, "_diktat_window", None)
|
||
if diktat_win is not None and diktat_win.winfo_exists():
|
||
self._main_hidden = True
|
||
self.withdraw()
|
||
return
|
||
self.destroy()
|
||
|
||
# _open_settings -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_autotext_dialog(self, parent=None):
|
||
"""Dialog zum Verwalten der Autotext-Abkürzungen (Abkürzung → Ersetzung, mehrzeilig möglich)."""
|
||
aw = tk.Toplevel(self)
|
||
aw.title("Autotext verwalten")
|
||
aw.transient(parent or self)
|
||
aw.configure(bg="#B9ECFA")
|
||
aw.minsize(500, 400)
|
||
aw.attributes("-topmost", True)
|
||
self._register_window(aw)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
setup_window_geometry_saving(aw, "autotext", 660, 500)
|
||
|
||
add_resize_grip(aw, 500, 400)
|
||
add_font_scale_control(aw)
|
||
af = ttk.Frame(aw, padding=12)
|
||
af.pack(fill="both", expand=True)
|
||
ttk.Label(af, text="Abkürzungen: Tippen Sie z. B. „mfg“ + Leerzeichen → wird zu „mit freundlichen Grüßen“. Mehrzeilige Ersetzungen möglich.").pack(anchor="w")
|
||
list_f = ttk.Frame(af)
|
||
list_f.pack(fill="both", expand=True, pady=(8, 8))
|
||
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 11))
|
||
listbox.pack(side="left", fill="both", expand=True)
|
||
scroll = ttk.Scrollbar(list_f, orient="vertical", command=listbox.yview)
|
||
scroll.pack(side="right", fill="y")
|
||
listbox.configure(yscrollcommand=scroll.set)
|
||
entries = dict(self._autotext_data.get("entries") or {})
|
||
|
||
def refresh_list():
|
||
listbox.delete(0, "end")
|
||
for k in sorted(entries.keys()):
|
||
listbox.insert("end", f"«{k}» → {((entries[k] or '').replace(chr(10), ' ↵ ')[:40])}…" if len((entries[k] or "").replace("\n", " ")) > 40 else f"«{k}» → {((entries[k] or '').replace(chr(10), ' ↵ '))}")
|
||
|
||
refresh_list()
|
||
|
||
edit_f = ttk.Frame(af)
|
||
edit_f.pack(fill="x", pady=(0, 8))
|
||
ttk.Label(edit_f, text="Abkürzung:").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||
abbrev_var = tk.StringVar()
|
||
ttk.Entry(edit_f, textvariable=abbrev_var, width=18).grid(row=0, column=1, sticky="w", padx=(0, 16))
|
||
ttk.Label(edit_f, text="Ersetzung (mehrzeilig möglich):").grid(row=1, column=0, sticky="nw", padx=(0, 8), pady=(4, 0))
|
||
repl_text = ScrolledText(edit_f, wrap="word", height=4, width=42, font=self._text_font, bg="#F5FCFF")
|
||
repl_text.grid(row=1, column=1, sticky="ew", pady=(4, 0))
|
||
edit_f.columnconfigure(1, weight=1)
|
||
|
||
def on_select(evt):
|
||
sel = listbox.curselection()
|
||
if not sel:
|
||
return
|
||
idx = sel[0]
|
||
keys = sorted(entries.keys())
|
||
if idx >= len(keys):
|
||
return
|
||
k = keys[idx]
|
||
abbrev_var.set(k)
|
||
repl_text.delete("1.0", "end")
|
||
repl_text.insert("1.0", entries.get(k, ""))
|
||
|
||
listbox.bind("<<ListboxSelect>>", on_select)
|
||
|
||
def do_add():
|
||
ab = abbrev_var.get().strip()
|
||
if not ab:
|
||
messagebox.showinfo("Hinweis", "Bitte eine Abkürzung eingeben.")
|
||
return
|
||
entries[ab] = repl_text.get("1.0", "end").rstrip("\n")
|
||
self._autotext_data["entries"] = entries
|
||
save_autotext(self._autotext_data)
|
||
refresh_list()
|
||
abbrev_var.set("")
|
||
repl_text.delete("1.0", "end")
|
||
self.set_status("Autotext-Eintrag hinzugefügt/aktualisiert.")
|
||
|
||
def do_delete():
|
||
sel = listbox.curselection()
|
||
if not sel:
|
||
messagebox.showinfo("Hinweis", "Bitte einen Eintrag in der Liste auswählen.")
|
||
return
|
||
keys = sorted(entries.keys())
|
||
idx = sel[0]
|
||
if idx >= len(keys):
|
||
return
|
||
k = keys[idx]
|
||
del entries[k]
|
||
self._autotext_data["entries"] = entries
|
||
save_autotext(self._autotext_data)
|
||
refresh_list()
|
||
abbrev_var.set("")
|
||
repl_text.delete("1.0", "end")
|
||
self.set_status("Autotext-Eintrag gelöscht.")
|
||
|
||
def do_diktieren():
|
||
if not self.ensure_ready():
|
||
messagebox.showwarning("Diktieren", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
|
||
return
|
||
rec_win = tk.Toplevel(aw)
|
||
rec_win.title("Autotext – Diktieren")
|
||
rec_win.transient(aw)
|
||
rec_win.geometry("420x130")
|
||
rec_win.configure(bg="#B9ECFA")
|
||
rec_win.attributes("-topmost", True)
|
||
self._register_window(rec_win)
|
||
lbl = ttk.Label(rec_win, text="Aufnahme läuft… Klicken Sie „Stoppen“, wenn Sie fertig sind.")
|
||
lbl.pack(pady=(16, 8))
|
||
try:
|
||
self.recorder.start()
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
rec_win.destroy()
|
||
return
|
||
|
||
def stop_and_transcribe():
|
||
try:
|
||
wav_path = self.recorder.stop_and_save_wav()
|
||
except Exception:
|
||
rec_win.destroy()
|
||
return
|
||
rec_win.destroy()
|
||
|
||
def worker():
|
||
try:
|
||
text = self.transcribe_wav(wav_path)
|
||
try:
|
||
if os.path.exists(wav_path):
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
def insert_text():
|
||
if aw.winfo_exists() and repl_text.winfo_exists():
|
||
repl_text.insert(tk.INSERT, text)
|
||
self.set_status("Ersetzungstext diktiert und eingefügt.")
|
||
self.after(0, insert_text)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
ttk.Button(rec_win, text="Stoppen", command=stop_and_transcribe).pack(pady=(0, 16))
|
||
|
||
def do_run_as_admin():
|
||
if not _run_as_admin():
|
||
messagebox.showerror("Fehler", "Konnte nicht als Administrator starten.")
|
||
|
||
btn_row = ttk.Frame(af)
|
||
btn_row.pack(fill="x")
|
||
ttk.Button(btn_row, text="Hinzufügen / Aktualisieren", command=do_add).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Diktieren", command=do_diktieren).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Löschen", command=do_delete).pack(side="left", padx=(0, 8))
|
||
if sys.platform == "win32" and not _is_admin():
|
||
ttk.Button(btn_row, text="Als Administrator starten", command=do_run_as_admin).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Schließen", command=aw.destroy).pack(side="left")
|
||
|
||
@staticmethod
|
||
def _hash_password(pw: str) -> str:
|
||
"""Erzeugt einen SHA-256-Hash des Passworts."""
|
||
return hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
||
|
||
def _show_login_dialog(self):
|
||
"""Zeigt beim ersten Start einen Registrierungs-Dialog, danach einen Passwort-Login."""
|
||
has_profile = bool(self._user_profile.get("name"))
|
||
|
||
if has_profile and self._user_profile.get("password_hash"):
|
||
self._show_password_login()
|
||
else:
|
||
self._show_registration_dialog()
|
||
|
||
def _show_password_login(self):
|
||
"""Passwort-Abfrage für bestehende Benutzer."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("🔒 Anmeldung")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("380x260")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 380, 260)
|
||
|
||
tk.Label(dlg, text="🔒 Anmeldung", font=("Segoe UI", 16, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
|
||
|
||
user_name = self._user_profile.get("name", "Benutzer")
|
||
tk.Label(dlg, text=f"Willkommen zurück, {user_name}!",
|
||
font=("Segoe UI", 10), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(10, 2))
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Passwort:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
err_label = tk.Label(form, text="", font=("Segoe UI", 9), bg="#E8F4FA", fg="#E05050")
|
||
err_label.pack(fill="x")
|
||
|
||
def do_login(event=None):
|
||
pw = pw_entry.get()
|
||
if not pw:
|
||
err_label.configure(text="⚠ Bitte Passwort eingeben.")
|
||
return
|
||
pw_hash = self._hash_password(pw)
|
||
if pw_hash == self._user_profile.get("password_hash"):
|
||
dlg.destroy()
|
||
else:
|
||
err_label.configure(text="❌ Falsches Passwort. Bitte erneut versuchen.")
|
||
pw_entry.delete(0, "end")
|
||
pw_entry.focus_set()
|
||
|
||
pw_entry.bind("<Return>", do_login)
|
||
|
||
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
|
||
btn_frame.pack(pady=8)
|
||
tk.Button(btn_frame, text="🔓 Anmelden", font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=do_login).pack()
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
|
||
pw_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_registration_dialog(self):
|
||
"""Erstregistrierung: Profil + Passwort festlegen."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Registrierung – AzA Profil")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("400x480")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 400, 480)
|
||
|
||
tk.Label(dlg, text="👤 Willkommen bei AzA", font=("Segoe UI", 16, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
|
||
tk.Label(dlg, text="Bitte erfassen Sie Ihr Profil und legen Sie ein Passwort fest:",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(8, 4))
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
name_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
name_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
name_entry.insert(0, self._user_profile.get("name", ""))
|
||
|
||
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
spec_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
spec_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
spec_entry.insert(0, self._user_profile.get("specialty", ""))
|
||
|
||
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
clinic_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
clinic_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
clinic_entry.insert(0, self._user_profile.get("clinic", ""))
|
||
|
||
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
sep.pack(fill="x", pady=(6, 6))
|
||
|
||
tk.Label(form, text="🔒 Passwort festlegen:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
tk.Label(form, text="Passwort bestätigen:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_confirm_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
pw_confirm_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
def do_save():
|
||
name = name_entry.get().strip()
|
||
if not name:
|
||
messagebox.showwarning("Pflichtfeld", "Bitte geben Sie Ihren Namen ein.", parent=dlg)
|
||
return
|
||
pw = pw_entry.get()
|
||
pw_confirm = pw_confirm_entry.get()
|
||
if not pw:
|
||
messagebox.showwarning("Pflichtfeld", "Bitte legen Sie ein Passwort fest.", parent=dlg)
|
||
return
|
||
if len(pw) < 4:
|
||
messagebox.showwarning("Passwort zu kurz", "Das Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
||
return
|
||
if pw != pw_confirm:
|
||
messagebox.showerror("Fehler", "Die Passwörter stimmen nicht überein.", parent=dlg)
|
||
pw_confirm_entry.delete(0, "end")
|
||
pw_confirm_entry.focus_set()
|
||
return
|
||
self._user_profile = {
|
||
"name": name,
|
||
"specialty": spec_entry.get().strip(),
|
||
"clinic": clinic_entry.get().strip(),
|
||
"password_hash": self._hash_password(pw),
|
||
}
|
||
save_user_profile(self._user_profile)
|
||
dlg.destroy()
|
||
|
||
tk.Button(dlg, text="💾 Registrieren & Starten", font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=do_save).pack(pady=12)
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
|
||
name_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_profile_editor(self):
|
||
"""Öffnet ein Fenster zum Bearbeiten des Benutzerprofils (inkl. Passwort ändern)."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Profil bearbeiten")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("380x440")
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
center_window(dlg, 380, 440)
|
||
|
||
tk.Label(dlg, text="👤 Profil bearbeiten", font=("Segoe UI", 13, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8)
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(8, 0))
|
||
name_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
||
name_e.pack(fill="x", ipady=4, pady=(0, 6))
|
||
name_e.insert(0, self._user_profile.get("name", ""))
|
||
|
||
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
spec_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
||
spec_e.pack(fill="x", ipady=4, pady=(0, 6))
|
||
spec_e.insert(0, self._user_profile.get("specialty", ""))
|
||
|
||
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
clinic_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
||
clinic_e.pack(fill="x", ipady=4, pady=(0, 6))
|
||
clinic_e.insert(0, self._user_profile.get("clinic", ""))
|
||
|
||
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
sep.pack(fill="x", pady=(8, 6))
|
||
|
||
tk.Label(form, text="🔒 Passwort ändern (optional):", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
||
tk.Label(form, text="Leer lassen, um das Passwort beizubehalten.",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
|
||
|
||
pw_old_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
pw_new_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
pw_confirm_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="●")
|
||
|
||
if self._user_profile.get("password_hash"):
|
||
tk.Label(form, text="Altes Passwort:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_old_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
|
||
tk.Label(form, text="Neues Passwort:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
||
pw_new_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
tk.Label(form, text="Neues Passwort bestätigen:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
||
pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
|
||
def do_save():
|
||
name = name_e.get().strip()
|
||
if not name:
|
||
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
|
||
return
|
||
new_pw = pw_new_e.get()
|
||
new_pw_confirm = pw_confirm_e.get()
|
||
old_hash = self._user_profile.get("password_hash", "")
|
||
|
||
if new_pw:
|
||
if old_hash and self._hash_password(pw_old_e.get()) != old_hash:
|
||
messagebox.showerror("Fehler", "Das alte Passwort ist nicht korrekt.", parent=dlg)
|
||
return
|
||
if len(new_pw) < 4:
|
||
messagebox.showwarning("Zu kurz", "Das neue Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
||
return
|
||
if new_pw != new_pw_confirm:
|
||
messagebox.showerror("Fehler", "Die neuen Passwörter stimmen nicht überein.", parent=dlg)
|
||
return
|
||
pw_hash = self._hash_password(new_pw)
|
||
else:
|
||
pw_hash = old_hash
|
||
|
||
self._user_profile = {
|
||
"name": name,
|
||
"specialty": spec_e.get().strip(),
|
||
"clinic": clinic_e.get().strip(),
|
||
"password_hash": pw_hash,
|
||
}
|
||
save_user_profile(self._user_profile)
|
||
self.set_status(f"Profil gespeichert: {name}")
|
||
dlg.destroy()
|
||
|
||
btn_row = tk.Frame(dlg, bg="#E8F4FA")
|
||
btn_row.pack(pady=10)
|
||
tk.Button(btn_row, text="💾 Speichern", font=("Segoe UI", 10, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", padx=16, pady=4, cursor="hand2",
|
||
command=do_save).pack(side="left", padx=6)
|
||
tk.Button(btn_row, text="Abbrechen", font=("Segoe UI", 10),
|
||
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
|
||
relief="flat", padx=12, pady=4, cursor="hand2",
|
||
command=dlg.destroy).pack(side="left", padx=6)
|
||
|
||
def _reset_window_positions(self):
|
||
"""Setzt alle gespeicherten Fensterpositionen und KG-Einstellungen zurück."""
|
||
answer = messagebox.askyesno(
|
||
"Fensterpositionen zurücksetzen",
|
||
"Alle Fensterpositionen und KG-Einstellungen zurücksetzen?\n\n"
|
||
"Beim nächsten Start werden alle Fenster\n"
|
||
"in der Bildschirmmitte geöffnet.\n"
|
||
"Die KG-Detailstufe (Kürzer/Ausführlicher)\n"
|
||
"wird auf Standard zurückgesetzt.",
|
||
parent=self,
|
||
)
|
||
if not answer:
|
||
return
|
||
deleted = reset_all_window_positions()
|
||
self._update_kg_detail_display()
|
||
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
|
||
self._update_soap_section_display()
|
||
try:
|
||
reset_button_heat()
|
||
except Exception:
|
||
pass
|
||
messagebox.showinfo(
|
||
"Zurückgesetzt",
|
||
f"{deleted} Fensterposition(en) zurückgesetzt.\n"
|
||
"KG-Detailstufe und Button-Farben auf Standard zurückgesetzt.\n\n"
|
||
"Bitte starten Sie die Anwendung neu,\n"
|
||
"damit die Änderung wirksam wird.",
|
||
parent=self,
|
||
)
|
||
|
||
def _toggle_minimize(self):
|
||
"""Fenster minimieren: Neu, Brief, OP-Bericht, Diktat oben; Aufnahme unten; Transparenz unten."""
|
||
if self._minimized:
|
||
self._status_row.pack(fill="x")
|
||
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||
self._bottom_frame.pack(fill="x")
|
||
self._top_right.pack(side="right", anchor="n")
|
||
self._btn_row_left.pack(side="left")
|
||
if getattr(self, "_mini_frame", None) is not None:
|
||
try:
|
||
self._mini_frame.destroy()
|
||
except Exception:
|
||
pass
|
||
self._mini_frame = None
|
||
self._mini_btn_korrigieren = None
|
||
self._btn_minimize.configure(text="−")
|
||
self._minimized = False
|
||
self.minsize(680, 560)
|
||
try:
|
||
g = getattr(self, "_geometry_before_minimize", None)
|
||
if g and len(g) >= 4:
|
||
self.geometry(f"{g[0]}x{g[1]}+{g[2]}+{g[3]}")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
self._geometry_before_minimize = (
|
||
self.winfo_width(), self.winfo_height(), self.winfo_x(), self.winfo_y()
|
||
)
|
||
self.paned.pack_forget()
|
||
self._bottom_frame.pack_forget()
|
||
self._top_right.pack_forget()
|
||
self._btn_row_left.pack_forget()
|
||
self._btn_minimize.configure(text="□")
|
||
self._minimized = True
|
||
self.minsize(360, 120)
|
||
top = self._btn_row_left.master
|
||
self._mini_frame = ttk.Frame(top, style="TopBar.TFrame", padding=(0, 0, 0, 4))
|
||
self._mini_frame.pack(fill="x")
|
||
top_row = ttk.Frame(self._mini_frame)
|
||
top_row.pack(fill="x")
|
||
|
||
# Buttons mit aktueller Skalierung
|
||
button_scale = load_button_scale()
|
||
font_scale = load_font_scale()
|
||
|
||
self._mini_btn_start = RoundedButton(
|
||
top_row, "⏺ Start", command=self.toggle_record,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=80, height=26, canvas_bg="#B9ECFA",
|
||
)
|
||
self._mini_btn_start.set_button_size_scale(button_scale)
|
||
self._mini_btn_start.set_font_size_scale(font_scale)
|
||
self._mini_btn_start.pack(side="left", padx=(0, 4), anchor="n")
|
||
if self.is_recording and getattr(self, "_recording_mode", "") == "new":
|
||
self._mini_btn_start.configure(text="⏹ Stopp")
|
||
|
||
self._mini_btn_korrigieren = RoundedButton(
|
||
top_row, "⏺ Korrig.", command=self._toggle_record_append,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=65, height=26, canvas_bg="#B9ECFA",
|
||
)
|
||
self._mini_btn_korrigieren.set_button_size_scale(button_scale)
|
||
self._mini_btn_korrigieren.set_font_size_scale(font_scale)
|
||
self._mini_btn_korrigieren.pack(side="left", padx=(0, 4), anchor="n")
|
||
if self.is_recording and getattr(self, "_recording_mode", "") == "append":
|
||
self._mini_btn_korrigieren.configure(text="⏹ Stopp")
|
||
|
||
btn_diktat = RoundedButton(top_row, "Diktat", command=self.open_diktat_window, width=60, height=26, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0")
|
||
btn_diktat.set_button_size_scale(button_scale)
|
||
btn_diktat.set_font_size_scale(font_scale)
|
||
btn_diktat.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_brief = RoundedButton(top_row, "Brief", command=self.open_brief_window, width=60, height=26, canvas_bg="#7EC8E3", bg="#7EC8E3", fg="#1a4d6d", active_bg="#6CB8D3")
|
||
btn_brief.set_button_size_scale(button_scale)
|
||
btn_brief.set_font_size_scale(font_scale)
|
||
btn_brief.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_op = RoundedButton(top_row, "OP-Bericht", command=self.open_op_bericht_window, width=90, height=26, canvas_bg="#A6E0F5", bg="#A6E0F5", fg="#1a4d6d", active_bg="#94D0E5")
|
||
btn_op.set_button_size_scale(button_scale)
|
||
btn_op.set_font_size_scale(font_scale)
|
||
btn_op.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_min = RoundedButton(top_row, "−", command=self._toggle_minimize, width=28, height=26, canvas_bg="#B9ECFA")
|
||
btn_min.set_button_size_scale(button_scale)
|
||
btn_min.set_font_size_scale(font_scale)
|
||
btn_min.pack(side="left", padx=(4, 0), anchor="n")
|
||
|
||
btn_tile = RoundedButton(top_row, "⊞", command=self.arrange_windows_top, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
|
||
btn_tile.set_button_size_scale(button_scale)
|
||
btn_tile.set_font_size_scale(font_scale)
|
||
btn_tile.pack(side="left", padx=(3, 0), anchor="n")
|
||
|
||
btn_reset_pos = RoundedButton(top_row, "↺", command=self._reset_window_positions, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
|
||
btn_reset_pos.set_button_size_scale(button_scale)
|
||
btn_reset_pos.set_font_size_scale(font_scale)
|
||
btn_reset_pos.pack(side="left", padx=(4, 0), anchor="n")
|
||
opacity_var = tk.DoubleVar(value=round(load_opacity() * 100))
|
||
|
||
def on_opacity_change(val):
|
||
try:
|
||
alpha = float(val) / 100.0
|
||
alpha = max(MIN_OPACITY, min(1.0, alpha))
|
||
self.attributes("-alpha", alpha)
|
||
save_opacity(alpha)
|
||
except Exception:
|
||
pass
|
||
|
||
opacity_row = ttk.Frame(self._mini_frame, padding=(0, 4, 0, 0))
|
||
opacity_row.pack(fill="x")
|
||
opacity_inner = ttk.Frame(opacity_row)
|
||
opacity_inner.pack(side="left")
|
||
|
||
lbl_half = tk.Label(opacity_inner, text="◐", font=("Segoe UI", 7),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
lbl_half.pack(side="left", padx=(0, 1))
|
||
lbl_half.bind("<Button-1>", lambda e: (opacity_var.set(round(MIN_OPACITY * 100)),
|
||
on_opacity_change(str(MIN_OPACITY * 100))))
|
||
lbl_half.bind("<Enter>", lambda e: lbl_half.configure(fg="#1a4d6d"))
|
||
lbl_half.bind("<Leave>", lambda e: lbl_half.configure(fg="#7AAFC8"))
|
||
|
||
try:
|
||
s = ttk.Style(self)
|
||
s.configure("MiniOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
|
||
except Exception:
|
||
pass
|
||
opacity_scale = ttk.Scale(
|
||
opacity_inner,
|
||
from_=40, to=100, variable=opacity_var,
|
||
orient="horizontal", length=50, command=on_opacity_change,
|
||
style="MiniOpacity.Horizontal.TScale",
|
||
)
|
||
opacity_scale.pack(side="left")
|
||
|
||
lbl_sun = tk.Label(opacity_inner, text="☀", font=("Segoe UI", 7),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
lbl_sun.pack(side="left", padx=(1, 0))
|
||
lbl_sun.bind("<Button-1>", lambda e: (opacity_var.set(100),
|
||
on_opacity_change("100")))
|
||
lbl_sun.bind("<Enter>", lambda e: lbl_sun.configure(fg="#1a4d6d"))
|
||
lbl_sun.bind("<Leave>", lambda e: lbl_sun.configure(fg="#7AAFC8"))
|
||
|
||
# Fenstergröße dynamisch basierend auf Button-Skalierung
|
||
base_width = 580
|
||
base_height = 150
|
||
scaled_width = int(base_width * button_scale)
|
||
scaled_height = int(base_height * button_scale)
|
||
self.geometry(f"{scaled_width}x{scaled_height}")
|
||
|
||
def _register_window(self, win):
|
||
"""Registriert ein AZA-Fenster im zentralen Tracker und entfernt es beim Schliessen."""
|
||
try:
|
||
if win and win.winfo_exists():
|
||
self._window_registry.add(win)
|
||
|
||
orig_protocol = None
|
||
try:
|
||
orig_protocol = win.protocol("WM_DELETE_WINDOW")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close():
|
||
try:
|
||
self._window_registry.discard(win)
|
||
except Exception:
|
||
pass
|
||
if orig_protocol and callable(orig_protocol):
|
||
try:
|
||
orig_protocol()
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
win.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||
except Exception:
|
||
pass
|
||
|
||
def _get_registered_windows(self):
|
||
"""Liefert alle noch lebenden registrierten Fenster (ohne self)."""
|
||
out = []
|
||
for w in list(self._window_registry):
|
||
try:
|
||
if w and w.winfo_exists() and w is not self:
|
||
out.append(w)
|
||
elif w is self:
|
||
continue
|
||
else:
|
||
self._window_registry.discard(w)
|
||
except Exception:
|
||
self._window_registry.discard(w)
|
||
return out
|
||
|
||
def _get_work_area_for_window(self, win):
|
||
"""Returns (left, top, right, bottom) of the monitor work-area containing *win*."""
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
|
||
user32 = ctypes.windll.user32
|
||
|
||
MONITOR_DEFAULTTONEAREST = 2
|
||
|
||
class RECT(ctypes.Structure):
|
||
_fields_ = [("left", wintypes.LONG),
|
||
("top", wintypes.LONG),
|
||
("right", wintypes.LONG),
|
||
("bottom", wintypes.LONG)]
|
||
|
||
class MONITORINFO(ctypes.Structure):
|
||
_fields_ = [("cbSize", wintypes.DWORD),
|
||
("rcMonitor", RECT),
|
||
("rcWork", RECT),
|
||
("dwFlags", wintypes.DWORD)]
|
||
|
||
hwnd = win.winfo_id()
|
||
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||
mi = MONITORINFO()
|
||
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||
user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
|
||
r = mi.rcWork
|
||
return (r.left, r.top, r.right, r.bottom)
|
||
except Exception:
|
||
sw = self.winfo_screenwidth()
|
||
sh = self.winfo_screenheight()
|
||
return (0, 0, sw, sh)
|
||
|
||
def _get_all_toplevels(self):
|
||
"""Alle sichtbaren Toplevel-Fenster sammeln (rekursiv)."""
|
||
result = []
|
||
def _collect(parent):
|
||
for w in parent.winfo_children():
|
||
if isinstance(w, tk.Toplevel) and w.winfo_exists() and w.winfo_viewable():
|
||
result.append(w)
|
||
_collect(w)
|
||
_collect(self)
|
||
return result
|
||
|
||
def arrange_windows_top(self):
|
||
"""
|
||
Robust:
|
||
1) Alle AZA-Fenster (inkl. Hauptfenster) in den internen Mini-Modus bringen
|
||
2) Danach (verzögert) alle Fenster oben sauber in eine Reihe positionieren
|
||
- Hauptfenster mittig
|
||
- andere rechts daneben
|
||
- keine Überlappung
|
||
Debug schreibt nach: window_tile_debug.txt
|
||
"""
|
||
import time as _tile_time
|
||
|
||
def _log(msg: str):
|
||
try:
|
||
p = os.path.join(os.path.dirname(__file__), "window_tile_debug.txt")
|
||
except Exception:
|
||
p = "window_tile_debug.txt"
|
||
try:
|
||
with open(p, "a", encoding="utf-8") as f:
|
||
f.write(f"{_tile_time.strftime('%H:%M:%S')} {msg}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
others = self._get_registered_windows()
|
||
wins = [self] + others
|
||
|
||
_log(f"FOUND {len(wins)} windows: " + ", ".join(
|
||
[getattr(w, "title", lambda: "?")() for w in wins if w is not None]))
|
||
|
||
for w in wins:
|
||
try:
|
||
minimize_fn = getattr(w, "_aza_minimize", None)
|
||
is_mini_fn = getattr(w, "_aza_is_minimized", None)
|
||
|
||
already = False
|
||
if is_mini_fn:
|
||
try:
|
||
already = bool(is_mini_fn())
|
||
except Exception:
|
||
already = False
|
||
|
||
if minimize_fn and not already:
|
||
minimize_fn()
|
||
except Exception as e:
|
||
_log(f"MINIMIZE ERR {w}: {e}")
|
||
|
||
def _place():
|
||
try:
|
||
try:
|
||
self.update()
|
||
except Exception:
|
||
self.update_idletasks()
|
||
|
||
wa_left, wa_top, wa_right, wa_bottom = self._get_work_area_for_window(self)
|
||
area_w = wa_right - wa_left
|
||
gap = 10
|
||
y = wa_top + 10
|
||
|
||
_log(f"WORK AREA: left={wa_left} top={wa_top} right={wa_right} "
|
||
f"bottom={wa_bottom} area_w={area_w}")
|
||
|
||
def _get_wh(win):
|
||
try:
|
||
win.update_idletasks()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
ww = int(win.winfo_width())
|
||
hh = int(win.winfo_height())
|
||
except Exception:
|
||
ww, hh = 0, 0
|
||
if ww < 80:
|
||
try:
|
||
ww = int(win.winfo_reqwidth())
|
||
except Exception:
|
||
ww = 220
|
||
if hh < 60:
|
||
try:
|
||
hh = int(win.winfo_reqheight())
|
||
except Exception:
|
||
hh = 120
|
||
return ww, hh
|
||
|
||
w_main, h_main = _get_wh(self)
|
||
x_main = wa_left + max(0, (area_w - w_main) // 2)
|
||
|
||
try:
|
||
self.wm_state("normal")
|
||
except Exception:
|
||
pass
|
||
|
||
self.geometry(f"{w_main}x{h_main}+{x_main}+{y}")
|
||
try:
|
||
self.lift()
|
||
except Exception:
|
||
pass
|
||
|
||
_log(f"MAIN placed: {w_main}x{h_main}+{x_main}+{y}")
|
||
|
||
x = x_main + w_main + gap
|
||
|
||
others = [w for w in wins if w is not self]
|
||
try:
|
||
others.sort(key=lambda w: (w.title() or "", str(w)))
|
||
except Exception:
|
||
pass
|
||
|
||
for w in others:
|
||
try:
|
||
if w is None or not w.winfo_exists():
|
||
continue
|
||
|
||
try:
|
||
w.wm_state("normal")
|
||
except Exception:
|
||
pass
|
||
|
||
ww, hh = _get_wh(w)
|
||
|
||
if x + ww > wa_right - 5:
|
||
x = max(wa_left, wa_right - ww - 5)
|
||
|
||
w.geometry(f"{ww}x{hh}+{x}+{y}")
|
||
try:
|
||
w.lift()
|
||
except Exception:
|
||
pass
|
||
|
||
_log(f"WIN placed: '{getattr(w, 'title', lambda: '?')()}' "
|
||
f"{ww}x{hh}+{x}+{y}")
|
||
|
||
x += ww + gap
|
||
except Exception as e:
|
||
_log(f"PLACE ERR {w}: {e}")
|
||
|
||
except Exception as e:
|
||
_log(f"PLACE OUTER ERR: {e}")
|
||
|
||
self.after(250, _place)
|
||
|
||
except Exception as e:
|
||
_log(f"ARRANGE OUTER ERR: {e}")
|
||
|
||
def set_status(self, s: str):
|
||
self.status_var.set(s)
|
||
self.update_idletasks()
|
||
|
||
def _apply_status_color(self):
|
||
"""Wendet die gespeicherte Statusanzeige-Farbe an."""
|
||
color = self._autotext_data.get("status_color", "#BD4500")
|
||
if color == "hidden":
|
||
self._status_row.pack_forget()
|
||
else:
|
||
try:
|
||
self._status_row.pack(fill="x")
|
||
# Vor paned einordnen
|
||
self._status_row.pack(fill="x", before=self.paned)
|
||
except Exception:
|
||
self._status_row.pack(fill="x")
|
||
self.lbl_status.configure(fg=color)
|
||
|
||
def _start_timer(self, phase: str):
|
||
self._phase = phase
|
||
self._timer_sec = 0
|
||
self._timer_running = True
|
||
self._tick_timer()
|
||
|
||
def _tick_timer(self):
|
||
if not self._timer_running:
|
||
return
|
||
self._timer_sec += 1
|
||
if self._phase == "transcribe":
|
||
self.set_status("Transkribiere Audio… (%d s)" % self._timer_sec)
|
||
elif self._phase == "kg":
|
||
self.set_status("Erstelle Krankengeschichte… (%d s)" % self._timer_sec)
|
||
self.after(1000, self._tick_timer)
|
||
|
||
def _show_interaktion_window(self, meds: list, result: str) -> None:
|
||
"""Zeigt das Interaktionscheck-Ergebnis in einem Fenster."""
|
||
win = tk.Toplevel(self)
|
||
win.title("Interaktionscheck")
|
||
win.transient(self)
|
||
win.configure(bg="#B9ECFA")
|
||
win.minsize(500, 400)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
setup_window_geometry_saving(win, "interaktionscheck", 700, 550)
|
||
|
||
add_resize_grip(win, 500, 400)
|
||
add_font_scale_control(win)
|
||
f = ttk.Frame(win, padding=12)
|
||
f.pack(fill="both", expand=True)
|
||
med_header = ttk.Frame(f)
|
||
med_header.pack(fill="x", anchor="w")
|
||
ttk.Label(med_header, text=f"Geprüfte Medikamente/Therapien: {', '.join(meds)}").pack(side="left")
|
||
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=18)
|
||
txt.pack(fill="both", expand=True, pady=(8, 8))
|
||
add_text_font_size_control(med_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="medikamenten_check")
|
||
txt.insert("1.0", result.strip())
|
||
self._bind_text_context_menu(txt)
|
||
|
||
# _show_text_window -> ausgelagert in Mixin-Modul
|
||
|
||
# _request_async_document -> ausgelagert in Mixin-Modul
|
||
|
||
# open_brief_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_rezept_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_kogu_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_diskussion_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_op_bericht_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_uebersetzer(self):
|
||
"""Startet das Übersetzer-Programm (translate.py) – Diktat, Übersetzung, Lernkarten, Gespräch mit KI – als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
translate_path = os.path.join(script_dir, "translate.py")
|
||
if not os.path.exists(translate_path):
|
||
messagebox.showerror("Fehler", f"translate.py nicht gefunden:\n{translate_path}")
|
||
return
|
||
kwargs = {"cwd": script_dir}
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([sys.executable, translate_path], **kwargs)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
|
||
def _open_email(self):
|
||
"""Startet das E-Mail-Programm (aza_email.py) als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
email_path = os.path.join(script_dir, "aza_email.py")
|
||
if not os.path.exists(email_path):
|
||
messagebox.showerror("Fehler", f"aza_email.py nicht gefunden:\n{email_path}")
|
||
return
|
||
|
||
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
|
||
if sys.platform == "win32":
|
||
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, email_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, email_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, email_path], cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"E-Mail konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
def _open_whatsapp(self):
|
||
"""Startet das WhatsApp-Programm (aza_whatsapp.py) als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
whatsapp_path = os.path.join(script_dir, "aza_whatsapp.py")
|
||
if not os.path.exists(whatsapp_path):
|
||
messagebox.showerror("Fehler", f"aza_whatsapp.py nicht gefunden:\n{whatsapp_path}")
|
||
return
|
||
|
||
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
|
||
if sys.platform == "win32":
|
||
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, whatsapp_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"WhatsApp konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
def _open_docapp(self):
|
||
"""Startet das DocApp-Programm (aza_docapp.py) als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
docapp_path = os.path.join(script_dir, "aza_docapp.py")
|
||
if not os.path.exists(docapp_path):
|
||
messagebox.showerror("Fehler", f"aza_docapp.py nicht gefunden:\n{docapp_path}")
|
||
return
|
||
|
||
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
|
||
if sys.platform == "win32":
|
||
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, docapp_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, docapp_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, docapp_path], cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"DocApp konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
# _open_todo_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_lernkarten_abfrage(self):
|
||
"""Startet das Lernkarten-Abfrage-Programm – Vokabeln und Sätze üben mit Lernzielkontrolle."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
abfrage_path = os.path.join(script_dir, "lernkarten_abfrage.py")
|
||
if not os.path.exists(abfrage_path):
|
||
messagebox.showerror("Fehler", f"lernkarten_abfrage.py nicht gefunden:\n{abfrage_path}")
|
||
return
|
||
kwargs = {"cwd": script_dir}
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([sys.executable, abfrage_path], **kwargs)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
|
||
def _open_arztzeugnis(self):
|
||
"""Öffnet ein Fenster zum Erstellen eines Arztzeugnisses mit Diktat, Drucken, E-Mail und Speichern."""
|
||
|
||
AZ_MIN_W, AZ_MIN_H = 520, 680
|
||
win = tk.Toplevel(self)
|
||
win.title("Arztzeugnis erstellen")
|
||
win.transient(self)
|
||
win.minsize(AZ_MIN_W, AZ_MIN_H)
|
||
win.configure(bg="#E8F4FA")
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
saved_geom = load_toplevel_geometry("arztzeugnis")
|
||
if saved_geom:
|
||
win.geometry(saved_geom)
|
||
else:
|
||
win.geometry(f"{AZ_MIN_W}x{AZ_MIN_H}")
|
||
center_window(win, AZ_MIN_W, AZ_MIN_H)
|
||
|
||
_az_geom_after = [None]
|
||
|
||
def _az_save_geom(event=None):
|
||
if _az_geom_after[0]:
|
||
win.after_cancel(_az_geom_after[0])
|
||
_az_geom_after[0] = win.after(400, lambda: save_toplevel_geometry("arztzeugnis", win.geometry()))
|
||
|
||
win.bind("<Configure>", _az_save_geom)
|
||
|
||
def _az_on_close():
|
||
try:
|
||
save_toplevel_geometry("arztzeugnis", win.geometry())
|
||
except Exception:
|
||
pass
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _az_on_close)
|
||
|
||
# ─── Header ───
|
||
header = tk.Frame(win, bg="#B9ECFA")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="Arztzeugnis", font=("Segoe UI", 14, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(pady=10)
|
||
|
||
# ─── Formular ───
|
||
form = tk.Frame(win, bg="#E8F4FA", padx=16, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
lbl_font = ("Segoe UI", 10, "bold")
|
||
ent_font = ("Segoe UI", 10)
|
||
|
||
def _add_field(parent, label_text, row):
|
||
tk.Label(parent, text=label_text, font=lbl_font, bg="#E8F4FA",
|
||
fg="#1a4d6d", anchor="w").grid(row=row, column=0, sticky="w", pady=(4, 0))
|
||
var = tk.StringVar()
|
||
ent = tk.Entry(parent, textvariable=var, font=ent_font, bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, insertbackground="#1a4d6d")
|
||
ent.grid(row=row, column=1, sticky="ew", padx=(8, 0), pady=(4, 0), ipady=4)
|
||
return var, ent
|
||
|
||
form.columnconfigure(1, weight=1)
|
||
|
||
patient_var, patient_ent = _add_field(form, "Patient:", 0)
|
||
gebdat_var, gebdat_ent = _add_field(form, "Geb.-Datum:", 1)
|
||
datum_var, datum_ent = _add_field(form, "Datum:", 2)
|
||
datum_var.set(datetime.now().strftime("%d.%m.%Y"))
|
||
|
||
tk.Label(form, text="Diagnose:", font=lbl_font, bg="#E8F4FA",
|
||
fg="#1a4d6d", anchor="w").grid(row=3, column=0, sticky="nw", pady=(8, 0))
|
||
diagnose_text = tk.Text(form, font=ent_font, bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, height=3, wrap="word",
|
||
insertbackground="#1a4d6d")
|
||
diagnose_text.grid(row=3, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
|
||
|
||
# ─── Beurteilung / Freitext ───
|
||
tk.Label(win, text="Beurteilung / Zeugnis-Text:", font=lbl_font,
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", padx=16, pady=(8, 0))
|
||
|
||
text_frame = tk.Frame(win, bg="#E8F4FA", padx=16)
|
||
text_frame.pack(fill="both", expand=True, pady=(0, 4))
|
||
|
||
az_text = tk.Text(text_frame, font=("Segoe UI", 11), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
|
||
insertbackground="#1a4d6d", padx=8, pady=6)
|
||
az_text.pack(fill="both", expand=True)
|
||
|
||
# ─── Diktat ───
|
||
az_recorder = [None]
|
||
az_is_recording = [False]
|
||
az_rec_status = tk.StringVar(value="")
|
||
|
||
def _az_toggle_record():
|
||
if az_is_recording[0]:
|
||
az_is_recording[0] = False
|
||
btn_rec.configure(text="⏺ Diktieren", bg="#5B8DB3")
|
||
az_rec_status.set("Transkribiere…")
|
||
recorder = az_recorder[0]
|
||
if recorder:
|
||
selfref = self
|
||
def _do():
|
||
try:
|
||
wav_path = recorder.stop_and_save_wav()
|
||
if wav_path:
|
||
text = selfref.transcribe_wav(wav_path)
|
||
if text:
|
||
def _insert():
|
||
if az_text.get("1.0", "end-1c").strip():
|
||
az_text.insert("insert", " " + text)
|
||
else:
|
||
az_text.insert("1.0", text)
|
||
az_rec_status.set("Diktat eingefügt.")
|
||
win.after(0, _insert)
|
||
else:
|
||
win.after(0, lambda: az_rec_status.set("Kein Text erkannt."))
|
||
else:
|
||
win.after(0, lambda: az_rec_status.set("Aufnahme fehlgeschlagen."))
|
||
except Exception as e:
|
||
win.after(0, lambda: az_rec_status.set(f"Fehler: {e}"))
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
else:
|
||
az_is_recording[0] = True
|
||
btn_rec.configure(text="⏹ Stoppen", bg="#C03030")
|
||
az_rec_status.set("Aufnahme läuft…")
|
||
az_recorder[0] = AudioRecorder()
|
||
az_recorder[0].start()
|
||
|
||
rec_frame = tk.Frame(win, bg="#E8F4FA")
|
||
rec_frame.pack(fill="x", padx=16, pady=(4, 0))
|
||
|
||
btn_rec = tk.Button(rec_frame, text="⏺ Diktieren", font=("Segoe UI", 10, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=14, pady=4, cursor="hand2",
|
||
command=_az_toggle_record)
|
||
btn_rec.pack(side="left")
|
||
|
||
tk.Label(rec_frame, textvariable=az_rec_status, font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#4a8aaa").pack(side="left", padx=(8, 0))
|
||
|
||
# ─── Aktions-Buttons ───
|
||
action_frame = tk.Frame(win, bg="#D4EEF7", padx=16, pady=8)
|
||
action_frame.pack(fill="x", side="bottom")
|
||
|
||
def _btn_style(text, bg_color, active_color, cmd):
|
||
return tk.Button(action_frame, text=text, font=("Segoe UI", 10, "bold"),
|
||
bg=bg_color, fg="#1a4d6d", activebackground=active_color,
|
||
relief="flat", bd=0, padx=14, pady=6, cursor="hand2",
|
||
command=cmd)
|
||
|
||
def _get_az_text():
|
||
lines = []
|
||
lines.append("ARZTZEUGNIS")
|
||
lines.append("=" * 40)
|
||
if patient_var.get().strip():
|
||
lines.append(f"Patient: {patient_var.get().strip()}")
|
||
if gebdat_var.get().strip():
|
||
lines.append(f"Geb.-Datum: {gebdat_var.get().strip()}")
|
||
lines.append(f"Datum: {datum_var.get().strip()}")
|
||
diag = diagnose_text.get("1.0", "end-1c").strip()
|
||
if diag:
|
||
lines.append(f"\nDiagnose:\n{diag}")
|
||
body = az_text.get("1.0", "end-1c").strip()
|
||
if body:
|
||
lines.append(f"\nBeurteilung:\n{body}")
|
||
return "\n".join(lines)
|
||
|
||
def _az_save():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("Speichern", "Kein Inhalt zum Speichern.", parent=win)
|
||
return
|
||
from tkinter import filedialog
|
||
path = filedialog.asksaveasfilename(
|
||
parent=win, title="Arztzeugnis speichern",
|
||
defaultextension=".txt",
|
||
filetypes=[("Textdatei", "*.txt"), ("Alle Dateien", "*.*")],
|
||
initialfile=f"Arztzeugnis_{patient_var.get().strip().replace(' ', '_') or 'Patient'}_{datum_var.get().strip().replace('.', '-')}.txt"
|
||
)
|
||
if path:
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
az_rec_status.set(f"Gespeichert: {os.path.basename(path)}")
|
||
|
||
def _az_print():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("Drucken", "Kein Inhalt zum Drucken.", parent=win)
|
||
return
|
||
import tempfile, subprocess as _sp
|
||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8")
|
||
tmp.write(content)
|
||
tmp.close()
|
||
try:
|
||
if sys.platform == "win32":
|
||
os.startfile(tmp.name, "print")
|
||
else:
|
||
_sp.Popen(["lpr", tmp.name])
|
||
az_rec_status.set("Druckauftrag gesendet.")
|
||
except Exception as e:
|
||
az_rec_status.set(f"Druckfehler: {e}")
|
||
|
||
def _az_email():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("E-Mail", "Kein Inhalt zum Senden.", parent=win)
|
||
return
|
||
import urllib.parse
|
||
subject = urllib.parse.quote(f"Arztzeugnis – {patient_var.get().strip()}")
|
||
body = urllib.parse.quote(content)
|
||
mailto = f"mailto:?subject={subject}&body={body}"
|
||
import webbrowser
|
||
webbrowser.open(mailto)
|
||
az_rec_status.set("E-Mail-Client geöffnet.")
|
||
|
||
_btn_style("💾 Speichern", "#B9ECFA", "#A8DCE8", _az_save).pack(side="left", padx=(0, 6))
|
||
_btn_style("🖨 Drucken", "#C8E8F0", "#B8D8E6", _az_print).pack(side="left", padx=(0, 6))
|
||
_btn_style("✉ E-Mail", "#D4EEF7", "#C4DEE8", _az_email).pack(side="left", padx=(0, 6))
|
||
|
||
# open_diktat_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _toggle_addon_collapse(self, event=None):
|
||
"""Klappt die Add-on-Buttons ein/aus."""
|
||
if self._addon_collapsed:
|
||
self._addon_buttons_container.pack(fill="x")
|
||
self._addon_toggle_label.configure(text="\u25BC Add-on (provisorisch):")
|
||
self._addon_collapsed = False
|
||
else:
|
||
self._addon_buttons_container.pack_forget()
|
||
self._addon_toggle_label.configure(text="\u25B6 Add-on (provisorisch):")
|
||
self._addon_collapsed = True
|
||
|
||
def _toggle_soap_collapse(self, event=None):
|
||
"""Klappt die SOAP-Steuerung (A, S, O, B, D, T, P) ein/aus."""
|
||
if self._soap_collapsed:
|
||
self._soap_container.pack(fill="x", before=self._soap_anchor)
|
||
self._soap_toggle_label.configure(text="\u25BC SOAP:")
|
||
self._soap_collapsed = False
|
||
else:
|
||
self._soap_container.pack_forget()
|
||
self._soap_toggle_label.configure(text="\u25B6 SOAP:")
|
||
self._soap_collapsed = True
|
||
self._autotext_data["soap_collapsed"] = self._soap_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _toggle_dokumente_collapse(self, event=None):
|
||
"""Klappt die Dokumente-Buttons (Brief, Rezept, … Korrektur) ein/aus."""
|
||
if self._dokumente_collapsed:
|
||
self._dokumente_container.pack(fill="x", before=self._dokumente_anchor)
|
||
self._dokumente_toggle_label.configure(text="\u25BC Dokumente:")
|
||
self._dokumente_collapsed = False
|
||
else:
|
||
self._dokumente_container.pack_forget()
|
||
self._dokumente_toggle_label.configure(text="\u25B6 Dokumente:")
|
||
self._dokumente_collapsed = True
|
||
self._autotext_data["dokumente_collapsed"] = self._dokumente_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _toggle_textbloecke_collapse(self, event=None):
|
||
"""Klappt die Textblöcke (1, 2, 3, 4, 5) ein/aus."""
|
||
if self._textbloecke_collapsed:
|
||
# Aufklappen - VOR dem Anker einfügen!
|
||
self._textbloecke_container.pack(fill="x", before=self._textbloecke_anchor)
|
||
self._textbloecke_toggle_label.configure(text="▼ Textblöcke:")
|
||
self._textbloecke_collapsed = False
|
||
else:
|
||
# Einklappen
|
||
self._textbloecke_container.pack_forget()
|
||
self._textbloecke_toggle_label.configure(text="▶ Textblöcke:")
|
||
self._textbloecke_collapsed = True
|
||
|
||
# Speichern
|
||
self._autotext_data["textbloecke_collapsed"] = self._textbloecke_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _update_addon_buttons_visibility(self):
|
||
"""Aktualisiert die Sichtbarkeit der einzelnen Add-on-Buttons basierend auf Einstellungen."""
|
||
addon_buttons = self._autotext_data.get("addon_buttons", {})
|
||
for button_id, row in self._addon_button_rows.items():
|
||
if addon_buttons.get(button_id, True):
|
||
row.pack(fill="x")
|
||
else:
|
||
row.pack_forget()
|
||
|
||
# open_ordner_window -> ausgelagert in Mixin-Modul
|
||
|
||
def open_ki_pruefen(self):
|
||
"""Prüft den oberen Text (KG) per KI auf Logik, Zusammenhänge, Diagnose-Therapie-Passung."""
|
||
kg = self.txt_output.get("1.0", "end").strip()
|
||
if not kg:
|
||
messagebox.showinfo("KI-Kontrolle", "Bitte zuerst Text im oberen Feld (Krankengeschichte) eingeben.")
|
||
return
|
||
if not self.ensure_ready():
|
||
messagebox.showwarning("KI-Kontrolle", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
|
||
return
|
||
self.set_status("KI-Kontrolle…")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": KI_PRUEFEN_PROMPT},
|
||
{"role": "user", "content": kg},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_ki_pruefen_window(result, kg))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _show_ki_pruefen_window(self, result: str, original_kg: str) -> None:
|
||
"""Zeigt das KI-Prüfergebnis in einem Fenster; Button „Übernehme Korrektur“ erzeugt korrigierte KG."""
|
||
win = tk.Toplevel(self)
|
||
win.title("KI-Kontrolle – Logik & Zusammenhänge")
|
||
win.transient(self)
|
||
win.configure(bg="#B9ECFA")
|
||
win.minsize(550, 450)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
setup_window_geometry_saving(win, "ki_kontrolle", 700, 600)
|
||
|
||
add_resize_grip(win, 550, 450)
|
||
add_font_scale_control(win)
|
||
f = ttk.Frame(win, padding=12)
|
||
f.pack(fill="both", expand=True)
|
||
ki_header = ttk.Frame(f)
|
||
ki_header.pack(fill="x", anchor="w")
|
||
ttk.Label(ki_header, text="KI-Kontrolle (Logik, Diagnose/Therapie-Passung):").pack(side="left")
|
||
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=16)
|
||
txt.pack(fill="both", expand=True, pady=(8, 8))
|
||
add_text_font_size_control(ki_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="ki_kontrolle")
|
||
txt.insert("1.0", result.strip())
|
||
self._bind_text_context_menu(txt)
|
||
|
||
btn_row = ttk.Frame(win, padding=(12, 0, 12, 12))
|
||
btn_row.pack(fill="x")
|
||
|
||
def do_uebernehme_korrektur():
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status("Erstelle korrigierte Krankengeschichte…")
|
||
|
||
prompt = """Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
|
||
|
||
Es liegt eine kurze Krankengeschichte und eine Prüfkritik vor.
|
||
Aufgabe: Passe die Krankengeschichte MINIMAL an, sodass die Kritikpunkte adressiert werden. Die Korrektur muss KNAPP bleiben – ähnlich kurz wie die Vorlage.
|
||
|
||
WICHTIG – unbedingt einhalten:
|
||
- Ungefähr gleicher Umfang wie die ursprüngliche KG (keine langen Absätze, keine Aufblähung).
|
||
- Nur das ändern, was die Kritik ausdrücklich verlangt (z. B. ICD-Code präzisieren, fehlende Angabe ergänzen). Keine zusätzlichen Details erfinden (keine konkreten mm-Werte, keine ausformulierten ABCDE-Scores, keine langen Aufklärungs- oder Wundmanagement-Texte, wenn sie nicht in der Vorlage standen).
|
||
- Struktur der Vorlage beibehalten (gleiche Überschriften, Stichpunkte). Keine neuen Abschnitte erfinden, nur vorhandene präzisieren.
|
||
- Ausgabe NUR die korrigierte KG – keine Sterne, keine Meta-Kommentare, keine Wiederholung der Kritik. Diagnosen mit ICD-10-GM in Klammern."""
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
user_content = (
|
||
"AKTUELLE KRANKENGESCHICHTE:\n" + (original_kg or "") + "\n\n"
|
||
"KRITIK / HINWEISE DER KI-PRÜFUNG:\n" + (result or "").strip()
|
||
)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
)
|
||
corrected = resp.choices[0].message.content.strip()
|
||
corrected = re.sub(r"\*+", "", corrected)
|
||
corrected = re.sub(r"#+", "", corrected)
|
||
for prefix in ("Aktuelle Krankengeschichte:", "Aktuelle Krankengeschichte", "Korrigierte Krankengeschichte:", "Korrigierte Krankengeschichte"):
|
||
if corrected.startswith(prefix):
|
||
corrected = corrected[len(prefix):].strip()
|
||
break
|
||
self.after(0, lambda: _apply_corrected(corrected))
|
||
self.after(0, lambda: self.set_status("Korrektur übernommen."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
def _apply_corrected(text):
|
||
try:
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", text)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
import threading
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
RoundedButton(
|
||
btn_row, "Übernehme Korrektur",
|
||
command=do_uebernehme_korrektur,
|
||
width=180, height=28, canvas_bg="#B9ECFA",
|
||
).pack(side="left")
|
||
|
||
def open_pruefen_window(self):
|
||
"""Öffnet das Korrektur-Fenster mit Korrekturen und Interaktionscheck."""
|
||
kg = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
raw = ""
|
||
if kg:
|
||
raw += "KRANKENGESCHICHTE:\n" + kg + "\n\n"
|
||
if transcript:
|
||
raw += "TRANSKRIPT:\n" + transcript + "\n\n"
|
||
raw = raw.strip() or "(Kein Text zum Prüfen)"
|
||
|
||
korrekturen = load_korrekturen()
|
||
corrected, applied = apply_korrekturen(raw, korrekturen)
|
||
display_text = extract_diagnosen_therapie_procedere(corrected)
|
||
|
||
win = tk.Toplevel(self)
|
||
win.title("Korrektur – Korrekturen & Interaktionscheck")
|
||
win.transient(self)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
default_pruefen_w, default_pruefen_h = 480, 550
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
pruefen_geo = load_pruefen_geometry()
|
||
if pruefen_geo:
|
||
if len(pruefen_geo) >= 4:
|
||
w0, h0, x0, y0 = pruefen_geo[0], pruefen_geo[1], pruefen_geo[2], pruefen_geo[3]
|
||
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}+{x0}+{y0}")
|
||
else:
|
||
w0, h0 = pruefen_geo[0], pruefen_geo[1]
|
||
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}")
|
||
center_window(win, max(default_pruefen_w, w0), max(default_pruefen_h, h0))
|
||
else:
|
||
# Keine gespeicherte Position → zentrieren
|
||
win.geometry(f"{default_pruefen_w}x{default_pruefen_h}")
|
||
center_window(win, default_pruefen_w, default_pruefen_h)
|
||
|
||
win.minsize(520, 560)
|
||
win.configure(bg="#B9ECFA")
|
||
|
||
def save_pruefen_size():
|
||
try:
|
||
w, h = win.winfo_width(), win.winfo_height()
|
||
x, y = win.winfo_x(), win.winfo_y()
|
||
if w > 200 and h > 150:
|
||
save_pruefen_geometry(w, h, x, y)
|
||
except Exception:
|
||
pass
|
||
|
||
win.bind("<Configure>", lambda e: win.after(500, save_pruefen_size) if e.widget is win else None)
|
||
add_resize_grip(win, 520, 560)
|
||
add_font_scale_control(win)
|
||
|
||
main_f = ttk.Frame(win, padding=12)
|
||
main_f.pack(fill="both", expand=True)
|
||
|
||
full_corrected = [corrected]
|
||
|
||
inter_header = ttk.Frame(main_f)
|
||
inter_header.pack(fill="x", anchor="w")
|
||
ttk.Label(inter_header, text="Geprüfter Text (Diagnosen, Procedere, Therapie):").pack(side="left")
|
||
txt = ScrolledText(main_f, wrap="word", font=self._text_font, bg="#F5FCFF", height=6)
|
||
txt.pack(fill="both", expand=True, pady=(0, 8))
|
||
add_text_font_size_control(inter_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="interaktionscheck")
|
||
txt.insert("1.0", display_text)
|
||
self._bind_text_context_menu(txt)
|
||
|
||
listbox_corrections = []
|
||
|
||
def refresh_all_list():
|
||
k = load_korrekturen()
|
||
listbox.delete(0, "end")
|
||
listbox_corrections.clear()
|
||
for cat in ("medikamente", "diagnosen"):
|
||
for f, r in (k.get(cat) or {}).items():
|
||
listbox.insert("end", f"«{f}» → «{r}» ({cat})")
|
||
listbox_corrections.append((f, r, cat))
|
||
|
||
def refresh_display():
|
||
new_text, _ = apply_korrekturen(raw, load_korrekturen())
|
||
full_corrected[0] = new_text
|
||
disp = extract_diagnosen_therapie_procedere(new_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
refresh_all_list()
|
||
|
||
ttk.Label(main_f, text="Alle gespeicherten Korrekturen (werden automatisch bei neuer KG angewendet):").pack(anchor="w", pady=(4, 0))
|
||
list_f = ttk.Frame(main_f)
|
||
list_f.pack(fill="x", pady=(0, 8))
|
||
listbox = tk.Listbox(list_f, height=4, font=("Segoe UI", 10))
|
||
listbox.pack(side="left", fill="both", expand=True)
|
||
refresh_all_list()
|
||
|
||
editing_entry = [None]
|
||
|
||
def on_listbox_double_click(evt):
|
||
sel = listbox.curselection()
|
||
if not sel or sel[0] >= len(listbox_corrections):
|
||
return
|
||
f, r, cat = listbox_corrections[sel[0]]
|
||
wrong_inline.set(f)
|
||
right_inline.set(r)
|
||
editing_entry[0] = (f, cat)
|
||
|
||
listbox.bind("<Double-Button-1>", on_listbox_double_click)
|
||
|
||
btn_row = ttk.Frame(main_f)
|
||
btn_row.pack(fill="x", pady=8)
|
||
|
||
def do_export():
|
||
from tkinter import filedialog
|
||
dest = filedialog.asksaveasfilename(
|
||
title="Korrekturen exportieren",
|
||
defaultextension=".json",
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not dest:
|
||
return
|
||
try:
|
||
with open(dest, "w", encoding="utf-8") as f:
|
||
json.dump(load_korrekturen(), f, ensure_ascii=False, indent=2)
|
||
self.set_status(f"Exportiert: {dest}")
|
||
messagebox.showinfo("Export", f"Korrekturen exportiert nach:\n{dest}")
|
||
except Exception as e:
|
||
messagebox.showerror("Export fehlgeschlagen", str(e))
|
||
|
||
def do_import():
|
||
from tkinter import filedialog
|
||
p = filedialog.askopenfilename(
|
||
title="Korrekturen importieren",
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not p:
|
||
return
|
||
try:
|
||
with open(p, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if isinstance(data, dict):
|
||
existing = load_korrekturen()
|
||
for cat, mapping in data.items():
|
||
if isinstance(mapping, dict) and cat in existing:
|
||
existing[cat].update(mapping)
|
||
elif isinstance(mapping, dict):
|
||
existing[cat] = mapping
|
||
save_korrekturen(existing)
|
||
refresh_display()
|
||
self.set_status("Importiert.")
|
||
messagebox.showinfo("Import", "Korrekturen importiert.")
|
||
else:
|
||
messagebox.showerror("Import", "Ungültiges Format.")
|
||
except Exception as e:
|
||
messagebox.showerror("Import fehlgeschlagen", str(e))
|
||
|
||
def extract_meds_from_text(text: str):
|
||
meds = []
|
||
stopwords = ("dass", "diese", "oder", "und", "eine", "der", "die", "das", "therapie", "procedere", "diagnose", "keine", "sowie")
|
||
for m in re.findall(r"[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{3,}", text):
|
||
w = m.strip()
|
||
if w.lower() not in stopwords and not w.isdigit():
|
||
meds.append(w)
|
||
return list(dict.fromkeys(meds))[:12]
|
||
|
||
def do_interaktion():
|
||
text = full_corrected[0]
|
||
meds = extract_meds_from_text(text)
|
||
if not meds:
|
||
messagebox.showinfo("Interaktionscheck", "Keine Medikamentennamen erkannt. Bitte manuell prüfen.")
|
||
return
|
||
if not self.ensure_ready():
|
||
import webbrowser
|
||
q = "+".join(meds[:6]) + "+Interaktion"
|
||
webbrowser.open(f"https://www.google.com/search?q={q}")
|
||
self.set_status("Websuche geöffnet (kein API-Key).")
|
||
return
|
||
self.set_status("Prüfe Interaktionen…")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
med_list = ", ".join(meds)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": INTERACTION_PROMPT},
|
||
{"role": "user", "content": f"Medikamente/Therapien: {med_list}"},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_interaktion_window(meds, result))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler – Websuche als Fallback."))
|
||
self.after(0, lambda: messagebox.showwarning("Interaktionscheck", f"KI-Prüfung fehlgeschlagen.\n{str(e)}\n\nWebsuche wird geöffnet."))
|
||
self.after(0, lambda: _fallback_google(meds))
|
||
|
||
def _fallback_google(meds):
|
||
import webbrowser
|
||
q = "+".join(meds[:6]) + "+Interaktion"
|
||
webbrowser.open(f"https://www.google.com/search?q={q}")
|
||
self.set_status("Websuche geöffnet (Fallback).")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def do_korrigieren():
|
||
t = full_corrected[0]
|
||
if "KRANKENGESCHICHTE:" in t:
|
||
kg_part = t.split("TRANSKRIPT:")[0].replace("KRANKENGESCHICHTE:", "").strip()
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", kg_part)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
elif "TRANSKRIPT:" in t:
|
||
trans_part = t.split("TRANSKRIPT:")[1].split("VORSICHT:")[0].strip()
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", trans_part)
|
||
self.set_status("Korrekturen in Transkript übernommen.")
|
||
elif t and t != "(Kein Text zum Prüfen)":
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", t)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
|
||
RoundedButton(btn_row, "Korrigieren", command=do_korrigieren, width=100, height=28, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
RoundedButton(btn_row, "Interaktionscheck", command=do_interaktion, width=140, height=28, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
btn_row2 = ttk.Frame(main_f, padding=(0, 4, 0, 0))
|
||
btn_row2.pack(fill="x")
|
||
RoundedButton(btn_row2, "Export", command=do_export, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
RoundedButton(btn_row2, "Import", command=do_import, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
bottom_add = ttk.Frame(win, padding=(12, 0, 12, 12))
|
||
bottom_add.pack(fill="x")
|
||
ttk.Label(bottom_add, text="Falsch:").pack(side="left", padx=(0, 4))
|
||
wrong_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=wrong_inline, width=22).pack(side="left", padx=(0, 12))
|
||
ttk.Label(bottom_add, text="→ Richtig:").pack(side="left", padx=4)
|
||
right_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=right_inline, width=22).pack(side="left", padx=(0, 12))
|
||
|
||
def add_inline_and_update():
|
||
w, r = wrong_inline.get().strip(), right_inline.get().strip()
|
||
if not w or not r:
|
||
messagebox.showinfo("Hinweis", "Bitte Falsch- und Richtig-Feld ausfüllen.", parent=win)
|
||
return
|
||
k = load_korrekturen()
|
||
if "medikamente" not in k:
|
||
k["medikamente"] = {}
|
||
if "diagnosen" not in k:
|
||
k["diagnosen"] = {}
|
||
if editing_entry[0]:
|
||
old_f, cat = editing_entry[0]
|
||
if cat in k and old_f in k[cat]:
|
||
del k[cat][old_f]
|
||
k[cat][w] = r
|
||
editing_entry[0] = None
|
||
else:
|
||
k["diagnosen"][w] = r
|
||
save_korrekturen(k)
|
||
|
||
# Korrekturen neu anwenden (auf den Originaltext)
|
||
corrected_text, _ = apply_korrekturen(raw, k)
|
||
full_corrected[0] = corrected_text
|
||
|
||
# Anzeige im Korrektur-Fenster aktualisieren
|
||
disp = extract_diagnosen_therapie_procedere(corrected_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
|
||
# Liste aktualisieren
|
||
refresh_all_list()
|
||
|
||
# Direkt in Hauptfenster-KG und Transkript übernehmen
|
||
t = corrected_text
|
||
if "KRANKENGESCHICHTE:" in t:
|
||
parts = t.split("TRANSKRIPT:")
|
||
kg_part = parts[0].replace("KRANKENGESCHICHTE:", "").strip()
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", kg_part)
|
||
if "TRANSKRIPT:" in t:
|
||
trans_part = t.split("TRANSKRIPT:")[1].split("VORSICHT:")[0].strip()
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", trans_part)
|
||
if t and "KRANKENGESCHICHTE:" not in t and "TRANSKRIPT:" not in t and t != "(Kein Text zum Prüfen)":
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", t)
|
||
|
||
wrong_inline.set("")
|
||
right_inline.set("")
|
||
self.set_status(f"Korrektur gespeichert: «{w}» → «{r}» – direkt in KG angewendet.")
|
||
|
||
btn_save = RoundedButton(
|
||
bottom_add, "Speichern und anwenden",
|
||
command=add_inline_and_update,
|
||
width=200, height=30, canvas_bg="#7EC8E3"
|
||
)
|
||
btn_save.pack(side="left", padx=(12, 0))
|
||
|
||
def _next_phase(self, phase: str):
|
||
self._phase = phase
|
||
self._timer_sec = 0
|
||
|
||
def _stop_timer(self):
|
||
self._timer_running = False
|
||
|
||
def _autocopy_kg(self, text: str):
|
||
if not text or not text.strip():
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(text)
|
||
|
||
def _fill_transcript(self, transcript: str):
|
||
korrekturen = load_korrekturen()
|
||
corrected_transcript, _ = apply_korrekturen(transcript, korrekturen)
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", corrected_transcript)
|
||
if corrected_transcript and corrected_transcript.strip():
|
||
try:
|
||
save_to_ablage("Diktat", corrected_transcript.strip())
|
||
except Exception:
|
||
pass
|
||
|
||
def _fill_kg_and_finish(self, kg: str):
|
||
self._stop_timer()
|
||
self.set_status("Fertig.")
|
||
korrekturen = load_korrekturen()
|
||
kg_corrected, _ = apply_korrekturen(kg, korrekturen)
|
||
cleaned_kg, comments_text = extract_kg_comments(kg_corrected)
|
||
cleaned_kg = strip_kg_warnings(cleaned_kg)
|
||
if cleaned_kg and cleaned_kg.strip():
|
||
try:
|
||
save_to_ablage("KG", cleaned_kg)
|
||
self.set_status("Fertig. KG automatisch gespeichert.")
|
||
except Exception:
|
||
pass
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", cleaned_kg)
|
||
self._autocopy_kg(cleaned_kg)
|
||
|
||
def _diktat_into_widget(self, parent_win, text_widget, status_callback=None):
|
||
"""Öffnet kleines Aufnahme-Fenster, transkribiert und fügt Text an Cursorposition in text_widget ein."""
|
||
if not self.ensure_ready():
|
||
return
|
||
rec_win = tk.Toplevel(parent_win)
|
||
rec_win.title("Diktat – an Cursorposition einfügen")
|
||
rec_win.transient(parent_win)
|
||
rec_win.geometry("420x150")
|
||
rec_win.configure(bg="#B9ECFA")
|
||
rec_win.minsize(350, 130)
|
||
rec_win.attributes("-topmost", True)
|
||
self._register_window(rec_win)
|
||
add_resize_grip(rec_win, 350, 130)
|
||
apply_initial_scaling_to_window(rec_win)
|
||
rf = ttk.Frame(rec_win, padding=16)
|
||
rf.pack(fill="both", expand=True)
|
||
status_var = tk.StringVar(value="Bereit. Cursor im Text setzen, dann Aufnahme starten.")
|
||
ttk.Label(rf, textvariable=status_var).pack(pady=(0, 12))
|
||
diktat_rec = [None]
|
||
is_rec = [False]
|
||
|
||
def toggle_rec():
|
||
if not diktat_rec[0]:
|
||
diktat_rec[0] = AudioRecorder()
|
||
rec = diktat_rec[0]
|
||
if not is_rec[0]:
|
||
try:
|
||
rec.start()
|
||
is_rec[0] = True
|
||
btn_rec.configure(text="⏹ Stoppen")
|
||
status_var.set("Aufnahme läuft…")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
rec_win.destroy()
|
||
else:
|
||
is_rec[0] = False
|
||
btn_rec.configure(text="⏺ Aufnahme starten")
|
||
status_var.set("Transkribiere…")
|
||
|
||
def worker():
|
||
try:
|
||
wav_path = rec.stop_and_save_wav()
|
||
transcript_text = self.transcribe_wav(wav_path)
|
||
transcript_text = self._diktat_apply_punctuation(transcript_text)
|
||
try:
|
||
if os.path.exists(wav_path):
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda: _insert_done(transcript_text))
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
self.after(0, lambda: rec_win.destroy())
|
||
|
||
def _insert_done(text):
|
||
diktat_rec[0] = None
|
||
if text:
|
||
idx = text_widget.index(tk.INSERT)
|
||
text_widget.insert(idx, text)
|
||
if status_callback:
|
||
status_callback("Diktat an Cursorposition eingefügt.")
|
||
status_var.set("Fertig.")
|
||
rec_win.destroy()
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
btn_rec = RoundedButton(
|
||
rf, "⏺ Aufnahme starten", command=toggle_rec,
|
||
width=160, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
btn_rec.pack()
|
||
|
||
def ensure_ready(self):
|
||
if not self.client:
|
||
messagebox.showerror(
|
||
"API-Key fehlt",
|
||
"OPENAI_API_KEY ist nicht gesetzt.\n\n"
|
||
"Lege eine '.env' Datei an (im gleichen Ordner wie dieses Script):\n"
|
||
"OPENAI_API_KEY=sk-...\n"
|
||
)
|
||
return False
|
||
return True
|
||
|
||
def toggle_record(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
if not self.is_recording:
|
||
self._new_session()
|
||
try:
|
||
self.recorder.start()
|
||
self.is_recording = True
|
||
self._recording_mode = "new"
|
||
self.btn_record.configure(text="⏹ Stopp")
|
||
self.btn_record_append.configure(text="⏺ Korrigieren")
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏹ Stopp")
|
||
except Exception:
|
||
pass
|
||
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini_korr:
|
||
try:
|
||
if mini_korr.winfo_exists():
|
||
mini_korr.configure(text="⏺ Korrigieren")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Aufnahme läuft… (sprich jetzt)")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
self.set_status("Bereit.")
|
||
else:
|
||
self._stop_and_process_recording()
|
||
|
||
def _toggle_record_append(self):
|
||
"""Aufnahme korrigieren: ergänzt die bestehende KG, ohne sie zu löschen."""
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
if not self.is_recording:
|
||
try:
|
||
self.recorder.start()
|
||
self.is_recording = True
|
||
self._recording_mode = "append"
|
||
self.btn_record_append.configure(text="⏹ Stopp")
|
||
self.btn_record.configure(text="⏺ Start")
|
||
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini_korr:
|
||
try:
|
||
if mini_korr.winfo_exists():
|
||
mini_korr.configure(text="⏹ Stopp")
|
||
except Exception:
|
||
pass
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏺ Start")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Korrektur-Aufnahme läuft… (sprich jetzt)")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
self.set_status("Bereit.")
|
||
else:
|
||
self._stop_and_process_recording()
|
||
|
||
def _stop_and_process_recording(self):
|
||
"""Stoppt die Aufnahme und verarbeitet sie (neu oder Korrektur)."""
|
||
self.is_recording = False
|
||
mode = getattr(self, "_recording_mode", "new")
|
||
self.btn_record.configure(text="⏺ Start")
|
||
self.btn_record_append.configure(text="⏺ Korrigieren")
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏺ Start")
|
||
except Exception:
|
||
pass
|
||
mini = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini:
|
||
try:
|
||
if mini.winfo_exists():
|
||
mini.configure(text="⏺ Korrigieren")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Stoppe Aufnahme…")
|
||
|
||
existing_transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
existing_kg = self.txt_output.get("1.0", "end").strip()
|
||
|
||
def worker():
|
||
def _safe_after(fn):
|
||
try:
|
||
if self.winfo_exists():
|
||
self.after(0, fn)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
wav_path = self.recorder.stop_and_save_wav()
|
||
self.last_wav_path = wav_path
|
||
|
||
new_transcript = self.transcribe_wav(wav_path)
|
||
_safe_after(lambda: self._next_phase("kg"))
|
||
|
||
if mode == "append" and existing_transcript:
|
||
full_transcript = existing_transcript + "\n\n" + new_transcript
|
||
if existing_kg:
|
||
kg = strip_kg_warnings(self.merge_kg(existing_kg, full_transcript))
|
||
else:
|
||
kg = strip_kg_warnings(self.summarize_text(full_transcript))
|
||
else:
|
||
full_transcript = new_transcript
|
||
kg = strip_kg_warnings(self.summarize_text(full_transcript))
|
||
|
||
_safe_after(lambda: self._fill_transcript(full_transcript))
|
||
_safe_after(lambda: self._fill_kg_and_finish(kg))
|
||
except Exception as e:
|
||
_safe_after(lambda: self._stop_timer())
|
||
_safe_after(lambda: self.set_status(f"Fehler: {e}" if e else "Bereit."))
|
||
finally:
|
||
try:
|
||
if self.last_wav_path and os.path.exists(self.last_wav_path):
|
||
os.remove(self.last_wav_path)
|
||
except Exception:
|
||
pass
|
||
self.last_wav_path = None
|
||
|
||
self._start_timer("transcribe")
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
_WHISPER_MEDICAL_PROMPT = (
|
||
"Medizinische Dokumentation auf Deutsch. "
|
||
"Capillitium, Fotodynamische Therapie, PDT, Basalzellkarzinom, Plattenepithelkarzinom, "
|
||
"Spinaliom, Spinaliom der Haut, Spinalzellkarzinom, "
|
||
"Melanom, Exzision, Biopsie, Kryotherapie, Kürettage, Histologie, Dermatoskopie, "
|
||
# Nävi / Muttermale
|
||
"Nävus, Nävi, Naevus, Naevi, Nävuszellnävus, dysplastischer Nävus, "
|
||
"Compound-Nävus, junktionaler Nävus, dermaler Nävus, Spitz-Nävus, "
|
||
# Effloreszenzen
|
||
"Erythem, Papel, Pustel, Makula, Plaque, Nodulus, Nodus, "
|
||
"Vesikel, Bulla, Erosion, Ulkus, Rhagade, Kruste, Squama, "
|
||
"Effloreszenzen, Lichenifikation, Exkoriation, "
|
||
# Häufige Diagnosen Dermatologie
|
||
"seborrhoische Keratose, Fibrom, Lipom, Atherom, Epidermoidzyste, "
|
||
"Verruca vulgaris, Verrucae, Kondylome, Molluscum contagiosum, "
|
||
"Hämangiom, Angiom, Keloid, hypertrophe Narbe, "
|
||
"Tinea, Mykose, Onychomykose, Herpes simplex, Herpes zoster, "
|
||
"Erysipel, Impetigo, Abszess, Phlegmone, Skabies, "
|
||
"Pemphigus, Pemphigoid, Lichen ruber, Lichen sclerosus, "
|
||
"Vitiligo, Pruritus, Prurigo, Mykosis fungoides, "
|
||
# Eingriffe / Befunde
|
||
"Shave-Biopsie, Stanzbiopsie, Inzisionsbiopsie, "
|
||
"Breslow-Dicke, Clark-Level, Sentinel-Lymphknoten, "
|
||
"Auflichtmikroskopie, Phototherapie, UVB, PUVA, "
|
||
# Allgemeinmedizin
|
||
"Anamnese, Diagnose, Therapie, Procedere, subjektiv, objektiv, "
|
||
"Abdomen, Thorax, Extremitäten, zervikal, lumbal, thorakal, sakral, "
|
||
"Sonographie, Röntgen, MRI, CT, EKG, Laborwerte, Blutbild, "
|
||
"Hypertonie, Diabetes mellitus, Hypercholesterinämie, Hypothyreose, "
|
||
"Antikoagulation, Thrombozytenaggregationshemmer, NSAR, ACE-Hemmer, "
|
||
"Immunsuppression, Kortikosteroide, Biologika, Methotrexat, "
|
||
"Psoriasis, Ekzem, Dermatitis, Urtikaria, Alopezie, Akne, Rosazea, "
|
||
"Aktinische Keratose, Morbus Bowen, Lentigo maligna, "
|
||
"Januar 2026, Februar 2026, März 2026, April 2026, Mai 2026, "
|
||
"Status nach, Z.n., s/p, i.v., p.o., s.c., "
|
||
"ICD-10, SOAP, Krankengeschichte, Kostengutsprache, Arztbrief."
|
||
)
|
||
|
||
_WHISPER_PROMPT_PREFIX = "Medizinische Dokumentation auf Deutsch"
|
||
|
||
def _transcribe_local(self, wav_path: str) -> str:
|
||
"""Lokale OpenAI-Transkription."""
|
||
with open(wav_path, "rb") as f:
|
||
is_gpt_transcribe = "gpt-" in TRANSCRIBE_MODEL
|
||
params = dict(model=TRANSCRIBE_MODEL, file=f, language="de")
|
||
if is_gpt_transcribe:
|
||
params["prompt"] = self._WHISPER_MEDICAL_PROMPT
|
||
else:
|
||
params["prompt"] = self._WHISPER_MEDICAL_PROMPT
|
||
params["temperature"] = 0.0
|
||
resp = self.client.audio.transcriptions.create(**params)
|
||
text = getattr(resp, "text", "")
|
||
if text is None:
|
||
text = ""
|
||
if text.strip().startswith(self._WHISPER_PROMPT_PREFIX):
|
||
text = ""
|
||
estimated_tokens = len(text) // 4
|
||
add_token_usage(estimated_tokens)
|
||
self.after(0, self.update_token_display)
|
||
return text
|
||
|
||
@staticmethod
|
||
def _read_backend_url_file():
|
||
try:
|
||
path = os.path.join(os.path.dirname(__file__), "backend_url.txt")
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
url = f.read().strip()
|
||
return url if url else None
|
||
except Exception:
|
||
return None
|
||
|
||
BACKEND_URL = os.getenv("MEDWORK_BACKEND_URL") or _read_backend_url_file.__func__() or "http://127.0.0.1:8001"
|
||
_TRANSCRIBE_BACKEND_TIMEOUT = 8
|
||
def _read_backend_token_file():
|
||
try:
|
||
path = os.path.join(os.path.dirname(__file__), "backend_token.txt")
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
token = f.read().strip()
|
||
return token if token else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
_BACKEND_API_TOKEN = (
|
||
os.getenv("MEDWORK_API_TOKEN")
|
||
or _read_backend_token_file()
|
||
or ""
|
||
)
|
||
|
||
def transcribe_file_via_backend_with_fallback(self, audio_path: str) -> str:
|
||
"""Backend-Transkription (8s Timeout) mit Silent Fallback auf lokal.
|
||
Wird aus Worker-Threads aufgerufen, blockiert UI nie."""
|
||
if not self._BACKEND_API_TOKEN:
|
||
return self._transcribe_local(audio_path)
|
||
try:
|
||
headers = {"X-Api-Token": self._BACKEND_API_TOKEN}
|
||
with open(audio_path, "rb") as f:
|
||
r = _requests.post(
|
||
f"{self.BACKEND_URL}/v1/transcribe",
|
||
files={"file": (os.path.basename(audio_path), f, "audio/wav")},
|
||
data={"language": "de"},
|
||
headers=headers,
|
||
timeout=self._TRANSCRIBE_BACKEND_TIMEOUT,
|
||
)
|
||
if not r.ok:
|
||
raise RuntimeError(f"HTTP {r.status_code}")
|
||
|
||
try:
|
||
data = r.json()
|
||
except ValueError:
|
||
raise RuntimeError("Ungültige JSON-Antwort")
|
||
|
||
if not data.get("success"):
|
||
raise RuntimeError("success != true")
|
||
|
||
text = data.get("transcript", "").strip()
|
||
if not text:
|
||
raise RuntimeError("Leeres Transkript")
|
||
|
||
estimated_tokens = len(text) // 4
|
||
add_token_usage(estimated_tokens)
|
||
try:
|
||
self.after(0, self.update_token_display)
|
||
except Exception:
|
||
pass
|
||
dur = data.get("duration_ms", "?")
|
||
try:
|
||
self.after(0, lambda: self.set_status(
|
||
f"Transkription via Backend ({dur} ms)"))
|
||
except Exception:
|
||
pass
|
||
return text
|
||
|
||
except Exception:
|
||
try:
|
||
self.after(0, lambda: self.set_status(
|
||
"Server nicht erreichbar \u2013 lokales Diktat aktiv."))
|
||
except Exception:
|
||
pass
|
||
|
||
return self._transcribe_local(audio_path)
|
||
|
||
def transcribe_wav(self, wav_path: str) -> str:
|
||
return self.transcribe_file_via_backend_with_fallback(wav_path)
|
||
|
||
def call_chat_completion(self, **kwargs):
|
||
"""Wrapper für chat.completions.create mit automatischem Token-Tracking."""
|
||
resp = self.client.chat.completions.create(**kwargs)
|
||
if hasattr(resp, 'usage'):
|
||
total_tokens = getattr(resp.usage, 'total_tokens', 0)
|
||
if total_tokens > 0:
|
||
add_token_usage(total_tokens)
|
||
self.after(0, self.update_token_display)
|
||
return resp
|
||
|
||
@staticmethod
|
||
def _diktat_apply_punctuation(text: str) -> str:
|
||
"""Ersetzt gesprochene Satzzeichen/Anweisungen durch echte Zeichen (nur im Diktat-Fenster)."""
|
||
if not text or not text.strip():
|
||
return text
|
||
t = text
|
||
# Zeilenumbrüche / Absätze zuerst
|
||
t = re.sub(r"\s+neuer\s+Absatz\s*", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+neue\s+Zeile\s*", "\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Zeilenumbruch\s*", "\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatz\s+", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatz\s*$", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatzzeichen\s*", "\n\n", t, flags=re.IGNORECASE)
|
||
# Satzzeichen (werden nicht ausgeschrieben, sondern als Zeichen eingefügt)
|
||
t = re.sub(r"\s+Punkt\s+", ". ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Punkt\s*$", ".", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Komma\s+", ", ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Komma\s*$", ",", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Semikolon\s+", "; ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Semikolon\s*$", ";", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Strichpunkt\s+", "; ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Strichpunkt\s*$", ";", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Doppelpunkt\s+", ": ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Doppelpunkt\s*$", ":", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Fragezeichen\s+", "? ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Fragezeichen\s*$", "?", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Ausrufezeichen\s+", "! ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Ausrufezeichen\s*$", "!", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Gedankenstrich\s+", " – ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Gedankenstrich\s*$", " –", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Bindestrich\s+", "-", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Schrägstrich\s+", "/", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Klammer\s+auf\s+", " (", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Klammer\s+zu\s+", ") ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Auslassungspunkte\s+", "… ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Auslassungspunkte\s*$", "…", t, flags=re.IGNORECASE)
|
||
# Ordinalzahlen: erstens → 1., zweitens → 2., usw.
|
||
ord_map = [
|
||
(r"\b(erstens)\b", "1."),
|
||
(r"\b(zweitens)\b", "2."),
|
||
(r"\b(drittens)\b", "3."),
|
||
(r"\b(viertens)\b", "4."),
|
||
(r"\b(f\u00fcnftens)\b", "5."),
|
||
(r"\b(sechstens)\b", "6."),
|
||
(r"\b(siebtens)\b", "7."),
|
||
(r"\b(achtens)\b", "8."),
|
||
(r"\b(neuntens)\b", "9."),
|
||
(r"\b(zehntens)\b", "10."),
|
||
]
|
||
for pat, repl in ord_map:
|
||
t = re.sub(pat, repl, t, flags=re.IGNORECASE)
|
||
# Gesprochene Jahreszahlen in Ziffern umwandeln
|
||
_year_words = {
|
||
"zweitausendzwanzig": "2020", "zweitausendeinundzwanzig": "2021",
|
||
"zweitausendzweiundzwanzig": "2022", "zweitausenddreiundzwanzig": "2023",
|
||
"zweitausendvierundzwanzig": "2024", "zweitausendf\u00fcnfundzwanzig": "2025",
|
||
"zweitausendsechsundzwanzig": "2026", "zweitausendsiebenundzwanzig": "2027",
|
||
"zweitausendachtundzwanzig": "2028", "zweitausendneunundzwanzig": "2029",
|
||
"zweitausenddreissig": "2030", "zweitausenddrei\u00dfig": "2030",
|
||
"neunzehnhundertneunzig": "1990",
|
||
"zweitausend": "2000",
|
||
}
|
||
for word, year in sorted(_year_words.items(), key=lambda x: -len(x[0])):
|
||
t = re.sub(r"\b" + word + r"\b", year, t, flags=re.IGNORECASE)
|
||
# Gesprochene Tageszahlen vor Monaten: "den dritten Januar" → "den 3. Januar"
|
||
_day_words = {
|
||
"ersten": "1.", "zweiten": "2.", "dritten": "3.", "vierten": "4.",
|
||
"f\u00fcnften": "5.", "sechsten": "6.", "siebten": "7.", "achten": "8.",
|
||
"neunten": "9.", "zehnten": "10.", "elften": "11.", "zw\u00f6lften": "12.",
|
||
"dreizehnten": "13.", "vierzehnten": "14.", "f\u00fcnfzehnten": "15.",
|
||
"sechzehnten": "16.", "siebzehnten": "17.", "achtzehnten": "18.",
|
||
"neunzehnten": "19.", "zwanzigsten": "20.", "einundzwanzigsten": "21.",
|
||
"zweiundzwanzigsten": "22.", "dreiundzwanzigsten": "23.",
|
||
"vierundzwanzigsten": "24.", "f\u00fcnfundzwanzigsten": "25.",
|
||
"sechsundzwanzigsten": "26.", "siebenundzwanzigsten": "27.",
|
||
"achtundzwanzigsten": "28.", "neunundzwanzigsten": "29.",
|
||
"dreissigsten": "30.", "drei\u00dfigsten": "30.", "einunddreissigsten": "31.",
|
||
"einunddrei\u00dfigsten": "31.",
|
||
}
|
||
_months = (r"(?:Januar|Februar|M\u00e4rz|April|Mai|Juni|Juli|August|"
|
||
r"September|Oktober|November|Dezember)")
|
||
for word, day in sorted(_day_words.items(), key=lambda x: -len(x[0])):
|
||
t = re.sub(r"\b" + word + r"\s+" + _months,
|
||
lambda m: day + " " + m.group(0).split()[-1], t, flags=re.IGNORECASE)
|
||
return t
|
||
|
||
def _build_system_prompt(self, base_prompt: str) -> str:
|
||
"""Baut den System-Prompt zusammen: Vorlage (höchste Priorität) + Detail-Level + Basis-Prompt."""
|
||
template = load_templates_text().strip()
|
||
detail_level = load_kg_detail_level()
|
||
detail_instr = get_kg_detail_instruction(detail_level)
|
||
|
||
parts = []
|
||
if template:
|
||
parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (hat höchste Priorität, MUSS vollständig eingehalten werden):\n"
|
||
"Die folgende Vorlage definiert verbindlich, wie die Krankengeschichte aufgebaut und formuliert werden soll. "
|
||
"Struktur, Reihenfolge, Stil und alle Vorgaben aus dieser Vorlage haben Vorrang vor allen anderen Anweisungen. "
|
||
"Baue die Krankengeschichte ZUERST nach dieser Vorlage auf, dann ergänze fehlende Standardabschnitte.\n\n"
|
||
f"{template}"
|
||
)
|
||
parts.append(base_prompt)
|
||
if detail_instr:
|
||
parts.append(detail_instr)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
parts.append(soap_instr)
|
||
soap_visibility = load_soap_visibility()
|
||
soap_order = load_soap_order()
|
||
order_instr = get_soap_order_instruction(soap_order, soap_visibility)
|
||
if order_instr:
|
||
parts.append(order_instr)
|
||
vis_instr = get_soap_visibility_instruction(soap_visibility)
|
||
if vis_instr:
|
||
parts.append(vis_instr)
|
||
return "\n\n".join(parts)
|
||
|
||
def summarize_text(self, transcript: str) -> str:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
system_content = self._build_system_prompt(SYSTEM_PROMPT)
|
||
|
||
user_text = f"""TRANSKRIPT:
|
||
{transcript}
|
||
"""
|
||
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def merge_kg(self, existing_kg: str, full_transcript: str) -> str:
|
||
"""Ergänzt die bestehende KG um neue Informationen aus dem Transkript."""
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
system_content = self._build_system_prompt(MERGE_PROMPT)
|
||
user_text = f"""BESTEHENDE KRANKENGESCHICHTE:
|
||
{existing_kg}
|
||
|
||
VOLLSTÄNDIGES TRANSKRIPT (bisher + Ergänzung):
|
||
{full_transcript}
|
||
|
||
Aktualisiere die KG: neue Informationen aus dem Transkript in die passenden Abschnitte einfügen, gleiche Überschriften beibehalten."""
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def make_kg_from_text(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not transcript:
|
||
messagebox.showinfo("Hinweis", "Bitte zuerst diktieren oder Text in das Transkript-Feld einfügen.")
|
||
return
|
||
|
||
self._start_timer("kg")
|
||
|
||
def worker():
|
||
try:
|
||
kg = strip_kg_warnings(self.summarize_text(transcript))
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
def _save_kg_and_show(k):
|
||
if k and str(k).strip():
|
||
try:
|
||
save_to_ablage("KG", str(k).strip())
|
||
except Exception:
|
||
pass
|
||
self._show_text_window("KG erneut erstellen", k, buttons="kg")
|
||
self.after(0, lambda k=kg: _save_kg_and_show(k))
|
||
except Exception as e:
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
# ─── KG bearbeiten: Kürzer / Ausführlicher / Vorlage ───
|
||
|
||
def _update_kg_detail_display(self):
|
||
"""Aktualisiert die Anzeige des Detail-Levels auf den Buttons."""
|
||
level = load_kg_detail_level()
|
||
if level < 0:
|
||
self.btn_kg_kuerzer.configure(text=f"Kürzer ({level})")
|
||
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
|
||
elif level > 0:
|
||
self.btn_kg_kuerzer.configure(text="Kürzer")
|
||
self.btn_kg_ausfuehrlicher.configure(text=f"Ausführlicher (+{level})")
|
||
else:
|
||
self.btn_kg_kuerzer.configure(text="Kürzer")
|
||
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
|
||
|
||
def _rebuild_soap_section_controls(self):
|
||
"""Baut die SOAP-Section-Controls neu auf (Reihenfolge + Sichtbarkeit aus Vorlage)."""
|
||
soap_inner = self._soap_inner
|
||
bg = self._soap_bg
|
||
for w in soap_inner.winfo_children():
|
||
w.destroy()
|
||
self._soap_section_labels.clear()
|
||
|
||
order = load_soap_order()
|
||
visibility = load_soap_visibility()
|
||
|
||
_FG = "#1a4d6d"
|
||
_ARROW_FG = "#7AAFC8"
|
||
_ARROW_HOVER = "#1a4d6d"
|
||
|
||
for sec_key in order:
|
||
if not visibility.get(sec_key, True):
|
||
continue
|
||
sec_frame = tk.Frame(soap_inner, bg=bg)
|
||
sec_frame.pack(side="left", padx=(0, 14))
|
||
|
||
lv = self._soap_section_levels.get(sec_key, 0)
|
||
lbl_text = sec_key if lv == 0 else f"{sec_key} {lv:+d}"
|
||
|
||
sec_label = tk.Label(sec_frame, text=lbl_text, font=("Segoe UI", 9, "bold"),
|
||
bg=bg, fg=_FG, anchor="center", width=4, pady=1)
|
||
sec_label.pack(side="left")
|
||
self._soap_section_labels[sec_key] = sec_label
|
||
|
||
btn_up = tk.Label(sec_frame, text="\u25B2", font=("Segoe UI", 8),
|
||
bg=bg, fg=_ARROW_FG, cursor="hand2",
|
||
bd=0, highlightthickness=0, padx=0, pady=0)
|
||
btn_up.pack(side="left", padx=(1, 0))
|
||
|
||
btn_down = tk.Label(sec_frame, text="\u25BC", font=("Segoe UI", 8),
|
||
bg=bg, fg=_ARROW_FG, cursor="hand2",
|
||
bd=0, highlightthickness=0, padx=0, pady=0)
|
||
btn_down.pack(side="left", padx=(0, 0))
|
||
|
||
def make_adjust(key, delta):
|
||
return lambda e: self._adjust_soap_section(key, delta)
|
||
|
||
btn_up.bind("<Button-1>", make_adjust(sec_key, +1))
|
||
btn_down.bind("<Button-1>", make_adjust(sec_key, -1))
|
||
|
||
def make_hover(w, enter_fg, leave_fg):
|
||
w.bind("<Enter>", lambda e, ww=w, c=enter_fg: ww.configure(fg=c))
|
||
w.bind("<Leave>", lambda e, ww=w, c=leave_fg: ww.configure(fg=c))
|
||
|
||
make_hover(btn_up, _ARROW_HOVER, _ARROW_FG)
|
||
make_hover(btn_down, _ARROW_HOVER, _ARROW_FG)
|
||
|
||
def make_reset(key):
|
||
return lambda e: self._adjust_soap_section(key, 0, reset=True)
|
||
|
||
sec_label.bind("<Double-Button-1>", make_reset(sec_key))
|
||
sec_label.configure(cursor="hand2")
|
||
|
||
reset_lbl = tk.Label(soap_inner, text=" \u21BA ", font=("Segoe UI", 10),
|
||
bg=bg, fg="#7AAFC8", cursor="hand2")
|
||
reset_lbl.pack(side="left", padx=(10, 0))
|
||
reset_lbl.bind("<Button-1>", lambda e: self._reset_all_soap_sections())
|
||
reset_lbl.bind("<Enter>", lambda e: reset_lbl.configure(fg="#1a4d6d"))
|
||
reset_lbl.bind("<Leave>", lambda e: reset_lbl.configure(fg="#7AAFC8"))
|
||
|
||
def _update_soap_section_display(self):
|
||
"""Aktualisiert die Anzeige aller SOAP-Section-Labels."""
|
||
for key in _SOAP_SECTIONS:
|
||
lbl = self._soap_section_labels.get(key)
|
||
if lbl:
|
||
lv = self._soap_section_levels.get(key, 0)
|
||
lbl.configure(text=key if lv == 0 else f"{key} {lv:+d}")
|
||
|
||
def _adjust_soap_section(self, key: str, delta: int, reset: bool = False):
|
||
"""Passt eine SOAP-Abschnitts-Detailstufe an und wendet optional sofort auf die aktuelle KG an."""
|
||
if reset:
|
||
self._soap_section_levels[key] = 0
|
||
else:
|
||
old = self._soap_section_levels.get(key, 0)
|
||
self._soap_section_levels[key] = max(-3, min(3, old + delta))
|
||
save_soap_section_levels(self._soap_section_levels)
|
||
self._update_soap_section_display()
|
||
lv = self._soap_section_levels[key]
|
||
name = _SOAP_LABELS[key]
|
||
if reset:
|
||
self.set_status(f"{name}: zurückgesetzt auf Standard.")
|
||
elif lv == 0:
|
||
self.set_status(f"{name}: Standard-Länge.")
|
||
else:
|
||
direction = "kürzer" if lv < 0 else "ausführlicher"
|
||
self.set_status(f"{name}: Stufe {lv:+d} ({direction}) – wird bei jeder KG-Erstellung berücksichtigt.")
|
||
|
||
# Sofort auf aktuelle KG anwenden, wenn vorhanden
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if kg_text and not reset:
|
||
self._apply_soap_section_edit(key, delta)
|
||
|
||
def _reset_all_soap_sections(self):
|
||
"""Setzt alle SOAP-Section-Levels auf 0 zurück."""
|
||
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
|
||
save_soap_section_levels(self._soap_section_levels)
|
||
self._update_soap_section_display()
|
||
self.set_status("Alle SOAP-Abschnittsstufen zurückgesetzt.")
|
||
|
||
def _apply_soap_section_edit(self, key: str, delta: int):
|
||
"""Wendet eine Kürzung/Erweiterung auf einen einzelnen SOAP-Abschnitt der aktuellen KG an."""
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
name = _SOAP_LABELS[key]
|
||
action = "gekürzt" if delta < 0 else "erweitert"
|
||
self.set_status(f"{name} wird {action}…")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
|
||
other_sections = ", ".join(
|
||
n for k, n in _SOAP_LABELS.items() if k != key)
|
||
if delta < 0:
|
||
task = (
|
||
f"Kürze LEICHT NUR den Abschnitt '{name}' in der folgenden Krankengeschichte. "
|
||
f"Formuliere die vorhandenen Punkte knapper – gleiche Fakten, kürzere Wortwahl. "
|
||
f"Stil beibehalten (Stichpunkte bleiben Stichpunkte). Nur eine kleine Reduktion. "
|
||
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
|
||
f"Gib die KOMPLETTE Krankengeschichte aus."
|
||
)
|
||
else:
|
||
task = (
|
||
f"Formuliere NUR den Abschnitt '{name}' in der folgenden Krankengeschichte LEICHT ausführlicher. "
|
||
f"Vorhandene Stichpunkte in vollständigere Sätze umformulieren. "
|
||
f"WICHTIG: NUR die bereits vorhandenen Informationen ausführlicher formulieren – "
|
||
f"KEINE neuen Fakten, Befunde, Werte oder Details erfinden! "
|
||
f"Nichts hinzufügen, was nicht bereits im Text steht. "
|
||
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
|
||
f"Gib die KOMPLETTE Krankengeschichte aus."
|
||
)
|
||
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" + task +
|
||
"\nDiagnosen mit ICD-10-GM-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die komplette KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": kg_text},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"{name} {action}"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _kg_kuerzer(self):
|
||
"""Kürzt die aktuelle KG UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
|
||
level = load_kg_detail_level()
|
||
new_level = max(-3, level - 1)
|
||
save_kg_detail_level(new_level)
|
||
self._update_kg_detail_display()
|
||
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
self.set_status(f"KG-Stil gespeichert: Stufe {new_level} (kürzer). Nächste KG wird kürzer erstellt.")
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status(f"KG wird gekürzt… (Stufe {new_level})")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
|
||
"Kürze die folgende Krankengeschichte deutlich. "
|
||
"Fasse Stichpunkte zusammen, entferne Wiederholungen und "
|
||
"reduziere auf das Wesentliche. Behalte die SOAP-Struktur bei "
|
||
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
|
||
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die gekürzte KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": kg_text},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"Gekürzt (Stufe {new_level})"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _kg_ausfuehrlicher(self):
|
||
"""Macht die aktuelle KG ausführlicher UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
|
||
level = load_kg_detail_level()
|
||
new_level = min(3, level + 1)
|
||
save_kg_detail_level(new_level)
|
||
self._update_kg_detail_display()
|
||
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
self.set_status(f"KG-Stil gespeichert: Stufe +{new_level} (ausführlicher). Nächste KG wird ausführlicher erstellt.")
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status(f"KG wird ausführlicher… (Stufe +{new_level})")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
|
||
"Formuliere die folgende Krankengeschichte ausführlicher. "
|
||
"Wandle vorhandene Stichpunkte in vollständige Sätze um. "
|
||
"STRIKT NUR vorhandene Informationen ausführlicher ausformulieren – "
|
||
"KEINE neuen Fakten, Befunde, Werte, Mengenangaben oder klinische Details erfinden! "
|
||
"Nichts hinzufügen, was nicht bereits im Text steht. "
|
||
"Behalte die SOAP-Struktur bei "
|
||
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
|
||
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die ausführlichere KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
|
||
user_content = f"KRANKENGESCHICHTE:\n{kg_text}"
|
||
if transcript:
|
||
user_content += f"\n\nORIGINAL-TRANSKRIPT (als Quelle für Details):\n{transcript}"
|
||
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"Ausführlicher (Stufe +{new_level})"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _apply_kg_edit(self, new_kg: str, label: str):
|
||
"""Wendet eine KG-Bearbeitung an (Kürzer/Ausführlicher) und zeigt das Ergebnis."""
|
||
if new_kg and new_kg.strip():
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", new_kg.strip())
|
||
try:
|
||
save_to_ablage("KG", new_kg.strip())
|
||
except Exception:
|
||
pass
|
||
self.set_status(f"KG {label}.")
|
||
else:
|
||
self.set_status("Keine Änderung erhalten.")
|
||
|
||
def _open_kg_vorlage(self):
|
||
"""Öffnet ein Fenster, in dem der Arzt eine Vorlage für die KG-Erstellung definieren kann."""
|
||
VOR_W, VOR_H = 540, 620
|
||
win = tk.Toplevel(self)
|
||
win.title("Vorlage – KG-Erstellung")
|
||
win.transient(self)
|
||
win.minsize(420, 500)
|
||
win.configure(bg="#E8F4FA")
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
saved_geom = load_toplevel_geometry("kg_vorlage")
|
||
if saved_geom:
|
||
win.geometry(saved_geom)
|
||
else:
|
||
win.geometry(f"{VOR_W}x{VOR_H}")
|
||
center_window(win, VOR_W, VOR_H)
|
||
|
||
def _on_close():
|
||
try:
|
||
save_toplevel_geometry("kg_vorlage", win.geometry())
|
||
except Exception:
|
||
pass
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||
win.bind("<Configure>", lambda e: save_toplevel_geometry("kg_vorlage", win.geometry()))
|
||
|
||
# Header
|
||
header = tk.Frame(win, bg="#C8E8C8")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="📋 Vorlage für KG-Erstellung", font=("Segoe UI", 12, "bold"),
|
||
bg="#C8E8C8", fg="#2A5A2A").pack(padx=12, pady=8)
|
||
|
||
# ─── SOAP-Reihenfolge mit Profilen ───
|
||
order_frame = tk.LabelFrame(win, text=" Abschnitts-Reihenfolge ", font=("Segoe UI", 9, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d", padx=10, pady=6)
|
||
order_frame.pack(fill="x", padx=14, pady=(10, 4))
|
||
|
||
presets_data = load_soap_presets()
|
||
active_preset_idx = [presets_data.get("active", 0)]
|
||
|
||
profile_bar = tk.Frame(order_frame, bg="#E8F4FA")
|
||
profile_bar.pack(fill="x", pady=(0, 4))
|
||
|
||
_profile_btns = []
|
||
_PROF_ACTIVE = {"bg": "#1a4d6d", "fg": "white", "font": ("Segoe UI", 9, "bold")}
|
||
_PROF_INACTIVE = {"bg": "#D4EEF7", "fg": "#1a4d6d", "font": ("Segoe UI", 9)}
|
||
|
||
order_list = []
|
||
current_visibility = {}
|
||
visibility_vars = {}
|
||
row_widgets = []
|
||
drag_info = {"active": False, "src_idx": -1, "num_labels": []}
|
||
|
||
list_frame = tk.Frame(order_frame, bg="#E8F4FA")
|
||
list_frame.pack(fill="x")
|
||
|
||
def _load_preset(idx):
|
||
active_preset_idx[0] = idx
|
||
preset = presets_data["presets"][idx]
|
||
order_list.clear()
|
||
order_list.extend(preset.get("order", list(DEFAULT_SOAP_ORDER)))
|
||
current_visibility.clear()
|
||
vis = preset.get("visibility", {})
|
||
current_visibility.update({k: vis.get(k, True) for k in DEFAULT_SOAP_ORDER})
|
||
for k in DEFAULT_SOAP_ORDER:
|
||
if k in visibility_vars:
|
||
visibility_vars[k].set(current_visibility.get(k, True))
|
||
else:
|
||
visibility_vars[k] = tk.BooleanVar(value=current_visibility.get(k, True))
|
||
for i, btn in enumerate(_profile_btns):
|
||
btn.configure(**(_PROF_ACTIVE if i == idx else _PROF_INACTIVE))
|
||
_rebuild_order_ui()
|
||
|
||
def _save_current_to_preset():
|
||
idx = active_preset_idx[0]
|
||
presets_data["presets"][idx]["order"] = list(order_list)
|
||
presets_data["presets"][idx]["visibility"] = dict(current_visibility)
|
||
presets_data["active"] = idx
|
||
|
||
for pi in range(NUM_SOAP_PRESETS):
|
||
style = _PROF_ACTIVE if pi == active_preset_idx[0] else _PROF_INACTIVE
|
||
btn = tk.Label(profile_bar, text=f" Profil {pi+1} ", cursor="hand2",
|
||
padx=10, pady=3, **style)
|
||
btn.pack(side="left", padx=(0, 4))
|
||
btn.bind("<Button-1>", lambda e, i=pi: _load_preset(i))
|
||
btn.bind("<Enter>", lambda e, b=btn, i=pi: b.configure(
|
||
bg="#2a6a8d" if i == active_preset_idx[0] else "#B8DDE8"))
|
||
btn.bind("<Leave>", lambda e, b=btn, i=pi: b.configure(
|
||
**(_PROF_ACTIVE if i == active_preset_idx[0] else _PROF_INACTIVE)))
|
||
_profile_btns.append(btn)
|
||
|
||
tk.Label(order_frame, text="Drag-and-Drop · Häkchen = in KG anzeigen · nur aktives Profil wird verwendet:",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#4a8aaa").pack(anchor="w", pady=(0, 4))
|
||
|
||
def _on_visibility_toggle(key):
|
||
current_visibility[key] = visibility_vars[key].get()
|
||
_rebuild_order_ui()
|
||
|
||
def _drag_start(event, idx):
|
||
drag_info["active"] = True
|
||
drag_info["src_idx"] = idx
|
||
row_widgets[idx].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
|
||
def _drag_motion(event):
|
||
if not drag_info["active"]:
|
||
return
|
||
src = drag_info["src_idx"]
|
||
y = event.y_root
|
||
target = src
|
||
for i, row in enumerate(row_widgets):
|
||
try:
|
||
ry = row.winfo_rooty()
|
||
rh = row.winfo_height()
|
||
if ry <= y < ry + rh:
|
||
target = i
|
||
break
|
||
except Exception:
|
||
pass
|
||
if target == src:
|
||
return
|
||
item = order_list.pop(src)
|
||
order_list.insert(target, item)
|
||
row = row_widgets.pop(src)
|
||
row_widgets.insert(target, row)
|
||
nlbl = drag_info["num_labels"].pop(src)
|
||
drag_info["num_labels"].insert(target, nlbl)
|
||
for w in row_widgets:
|
||
w.pack_forget()
|
||
for i, w in enumerate(row_widgets):
|
||
w.pack(fill="x", pady=1)
|
||
for i, lbl in enumerate(drag_info["num_labels"]):
|
||
lbl.configure(text=f"{i + 1}.")
|
||
for w in row_widgets:
|
||
w.configure(highlightbackground="#B0D4E8", highlightthickness=1)
|
||
row_widgets[target].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
drag_info["src_idx"] = target
|
||
|
||
def _drag_end(event):
|
||
if drag_info["active"]:
|
||
drag_info["active"] = False
|
||
_rebuild_order_ui()
|
||
|
||
def _rebuild_order_ui():
|
||
for w in list_frame.winfo_children():
|
||
w.destroy()
|
||
row_widgets.clear()
|
||
drag_info["num_labels"] = []
|
||
for idx, key in enumerate(order_list):
|
||
vis = visibility_vars.get(key, tk.BooleanVar(value=True)).get()
|
||
row_bg = "white" if vis else "#F0F0F0"
|
||
fg_color = "#1a4d6d" if vis else "#AAAAAA"
|
||
row = tk.Frame(list_frame, bg=row_bg, highlightbackground="#B0D4E8",
|
||
highlightthickness=1, padx=4, pady=2)
|
||
row.pack(fill="x", pady=1)
|
||
handle = tk.Label(row, text="☰", font=("Segoe UI", 10),
|
||
bg=row_bg, fg="#B0D4E8", cursor="fleur", padx=2)
|
||
handle.pack(side="left", padx=(2, 4))
|
||
cb = tk.Checkbutton(row, variable=visibility_vars.get(key),
|
||
bg=row_bg, activebackground=row_bg,
|
||
command=lambda k=key: _on_visibility_toggle(k))
|
||
cb.pack(side="left", padx=(0, 0))
|
||
num_lbl = tk.Label(row, text=f"{idx + 1}.", font=("Segoe UI", 10, "bold"),
|
||
bg=row_bg, fg=fg_color, width=2)
|
||
num_lbl.pack(side="left", padx=(2, 2))
|
||
drag_info["num_labels"].append(num_lbl)
|
||
name_lbl = tk.Label(row, text=f"{key} – {_SOAP_LABELS.get(key, key)}",
|
||
font=("Segoe UI", 10), bg=row_bg, fg=fg_color, anchor="w")
|
||
name_lbl.pack(side="left", fill="x", expand=True, padx=4)
|
||
for widget in (handle, name_lbl, num_lbl, row):
|
||
widget.bind("<ButtonPress-1>", lambda e, i=idx: _drag_start(e, i))
|
||
widget.bind("<B1-Motion>", _drag_motion)
|
||
widget.bind("<ButtonRelease-1>", _drag_end)
|
||
handle.bind("<Enter>", lambda e, h=handle: h.configure(fg="#1a4d6d"))
|
||
handle.bind("<Leave>", lambda e, h=handle: h.configure(fg="#B0D4E8"))
|
||
row_widgets.append(row)
|
||
|
||
_load_preset(active_preset_idx[0])
|
||
|
||
def _reset_order():
|
||
order_list.clear()
|
||
order_list.extend(DEFAULT_SOAP_ORDER)
|
||
for k in visibility_vars:
|
||
visibility_vars[k].set(True)
|
||
current_visibility[k] = True
|
||
_rebuild_order_ui()
|
||
|
||
reset_order_btn = tk.Label(order_frame, text="↺ Standard-Reihenfolge", font=("Segoe UI", 8),
|
||
bg="#E8F4FA", fg="#7EC8E3", cursor="hand2")
|
||
reset_order_btn.pack(anchor="e", pady=(4, 0))
|
||
reset_order_btn.bind("<Button-1>", lambda e: _reset_order())
|
||
reset_order_btn.bind("<Enter>", lambda e: reset_order_btn.configure(fg="#1a4d6d"))
|
||
reset_order_btn.bind("<Leave>", lambda e: reset_order_btn.configure(fg="#7EC8E3"))
|
||
|
||
# ─── Freitext-Vorlage ───
|
||
tk.Label(win, text="Zusätzliche Anweisungen für die KI (optional):",
|
||
font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d"
|
||
).pack(anchor="w", padx=14, pady=(8, 2))
|
||
|
||
example_frame = tk.Frame(win, bg="#F5FBF5", padx=10, pady=4)
|
||
example_frame.pack(fill="x", padx=14, pady=(0, 4))
|
||
tk.Label(example_frame,
|
||
text="z.B. «Fachrichtung: Dermatologie» · «Procedere immer mit Kontrolltermin» · «Kurz und stichpunktartig»",
|
||
font=("Segoe UI", 7), bg="#F5FBF5", fg="#4A7A4A",
|
||
wraplength=460, justify="left").pack(anchor="w")
|
||
|
||
txt_frame = tk.Frame(win, bg="#E8F4FA", padx=14)
|
||
txt_frame.pack(fill="both", expand=True, pady=(0, 4))
|
||
vorlage_text = tk.Text(txt_frame, font=("Segoe UI", 11), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
|
||
insertbackground="#1a4d6d", padx=8, pady=6, height=5)
|
||
vorlage_text.pack(fill="both", expand=True)
|
||
|
||
current = load_templates_text()
|
||
if current:
|
||
vorlage_text.insert("1.0", current)
|
||
|
||
status_var = tk.StringVar(value="")
|
||
|
||
# Buttons
|
||
btn_frame = tk.Frame(win, bg="#D4EEF7", padx=14, pady=8)
|
||
btn_frame.pack(fill="x")
|
||
|
||
def _save():
|
||
text = vorlage_text.get("1.0", "end-1c").strip()
|
||
save_templates_text(text)
|
||
_save_current_to_preset()
|
||
save_soap_presets(presets_data)
|
||
self._rebuild_soap_section_controls()
|
||
status_var.set(f"✓ Profil {active_preset_idx[0]+1} + Vorlage gespeichert.")
|
||
|
||
def _clear():
|
||
vorlage_text.delete("1.0", "end")
|
||
save_templates_text("")
|
||
_reset_order()
|
||
_save_current_to_preset()
|
||
save_soap_presets(presets_data)
|
||
self._rebuild_soap_section_controls()
|
||
status_var.set("Alles zurückgesetzt – Standard-Format wird verwendet.")
|
||
|
||
def _save_and_close():
|
||
_save()
|
||
_on_close()
|
||
|
||
save_btn = tk.Button(btn_frame, text="💾 Speichern", font=("Segoe UI", 11, "bold"),
|
||
bg="#5BDB7B", fg="#1a4d6d", activebackground="#4BCB6B",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=_save)
|
||
save_btn.pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Speichern & Schliessen", font=("Segoe UI", 9),
|
||
bg="#7EC8E3", fg="#1a4d6d", activebackground="#6CB8D3",
|
||
relief="flat", bd=0, padx=12, pady=4, cursor="hand2",
|
||
command=_save_and_close).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Zurücksetzen", font=("Segoe UI", 9),
|
||
bg="#E0E0E0", fg="#666", activebackground="#D0D0D0",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_clear).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
|
||
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_on_close).pack(side="right")
|
||
|
||
tk.Label(win, textvariable=status_var, font=("Segoe UI", 8, "bold"),
|
||
bg="#E8F4FA", fg="#2A7A2A").pack(fill="x", padx=14, pady=(0, 4))
|
||
|
||
def _rebuild_textblock_buttons(self):
|
||
"""Erstellt die Textblock-Buttons neu basierend auf _textbloecke_data."""
|
||
for w in self._textbloecke_rows_frame.winfo_children():
|
||
w.destroy()
|
||
self._textbloecke_buttons = []
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
for i in range(0, len(slots), 2):
|
||
row_slots = slots[i : i + 2]
|
||
block_row = ttk.Frame(self._textbloecke_rows_frame, padding=(0, 4, 0, 0))
|
||
block_row.pack(fill="x")
|
||
block_inner = ttk.Frame(block_row)
|
||
block_inner.pack(anchor="center")
|
||
for slot_str in row_slots:
|
||
btn = RoundedButton(
|
||
block_inner, self._textblock_label(slot_str),
|
||
command=None,
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", width=70, height=26, canvas_bg="#B9ECFA",
|
||
radius=0,
|
||
)
|
||
btn._textblock_slot = slot_str
|
||
def make_click(s):
|
||
return lambda: self._copy_textblock(s)
|
||
btn.configure(command=make_click(slot_str))
|
||
def on_btn_press(e, s):
|
||
self._textblock_copy_to_clipboard(s)
|
||
btn.bind("<ButtonPress-1>", lambda e, s=slot_str: on_btn_press(e, s), add="+")
|
||
btn.pack(side="left", padx=(0, 4))
|
||
def make_ctx(s):
|
||
return lambda e: self._textblock_context(e, s)
|
||
def make_save_sel(s):
|
||
return lambda e: self._save_selection_to_textblock(e, s)
|
||
btn.bind("<Button-3>", make_ctx(slot_str))
|
||
btn.bind("<Shift-Button-1>", make_save_sel(slot_str))
|
||
self._textbloecke_buttons.append((slot_str, btn))
|
||
|
||
def _add_textblock(self):
|
||
"""Fügt einen Textblock hinzu – wenn vorher mit - entfernt, wird der gespeicherte Inhalt wiederhergestellt."""
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
next_num = int(slots[-1]) + 1 if slots else 1
|
||
slot_str = str(next_num)
|
||
if self._removed_textbloecke:
|
||
restored = self._removed_textbloecke.pop()
|
||
self._textbloecke_data[slot_str] = {"name": restored.get("name", f"Textblock {slot_str}"), "content": restored.get("content", "")}
|
||
else:
|
||
self._textbloecke_data[slot_str] = {"name": f"Textblock {slot_str}", "content": ""}
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._rebuild_textblock_buttons()
|
||
self.set_status(f"Textblock {slot_str} hinzugefügt.")
|
||
|
||
def _remove_textblock(self):
|
||
"""Entfernt den letzten Textblock. Inhalt wird gespeichert für spätere Wiederverwendung mit +."""
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
if len(slots) <= 2:
|
||
self.set_status("Mindestens 2 Textblöcke müssen bestehen bleiben.")
|
||
return
|
||
last = slots[-1]
|
||
self._removed_textbloecke.append(dict(self._textbloecke_data[last]))
|
||
del self._textbloecke_data[last]
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._rebuild_textblock_buttons()
|
||
self.set_status(f"Textblock {last} entfernt (Inhalt für + gespeichert).")
|
||
|
||
def _textblock_label(self, slot: str) -> str:
|
||
"""Anzeigetext für einen Textblock-Button (Name oder gekürzter Inhalt)."""
|
||
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
|
||
name = (d.get("name") or "").strip()
|
||
content = (d.get("content") or "").strip()
|
||
max_len = 12
|
||
if name and name != f"Textblock {slot}":
|
||
return (name[:max_len] + "…") if len(name) > max_len else name
|
||
if content:
|
||
return (content[:max_len] + "…") if len(content) > max_len else content
|
||
return f"Textblock {slot}"
|
||
|
||
def _refresh_textblock_button(self, slot: str):
|
||
"""Aktualisiert den angezeigten Text eines Textblock-Buttons."""
|
||
for s, btn in self._textbloecke_buttons:
|
||
if s == slot:
|
||
btn.configure(text=self._textblock_label(slot))
|
||
break
|
||
|
||
# Typische SOAP-/KG-Überschriften für Abschnitts-Kopieren (Doppelklick)
|
||
_KG_SECTION_HEADERS = (
|
||
"subjektiv", "objektiv", "diagnose", "diagnosen", "beurteilung",
|
||
"therapie", "procedere", "anlass", "befunde", "empfehlung",
|
||
"assessment", "plan", "befund", "verlauf", "medikation",
|
||
)
|
||
|
||
def _get_kg_section_at_cursor(self, text_widget):
|
||
"""Wenn Cursor auf einer Abschnittsüberschrift steht: (start, end, text) des Abschnitts, sonst None."""
|
||
try:
|
||
insert = text_widget.index(tk.INSERT)
|
||
line_no = int(insert.split(".")[0])
|
||
line_start = f"{line_no}.0"
|
||
line_end = f"{line_no}.end"
|
||
line_text = (text_widget.get(line_start, line_end) or "").strip()
|
||
if not line_text:
|
||
return None
|
||
line_lower = line_text.lower()
|
||
if line_lower.endswith(":"):
|
||
head = line_lower.rstrip(":").strip()
|
||
elif "." in line_lower and line_lower.split(".", 1)[0].strip().isdigit():
|
||
head = line_lower.split(".", 1)[1].strip().rstrip(":").strip()
|
||
else:
|
||
return None
|
||
if not any(h == head or head.startswith(h + " ") or head.startswith(h + ":") or h in head for h in self._KG_SECTION_HEADERS):
|
||
return None
|
||
content = text_widget.get("1.0", "end")
|
||
lines = content.split("\n")
|
||
start_line = line_no
|
||
end_line = len(lines)
|
||
|
||
def _is_section_header(ln_text):
|
||
"""Prüft ob eine Zeile eine bekannte SOAP-/KG-Abschnittsüberschrift ist."""
|
||
lt = (ln_text or "").strip().lower()
|
||
if not lt:
|
||
return False
|
||
if lt.endswith(":"):
|
||
h2 = lt.rstrip(":").strip()
|
||
elif "." in lt and lt.split(".", 1)[0].strip().isdigit():
|
||
h2 = lt.split(".", 1)[1].strip().rstrip(":").strip()
|
||
else:
|
||
return False
|
||
return any(k == h2 or h2.startswith(k + " ") or h2.startswith(k + ":") or k in h2 for k in self._KG_SECTION_HEADERS)
|
||
|
||
for i in range(start_line + 1, len(lines) + 1):
|
||
if i > len(lines):
|
||
break
|
||
if _is_section_header(lines[i - 1]):
|
||
end_line = i - 1
|
||
break
|
||
|
||
while end_line > start_line and not (lines[end_line - 1] or "").strip():
|
||
end_line -= 1
|
||
|
||
section_text = "\n".join(lines[start_line - 1 : end_line]).rstrip()
|
||
if not section_text:
|
||
return None
|
||
start_idx = f"{start_line}.0"
|
||
end_idx = f"{end_line}.end"
|
||
return (start_idx, end_idx, section_text)
|
||
except (tk.TclError, AttributeError, ValueError, IndexError):
|
||
return None
|
||
|
||
def _bind_kg_section_copy(self, text_widget):
|
||
"""Doppelklick auf eine SOAP-Überschrift: ganzen Abschnitt in Zwischenablage kopieren."""
|
||
def on_double_click(event):
|
||
result = self._get_kg_section_at_cursor(text_widget)
|
||
if result:
|
||
_, _, section_text = result
|
||
try:
|
||
if not _win_clipboard_set(section_text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(section_text)
|
||
self.set_status("Abschnitt kopiert.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
text_widget.bind("<Double-Button-1>", on_double_click, add="+")
|
||
|
||
def _bind_text_context_menu(self, text_widget):
|
||
"""Rechtsklick-Menü: Kopieren, Einfügen, Markierung in Textblock speichern."""
|
||
def on_right_click(event):
|
||
menu = tk.Menu(text_widget, tearoff=0)
|
||
menu.add_command(
|
||
label="Kopieren",
|
||
command=lambda: _do_copy(),
|
||
)
|
||
menu.add_command(
|
||
label="Einfügen",
|
||
command=lambda: _do_paste(),
|
||
)
|
||
slots = sorted(getattr(self, "_textbloecke_data", {}).keys())
|
||
if slots:
|
||
save_menu = tk.Menu(menu, tearoff=0)
|
||
for slot in slots:
|
||
lbl = self._textblock_label(slot)
|
||
save_menu.add_command(
|
||
label=lbl,
|
||
command=lambda s=slot, w=text_widget: _do_save_to_textblock(s, w),
|
||
)
|
||
menu.add_cascade(label="Markierung in Textblock speichern", menu=save_menu)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _do_copy():
|
||
try:
|
||
if hasattr(text_widget, "selection_present") and text_widget.selection_present():
|
||
sel = text_widget.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
if not _win_clipboard_set(sel):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sel)
|
||
self.set_status("Kopiert.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _do_paste():
|
||
try:
|
||
text = self.clipboard_get()
|
||
if isinstance(text, str):
|
||
text_widget.insert(tk.INSERT, text)
|
||
self.set_status("Eingefügt.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _do_save_to_textblock(slot, w):
|
||
try:
|
||
if hasattr(w, "selection_present") and w.selection_present():
|
||
text = w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
else:
|
||
text = self._get_selection_from_focus()
|
||
except (tk.TclError, AttributeError):
|
||
text = ""
|
||
if not (text or "").strip():
|
||
self.set_status("Keine Markierung – zuerst Text markieren.")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
text_widget.bind("<Button-3>", on_right_click)
|
||
|
||
def _bind_textblock_pending(self, text_widget):
|
||
"""Wie textbloecke.py: ButtonPress-1 = Auswahl für Drag; Cursorposition ständig aktualisieren für Einfügen."""
|
||
if not hasattr(self, "_textblock_drag_data"):
|
||
self._textblock_drag_data = {"text": None, "active": False}
|
||
self._bind_text_context_menu(text_widget)
|
||
|
||
def _save_as_last():
|
||
"""Speichert Widget + Cursorposition – wird vor Klick auf Textblock-Button gesichert."""
|
||
try:
|
||
if text_widget.winfo_exists():
|
||
self._last_focused_text_widget = text_widget
|
||
self._last_insert_index = text_widget.index(tk.INSERT)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
text_widget.bind("<FocusIn>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<FocusOut>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<KeyRelease>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<ButtonRelease-1>", lambda e: _save_as_last(), add="+")
|
||
|
||
def get_selection_from_widget(w):
|
||
"""Wie textbloecke.py get_selection(): Auswahl per tag_nextrange('sel')."""
|
||
try:
|
||
sel = w.tag_nextrange("sel", "1.0")
|
||
if sel:
|
||
return w.get(*sel)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
try:
|
||
if hasattr(w, "selection_present") and w.selection_present():
|
||
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return None
|
||
|
||
def start_drag(event):
|
||
"""Bei gedrückter Maustaste im Textfeld: Auswahl sichern (wie textbloecke.py start_drag)."""
|
||
s = get_selection_from_widget(text_widget)
|
||
if s and (s or "").strip():
|
||
self._textblock_drag_data["text"] = s
|
||
self._textblock_drag_data["active"] = False
|
||
else:
|
||
self._textblock_drag_data["text"] = None
|
||
self._textblock_drag_data["active"] = False
|
||
text_widget.bind("<ButtonPress-1>", start_drag, add="+")
|
||
text_widget.bind("<B1-Motion>", self._textblock_on_drag_motion, add="+")
|
||
text_widget.bind("<ButtonRelease-1>", self._on_global_drag_release, add="+")
|
||
self._bind_autotext(text_widget)
|
||
|
||
def _check_autotext_focus_out(self):
|
||
"""Prüft, ob der Fokus noch in unserer App ist (Hauptfenster oder Toplevel)."""
|
||
try:
|
||
w = self.focus_get()
|
||
if w is None:
|
||
self._autotext_focus_in_app[0] = False
|
||
return
|
||
top = w.winfo_toplevel()
|
||
if top == self:
|
||
self._autotext_focus_in_app[0] = True
|
||
return
|
||
if hasattr(top, "master") and top.master == self:
|
||
self._autotext_focus_in_app[0] = True
|
||
return
|
||
self._autotext_focus_in_app[0] = False
|
||
except (tk.TclError, AttributeError):
|
||
self._autotext_focus_in_app[0] = False
|
||
|
||
def _bind_autotext(self, text_widget):
|
||
"""Ersetzt Abkürzungen durch Autotext nach Leerzeichen/Zeilenumbruch etc. (dauerhaft gespeichert, wenn aktiviert)."""
|
||
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
|
||
|
||
def on_focus_in(event):
|
||
self._autotext_focus_in_app[0] = True
|
||
|
||
def on_focus_out(event):
|
||
self.after(50, self._check_autotext_focus_out)
|
||
|
||
text_widget.bind("<FocusIn>", on_focus_in, add="+")
|
||
text_widget.bind("<FocusOut>", on_focus_out, add="+")
|
||
|
||
def on_keyrelease(event):
|
||
if not getattr(self, "_autotext_data", {}).get("enabled", True):
|
||
return
|
||
entries = (self._autotext_data.get("entries") or {})
|
||
if not entries:
|
||
return
|
||
try:
|
||
text_widget.update_idletasks()
|
||
insert = text_widget.index(tk.INSERT)
|
||
text_before = text_widget.get("1.0", insert)
|
||
if not text_before:
|
||
return
|
||
last_char = text_before[-1]
|
||
if last_char not in AUTOTEXT_TERMINATORS:
|
||
return
|
||
word_end = len(text_before) - 1
|
||
i = word_end - 1
|
||
while i >= 0 and text_before[i] not in AUTOTEXT_TERMINATORS:
|
||
i -= 1
|
||
word_start = i + 1
|
||
word = text_before[word_start:word_end]
|
||
if not word or word not in entries:
|
||
return
|
||
expansion = entries[word]
|
||
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
|
||
text_widget.delete(start_idx, insert)
|
||
text_widget.insert(start_idx, expansion + last_char)
|
||
except (tk.TclError, AttributeError, IndexError):
|
||
pass
|
||
|
||
text_widget.bind("<KeyRelease>", lambda e: self.after(80, lambda: on_keyrelease(e)), add="+")
|
||
|
||
def _run_global_autotext_listener(self):
|
||
"""Hintergrund-Thread: Globaler Tastatur-Hook (Windows). Ersetzung in Worker-Thread mit Verzögerung, damit der Listener nicht blockiert."""
|
||
if not _HAS_PYNPUT:
|
||
return
|
||
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
|
||
controller = KbdController()
|
||
buffer = self._autotext_global_buffer
|
||
replace_queue = []
|
||
queue_lock = threading.Lock()
|
||
REPLACE_DELAY = 0.2
|
||
|
||
def key_to_char(key):
|
||
try:
|
||
if key == Key.space:
|
||
return " "
|
||
if key == Key.enter:
|
||
return "\n"
|
||
if key == Key.tab:
|
||
return "\t"
|
||
if hasattr(key, "char") and key.char:
|
||
return key.char
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def key_to_terminator_char(key):
|
||
try:
|
||
if key == Key.space:
|
||
return " "
|
||
if key == Key.enter:
|
||
return "\n"
|
||
if key == Key.tab:
|
||
return "\t"
|
||
if hasattr(key, "vk") and key.vk == 13:
|
||
return "\n"
|
||
if hasattr(key, "char") and key.char and key.char in AUTOTEXT_TERMINATORS:
|
||
return key.char
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def worker():
|
||
while True:
|
||
time.sleep(0.02)
|
||
with queue_lock:
|
||
if not replace_queue:
|
||
continue
|
||
n_back, text = replace_queue.pop(0)
|
||
injecting_ref = getattr(self, "_autotext_injecting", [False])
|
||
injecting_ref[0] = True
|
||
time.sleep(REPLACE_DELAY)
|
||
try:
|
||
data = load_autotext()
|
||
if not data.get("enabled", True):
|
||
injecting_ref[0] = False
|
||
continue
|
||
saved = _win_clipboard_get()
|
||
if not _win_clipboard_set(text):
|
||
injecting_ref[0] = False
|
||
continue
|
||
for _ in range(n_back):
|
||
controller.press(Key.backspace)
|
||
controller.release(Key.backspace)
|
||
time.sleep(0.1)
|
||
with controller.pressed(Key.ctrl):
|
||
controller.tap(KeyCode.from_char("v"))
|
||
time.sleep(0.05)
|
||
if saved:
|
||
_win_clipboard_set(saved)
|
||
except Exception:
|
||
pass
|
||
injecting_ref[0] = False
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def on_press(key):
|
||
if getattr(self, "_autotext_injecting", [False])[0]:
|
||
return
|
||
try:
|
||
if key == Key.backspace:
|
||
if buffer:
|
||
buffer.pop()
|
||
else:
|
||
ch = key_to_char(key)
|
||
if ch:
|
||
buffer.append(ch)
|
||
if len(buffer) > 200:
|
||
buffer.pop(0)
|
||
except Exception:
|
||
pass
|
||
|
||
def on_release(key):
|
||
if getattr(self, "_autotext_injecting", [False])[0]:
|
||
return
|
||
if getattr(self, "_autotext_focus_in_app", [False])[0]:
|
||
return
|
||
terminator_char = key_to_terminator_char(key)
|
||
if terminator_char is None:
|
||
return
|
||
try:
|
||
data = load_autotext()
|
||
if not data.get("enabled", True):
|
||
return
|
||
entries = data.get("entries") or {}
|
||
if not entries or len(buffer) < 2:
|
||
return
|
||
i = len(buffer) - 2
|
||
while i >= 0 and buffer[i] not in AUTOTEXT_TERMINATORS:
|
||
i -= 1
|
||
word = "".join(buffer[i + 1 : -1])
|
||
if not word:
|
||
return
|
||
expansion = entries.get(word)
|
||
if expansion is None:
|
||
for k, v in entries.items():
|
||
if k.lower() == word.lower():
|
||
expansion = v
|
||
break
|
||
if not expansion:
|
||
return
|
||
n_back = len(word) + 1
|
||
text = expansion + terminator_char
|
||
with queue_lock:
|
||
replace_queue.append((n_back, text))
|
||
del buffer[-(len(word) + 1) :]
|
||
buffer.extend(list(expansion + terminator_char))
|
||
while len(buffer) > 200:
|
||
buffer.pop(0)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
with KbdListener(on_press=on_press, on_release=on_release) as listener:
|
||
listener.join()
|
||
except Exception:
|
||
pass
|
||
|
||
def _textblock_slot_at(self, x_root: int, y_root: int):
|
||
"""Wie textbloecke.py button_index_at: Slot ('1'–'4') des Buttons unter (x_root, y_root) per Rechteck-Test."""
|
||
if not hasattr(self, "_textbloecke_buttons"):
|
||
return None
|
||
try:
|
||
for slot, btn in self._textbloecke_buttons:
|
||
bx = btn.winfo_rootx()
|
||
by = btn.winfo_rooty()
|
||
bw = max(1, btn.winfo_width())
|
||
bh = max(1, btn.winfo_height())
|
||
if bx <= x_root <= bx + bw and by <= y_root <= by + bh:
|
||
return slot
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return None
|
||
|
||
def _textblock_on_drag_motion(self, event):
|
||
"""Wie textbloecke.py on_drag_motion: Sobald Maus sich bewegt und wir Text haben → Drag aktiv."""
|
||
d = getattr(self, "_textblock_drag_data", None)
|
||
if d is not None and d.get("text"):
|
||
d["active"] = True
|
||
|
||
def _on_global_drag_release(self, event):
|
||
"""Wie textbloecke.py on_release: Maus losgelassen – wenn Drag aktiv und über Button → dort speichern; sonst in Zwischenablage für externes Einfügen."""
|
||
d = getattr(self, "_textblock_drag_data", None)
|
||
if d is None:
|
||
return
|
||
if not d.get("active") or not d.get("text"):
|
||
d["text"] = None
|
||
d["active"] = False
|
||
return
|
||
slot = self._textblock_slot_at(event.x_root, event.y_root)
|
||
if slot:
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = d["text"] or ""
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Text in Textblock gezogen und gespeichert.")
|
||
self._just_dropped_on_textblock = True
|
||
else:
|
||
try:
|
||
t = (d["text"] or "").strip()
|
||
if not _win_clipboard_set(t):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(t)
|
||
self.set_status("Text in Zwischenablage – in Editor/andere App mit Strg+V einfügen.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
d["text"] = None
|
||
d["active"] = False
|
||
|
||
def _on_focus_out_for_external_paste(self, event):
|
||
"""Wenn unser Fenster den Fokus verliert: externes Fenster merken, Cursor-Ziel bei uns verwerfen."""
|
||
if event.widget is not self:
|
||
return
|
||
self._last_focused_text_widget = None
|
||
self.after(120, self._store_last_external_window)
|
||
|
||
def _store_last_external_window(self):
|
||
"""Unter Windows: merkt sich das aktuell aktive Fenster (externes Programm)."""
|
||
if _user32 is None:
|
||
return
|
||
try:
|
||
hwnd = _user32.GetForegroundWindow()
|
||
if hwnd and hwnd != self.winfo_id():
|
||
self._last_external_hwnd = hwnd
|
||
except Exception:
|
||
pass
|
||
|
||
def _paste_to_external_window(self):
|
||
"""Unter Windows: aktiviert das zuletzt gespeicherte externe Fenster und sendet Strg+V."""
|
||
if _user32 is None:
|
||
return False
|
||
hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if not hwnd:
|
||
return False
|
||
try:
|
||
_user32.SetForegroundWindow(hwnd)
|
||
self.after(80, self._send_ctrl_v)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _send_ctrl_v(self):
|
||
"""Sendet Strg+V (Einfügen) an das aktive Fenster."""
|
||
if _user32 is None:
|
||
return
|
||
try:
|
||
VK_CONTROL = 0x11
|
||
VK_V = 0x56
|
||
_user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
_user32.keybd_event(VK_V, 0, 0, 0)
|
||
_user32.keybd_event(VK_V, 0, 2, 0)
|
||
_user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
def _textblock_copy_to_clipboard(self, slot: str):
|
||
"""Kopiert den Textblock-Inhalt in die Zwischenablage (z. B. beim Drücken/Ziehen in anderen Editor)."""
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
return
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(content)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _copy_textblock(self, slot: str):
|
||
"""Klick auf Button: Text dort einfügen, wo der Cursor ist – bei uns im Hauptfenster oder im externen Programm."""
|
||
if getattr(self, "_just_dropped_on_textblock", False):
|
||
self._just_dropped_on_textblock = False
|
||
return
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
self.set_status("Textblock ist leer. Rechtsklick → 'Aus Zwischenablage speichern' oder Text auf Button ziehen.")
|
||
return
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(content)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
w = getattr(self, "_last_focused_text_widget", None)
|
||
try:
|
||
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
|
||
w.focus_set()
|
||
w.insert(tk.INSERT, content)
|
||
w.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
if sys.platform == "win32" and _user32 and getattr(self, "_last_external_hwnd", None):
|
||
if self._paste_to_external_window():
|
||
self.set_status("Textblock in externes Programm (Word/Notepad) eingefügt.")
|
||
return
|
||
if hasattr(self, "txt_output"):
|
||
try:
|
||
w2 = self.txt_output
|
||
if w2.winfo_exists() and hasattr(w2, "insert"):
|
||
w2.focus_set()
|
||
w2.insert(tk.INSERT, content)
|
||
w2.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
self.set_status("Textblock in Zwischenablage – Cursor setzen, dann Strg+V.")
|
||
|
||
def _get_selection_from_focus(self) -> str:
|
||
"""Liefert die aktuelle Auswahl aus einem Text-Widget (Transkript, KG, Diktat, Brief etc.)."""
|
||
def find_text_with_selection(widget):
|
||
try:
|
||
if widget.winfo_class() == "Text" and hasattr(widget, "selection_present") and widget.selection_present():
|
||
return widget.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
for c in widget.winfo_children():
|
||
r = find_text_with_selection(c)
|
||
if r:
|
||
return r
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return ""
|
||
w = self.focus_get()
|
||
try:
|
||
if w is not None and hasattr(w, "selection_present") and w.selection_present():
|
||
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return find_text_with_selection(self) or ""
|
||
|
||
def _save_selection_to_textblock(self, event, slot: str):
|
||
"""Speichert die aktuelle Textauswahl (aus beliebigem Feld) in den Textblock. Shift+Klick oder Menü."""
|
||
text = self._get_selection_from_focus()
|
||
if not text.strip():
|
||
self.set_status("Keine Auswahl – Text markieren oder Rechtsklick → Aus Zwischenablage speichern.")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
def _textblock_context(self, event, slot: str):
|
||
"""Rechtsklick-Menü: Einfügen, Speichern (Auswahl/Zwischenablage), Umbenennen, Löschen."""
|
||
menu = tk.Menu(self, tearoff=0)
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if content.strip():
|
||
menu.add_command(
|
||
label="An Cursorposition einfügen",
|
||
command=lambda: self._textblock_insert_at_cursor(slot),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(
|
||
label="Aus Zwischenablage speichern",
|
||
command=lambda: self._textblock_save_from_clipboard(slot),
|
||
)
|
||
menu.add_command(
|
||
label="Markierung speichern",
|
||
command=lambda: self._textblock_save_from_selection(slot),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(
|
||
label="Umbenennen",
|
||
command=lambda: self._textblock_rename(slot),
|
||
)
|
||
menu.add_command(
|
||
label="Textblock leeren",
|
||
command=lambda: self._textblock_clear(slot),
|
||
)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _textblock_insert_at_cursor(self, slot: str):
|
||
"""Fügt den Textblock-Inhalt an der Cursorposition ein (oder in Zwischenablage)."""
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
self.set_status("Textblock ist leer.")
|
||
return
|
||
w = getattr(self, "_last_focused_text_widget", None)
|
||
if w is None and hasattr(self, "txt_output"):
|
||
w = self.txt_output
|
||
try:
|
||
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
|
||
w.focus_set()
|
||
w.insert(tk.INSERT, content)
|
||
w.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(content)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
self.set_status("Textblock in Zwischenablage – mit Strg+V einfügen.")
|
||
|
||
def _textblock_clear(self, slot: str):
|
||
"""Löscht den Inhalt (und optional den Namen) des Textblocks."""
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = ""
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Textblock gelöscht.")
|
||
|
||
def _textblock_save_from_selection(self, slot: str):
|
||
"""Speichert die Auswahl des fokussierten Textfelds in den Textblock."""
|
||
text = self._get_selection_from_focus()
|
||
if not text.strip():
|
||
messagebox.showinfo("Hinweis", "Bitte zuerst Text markieren (z. B. im Transkript, in der KG oder im Diktat-Fenster).")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
def _textblock_save_from_clipboard(self, slot: str):
|
||
"""Speichert den Inhalt der Zwischenablage in den Textblock (überschreibt)."""
|
||
try:
|
||
text = self.clipboard_get()
|
||
except Exception:
|
||
text = ""
|
||
if isinstance(text, str):
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Textblock gespeichert.")
|
||
else:
|
||
self.set_status("Kein Text in Zwischenablage.")
|
||
|
||
def _textblock_rename(self, slot: str):
|
||
"""Umbenennen direkt am Button: Entry überlagert den Button, kein neues Fenster."""
|
||
def _do_rename():
|
||
btn = None
|
||
for s, b in self._textbloecke_buttons:
|
||
if s == slot:
|
||
btn = b
|
||
break
|
||
if not btn or not btn.winfo_exists():
|
||
return
|
||
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
|
||
current = (d.get("name") or "").strip() or f"Textblock {slot}"
|
||
parent = btn.nametowidget(btn.winfo_parent())
|
||
entry = ttk.Entry(parent, width=12, font=("Segoe UI", 11))
|
||
entry.insert(0, current)
|
||
entry.select_range(0, tk.END)
|
||
parent.update_idletasks()
|
||
entry.place(x=btn.winfo_x(), y=btn.winfo_y(), width=btn.winfo_width(), height=btn.winfo_height())
|
||
entry.focus_set()
|
||
|
||
def finish(save: bool):
|
||
if save:
|
||
new_name = entry.get().strip()
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["name"] = new_name or f"Textblock {slot}"
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Button umbenannt.")
|
||
entry.place_forget()
|
||
entry.destroy()
|
||
|
||
entry.bind("<Return>", lambda e: finish(True))
|
||
entry.bind("<Escape>", lambda e: finish(False))
|
||
self.after(150, _do_rename)
|
||
|
||
def _new_session(self):
|
||
# Vor dem Leeren: KG und (falls vorhanden) Brief/Rezept/KOGU zuverlässig in Ablage speichern (JSON + .txt)
|
||
def get_str(widget_or_str):
|
||
if widget_or_str is None:
|
||
return ""
|
||
if hasattr(widget_or_str, "get"):
|
||
try:
|
||
s = widget_or_str.get("1.0", "end")
|
||
return (s if isinstance(s, str) else str(s)).strip()
|
||
except Exception:
|
||
return ""
|
||
return (str(widget_or_str)).strip()
|
||
kg_text = get_str(self.txt_output)
|
||
brief_text = get_str(self._last_brief_text)
|
||
rezept_text = get_str(self._last_rezept_text)
|
||
kogu_text = get_str(self._last_kogu_text)
|
||
try:
|
||
if kg_text:
|
||
save_to_ablage("KG", kg_text)
|
||
if brief_text:
|
||
save_to_ablage("Briefe", brief_text)
|
||
if rezept_text:
|
||
save_to_ablage("Rezepte", rezept_text)
|
||
if kogu_text:
|
||
save_to_ablage("Kostengutsprachen", kogu_text)
|
||
except Exception as e:
|
||
try:
|
||
messagebox.showerror("Speichern bei Neu", str(e))
|
||
except Exception:
|
||
pass
|
||
self._last_brief_text = ""
|
||
self._last_rezept_text = ""
|
||
self._last_kogu_text = ""
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_output.delete("1.0", "end")
|
||
self.set_status("Bereit.")
|
||
|
||
def copy_output(self):
|
||
text = self.txt_output.get("1.0", "end").strip()
|
||
if not text:
|
||
messagebox.showinfo("Hinweis", "Keine Krankengeschichte zum Kopieren.")
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(text)
|
||
self.set_status("KG kopiert.")
|
||
|
||
def copy_transcript(self):
|
||
text = self.txt_transcript.get("1.0", "end").strip()
|
||
if not text:
|
||
messagebox.showinfo("Hinweis", "Kein Transkript zum Kopieren.")
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(text)
|
||
self.set_status("Transkript kopiert.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
load_dotenv()
|
||
# Windows DPI fix (optional)
|
||
try:
|
||
import ctypes
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from keygen_license import show_license_dialog_and_exit_if_invalid
|
||
show_license_dialog_and_exit_if_invalid("KG-Diktat")
|
||
except ImportError:
|
||
pass
|
||
|
||
app = KGDesktopApp()
|
||
app.mainloop()
|