Files
aza/AzA march 2026/aza_ui_helpers.py

1281 lines
40 KiB
Python
Raw Normal View History

2026-03-25 22:03:39 +01:00
# -*- coding: utf-8 -*-
"""
UI-Hilfsklassen und -Funktionen für KG-Diktat Desktop.
ToolTip, RoundedButton, Geometrie-Verwaltung, Schriftgrößen-Steuerung.
"""
import os
import json
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
from aza_config import (
FIXED_FONT_SCALE, FIXED_BUTTON_SCALE,
_ALL_WINDOWS,
FONT_SIZES_CONFIG_FILENAME, PANED_POSITIONS_CONFIG_FILENAME,
get_writable_data_dir,
)
# ─── Tooltip ───
class ToolTip:
"""Zeigt Tooltips beim Überfahren mit der Maus."""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip = None
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def show_tooltip(self, event=None):
try:
x = self.widget.winfo_rootx() + 25
y = self.widget.winfo_rooty() + 25
self.tooltip = tk.Toplevel(self.widget)
self.tooltip.wm_overrideredirect(True)
self.tooltip.wm_geometry(f"+{x}+{y}")
label = tk.Label(
self.tooltip,
text=self.text,
background="#FFFACD",
foreground="#000000",
relief="solid",
borderwidth=1,
font=("Segoe UI", 9),
padx=8,
pady=4
)
label.pack()
except Exception:
pass
def hide_tooltip(self, event=None):
if self.tooltip:
self.tooltip.destroy()
self.tooltip = None
def add_tooltip(widget, text):
"""Fügt einem Widget einen Tooltip hinzu."""
return ToolTip(widget, text)
2026-06-13 22:47:31 +02:00
# ─── AzA-Werkzeugfenster: Action-Buttons (lesbarer Disabled-State) ───
#
# Regel für AzA-Toolfenster:
# - Buttons immer lesbar (aktiv + deaktiviert)
# - Disabled: heller Hintergrund, dunkler Text (kein ausgewaschenes Weiss)
# - Blau = normale Aktion, Rot = destruktiv
# - Untere Actionbar fix; Schliessen gut sichtbar
TOOL_ACTION_BTN_STYLES: dict[str, dict[str, dict[str, str]]] = {
"primary": {
"enabled": {
"bg": "#5B8DB3",
"fg": "#FFFFFF",
"activebackground": "#4A7A9C",
"activeforeground": "#FFFFFF",
},
"disabled": {
"bg": "#DDE8F2",
"fg": "#2F5570",
"disabledforeground": "#2F5570",
"activebackground": "#DDE8F2",
"activeforeground": "#2F5570",
},
},
"secondary": {
"enabled": {
"bg": "#3A6F8F",
"fg": "#FFFFFF",
"activebackground": "#2F5E7A",
"activeforeground": "#FFFFFF",
},
"disabled": {
"bg": "#DDE8F2",
"fg": "#3A5F75",
"disabledforeground": "#3A5F75",
"activebackground": "#DDE8F2",
"activeforeground": "#3A5F75",
},
},
"danger": {
"enabled": {
"bg": "#C04040",
"fg": "#FFFFFF",
"activebackground": "#A83838",
"activeforeground": "#FFFFFF",
},
"disabled": {
"bg": "#F0DDDD",
"fg": "#8B3535",
"disabledforeground": "#8B3535",
"activebackground": "#F0DDDD",
"activeforeground": "#8B3535",
},
},
"close": {
"enabled": {
"bg": "#5B8DB3",
"fg": "#FFFFFF",
"activebackground": "#4A7A9C",
"activeforeground": "#FFFFFF",
},
"disabled": {
"bg": "#DDE8F2",
"fg": "#2F5570",
"disabledforeground": "#2F5570",
"activebackground": "#DDE8F2",
"activeforeground": "#2F5570",
},
},
}
def create_tool_action_button(
parent,
text: str,
command,
*,
kind: str = "primary",
font=None,
padx: int = 10,
pady: int = 5,
width=None,
) -> tk.Button:
"""Action-Button für AzA-Werkzeugfenster mit lesbarem Disabled-State."""
styles = TOOL_ACTION_BTN_STYLES.get(kind, TOOL_ACTION_BTN_STYLES["primary"])
kw: dict = {
"text": text,
"command": command,
"font": font or ("Segoe UI", 8, "bold"),
"relief": "flat",
"bd": 0,
"padx": padx,
"pady": pady,
"cursor": "hand2",
**styles["enabled"],
}
if width is not None:
kw["width"] = width
btn = tk.Button(parent, **kw)
btn._aza_btn_kind = kind # type: ignore[attr-defined]
return btn
def set_tool_action_button_enabled(btn: tk.Button, enabled: bool) -> None:
"""Aktiviert/deaktiviert Action-Button — Text bleibt lesbar."""
kind = str(getattr(btn, "_aza_btn_kind", "primary") or "primary")
styles = TOOL_ACTION_BTN_STYLES.get(kind, TOOL_ACTION_BTN_STYLES["primary"])
style = styles["enabled"] if enabled else styles["disabled"]
try:
btn.config(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
**style,
)
except Exception:
pass
2026-03-25 22:03:39 +01:00
# ─── Fenster-Geometrie ───
def center_window(window, width=None, height=None):
"""Zentriert ein Fenster auf dem Bildschirm."""
window.update_idletasks()
if width is None or height is None:
geom = window.geometry().split('+')[0].split('x')
if width is None:
width = int(geom[0])
if height is None:
height = int(geom[1])
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = (screen_width - width) // 2
y = (screen_height - height) // 2
window.geometry(f"{width}x{height}+{x}+{y}")
2026-06-13 22:47:31 +02:00
def _safe_untopmost_tool(window) -> None:
try:
if window.winfo_exists():
window.attributes("-topmost", False)
except Exception:
pass
def clamp_window_position(
x: int,
y: int,
width: int,
height: int,
*,
screen_w: int | None = None,
screen_h: int | None = None,
margin: int = 8,
) -> tuple[int, int]:
"""Begrenzt Fensterposition auf sichtbaren Bildschirmbereich."""
sw = screen_w if screen_w is not None else 1920
sh = screen_h if screen_h is not None else 1080
max_x = max(margin, sw - width - margin)
max_y = max(margin, sh - height - margin)
return max(margin, min(int(x), max_x)), max(margin, min(int(y), max_y))
def get_work_area_at_point(x: int, y: int) -> tuple[int, int, int, int]:
"""Arbeitsfläche (left, top, right, bottom) des Monitors unter dem Punkt."""
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),
]
class POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
pt = POINT(int(x), int(y))
hmon = user32.MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST)
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
r = mi.rcWork
return (int(r.left), int(r.top), int(r.right), int(r.bottom))
except Exception:
return (0, 0, 1920, 1080)
def clamp_window_position_to_work_area(
x: int,
y: int,
width: int,
height: int,
work_area: tuple[int, int, int, int],
*,
margin: int = 8,
) -> tuple[int, int]:
"""Begrenzt Position auf Monitor-Arbeitsfläche (Taskleiste berücksichtigt)."""
left, top, right, bottom = work_area
work_w = max(1, int(right) - int(left))
work_h = max(1, int(bottom) - int(top))
rel_x = int(x) - int(left)
rel_y = int(y) - int(top)
rel_x, rel_y = clamp_window_position(
rel_x, rel_y, width, height, screen_w=work_w, screen_h=work_h, margin=margin,
)
return int(left) + rel_x, int(top) + rel_y
def parse_geometry_size(geometry: str) -> tuple[int, int]:
"""Liest Breite/Höhe aus Tk-Geometry-String."""
try:
part = str(geometry or "").split("+", 1)[0]
w_s, h_s = part.split("x", 1)
return max(1, int(w_s)), max(1, int(h_s))
except Exception:
return 680, 560
def _widget_anchor_offset_in_toplevel(app: tk.Misc, anchor_widget: tk.Misc) -> tuple[int, int] | None:
"""Relativer Ankerpunkt (Mitte) eines Widgets im Hauptfenster."""
try:
if anchor_widget is None or not anchor_widget.winfo_exists():
return None
app.update_idletasks()
anchor_widget.update_idletasks()
ax = int(anchor_widget.winfo_rootx()) + max(1, int(anchor_widget.winfo_width())) // 2
ay = int(anchor_widget.winfo_rooty()) + max(1, int(anchor_widget.winfo_height())) // 2
ox = ax - int(app.winfo_rootx())
oy = ay - int(app.winfo_rooty())
return ox, oy
except Exception:
return None
def restore_main_window_at_cursor(
app: tk.Misc,
cursor_x: int,
cursor_y: int,
*,
anchor_widget: tk.Misc | None = None,
fallback_anchor: str = "top_right",
) -> None:
"""Hauptfenster an Cursorposition wiederherstellen (Größe beibehalten)."""
cx, cy = int(cursor_x), int(cursor_y)
was_zoomed = bool(getattr(app, "_mini_restore_was_zoomed", False))
try:
app.deiconify()
except Exception:
pass
if was_zoomed:
try:
work = get_work_area_at_point(cx, cy)
left, top = int(work[0]), int(work[1])
width, height = parse_geometry_size(
str(getattr(app, "_mini_restore_geometry", "") or "")
)
app.geometry(f"{width}x{height}+{left}+{top}")
app.update_idletasks()
app.state("zoomed")
except Exception:
try:
app.state("zoomed")
except Exception:
pass
try:
if hasattr(app, "_apply_main_topmost_state"):
app._apply_main_topmost_state()
app.lift()
app.focus_force()
except Exception:
pass
return
geom = getattr(app, "_mini_restore_geometry", None) or ""
width, height = parse_geometry_size(str(geom) if geom else "")
if width <= 1 or height <= 1:
try:
app.update_idletasks()
width = max(680, int(app.winfo_width() or 680))
height = max(560, int(app.winfo_height() or 560))
except Exception:
width, height = 680, 560
offset = _widget_anchor_offset_in_toplevel(app, anchor_widget)
if offset is None:
btn = getattr(app, "_btn_mini_record", None)
offset = _widget_anchor_offset_in_toplevel(app, btn)
if offset is None:
btn = getattr(app, "_btn_minimize", None)
offset = _widget_anchor_offset_in_toplevel(app, btn)
if offset is None:
if fallback_anchor == "top_left":
offset = (24, 24)
else:
offset = (max(width - 24, 24), 24)
win_x = cx - int(offset[0])
win_y = cy - int(offset[1])
work = get_work_area_at_point(cx, cy)
win_x, win_y = clamp_window_position_to_work_area(
win_x, win_y, width, height, work,
)
try:
app.geometry(f"{width}x{height}+{win_x}+{win_y}")
except Exception:
pass
try:
app.update_idletasks()
except Exception:
pass
try:
if hasattr(app, "_apply_main_topmost_state"):
app._apply_main_topmost_state()
elif hasattr(app, "lift"):
app.lift()
app.lift()
app.focus_force()
except Exception:
try:
app.lift()
except Exception:
pass
def restore_tool_window_at_cursor(
window: tk.Misc,
cursor_x: int,
cursor_y: int,
*,
anchor_widget: tk.Misc | None = None,
fallback_anchor: str = "top_right",
) -> None:
"""Tool-Fenster (Diktat etc.) an Cursorposition wiederherstellen (Größe beibehalten)."""
cx, cy = int(cursor_x), int(cursor_y)
try:
window.deiconify()
except Exception:
pass
geom = getattr(window, "_mini_restore_geometry", None) or ""
width, height = parse_geometry_size(str(geom) if geom else "")
if width <= 1 or height <= 1:
try:
window.update_idletasks()
width = max(int(window.winfo_width() or 420), 420)
height = max(int(window.winfo_height() or 380), 380)
except Exception:
width, height = 420, 380
offset = _widget_anchor_offset_in_toplevel(window, anchor_widget)
if offset is None:
if fallback_anchor == "top_left":
offset = (24, 24)
else:
offset = (max(width - 24, 24), 24)
win_x = cx - int(offset[0])
win_y = cy - int(offset[1])
work = get_work_area_at_point(cx, cy)
win_x, win_y = clamp_window_position_to_work_area(
win_x, win_y, width, height, work,
)
try:
window.geometry(f"{width}x{height}+{win_x}+{win_y}")
except Exception:
pass
try:
window.update_idletasks()
except Exception:
pass
try:
if bool(getattr(window, "_tool_pinned", False)):
apply_tool_window_pin(window, True)
window.lift()
window.focus_force()
except Exception:
try:
window.lift()
except Exception:
pass
def apply_tool_window_pin(window: tk.Misc, pinned: bool) -> None:
"""Tool-Fenster-Pin (eigener State, unabhängig vom Hauptfenster)."""
try:
window._tool_pinned = bool(pinned)
window.attributes("-topmost", bool(pinned))
if pinned:
window.lift()
except Exception:
pass
def toggle_tool_window_pin(window: tk.Misc) -> bool:
new_val = not bool(getattr(window, "_tool_pinned", False))
apply_tool_window_pin(window, new_val)
return new_val
def refresh_tool_pin_button(btn: tk.Misc | None, pinned: bool) -> None:
if btn is None:
return
try:
btn.configure(
text=("📌" if pinned else "📍"),
fg=("#FFFFFF" if pinned else "#E8F4FA"),
)
except Exception:
pass
def _restore_tool_topmost(tool_win: tk.Misc | None) -> None:
if tool_win is None:
return
try:
if not tool_win.winfo_exists():
return
except Exception:
return
if getattr(tool_win, "_tool_pinned", None) is not None:
apply_tool_window_pin(tool_win, bool(getattr(tool_win, "_tool_pinned", False)))
def add_tool_pin_button(
header: tk.Misc,
window: tk.Misc,
*,
bg: str,
side: str = "right",
padx: tuple[int, int] | int = (0, 4),
) -> tk.Label:
"""Pinnnadel für Tool-Fenster (Session-State, nicht persistent)."""
window._tool_pinned = False
btn = tk.Label(
header,
text="📍",
font=("Segoe UI Emoji", 12),
bg=bg,
fg="#E8F4FA",
cursor="hand2",
padx=8,
)
btn.pack(side=side, padx=padx)
def _toggle(_evt=None):
pinned = toggle_tool_window_pin(window)
refresh_tool_pin_button(btn, pinned)
btn.bind("<Button-1>", _toggle)
tip = ToolTip(btn, "Fenster immer im Vordergrund")
def _refresh_tip(_evt=None):
pinned = bool(getattr(window, "_tool_pinned", False))
tip.text = "Fixierung lösen" if pinned else "Fenster immer im Vordergrund"
btn.bind("<Enter>", _refresh_tip, add="+")
window._tool_pin_btn = btn
return btn
def bring_tool_window_to_front(window, *, flash_ms: int = 300) -> None:
"""Kurz in den Vordergrund (topmost-Flash), ohne dauerhaftes Always-on-top."""
if getattr(window, "_main_pinned", None) is not None:
try:
window.lift()
if hasattr(window, "_apply_main_topmost_state"):
window._apply_main_topmost_state()
else:
window.attributes("-topmost", bool(getattr(window, "_main_pinned", False)))
window.focus_force()
except Exception:
pass
return
try:
window.lift()
window.attributes("-topmost", True)
if flash_ms > 0:
window.after(flash_ms, lambda: _safe_untopmost_tool(window))
window.focus_force()
except Exception:
pass
def center_tool_window(
window,
width: int | None = None,
height: int | None = None,
*,
parent=None,
y_ratio: float = 0.08,
vertical_center: bool = False,
bring_to_front: bool = True,
) -> None:
"""Zentriert AzA-Werkzeugfenster horizontal; optional vertikal mittig."""
try:
window.update_idletasks()
except Exception:
pass
if width is None or height is None:
try:
geom = window.geometry().split("+")[0].split("x")
if width is None:
width = int(geom[0])
if height is None:
height = int(geom[1])
except Exception:
width = width or 400
height = height or 300
if parent is not None:
try:
window.transient(parent)
except Exception:
pass
try:
anchor = parent if parent is not None else window
sw = anchor.winfo_screenwidth()
sh = anchor.winfo_screenheight()
sx = anchor.winfo_rootx()
sy = anchor.winfo_rooty()
pw = max(int(anchor.winfo_width() or 0), 1)
ph = max(int(anchor.winfo_height() or 0), 1)
if parent is not None and pw > 1 and ph > 1:
x = sx + max(0, (pw - width) // 2)
if vertical_center:
y = sy + max(0, (ph - height) // 2)
else:
y = sy + max(0, int(ph * y_ratio))
else:
x = sx + max(0, (sw - width) // 2)
if vertical_center:
y = sy + max(0, (sh - height) // 2)
else:
y = sy + max(0, int(sh * y_ratio))
x, y = clamp_window_position(x, y, width, height, sw, sh)
window.geometry(f"{width}x{height}+{x}+{y}")
except Exception:
try:
window.geometry(f"{width}x{height}")
except Exception:
pass
if bring_to_front:
bring_tool_window_to_front(window)
# ─── Modale Dialoge / Messageboxes (immer sichtbar über gepinntem Hauptfenster) ───
def _resolve_toplevel_parent(widget) -> tk.Misc | None:
if widget is None:
return None
try:
if widget.winfo_exists():
return widget.winfo_toplevel()
except Exception:
pass
return widget
def _find_main_app_window(widget) -> tk.Misc | None:
w = widget
while w is not None:
if getattr(w, "_main_pinned", None) is not None:
return w
try:
w = w.master
except Exception:
break
return None
def _restore_main_topmost(main) -> None:
if main is None:
return
try:
if hasattr(main, "_apply_main_topmost_state"):
main._apply_main_topmost_state()
elif getattr(main, "_main_pinned", None) is not None:
main.attributes("-topmost", bool(main._main_pinned))
except Exception:
pass
def prepare_dialog_parent(parent) -> tk.Misc | None:
"""Parent-Toplevel für Dialog sichtbar nach vorne holen."""
parent_tl = _resolve_toplevel_parent(parent)
if parent_tl is None:
return None
try:
parent_tl.lift()
parent_tl.focus_force()
parent_tl.update_idletasks()
except Exception:
pass
return parent_tl
def make_modal_topmost(dialog, parent=None, *, grab: bool = True) -> None:
"""Modaler Toplevel-Dialog: transient, grab, lift, topmost (über Pin-Hauptfenster)."""
parent_tl = prepare_dialog_parent(parent)
if parent_tl is not None:
try:
dialog.transient(parent_tl)
except Exception:
pass
try:
dialog.update_idletasks()
dialog.attributes("-topmost", True)
dialog.lift()
dialog.focus_force()
if grab:
dialog.grab_set()
except Exception:
pass
def release_modal_dialog(dialog, parent=None) -> None:
"""Grab/Topmost lösen; Hauptfenster-Pin-State wiederherstellen."""
try:
if dialog.winfo_exists():
dialog.grab_release()
dialog.attributes("-topmost", False)
except Exception:
pass
main = _find_main_app_window(_resolve_toplevel_parent(parent or dialog))
_restore_main_topmost(main)
def _run_with_visible_parent(parent, func):
"""Messagebox/simpledialog: Parent kurz topmost, danach Pin wiederherstellen."""
parent_tl = prepare_dialog_parent(parent)
main = _find_main_app_window(parent_tl) if parent_tl else None
if parent_tl is not None:
try:
parent_tl.attributes("-topmost", True)
parent_tl.lift()
parent_tl.focus_force()
parent_tl.update_idletasks()
except Exception:
pass
try:
return func()
finally:
if parent_tl is not None:
try:
parent_tl.attributes("-topmost", False)
except Exception:
pass
_restore_tool_topmost(parent_tl)
_restore_main_topmost(main)
def aza_showinfo(title, message, *, parent=None):
from tkinter import messagebox
return _run_with_visible_parent(
parent, lambda: messagebox.showinfo(title, message, parent=parent),
)
def aza_showwarning(title, message, *, parent=None):
from tkinter import messagebox
return _run_with_visible_parent(
parent, lambda: messagebox.showwarning(title, message, parent=parent),
)
def aza_showerror(title, message, *, parent=None):
from tkinter import messagebox
return _run_with_visible_parent(
parent, lambda: messagebox.showerror(title, message, parent=parent),
)
def aza_askyesno(title, message, *, parent=None):
from tkinter import messagebox
return _run_with_visible_parent(
parent, lambda: messagebox.askyesno(title, message, parent=parent),
)
def aza_askokcancel(title, message, *, parent=None):
from tkinter import messagebox
return _run_with_visible_parent(
parent, lambda: messagebox.askokcancel(title, message, parent=parent),
)
def aza_askstring(title, prompt, *, parent=None, **kwargs):
from tkinter import simpledialog
return _run_with_visible_parent(
parent, lambda: simpledialog.askstring(title, prompt, parent=parent, **kwargs),
)
2026-03-25 22:03:39 +01:00
def save_toplevel_geometry(window_name: str, geometry: str) -> None:
"""Speichert die Geometrie eines Toplevel-Fensters."""
try:
config_file = os.path.join(
get_writable_data_dir(),
f"kg_diktat_{window_name}_geometry.txt"
)
with open(config_file, "w", encoding="utf-8") as f:
f.write(geometry)
except Exception:
pass
def load_toplevel_geometry(window_name: str) -> str:
"""Lädt die gespeicherte Geometrie eines Toplevel-Fensters."""
try:
config_file = os.path.join(
get_writable_data_dir(),
f"kg_diktat_{window_name}_geometry.txt"
)
if os.path.isfile(config_file):
with open(config_file, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return None
def setup_window_geometry_saving(window, window_name: str, default_width: int, default_height: int):
"""Richtet automatisches Speichern der Fensterposition ein und lädt gespeicherte Position."""
saved_geom = load_toplevel_geometry(window_name)
if saved_geom:
try:
window.geometry(saved_geom)
except Exception:
window.geometry(f"{default_width}x{default_height}")
center_window(window, default_width, default_height)
else:
window.geometry(f"{default_width}x{default_height}")
center_window(window, default_width, default_height)
def on_close():
try:
geom = window.geometry()
save_toplevel_geometry(window_name, geom)
except Exception:
pass
try:
window.destroy()
except Exception:
pass
window.protocol("WM_DELETE_WINDOW", on_close)
# ─── Resize-Griff ───
def add_resize_grip(win, min_w=None, min_h=None):
"""Fuegt unten rechts einen sichtbaren Resize-Griff hinzu.
Verwendet place() mit lift(), damit der Grip garantiert ueber allen
pack/grid-Inhalten schwebt und nicht ueberdeckt wird.
Ein after()-Aufruf stellt sicher, dass der Grip nach dem Layout
nochmals nach vorn gebracht wird.
"""
mw, mh = win.minsize() if win.minsize() else (300, 200)
min_w = min_w if min_w is not None else mw
min_h = min_h if min_h is not None else mh
grip = tk.Frame(win, width=28, height=28, bg="#C8C8C8", cursor="size_nw_se",
highlightthickness=1, highlightbackground="#A0A0A0")
grip.place(relx=1.0, rely=1.0, anchor="se")
grip.pack_propagate(False)
lbl = tk.Label(grip, text="\u22F0", font=("Segoe UI", 12), bg="#C8C8C8",
fg="#555", cursor="size_nw_se")
lbl.pack(fill="both", expand=True)
grip.lift()
def _ensure_on_top():
try:
grip.lift()
except Exception:
pass
win.after(100, _ensure_on_top)
win.after(500, _ensure_on_top)
data = [0, 0, 0, 0]
def on_press(e):
data[0], data[1] = e.x_root, e.y_root
data[2], data[3] = win.winfo_width(), win.winfo_height()
def on_motion(e):
nw = max(min_w, data[2] + (e.x_root - data[0]))
nh = max(min_h, data[3] + (e.y_root - data[1]))
win.geometry(f"{int(nw)}x{int(nh)}")
for w in (grip, lbl):
w.bind("<ButtonPress-1>", on_press)
w.bind("<B1-Motion>", on_motion)
# ─── Abgerundeter Button ───
def _round_rect(canvas, x1, y1, x2, y2, r=8, **kw):
"""Zeichnet ein abgerundetes Rechteck auf dem Canvas."""
fill = kw.get("fill", kw.get("outline", "gray"))
if r <= 0:
canvas.create_rectangle(x1, y1, x2, y2, **kw)
return
canvas.create_rectangle(x1 + r, y1, x2 - r, y2, **kw)
canvas.create_rectangle(x1, y1 + r, x2, y2 - r, **kw)
canvas.create_arc(x1, y1, x1 + 2 * r, y1 + 2 * r, start=90, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x2 - 2 * r, y1, x2, y1 + 2 * r, start=0, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x2 - 2 * r, y2 - 2 * r, x2, y2, start=270, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x1, y2 - 2 * r, x1 + 2 * r, y2, start=180, extent=90, style=tk.PIESLICE, **kw)
def _blend_color(hex1: str, hex2: str, factor: float) -> str:
"""Mischt zwei Hex-Farben. factor=0 → hex1, factor=1 → hex2."""
factor = max(0.0, min(1.0, factor))
try:
r1, g1, b1 = int(hex1[1:3], 16), int(hex1[3:5], 16), int(hex1[5:7], 16)
r2, g2, b2 = int(hex2[1:3], 16), int(hex2[3:5], 16), int(hex2[5:7], 16)
r = int(r1 + (r2 - r1) * factor)
g = int(g1 + (g2 - g1) * factor)
b = int(b1 + (b2 - b1) * factor)
return f"#{r:02x}{g:02x}{b:02x}"
except Exception:
return hex1
# Pastell-Teal-Orange: warm, aber weicher/sanfter.
_USAGE_HEAT_TARGET_BG = "#FDC8A3"
_USAGE_HEAT_TARGET_ACTIVE = "#F7BB91"
_USAGE_HEAT_STEP = 0.10
_USAGE_HEAT_FILE = os.path.join(get_writable_data_dir(), "kg_diktat_button_heat.json")
_button_heat_data: dict = {}
_button_heat_session_used: set = set()
_all_rounded_buttons: list = []
def _load_button_heat():
global _button_heat_data
try:
if os.path.isfile(_USAGE_HEAT_FILE):
with open(_USAGE_HEAT_FILE, "r", encoding="utf-8") as f:
_button_heat_data = json.load(f)
except Exception:
_button_heat_data = {}
def save_button_heat():
"""Beim App-Beenden aufrufen: unbenutzte Buttons um 1 reduzieren, dann speichern."""
for key in list(_button_heat_data.keys()):
if key not in _button_heat_session_used:
_button_heat_data[key] = max(0, _button_heat_data[key] - 1)
if _button_heat_data[key] <= 0:
del _button_heat_data[key]
try:
with open(_USAGE_HEAT_FILE, "w", encoding="utf-8") as f:
json.dump(_button_heat_data, f, indent=2, ensure_ascii=False)
except Exception:
pass
def reset_button_heat():
"""Setzt alle Button-Farben auf Originalzustand zurück."""
global _button_heat_data, _button_heat_session_used
_button_heat_data.clear()
_button_heat_session_used.clear()
try:
if os.path.isfile(_USAGE_HEAT_FILE):
os.remove(_USAGE_HEAT_FILE)
except Exception:
pass
for btn in _all_rounded_buttons:
try:
if btn.winfo_exists():
btn._click_count = 0
btn._bg = btn._orig_bg
btn._active_bg = btn._orig_active_bg
btn._draw()
except Exception:
pass
_load_button_heat()
class RoundedButton(tk.Canvas):
"""Button mit abgerundeten Ecken (gleiche Nutzung wie ttk.Button)."""
def __init__(self, parent, text, command=None, bg="#7EC8E3", fg="#1a4d6d", active_bg="#5AB9E8", radius=8, width=None, height=None, canvas_bg=None, **kw):
self._base_width = width if width is not None else 120
self._base_height = height if height is not None else 32
kw.setdefault("highlightthickness", 0)
if canvas_bg is not None:
kw["bg"] = canvas_bg
super().__init__(parent, width=self._base_width, height=self._base_height, **kw)
self._command = command
self._orig_bg = bg
self._orig_active_bg = active_bg
self._bg, self._fg = bg, fg
self._active_bg = active_bg
self._radius = radius
self._text = text
self._heat_key = text
self._base_font_size = 11
self._font_size_scale = 1.0
self._button_size_scale = 1.0
self._click_count = _button_heat_data.get(self._heat_key, 0)
if self._click_count > 0:
self._apply_usage_heat()
_all_rounded_buttons.append(self)
self.bind("<Button-1>", self._on_click)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
self.bind("<Configure>", lambda e: self._draw())
self._draw()
def set_font_size_scale(self, scale: float):
self._font_size_scale = scale
self._draw()
def set_button_size_scale(self, scale: float):
self._button_size_scale = scale
new_width = int(self._base_width * scale)
new_height = int(self._base_height * scale)
self.configure(width=new_width, height=new_height)
self._draw()
def set_font_scale(self, scale: float):
self.set_font_size_scale(scale)
self.set_button_size_scale(scale)
def _apply_usage_heat(self):
"""Farbe Richtung Orange verschieben basierend auf Klickanzahl."""
if getattr(self, "lock_color", False):
return
factor = min(self._click_count * _USAGE_HEAT_STEP, 1.0)
self._bg = _blend_color(self._orig_bg, _USAGE_HEAT_TARGET_BG, factor)
self._active_bg = _blend_color(self._orig_active_bg, _USAGE_HEAT_TARGET_ACTIVE, factor)
def _draw(self, active=False):
self.delete("all")
w, h = self.winfo_width(), self.winfo_height()
if w <= 1:
w = int(self._base_width * self._button_size_scale)
if h <= 1:
h = int(self._base_height * self._button_size_scale)
fill = self._active_bg if active else self._bg
_round_rect(self, 0, 0, w, h, self._radius, fill=fill, outline=fill)
font_size = max(5, int(16 * self._font_size_scale))
self.create_text(w // 2, h // 2, text=self._text, fill=self._fg, font=("Segoe UI", font_size))
def _on_click(self, event):
self._click_count += 1
_button_heat_data[self._heat_key] = self._click_count
_button_heat_session_used.add(self._heat_key)
self._apply_usage_heat()
self._draw(active=True)
if self._command:
self._command()
def _on_enter(self, event):
self._draw(active=True)
def _on_leave(self, event):
self._draw(active=False)
def configure(self, **kw):
if "command" in kw:
self._command = kw.pop("command")
2026-06-10 22:55:03 +02:00
redraw = False
2026-03-25 22:03:39 +01:00
if "text" in kw:
self._text = kw.pop("text")
2026-06-10 22:55:03 +02:00
redraw = True
# Eigene Style-Optionen abfangen (gleiche Namen wie im Konstruktor),
# damit sie NICHT an tk.Canvas weitergereicht werden ("unknown option").
if "bg" in kw:
self._bg = kw.pop("bg")
self._orig_bg = self._bg
redraw = True
if "fg" in kw:
self._fg = kw.pop("fg")
redraw = True
if "active_bg" in kw:
self._active_bg = kw.pop("active_bg")
self._orig_active_bg = self._active_bg
redraw = True
if kw:
super().configure(**kw)
if redraw:
2026-03-25 22:03:39 +01:00
self._draw()
2026-06-10 22:55:03 +02:00
config = configure
2026-03-25 22:03:39 +01:00
# ─── Schriftgrößen-Skalierung ───
def add_font_scale_control(win, on_change_callback=None):
"""Wendet fixierte optimale Schrift- und Button-Größen an."""
if win not in _ALL_WINDOWS:
_ALL_WINDOWS.append(win)
def on_close():
try:
if win in _ALL_WINDOWS:
_ALL_WINDOWS.remove(win)
except Exception:
pass
try:
win.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_close)
win.after(50, lambda: scale_window_fonts(win, FIXED_FONT_SCALE))
win.after(100, lambda: scale_window_buttons(win, FIXED_BUTTON_SCALE))
return None, None, None
def scale_window_fonts(win, scale: float):
"""Skaliert nur die Schriftgrößen in Text-Widgets und Labels."""
try:
def scale_recursive(widget):
try:
if isinstance(widget, (tk.Text, ScrolledText)):
try:
current_font = widget.cget("font")
if isinstance(current_font, str):
current_font = tkfont.Font(font=current_font)
elif isinstance(current_font, tuple):
base_size = 16
new_size = max(5, int(base_size * scale))
widget.configure(font=(current_font[0], new_size))
except Exception:
pass
elif isinstance(widget, tk.Label):
try:
current_font = widget.cget("font")
if isinstance(current_font, tuple) and len(current_font) >= 2:
base_size = 16
new_size = max(5, int(base_size * scale))
widget.configure(font=(current_font[0], new_size))
except Exception:
pass
elif isinstance(widget, RoundedButton):
widget.set_font_size_scale(scale)
for child in widget.winfo_children():
scale_recursive(child)
except Exception:
pass
scale_recursive(win)
except Exception:
pass
def scale_window_buttons(win, scale: float):
"""Skaliert nur die Button-Größen."""
try:
def scale_recursive(widget):
try:
if isinstance(widget, RoundedButton):
widget.set_button_size_scale(scale)
for child in widget.winfo_children():
scale_recursive(child)
except Exception:
pass
scale_recursive(win)
except Exception:
pass
def scale_window_widgets(win, scale: float):
"""Legacy-Funktion für Rückwärtskompatibilität."""
scale_window_fonts(win, scale)
scale_window_buttons(win, scale)
def apply_initial_scaling_to_window(win):
"""Wendet initiale Skalierung auf ein neu erstelltes Fenster an."""
from aza_persistence import load_font_scale, load_button_scale
try:
if win not in _ALL_WINDOWS:
_ALL_WINDOWS.append(win)
font_scale = load_font_scale()
button_scale = load_button_scale()
win.after(100, lambda: scale_window_fonts(win, font_scale))
win.after(150, lambda: scale_window_buttons(win, button_scale))
except Exception:
pass
# ─── Textfeld-Schriftgrößen (per-window) ───
def _font_sizes_config_path():
return os.path.join(get_writable_data_dir(), FONT_SIZES_CONFIG_FILENAME)
def load_paned_positions():
"""Lädt gespeicherte PanedWindow-Positionen."""
try:
path = os.path.join(get_writable_data_dir(), PANED_POSITIONS_CONFIG_FILENAME)
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def save_paned_positions(positions):
"""Speichert PanedWindow-Positionen."""
try:
path = os.path.join(get_writable_data_dir(), PANED_POSITIONS_CONFIG_FILENAME)
with open(path, "w", encoding="utf-8") as f:
json.dump(positions, f, indent=2)
except Exception:
pass
def load_text_font_size(key: str, default: int = 9) -> int:
2026-03-25 22:03:39 +01:00
"""Lädt gespeicherte Schriftgröße für einen bestimmten Text-Widget."""
try:
path = _font_sizes_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return int(data.get(key, default))
except Exception:
pass
return default
def save_text_font_size(key: str, size: int):
"""Speichert Schriftgröße für einen bestimmten Text-Widget."""
try:
path = _font_sizes_config_path()
data = {}
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
data[key] = int(size)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception:
pass
def add_text_font_size_control(parent_frame, text_widget, initial_size=10, label="Aa", bg_color="#F5FCFF", save_key=None):
"""Fügt ▲▼-Pfeile für Textfeld-Schriftgrößen hinzu (520pt)."""
if save_key:
initial_size = load_text_font_size(save_key, initial_size)
_size = [max(5, min(20, initial_size))]
_fg = "#8AAFC0"
_fg_hover = "#1a4d6d"
control_frame = tk.Frame(parent_frame, bg=bg_color, highlightthickness=0, bd=0)
control_frame.pack(side="right", padx=4)
lbl = tk.Label(control_frame, text=label, font=("Segoe UI", 8),
bg=bg_color, fg=_fg)
lbl.pack(side="left", padx=(0, 1))
size_lbl = tk.Label(control_frame, text=str(_size[0]), font=("Segoe UI", 8),
bg=bg_color, fg=_fg, width=2, anchor="center")
size_lbl.pack(side="left")
def _apply(new_size):
new_size = max(5, min(20, new_size))
_size[0] = new_size
size_lbl.configure(text=str(new_size))
text_widget.configure(font=("Segoe UI", new_size))
if save_key:
save_text_font_size(save_key, new_size)
text_widget.configure(font=("Segoe UI", _size[0]))
btn_up = tk.Label(control_frame, text="\u25B2", font=("Segoe UI", 7),
bg=bg_color, fg=_fg, cursor="hand2",
bd=0, highlightthickness=0, padx=0, pady=0)
btn_up.pack(side="left", padx=(2, 0))
btn_down = tk.Label(control_frame, text="\u25BC", font=("Segoe UI", 7),
bg=bg_color, fg=_fg, cursor="hand2",
bd=0, highlightthickness=0, padx=0, pady=0)
btn_down.pack(side="left", padx=(0, 0))
btn_up.bind("<Button-1>", lambda e: _apply(_size[0] + 1))
btn_down.bind("<Button-1>", lambda e: _apply(_size[0] - 1))
for w in (btn_up, btn_down):
w.bind("<Enter>", lambda e, ww=w: ww.configure(fg=_fg_hover))
w.bind("<Leave>", lambda e, ww=w: ww.configure(fg=_fg))
return _size