diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 518dfa9..5831580 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -28,6 +28,7 @@ "Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.", "Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.", "Konfigurierbare Mindestanzeigezeit des Splash-Screens mit fünf Sekunden Standardwert ergänzt.", + "Theme-unabhängigen Splash-Fortschrittsbalken in Bild-Blau mit Silberrahmen und zeitbasiertem Fortschritt eingeführt.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", diff --git a/src/ccma/ui/splash.py b/src/ccma/ui/splash.py index 199d1e7..1826856 100644 --- a/src/ccma/ui/splash.py +++ b/src/ccma/ui/splash.py @@ -8,7 +8,6 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from queue import Empty, Queue -from tkinter import ttk from ccma import __version__ from ccma.domain.models import HousekeeperFinding @@ -48,6 +47,8 @@ class SplashScreen(tk.Toplevel): 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) @@ -56,6 +57,7 @@ class SplashScreen(tk.Toplevel): self.status_item: int self._build_ui() self._center() + self._update_progress() self.after(120, self._start) def _build_ui(self) -> None: @@ -88,8 +90,8 @@ class SplashScreen(tk.Toplevel): fill="#e8edf2", font=("TkFixedFont", 10), ) - self.progress = ttk.Progressbar(self, mode="indeterminate") - self.canvas.create_window(30, 476, anchor="nw", width=708, window=self.progress) + 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() @@ -109,8 +111,6 @@ class SplashScreen(tk.Toplevel): self.geometry(f"{width}x{height}+{x}+{y}") def _start(self) -> None: - self.progress.start(10) - def worker() -> None: try: self._messages.put(("status", "Öffne Mitglieder-Store …")) @@ -150,7 +150,8 @@ class SplashScreen(tk.Toplevel): self.after(30, self._poll_messages) def _finish(self, result: StartupResult) -> None: - self.progress.stop() + 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)) @@ -160,24 +161,78 @@ class SplashScreen(tk.Toplevel): self.minimum_display_seconds, time.monotonic(), ) - self.after(remaining_ms, callback) + 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.progress.stop() + 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" + + def __init__(self, master: tk.Misc, *, width: int, height: int): + inner_width = max(1, width - 4) + inner_height = max(1, height - 4) + super().__init__( + master, + width=inner_width, + height=inner_height, + background=self.TRACK_COLOR, + borderwidth=0, + highlightthickness=2, + highlightbackground=self.BORDER_COLOR, + highlightcolor=self.BORDER_COLOR, + ) + self._bar_width = inner_width + self._bar_height = inner_height + self.fill_item = self.create_rectangle(0, 0, 0, inner_height, fill=self.FILL_COLOR, outline="") + + def set(self, percent: float) -> None: + value = min(100.0, max(0.0, float(percent))) + self.coords(self.fill_item, 0, 0, self._bar_width * value / 100.0, self._bar_height) + def centered_position( *, @@ -201,3 +256,12 @@ def centered_position( 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 diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index d239f8f..67a6eb5 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -32,11 +32,15 @@ def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None: def test_splash_minimum_time_only_waits_for_remaining_duration() -> None: - from ccma.ui.splash import _remaining_minimum_ms + from ccma.ui.splash import _progress_value, _remaining_minimum_ms assert _remaining_minimum_ms(100.0, 5.0, 102.25) == 2750 assert _remaining_minimum_ms(100.0, 5.0, 106.0) == 0 assert _remaining_minimum_ms(100.0, 0.0, 100.0) == 0 + assert _progress_value(2.5, 5.0, startup_finished=False) == 47.5 + assert _progress_value(5.0, 5.0, startup_finished=False) == 95.0 + assert _progress_value(2.5, 5.0, startup_finished=True) == 50.0 + assert _progress_value(5.0, 5.0, startup_finished=True) == 100.0 def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None: