564 lines
19 KiB
Python
564 lines
19 KiB
Python
# -*- 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 (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("<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
|