# -*- 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) # ─── 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 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") if "text" in kw: self._text = kw.pop("text") self._draw() super().configure(**kw) # ─── 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 = 10) -> 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