Files
aza/AzA march 2026/aza_office_workspace_ui.py

634 lines
21 KiB
Python
Raw Normal View History

2026-05-06 22:43:22 +02:00
# -*- coding: utf-8 -*-
"""Verwaltungsfenster für Autotext und Textblöcke — ruhige Office-Hülle, ohne alte Hauptfenster-Optik."""
from __future__ import annotations
import threading
import tkinter as tk
from tkinter import messagebox
from tkinter.scrolledtext import ScrolledText
from typing import Dict
FF = "Segoe UI"
_AUTOTEXT_ATTR = "_aza_workspace_autotext_toplevel"
_TB_WIN_DICT_ATTR = "_aza_workspace_tb_toplevels"
def _palette_for_app(app) -> Dict[str, str]:
sh = getattr(app, "_aza_office_v1", None)
p = getattr(sh, "_palette", None) if sh is not None else None
if isinstance(p, dict) and p:
return dict(p)
try:
from aza_office_shell_v1 import PALETTE_LIGHT
return dict(PALETTE_LIGHT)
except Exception:
return {
"BG": "#EAF2F7",
"SURFACE": "#FFFFFF",
"BORDER": "#D6E2EB",
"TEXT": "#1A4D6D",
"SUBTLE": "#5C7A8E",
"ACCENT": "#5B8DB3",
"TEXT_AREA_BG": "#FFFFFF",
"TEXT_AREA_FG": "#1A4D6D",
"ACCENT_SOFT": "#E2EEF6",
"TEXT_STRONG": "#0F3850",
}
def _header_bar(parent, palette: Dict[str, str], title: str) -> tk.Frame:
bar = tk.Frame(parent, bg=palette["ACCENT"], bd=0, highlightthickness=0)
bar.pack(fill="x")
tk.Label(
bar, text=title, bg=palette["ACCENT"], fg="white",
font=(FF, 10, "bold"), padx=12, pady=8, anchor="w",
).pack(side="left")
return bar
def _register_if_any(app, win: tk.Toplevel) -> None:
reg = getattr(app, "_register_window", None)
if callable(reg):
try:
reg(win)
except Exception:
pass
def _lift_above_main(win: tk.Toplevel, app: tk.Misc) -> None:
"""Hält den Dialog über dem Hauptfenster (Windows: transient + lift + topmost)."""
try:
win.transient(app)
except Exception:
pass
try:
win.lift(app)
win.focus_force()
except Exception:
pass
try:
win.attributes("-topmost", True)
except Exception:
pass
def open_workspace_autotext_manager(app) -> None:
"""Genau ein Autotext-Fenster; Kürzel wie bisher über basis14 / Persistenz."""
from aza_ui_helpers import (
center_window,
load_toplevel_geometry,
save_toplevel_geometry,
)
from aza_persistence import load_autotext, save_autotext
from aza_workspace_sync import (
prune_autotext_meta,
schedule_workspace_cloud_push,
touch_autotext_entry_meta,
)
exist = getattr(app, _AUTOTEXT_ATTR, None)
if exist is not None:
try:
if exist.winfo_exists():
rf = getattr(exist, "_aza_reload_autotext_disk", None)
if callable(rf):
rf()
exist.deiconify()
_lift_above_main(exist, app)
return
except tk.TclError:
pass
p = _palette_for_app(app)
root = tk.Toplevel(app)
setattr(app, _AUTOTEXT_ATTR, root)
root.title("Autotext")
root.configure(bg=p["SURFACE"])
root.minsize(560, 540)
root.transient(app)
_register_if_any(app, root)
geom_name = "workspace_autotext"
saved = load_toplevel_geometry(geom_name)
if saved:
try:
root.geometry(saved)
except tk.TclError:
root.geometry("720x640")
center_window(root, 720, 640)
else:
root.geometry("720x640")
center_window(root, 720, 640)
dik_state = {"active": False}
def _clear_ref_and_close() -> None:
if dik_state["active"]:
dik_state["active"] = False
try:
rec = getattr(app, "recorder", None)
if rec is not None:
try:
rec.stop_and_save_wav()
except Exception:
pass
except Exception:
pass
try:
if getattr(app, _AUTOTEXT_ATTR, None) is root:
setattr(app, _AUTOTEXT_ATTR, None)
except Exception:
pass
try:
save_toplevel_geometry(geom_name, root.geometry())
except Exception:
pass
try:
root.attributes("-topmost", False)
except Exception:
pass
try:
root.destroy()
except Exception:
pass
root.protocol("WM_DELETE_WINDOW", _clear_ref_and_close)
_lift_above_main(root, app)
outer = tk.Frame(root, bg=p["SURFACE"])
outer.pack(fill="both", expand=True)
_header_bar(outer, p, "Autotext verwalten")
body = tk.Frame(outer, bg=p["SURFACE"])
body.pack(fill="both", expand=True, padx=12, pady=(0, 12))
btn_bar = tk.Frame(body, bg=p["SURFACE"])
btn_bar.pack(side="bottom", fill="x", pady=(10, 0))
top_part = tk.Frame(body, bg=p["SURFACE"])
top_part.pack(fill="both", expand=True)
tk.Label(
top_part,
text="Kürzel tippen und mit Leerzeichen, Tab oder Satzzeichen abschließen — "
"Ersetzung funktioniert in AzA und (wenn aktiv) global in anderen Programmen.",
bg=p["SURFACE"], fg=p["SUBTLE"], font=(FF, 9),
wraplength=640, justify="left",
).pack(anchor="w", pady=(10, 8))
data_ref = getattr(app, "_autotext_data", None)
if not isinstance(data_ref, dict):
messagebox.showerror("Autotext", "Interne Datenbasis fehlt.", parent=root)
_clear_ref_and_close()
return
def pull_disk_into_data_ref() -> None:
fresh = load_autotext()
if not isinstance(fresh, dict):
return
data_ref["enabled"] = bool(fresh.get("enabled", True))
fe = fresh.get("entries") if isinstance(fresh.get("entries"), dict) else {}
te = data_ref.setdefault("entries", {})
if isinstance(te, dict):
te.clear()
te.update(fe)
else:
data_ref["entries"] = dict(fe)
fm = fresh.get("entry_meta")
if isinstance(fm, dict):
tm = data_ref.setdefault("entry_meta", {})
if isinstance(tm, dict):
tm.clear()
tm.update(fm)
fts = fresh.get("workspace_backup_ts")
if fts is not None:
data_ref["workspace_backup_ts"] = fts
pull_disk_into_data_ref()
entries = data_ref.setdefault("entries", {})
enabled_var = tk.BooleanVar(value=bool(data_ref.get("enabled", True)))
tk.Checkbutton(
top_part, text="Autotext aktiv (In-App und global)",
variable=enabled_var,
command=lambda: _persist_enabled(),
bg=p["SURFACE"], fg=p["TEXT"], font=(FF, 9),
activebackground=p["SURFACE"], anchor="w", highlightthickness=0,
).pack(fill="x", pady=(0, 10))
def _persist_enabled() -> None:
if not isinstance(data_ref, dict):
return
data_ref["enabled"] = bool(enabled_var.get())
save_autotext(data_ref)
schedule_workspace_cloud_push()
edit_grid = tk.Frame(top_part, bg=p["SURFACE"])
edit_grid.pack(fill="x", pady=(0, 6))
tk.Label(edit_grid, text="Kürzel", bg=p["SURFACE"], fg=p["TEXT"],
font=(FF, 9, "bold")).grid(row=0, column=0, sticky="w")
tk.Label(edit_grid, text="Ersetzungstext (mehrzeilig)", bg=p["SURFACE"],
fg=p["TEXT"], font=(FF, 9, "bold")).grid(row=0, column=1, sticky="w")
abbrev_var = tk.StringVar()
ae = tk.Entry(edit_grid, textvariable=abbrev_var, width=18, relief="solid", bd=1,
highlightthickness=1, highlightbackground=p["BORDER"],
fg=p["TEXT_AREA_FG"], insertbackground=p["TEXT_AREA_FG"],
bg=p["TEXT_AREA_BG"], font=(FF, 10))
ae.grid(row=1, column=0, sticky="nw", padx=(0, 12), pady=4)
repl_box = tk.Text(
edit_grid, wrap="word", height=5, width=52, relief="flat", bd=0,
highlightthickness=1, highlightbackground=p["BORDER"],
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"], font=(FF, 10),
padx=6, pady=6,
)
repl_box.grid(row=1, column=1, sticky="ew", pady=4)
edit_grid.columnconfigure(1, weight=1)
lst_fr = tk.Frame(top_part, bg=p["SURFACE"])
lst_fr.pack(fill="both", expand=True, pady=(6, 0))
tk.Label(lst_fr, text="Gespeicherte Kürzel", bg=p["SURFACE"],
fg=p["TEXT"], font=(FF, 9, "bold")).pack(anchor="w")
lb_wrap = tk.Frame(lst_fr, bg=p["SURFACE"], highlightthickness=1,
highlightbackground=p["BORDER"])
lb_wrap.pack(fill="both", expand=False, pady=4)
scrollbar = tk.Scrollbar(lb_wrap)
scrollbar.pack(side="right", fill="y")
list_height = 5
listbox = tk.Listbox(
lb_wrap, height=list_height, font=(FF, 10), activestyle="dotbox",
yscrollcommand=scrollbar.set,
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"],
selectbackground=p.get("ACCENT_SOFT", "#E2EEF6"),
selectforeground=p.get("TEXT_STRONG", p["TEXT"]),
borderwidth=0, highlightthickness=0,
)
scrollbar.config(command=listbox.yview)
listbox.pack(side="left", fill="both", expand=False)
def refresh_list() -> None:
listbox.delete(0, "end")
ks = sorted(entries.keys(), key=lambda s: (s.lower(), s))
for k in ks:
val = entries.get(k, "") or ""
short = val.replace("\n", " ")
suffix = ("" + short[-28:]) if len(short) > 48 else ""
preview = short[:48] + suffix if len(short) > 48 else short
listbox.insert("end", f"«{k}» {preview}")
def persist() -> None:
ent = data_ref.setdefault("entries", {})
prune_autotext_meta(data_ref, set(ent.keys()))
save_autotext(data_ref)
schedule_workspace_cloud_push()
refresh_list()
refresh_list()
def on_pick(_evt=None) -> None:
sel = listbox.curselection()
if not sel:
return
keys_sorted = sorted(entries.keys(), key=lambda s: (s.lower(), s))
i = sel[0]
if i >= len(keys_sorted):
return
k = keys_sorted[i]
abbrev_var.set(k)
repl_box.delete("1.0", "end")
repl_box.insert("1.0", entries.get(k, ""))
listbox.bind("<<ListboxSelect>>", on_pick)
def reload_ui_full() -> None:
pull_disk_into_data_ref()
try:
enabled_var.set(bool(data_ref.get("enabled", True)))
except tk.TclError:
pass
refresh_list()
abbrev_var.set("")
try:
repl_box.delete("1.0", "end")
except tk.TclError:
pass
root._aza_reload_autotext_disk = reload_ui_full
def do_save_entry() -> None:
ab = abbrev_var.get().strip()
if not ab:
messagebox.showinfo("Autotext", "Bitte ein Kürzel eingeben.", parent=root)
return
entries[ab] = repl_box.get("1.0", "end").rstrip("\n")
touch_autotext_entry_meta(data_ref, ab)
persist()
getattr(app, "set_status", lambda *_: None)("Autotext gespeichert.")
abbrev_var.set("")
repl_box.delete("1.0", "end")
def do_delete_entry() -> None:
sel = listbox.curselection()
if not sel:
messagebox.showinfo("Autotext", "Bitte ein Kürzel in der Liste wählen.",
parent=root)
return
keys_sorted = sorted(entries.keys(), key=lambda s: (s.lower(), s))
i = sel[0]
if i >= len(keys_sorted):
return
k_del = keys_sorted[i]
if not messagebox.askyesno("Autotext", f"Kürzel «{k_del}» wirklich löschen?",
parent=root):
return
entries.pop(k_del, None)
em = data_ref.get("entry_meta")
if isinstance(em, dict):
em.pop(k_del, None)
persist()
abbrev_var.set("")
repl_box.delete("1.0", "end")
def _diktat_toggle() -> None:
if not dik_state["active"]:
if not callable(getattr(app, "ensure_ready", None)):
return
if not app.ensure_ready():
messagebox.showinfo(
"Diktat",
"KI-Verbindung noch nicht bereit.",
parent=root,
)
return
if callable(getattr(app, "_check_ai_consent", None)):
try:
if not app._check_ai_consent():
return
except Exception:
pass
mic_fn = getattr(app, "_ensure_microphone_ready", None)
recorder = getattr(app, "recorder", None)
transcribe_fn = getattr(app, "transcribe_wav", None)
if recorder is None or transcribe_fn is None or not callable(mic_fn):
messagebox.showinfo("Mikrofon", "Recorder nicht verfügbar.", parent=root)
return
if not mic_fn():
return
try:
recorder.start()
except Exception as exc:
messagebox.showwarning("Aufnahme", str(exc), parent=root)
return
dik_state["active"] = True
try:
btn_diktat.configure(text="Stoppen")
except tk.TclError:
pass
return
dik_state["active"] = False
try:
btn_diktat.configure(text="Diktat")
except tk.TclError:
pass
wav_path = None
try:
rec = getattr(app, "recorder", None)
if rec is None:
return
wav_path = rec.stop_and_save_wav()
except Exception:
return
def worker():
try:
from aza_audio import persist_audio_safe
transcribe_fn = getattr(app, "transcribe_wav", None)
if wav_path is None or transcribe_fn is None:
return
safe_path = None
try:
safe_path = persist_audio_safe(wav_path)
except Exception:
safe_path = wav_path
text_raw = transcribe_fn(safe_path)
raw = text_raw if isinstance(text_raw, str) else str(text_raw or "")
txt = raw.strip()
dik_apply = getattr(app, "_diktat_apply_punctuation", None)
if callable(dik_apply):
try:
txt = dik_apply(txt)
except Exception:
pass
if safe_path and safe_path != wav_path:
try:
import os
os.remove(wav_path)
except Exception:
pass
def ins() -> None:
try:
if root.winfo_exists():
chunk = (txt or "").strip()
if chunk:
cur = repl_box.get("1.0", "end").rstrip()
if not cur:
repl_box.insert("1.0", chunk)
else:
if not cur.endswith("\n"):
repl_box.insert(tk.END, "\n")
repl_box.insert(tk.END, chunk)
repl_box.see(tk.INSERT)
except tk.TclError:
pass
app.after(0, ins)
except Exception as exc_inner:
def err() -> None:
messagebox.showerror(
"Fehler bei Transkription", str(exc_inner), parent=root,
)
app.after(0, err)
threading.Thread(target=worker, daemon=True).start()
tk.Button(btn_bar, text="Speichern / Aktualisieren", command=do_save_entry,
bg=p["ACCENT"], fg="white", font=(FF, 9), relief="flat", padx=12, pady=6,
cursor="hand2", activebackground="#4A7A9E",
).pack(side="left", padx=(0, 6))
btn_diktat = tk.Button(
btn_bar, text="Diktat", command=_diktat_toggle,
bg=p["SURFACE"], fg=p["TEXT"], font=(FF, 9), relief="solid", bd=1,
padx=10, pady=5, cursor="hand2",
)
btn_diktat.pack(side="left", padx=(0, 6))
tk.Button(btn_bar, text="Löschen", command=do_delete_entry,
bg=p["SURFACE"], fg=p["TEXT"], font=(FF, 9),
relief="solid", bd=1, padx=10, pady=5, cursor="hand2",
).pack(side="left", padx=(0, 6))
tk.Button(btn_bar, text="Schließen", command=_clear_ref_and_close,
bg=p["SURFACE"], fg=p["SUBTLE"], font=(FF, 9),
relief="solid", bd=1, padx=10, pady=5, cursor="hand2",
).pack(side="right")
def _tb_windows_map(app):
d = getattr(app, _TB_WIN_DICT_ATTR, None)
if not isinstance(d, dict):
d = {}
setattr(app, _TB_WIN_DICT_ATTR, d)
return d
def open_workspace_textblock_editor(app, slot_key: str) -> None:
from aza_ui_helpers import (
center_window,
load_toplevel_geometry,
save_toplevel_geometry,
)
from aza_persistence import load_textbloecke, save_textbloecke
from aza_workspace_sync import schedule_workspace_cloud_push, utc_now_iso
p = _palette_for_app(app)
key = str(slot_key).strip()
if not key.isdigit():
return
wins = _tb_windows_map(app)
prev = wins.get(key)
if prev is not None:
try:
if prev.winfo_exists():
prev.deiconify()
_lift_above_main(prev, app)
return
except tk.TclError:
pass
data = load_textbloecke()
slot = dict(data.get(key) or {})
root = tk.Toplevel(app)
wins[key] = root
root.title(slot.get("name") or f"Textblock {key}")
root.configure(bg=p["SURFACE"])
root.minsize(500, 360)
root.transient(app)
_register_if_any(app, root)
geom_name = f"workspace_tb_{key}"
saved = load_toplevel_geometry(geom_name)
if saved:
try:
root.geometry(saved)
except tk.TclError:
root.geometry("660x520")
center_window(root, 660, 520)
else:
root.geometry("660x520")
center_window(root, 660, 520)
def _close_tb() -> None:
wm = _tb_windows_map(app)
if wm.get(key) is root:
wm.pop(key, None)
try:
save_toplevel_geometry(geom_name, root.geometry())
except Exception:
pass
try:
root.attributes("-topmost", False)
except Exception:
pass
try:
root.destroy()
except Exception:
pass
root.protocol("WM_DELETE_WINDOW", _close_tb)
_lift_above_main(root, app)
outer = tk.Frame(root, bg=p["SURFACE"])
outer.pack(fill="both", expand=True)
ttl = slot.get("name") or f"Textblock {key}"
_header_bar(outer, p, ttl)
body = tk.Frame(outer, bg=p["SURFACE"])
body.pack(fill="both", expand=True, padx=14, pady=12)
tk.Label(body, text="Anzeigename", bg=p["SURFACE"], fg=p["TEXT"],
font=(FF, 9, "bold")).pack(anchor="w")
name_var = tk.StringVar(value=str(slot.get("name") or ttl))
tk.Entry(body, textvariable=name_var, width=40,
relief="solid", bd=1, bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"],
insertbackground=p["TEXT_AREA_FG"],
highlightthickness=1, highlightbackground=p["BORDER"],
font=(FF, 10)).pack(fill="x", pady=(2, 10))
tk.Label(body, text="Inhalt", bg=p["SURFACE"], fg=p["TEXT"],
font=(FF, 9, "bold")).pack(anchor="w")
txt = ScrolledText(
body, wrap="word", height=16, font=(FF, 10),
bg=p["TEXT_AREA_BG"], fg=p["TEXT_AREA_FG"],
insertbackground=p["TEXT_AREA_FG"],
relief="flat", bd=0, highlightthickness=1,
highlightbackground=p["BORDER"], padx=8, pady=8,
)
txt.pack(fill="both", expand=True, pady=(4, 10))
txt.insert("1.0", slot.get("content") or "")
def do_save() -> None:
fresh = load_textbloecke()
nm = name_var.get().strip() or f"Textblock {key}"
fresh[key] = {
"name": nm,
"content": txt.get("1.0", "end").rstrip("\n"),
"updated_at": utc_now_iso(),
}
save_textbloecke(fresh)
schedule_workspace_cloud_push()
shell = getattr(app, "_aza_office_v1", None)
if shell is not None and hasattr(shell, "refresh_sidebar_textbloecke_section"):
try:
shell.refresh_sidebar_textbloecke_section()
except Exception:
pass
getattr(app, "set_status", lambda *_: None)(f"Textblock {key} gespeichert.")
try:
root.title(nm)
except tk.TclError:
pass
bar_fr = tk.Frame(body, bg=p["SURFACE"])
bar_fr.pack(fill="x")
tk.Button(bar_fr, text="Speichern", command=do_save,
bg=p["ACCENT"], fg="white", font=(FF, 9), relief="flat",
padx=14, pady=6, cursor="hand2",
).pack(side="left", padx=(0, 8))
tk.Button(bar_fr, text="Schließen", command=_close_tb,
bg=p["SURFACE"], fg=p["SUBTLE"], font=(FF, 9), relief="solid",
bd=1, padx=12, pady=5, cursor="hand2",
).pack(side="left")
__all__ = [
"open_workspace_autotext_manager",
"open_workspace_textblock_editor",
]