Files
aza/AzA march 2026/aza_ui_helpers.py
2026-06-13 22:47:31 +02:00

1281 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)
# ─── 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
# ─── 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}")
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),
)
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")
redraw = False
if "text" in kw:
self._text = kw.pop("text")
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:
self._draw()
config = configure
# ─── 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:
"""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