diff --git a/README.md b/README.md index 2b70654..312377a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ PYTHONPATH=src python -m ccma On first start, select or create the central member-store directory. The `VERSION` file is the single source for application and package versions. +The splash screen remains visible for at least five seconds by default. This +advanced setting is intentionally not exposed in the options dialog. It can be +changed directly in `config.json`, including disabling the minimum with `0`: + +```json +"splash_minimum_seconds": 0 +``` + ## Store layout ```text diff --git a/src/ccma/app.py b/src/ccma/app.py index 789d338..b5f690d 100644 --- a/src/ccma/app.py +++ b/src/ccma/app.py @@ -55,6 +55,7 @@ class CCMAApp(tk.Tk): run_housekeeper=self.config_obj.run_housekeeper_on_startup, monitor=self.startup_monitor, housekeeper_settings=self.config_obj.housekeeper_settings(), + minimum_display_seconds=self.config_obj.splash_minimum_seconds, ) except Exception as exc: self._startup_failed(exc) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index cd1442b..518dfa9 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -27,6 +27,7 @@ "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", "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.", "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/config.py b/src/ccma/config.py index 97cd80a..67f0677 100644 --- a/src/ccma/config.py +++ b/src/ccma/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import os from dataclasses import dataclass from pathlib import Path @@ -18,6 +19,7 @@ class AppConfig: gnucash_path: str = "" theme_mode: str = "dark" run_housekeeper_on_startup: bool = True + splash_minimum_seconds: float = 5.0 birthday_days_before: int = 7 birthday_days_after: int = 2 anniversary_days_before: int = 14 @@ -40,6 +42,7 @@ class AppConfig: "gnucash_path": self.gnucash_path, "theme_mode": self.theme_mode, "run_housekeeper_on_startup": self.run_housekeeper_on_startup, + "splash_minimum_seconds": _non_negative_float(self.splash_minimum_seconds, 5.0), "birthday_days_before": self.birthday_days_before, "birthday_days_after": self.birthday_days_after, "anniversary_days_before": self.anniversary_days_before, @@ -96,6 +99,7 @@ def load_config() -> AppConfig: gnucash_path=str(data.get("gnucash_path", "")), theme_mode=str(data.get("theme_mode", "dark")), run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)), + splash_minimum_seconds=_non_negative_float(data.get("splash_minimum_seconds", 5.0), 5.0), birthday_days_before=int(data.get("birthday_days_before", 7)), birthday_days_after=int(data.get("birthday_days_after", 2)), anniversary_days_before=int(data.get("anniversary_days_before", 14)), @@ -113,3 +117,11 @@ def _legacy_config_directory() -> Path: if os.name == "nt": return Path(os.environ.get("APPDATA", Path.home())) / "C3MA" return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "c3ma" + + +def _non_negative_float(value: object, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return max(0.0, parsed) if math.isfinite(parsed) else default diff --git a/src/ccma/ui/splash.py b/src/ccma/ui/splash.py index c05c4ed..199d1e7 100644 --- a/src/ccma/ui/splash.py +++ b/src/ccma/ui/splash.py @@ -1,6 +1,8 @@ from __future__ import annotations +import math import threading +import time import tkinter as tk from collections.abc import Callable from dataclasses import dataclass @@ -33,6 +35,7 @@ class SplashScreen(tk.Toplevel): 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 @@ -41,6 +44,10 @@ class SplashScreen(tk.Toplevel): 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._messages: Queue[tuple[str, object]] = Queue() self.overrideredirect(True) self.resizable(False, False) @@ -145,7 +152,15 @@ class SplashScreen(tk.Toplevel): def _finish(self, result: StartupResult) -> None: self.progress.stop() self._set_status(f"Bereit · {len(result.findings)} Vorgänge benötigen Aufmerksamkeit") - self.after(350, lambda: self._complete(result)) + 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, callback) def _set_status(self, text: str) -> None: self.canvas.itemconfigure(self.status_item, text=text) @@ -156,6 +171,10 @@ class SplashScreen(tk.Toplevel): def _fail(self, error: Exception) -> None: self.progress.stop() + self._set_status("Start fehlgeschlagen") + self._after_minimum(lambda: self._complete_error(error)) + + def _complete_error(self, error: Exception) -> None: self.destroy() self.on_error(error) @@ -177,3 +196,8 @@ def centered_position( 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) diff --git a/tests/test_config.py b/tests/test_config.py index 3523b2a..d370931 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,7 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None gnucash_path=str(tmp_path / "club.gnucash"), theme_mode="light", run_housekeeper_on_startup=False, + splash_minimum_seconds=0, birthday_days_before=10, birthday_days_after=3, anniversary_days_before=21, @@ -26,6 +27,16 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None raw = json.loads(expected.path.read_text(encoding="utf-8")) assert raw["schema_version"] == 1 assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080] + assert raw["splash_minimum_seconds"] == 0 + + +def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config")) + assert load_config().splash_minimum_seconds == 5.0 + + config = AppConfig(splash_minimum_seconds=-10) + config.save() + assert load_config().splash_minimum_seconds == 0.0 def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None: diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index 02e6420..d239f8f 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -31,6 +31,14 @@ def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None: ) == (0, 0) +def test_splash_minimum_time_only_waits_for_remaining_duration() -> None: + from ccma.ui.splash import _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 + + def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None: from ccma.domain.models import Event from ccma.ui.member_tab import _event_label