from __future__ import annotations import math import threading import time import tkinter as tk from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from queue import Empty, Queue from ccma import __version__ from ccma.domain.models import HousekeeperFinding from ccma.services.housekeeper import Housekeeper, HousekeeperSettings from ccma.storage.repository import MemberRepository from ccma.ui.monitors import MonitorBounds, preferred_monitor @dataclass(slots=True) class StartupResult: repository: MemberRepository validation_errors: list[str] findings: list[HousekeeperFinding] class SplashScreen(tk.Toplevel): def __init__( self, master: tk.Tk, repository: MemberRepository, on_complete: Callable[[StartupResult], None], on_error: Callable[[Exception], None], *, run_housekeeper: bool = True, monitor: MonitorBounds | None = None, housekeeper_settings: HousekeeperSettings | None = None, minimum_display_seconds: float = 5.0, ): super().__init__(master) self.repository = repository self.on_complete = on_complete self.on_error = on_error self.run_housekeeper = run_housekeeper self.monitor = monitor self.housekeeper_settings = housekeeper_settings self.minimum_display_seconds = ( max(0.0, float(minimum_display_seconds)) if math.isfinite(float(minimum_display_seconds)) else 5.0 ) self.shown_at = time.monotonic() self.startup_finished = False self._progress_job: str | None = None self._messages: Queue[tuple[str, object]] = Queue() self.overrideredirect(True) self.resizable(False, False) self.configure(background="#0b1117") self.canvas: tk.Canvas self.status_item: int self._build_ui() self._center() self._update_progress() self.after(120, self._start) def _build_ui(self) -> None: width, height = 768, 512 self.geometry(f"{width}x{height}") self.canvas = tk.Canvas( self, width=width, height=height, highlightthickness=0, background="#0b1117", ) self.canvas.pack(fill="both", expand=True) image_path = Path(__file__).resolve().parent.parent / "assets" / "splash.png" self.background_image = tk.PhotoImage(master=self, file=str(image_path)).subsample(2, 2) self.background_item = self.canvas.create_image(0, 0, anchor="nw", image=self.background_image) self.canvas.create_text( width - 24, 24, anchor="ne", text=f"v{__version__}", fill="#b8c5d0", font=("TkFixedFont", 10), ) self.status_item = self.canvas.create_text( 30, 450, anchor="sw", text="Initialisiere …", fill="#e8edf2", font=("TkFixedFont", 10), ) self.progress = SplashProgressBar(self, width=708, height=16) self.canvas.create_window(30, 476, anchor="nw", width=708, height=16, window=self.progress) def _center(self) -> None: self.update_idletasks() width, height = self.winfo_width(), self.winfo_height() monitor = self.monitor or preferred_monitor(self) pointer_x, pointer_y = monitor.center x, y = centered_position( width=width, height=height, pointer_x=pointer_x, pointer_y=pointer_y, screen_x=monitor.x, screen_y=monitor.y, screen_width=monitor.width, screen_height=monitor.height, ) self.geometry(f"{width}x{height}+{x}+{y}") def _start(self) -> None: def worker() -> None: try: self._messages.put(("status", "Öffne Mitglieder-Store …")) self.repository.initialize() self._messages.put(("status", "Validiere Mitgliederakten …")) errors = self.repository.validate() self._messages.put(("status", "Baue Suchindex …")) self.repository.list_members() member_count = len(self.repository.list_member_ids()) findings = [] if self.run_housekeeper: self._messages.put(("status", "Starte Hausmeister …")) findings = Housekeeper(self.repository, self.housekeeper_settings).run( member_delay=_member_delay_for_splash( self.minimum_display_seconds, member_count, ) ) result = StartupResult(self.repository, errors, findings) self._messages.put(("result", result)) except Exception as exc: self._messages.put(("error", exc)) threading.Thread(target=worker, name="ccma-startup", daemon=True).start() self.after(30, self._poll_messages) def _poll_messages(self) -> None: keep_polling = True try: while True: kind, payload = self._messages.get_nowait() if kind == "status": self._set_status(str(payload)) elif kind == "result": keep_polling = False self._finish(payload) # type: ignore[arg-type] elif kind == "error": keep_polling = False self._fail(payload) # type: ignore[arg-type] except Empty: pass if keep_polling and self.winfo_exists(): self.after(30, self._poll_messages) def _finish(self, result: StartupResult) -> None: self.startup_finished = True self._update_progress() self._set_status(f"Bereit · {len(result.findings)} Vorgänge benötigen Aufmerksamkeit") self._after_minimum(lambda: self._complete(result)) def _after_minimum(self, callback: Callable[[], None]) -> None: remaining_ms = _remaining_minimum_ms( self.shown_at, self.minimum_display_seconds, time.monotonic(), ) self.after(remaining_ms, lambda: self._show_completed_progress(callback)) def _show_completed_progress(self, callback: Callable[[], None]) -> None: self.startup_finished = True self.progress.set(100) self.after_idle(callback) def _set_status(self, text: str) -> None: self.canvas.itemconfigure(self.status_item, text=text) def _complete(self, result: StartupResult) -> None: self._cancel_progress_job() self.destroy() self.on_complete(result) def _fail(self, error: Exception) -> None: self.startup_finished = True self._update_progress() self._set_status("Start fehlgeschlagen") self._after_minimum(lambda: self._complete_error(error)) def _complete_error(self, error: Exception) -> None: self._cancel_progress_job() self.destroy() self.on_error(error) def _update_progress(self) -> None: if not self.winfo_exists(): return elapsed = max(0.0, time.monotonic() - self.shown_at) value = _progress_value(elapsed, self.minimum_display_seconds, self.startup_finished) self.progress.set(value) if value < 100 or not self.startup_finished: self._cancel_progress_job() self._progress_job = self.after(40, self._update_progress) def _cancel_progress_job(self) -> None: if not self._progress_job: return try: self.after_cancel(self._progress_job) except tk.TclError: pass self._progress_job = None class SplashProgressBar(tk.Canvas): TRACK_COLOR = "#071a29" FILL_COLOR = "#2389c9" BORDER_COLOR = "#aeb8c2" BORDER_WIDTH = 2 def __init__(self, master: tk.Misc, *, width: int, height: int): super().__init__( master, width=width, height=height, background=self.BORDER_COLOR, borderwidth=0, highlightthickness=0, ) inset = self.BORDER_WIDTH self._fill_left = inset self._fill_top = inset self._fill_width = max(1, width - 2 * inset) self._fill_bottom = max(inset + 1, height - inset) self.create_rectangle( inset, inset, width - inset, height - inset, fill=self.TRACK_COLOR, outline="", ) self.fill_item = self.create_rectangle( self._fill_left, self._fill_top, self._fill_left, self._fill_bottom, fill=self.FILL_COLOR, outline="", ) def set(self, percent: float) -> None: value = min(100.0, max(0.0, float(percent))) fill_right = self._fill_left + self._fill_width * value / 100.0 self.coords( self.fill_item, self._fill_left, self._fill_top, fill_right, self._fill_bottom, ) def centered_position( *, width: int, height: int, pointer_x: int, pointer_y: int, screen_x: int, screen_y: int, screen_width: int, screen_height: int, ) -> tuple[int, int]: """Center around the pointer and keep the complete window on the virtual desktop.""" maximum_x = screen_x + max(0, screen_width - width) maximum_y = screen_y + max(0, screen_height - height) x = min(max(pointer_x - width // 2, screen_x), maximum_x) y = min(max(pointer_y - height // 2, screen_y), maximum_y) return x, y def _remaining_minimum_ms(started_at: float, minimum_seconds: float, now: float) -> int: remaining = max(0.0, minimum_seconds - max(0.0, now - started_at)) return int(remaining * 1000 + 0.999) def _progress_value(elapsed: float, minimum_seconds: float, startup_finished: bool) -> float: if minimum_seconds <= 0: return 100.0 if startup_finished else 5.0 elapsed_ratio = min(1.0, max(0.0, elapsed) / minimum_seconds) if startup_finished: return elapsed_ratio * 100.0 return elapsed_ratio * 95.0 def _member_delay_for_splash(minimum_seconds: float, member_count: int) -> float: if minimum_seconds <= 0 or member_count <= 0: return 0.0 return minimum_seconds / member_count