Files

564 lines
19 KiB
Python
Raw Permalink Normal View History

2026-04-16 13:32:32 +02:00
# -*- coding: utf-8 -*-
"""
UI-Hilfsklassen und -Funktionen für KG-Diktat Desktop.
ToolTip, RoundedButton, Geometrie-Verwaltung, Schriftgrößen-Steuerung.
"""
import os
import json
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
from aza_config import (
FIXED_FONT_SCALE, FIXED_BUTTON_SCALE,
_ALL_WINDOWS,
FONT_SIZES_CONFIG_FILENAME, PANED_POSITIONS_CONFIG_FILENAME,
get_writable_data_dir,
)
# ─── Tooltip ───
class ToolTip:
"""Zeigt Tooltips beim Überfahren mit der Maus."""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip = None
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def show_tooltip(self, event=None):
try:
x = self.widget.winfo_rootx() + 25
y = self.widget.winfo_rooty() + 25
self.tooltip = tk.Toplevel(self.widget)
self.tooltip.wm_overrideredirect(True)
self.tooltip.wm_geometry(f"+{x}+{y}")
label = tk.Label(
self.tooltip,
text=self.text,
background="#FFFACD",
foreground="#000000",
relief="solid",
borderwidth=1,
font=("Segoe UI", 9),
padx=8,
pady=4
)
label.pack()
except Exception:
pass
def hide_tooltip(self, event=None):
if self.tooltip:
self.tooltip.destroy()
self.tooltip = None
def add_tooltip(widget, text):
"""Fügt einem Widget einen Tooltip hinzu."""
return ToolTip(widget, text)
# ─── 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