Files
aza/AzA march 2026/backup_final_release_20260607_223600/aza_diktat_mixin.py

504 lines
22 KiB
Python
Raw Normal View History

2026-06-10 22:55:03 +02:00
# -*- coding: utf-8 -*-
"""
AzaDiktatMixin Diktat-Fenster (nur Transkription, keine KG).
"""
import os
import threading
import wave
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter.scrolledtext import ScrolledText
from aza_persistence import (
load_diktat_geometry,
save_diktat_geometry,
save_to_ablage,
_win_clipboard_set,
sanitize_markdown_for_plain_text,
is_autocopy_after_diktat_enabled,
is_global_right_click_paste_enabled,
save_autocopy_prefs,
)
from aza_ui_helpers import (
center_window,
add_resize_grip,
add_font_scale_control,
add_text_font_size_control,
RoundedButton,
)
from aza_audio import AudioRecorder
class AzaDiktatMixin:
"""Mixin für das Diktat-Fenster (nur Aufnahme + Transkription)."""
def open_diktat_window(self):
"""Unabhängiges Fenster: nur Diktat aufnehmen und transkribieren (keine KG). Text wird automatisch kopiert."""
if not self.ensure_ready():
return
DIKTAT_MIN_W, DIKTAT_MIN_H = 420, 380
_BG = "#E8F4FA"
_HDR_BG = "#1A4D6D"
_HDR_FG = "#FFFFFF"
_CARD_BG = "#FFFFFF"
_CARD_BD = "#C8D8E8"
_TEXT = "#1A3D55"
_TEXT_SUB = "#607890"
_BTN_PRI = "#1A4D6D"
_BTN_PRI_H = "#4A7A9E"
_BTN_SEC = "#FFFFFF"
_BTN_SEC_BD = "#C8D8E8"
_STATUS_REC = "#C86B2A"
win = tk.Toplevel(self)
win.title("Diktat")
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.configure(bg=_BG)
win.attributes("-topmost", True)
self._diktat_window = win
self._register_window(win)
# Fensterposition: gespeichert laden oder zentrieren
saved_geom = load_diktat_geometry()
if saved_geom:
# Gespeicherte Position verwenden (Position wird beibehalten!)
win.geometry(saved_geom)
else:
# Keine gespeicherte Position → zentrieren
win.geometry("300x290")
center_window(win, 300, 290)
def on_diktat_close():
try:
geom = win.geometry()
save_diktat_geometry(geom)
except Exception:
pass
self._diktat_window = None
if hasattr(self, "_aza_windows"):
self._aza_windows.discard(win)
win.destroy()
if getattr(self, "_main_hidden", False):
try:
self.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_diktat_close)
# Speichere Position auch während Verschieben/Resize
_diktat_geom_after_id = [None]
def on_diktat_configure(e):
if e.widget is win and _diktat_geom_after_id[0]:
win.after_cancel(_diktat_geom_after_id[0])
if e.widget is win:
_diktat_geom_after_id[0] = win.after(400, lambda: save_diktat_geometry(win.geometry()))
win.bind("<Configure>", on_diktat_configure)
add_resize_grip(win, DIKTAT_MIN_W, DIKTAT_MIN_H)
add_font_scale_control(win)
# ─── Header (AzA Add-on Beta Stil) ───
diktat_header = tk.Frame(win, bg=_HDR_BG)
diktat_header.pack(fill="x")
tk.Label(diktat_header, text="Diktat", font=("Segoe UI", 12, "bold"),
bg=_HDR_BG, fg=_HDR_FG).pack(side="left", padx=(14, 6), pady=10)
_dik_minimized = [False]
_dik_geom_before = [None]
_dik_restoring = [False]
# Mini buttons + status (shown when minimized)
_mini_rec_btn = [None]
_mini_neu_btn = [None]
_mini_status_lbl = [None]
def _mini_toggle_record():
"""Start/Stop Aufnahme im minimierten Zustand."""
toggle_diktat()
if _mini_rec_btn[0]:
if is_recording[0]:
_mini_rec_btn[0].configure(text="", fg="#D04040")
else:
_mini_rec_btn[0].configure(text="", fg="#5A90B0")
def _mini_new_diktat():
"""Neu: Text leeren, laufende Aufnahme verwerfen, sofort neue Aufnahme starten."""
do_neu()
if _mini_rec_btn[0]:
if is_recording[0]:
_mini_rec_btn[0].configure(text="", fg="#D04040")
else:
_mini_rec_btn[0].configure(text="", fg="#5A90B0")
def _restore_diktat_content():
if not _dik_minimized[0]:
return
_dik_restoring[0] = True
if _mini_rec_btn[0]:
_mini_rec_btn[0].pack_forget()
if _mini_neu_btn[0]:
_mini_neu_btn[0].pack_forget()
if _mini_status_lbl[0]:
_mini_status_lbl[0]._parent_bar.pack_forget()
body_outer.pack(fill="both", expand=True)
btn_minimize_dik.configure(text="")
_dik_minimized[0] = False
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.after(200, lambda: _dik_restoring.__setitem__(0, False))
def _toggle_minimize_diktat():
if _dik_minimized[0]:
_restore_diktat_content()
if _dik_geom_before[0]:
try:
win.geometry(_dik_geom_before[0])
except Exception:
pass
else:
_dik_geom_before[0] = win.geometry()
body_outer.pack_forget()
btn_minimize_dik.configure(text="")
_dik_minimized[0] = True
win.minsize(200, 74)
w_cur = win.winfo_width()
win.geometry(f"{w_cur}x74")
# Show mini record button
rec_text = "" if is_recording[0] else ""
rec_fg = "#D04040" if is_recording[0] else "#5A90B0"
if not _mini_rec_btn[0]:
_mini_rec_btn[0] = tk.Label(diktat_header, text=rec_text, font=("Segoe UI", 14, "bold"),
bg=_HDR_BG, fg=rec_fg, cursor="hand2", padx=4)
_mini_rec_btn[0].bind("<Button-1>", lambda e: _mini_toggle_record())
_mini_rec_btn[0].bind("<Enter>", lambda e: _mini_rec_btn[0].configure(fg="#A8D4F0"))
_mini_rec_btn[0].bind("<Leave>", lambda e: _mini_rec_btn[0].configure(
fg="#D04040" if is_recording[0] else "#A0C4D8"))
else:
_mini_rec_btn[0].configure(text=rec_text, fg=rec_fg)
_mini_rec_btn[0].pack(side="left", padx=(0, 4))
if not _mini_neu_btn[0]:
_mini_neu_btn[0] = tk.Label(diktat_header, text="Neu", font=("Segoe UI", 9),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=4)
_mini_neu_btn[0].bind("<Button-1>", lambda e: _mini_new_diktat())
_mini_neu_btn[0].bind("<Enter>", lambda e: _mini_neu_btn[0].configure(fg="#FFFFFF"))
_mini_neu_btn[0].bind("<Leave>", lambda e: _mini_neu_btn[0].configure(fg="#A0C4D8"))
_mini_neu_btn[0].pack(side="left", padx=(0, 4))
# Show mini status bar below header
if not _mini_status_lbl[0]:
_mini_status_bar = tk.Frame(win, bg=_BG, height=22, padx=10, pady=2)
_mini_status_lbl[0] = tk.Label(
_mini_status_bar, textvariable=status_var,
fg=_STATUS_REC, bg=_BG,
font=("Segoe UI", 8), anchor="w",
)
_mini_status_lbl[0].pack(side="left", fill="x", expand=True)
_mini_status_lbl[0]._parent_bar = _mini_status_bar
_mini_status_lbl[0]._parent_bar.pack(fill="x")
_mini_status_lbl[0].pack(side="left", fill="x", expand=True)
def _on_dik_configure(e):
if e.widget is not win:
return
if _dik_minimized[0] and not _dik_restoring[0] and e.height > 95:
_restore_diktat_content()
win.bind("<Configure>", _on_dik_configure, add="+")
btn_minimize_dik = tk.Label(diktat_header, text="", font=("Segoe UI", 12, "bold"),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=6)
btn_minimize_dik.pack(side="right", padx=(0, 4))
btn_minimize_dik.bind("<Button-1>", lambda e: _toggle_minimize_diktat())
btn_minimize_dik.bind("<Enter>", lambda e: btn_minimize_dik.configure(fg="#FFFFFF"))
btn_minimize_dik.bind("<Leave>", lambda e: btn_minimize_dik.configure(fg="#A0C4D8"))
btn_close_hdr = tk.Label(diktat_header, text="Schliessen", font=("Segoe UI", 9),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=10)
btn_close_hdr.pack(side="right", padx=(0, 6))
btn_close_hdr.bind("<Button-1>", lambda e: on_diktat_close())
btn_close_hdr.bind("<Enter>", lambda e: btn_close_hdr.configure(fg="#FFFFFF"))
btn_close_hdr.bind("<Leave>", lambda e: btn_close_hdr.configure(fg="#A0C4D8"))
win._aza_minimize = _toggle_minimize_diktat
win._aza_is_minimized = lambda: _dik_minimized[0]
if hasattr(self, "_aza_windows"):
self._aza_windows.add(win)
body_outer = tk.Frame(win, bg=_BG, padx=12, pady=10)
body_outer.pack(fill="both", expand=True)
main_f = tk.Frame(body_outer, bg=_CARD_BG,
highlightbackground=_CARD_BD, highlightthickness=1)
main_f.pack(fill="both", expand=True, padx=0, pady=0)
main_inner = tk.Frame(main_f, bg=_CARD_BG, padx=12, pady=10)
main_inner.pack(fill="both", expand=True)
label_frame = tk.Frame(main_inner, bg=_CARD_BG)
label_frame.pack(fill="x", anchor="w")
tk.Label(label_frame, text="Transkript", font=("Segoe UI", 9, "bold"),
bg=_CARD_BG, fg=_TEXT).pack(side="left")
# Modus auch direkt im Diktat-Fenster: Medizin vs. Allgemein (exklusiv)
if not hasattr(self, "_transcribe_medical_var"):
self._transcribe_medical_var = tk.BooleanVar(value=True)
if not hasattr(self, "_transcribe_general_var"):
self._transcribe_general_var = tk.BooleanVar(value=False)
if not hasattr(self, "_transcribe_toggle_guard"):
self._transcribe_toggle_guard = False
def _set_mode(domain_value: str):
if hasattr(self, "_set_transcribe_domain"):
self._set_transcribe_domain(domain_value)
return
# Fallback ohne Hauptmethode
if domain_value == "general":
self._transcribe_medical_var.set(False)
self._transcribe_general_var.set(True)
else:
self._transcribe_medical_var.set(True)
self._transcribe_general_var.set(False)
def _on_med_toggle():
if self._transcribe_medical_var.get():
_set_mode("medical")
elif not self._transcribe_general_var.get():
_set_mode("medical")
def _on_gen_toggle():
if self._transcribe_general_var.get():
_set_mode("general")
elif not self._transcribe_medical_var.get():
_set_mode("medical")
tk.Checkbutton(
label_frame, text="Medizin", variable=self._transcribe_medical_var,
command=_on_med_toggle, bg=_CARD_BG, fg=_TEXT,
activebackground=_CARD_BG, selectcolor="#E8F4FA",
font=("Segoe UI", 8),
).pack(side="left", padx=(10, 4))
tk.Checkbutton(
label_frame, text="Allgemein", variable=self._transcribe_general_var,
command=_on_gen_toggle, bg=_CARD_BG, fg=_TEXT,
activebackground=_CARD_BG, selectcolor="#E8F4FA",
font=("Segoe UI", 8),
).pack(side="left")
diktat_font = ("Segoe UI", 8)
txt = ScrolledText(main_inner, wrap="word", font=diktat_font, bg="#FAFCFE",
height=8, relief="flat", bd=1,
highlightbackground=_CARD_BD, highlightthickness=1)
txt.pack(fill="both", expand=True, pady=(6, 6))
add_text_font_size_control(label_frame, txt, initial_size=8, bg_color=_CARD_BG, save_key="diktat_window")
self._bind_textblock_pending(txt)
status_var = tk.StringVar(value="Bereit.")
lbl_status = tk.Label(
main_inner, textvariable=status_var, fg=_TEXT_SUB, bg=_CARD_BG,
font=("Segoe UI", 8), anchor="w",
)
lbl_status.pack(fill="x", pady=(0, 4))
cb_row = tk.Frame(main_inner, bg=_CARD_BG)
cb_row.pack(fill="x", pady=(2, 0))
_diktat_autocopy_var = tk.BooleanVar(value=is_autocopy_after_diktat_enabled())
ttk.Checkbutton(
cb_row, text="Autokopie nach Transkription",
variable=_diktat_autocopy_var,
command=lambda: save_autocopy_prefs(autocopy=_diktat_autocopy_var.get()),
).pack(side="left")
_diktat_rclick_var = tk.BooleanVar(value=is_global_right_click_paste_enabled())
ttk.Checkbutton(
cb_row, text="Rechtsklick = Einfügen",
variable=_diktat_rclick_var,
command=lambda: save_autocopy_prefs(global_right_click=_diktat_rclick_var.get()),
).pack(side="left", padx=(12, 0))
btn_row = tk.Frame(main_inner, bg=_CARD_BG)
btn_row.pack(fill="x", pady=(6, 0))
btn_row2 = tk.Frame(main_inner, bg=_CARD_BG)
btn_row2.pack(fill="x", pady=(4, 0))
diktat_recorder = [None]
is_recording = [False]
def toggle_diktat():
if not diktat_recorder[0]:
diktat_recorder[0] = AudioRecorder()
rec = diktat_recorder[0]
if not is_recording[0]:
try:
rec.start()
is_recording[0] = True
btn_diktat_record.configure(text="Aufnahme stoppen", bg="#C86B2A", active_bg="#A85520")
lbl_status.configure(fg=_STATUS_REC)
status_var.set("Aufnahme läuft…")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
status_var.set("Bereit.")
else:
is_recording[0] = False
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
lbl_status.configure(fg=_TEXT_SUB)
status_var.set("Transkribiere…")
def worker():
def _safe_after(fn):
try:
if self.winfo_exists():
self.after(0, fn)
except Exception:
pass
try:
wav_path = rec.stop_and_save_wav()
try:
with wave.open(wav_path, 'rb') as wf:
frames = wf.getnframes()
framerate = wf.getframerate()
duration = frames / float(framerate)
if duration < 0.3:
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Audio erkannt."))
return
except Exception:
pass
transcript_result = self.transcribe_wav(wav_path)
if hasattr(transcript_result, 'text'):
transcript_text = transcript_result.text
elif isinstance(transcript_result, str):
transcript_text = transcript_result
else:
transcript_text = ""
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
if not transcript_text or not transcript_text.strip():
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Text erkannt."))
return
transcript_text = self._diktat_apply_punctuation(transcript_text)
if not transcript_text or not transcript_text.strip():
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Text erkannt."))
return
_safe_after(lambda: _done(transcript_text))
except Exception as e:
def _show_err(err=e):
try:
if win.winfo_exists():
messagebox.showerror("Fehler", str(err), parent=win)
status_var.set("Fehler.")
except Exception:
pass
_safe_after(_show_err)
def _done(text):
diktat_recorder[0] = None
try:
if not win.winfo_exists():
return
txt.configure(state="normal")
idx = txt.index(tk.INSERT)
txt.insert(idx, text)
full = txt.get("1.0", "end").strip()
copied = False
if _diktat_autocopy_var.get() and full:
if not _win_clipboard_set(full):
try:
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(full))
except Exception:
pass
copied = True
if full:
try:
save_to_ablage("Diktat", full)
except Exception:
pass
if copied:
self.set_status("Diktat transkribiert und kopiert.")
status_var.set("Fertig. Kopiert.")
else:
self.set_status("Diktat transkribiert.")
status_var.set("Fertig.")
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
def do_neu():
if is_recording[0]:
if not messagebox.askyesno(
"Aufnahme läuft",
"Es läuft gerade eine Aufnahme.\n"
"Möchtest du die aktuelle Aufnahme wirklich verwerfen und neu starten?",
parent=win
):
return
rec = diktat_recorder[0]
is_recording[0] = False
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
lbl_status.configure(fg=_TEXT_SUB)
try:
wav_path = rec.stop_and_save_wav()
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Bereit.")
toggle_diktat()
def do_kopieren():
t = txt.get("1.0", "end").strip()
if t:
if not _win_clipboard_set(t):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(t))
self.set_status("Diktat kopiert.")
else:
self.set_status("Nichts zum Kopieren.")
def do_clear_text():
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Diktatfeld geleert.")
self.set_status("Diktatfeld geleert.")
btn_diktat_record = RoundedButton(
btn_row, "Diktat starten", command=toggle_diktat,
width=148, height=30, canvas_bg=_CARD_BG,
bg=_BTN_PRI, fg="#FFFFFF", active_bg=_BTN_PRI_H,
)
btn_diktat_record.pack(side="left")
RoundedButton(
btn_row, "Neu", command=do_neu,
width=72, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left", padx=(8, 0))
RoundedButton(
btn_row2, "Kopieren", command=do_kopieren,
width=100, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left")
RoundedButton(
btn_row2, "Leeren", command=do_clear_text,
width=80, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left", padx=(8, 0))
if self._autotext_data.get("diktat_auto_start", True):
win.after(350, toggle_diktat)