# -*- 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("", self.show_tooltip) self.widget.bind("", 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("", _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("", _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("", on_press) w.bind("", 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("", self._on_click) self.bind("", self._on_enter) self.bind("", self._on_leave) self.bind("", 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 (5–20pt).""" 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("", lambda e: _apply(_size[0] + 1)) btn_down.bind("", lambda e: _apply(_size[0] - 1)) for w in (btn_up, btn_down): w.bind("", lambda e, ww=w: ww.configure(fg=_fg_hover)) w.bind("", lambda e, ww=w: ww.configure(fg=_fg)) return _size