Files
aza/AzA march 2026 - Kopie (14)/aza_ui_helpers.py
2026-04-19 20:41:37 +02:00

564 lines
19 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)
# ─── 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("<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")
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 (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