diff --git a/install.sh b/install.sh index b1def8e..1cc27c1 100755 --- a/install.sh +++ b/install.sh @@ -150,6 +150,8 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ || ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \ + || ! -f "${SOURCE_ROOT}/ptlinux/ui/__init__.py" \ + || ! -f "${SOURCE_ROOT}/ptlinux/ui/dashboard.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ @@ -209,15 +211,20 @@ install -d -m 0755 \ "${STAGING_RELEASE}/ptlinux" \ "${STAGING_RELEASE}/ptlinux/native" \ "${STAGING_RELEASE}/ptdiag" -for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py +shopt -s globstar nullglob +for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"**/*.py do - case "${SOURCE_MODULE##*/}" in - desktop_entry.py|launcher_wrapper.py) - continue - ;; + RELATIVE_MODULE="${SOURCE_MODULE#"${SOURCE_ROOT}/ptlinux/"}" + case "$RELATIVE_MODULE" in + desktop_entry.py|launcher_wrapper.py) + continue + ;; esac - install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/" + install -D -m 0644 \ + "$SOURCE_MODULE" \ + "${STAGING_RELEASE}/ptlinux/${RELATIVE_MODULE}" done +shopt -u globstar nullglob install -m 0644 \ "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ "${STAGING_RELEASE}/ptlinux/launcher-update-public.pem" diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index 7aafb36..502889b 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -914,7 +914,7 @@ def _self_check_release(release: Path, expected_version: str) -> None: check = ( "import sys; " "sys.path.insert(0, sys.argv[1]); " - "import ptlinux, ptlinux.cli, ptlinux.self_update; " + "import ptlinux, ptlinux.cli, ptlinux.self_update, ptlinux.ui; " "raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)" ) try: diff --git a/ptlinux/ui/__init__.py b/ptlinux/ui/__init__.py new file mode 100644 index 0000000..e410f0d --- /dev/null +++ b/ptlinux/ui/__init__.py @@ -0,0 +1,150 @@ +"""Public interface for the native PristonTale EU dashboard.""" + +from .dashboard import launch_external_url, patch_change_colors, run_dashboard +from .viewmodels.dashboard import ( + ACTIVE_POLL_INTERVAL_MILLISECONDS, + BASE_DISPLAY_DPI, + BASE_TK_SCALING, + DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS, + DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + DASHBOARD_STARTUP_COMPONENTS, + DASHBOARD_STARTUP_REQUIRED_COMPONENTS, + DASHBOARD_TRANSITION_MINIMUM_OPACITY, + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + DASHBOARD_TRANSITION_STEPS, + IDLE_POLL_INTERVAL_MILLISECONDS, + POLL_INTERVAL_MILLISECONDS, + PRELOADER_STATUS_CHARACTERS, + STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, + UI_SCALE_FACTORS, + UI_SCALE_LABELS, + WINDOW_HEIGHT, + WINDOW_WIDTH, + DashboardError, + GameClientDebugState, + GameClientTracker, + GameLaunchSupervisorRegistry, + LauncherUpdatePresentation, + dashboard_content_ready, + dashboard_controls_stacked, + dashboard_poll_interval, + dashboard_preloader_delay, + dashboard_transition_opacity, + debug_elapsed_text, + fitted_window_size, + freeze_preloader_geometry, + game_client_activity_text, + interrupt_game_supervisor, + launcher_child_command, + launcher_restart_spec, + launcher_update_presentation, + news_card_columns, + preloader_logo_subsample_factor, + preloader_status_text, + resolve_ui_scale, + setting_label, + setting_value, + startup_progress_animation_value, +) +from .viewmodels.downloader import ( + DOWNLOAD_SPEED_CUSTOM_LABEL, + DOWNLOAD_SPEED_PRESETS_MBPS, + TRANSFER_RATE_IDLE_SECONDS, + TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, + TRANSFER_RATE_WINDOW_SECONDS, + ProgressCoalescer, + TransferRateEstimator, + download_activity_metadata, + download_speed_setting_label, + download_speed_setting_value, + format_eta, + format_transfer_rate, + operation_activity_text, + should_refresh_operation_elapsed, +) +from .viewmodels.health import ( + SERVER_STATUS_STALE_SECONDS, + ServerStatusCheck, + ServerStatusPresentation, + perform_server_status_check, + server_status_check_text, + server_status_presentation, +) +from .viewmodels.installation import ( + GameSyncPresentation, + game_file_progress_status, + game_sync_presentation, +) + + +__all__ = [ + "ACTIVE_POLL_INTERVAL_MILLISECONDS", + "BASE_DISPLAY_DPI", + "BASE_TK_SCALING", + "DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS", + "DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS", + "DASHBOARD_STARTUP_COMPONENTS", + "DASHBOARD_STARTUP_REQUIRED_COMPONENTS", + "DASHBOARD_TRANSITION_MINIMUM_OPACITY", + "DASHBOARD_TRANSITION_STEP_MILLISECONDS", + "DASHBOARD_TRANSITION_STEPS", + "DOWNLOAD_SPEED_CUSTOM_LABEL", + "DOWNLOAD_SPEED_PRESETS_MBPS", + "DashboardError", + "GameClientDebugState", + "GameClientTracker", + "GameLaunchSupervisorRegistry", + "GameSyncPresentation", + "IDLE_POLL_INTERVAL_MILLISECONDS", + "LauncherUpdatePresentation", + "POLL_INTERVAL_MILLISECONDS", + "PRELOADER_STATUS_CHARACTERS", + "ProgressCoalescer", + "SERVER_STATUS_STALE_SECONDS", + "STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS", + "ServerStatusCheck", + "ServerStatusPresentation", + "TRANSFER_RATE_IDLE_SECONDS", + "TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS", + "TRANSFER_RATE_WINDOW_SECONDS", + "TransferRateEstimator", + "UI_SCALE_FACTORS", + "UI_SCALE_LABELS", + "WINDOW_HEIGHT", + "WINDOW_WIDTH", + "dashboard_content_ready", + "dashboard_controls_stacked", + "dashboard_poll_interval", + "dashboard_preloader_delay", + "dashboard_transition_opacity", + "debug_elapsed_text", + "download_activity_metadata", + "download_speed_setting_label", + "download_speed_setting_value", + "fitted_window_size", + "format_eta", + "format_transfer_rate", + "freeze_preloader_geometry", + "game_client_activity_text", + "game_file_progress_status", + "game_sync_presentation", + "interrupt_game_supervisor", + "launch_external_url", + "launcher_child_command", + "launcher_restart_spec", + "launcher_update_presentation", + "news_card_columns", + "operation_activity_text", + "patch_change_colors", + "perform_server_status_check", + "preloader_logo_subsample_factor", + "preloader_status_text", + "resolve_ui_scale", + "run_dashboard", + "server_status_check_text", + "server_status_presentation", + "setting_label", + "setting_value", + "should_refresh_operation_elapsed", + "startup_progress_animation_value", +] diff --git a/ptlinux/ui/components/__init__.py b/ptlinux/ui/components/__init__.py new file mode 100644 index 0000000..2339b10 --- /dev/null +++ b/ptlinux/ui/components/__init__.py @@ -0,0 +1 @@ +"""Reusable Tk dashboard components.""" diff --git a/ptlinux/ui/components/action_panel.py b/ptlinux/ui/components/action_panel.py new file mode 100644 index 0000000..ad82bec --- /dev/null +++ b/ptlinux/ui/components/action_panel.py @@ -0,0 +1,119 @@ +"""Primary game and launcher action controls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ActionPanel: + container: Any + game_actions: Any + system_actions: Any + update_button: Any + repair_button: Any + import_button: Any + doctor_button: Any + settings_button: Any + launcher_update_button: Any + launcher_update_badge: Any + + @property + def operation_buttons(self) -> tuple[Any, ...]: + return ( + self.update_button, + self.repair_button, + self.doctor_button, + self.settings_button, + self.import_button, + self.launcher_update_button, + ) + + @property + def mutation_buttons(self) -> tuple[Any, ...]: + return ( + self.update_button, + self.repair_button, + self.import_button, + self.launcher_update_button, + ) + + +def build_action_panel(parent: Any, *, ttk: Any, launcher_version: str) -> ActionPanel: + controls = ttk.Frame(parent, style="Root.TFrame") + controls.pack(fill="x", pady=(8, 8)) + controls.columnconfigure(0, weight=1, uniform="actions") + controls.columnconfigure(1, weight=1, uniform="actions") + game_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) + game_actions.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) + ttk.Label(game_actions, text="GAME FILES", style="Section.TLabel").pack(anchor="w") + game_button_row = ttk.Frame(game_actions, style="Surface.TFrame") + game_button_row.pack(anchor="w", fill="x", pady=(6, 0)) + update_button = ttk.Button( + game_button_row, + text="UPDATE", + style="Secondary.TButton", + underline=0, + ) + update_button.pack(side="left") + repair_button = ttk.Button( + game_button_row, + text="REPAIR", + style="Secondary.TButton", + underline=0, + ) + repair_button.pack(side="left", padx=(8, 0)) + import_button = ttk.Button( + game_button_row, + text="IMPORT GAME", + style="Secondary.TButton", + underline=0, + ) + import_button.pack(side="left", padx=(8, 0)) + + system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) + system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) + system_heading = ttk.Frame(system_actions, style="Surface.TFrame") + system_heading.pack(fill="x") + ttk.Label(system_heading, text="LAUNCHER", style="Section.TLabel").pack(side="left") + launcher_update_badge = ttk.Label( + system_heading, + text="", + style="UpdateBadge.TLabel", + ) + system_button_row = ttk.Frame(system_actions, style="Surface.TFrame") + system_button_row.pack(anchor="w", fill="x", pady=(6, 0)) + doctor_button = ttk.Button( + system_button_row, + text="SYSTEM CHECK", + style="Secondary.TButton", + underline=7, + ) + doctor_button.pack(side="left") + settings_button = ttk.Button( + system_button_row, + text="OPEN SETTINGS", + style="Secondary.TButton", + underline=5, + ) + settings_button.pack(side="left", padx=(8, 0)) + launcher_update_button = ttk.Button( + system_button_row, + text=f"LAUNCHER {launcher_version}", + style="Secondary.TButton", + underline=0, + ) + launcher_update_button.pack(side="left", padx=(8, 0)) + return ActionPanel( + container=controls, + game_actions=game_actions, + system_actions=system_actions, + update_button=update_button, + repair_button=repair_button, + import_button=import_button, + doctor_button=doctor_button, + settings_button=settings_button, + launcher_update_button=launcher_update_button, + launcher_update_badge=launcher_update_badge, + ) diff --git a/ptlinux/ui/components/broadcasts_panel.py b/ptlinux/ui/components/broadcasts_panel.py new file mode 100644 index 0000000..e1a69f9 --- /dev/null +++ b/ptlinux/ui/components/broadcasts_panel.py @@ -0,0 +1,33 @@ +"""Latest official broadcast panel construction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class BroadcastsPanel: + container: Any + content: Any + + +def build_broadcasts_panel( + parent: Any, + *, + ttk: Any, + before: Any, +) -> BroadcastsPanel: + panel = ttk.Frame(parent, style="Surface.TFrame", padding=(10, 8)) + panel.pack(fill="x", pady=(0, 8), before=before) + heading = ttk.Frame(panel, style="Surface.TFrame") + heading.pack(fill="x") + ttk.Label(heading, text="LATEST BROADCAST", style="Section.TLabel").pack(side="left") + ttk.Label( + heading, + text="OFFICIAL DISCORD", + style="Muted.TLabel", + ).pack(side="right") + content = ttk.Frame(panel, style="Surface.TFrame") + content.pack(fill="x", pady=(6, 0)) + return BroadcastsPanel(container=panel, content=content) diff --git a/ptlinux/ui/components/header.py b/ptlinux/ui/components/header.py new file mode 100644 index 0000000..2352205 --- /dev/null +++ b/ptlinux/ui/components/header.py @@ -0,0 +1,74 @@ +"""Dashboard header construction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ..viewmodels.dashboard import preloader_logo_subsample_factor + + +@dataclass(frozen=True) +class DashboardHeader: + frame: Any + version: Any + logo: Any + + +def build_dashboard_header( + parent: Any, + *, + widgets: Any, + tk_module: Any, + launcher_version: str, + window_icon: Any | None, +) -> DashboardHeader: + """Build the shared content frame and branded dashboard heading.""" + + frame = widgets.Frame(parent, style="Root.TFrame", padding=12) + frame.pack(fill="both", expand=True) + header = widgets.Frame(frame, style="Root.TFrame") + header.pack(fill="x", pady=(0, 8)) + logo = tk_module.Canvas( + header, + width=42, + height=42, + background="#0c0908", + highlightthickness=0, + borderwidth=0, + ) + dashboard_header_icon = None + icon: Any = window_icon + if icon is not None: + try: + header_icon_factor = preloader_logo_subsample_factor( + icon.width(), + icon.height(), + max_dimension=40, + ) + dashboard_header_icon = icon.subsample( + header_icon_factor, + header_icon_factor, + ) + except tk_module.TclError: + dashboard_header_icon = None + if dashboard_header_icon is not None: + logo.create_image(21, 21, image=dashboard_header_icon) + logo.image = dashboard_header_icon + logo.pack(side="left", padx=(0, 9)) + brand = widgets.Frame(header, style="Root.TFrame") + brand.pack(side="left", fill="x", expand=True) + widgets.Label(brand, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w") + widgets.Label( + brand, + text="PRISTONTALE EU · NATIVE LINUX LAUNCHER", + style="Tagline.TLabel", + ).pack(anchor="w", pady=(1, 0)) + version = tk_module.StringVar( + value=f"Launcher {launcher_version} · checking official game build…" + ) + widgets.Label(header, textvariable=version, style="Tagline.TLabel").pack( + side="right", + anchor="e", + ) + return DashboardHeader(frame=frame, version=version, logo=logo) diff --git a/ptlinux/ui/components/news_panel.py b/ptlinux/ui/components/news_panel.py new file mode 100644 index 0000000..d4e5a30 --- /dev/null +++ b/ptlinux/ui/components/news_panel.py @@ -0,0 +1,108 @@ +"""Official news and patch-note viewport construction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +MAX_NEWS_CARDS = 3 +NEWS_CARD_HEIGHT = 242 +NEWS_IMAGE_WIDTH = 292 +NEWS_IMAGE_HEIGHT = 118 +PATCH_CARD_WRAP_LENGTH = 900 +PATCH_CHANGE_WRAP_LENGTH = 790 + + +@dataclass(frozen=True) +class NewsPanel: + container: Any + news_tab_button: Any + patches_tab_button: Any + canvas: Any + scrollbar: Any + content_host: Any + news_content: Any + patches_content: Any + canvas_window: int + + +def build_news_panel( + parent: Any, + *, + ttk_module: Any, + tk_module: Any, + controls: Any, + ui_scale: float, +) -> NewsPanel: + news_panel = ttk_module.Frame(parent, style="Surface.TFrame", padding=(10, 8)) + news_panel.pack(fill="both", expand=True, pady=(0, 8), before=controls) + news_heading = ttk_module.Frame(news_panel, style="Surface.TFrame") + news_heading.pack(fill="x") + ttk_module.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left") + news_tabs = ttk_module.Frame(news_heading, style="Surface.TFrame") + news_tabs.pack(side="left", padx=(14, 0)) + news_tab_button = ttk_module.Button( + news_tabs, + text="NEWS", + style="NewsTabActive.TButton", + underline=0, + ) + news_tab_button.pack(side="left") + patches_tab_button = ttk_module.Button( + news_tabs, + text="PATCHES", + style="NewsTab.TButton", + underline=0, + ) + patches_tab_button.pack(side="left", padx=(5, 0)) + ttk_module.Label( + news_heading, + text="OFFICIAL FEED", + style="Muted.TLabel", + ).pack(side="right") + news_viewport = ttk_module.Frame(news_panel, style="Surface.TFrame") + news_viewport.pack(fill="both", expand=True, pady=(6, 0)) + news_viewport.grid_columnconfigure(0, weight=1) + news_viewport.grid_rowconfigure(0, weight=1) + news_canvas = tk_module.Canvas( + news_viewport, + background="#191311", + highlightthickness=0, + borderwidth=0, + height=round(252 * ui_scale), + takefocus=True, + ) + news_canvas.grid(row=0, column=0, sticky="nsew") + news_scrollbar = ttk_module.Scrollbar( + news_viewport, + orient="vertical", + style="News.Vertical.TScrollbar", + command=news_canvas.yview, + ) + news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0)) + news_scrollbar.grid_remove() + news_canvas.configure(yscrollcommand=news_scrollbar.set) + news_content_host = ttk_module.Frame(news_canvas, style="Surface.TFrame") + news_content_host.grid_columnconfigure(0, weight=1) + news_content = ttk_module.Frame(news_content_host, style="Surface.TFrame") + patches_content = ttk_module.Frame(news_content_host, style="Surface.TFrame") + news_content.grid(row=0, column=0, sticky="nsew") + patches_content.grid(row=0, column=0, sticky="nsew") + patches_content.grid_remove() + news_window = news_canvas.create_window( + (0, 0), + window=news_content_host, + anchor="nw", + ) + return NewsPanel( + container=news_panel, + news_tab_button=news_tab_button, + patches_tab_button=patches_tab_button, + canvas=news_canvas, + scrollbar=news_scrollbar, + content_host=news_content_host, + news_content=news_content, + patches_content=patches_content, + canvas_window=news_window, + ) diff --git a/ptlinux/ui/components/progress_panel.py b/ptlinux/ui/components/progress_panel.py new file mode 100644 index 0000000..967c7ec --- /dev/null +++ b/ptlinux/ui/components/progress_panel.py @@ -0,0 +1,163 @@ +"""Operation progress, realm choice, and server-summary controls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProgressPanel: + container: Any + realm_value: Any + realm_selector: Any + play_button: Any + status: Any + progress_text: Any + speed_text: Any + client_activity_text: Any + download_context_text: Any + activity_meta_text: Any + progress_bar: Any + server_status_content: Any + server_status_debug_text: Any + log_toggle: Any + + def refresh_activity_metadata(self) -> None: + self.activity_meta_text.set( + self.download_context_text.get() or self.client_activity_text.get() + ) + + def set_download_context(self, value: str) -> None: + self.download_context_text.set(value) + self.refresh_activity_metadata() + + +def build_progress_panel( + parent: Any, + *, + widgets: Any, + tk_module: Any, + default_realm: str, +) -> ProgressPanel: + activity_panel = widgets.Frame(parent, style="Surface.TFrame", padding=(10, 7)) + activity_panel.pack(fill="x", pady=(0, 8)) + realm_value = tk_module.StringVar(value=default_realm) + activity_action = widgets.Frame(activity_panel, style="Surface.TFrame") + activity_action.pack(side="right", fill="y", padx=(12, 0)) + play_button = widgets.Button( + activity_action, + text="PLAY", + style="Play.TButton", + underline=0, + ) + play_button.pack(fill="both", expand=True) + realm_selector = widgets.Combobox( + activity_action, + name="realm_selector", + textvariable=realm_value, + values=(default_realm,), + state="readonly", + style="Realm.TCombobox", + font=("Sans", 9, "bold"), + justify="center", + width=20, + ) + realm_selector.pack(fill="x", pady=(6, 5)) + activity_copy = widgets.Frame(activity_panel, style="Surface.TFrame") + activity_copy.pack(side="left", fill="both", expand=True) + status = tk_module.StringVar(value="Ready") + progress_text = tk_module.StringVar(value="Click Play to start your journey") + speed_text = tk_module.StringVar(value="") + client_activity_text = tk_module.StringVar(value="") + download_context_text = tk_module.StringVar(value="") + activity_meta_text = tk_module.StringVar(value="") + activity_heading = widgets.Frame(activity_copy, style="Surface.TFrame") + activity_heading.pack(fill="x") + widgets.Label( + activity_heading, + textvariable=status, + style="Status.TLabel", + ).pack(side="left", anchor="w") + widgets.Label( + activity_heading, + textvariable=activity_meta_text, + style="Muted.TLabel", + ).pack(side="right", anchor="e") + progress_bar = widgets.Progressbar( + activity_copy, + style="Depot.Horizontal.TProgressbar", + mode="determinate", + maximum=1, + value=0, + ) + progress_bar.pack(fill="x", pady=(5, 2)) + progress_details = widgets.Frame(activity_copy, style="Surface.TFrame") + progress_details.pack(fill="x") + widgets.Label( + progress_details, + textvariable=speed_text, + style="Speed.TLabel", + width=28, + anchor="e", + ).pack(side="right") + widgets.Label( + progress_details, + textvariable=progress_text, + style="Muted.TLabel", + anchor="w", + ).pack(side="left", fill="x", expand=True) + server_status_row = widgets.Frame(activity_copy, style="Surface.TFrame") + server_status_row.pack(fill="x", pady=(5, 1)) + widgets.Label( + server_status_row, + text="SERVERS", + style="ServerHeading.TLabel", + ).pack(side="left", fill="y", padx=(0, 8)) + server_status_content = widgets.Frame(server_status_row, style="Surface.TFrame") + server_status_content.pack(side="left", fill="y", anchor="w") + log_toggle = tk_module.Button( + server_status_row, + text=">_", + background="#191311", + foreground="#f8fafc", + activebackground="#24170f", + activeforeground="#ffffff", + font=("Monospace", 8, "bold"), + padx=3, + pady=1, + borderwidth=0, + highlightthickness=1, + highlightbackground="#191311", + highlightcolor="#f4bd61", + relief="flat", + takefocus=True, + ) + log_toggle.pack(side="right", anchor="e", padx=(8, 0)) + server_status_debug_text = tk_module.StringVar(value="") + widgets.Label( + server_status_row, + textvariable=server_status_debug_text, + style="Muted.TLabel", + ).pack(side="right", anchor="e", padx=(8, 0)) + widgets.Label( + server_status_content, + text="Checking official server status…", + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") + return ProgressPanel( + container=activity_panel, + realm_value=realm_value, + realm_selector=realm_selector, + play_button=play_button, + status=status, + progress_text=progress_text, + speed_text=speed_text, + client_activity_text=client_activity_text, + download_context_text=download_context_text, + activity_meta_text=activity_meta_text, + progress_bar=progress_bar, + server_status_content=server_status_content, + server_status_debug_text=server_status_debug_text, + log_toggle=log_toggle, + ) diff --git a/ptlinux/ui/components/server_status.py b/ptlinux/ui/components/server_status.py new file mode 100644 index 0000000..4a7fadb --- /dev/null +++ b/ptlinux/ui/components/server_status.py @@ -0,0 +1,58 @@ +"""Rendering for the compact official-realm status strip.""" + +from __future__ import annotations + +from typing import Any, Iterable + +from ...depot import DepotStartOption +from ...server_status import RealmServerStatus +from ..viewmodels.health import server_status_presentation + + +def render_server_status( + container: Any, + *, + ttk: Any, + statuses: tuple[RealmServerStatus, ...] | None, + start_options: Iterable[DepotStartOption], + placeholder: str, + show_debug_details: bool, +) -> None: + """Replace the status strip contents from one immutable state snapshot.""" + + for child in container.winfo_children(): + child.destroy() + if statuses is None: + ttk.Label( + container, + text=placeholder, + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") + return + statuses_by_name = {item.name.casefold(): item for item in statuses} + visible = [ + statuses_by_name[option.name.casefold()] + for option in start_options + if option.name.casefold() in statuses_by_name + ] + if not visible: + ttk.Label( + container, + text="Official status is unavailable for this realm.", + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") + return + for index, server in enumerate(visible): + presentation = server_status_presentation( + server, + show_debug_details=show_debug_details, + ) + ttk.Label( + container, + text=presentation.text, + style=presentation.style, + ).pack( + side="left", + anchor="w", + padx=(0 if index == 0 else 6, 0), + ) diff --git a/ptlinux/ui/components/theme.py b/ptlinux/ui/components/theme.py new file mode 100644 index 0000000..ce640b0 --- /dev/null +++ b/ptlinux/ui/components/theme.py @@ -0,0 +1,459 @@ +"""Central Tk style configuration for the native dashboard.""" + +from __future__ import annotations + +from typing import Any + + +def configure_dashboard_style(root: Any, ttk: Any, launcher_ui_scale: float) -> None: + style = ttk.Style(root) + style.theme_use("clam") + style.configure("Root.TFrame", background="#0c0908") + style.configure("Surface.TFrame", background="#191311") + style.configure("Hero.TFrame", background="#24170f") + style.configure("Card.TFrame", background="#211916") + style.configure( + "Brand.TLabel", + background="#0c0908", + foreground="#f5d18b", + font=("Georgia", 21, "bold"), + ) + style.configure( + "Tagline.TLabel", + background="#0c0908", + foreground="#a8998a", + font=("Sans", 8), + ) + style.configure( + "Section.TLabel", + background="#191311", + foreground="#f0d7a2", + font=("Sans", 9, "bold"), + ) + style.configure( + "HeroTitle.TLabel", + background="#24170f", + foreground="#fff2d3", + font=("Georgia", 17, "bold"), + ) + style.configure( + "HeroText.TLabel", + background="#24170f", + foreground="#d6c6b3", + font=("Sans", 10), + ) + style.configure( + "Status.TLabel", + background="#191311", + foreground="#dfd1c1", + font=("Sans", 9), + ) + style.configure( + "Muted.TLabel", + background="#191311", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "ServerHeading.TLabel", + background="#191311", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + padding=(0, 3), + ) + style.configure( + "ServerOnline.TLabel", + background="#102719", + foreground="#58e27a", + font=("Sans", 8, "bold"), + padding=(8, 3), + anchor="w", + ) + style.configure( + "ServerOffline.TLabel", + background="#301514", + foreground="#ff6258", + font=("Sans", 8, "bold"), + padding=(8, 3), + anchor="w", + ) + style.configure( + "ServerUnknown.TLabel", + background="#211916", + foreground="#b8a99a", + font=("Sans", 8), + padding=(8, 3), + anchor="w", + ) + style.configure( + "Speed.TLabel", + background="#191311", + foreground="#e5b66c", + font=("Monospace", 8, "bold"), + ) + style.configure( + "SettingsTitle.TLabel", + background="#191311", + foreground="#fff0d2", + font=("Georgia", 16, "bold"), + ) + style.configure( + "SettingsText.TLabel", + background="#191311", + foreground="#d8cbbd", + font=("Sans", 9), + ) + style.configure( + "SettingsHint.TLabel", + background="#191311", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "SettingsCard.TLabelframe", + background="#191311", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#120e0c", + borderwidth=1, + relief="solid", + ) + style.configure( + "SettingsCard.TLabelframe.Label", + background="#191311", + foreground="#f0d7a2", + font=("Sans", 9, "bold"), + ) + style.configure("SettingsCard.TFrame", background="#191311") + style.configure( + "Update.Treeview", + background="#100d0c", + fieldbackground="#100d0c", + foreground="#ded1c4", + rowheight=max(24, round(24 * launcher_ui_scale)), + bordercolor="#4a3528", + ) + style.configure( + "Update.Treeview.Heading", + background="#2a201c", + foreground="#f0d7a2", + font=("Sans", 8, "bold"), + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#120e0c", + relief="flat", + ) + style.map( + "Update.Treeview.Heading", + background=[("active", "#2a201c"), ("pressed", "#2a201c")], + foreground=[("active", "#f0d7a2"), ("pressed", "#f0d7a2")], + relief=[("active", "flat"), ("pressed", "flat")], + ) + style.map( + "Update.Treeview", + background=[("selected", "#6a482d")], + foreground=[("selected", "#ffffff")], + ) + style.configure("SettingsFooter.TFrame", background="#100d0c") + style.configure( + "SettingsFooter.TLabel", + background="#100d0c", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "Settings.TEntry", + fieldbackground="#100d0c", + foreground="#f8ead2", + insertcolor="#f8fafc", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#4a3528", + focuscolor="#f4bd61", + focusthickness=2, + ) + style.map("Settings.TEntry", bordercolor=[("focus", "#f4bd61")]) + style.configure( + "Settings.TCombobox", + fieldbackground="#100d0c", + background="#211916", + foreground="#f8ead2", + arrowcolor="#e5b66c", + bordercolor="#4a3528", + focuscolor="#f4bd61", + focusthickness=2, + ) + style.map( + "Settings.TCombobox", + fieldbackground=[("readonly", "#100d0c")], + foreground=[("readonly", "#f8ead2")], + bordercolor=[("focus", "#f4bd61")], + ) + style.configure( + "Realm.TCombobox", + fieldbackground="#211916", + background="#211916", + foreground="#fff0d2", + arrowcolor="#d8aa61", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#4a3528", + font=("Sans", 9, "bold"), + padding=(10, 7), + relief="flat", + ) + style.map( + "Realm.TCombobox", + fieldbackground=[ + ("disabled", "#171412"), + ("readonly", "#211916"), + ], + background=[ + ("disabled", "#171412"), + ("active", "#2a201c"), + ("readonly", "#211916"), + ], + foreground=[ + ("disabled", "#887a70"), + ("readonly", "#fff0d2"), + ], + arrowcolor=[ + ("disabled", "#887a70"), + ("active", "#f0d7a2"), + ("readonly", "#d8aa61"), + ], + bordercolor=[ + ("disabled", "#4a3528"), + ("focus", "#6a4d38"), + ("readonly", "#4a3528"), + ], + ) + root.option_add("*realm_selector*Listbox.background", "#211916") + root.option_add("*realm_selector*Listbox.foreground", "#fff0d2") + root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c") + root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2") + root.option_add("*realm_selector*Listbox.font", "Sans 9 bold") + root.option_add("*realm_selector*Listbox.justify", "center") + root.option_add("*realm_selector*Listbox.selectBorderWidth", 4) + root.option_add("*realm_selector*Listbox.borderWidth", 1) + root.option_add("*realm_selector*Listbox.highlightThickness", 0) + root.option_add("*realm_selector*Listbox.relief", "flat") + root.option_add("*realm_selector*Listbox.activeStyle", "none") + style.configure( + "Settings.TCheckbutton", + background="#191311", + foreground="#d8cbbd", + font=("Sans", 9), + focuscolor="#f4bd61", + focusthickness=2, + ) + style.map( + "Settings.TCheckbutton", + background=[("active", "#191311")], + foreground=[("active", "#fff0d2")], + ) + style.configure( + "PreloaderStatus.TLabel", + background="#0c0908", + foreground="#dfd1c1", + font=("Sans", 10), + ) + style.configure( + "PreloaderMuted.TLabel", + background="#0c0908", + foreground="#aa9a8b", + font=("Sans", 9), + ) + style.configure( + "NewsTitle.TLabel", + background="#211916", + foreground="#fff0d2", + font=("Sans", 9, "bold"), + ) + style.configure( + "NewsMeta.TLabel", + background="#211916", + foreground="#d8aa61", + font=("Sans", 9, "bold"), + ) + style.configure( + "NewsBody.TLabel", + background="#211916", + foreground="#c9baaa", + font=("Sans", 8), + ) + style.configure( + "NewsTab.TButton", + background="#191311", + foreground="#9f8d7c", + font=("Sans", 8, "bold"), + padding=(10, 4), + bordercolor="#32251f", + borderwidth=1, + relief="flat", + focusthickness=0, + ) + style.map( + "NewsTab.TButton", + background=[("active", "#2a201c")], + foreground=[("active", "#f0d7a2")], + bordercolor=[("focus", "#f4bd61"), ("active", "#4a3528")], + ) + style.configure( + "NewsTabActive.TButton", + background="#2a201c", + foreground="#f0d7a2", + font=("Sans", 8, "bold"), + padding=(10, 4), + bordercolor="#6a4d38", + borderwidth=1, + relief="flat", + focusthickness=0, + ) + style.map( + "NewsTabActive.TButton", + background=[("active", "#32251f")], + foreground=[("active", "#fff0d2")], + bordercolor=[("focus", "#f4bd61")], + ) + style.configure( + "News.Vertical.TScrollbar", + background="#4a3528", + troughcolor="#191311", + arrowcolor="#d8aa61", + bordercolor="#191311", + lightcolor="#4a3528", + darkcolor="#32251f", + ) + style.map( + "News.Vertical.TScrollbar", + background=[ + ("pressed", "#765034"), + ("active", "#62452f"), + ("!disabled", "#4a3528"), + ], + arrowcolor=[ + ("pressed", "#fff0c2"), + ("active", "#f0c77f"), + ("!disabled", "#d8aa61"), + ], + lightcolor=[ + ("pressed", "#765034"), + ("active", "#62452f"), + ("!disabled", "#4a3528"), + ], + darkcolor=[ + ("pressed", "#4b3021"), + ("active", "#3d2a21"), + ("!disabled", "#32251f"), + ], + ) + style.configure( + "Play.TButton", + background="#d98927", + foreground="#fff8e9", + font=("Sans", 10, "bold"), + padding=(20, 10), + bordercolor="#f4bd61", + borderwidth=1, + relief="flat", + focuscolor="#fff0c2", + focusthickness=2, + ) + style.map( + "Play.TButton", + background=[("active", "#efaa4c"), ("disabled", "#6c4826")], + foreground=[("disabled", "#bca889")], + bordercolor=[("focus", "#fff0c2")], + ) + style.configure( + "Secondary.TButton", + background="#0b0a09", + foreground="#f2b657", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#d89332", + borderwidth=1, + relief="flat", + focuscolor="#f4bd61", + focusthickness=2, + ) + style.map( + "Secondary.TButton", + background=[("active", "#271b10"), ("disabled", "#171412")], + foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], + bordercolor=[("focus", "#f4bd61")], + ) + style.configure( + "LauncherUpdate.TButton", + background="#f4bd61", + foreground="#24170f", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#ffe3a3", + borderwidth=2, + relief="flat", + focuscolor="#24170f", + focusthickness=2, + ) + style.map( + "LauncherUpdate.TButton", + background=[("active", "#ffd47f"), ("disabled", "#7b5d37")], + foreground=[("active", "#120b06"), ("disabled", "#d0bea2")], + bordercolor=[("focus", "#ffffff")], + ) + style.configure( + "LauncherManualUpdate.TButton", + background="#a63f2f", + foreground="#fff4e9", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#e98769", + borderwidth=2, + relief="flat", + focuscolor="#ffffff", + focusthickness=2, + ) + style.map( + "LauncherManualUpdate.TButton", + background=[("active", "#c95842"), ("disabled", "#60352e")], + foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")], + bordercolor=[("focus", "#ffffff")], + ) + style.configure( + "UpdateBadge.TLabel", + background="#d98927", + foreground="#1a1008", + font=("Sans", 7, "bold"), + padding=(7, 3), + ) + style.configure( + "Activity.TButton", + background="#0c0908", + foreground="#f8fafc", + font=("Monospace", 8, "bold"), + padding=(3, 2), + borderwidth=0, + relief="flat", + ) + style.map( + "Activity.TButton", + background=[("active", "#24170f")], + foreground=[("active", "#ffffff")], + ) + style.configure( + "News.TButton", + background="#211916", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + padding=(0, 3), + ) + style.map("News.TButton", foreground=[("active", "#ffe0a0")]) + style.configure( + "Depot.Horizontal.TProgressbar", + background="#d68c34", + troughcolor="#34251f", + bordercolor="#34251f", + lightcolor="#e4a34c", + darkcolor="#b66c25", + ) diff --git a/ptlinux/ui/components/update_dialog.py b/ptlinux/ui/components/update_dialog.py new file mode 100644 index 0000000..94d13eb --- /dev/null +++ b/ptlinux/ui/components/update_dialog.py @@ -0,0 +1,434 @@ +"""Launcher update history, install, and rollback dialog.""" + +from __future__ import annotations + +from typing import Any, Callable + +from ...paths import AppPaths +from ...self_update import ( + LauncherUpdateCheck, + LauncherUpdateHistory, + inspect_launcher_update_history, +) +from ...tk_chrome import ( + centered_window_geometry, + create_window_chrome, + show_themed_dialog, +) +from ..viewmodels.dashboard import fitted_window_size +from ..viewmodels.downloader import format_bytes + + +class LauncherUpdateDialog: + """Own the update window and expose small refresh/error entry points.""" + + def __init__( + self, + root: Any, + *, + paths: AppPaths, + launcher_version: str, + ui_scale: float, + window_icon: Any | None, + apply_window_icon: Callable[[Any], None], + get_check: Callable[[], LauncherUpdateCheck | None], + is_busy: Callable[[], bool], + request_check: Callable[[], None], + install_and_restart: Callable[[str], None], + rollback_and_restart: Callable[[str], None], + open_article: Callable[[str], None], + append_log: Callable[[str], None], + ) -> None: + self._root = root + self._paths = paths + self._launcher_version = launcher_version + self._ui_scale = ui_scale + self._window_icon = window_icon + self._apply_window_icon = apply_window_icon + self._get_check = get_check + self._is_busy = is_busy + self._request_check = request_check + self._install_and_restart = install_and_restart + self._rollback_and_restart = rollback_and_restart + self._open_article = open_article + self._append_log = append_log + self._window: Any = None + self._required_reason: str | None = None + self._installed_history: LauncherUpdateHistory | None = None + self._status_value: Any = None + self._details_value: Any = None + self._history: Any = None + self._release_notes_button: Any = None + self._check_button: Any = None + self._rollback_button: Any = None + self._install_button: Any = None + + def open(self, required_reason: str | None = None) -> None: + if self._window is not None and self._window.winfo_exists(): + if required_reason is not None: + self._required_reason = required_reason + self._window.deiconify() + self._window.lift() + self._window.focus_set() + self.refresh() + return + self._required_reason = required_reason + + import tkinter as tk + from tkinter import ttk + + dialog = tk.Toplevel(self._root) + dialog.withdraw() + self._window = dialog + dialog.title("PristonTale EU — Launcher Updates") + dialog.resizable(False, False) + dialog.configure(background="#0c0908") + dialog.transient(self._root) + self._apply_window_icon(dialog) + updates_chrome = create_window_chrome( + dialog, + title="PRISTONTALE EU", + subtitle="LAUNCHER UPDATES", + window_icon=self._window_icon, + show_close=True, + show_minimize=False, + allow_close=True, + ) + + updates_header = tk.Frame(updates_chrome.content, background="#24170f") + updates_header.pack(fill="x") + tk_accent = tk.Frame(updates_header, background="#d98927", width=5) + tk_accent.pack(side="left", fill="y") + header_copy = tk.Frame(updates_header, background="#24170f") + header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) + tk.Label( + header_copy, + text="SIGNED STABLE CHANNEL", + background="#24170f", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + ).pack(anchor="w") + tk.Label( + header_copy, + text="Launcher Updates", + background="#24170f", + foreground="#fff0d2", + font=("Georgia", 18, "bold"), + ).pack(anchor="w", pady=(2, 0)) + + content = ttk.Frame( + updates_chrome.content, + style="Root.TFrame", + padding=(16, 14, 16, 16), + ) + content.pack(fill="both", expand=True) + content.columnconfigure(0, weight=1) + content.rowconfigure(1, weight=1) + self._status_value = tk.StringVar(value="Checking the signed update channel…") + self._details_value = tk.StringVar(value="") + overview = ttk.LabelFrame( + content, + text="UPDATE STATUS", + style="SettingsCard.TLabelframe", + padding=14, + ) + overview.grid(row=0, column=0, sticky="ew") + overview.columnconfigure(0, weight=1) + ttk.Label( + overview, + textvariable=self._status_value, + style="SettingsText.TLabel", + wraplength=round(480 * self._ui_scale), + justify="left", + ).grid(row=0, column=0, sticky="w") + ttk.Label( + overview, + textvariable=self._details_value, + style="SettingsHint.TLabel", + wraplength=round(480 * self._ui_scale), + justify="left", + ).grid(row=1, column=0, sticky="w", pady=(6, 0)) + self._release_notes_button = ttk.Button( + overview, + text="VIEW RELEASE NOTES", + style="Secondary.TButton", + state="disabled", + command=self._view_release_notes, + ) + self._release_notes_button.grid( + row=0, + column=1, + rowspan=2, + sticky="e", + padx=(12, 0), + ) + + history_card = ttk.LabelFrame( + content, + text="INSTALLED HISTORY", + style="SettingsCard.TLabelframe", + padding=12, + ) + history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0)) + history_card.columnconfigure(0, weight=1) + history_card.rowconfigure(0, weight=1) + self._history = ttk.Treeview( + history_card, + columns=("version", "state", "installed"), + show="headings", + height=6, + style="Update.Treeview", + selectmode="browse", + ) + self._history.heading("version", text="VERSION", anchor="w") + self._history.heading("state", text="STATE", anchor="w") + self._history.heading("installed", text="INSTALLED", anchor="w") + self._history.column( + "version", + width=round(120 * self._ui_scale), + minwidth=round(80 * self._ui_scale), + stretch=False, + anchor="w", + ) + self._history.column( + "state", + width=round(150 * self._ui_scale), + minwidth=round(100 * self._ui_scale), + stretch=False, + anchor="w", + ) + self._history.column( + "installed", + width=round(360 * self._ui_scale), + minwidth=round(180 * self._ui_scale), + stretch=True, + anchor="w", + ) + self._history.grid(row=0, column=0, sticky="nsew") + history_scrollbar = ttk.Scrollbar( + history_card, + orient="vertical", + style="News.Vertical.TScrollbar", + command=self._history.yview, + ) + history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0)) + self._history.configure(yscrollcommand=history_scrollbar.set) + + footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) + footer.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + footer.columnconfigure(0, weight=1) + self._check_button = ttk.Button( + footer, + text="CHECK AGAIN", + style="Secondary.TButton", + command=self._check_again, + ) + self._check_button.grid(row=0, column=0, sticky="w") + self._rollback_button = ttk.Button( + footer, + text="ROLL BACK & RESTART", + style="Secondary.TButton", + state="disabled", + command=self._rollback_update, + ) + self._rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0)) + self._install_button = ttk.Button( + footer, + text="INSTALL & RESTART", + style="Play.TButton", + state="disabled", + command=self._install_update, + ) + self._install_button.grid(row=0, column=2, sticky="e", padx=(8, 0)) + close_button = ttk.Button( + footer, + text="CLOSE", + style="Secondary.TButton", + command=self.close, + ) + close_button.grid(row=0, column=3, sticky="e", padx=(8, 0)) + + updates_chrome.set_close_command(self.close) + dialog.protocol("WM_DELETE_WINDOW", self.close) + dialog.bind("", lambda _event: self.close()) + self.refresh() + if self._get_check() is None: + self._check_again() + dialog.update_idletasks() + updates_width, updates_height = fitted_window_size( + 760, + 620, + scale=self._ui_scale, + screen_width=dialog.winfo_screenwidth(), + screen_height=dialog.winfo_screenheight(), + ) + dialog.geometry( + centered_window_geometry( + updates_width, + updates_height, + parent_x=self._root.winfo_rootx(), + parent_y=self._root.winfo_rooty(), + parent_width=self._root.winfo_width(), + parent_height=self._root.winfo_height(), + ) + ) + dialog.deiconify() + dialog.lift() + self._history.focus_set() + + def close(self) -> None: + window = self._window + self._window = None + if window is not None and window.winfo_exists(): + window.destroy() + + def refresh(self) -> None: + if self._window is None or not self._window.winfo_exists(): + return + assert self._status_value is not None + assert self._details_value is not None + assert self._history is not None + assert self._release_notes_button is not None + assert self._check_button is not None + assert self._rollback_button is not None + assert self._install_button is not None + check = self._get_check() + try: + self._installed_history = inspect_launcher_update_history(self._paths) + except RuntimeError as error: + self._installed_history = None + self._append_log(f"Could not inspect launcher update history: {error}") + for item in self._history.get_children(): + self._history.delete(item) + if self._installed_history is not None: + for release in self._installed_history.releases: + state_label = ( + "Current" + if release.active + else "Rollback target" + if release.previous + else "Retained" + ) + self._history.insert( + "", + "end", + values=(release.version, state_label, release.installed_at), + ) + if not self._history.get_children(): + self._history.insert( + "", + "end", + values=(self._launcher_version, "Running", "Not recorded"), + ) + busy = self._is_busy() + rollback_available = ( + self._installed_history is not None + and self._installed_history.previous_version is not None + and not busy + ) + self._rollback_button.configure( + state="normal" if rollback_available else "disabled" + ) + self._check_button.configure(state="disabled" if busy else "normal") + if check is None: + self._status_value.set("Checking the signed update channel…") + self._details_value.set( + "Installed launcher " + + self._launcher_version + + " · release information pending" + ) + self._install_button.configure(state="disabled") + self._release_notes_button.configure(state="disabled") + return + status_prefix = ( + f"Compatibility update required: {self._required_reason}\n" + if self._required_reason is not None + else "" + ) + self._status_value.set(status_prefix + check.reason) + self._details_value.set( + f"Current {check.current_version} · Latest {check.latest_version} · " + f"{check.manifest.channel.title()} channel · " + f"{format_bytes(check.manifest.archive_size)} · " + f"published {check.manifest.published_at} · " + f"checked {check.checked_at or 'time unavailable'}" + ) + self._install_button.configure( + state=( + "normal" + if check.available and check.supported and not busy + else "disabled" + ) + ) + self._release_notes_button.configure( + state="normal" if check.manifest.notes_url is not None else "disabled" + ) + + def show_error(self, message: str) -> None: + if self._window is None or not self._window.winfo_exists(): + return + assert self._status_value is not None + assert self._details_value is not None + assert self._check_button is not None + self._status_value.set("The signed update channel could not be checked.") + self._details_value.set(message) + self._check_button.configure(state="normal") + + def _check_again(self) -> None: + assert self._status_value is not None + assert self._details_value is not None + assert self._check_button is not None + assert self._install_button is not None + self._status_value.set("Checking the signed update channel…") + self._details_value.set("Authenticating the stable manifest and signature.") + self._check_button.configure(state="disabled") + self._install_button.configure(state="disabled") + self._request_check() + + def _view_release_notes(self) -> None: + check = self._get_check() + if check is not None and check.manifest.notes_url is not None: + self._open_article(check.manifest.notes_url) + + def _install_update(self) -> None: + check = self._get_check() + if check is None or not check.available or not check.supported: + return + if not show_themed_dialog( + self._window, + title="Update launcher", + message=( + f"Install signed launcher {check.latest_version} " + f"({format_bytes(check.manifest.archive_size)}) and restart now?" + ), + kind="question", + confirm=True, + accept_text="INSTALL & RESTART", + window_icon=self._window_icon, + ): + return + version_to_install = check.latest_version + self.close() + self._install_and_restart(version_to_install) + + def _rollback_update(self) -> None: + history = self._installed_history + if history is None or history.previous_version is None: + return + if not show_themed_dialog( + self._window, + title="Roll back launcher", + message=( + f"Switch from launcher {history.current_version or self._launcher_version} " + f"to {history.previous_version} and restart?\n\n" + "The current release remains available as the next rollback target." + ), + kind="warning", + confirm=True, + accept_text="ROLL BACK & RESTART", + window_icon=self._window_icon, + ): + return + target_version = history.previous_version + self.close() + self._rollback_and_restart(target_version) diff --git a/ptlinux/ui.py b/ptlinux/ui/dashboard.py similarity index 54% rename from ptlinux/ui.py rename to ptlinux/ui/dashboard.py index 51c64be..43f5c35 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui/dashboard.py @@ -3,13 +3,9 @@ from __future__ import annotations import base64 -from collections import deque -from dataclasses import dataclass import json import math -import os import queue -import signal import subprocess import sys import threading @@ -17,17 +13,17 @@ import time from typing import TYPE_CHECKING, Callable, Iterator, Mapping if TYPE_CHECKING: - from tkinter import Frame, PhotoImage, Toplevel + from tkinter import PhotoImage, Toplevel -from . import __version__ -from .changelog import ( +from .. import __version__ +from ..changelog import ( MAX_PATCHES as MAX_PATCH_NOTES, PatchNote, fetch_patch_notes, patch_change_tag, read_cached_patch_notes, ) -from .depot import ( +from ..depot import ( DEFAULT_DEPOT_START_OPTION, DepotClient, DepotError, @@ -37,24 +33,24 @@ from .depot import ( launch_catalog_from_snapshot, select_depot_start_option, ) -from .desktop_identity import ( +from ..desktop_identity import ( APPLICATION_NAME, APPLICATION_WM_CLASS, find_application_icon, ) -from .dashboard_debug import ( +from ..dashboard_debug import ( RuntimeDebugInfo, VerificationDebugInfo, inspect_runtime_debug, read_verification_debug, ) -from .install_status import ( +from ..install_status import ( GameInstallationStatus, PrimaryAction, inspect_game_installation, ) -from .import_state import game_import_requires_verification -from .news import ( +from ..import_state import game_import_requires_verification +from ..news import ( Broadcast, NewsArticle, fetch_latest_broadcast, @@ -66,164 +62,105 @@ from .news import ( read_cached_news_image, read_cached_official_logo, ) -from .overlay import RuntimeCompatibility, inspect_runtime_compatibility -from .paths import AppPaths -from .settings import Settings, ensure_settings, load_settings, update_settings -from .self_update import ( +from ..overlay import RuntimeCompatibility, inspect_runtime_compatibility +from ..paths import AppPaths +from ..settings import Settings, ensure_settings, load_settings, update_settings +from ..self_update import ( LauncherUpdateCheck, - LauncherUpdateHistory, cache_launcher_update_check, check_for_launcher_update, - inspect_launcher_update_history, read_cached_launcher_update_check, ) -from .server_status import ( - RealmServerStatus, - ServerStatusError, - fetch_server_status, -) -from .tk_chrome import ( +from ..server_status import RealmServerStatus +from ..tk_chrome import ( centered_window_geometry, create_dashboard_chrome, create_window_chrome, load_square_window_icon, show_themed_dialog, ) +from .components.action_panel import build_action_panel +from .components.broadcasts_panel import build_broadcasts_panel +from .components.header import build_dashboard_header +from .components.news_panel import ( + MAX_NEWS_CARDS, + NEWS_CARD_HEIGHT, + NEWS_IMAGE_HEIGHT, + NEWS_IMAGE_WIDTH, + PATCH_CARD_WRAP_LENGTH, + PATCH_CHANGE_WRAP_LENGTH, + build_news_panel, +) +from .components.progress_panel import build_progress_panel +from .components.server_status import render_server_status as render_server_status_component +from .components.theme import configure_dashboard_style +from .components.update_dialog import LauncherUpdateDialog +from .dialogs.activity import build_activity_window +from .viewmodels.dashboard import ( + ACTIVE_POLL_INTERVAL_MILLISECONDS, + BASE_DISPLAY_DPI, + BASE_TK_SCALING, + DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + DASHBOARD_STARTUP_COMPONENTS, + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + DASHBOARD_TRANSITION_STEPS, + PRELOADER_STATUS_CHARACTERS, + STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, + WINDOW_HEIGHT, + WINDOW_WIDTH, + DashboardError, + GameClientTracker, + GameLaunchSupervisorRegistry, + dashboard_content_ready, + dashboard_controls_stacked, + dashboard_poll_interval, + dashboard_preloader_delay, + dashboard_transition_opacity, + debug_elapsed_text, + fitted_window_size, + freeze_preloader_geometry, + game_client_activity_text, + interrupt_game_supervisor, + launcher_child_command, + launcher_restart_spec, + launcher_update_presentation, + news_card_columns, + preloader_logo_subsample_factor, + preloader_status_text, + resolve_ui_scale, + startup_progress_animation_value, +) +from .viewmodels.downloader import ( + ProgressCoalescer, + TransferRateEstimator, + as_non_negative_float as _as_float, + as_non_negative_int as _as_int, + download_activity_metadata, + format_bytes as _format_bytes, + format_eta, + format_transfer_rate, + operation_activity_text, + should_refresh_operation_elapsed, +) +from .viewmodels.health import ( + ServerStatusCheck, + perform_server_status_check, + server_status_check_text, + server_status_presentation, +) +from .viewmodels.installation import ( + game_file_progress_status, + game_sync_presentation, +) +from .dialogs.settings_dialog import open_settings_dialog -MAX_NEWS_CARDS = 3 -WINDOW_WIDTH = 1002 -WINDOW_HEIGHT = 784 -BASE_DISPLAY_DPI = 96.0 -BASE_TK_SCALING = BASE_DISPLAY_DPI / 72.0 -UI_SCALE_FACTORS: dict[str, float] = { - "100": 1.0, - "125": 1.25, - "150": 1.5, - "175": 1.75, - "200": 2.0, -} -UI_SCALE_LABELS: dict[str, str] = { - "Automatic (system DPI)": "auto", - "100% — Standard": "100", - "125% — Large": "125", - "150% — Larger": "150", - "175% — Extra large": "175", - "200% — Maximum": "200", -} -RUNTIME_LABELS: dict[str, str] = { - "Automatic (recommended)": "auto", - "Proton 7 — Steam Runtime container": "proton7-container", - "Proton 7 — Direct": "proton7", - "UMU Launcher": "umu", -} -PROTON_SYNC_LABELS: dict[str, str] = { - "Automatic (recommended)": "auto", - "Fsync": "fsync", - "Esync": "esync", -} -DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = { - "Unlimited": None, - "5 MB/s": 5.0, - "10 MB/s": 10.0, - "25 MB/s": 25.0, - "50 MB/s": 50.0, -} -DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…" -NEWS_THREE_COLUMN_WIDTH = 900 -NEWS_TWO_COLUMN_WIDTH = 620 -STACKED_CONTROLS_WIDTH = 860 -NEWS_CARD_HEIGHT = 242 -NEWS_IMAGE_WIDTH = 292 -NEWS_IMAGE_HEIGHT = 118 -PATCH_CARD_WRAP_LENGTH = 900 -PATCH_CHANGE_WRAP_LENGTH = 790 LAUNCH_STAGE_TOTAL = 4 EVENT_BATCH_SIZE = 48 -POLL_INTERVAL_MILLISECONDS = 33 -ACTIVE_POLL_INTERVAL_MILLISECONDS = 75 -IDLE_POLL_INTERVAL_MILLISECONDS = 500 MAX_ACTIVITY_LOG_LINES = 5000 ACTIVITY_LOG_TRIM_LINES = 500 -TRANSFER_RATE_WINDOW_SECONDS = 3.0 -TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 -TRANSFER_RATE_IDLE_SECONDS = 2.0 -DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300 -DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 -DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 -DASHBOARD_TRANSITION_STEPS = 9 -DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 -STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS = 16 -STARTUP_PROGRESS_ANIMATION_EASING = 0.28 -STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP = 0.08 PATCH_PRELOAD_INTERVAL_MILLISECONDS = 1 -PRELOADER_STATUS_CHARACTERS = 64 DASHBOARD_DEBUG_KEY = "" -SERVER_STATUS_STALE_SECONDS = 5 * 60 -DASHBOARD_STARTUP_COMPONENTS = frozenset( - { - "broadcast", - "cached_content", - "compatibility", - "launcher_update", - "logo", - "news", - "news_artwork", - "patches", - "server_status", - "version", - } -) -DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"}) - - -@dataclass(frozen=True) -class LauncherUpdatePresentation: - """Visual treatment for one authenticated launcher-update result.""" - - button_text: str - button_style: str - badge_text: str | None - - -@dataclass(frozen=True) -class GameSyncPresentation: - """User-facing wording for one managed-game synchronization.""" - - operation_label: str - file_progress_verb: str - - -@dataclass(frozen=True) -class ServerStatusPresentation: - """Text and Tk style for one official realm-health result.""" - - text: str - style: str - - -@dataclass(frozen=True) -class ServerStatusCheck: - """One measured attempt to retrieve official realm health.""" - - statuses: tuple[RealmServerStatus, ...] | None - checked_at: float - duration_seconds: float - error: str | None = None - - -@dataclass(frozen=True) -class GameClientDebugState: - """Live process state exposed only in the hidden dashboard debug view.""" - - client_id: int - phase: str - supervisor_pid: int | None - elapsed_seconds: float - - -class DashboardError(RuntimeError): - """Raised when the native dashboard cannot be displayed.""" def launch_external_url(url: str) -> None: @@ -248,461 +185,6 @@ def launch_external_url(url: str) -> None: ).start() -def resolve_ui_scale(preference: str, pixels_per_inch: float) -> float: - """Resolve a saved launcher scale while respecting the detected display DPI.""" - - if preference == "auto": - if not math.isfinite(pixels_per_inch) or pixels_per_inch <= 0: - return 1.0 - return min(2.0, max(1.0, pixels_per_inch / BASE_DISPLAY_DPI)) - try: - return UI_SCALE_FACTORS[preference] - except KeyError as error: - raise ValueError(f"unsupported launcher UI scale: {preference}") from error - - -def fitted_window_size( - width: int, - height: int, - *, - scale: float, - screen_width: int, - screen_height: int, - margin: int = 64, -) -> tuple[int, int]: - """Scale an initial window without placing it beyond the available display.""" - - if width <= 0 or height <= 0 or scale <= 0: - raise ValueError("window dimensions and scale must be positive") - if screen_width <= 0 or screen_height <= 0 or margin < 0: - raise ValueError("screen dimensions must be positive and margin non-negative") - available_width = max(1, screen_width - margin) - available_height = max(1, screen_height - margin) - return ( - min(max(1, round(width * scale)), available_width), - min(max(1, round(height * scale)), available_height), - ) - - -def news_card_columns(width: int, scale: float) -> int: - """Choose a readable card grid for the current physical dashboard width.""" - - if width <= 0 or scale <= 0: - return 1 - logical_width = width / scale - if logical_width >= NEWS_THREE_COLUMN_WIDTH: - return 3 - if logical_width >= NEWS_TWO_COLUMN_WIDTH: - return 2 - return 1 - - -def dashboard_controls_stacked(width: int, scale: float) -> bool: - return width <= 0 or scale <= 0 or width / scale < STACKED_CONTROLS_WIDTH - - -def setting_label(value: str, choices: Mapping[str, str]) -> str: - for label, choice in choices.items(): - if choice == value: - return label - raise ValueError(f"unsupported saved setting value: {value}") - - -def setting_value(label: str, choices: Mapping[str, str]) -> str: - try: - return choices[label] - except KeyError as error: - raise ValueError(f"unsupported setting choice: {label}") from error - - -def download_speed_setting_label(limit_mbps: float | None) -> str: - for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items(): - if preset == limit_mbps: - return label - return DOWNLOAD_SPEED_CUSTOM_LABEL - - -def download_speed_setting_value(label: str, custom_value: str) -> float | None: - if label in DOWNLOAD_SPEED_PRESETS_MBPS: - return DOWNLOAD_SPEED_PRESETS_MBPS[label] - if label != DOWNLOAD_SPEED_CUSTOM_LABEL: - raise ValueError(f"unsupported download speed choice: {label}") - try: - value = float(custom_value.strip()) - except ValueError as error: - raise ValueError("custom download speed must be a number in MB/s") from error - if not math.isfinite(value) or value <= 0 or value > 10_000: - raise ValueError( - "custom download speed must be greater than 0 and at most 10000 MB/s" - ) - return value - - -def download_activity_metadata( - *, - current_server: str | None, - retry_count: int, - retry_reason: str | None, - active_workers: int, - target_workers: int, -) -> str: - """Render compact download details for the existing activity heading.""" - - if retry_count < 0 or active_workers < 0 or target_workers < 0: - raise ValueError("download activity counts must not be negative") - parts: list[str] = [] - if current_server: - parts.append(f"CDN: {_ellipsize(current_server, 28)}") - if target_workers: - parts.append(f"Workers: {active_workers}/{target_workers}") - retry_text = f"Retries: {retry_count}" - if retry_reason: - retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})" - parts.append(retry_text) - return " · ".join(parts) - - -def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str: - normalized = " ".join(value.split()) - if len(normalized) <= limit: - return normalized - if keep_tail: - return f"…{normalized[-(limit - 1):]}" - return f"{normalized[:limit - 1]}…" - - -def call_optional(callback: Callable[..., None] | None, *arguments: object) -> None: - if callback is not None: - callback(*arguments) - - -class GameLaunchSupervisorRegistry: - """Bridge Tk cancellation requests to game launch worker processes safely.""" - - def __init__(self) -> None: - self._lock = threading.Lock() - self._processes: dict[int, subprocess.Popen[str]] = {} - self._cancel_requested: set[int] = set() - - def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool: - """Register a supervisor and report whether cancellation raced its start.""" - - with self._lock: - self._processes[client_id] = process - return client_id in self._cancel_requested - - def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None: - """Record cancellation and return an already-started supervisor, if any.""" - - with self._lock: - self._cancel_requested.add(client_id) - return self._processes.get(client_id) - - def detach(self, client_id: int, process: subprocess.Popen[str]) -> None: - with self._lock: - if self._processes.get(client_id) is process: - self._processes.pop(client_id, None) - - def is_cancel_requested(self, client_id: int) -> bool: - with self._lock: - return client_id in self._cancel_requested - - def finish(self, client_id: int) -> bool: - """Forget one launch and return whether the user cancelled it.""" - - with self._lock: - self._processes.pop(client_id, None) - if client_id not in self._cancel_requested: - return False - self._cancel_requested.remove(client_id) - return True - - -def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool: - """Ask the play CLI to stop its complete Proton/game process group.""" - - if process.poll() is not None: - return False - try: - process.send_signal(signal.SIGINT) - except OSError: - return False - return True - - -class GameClientTracker: - """Track dashboard-launched clients without retaining process objects in Tk.""" - - def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None: - self._clock = clock - self._next_client_id = 1 - self._starting: set[int] = set() - self._running: set[int] = set() - self._started_at: dict[int, float] = {} - self._running_at: dict[int, float] = {} - self._supervisor_pids: dict[int, int] = {} - - def begin(self) -> int: - client_id = self._next_client_id - self._next_client_id += 1 - self._starting.add(client_id) - self._started_at[client_id] = self._clock() - return client_id - - def attach_supervisor(self, client_id: int, pid: int) -> bool: - if pid <= 0 or ( - client_id not in self._starting and client_id not in self._running - ): - return False - self._supervisor_pids[client_id] = pid - return True - - def mark_running(self, client_id: int) -> bool: - if client_id in self._running or client_id not in self._starting: - return False - self._starting.remove(client_id) - self._running.add(client_id) - self._running_at[client_id] = self._clock() - return True - - def finish(self, client_id: int) -> str | None: - if client_id in self._starting: - self._starting.remove(client_id) - phase = "starting" - elif client_id in self._running: - self._running.remove(client_id) - phase = "running" - else: - return None - self._started_at.pop(client_id, None) - self._running_at.pop(client_id, None) - self._supervisor_pids.pop(client_id, None) - return phase - - def debug_states( - self, - *, - timestamp: float | None = None, - ) -> tuple[GameClientDebugState, ...]: - now = self._clock() if timestamp is None else timestamp - return tuple( - GameClientDebugState( - client_id=client_id, - phase="running" if client_id in self._running else "starting", - supervisor_pid=self._supervisor_pids.get(client_id), - elapsed_seconds=max( - 0.0, - now - - ( - self._running_at.get(client_id, now) - if client_id in self._running - else self._started_at.get(client_id, now) - ), - ), - ) - for client_id in sorted(self._starting | self._running) - ) - - @property - def starting_count(self) -> int: - return len(self._starting) - - @property - def running_count(self) -> int: - return len(self._running) - - @property - def active_count(self) -> int: - return self.starting_count + self.running_count - - -def game_client_activity_text(running_count: int) -> str: - if running_count < 0: - raise ValueError("running client count must not be negative") - if running_count == 0: - return "" - label = "client" if running_count == 1 else "clients" - return f"{running_count} {label} running" - - -def debug_elapsed_text(elapsed_seconds: float) -> str: - """Format a live debug duration without sub-second UI churn.""" - - seconds = max(0, int(elapsed_seconds)) - hours, remainder = divmod(seconds, 60 * 60) - minutes, seconds = divmod(remainder, 60) - if hours: - return f"{hours}h {minutes}m {seconds}s" - if minutes: - return f"{minutes}m {seconds}s" - return f"{seconds}s" - - -def operation_activity_text( - message: str, - *, - started_at: float | None, - step: int = 0, - total: int = 0, - detail: str | None = None, - timestamp: float | None = None, -) -> str: - """Render live operation progress without dropping its elapsed timer.""" - - if detail is not None: - base = detail - elif total > 0: - base = f"Step {step}/{total} · {message}" - else: - return message - if started_at is None: - return base - now = time.monotonic() if timestamp is None else timestamp - elapsed = int(max(0.0, now - started_at)) - return f"{base} · {elapsed}s elapsed" - - -def should_refresh_operation_elapsed( - *, - active: bool, - started_at: float | None, - stage_total: int, - detail: str | None, -) -> bool: - """Return whether the dashboard has operation text with a live timer.""" - - return ( - active - and started_at is not None - and (stage_total > 0 or detail is not None) - ) - - -def game_sync_presentation( - *, - installed: bool, - repair: bool = False, -) -> GameSyncPresentation: - """Distinguish a fresh download from an update or repair.""" - - if not installed: - return GameSyncPresentation( - operation_label=( - "Installing and verifying official game files…" - if repair - else "Installing official game files…" - ), - file_progress_verb="Downloading", - ) - if repair: - return GameSyncPresentation( - operation_label="Checking every game file against the official depot…", - file_progress_verb="Repairing", - ) - return GameSyncPresentation( - operation_label="Synchronizing official game files…", - file_progress_verb="Updating", - ) - - -def game_file_progress_status(current_file: str, verb: str) -> str: - """Render the current depot asset with the operation's exact intent.""" - - return f"{verb} {current_file}" - - -def perform_server_status_check( - *, - fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status, - monotonic: Callable[[], float] = time.monotonic, - wall_clock: Callable[[], float] = time.time, -) -> ServerStatusCheck: - """Measure one official status request without leaking its endpoint.""" - - started_at = monotonic() - try: - statuses = fetcher() - except ServerStatusError as error: - duration_seconds = max(0.0, monotonic() - started_at) - return ServerStatusCheck( - statuses=None, - checked_at=wall_clock(), - duration_seconds=duration_seconds, - error=str(error), - ) - duration_seconds = max(0.0, monotonic() - started_at) - return ServerStatusCheck( - statuses=statuses, - checked_at=wall_clock(), - duration_seconds=duration_seconds, - ) - - -def server_status_check_text( - check: ServerStatusCheck, - *, - timestamp: float | None = None, -) -> str: - """Render fetch age, latency, and freshness for the hidden debug view.""" - - now = time.time() if timestamp is None else timestamp - age_seconds = max(0.0, now - check.checked_at) - whole_seconds = int(age_seconds) - if whole_seconds < 60: - age = f"{whole_seconds}s" - elif whole_seconds < 60 * 60: - age = f"{whole_seconds // 60}m" - elif whole_seconds < 24 * 60 * 60: - age = f"{whole_seconds // (60 * 60)}h" - else: - age = f"{whole_seconds // (24 * 60 * 60)}d" - latency_milliseconds = max(0, round(check.duration_seconds * 1000)) - timing = f"checked {age} ago · {latency_milliseconds} ms" - if check.error is not None: - return f"failed · {timing} · {check.error}" - freshness = "stale" if age_seconds >= SERVER_STATUS_STALE_SECONDS else "fresh" - return f"{freshness} · {timing}" - - -def _server_component_text(name: str, online: bool | None) -> str: - if online is True: - state = "up" - elif online is False: - state = "down" - else: - state = "unknown" - return f"{name} {state}" - - -def server_status_presentation( - status: RealmServerStatus, - *, - show_debug_details: bool = False, -) -> ServerStatusPresentation: - if status.online is True: - text = f"● {status.name}" - style = "ServerOnline.TLabel" - elif status.online is False: - text = f"● {status.name} · offline" - style = "ServerOffline.TLabel" - else: - text = f"○ {status.name} · status unknown" - style = "ServerUnknown.TLabel" - if show_debug_details: - details: list[str] = [] - if status.players_online is not None: - label = "player" if status.players_online == 1 else "players" - details.append(f"{status.players_online} {label}") - details.extend( - ( - _server_component_text("login", status.login_online), - _server_component_text("game", status.game_online), - ) - ) - text = " · ".join((text, *details)) - return ServerStatusPresentation(text=text, style=style) - - def patch_change_colors(type_key: str) -> tuple[str, str]: """Return a restrained card-background palette for one patch change tag.""" @@ -717,235 +199,6 @@ def patch_change_colors(type_key: str) -> tuple[str, str]: }.get(type_key.casefold(), ("#2a201c", "#d8aa61")) -def launcher_update_presentation( - check: LauncherUpdateCheck, -) -> LauncherUpdatePresentation: - """Make authenticated updates visually distinct from the normal version button.""" - - if check.available and check.supported: - return LauncherUpdatePresentation( - button_text=f"UPDATE LAUNCHER · {check.latest_version}", - button_style="LauncherUpdate.TButton", - badge_text="SIGNED UPDATE AVAILABLE", - ) - if check.available: - return LauncherUpdatePresentation( - button_text=f"MANUAL UPDATE · {check.latest_version}", - button_style="LauncherManualUpdate.TButton", - badge_text="MANUAL UPDATE REQUIRED", - ) - return LauncherUpdatePresentation( - button_text=f"LAUNCHER {check.current_version}", - button_style="Secondary.TButton", - badge_text=None, - ) - - -def dashboard_preloader_delay( - started_at: float, - *, - timestamp: float | None = None, -) -> int: - """Keep the startup preloader visible long enough to avoid a brief flash.""" - - now = time.monotonic() if timestamp is None else timestamp - elapsed_milliseconds = round(max(0.0, now - started_at) * 1000) - return max(0, DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS - elapsed_milliseconds) - - -def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool: - """Return whether cached content is ready for an interactive dashboard.""" - - return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components) - - -def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: - """Return one clamped opacity step for the loader/dashboard cross-fade.""" - - progress = min(max(step, 0), DASHBOARD_TRANSITION_STEPS) / DASHBOARD_TRANSITION_STEPS - opacity_range = 1.0 - DASHBOARD_TRANSITION_MINIMUM_OPACITY - if fading_in: - return DASHBOARD_TRANSITION_MINIMUM_OPACITY + opacity_range * progress - return 1.0 - opacity_range * progress - - -def preloader_status_text( - value: str, - step: int | None = None, - total: int | None = None, -) -> str: - """Render one launch stage without letting its widget change shape.""" - - if step is None or total is None: - return value - return f"Step {step} of {total} · {value}" - - -def preloader_logo_subsample_factor( - width: int, - height: int, - *, - max_dimension: int = 88, -) -> int: - """Return an integer Tk subsample factor for a compact preloader logo.""" - - if width <= 0 or height <= 0 or max_dimension <= 0: - raise ValueError("preloader logo dimensions must be positive") - return max( - 1, - math.ceil(width / max_dimension), - math.ceil(height / max_dimension), - ) - - -def startup_progress_animation_value(current: float, target: float) -> float: - """Ease one determinate startup-progress frame without overshooting.""" - - distance = abs(target - current) - if distance == 0: - return target - step = max( - STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP, - distance * STARTUP_PROGRESS_ANIMATION_EASING, - ) - if step >= distance: - return target - direction = 1.0 if target > current else -1.0 - return current + (direction * step) - - -def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]: - """Lock a measured preloader frame so later status text cannot reflow it.""" - - frame.update_idletasks() - width = max(1, frame.winfo_reqwidth()) - height = max(1, frame.winfo_reqheight()) - frame.configure(width=width, height=height) - frame.pack_propagate(False) - return width, height - - -def launcher_restart_spec( - paths: AppPaths, - environment: Mapping[str, str] | None = None, -) -> tuple[list[str], dict[str, str]]: - """Build an isolated restart that imports the newly activated release.""" - - current = paths.launcher_current - if not current.is_symlink(): - raise DashboardError(f"launcher current path is not a symbolic link: {current}") - try: - release = current.resolve(strict=True) - except OSError as exc: - raise DashboardError(f"launcher current release is unavailable: {exc}") from exc - releases = paths.launcher_releases.resolve(strict=False) - if release.parent != releases or not (release / "ptlinux").is_dir(): - raise DashboardError(f"launcher current release is outside the release store: {release}") - env = dict(os.environ if environment is None else environment) - env.update( - { - "PTLINUX_INSTALL_DIR": str(paths.data), - "PTLINUX_APP_HOME": str(paths.app_home), - "PYTHONSAFEPATH": "1", - "PYTHONPATH": str(release), - } - ) - return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env - - -def launcher_child_command(*arguments: str) -> list[str]: - """Build a child command without writing bytecode into the active release.""" - - return [sys.executable, "-B", "-m", "ptlinux", *arguments] - - -class ProgressCoalescer: - """Keep only the latest cross-thread progress event for the Tk loop. - - The depot has concurrent download workers. Passing every 512 KiB byte update - through the GUI queue lets output overwhelm Tk, so the dashboard displays the - most recent snapshot once per UI frame instead. - """ - - def __init__(self) -> None: - self._lock = threading.Lock() - self._latest: dict[str, object] | None = None - - def publish(self, event: Mapping[str, object]) -> None: - with self._lock: - self._latest = dict(event) - - def take(self) -> dict[str, object] | None: - with self._lock: - latest = self._latest - self._latest = None - return latest - - def clear(self) -> None: - with self._lock: - self._latest = None - - -class TransferRateEstimator: - """Estimate a stable byte rate from cumulative network-transfer samples.""" - - def __init__( - self, - window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS, - minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, - ) -> None: - if window_seconds <= 0: - raise ValueError("transfer-rate window must be positive") - if minimum_interval_seconds < 0: - raise ValueError("transfer-rate minimum interval must not be negative") - self._window_seconds = window_seconds - self._minimum_interval_seconds = minimum_interval_seconds - self._samples: deque[tuple[float, int]] = deque() - - def reset(self) -> None: - self._samples.clear() - - def update( - self, - transferred_bytes: int, - *, - timestamp: float | None = None, - ) -> float | None: - """Record one cumulative sample and return bytes per second when stable.""" - - if transferred_bytes < 0: - raise ValueError("transferred bytes must not be negative") - now = time.monotonic() if timestamp is None else timestamp - if self._samples: - last_time, last_bytes = self._samples[-1] - if now <= last_time or transferred_bytes < last_bytes: - self.reset() - self._samples.append((now, transferred_bytes)) - cutoff = now - self._window_seconds - while len(self._samples) > 2 and self._samples[1][0] <= cutoff: - self._samples.popleft() - if len(self._samples) < 2: - return None - first_time, first_bytes = self._samples[0] - elapsed = now - first_time - if elapsed < self._minimum_interval_seconds: - return None - return max(0.0, (transferred_bytes - first_bytes) / elapsed) - - def is_idle( - self, - threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS, - *, - timestamp: float | None = None, - ) -> bool: - """Return whether no cumulative sample arrived within the threshold.""" - - if threshold_seconds < 0: - raise ValueError("idle threshold must not be negative") - if not self._samples: - return False - now = time.monotonic() if timestamp is None else timestamp - return now - self._samples[-1][0] >= threshold_seconds def run_dashboard(paths: AppPaths) -> None: @@ -1027,457 +280,7 @@ def run_dashboard(paths: AppPaths) -> None: ) dashboard_content = dashboard_chrome.content - style = ttk.Style(root) - style.theme_use("clam") - style.configure("Root.TFrame", background="#0c0908") - style.configure("Surface.TFrame", background="#191311") - style.configure("Hero.TFrame", background="#24170f") - style.configure("Card.TFrame", background="#211916") - style.configure( - "Brand.TLabel", - background="#0c0908", - foreground="#f5d18b", - font=("Georgia", 21, "bold"), - ) - style.configure( - "Tagline.TLabel", - background="#0c0908", - foreground="#a8998a", - font=("Sans", 8), - ) - style.configure( - "Section.TLabel", - background="#191311", - foreground="#f0d7a2", - font=("Sans", 9, "bold"), - ) - style.configure( - "HeroTitle.TLabel", - background="#24170f", - foreground="#fff2d3", - font=("Georgia", 17, "bold"), - ) - style.configure( - "HeroText.TLabel", - background="#24170f", - foreground="#d6c6b3", - font=("Sans", 10), - ) - style.configure( - "Status.TLabel", - background="#191311", - foreground="#dfd1c1", - font=("Sans", 9), - ) - style.configure( - "Muted.TLabel", - background="#191311", - foreground="#aa9a8b", - font=("Sans", 8), - ) - style.configure( - "ServerHeading.TLabel", - background="#191311", - foreground="#d8aa61", - font=("Sans", 8, "bold"), - padding=(0, 3), - ) - style.configure( - "ServerOnline.TLabel", - background="#102719", - foreground="#58e27a", - font=("Sans", 8, "bold"), - padding=(8, 3), - anchor="w", - ) - style.configure( - "ServerOffline.TLabel", - background="#301514", - foreground="#ff6258", - font=("Sans", 8, "bold"), - padding=(8, 3), - anchor="w", - ) - style.configure( - "ServerUnknown.TLabel", - background="#211916", - foreground="#b8a99a", - font=("Sans", 8), - padding=(8, 3), - anchor="w", - ) - style.configure( - "Speed.TLabel", - background="#191311", - foreground="#e5b66c", - font=("Monospace", 8, "bold"), - ) - style.configure( - "SettingsTitle.TLabel", - background="#191311", - foreground="#fff0d2", - font=("Georgia", 16, "bold"), - ) - style.configure( - "SettingsText.TLabel", - background="#191311", - foreground="#d8cbbd", - font=("Sans", 9), - ) - style.configure( - "SettingsHint.TLabel", - background="#191311", - foreground="#aa9a8b", - font=("Sans", 8), - ) - style.configure( - "SettingsCard.TLabelframe", - background="#191311", - bordercolor="#4a3528", - lightcolor="#4a3528", - darkcolor="#120e0c", - borderwidth=1, - relief="solid", - ) - style.configure( - "SettingsCard.TLabelframe.Label", - background="#191311", - foreground="#f0d7a2", - font=("Sans", 9, "bold"), - ) - style.configure("SettingsCard.TFrame", background="#191311") - style.configure( - "Update.Treeview", - background="#100d0c", - fieldbackground="#100d0c", - foreground="#ded1c4", - rowheight=max(24, round(24 * launcher_ui_scale)), - bordercolor="#4a3528", - ) - style.configure( - "Update.Treeview.Heading", - background="#2a201c", - foreground="#f0d7a2", - font=("Sans", 8, "bold"), - bordercolor="#4a3528", - lightcolor="#4a3528", - darkcolor="#120e0c", - relief="flat", - ) - style.map( - "Update.Treeview.Heading", - background=[("active", "#2a201c"), ("pressed", "#2a201c")], - foreground=[("active", "#f0d7a2"), ("pressed", "#f0d7a2")], - relief=[("active", "flat"), ("pressed", "flat")], - ) - style.map( - "Update.Treeview", - background=[("selected", "#6a482d")], - foreground=[("selected", "#ffffff")], - ) - style.configure("SettingsFooter.TFrame", background="#100d0c") - style.configure( - "SettingsFooter.TLabel", - background="#100d0c", - foreground="#aa9a8b", - font=("Sans", 8), - ) - style.configure( - "Settings.TEntry", - fieldbackground="#100d0c", - foreground="#f8ead2", - insertcolor="#f8fafc", - bordercolor="#4a3528", - lightcolor="#4a3528", - darkcolor="#4a3528", - focuscolor="#f4bd61", - focusthickness=2, - ) - style.map("Settings.TEntry", bordercolor=[("focus", "#f4bd61")]) - style.configure( - "Settings.TCombobox", - fieldbackground="#100d0c", - background="#211916", - foreground="#f8ead2", - arrowcolor="#e5b66c", - bordercolor="#4a3528", - focuscolor="#f4bd61", - focusthickness=2, - ) - style.map( - "Settings.TCombobox", - fieldbackground=[("readonly", "#100d0c")], - foreground=[("readonly", "#f8ead2")], - bordercolor=[("focus", "#f4bd61")], - ) - style.configure( - "Realm.TCombobox", - fieldbackground="#211916", - background="#211916", - foreground="#fff0d2", - arrowcolor="#d8aa61", - bordercolor="#4a3528", - lightcolor="#4a3528", - darkcolor="#4a3528", - font=("Sans", 9, "bold"), - padding=(10, 7), - relief="flat", - ) - style.map( - "Realm.TCombobox", - fieldbackground=[ - ("disabled", "#171412"), - ("readonly", "#211916"), - ], - background=[ - ("disabled", "#171412"), - ("active", "#2a201c"), - ("readonly", "#211916"), - ], - foreground=[ - ("disabled", "#887a70"), - ("readonly", "#fff0d2"), - ], - arrowcolor=[ - ("disabled", "#887a70"), - ("active", "#f0d7a2"), - ("readonly", "#d8aa61"), - ], - bordercolor=[ - ("disabled", "#4a3528"), - ("focus", "#6a4d38"), - ("readonly", "#4a3528"), - ], - ) - root.option_add("*realm_selector*Listbox.background", "#211916") - root.option_add("*realm_selector*Listbox.foreground", "#fff0d2") - root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c") - root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2") - root.option_add("*realm_selector*Listbox.font", "Sans 9 bold") - root.option_add("*realm_selector*Listbox.justify", "center") - root.option_add("*realm_selector*Listbox.selectBorderWidth", 4) - root.option_add("*realm_selector*Listbox.borderWidth", 1) - root.option_add("*realm_selector*Listbox.highlightThickness", 0) - root.option_add("*realm_selector*Listbox.relief", "flat") - root.option_add("*realm_selector*Listbox.activeStyle", "none") - style.configure( - "Settings.TCheckbutton", - background="#191311", - foreground="#d8cbbd", - font=("Sans", 9), - focuscolor="#f4bd61", - focusthickness=2, - ) - style.map( - "Settings.TCheckbutton", - background=[("active", "#191311")], - foreground=[("active", "#fff0d2")], - ) - style.configure( - "PreloaderStatus.TLabel", - background="#0c0908", - foreground="#dfd1c1", - font=("Sans", 10), - ) - style.configure( - "PreloaderMuted.TLabel", - background="#0c0908", - foreground="#aa9a8b", - font=("Sans", 9), - ) - style.configure( - "NewsTitle.TLabel", - background="#211916", - foreground="#fff0d2", - font=("Sans", 9, "bold"), - ) - style.configure( - "NewsMeta.TLabel", - background="#211916", - foreground="#d8aa61", - font=("Sans", 9, "bold"), - ) - style.configure( - "NewsBody.TLabel", - background="#211916", - foreground="#c9baaa", - font=("Sans", 8), - ) - style.configure( - "NewsTab.TButton", - background="#191311", - foreground="#9f8d7c", - font=("Sans", 8, "bold"), - padding=(10, 4), - bordercolor="#32251f", - borderwidth=1, - relief="flat", - focusthickness=0, - ) - style.map( - "NewsTab.TButton", - background=[("active", "#2a201c")], - foreground=[("active", "#f0d7a2")], - bordercolor=[("focus", "#f4bd61"), ("active", "#4a3528")], - ) - style.configure( - "NewsTabActive.TButton", - background="#2a201c", - foreground="#f0d7a2", - font=("Sans", 8, "bold"), - padding=(10, 4), - bordercolor="#6a4d38", - borderwidth=1, - relief="flat", - focusthickness=0, - ) - style.map( - "NewsTabActive.TButton", - background=[("active", "#32251f")], - foreground=[("active", "#fff0d2")], - bordercolor=[("focus", "#f4bd61")], - ) - style.configure( - "News.Vertical.TScrollbar", - background="#4a3528", - troughcolor="#191311", - arrowcolor="#d8aa61", - bordercolor="#191311", - lightcolor="#4a3528", - darkcolor="#32251f", - ) - style.map( - "News.Vertical.TScrollbar", - background=[ - ("pressed", "#765034"), - ("active", "#62452f"), - ("!disabled", "#4a3528"), - ], - arrowcolor=[ - ("pressed", "#fff0c2"), - ("active", "#f0c77f"), - ("!disabled", "#d8aa61"), - ], - lightcolor=[ - ("pressed", "#765034"), - ("active", "#62452f"), - ("!disabled", "#4a3528"), - ], - darkcolor=[ - ("pressed", "#4b3021"), - ("active", "#3d2a21"), - ("!disabled", "#32251f"), - ], - ) - style.configure( - "Play.TButton", - background="#d98927", - foreground="#fff8e9", - font=("Sans", 10, "bold"), - padding=(20, 10), - bordercolor="#f4bd61", - borderwidth=1, - relief="flat", - focuscolor="#fff0c2", - focusthickness=2, - ) - style.map( - "Play.TButton", - background=[("active", "#efaa4c"), ("disabled", "#6c4826")], - foreground=[("disabled", "#bca889")], - bordercolor=[("focus", "#fff0c2")], - ) - style.configure( - "Secondary.TButton", - background="#0b0a09", - foreground="#f2b657", - font=("Sans", 8, "bold"), - padding=(12, 8), - bordercolor="#d89332", - borderwidth=1, - relief="flat", - focuscolor="#f4bd61", - focusthickness=2, - ) - style.map( - "Secondary.TButton", - background=[("active", "#271b10"), ("disabled", "#171412")], - foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], - bordercolor=[("focus", "#f4bd61")], - ) - style.configure( - "LauncherUpdate.TButton", - background="#f4bd61", - foreground="#24170f", - font=("Sans", 8, "bold"), - padding=(12, 8), - bordercolor="#ffe3a3", - borderwidth=2, - relief="flat", - focuscolor="#24170f", - focusthickness=2, - ) - style.map( - "LauncherUpdate.TButton", - background=[("active", "#ffd47f"), ("disabled", "#7b5d37")], - foreground=[("active", "#120b06"), ("disabled", "#d0bea2")], - bordercolor=[("focus", "#ffffff")], - ) - style.configure( - "LauncherManualUpdate.TButton", - background="#a63f2f", - foreground="#fff4e9", - font=("Sans", 8, "bold"), - padding=(12, 8), - bordercolor="#e98769", - borderwidth=2, - relief="flat", - focuscolor="#ffffff", - focusthickness=2, - ) - style.map( - "LauncherManualUpdate.TButton", - background=[("active", "#c95842"), ("disabled", "#60352e")], - foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")], - bordercolor=[("focus", "#ffffff")], - ) - style.configure( - "UpdateBadge.TLabel", - background="#d98927", - foreground="#1a1008", - font=("Sans", 7, "bold"), - padding=(7, 3), - ) - style.configure( - "Activity.TButton", - background="#0c0908", - foreground="#f8fafc", - font=("Monospace", 8, "bold"), - padding=(3, 2), - borderwidth=0, - relief="flat", - ) - style.map( - "Activity.TButton", - background=[("active", "#24170f")], - foreground=[("active", "#ffffff")], - ) - style.configure( - "News.TButton", - background="#211916", - foreground="#e5b66c", - font=("Sans", 9, "bold"), - padding=(0, 3), - ) - style.map("News.TButton", foreground=[("active", "#ffe0a0")]) - style.configure( - "Depot.Horizontal.TProgressbar", - background="#d68c34", - troughcolor="#34251f", - bordercolor="#34251f", - lightcolor="#e4a34c", - darkcolor="#b66c25", - ) + configure_dashboard_style(root, ttk, launcher_ui_scale) startup_started_at = time.monotonic() startup_status = tk.StringVar(value="Loading dashboard contents…") @@ -1737,335 +540,85 @@ def run_dashboard(paths: AppPaths) -> None: startup_timeout_after = None begin_dashboard_transition(timed_out=True) - frame = ttk.Frame(dashboard_content, style="Root.TFrame", padding=12) - frame.pack(fill="both", expand=True) + header_widgets = build_dashboard_header( + dashboard_content, + widgets=ttk, + tk_module=tk, + launcher_version=__version__, + window_icon=window_icon, + ) + frame = header_widgets.frame + version = header_widgets.version + logo = header_widgets.logo - header = ttk.Frame(frame, style="Root.TFrame") - header.pack(fill="x", pady=(0, 8)) - logo = tk.Canvas( - header, - width=42, - height=42, - background="#0c0908", - highlightthickness=0, - borderwidth=0, - ) - dashboard_header_icon: PhotoImage | None = None - if window_icon is not None: - try: - header_icon_factor = preloader_logo_subsample_factor( - window_icon.width(), - window_icon.height(), - max_dimension=40, - ) - dashboard_header_icon = window_icon.subsample( - header_icon_factor, - header_icon_factor, - ) - except tk.TclError: - dashboard_header_icon = None - if dashboard_header_icon is not None: - logo.create_image(21, 21, image=dashboard_header_icon) - logo.image = dashboard_header_icon - logo.pack(side="left", padx=(0, 9)) - brand = ttk.Frame(header, style="Root.TFrame") - brand.pack(side="left", fill="x", expand=True) - ttk.Label(brand, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w") - ttk.Label( - brand, - text="PRISTONTALE EU · NATIVE LINUX LAUNCHER", - style="Tagline.TLabel", - ).pack(anchor="w", pady=(1, 0)) - version = tk.StringVar( - value=f"Launcher {__version__} · checking official game build…" - ) - ttk.Label(header, textvariable=version, style="Tagline.TLabel").pack( - side="right", - anchor="e", + action_widgets = build_action_panel( + frame, + ttk=ttk, + launcher_version=__version__, ) + controls = action_widgets.container + game_actions = action_widgets.game_actions + system_actions = action_widgets.system_actions + update_button = action_widgets.update_button + repair_button = action_widgets.repair_button + import_button = action_widgets.import_button + doctor_button = action_widgets.doctor_button + settings_button = action_widgets.settings_button + launcher_update_button = action_widgets.launcher_update_button + launcher_update_badge = action_widgets.launcher_update_badge - controls = ttk.Frame(frame, style="Root.TFrame") - controls.pack(fill="x", pady=(8, 8)) - controls.columnconfigure(0, weight=1, uniform="actions") - controls.columnconfigure(1, weight=1, uniform="actions") - game_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) - game_actions.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) - ttk.Label(game_actions, text="GAME FILES", style="Section.TLabel").pack(anchor="w") - game_button_row = ttk.Frame(game_actions, style="Surface.TFrame") - game_button_row.pack(anchor="w", fill="x", pady=(6, 0)) - update_button = ttk.Button( - game_button_row, - text="UPDATE", - style="Secondary.TButton", - underline=0, + progress_widgets = build_progress_panel( + frame, + widgets=ttk, + tk_module=tk, + default_realm=DEFAULT_DEPOT_START_OPTION.name, ) - update_button.pack(side="left") - repair_button = ttk.Button( - game_button_row, - text="REPAIR", - style="Secondary.TButton", - underline=0, - ) - repair_button.pack(side="left", padx=(8, 0)) - import_button = ttk.Button( - game_button_row, - text="IMPORT GAME", - style="Secondary.TButton", - underline=0, - ) - import_button.pack(side="left", padx=(8, 0)) + realm_value = progress_widgets.realm_value + realm_selector = progress_widgets.realm_selector + play_button = progress_widgets.play_button + status = progress_widgets.status + progress_text = progress_widgets.progress_text + speed_text = progress_widgets.speed_text + client_activity_text = progress_widgets.client_activity_text + progress_bar = progress_widgets.progress_bar + server_status_content = progress_widgets.server_status_content + server_status_debug_text = progress_widgets.server_status_debug_text + log_toggle = progress_widgets.log_toggle + refresh_activity_meta_text = progress_widgets.refresh_activity_metadata + set_download_context_text = progress_widgets.set_download_context - system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) - system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) - system_heading = ttk.Frame(system_actions, style="Surface.TFrame") - system_heading.pack(fill="x") - ttk.Label(system_heading, text="LAUNCHER", style="Section.TLabel").pack(side="left") - launcher_update_badge = ttk.Label( - system_heading, - text="", - style="UpdateBadge.TLabel", + news_widgets = build_news_panel( + frame, + ttk_module=ttk, + tk_module=tk, + controls=controls, + ui_scale=launcher_ui_scale, ) - system_button_row = ttk.Frame(system_actions, style="Surface.TFrame") - system_button_row.pack(anchor="w", fill="x", pady=(6, 0)) - doctor_button = ttk.Button( - system_button_row, - text="SYSTEM CHECK", - style="Secondary.TButton", - underline=7, - ) - doctor_button.pack(side="left") - settings_button = ttk.Button( - system_button_row, - text="OPEN SETTINGS", - style="Secondary.TButton", - underline=5, - ) - settings_button.pack(side="left", padx=(8, 0)) - launcher_update_button = ttk.Button( - system_button_row, - text=f"LAUNCHER {__version__}", - style="Secondary.TButton", - underline=0, - ) - launcher_update_button.pack(side="left", padx=(8, 0)) + news_panel = news_widgets.container + news_tab_button = news_widgets.news_tab_button + patches_tab_button = news_widgets.patches_tab_button + news_canvas = news_widgets.canvas + news_scrollbar = news_widgets.scrollbar + news_content_host = news_widgets.content_host + news_content = news_widgets.news_content + patches_content = news_widgets.patches_content + news_window = news_widgets.canvas_window - activity_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 7)) - activity_panel.pack(fill="x", pady=(0, 8)) - realm_value = tk.StringVar(value=DEFAULT_DEPOT_START_OPTION.name) - activity_action = ttk.Frame(activity_panel, style="Surface.TFrame") - activity_action.pack(side="right", fill="y", padx=(12, 0)) - play_button = ttk.Button( - activity_action, - text="PLAY", - style="Play.TButton", - underline=0, + broadcast_widgets = build_broadcasts_panel( + frame, + ttk=ttk, + before=news_panel, ) - play_button.pack(fill="both", expand=True) - realm_selector = ttk.Combobox( - activity_action, - name="realm_selector", - textvariable=realm_value, - values=(DEFAULT_DEPOT_START_OPTION.name,), - state="readonly", - style="Realm.TCombobox", - font=("Sans", 9, "bold"), - justify="center", - width=20, - ) - realm_selector.pack(fill="x", pady=(6, 5)) - activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame") - activity_copy.pack(side="left", fill="both", expand=True) - status = tk.StringVar(value="Ready") - progress_text = tk.StringVar(value="Click Play to start your journey") - speed_text = tk.StringVar(value="") - client_activity_text = tk.StringVar(value="") - download_context_text = tk.StringVar(value="") - activity_meta_text = tk.StringVar(value="") + broadcast_content = broadcast_widgets.content - def refresh_activity_meta_text() -> None: - activity_meta_text.set( - download_context_text.get() or client_activity_text.get() - ) - - def set_download_context_text(value: str) -> None: - download_context_text.set(value) - refresh_activity_meta_text() - - activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame") - activity_heading.pack(fill="x") - ttk.Label( - activity_heading, - textvariable=status, - style="Status.TLabel", - ).pack(side="left", anchor="w") - ttk.Label( - activity_heading, - textvariable=activity_meta_text, - style="Muted.TLabel", - ).pack(side="right", anchor="e") - progress_bar = ttk.Progressbar( - activity_copy, - style="Depot.Horizontal.TProgressbar", - mode="determinate", - maximum=1, - value=0, + activity_widgets = build_activity_window( + root, + tk_module=tk, + ui_scale=launcher_ui_scale, + apply_window_icon=apply_window_icon, ) - progress_bar.pack(fill="x", pady=(5, 2)) - progress_details = ttk.Frame(activity_copy, style="Surface.TFrame") - progress_details.pack(fill="x") - ttk.Label( - progress_details, - textvariable=speed_text, - style="Speed.TLabel", - width=28, - anchor="e", - ).pack(side="right") - ttk.Label( - progress_details, - textvariable=progress_text, - style="Muted.TLabel", - anchor="w", - ).pack(side="left", fill="x", expand=True) - server_status_row = ttk.Frame(activity_copy, style="Surface.TFrame") - server_status_row.pack(fill="x", pady=(5, 1)) - ttk.Label( - server_status_row, - text="SERVERS", - style="ServerHeading.TLabel", - ).pack(side="left", fill="y", padx=(0, 8)) - server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame") - server_status_content.pack(side="left", fill="y", anchor="w") - log_toggle = tk.Button( - server_status_row, - text=">_", - background="#191311", - foreground="#f8fafc", - activebackground="#24170f", - activeforeground="#ffffff", - font=("Monospace", 8, "bold"), - padx=3, - pady=1, - borderwidth=0, - highlightthickness=1, - highlightbackground="#191311", - highlightcolor="#f4bd61", - relief="flat", - takefocus=True, - ) - log_toggle.pack(side="right", anchor="e", padx=(8, 0)) - server_status_debug_text = tk.StringVar(value="") - ttk.Label( - server_status_row, - textvariable=server_status_debug_text, - style="Muted.TLabel", - ).pack(side="right", anchor="e", padx=(8, 0)) - ttk.Label( - server_status_content, - text="Checking official server status…", - style="ServerUnknown.TLabel", - ).pack(side="left", anchor="w") - - news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) - news_panel.pack(fill="both", expand=True, pady=(0, 8), before=controls) - news_heading = ttk.Frame(news_panel, style="Surface.TFrame") - news_heading.pack(fill="x") - ttk.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left") - news_tabs = ttk.Frame(news_heading, style="Surface.TFrame") - news_tabs.pack(side="left", padx=(14, 0)) - news_tab_button = ttk.Button( - news_tabs, - text="NEWS", - style="NewsTabActive.TButton", - underline=0, - ) - news_tab_button.pack(side="left") - patches_tab_button = ttk.Button( - news_tabs, - text="PATCHES", - style="NewsTab.TButton", - underline=0, - ) - patches_tab_button.pack(side="left", padx=(5, 0)) - ttk.Label( - news_heading, - text="OFFICIAL FEED", - style="Muted.TLabel", - ).pack(side="right") - news_viewport = ttk.Frame(news_panel, style="Surface.TFrame") - news_viewport.pack(fill="both", expand=True, pady=(6, 0)) - news_viewport.grid_columnconfigure(0, weight=1) - news_viewport.grid_rowconfigure(0, weight=1) - news_canvas = tk.Canvas( - news_viewport, - background="#191311", - highlightthickness=0, - borderwidth=0, - height=round(252 * launcher_ui_scale), - takefocus=True, - ) - news_canvas.grid(row=0, column=0, sticky="nsew") - news_scrollbar = ttk.Scrollbar( - news_viewport, - orient="vertical", - style="News.Vertical.TScrollbar", - command=news_canvas.yview, - ) - news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0)) - news_scrollbar.grid_remove() - news_canvas.configure(yscrollcommand=news_scrollbar.set) - news_content_host = ttk.Frame(news_canvas, style="Surface.TFrame") - news_content_host.grid_columnconfigure(0, weight=1) - news_content = ttk.Frame(news_content_host, style="Surface.TFrame") - patches_content = ttk.Frame(news_content_host, style="Surface.TFrame") - news_content.grid(row=0, column=0, sticky="nsew") - patches_content.grid(row=0, column=0, sticky="nsew") - patches_content.grid_remove() - news_window = news_canvas.create_window( - (0, 0), - window=news_content_host, - anchor="nw", - ) - - broadcast_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) - broadcast_panel.pack(fill="x", pady=(0, 8), before=news_panel) - broadcast_heading = ttk.Frame(broadcast_panel, style="Surface.TFrame") - broadcast_heading.pack(fill="x") - ttk.Label(broadcast_heading, text="LATEST BROADCAST", style="Section.TLabel").pack(side="left") - ttk.Label( - broadcast_heading, - text="OFFICIAL DISCORD", - style="Muted.TLabel", - ).pack(side="right") - broadcast_content = ttk.Frame(broadcast_panel, style="Surface.TFrame") - broadcast_content.pack(fill="x", pady=(6, 0)) - - activity_window = tk.Toplevel(root) - activity_window.title("PristonTale EU — Activity") - activity_width, activity_height = fitted_window_size( - 800, - 400, - scale=launcher_ui_scale, - screen_width=activity_window.winfo_screenwidth(), - screen_height=activity_window.winfo_screenheight(), - ) - activity_window.geometry(f"{activity_width}x{activity_height}") - activity_window.resizable(False, False) - activity_window.configure(background="#0c0908") - apply_window_icon(activity_window) - log = tk.Text( - activity_window, - background="#100d0c", - foreground="#d8cbbd", - insertbackground="#f8fafc", - relief="flat", - font=("Monospace", 9), - padx=10, - pady=8, - state="disabled", - ) - log.pack(fill="both", expand=True, padx=10, pady=10) - activity_window.withdraw() - + activity_window = activity_widgets.window + log = activity_widgets.log events: queue.Queue[tuple[str, object]] = queue.Queue() progress_updates = ProgressCoalescer() transfer_rate = TransferRateEstimator() @@ -2086,9 +639,7 @@ def run_dashboard(paths: AppPaths) -> None: preloader_progress: ttk.Progressbar | None = None preloader_image_references: list[tk.PhotoImage] = [] settings_window: tk.Toplevel | None = None - launcher_update_window: tk.Toplevel | None = None - launcher_update_dialog_refresh: Callable[[], None] | None = None - launcher_update_dialog_error: Callable[[str], None] | None = None + launcher_update_dialog: LauncherUpdateDialog | None = None dashboard_debug_window: tk.Toplevel | None = None dashboard_debug_output: tk.Text | None = None game_clients = GameClientTracker() @@ -3230,44 +1781,14 @@ def run_dashboard(paths: AppPaths) -> None: def render_server_status() -> None: update_server_status_debug_text() - for child in server_status_content.winfo_children(): - child.destroy() - if latest_server_statuses is None: - ttk.Label( - server_status_content, - text=server_status_placeholder, - style="ServerUnknown.TLabel", - ).pack(side="left", anchor="w") - return - statuses_by_name = { - item.name.casefold(): item for item in latest_server_statuses - } - visible = [ - statuses_by_name[option.name.casefold()] - for option in available_start_options - if option.name.casefold() in statuses_by_name - ] - if not visible: - ttk.Label( - server_status_content, - text="Official status is unavailable for this realm.", - style="ServerUnknown.TLabel", - ).pack(side="left", anchor="w") - return - for index, server in enumerate(visible): - presentation = server_status_presentation( - server, - show_debug_details=show_dashboard_debug, - ) - ttk.Label( - server_status_content, - text=presentation.text, - style=presentation.style, - ).pack( - side="left", - anchor="w", - padx=(0 if index == 0 else 6, 0), - ) + render_server_status_component( + server_status_content, + ttk=ttk, + statuses=latest_server_statuses, + start_options=available_start_options, + placeholder=server_status_placeholder, + show_debug_details=show_dashboard_debug, + ) def show_server_status( check: ServerStatusCheck, @@ -3763,7 +2284,8 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_badge.pack_forget() if log_reason: append_log(check.reason) - call_optional(launcher_update_dialog_refresh) + if launcher_update_dialog is not None: + launcher_update_dialog.refresh() def refresh_launcher_update_check() -> None: def worker() -> None: @@ -4163,7 +2685,8 @@ def run_dashboard(paths: AppPaths) -> None: ) launcher_update_badge.pack_forget() append_log(f"Launcher update check unavailable: {value}") - call_optional(launcher_update_dialog_error, value) + if launcher_update_dialog is not None: + launcher_update_dialog.show_error(value) mark_startup_component_ready( "launcher_update", "Launcher update check complete", @@ -4198,368 +2721,39 @@ def run_dashboard(paths: AppPaths) -> None: def command_for(*arguments: str) -> list[str]: return launcher_child_command(*arguments) + def install_launcher_and_restart(version_to_install: str) -> None: + run_operation( + f"Installing launcher {version_to_install}…", + command_for("launcher-update"), + restart_on_success=True, + ) + + def rollback_launcher_and_restart(target_version: str) -> None: + run_operation( + f"Rolling back launcher to {target_version}…", + command_for("launcher-update", "--rollback"), + restart_on_success=True, + ) + + launcher_update_dialog = LauncherUpdateDialog( + root, + paths=paths, + launcher_version=__version__, + ui_scale=launcher_ui_scale, + window_icon=window_icon, + apply_window_icon=apply_window_icon, + get_check=lambda: launcher_update_check, + is_busy=lambda: operation_active or bool(game_clients.running_count), + request_check=refresh_launcher_update_check, + install_and_restart=install_launcher_and_restart, + rollback_and_restart=rollback_launcher_and_restart, + open_article=open_article, + append_log=append_log, + ) + def run_launcher_update(required_reason: str | None = None) -> None: - nonlocal launcher_update_window - nonlocal launcher_update_dialog_refresh, launcher_update_dialog_error - if launcher_update_window is not None and launcher_update_window.winfo_exists(): - launcher_update_window.deiconify() - launcher_update_window.lift() - launcher_update_window.focus_set() - if launcher_update_dialog_refresh is not None: - launcher_update_dialog_refresh() - return - - dialog = tk.Toplevel(root) - dialog.withdraw() - launcher_update_window = dialog - dialog.title("PristonTale EU — Launcher Updates") - dialog.resizable(False, False) - dialog.configure(background="#0c0908") - dialog.transient(root) - apply_window_icon(dialog) - updates_chrome = create_window_chrome( - dialog, - title="PRISTONTALE EU", - subtitle="LAUNCHER UPDATES", - window_icon=window_icon, - show_close=True, - show_minimize=False, - allow_close=True, - ) - - updates_header = tk.Frame(updates_chrome.content, background="#24170f") - updates_header.pack(fill="x") - tk_accent = tk.Frame(updates_header, background="#d98927", width=5) - tk_accent.pack(side="left", fill="y") - header_copy = tk.Frame(updates_header, background="#24170f") - header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) - tk.Label( - header_copy, - text="SIGNED STABLE CHANNEL", - background="#24170f", - foreground="#d8aa61", - font=("Sans", 8, "bold"), - ).pack(anchor="w") - tk.Label( - header_copy, - text="Launcher Updates", - background="#24170f", - foreground="#fff0d2", - font=("Georgia", 18, "bold"), - ).pack(anchor="w", pady=(2, 0)) - - content = ttk.Frame( - updates_chrome.content, - style="Root.TFrame", - padding=(16, 14, 16, 16), - ) - content.pack(fill="both", expand=True) - content.columnconfigure(0, weight=1) - content.rowconfigure(1, weight=1) - - status_value = tk.StringVar(value="Checking the signed update channel…") - details_value = tk.StringVar(value="") - overview = ttk.LabelFrame( - content, - text="UPDATE STATUS", - style="SettingsCard.TLabelframe", - padding=14, - ) - overview.grid(row=0, column=0, sticky="ew") - overview.columnconfigure(0, weight=1) - ttk.Label( - overview, - textvariable=status_value, - style="SettingsText.TLabel", - wraplength=round(480 * launcher_ui_scale), - justify="left", - ).grid(row=0, column=0, sticky="w") - ttk.Label( - overview, - textvariable=details_value, - style="SettingsHint.TLabel", - wraplength=round(480 * launcher_ui_scale), - justify="left", - ).grid(row=1, column=0, sticky="w", pady=(6, 0)) - release_notes_button = ttk.Button( - overview, - text="VIEW RELEASE NOTES", - style="Secondary.TButton", - state="disabled", - ) - release_notes_button.grid(row=0, column=1, rowspan=2, sticky="e", padx=(12, 0)) - - history_card = ttk.LabelFrame( - content, - text="INSTALLED HISTORY", - style="SettingsCard.TLabelframe", - padding=12, - ) - history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0)) - history_card.columnconfigure(0, weight=1) - history_card.rowconfigure(0, weight=1) - history = ttk.Treeview( - history_card, - columns=("version", "state", "installed"), - show="headings", - height=6, - style="Update.Treeview", - selectmode="browse", - ) - history.heading("version", text="VERSION", anchor="w") - history.heading("state", text="STATE", anchor="w") - history.heading("installed", text="INSTALLED", anchor="w") - history.column( - "version", - width=round(120 * launcher_ui_scale), - minwidth=round(80 * launcher_ui_scale), - stretch=False, - anchor="w", - ) - history.column( - "state", - width=round(150 * launcher_ui_scale), - minwidth=round(100 * launcher_ui_scale), - stretch=False, - anchor="w", - ) - history.column( - "installed", - width=round(360 * launcher_ui_scale), - minwidth=round(180 * launcher_ui_scale), - stretch=True, - anchor="w", - ) - history.grid(row=0, column=0, sticky="nsew") - history_scrollbar = ttk.Scrollbar( - history_card, - orient="vertical", - style="News.Vertical.TScrollbar", - command=history.yview, - ) - history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0)) - history.configure(yscrollcommand=history_scrollbar.set) - - footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) - footer.grid(row=2, column=0, sticky="ew", pady=(12, 0)) - footer.columnconfigure(0, weight=1) - check_button = ttk.Button( - footer, - text="CHECK AGAIN", - style="Secondary.TButton", - ) - check_button.grid(row=0, column=0, sticky="w") - rollback_button = ttk.Button( - footer, - text="ROLL BACK & RESTART", - style="Secondary.TButton", - state="disabled", - ) - rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0)) - install_button = ttk.Button( - footer, - text="INSTALL & RESTART", - style="Play.TButton", - state="disabled", - ) - install_button.grid(row=0, column=2, sticky="e", padx=(8, 0)) - close_button = ttk.Button( - footer, - text="CLOSE", - style="Secondary.TButton", - ) - close_button.grid(row=0, column=3, sticky="e", padx=(8, 0)) - - installed_history: LauncherUpdateHistory | None = None - - def close_updates() -> None: - nonlocal launcher_update_window - nonlocal launcher_update_dialog_refresh, launcher_update_dialog_error - launcher_update_dialog_refresh = None - launcher_update_dialog_error = None - if launcher_update_window is not None and launcher_update_window.winfo_exists(): - launcher_update_window.destroy() - launcher_update_window = None - - def refresh_update_dialog() -> None: - nonlocal installed_history - check = launcher_update_check - try: - installed_history = inspect_launcher_update_history(paths) - except RuntimeError as error: - installed_history = None - append_log(f"Could not inspect launcher update history: {error}") - for item in history.get_children(): - history.delete(item) - if installed_history is not None: - for release in installed_history.releases: - state_label = ( - "Current" - if release.active - else "Rollback target" - if release.previous - else "Retained" - ) - history.insert( - "", - "end", - values=(release.version, state_label, release.installed_at), - ) - if not history.get_children(): - history.insert("", "end", values=(__version__, "Running", "Not recorded")) - rollback_available = ( - installed_history is not None - and installed_history.previous_version is not None - and not operation_active - and not game_clients.running_count - ) - rollback_button.configure( - state="normal" if rollback_available else "disabled" - ) - check_button.configure( - state=( - "disabled" - if operation_active or game_clients.running_count - else "normal" - ) - ) - if check is None: - status_value.set("Checking the signed update channel…") - details_value.set( - "Installed launcher " + __version__ + " · release information pending" - ) - install_button.configure(state="disabled") - release_notes_button.configure(state="disabled") - return - status_prefix = ( - f"Compatibility update required: {required_reason}\n" - if required_reason is not None - else "" - ) - status_value.set(status_prefix + check.reason) - details_value.set( - f"Current {check.current_version} · Latest {check.latest_version} · " - f"{check.manifest.channel.title()} channel · " - f"{_format_bytes(check.manifest.archive_size)} · " - f"published {check.manifest.published_at} · " - f"checked {check.checked_at or 'time unavailable'}" - ) - install_button.configure( - state=( - "normal" - if check.available - and check.supported - and not operation_active - and not game_clients.running_count - else "disabled" - ) - ) - release_notes_button.configure( - state="normal" if check.manifest.notes_url is not None else "disabled" - ) - - def show_update_error(message: str) -> None: - status_value.set("The signed update channel could not be checked.") - details_value.set(message) - check_button.configure(state="normal") - - def check_again() -> None: - status_value.set("Checking the signed update channel…") - details_value.set("Authenticating the stable manifest and signature.") - check_button.configure(state="disabled") - install_button.configure(state="disabled") - refresh_launcher_update_check() - - def view_release_notes() -> None: - check = launcher_update_check - if check is not None and check.manifest.notes_url is not None: - open_article(check.manifest.notes_url) - - def install_update() -> None: - check = launcher_update_check - if check is None or not check.available or not check.supported: - return - if not show_themed_dialog( - dialog, - title="Update launcher", - message=( - f"Install signed launcher {check.latest_version} " - f"({_format_bytes(check.manifest.archive_size)}) and restart now?" - ), - kind="question", - confirm=True, - accept_text="INSTALL & RESTART", - window_icon=window_icon, - ): - return - version_to_install = check.latest_version - close_updates() - run_operation( - f"Installing launcher {version_to_install}…", - command_for("launcher-update"), - restart_on_success=True, - ) - - def rollback_update() -> None: - history_snapshot = installed_history - if history_snapshot is None or history_snapshot.previous_version is None: - return - if not show_themed_dialog( - dialog, - title="Roll back launcher", - message=( - f"Switch from launcher {history_snapshot.current_version or __version__} " - f"to {history_snapshot.previous_version} and restart?\n\n" - "The current release remains available as the next rollback target." - ), - kind="warning", - confirm=True, - accept_text="ROLL BACK & RESTART", - window_icon=window_icon, - ): - return - target_version = history_snapshot.previous_version - close_updates() - run_operation( - f"Rolling back launcher to {target_version}…", - command_for("launcher-update", "--rollback"), - restart_on_success=True, - ) - - release_notes_button.configure(command=view_release_notes) - check_button.configure(command=check_again) - rollback_button.configure(command=rollback_update) - install_button.configure(command=install_update) - close_button.configure(command=close_updates) - updates_chrome.set_close_command(close_updates) - dialog.protocol("WM_DELETE_WINDOW", close_updates) - dialog.bind("", lambda _event: close_updates()) - launcher_update_dialog_refresh = refresh_update_dialog - launcher_update_dialog_error = show_update_error - refresh_update_dialog() - if launcher_update_check is None: - check_again() - dialog.update_idletasks() - updates_width, updates_height = fitted_window_size( - 760, - 620, - scale=launcher_ui_scale, - screen_width=dialog.winfo_screenwidth(), - screen_height=dialog.winfo_screenheight(), - ) - dialog.geometry( - centered_window_geometry( - updates_width, - updates_height, - parent_x=root.winfo_rootx(), - parent_y=root.winfo_rooty(), - parent_width=root.winfo_width(), - parent_height=root.winfo_height(), - ) - ) - dialog.deiconify() - dialog.lift() - history.focus_set() + assert launcher_update_dialog is not None + launcher_update_dialog.open(required_reason) launcher_update_button.configure(command=run_launcher_update) @@ -4649,488 +2843,40 @@ def run_dashboard(paths: AppPaths) -> None: command=lambda: run_operation("Checking the system…", command_for("doctor")) ) + def apply_saved_settings(updated_settings: Settings) -> None: + saved_limit = ( + f"{updated_settings.download_speed_limit_mbps:g} MB/s" + if updated_settings.download_speed_limit_mbps is not None + else "unlimited" + ) + append_log( + f"Saved launcher settings to {paths.settings_file}: " + f"runtime={updated_settings.runtime}, " + f"proton_sync={updated_settings.proton_sync}, " + f"download_limit={saved_limit}, " + f"low_bandwidth={updated_settings.low_bandwidth_mode}" + ) + status.set("Settings saved.") + progress_text.set( + "Game and download settings apply to the next operation; launcher scale " + "and cached-content mode apply after restart." + ) + if show_dashboard_debug: + refresh_dashboard_debug_disk_state() + update_dashboard_debug_view(force=True) + def open_settings() -> None: nonlocal settings_window - if settings_window is not None and settings_window.winfo_exists(): - settings_window.deiconify() - settings_window.lift() - settings_window.focus_set() - return - - try: - current_settings = ensure_settings(paths) - except (OSError, ValueError) as error: - show_themed_dialog( - root, - title="Settings", - message=f"The launcher could not load its settings:\n\n{error}", - kind="error", - window_icon=window_icon, - ) - return - dialog = tk.Toplevel(root) - dialog.withdraw() - settings_window = dialog - dialog.title("PristonTale EU — Settings") - dialog.resizable(False, False) - dialog.configure(background="#0c0908") - dialog.transient(root) - apply_window_icon(dialog) - settings_chrome = create_window_chrome( - dialog, - title="PRISTONTALE EU", - subtitle="LAUNCHER SETTINGS", + settings_window = open_settings_dialog( + root, + paths=paths, + launcher_ui_scale=launcher_ui_scale, window_icon=window_icon, - show_close=True, - show_minimize=False, - allow_close=True, + apply_window_icon=apply_window_icon, + on_saved=apply_saved_settings, + existing=settings_window, ) - settings_header = tk.Frame( - settings_chrome.content, - background="#24170f", - ) - settings_header.pack(fill="x") - tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y") - header_copy = tk.Frame(settings_header, background="#24170f") - header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) - tk.Label( - header_copy, - text="PRISTONTALE EU", - background="#24170f", - foreground="#e5b66c", - font=("Sans", 9, "bold"), - ).pack(anchor="w") - tk.Label( - header_copy, - text="LAUNCHER SETTINGS", - background="#24170f", - foreground="#fff0d2", - font=("Georgia", 18, "bold"), - ).pack(anchor="w", pady=(1, 0)) - tk.Label( - header_copy, - text=( - "Tune launcher accessibility, downloads, the game window, and runtime " - "preferences." - ), - background="#24170f", - foreground="#c9baaa", - font=("Sans", 9), - ).pack(anchor="w", pady=(2, 0)) - - content = ttk.Frame( - settings_chrome.content, - style="Root.TFrame", - padding=(16, 14, 16, 16), - ) - content.pack(fill="both", expand=True) - content.columnconfigure(0, weight=1, uniform="settings-card") - content.columnconfigure(1, weight=1, uniform="settings-card") - - borderless_value = tk.BooleanVar(value=current_settings.borderless) - ui_scale_value = tk.StringVar( - value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) - ) - suppress_unresponsive_value = tk.BooleanVar( - value=current_settings.suppress_unresponsive_warnings - ) - runtime_value = tk.StringVar( - value=setting_label(current_settings.runtime, RUNTIME_LABELS) - ) - proton_sync_value = tk.StringVar( - value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS) - ) - proton_path_value = tk.StringVar(value=current_settings.proton_path or "") - game_source_value = tk.StringVar(value=current_settings.game_source or "") - download_speed_value = tk.StringVar( - value=download_speed_setting_label( - current_settings.download_speed_limit_mbps - ) - ) - custom_download_speed_value = tk.StringVar( - value=( - f"{current_settings.download_speed_limit_mbps:g}" - if current_settings.download_speed_limit_mbps is not None - else "5" - ) - ) - low_bandwidth_value = tk.BooleanVar( - value=current_settings.low_bandwidth_mode - ) - - def add_choice( - parent: ttk.LabelFrame, - row: int, - label: str, - description: str, - variable: tk.StringVar, - choices: tuple[str, ...], - ) -> None: - ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( - row=row, - column=0, - sticky="w", - ) - ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( - row=row, - column=1, - sticky="e", - ) - ttk.Combobox( - parent, - textvariable=variable, - values=choices, - state="readonly", - style="Settings.TCombobox", - ).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) - - def add_path( - parent: ttk.LabelFrame, - row: int, - label: str, - description: str, - variable: tk.StringVar, - browse: Callable[[], None], - ) -> None: - ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( - row=row, - column=0, - sticky="w", - ) - ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( - row=row, - column=1, - sticky="e", - ) - ttk.Entry(parent, textvariable=variable, style="Settings.TEntry").grid( - row=row + 1, - column=0, - columnspan=1, - sticky="ew", - pady=(3, 8), - ) - ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid( - row=row + 1, - column=1, - sticky="e", - padx=(8, 0), - pady=(3, 8), - ) - - def choose_proton_path() -> None: - selected = filedialog.askopenfilename( - parent=dialog, - title="Choose the Proton script", - ) - if selected: - proton_path_value.set(selected) - - def choose_game_source() -> None: - selected = filedialog.askdirectory( - parent=dialog, - title="Choose an existing PristonTale EU game directory", - mustexist=True, - ) - if selected: - game_source_value.set(selected) - - def close_settings() -> None: - nonlocal settings_window - if settings_window is not None and settings_window.winfo_exists(): - settings_window.destroy() - settings_window = None - - settings_chrome.set_close_command(close_settings) - - def save_current_settings() -> None: - try: - download_speed_limit = download_speed_setting_value( - download_speed_value.get(), - custom_download_speed_value.get(), - ) - updated_settings = update_settings( - paths, - borderless=borderless_value.get(), - ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), - suppress_unresponsive_warnings=suppress_unresponsive_value.get(), - runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), - proton_sync=setting_value( - proton_sync_value.get(), - PROTON_SYNC_LABELS, - ), - proton_path=proton_path_value.get().strip() or None, - game_source=game_source_value.get().strip() or None, - download_speed_limit_mbps=download_speed_limit, - low_bandwidth_mode=low_bandwidth_value.get(), - ) - except (OSError, ValueError) as error: - show_themed_dialog( - dialog, - title="Settings", - message=str(error), - kind="error", - window_icon=window_icon, - ) - return - saved_limit = ( - f"{updated_settings.download_speed_limit_mbps:g} MB/s" - if updated_settings.download_speed_limit_mbps is not None - else "unlimited" - ) - append_log( - f"Saved launcher settings to {paths.settings_file}: " - f"runtime={updated_settings.runtime}, " - f"proton_sync={updated_settings.proton_sync}, " - f"download_limit={saved_limit}, " - f"low_bandwidth={updated_settings.low_bandwidth_mode}" - ) - status.set("Settings saved.") - progress_text.set( - "Game and download settings apply to the next operation; launcher scale " - "and cached-content mode apply after restart." - ) - if show_dashboard_debug: - refresh_dashboard_debug_disk_state() - update_dashboard_debug_view(force=True) - close_settings() - - display_card = ttk.LabelFrame( - content, - text="GAME WINDOW", - style="SettingsCard.TLabelframe", - padding=14, - ) - display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) - display_card.columnconfigure(0, weight=1) - display_card.columnconfigure(1, weight=1) - ttk.Label( - display_card, - text=( - "The launcher keeps the game window's title bar by default. Enable " - "Borderless only when you want to remove its host decorations." - ), - style="SettingsHint.TLabel", - wraplength=360, - ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) - presentation = ttk.Frame(display_card, style="SettingsCard.TFrame") - presentation.grid(row=1, column=0, columnspan=2, sticky="w") - ttk.Checkbutton( - presentation, - text="Remove title bar and borders (Borderless)", - variable=borderless_value, - style="Settings.TCheckbutton", - ).pack(anchor="w") - ttk.Checkbutton( - presentation, - text="Suppress false 'not responding' warnings", - variable=suppress_unresponsive_value, - style="Settings.TCheckbutton", - ).pack(anchor="w", pady=(7, 0)) - add_choice( - display_card, - 2, - "Launcher size", - "Applies after launcher restart", - ui_scale_value, - tuple(UI_SCALE_LABELS), - ) - - rendering_card = ttk.LabelFrame( - content, - text="RUNTIME", - style="SettingsCard.TLabelframe", - padding=14, - ) - rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) - rendering_card.columnconfigure(0, weight=1) - rendering_card.columnconfigure(1, weight=1) - ttk.Label( - rendering_card, - text="Choose which compatibility runtime launches the proven game setup.", - style="SettingsHint.TLabel", - wraplength=360, - ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) - add_choice( - rendering_card, - 1, - "Runtime", - "Compatibility layer", - runtime_value, - tuple(RUNTIME_LABELS), - ) - add_choice( - rendering_card, - 3, - "Proton synchronization", - "Automatic selects the compatible mode", - proton_sync_value, - tuple(PROTON_SYNC_LABELS), - ) - - downloads_card = ttk.LabelFrame( - content, - text="DOWNLOADS", - style="SettingsCard.TLabelframe", - padding=14, - ) - downloads_card.grid( - row=1, - column=0, - columnspan=2, - sticky="ew", - pady=(12, 0), - ) - downloads_card.columnconfigure(0, weight=1) - downloads_card.columnconfigure(1, weight=1) - downloads_card.columnconfigure(2, weight=1) - ttk.Label( - downloads_card, - text=( - "Concurrency adapts to CDN latency, retries, transfer and disk speed, " - "CPU load, and the remaining files." - ), - style="SettingsHint.TLabel", - ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9)) - ttk.Checkbutton( - downloads_card, - text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)", - variable=low_bandwidth_value, - style="Settings.TCheckbutton", - ).grid(row=1, column=0, sticky="w", padx=(0, 16)) - ttk.Label( - downloads_card, - text="Speed limit", - style="SettingsText.TLabel", - ).grid(row=1, column=1, sticky="w") - ttk.Label( - downloads_card, - text="Custom MB/s", - style="SettingsText.TLabel", - ).grid(row=1, column=2, sticky="w", padx=(8, 0)) - download_speed_selector = ttk.Combobox( - downloads_card, - textvariable=download_speed_value, - values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + ( - DOWNLOAD_SPEED_CUSTOM_LABEL, - ), - state="readonly", - style="Settings.TCombobox", - ) - download_speed_selector.grid( - row=2, - column=1, - sticky="ew", - pady=(3, 0), - ) - custom_download_speed = ttk.Entry( - downloads_card, - textvariable=custom_download_speed_value, - style="Settings.TEntry", - width=12, - ) - custom_download_speed.grid( - row=2, - column=2, - sticky="ew", - padx=(8, 0), - pady=(3, 0), - ) - - def update_custom_download_state(_event: object | None = None) -> None: - custom_download_speed.configure( - state=( - "normal" - if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL - else "disabled" - ) - ) - - download_speed_selector.bind( - "<>", - update_custom_download_state, - ) - update_custom_download_state() - - overrides_card = ttk.LabelFrame( - content, - text="OPTIONAL OVERRIDES", - style="SettingsCard.TLabelframe", - padding=14, - ) - overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)) - overrides_card.columnconfigure(0, weight=1) - ttk.Label( - overrides_card, - text="Leave these blank for automatic runtime and managed-game detection.", - style="SettingsHint.TLabel", - ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) - add_path( - overrides_card, - 1, - "Proton script", - "Use a specific Proton executable", - proton_path_value, - choose_proton_path, - ) - add_path( - overrides_card, - 3, - "Import source", - "Seed a new managed installation only", - game_source_value, - choose_game_source, - ) - - footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) - footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) - footer.columnconfigure(0, weight=1) - ttk.Label( - footer, - text="Only the settings shown here affect the next launcher operation.", - style="SettingsFooter.TLabel", - ).grid(row=0, column=0, sticky="w") - buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame") - buttons_row.grid(row=0, column=1, sticky="e") - ttk.Button( - buttons_row, - text="CANCEL", - style="Secondary.TButton", - command=close_settings, - ).pack(side="left") - ttk.Button( - buttons_row, - text="SAVE SETTINGS", - style="Play.TButton", - command=save_current_settings, - ).pack(side="left", padx=(8, 0)) - - dialog.protocol("WM_DELETE_WINDOW", close_settings) - dialog.bind("", lambda _event: close_settings()) - dialog.bind("", lambda _event: save_current_settings()) - dialog.update_idletasks() - settings_width, settings_height = fitted_window_size( - 842, - 880, - scale=launcher_ui_scale, - screen_width=dialog.winfo_screenwidth(), - screen_height=dialog.winfo_screenheight(), - ) - dialog.geometry( - centered_window_geometry( - settings_width, - settings_height, - parent_x=root.winfo_rootx(), - parent_y=root.winfo_rooty(), - parent_width=root.winfo_width(), - parent_height=root.winfo_height(), - ) - ) - dialog.deiconify() - dialog.lift() - dialog.grab_set() - dialog.focus_set() - settings_button.configure(command=open_settings) def choose_import_source() -> None: @@ -5301,56 +3047,3 @@ def run_dashboard(paths: AppPaths) -> None: root.destroy() except tk.TclError: pass - - -def _as_int(value: object) -> int: - return value if isinstance(value, int) and value >= 0 else 0 - - -def _as_float(value: object) -> float | None: - if isinstance(value, bool) or not isinstance(value, (int, float)): - return None - number = float(value) - return number if math.isfinite(number) and number >= 0 else None - - -def dashboard_poll_interval( - *, - operation_active: bool, - game_window_active: bool, - pending_events: bool, -) -> int: - """Choose a responsive poll rate while avoiding busy idle/game polling.""" - - if pending_events: - return POLL_INTERVAL_MILLISECONDS - if operation_active and not game_window_active: - return ACTIVE_POLL_INTERVAL_MILLISECONDS - return IDLE_POLL_INTERVAL_MILLISECONDS - - -def _format_bytes(value: int) -> str: - units = ("B", "KiB", "MiB", "GiB") - amount = float(value) - for unit in units: - if amount < 1024 or unit == units[-1]: - return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}" - amount /= 1024 - return f"{value} B" - - -def format_transfer_rate(bytes_per_second: float) -> str: - return f"{_format_bytes(max(0, round(bytes_per_second)))}/s" - - -def format_eta(seconds: float | None) -> str: - if seconds is None or not math.isfinite(seconds) or seconds < 0: - return "calculating…" - total_seconds = max(0, math.ceil(seconds)) - hours, remainder = divmod(total_seconds, 60 * 60) - minutes, seconds = divmod(remainder, 60) - if hours: - return f"{hours}h {minutes:02d}m" - if minutes: - return f"{minutes}m {seconds:02d}s" - return f"{seconds}s" diff --git a/ptlinux/ui/dialogs/__init__.py b/ptlinux/ui/dialogs/__init__.py new file mode 100644 index 0000000..af5ba0a --- /dev/null +++ b/ptlinux/ui/dialogs/__init__.py @@ -0,0 +1 @@ +"""Modal and auxiliary windows owned by the native dashboard.""" diff --git a/ptlinux/ui/dialogs/activity.py b/ptlinux/ui/dialogs/activity.py new file mode 100644 index 0000000..3d7dd58 --- /dev/null +++ b/ptlinux/ui/dialogs/activity.py @@ -0,0 +1,50 @@ +"""Activity-log auxiliary window.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from ..viewmodels.dashboard import fitted_window_size + + +@dataclass(frozen=True) +class ActivityWindow: + window: Any + log: Any + + +def build_activity_window( + root: Any, + *, + tk_module: Any, + ui_scale: float, + apply_window_icon: Callable[[Any], None], +) -> ActivityWindow: + window = tk_module.Toplevel(root) + window.title("PristonTale EU — Activity") + width, height = fitted_window_size( + 800, + 400, + scale=ui_scale, + screen_width=window.winfo_screenwidth(), + screen_height=window.winfo_screenheight(), + ) + window.geometry(f"{width}x{height}") + window.resizable(False, False) + window.configure(background="#0c0908") + apply_window_icon(window) + log = tk_module.Text( + window, + background="#100d0c", + foreground="#d8cbbd", + insertbackground="#f8fafc", + relief="flat", + font=("Monospace", 9), + padx=10, + pady=8, + state="disabled", + ) + log.pack(fill="both", expand=True, padx=10, pady=10) + window.withdraw() + return ActivityWindow(window=window, log=log) diff --git a/ptlinux/ui/dialogs/settings_dialog.py b/ptlinux/ui/dialogs/settings_dialog.py new file mode 100644 index 0000000..8d9f306 --- /dev/null +++ b/ptlinux/ui/dialogs/settings_dialog.py @@ -0,0 +1,514 @@ +"""Launcher settings dialog, isolated from dashboard orchestration.""" + +from __future__ import annotations + +from typing import Any, Callable + +from ...paths import AppPaths +from ...settings import Settings, ensure_settings, update_settings +from ...tk_chrome import ( + centered_window_geometry, + create_window_chrome, + show_themed_dialog, +) +from ..viewmodels.dashboard import ( + UI_SCALE_LABELS, + fitted_window_size, + setting_label, + setting_value, +) +from ..viewmodels.downloader import ( + DOWNLOAD_SPEED_CUSTOM_LABEL, + DOWNLOAD_SPEED_PRESETS_MBPS, + download_speed_setting_label, + download_speed_setting_value, +) + + +RUNTIME_LABELS: dict[str, str] = { + "Automatic (recommended)": "auto", + "Proton 7 — Steam Runtime container": "proton7-container", + "Proton 7 — Direct": "proton7", + "UMU Launcher": "umu", +} +PROTON_SYNC_LABELS: dict[str, str] = { + "Automatic (recommended)": "auto", + "Fsync": "fsync", + "Esync": "esync", +} + + +def open_settings_dialog( + root: Any, + *, + paths: AppPaths, + launcher_ui_scale: float, + window_icon: Any | None, + apply_window_icon: Callable[[Any], None], + on_saved: Callable[[Settings], None], + existing: Any = None, +) -> Any | None: + """Create, or focus, the single settings window owned by a dashboard.""" + + import tkinter as tk + from tkinter import filedialog, ttk + + active_window: Any = existing + if active_window is not None and active_window.winfo_exists(): + active_window.deiconify() + active_window.lift() + active_window.focus_set() + return active_window + + try: + current_settings = ensure_settings(paths) + except (OSError, ValueError) as settings_error: + show_themed_dialog( + root, + title="Settings", + message=f"The launcher could not load its settings:\n\n{settings_error}", + kind="error", + window_icon=window_icon, + ) + return None + dialog = tk.Toplevel(root) + dialog.withdraw() + dialog.title("PristonTale EU — Settings") + dialog.resizable(False, False) + dialog.configure(background="#0c0908") + dialog.transient(root) + apply_window_icon(dialog) + settings_chrome = create_window_chrome( + dialog, + title="PRISTONTALE EU", + subtitle="LAUNCHER SETTINGS", + window_icon=window_icon, + show_close=True, + show_minimize=False, + allow_close=True, + ) + + settings_header = tk.Frame( + settings_chrome.content, + background="#24170f", + ) + settings_header.pack(fill="x") + tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y") + header_copy = tk.Frame(settings_header, background="#24170f") + header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) + tk.Label( + header_copy, + text="PRISTONTALE EU", + background="#24170f", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + ).pack(anchor="w") + tk.Label( + header_copy, + text="LAUNCHER SETTINGS", + background="#24170f", + foreground="#fff0d2", + font=("Georgia", 18, "bold"), + ).pack(anchor="w", pady=(1, 0)) + tk.Label( + header_copy, + text=( + "Tune launcher accessibility, downloads, the game window, and runtime " + "preferences." + ), + background="#24170f", + foreground="#c9baaa", + font=("Sans", 9), + ).pack(anchor="w", pady=(2, 0)) + + content = ttk.Frame( + settings_chrome.content, + style="Root.TFrame", + padding=(16, 14, 16, 16), + ) + content.pack(fill="both", expand=True) + content.columnconfigure(0, weight=1, uniform="settings-card") + content.columnconfigure(1, weight=1, uniform="settings-card") + + borderless_value = tk.BooleanVar(value=current_settings.borderless) + ui_scale_value = tk.StringVar( + value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) + ) + suppress_unresponsive_value = tk.BooleanVar( + value=current_settings.suppress_unresponsive_warnings + ) + runtime_value = tk.StringVar( + value=setting_label(current_settings.runtime, RUNTIME_LABELS) + ) + proton_sync_value = tk.StringVar( + value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS) + ) + proton_path_value = tk.StringVar(value=current_settings.proton_path or "") + game_source_value = tk.StringVar(value=current_settings.game_source or "") + download_speed_value = tk.StringVar( + value=download_speed_setting_label( + current_settings.download_speed_limit_mbps + ) + ) + custom_download_speed_value = tk.StringVar( + value=( + f"{current_settings.download_speed_limit_mbps:g}" + if current_settings.download_speed_limit_mbps is not None + else "5" + ) + ) + low_bandwidth_value = tk.BooleanVar( + value=current_settings.low_bandwidth_mode + ) + + def add_choice( + parent: ttk.LabelFrame, + row: int, + label: str, + description: str, + variable: tk.StringVar, + choices: tuple[str, ...], + ) -> None: + ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( + row=row, + column=0, + sticky="w", + ) + ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( + row=row, + column=1, + sticky="e", + ) + ttk.Combobox( + parent, + textvariable=variable, + values=choices, + state="readonly", + style="Settings.TCombobox", + ).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) + + def add_path( + parent: ttk.LabelFrame, + row: int, + label: str, + description: str, + variable: tk.StringVar, + browse: Callable[[], None], + ) -> None: + ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( + row=row, + column=0, + sticky="w", + ) + ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( + row=row, + column=1, + sticky="e", + ) + ttk.Entry(parent, textvariable=variable, style="Settings.TEntry").grid( + row=row + 1, + column=0, + columnspan=1, + sticky="ew", + pady=(3, 8), + ) + ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid( + row=row + 1, + column=1, + sticky="e", + padx=(8, 0), + pady=(3, 8), + ) + + def choose_proton_path() -> None: + selected = filedialog.askopenfilename( + parent=dialog, + title="Choose the Proton script", + ) + if selected: + proton_path_value.set(selected) + + def choose_game_source() -> None: + selected = filedialog.askdirectory( + parent=dialog, + title="Choose an existing PristonTale EU game directory", + mustexist=True, + ) + if selected: + game_source_value.set(selected) + + def close_settings() -> None: + if dialog.winfo_exists(): + dialog.destroy() + + settings_chrome.set_close_command(close_settings) + + def save_current_settings() -> None: + try: + download_speed_limit = download_speed_setting_value( + download_speed_value.get(), + custom_download_speed_value.get(), + ) + updated_settings = update_settings( + paths, + borderless=borderless_value.get(), + ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), + suppress_unresponsive_warnings=suppress_unresponsive_value.get(), + runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), + proton_sync=setting_value( + proton_sync_value.get(), + PROTON_SYNC_LABELS, + ), + proton_path=proton_path_value.get().strip() or None, + game_source=game_source_value.get().strip() or None, + download_speed_limit_mbps=download_speed_limit, + low_bandwidth_mode=low_bandwidth_value.get(), + ) + except (OSError, ValueError) as save_error: + show_themed_dialog( + dialog, + title="Settings", + message=str(save_error), + kind="error", + window_icon=window_icon, + ) + return + on_saved(updated_settings) + close_settings() + + display_card = ttk.LabelFrame( + content, + text="GAME WINDOW", + style="SettingsCard.TLabelframe", + padding=14, + ) + display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + display_card.columnconfigure(0, weight=1) + display_card.columnconfigure(1, weight=1) + ttk.Label( + display_card, + text=( + "The launcher keeps the game window's title bar by default. Enable " + "Borderless only when you want to remove its host decorations." + ), + style="SettingsHint.TLabel", + wraplength=360, + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + presentation = ttk.Frame(display_card, style="SettingsCard.TFrame") + presentation.grid(row=1, column=0, columnspan=2, sticky="w") + ttk.Checkbutton( + presentation, + text="Remove title bar and borders (Borderless)", + variable=borderless_value, + style="Settings.TCheckbutton", + ).pack(anchor="w") + ttk.Checkbutton( + presentation, + text="Suppress false 'not responding' warnings", + variable=suppress_unresponsive_value, + style="Settings.TCheckbutton", + ).pack(anchor="w", pady=(7, 0)) + add_choice( + display_card, + 2, + "Launcher size", + "Applies after launcher restart", + ui_scale_value, + tuple(UI_SCALE_LABELS), + ) + + rendering_card = ttk.LabelFrame( + content, + text="RUNTIME", + style="SettingsCard.TLabelframe", + padding=14, + ) + rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) + rendering_card.columnconfigure(0, weight=1) + rendering_card.columnconfigure(1, weight=1) + ttk.Label( + rendering_card, + text="Choose which compatibility runtime launches the proven game setup.", + style="SettingsHint.TLabel", + wraplength=360, + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + add_choice( + rendering_card, + 1, + "Runtime", + "Compatibility layer", + runtime_value, + tuple(RUNTIME_LABELS), + ) + add_choice( + rendering_card, + 3, + "Proton synchronization", + "Automatic selects the compatible mode", + proton_sync_value, + tuple(PROTON_SYNC_LABELS), + ) + + downloads_card = ttk.LabelFrame( + content, + text="DOWNLOADS", + style="SettingsCard.TLabelframe", + padding=14, + ) + downloads_card.grid( + row=1, + column=0, + columnspan=2, + sticky="ew", + pady=(12, 0), + ) + downloads_card.columnconfigure(0, weight=1) + downloads_card.columnconfigure(1, weight=1) + downloads_card.columnconfigure(2, weight=1) + ttk.Label( + downloads_card, + text=( + "Concurrency adapts to CDN latency, retries, transfer and disk speed, " + "CPU load, and the remaining files." + ), + style="SettingsHint.TLabel", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9)) + ttk.Checkbutton( + downloads_card, + text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)", + variable=low_bandwidth_value, + style="Settings.TCheckbutton", + ).grid(row=1, column=0, sticky="w", padx=(0, 16)) + ttk.Label( + downloads_card, + text="Speed limit", + style="SettingsText.TLabel", + ).grid(row=1, column=1, sticky="w") + ttk.Label( + downloads_card, + text="Custom MB/s", + style="SettingsText.TLabel", + ).grid(row=1, column=2, sticky="w", padx=(8, 0)) + download_speed_selector = ttk.Combobox( + downloads_card, + textvariable=download_speed_value, + values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + ( + DOWNLOAD_SPEED_CUSTOM_LABEL, + ), + state="readonly", + style="Settings.TCombobox", + ) + download_speed_selector.grid( + row=2, + column=1, + sticky="ew", + pady=(3, 0), + ) + custom_download_speed = ttk.Entry( + downloads_card, + textvariable=custom_download_speed_value, + style="Settings.TEntry", + width=12, + ) + custom_download_speed.grid( + row=2, + column=2, + sticky="ew", + padx=(8, 0), + pady=(3, 0), + ) + + def update_custom_download_state(_event: object | None = None) -> None: + custom_download_speed.configure( + state=( + "normal" + if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL + else "disabled" + ) + ) + + download_speed_selector.bind( + "<>", + update_custom_download_state, + ) + update_custom_download_state() + + overrides_card = ttk.LabelFrame( + content, + text="OPTIONAL OVERRIDES", + style="SettingsCard.TLabelframe", + padding=14, + ) + overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + overrides_card.columnconfigure(0, weight=1) + ttk.Label( + overrides_card, + text="Leave these blank for automatic runtime and managed-game detection.", + style="SettingsHint.TLabel", + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + add_path( + overrides_card, + 1, + "Proton script", + "Use a specific Proton executable", + proton_path_value, + choose_proton_path, + ) + add_path( + overrides_card, + 3, + "Import source", + "Seed a new managed installation only", + game_source_value, + choose_game_source, + ) + + footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) + footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label( + footer, + text="Only the settings shown here affect the next launcher operation.", + style="SettingsFooter.TLabel", + ).grid(row=0, column=0, sticky="w") + buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame") + buttons_row.grid(row=0, column=1, sticky="e") + ttk.Button( + buttons_row, + text="CANCEL", + style="Secondary.TButton", + command=close_settings, + ).pack(side="left") + ttk.Button( + buttons_row, + text="SAVE SETTINGS", + style="Play.TButton", + command=save_current_settings, + ).pack(side="left", padx=(8, 0)) + + dialog.protocol("WM_DELETE_WINDOW", close_settings) + dialog.bind("", lambda _event: close_settings()) + dialog.bind("", lambda _event: save_current_settings()) + dialog.update_idletasks() + settings_width, settings_height = fitted_window_size( + 842, + 880, + scale=launcher_ui_scale, + screen_width=dialog.winfo_screenwidth(), + screen_height=dialog.winfo_screenheight(), + ) + dialog.geometry( + centered_window_geometry( + settings_width, + settings_height, + parent_x=root.winfo_rootx(), + parent_y=root.winfo_rooty(), + parent_width=root.winfo_width(), + parent_height=root.winfo_height(), + ) + ) + dialog.deiconify() + dialog.lift() + dialog.grab_set() + dialog.focus_set() + return dialog diff --git a/ptlinux/ui/viewmodels/__init__.py b/ptlinux/ui/viewmodels/__init__.py new file mode 100644 index 0000000..6e3890c --- /dev/null +++ b/ptlinux/ui/viewmodels/__init__.py @@ -0,0 +1 @@ +"""Pure state and presentation models used by the native dashboard.""" diff --git a/ptlinux/ui/viewmodels/dashboard.py b/ptlinux/ui/viewmodels/dashboard.py new file mode 100644 index 0000000..f02f749 --- /dev/null +++ b/ptlinux/ui/viewmodels/dashboard.py @@ -0,0 +1,480 @@ +"""Dashboard state and presentation helpers independent of Tk widget layout.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +import os +import signal +import subprocess +import sys +import threading +import time +from typing import TYPE_CHECKING, Callable, Mapping + +from ...paths import AppPaths +from ...self_update import LauncherUpdateCheck + + +if TYPE_CHECKING: + from tkinter import Frame + + +WINDOW_WIDTH = 1002 +WINDOW_HEIGHT = 784 +BASE_DISPLAY_DPI = 96.0 +BASE_TK_SCALING = BASE_DISPLAY_DPI / 72.0 +UI_SCALE_FACTORS: dict[str, float] = { + "100": 1.0, + "125": 1.25, + "150": 1.5, + "175": 1.75, + "200": 2.0, +} +UI_SCALE_LABELS: dict[str, str] = { + "Automatic (system DPI)": "auto", + "100% — Standard": "100", + "125% — Large": "125", + "150% — Larger": "150", + "175% — Extra large": "175", + "200% — Maximum": "200", +} +NEWS_THREE_COLUMN_WIDTH = 900 +NEWS_TWO_COLUMN_WIDTH = 620 +STACKED_CONTROLS_WIDTH = 860 +POLL_INTERVAL_MILLISECONDS = 33 +ACTIVE_POLL_INTERVAL_MILLISECONDS = 75 +IDLE_POLL_INTERVAL_MILLISECONDS = 500 +DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300 +DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 +DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 +DASHBOARD_TRANSITION_STEPS = 9 +DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 +STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS = 16 +STARTUP_PROGRESS_ANIMATION_EASING = 0.28 +STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP = 0.08 +PRELOADER_STATUS_CHARACTERS = 64 +DASHBOARD_STARTUP_COMPONENTS = frozenset( + { + "broadcast", + "cached_content", + "compatibility", + "launcher_update", + "logo", + "news", + "news_artwork", + "patches", + "server_status", + "version", + } +) +DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"}) + + +@dataclass(frozen=True) +class LauncherUpdatePresentation: + """Visual treatment for one authenticated launcher-update result.""" + + button_text: str + button_style: str + badge_text: str | None + + +@dataclass(frozen=True) +class GameClientDebugState: + """Live process state exposed only in the hidden dashboard debug view.""" + + client_id: int + phase: str + supervisor_pid: int | None + elapsed_seconds: float + + +class DashboardError(RuntimeError): + """Raised when the native dashboard cannot be displayed.""" + + +def resolve_ui_scale(preference: str, pixels_per_inch: float) -> float: + """Resolve a saved launcher scale while respecting the detected display DPI.""" + + if preference == "auto": + if not math.isfinite(pixels_per_inch) or pixels_per_inch <= 0: + return 1.0 + return min(2.0, max(1.0, pixels_per_inch / BASE_DISPLAY_DPI)) + try: + return UI_SCALE_FACTORS[preference] + except KeyError as error: + raise ValueError(f"unsupported launcher UI scale: {preference}") from error + + +def fitted_window_size( + width: int, + height: int, + *, + scale: float, + screen_width: int, + screen_height: int, + margin: int = 64, +) -> tuple[int, int]: + """Scale an initial window without placing it beyond the available display.""" + + if width <= 0 or height <= 0 or scale <= 0: + raise ValueError("window dimensions and scale must be positive") + if screen_width <= 0 or screen_height <= 0 or margin < 0: + raise ValueError("screen dimensions must be positive and margin non-negative") + available_width = max(1, screen_width - margin) + available_height = max(1, screen_height - margin) + return ( + min(max(1, round(width * scale)), available_width), + min(max(1, round(height * scale)), available_height), + ) + + +def news_card_columns(width: int, scale: float) -> int: + """Choose a readable card grid for the current physical dashboard width.""" + + if width <= 0 or scale <= 0: + return 1 + logical_width = width / scale + if logical_width >= NEWS_THREE_COLUMN_WIDTH: + return 3 + if logical_width >= NEWS_TWO_COLUMN_WIDTH: + return 2 + return 1 + + +def dashboard_controls_stacked(width: int, scale: float) -> bool: + return width <= 0 or scale <= 0 or width / scale < STACKED_CONTROLS_WIDTH + + +def setting_label(value: str, choices: Mapping[str, str]) -> str: + for label, choice in choices.items(): + if choice == value: + return label + raise ValueError(f"unsupported saved setting value: {value}") + + +def setting_value(label: str, choices: Mapping[str, str]) -> str: + try: + return choices[label] + except KeyError as error: + raise ValueError(f"unsupported setting choice: {label}") from error + + +class GameLaunchSupervisorRegistry: + """Bridge Tk cancellation requests to game launch worker processes safely.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._processes: dict[int, subprocess.Popen[str]] = {} + self._cancel_requested: set[int] = set() + + def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool: + """Register a supervisor and report whether cancellation raced its start.""" + + with self._lock: + self._processes[client_id] = process + return client_id in self._cancel_requested + + def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None: + """Record cancellation and return an already-started supervisor, if any.""" + + with self._lock: + self._cancel_requested.add(client_id) + return self._processes.get(client_id) + + def detach(self, client_id: int, process: subprocess.Popen[str]) -> None: + with self._lock: + if self._processes.get(client_id) is process: + self._processes.pop(client_id, None) + + def is_cancel_requested(self, client_id: int) -> bool: + with self._lock: + return client_id in self._cancel_requested + + def finish(self, client_id: int) -> bool: + """Forget one launch and return whether the user cancelled it.""" + + with self._lock: + self._processes.pop(client_id, None) + if client_id not in self._cancel_requested: + return False + self._cancel_requested.remove(client_id) + return True + + +def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool: + """Ask the play CLI to stop its complete Proton/game process group.""" + + if process.poll() is not None: + return False + try: + process.send_signal(signal.SIGINT) + except OSError: + return False + return True + + +class GameClientTracker: + """Track dashboard-launched clients without retaining process objects in Tk.""" + + def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None: + self._clock = clock + self._next_client_id = 1 + self._starting: set[int] = set() + self._running: set[int] = set() + self._started_at: dict[int, float] = {} + self._running_at: dict[int, float] = {} + self._supervisor_pids: dict[int, int] = {} + + def begin(self) -> int: + client_id = self._next_client_id + self._next_client_id += 1 + self._starting.add(client_id) + self._started_at[client_id] = self._clock() + return client_id + + def attach_supervisor(self, client_id: int, pid: int) -> bool: + if pid <= 0 or ( + client_id not in self._starting and client_id not in self._running + ): + return False + self._supervisor_pids[client_id] = pid + return True + + def mark_running(self, client_id: int) -> bool: + if client_id in self._running or client_id not in self._starting: + return False + self._starting.remove(client_id) + self._running.add(client_id) + self._running_at[client_id] = self._clock() + return True + + def finish(self, client_id: int) -> str | None: + if client_id in self._starting: + self._starting.remove(client_id) + phase = "starting" + elif client_id in self._running: + self._running.remove(client_id) + phase = "running" + else: + return None + self._started_at.pop(client_id, None) + self._running_at.pop(client_id, None) + self._supervisor_pids.pop(client_id, None) + return phase + + def debug_states( + self, + *, + timestamp: float | None = None, + ) -> tuple[GameClientDebugState, ...]: + now = self._clock() if timestamp is None else timestamp + return tuple( + GameClientDebugState( + client_id=client_id, + phase="running" if client_id in self._running else "starting", + supervisor_pid=self._supervisor_pids.get(client_id), + elapsed_seconds=max( + 0.0, + now + - ( + self._running_at.get(client_id, now) + if client_id in self._running + else self._started_at.get(client_id, now) + ), + ), + ) + for client_id in sorted(self._starting | self._running) + ) + + @property + def starting_count(self) -> int: + return len(self._starting) + + @property + def running_count(self) -> int: + return len(self._running) + + @property + def active_count(self) -> int: + return self.starting_count + self.running_count + + +def game_client_activity_text(running_count: int) -> str: + if running_count < 0: + raise ValueError("running client count must not be negative") + if running_count == 0: + return "" + label = "client" if running_count == 1 else "clients" + return f"{running_count} {label} running" + + +def debug_elapsed_text(elapsed_seconds: float) -> str: + """Format a live debug duration without sub-second UI churn.""" + + seconds = max(0, int(elapsed_seconds)) + hours, remainder = divmod(seconds, 60 * 60) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"{hours}h {minutes}m {seconds}s" + if minutes: + return f"{minutes}m {seconds}s" + return f"{seconds}s" + + +def launcher_update_presentation( + check: LauncherUpdateCheck, +) -> LauncherUpdatePresentation: + """Make authenticated updates visually distinct from the normal version button.""" + + if check.available and check.supported: + return LauncherUpdatePresentation( + button_text=f"UPDATE LAUNCHER · {check.latest_version}", + button_style="LauncherUpdate.TButton", + badge_text="SIGNED UPDATE AVAILABLE", + ) + if check.available: + return LauncherUpdatePresentation( + button_text=f"MANUAL UPDATE · {check.latest_version}", + button_style="LauncherManualUpdate.TButton", + badge_text="MANUAL UPDATE REQUIRED", + ) + return LauncherUpdatePresentation( + button_text=f"LAUNCHER {check.current_version}", + button_style="Secondary.TButton", + badge_text=None, + ) + + +def dashboard_preloader_delay( + started_at: float, + *, + timestamp: float | None = None, +) -> int: + """Keep the startup preloader visible long enough to avoid a brief flash.""" + + now = time.monotonic() if timestamp is None else timestamp + elapsed_milliseconds = round(max(0.0, now - started_at) * 1000) + return max(0, DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS - elapsed_milliseconds) + + +def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool: + """Return whether cached content is ready for an interactive dashboard.""" + + return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components) + + +def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: + """Return one clamped opacity step for the loader/dashboard cross-fade.""" + + progress = min(max(step, 0), DASHBOARD_TRANSITION_STEPS) / DASHBOARD_TRANSITION_STEPS + opacity_range = 1.0 - DASHBOARD_TRANSITION_MINIMUM_OPACITY + if fading_in: + return DASHBOARD_TRANSITION_MINIMUM_OPACITY + opacity_range * progress + return 1.0 - opacity_range * progress + + +def preloader_status_text( + value: str, + step: int | None = None, + total: int | None = None, +) -> str: + """Render one launch stage without letting its widget change shape.""" + + if step is None or total is None: + return value + return f"Step {step} of {total} · {value}" + + +def preloader_logo_subsample_factor( + width: int, + height: int, + *, + max_dimension: int = 88, +) -> int: + """Return an integer Tk subsample factor for a compact preloader logo.""" + + if width <= 0 or height <= 0 or max_dimension <= 0: + raise ValueError("preloader logo dimensions must be positive") + return max( + 1, + math.ceil(width / max_dimension), + math.ceil(height / max_dimension), + ) + + +def startup_progress_animation_value(current: float, target: float) -> float: + """Ease one determinate startup-progress frame without overshooting.""" + + distance = abs(target - current) + if distance == 0: + return target + step = max( + STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP, + distance * STARTUP_PROGRESS_ANIMATION_EASING, + ) + if step >= distance: + return target + direction = 1.0 if target > current else -1.0 + return current + (direction * step) + + +def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]: + """Lock a measured preloader frame so later status text cannot reflow it.""" + + frame.update_idletasks() + width = max(1, frame.winfo_reqwidth()) + height = max(1, frame.winfo_reqheight()) + frame.configure(width=width, height=height) + frame.pack_propagate(False) + return width, height + + +def launcher_restart_spec( + paths: AppPaths, + environment: Mapping[str, str] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Build an isolated restart that imports the newly activated release.""" + + current = paths.launcher_current + if not current.is_symlink(): + raise DashboardError(f"launcher current path is not a symbolic link: {current}") + try: + release = current.resolve(strict=True) + except OSError as exc: + raise DashboardError(f"launcher current release is unavailable: {exc}") from exc + releases = paths.launcher_releases.resolve(strict=False) + if release.parent != releases or not (release / "ptlinux").is_dir(): + raise DashboardError(f"launcher current release is outside the release store: {release}") + env = dict(os.environ if environment is None else environment) + env.update( + { + "PTLINUX_INSTALL_DIR": str(paths.data), + "PTLINUX_APP_HOME": str(paths.app_home), + "PYTHONSAFEPATH": "1", + "PYTHONPATH": str(release), + } + ) + return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env + + +def launcher_child_command(*arguments: str) -> list[str]: + """Build a child command without writing bytecode into the active release.""" + + return [sys.executable, "-B", "-m", "ptlinux", *arguments] + + +def dashboard_poll_interval( + *, + operation_active: bool, + game_window_active: bool, + pending_events: bool, +) -> int: + """Choose a responsive poll rate while avoiding busy idle/game polling.""" + + if pending_events: + return POLL_INTERVAL_MILLISECONDS + if operation_active and not game_window_active: + return ACTIVE_POLL_INTERVAL_MILLISECONDS + return IDLE_POLL_INTERVAL_MILLISECONDS diff --git a/ptlinux/ui/viewmodels/downloader.py b/ptlinux/ui/viewmodels/downloader.py new file mode 100644 index 0000000..9c50d6d --- /dev/null +++ b/ptlinux/ui/viewmodels/downloader.py @@ -0,0 +1,240 @@ +"""Download and long-running-operation presentation state.""" + +from __future__ import annotations + +from collections import deque +import math +import threading +import time +from typing import Mapping + + +DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = { + "Unlimited": None, + "5 MB/s": 5.0, + "10 MB/s": 10.0, + "25 MB/s": 25.0, + "50 MB/s": 50.0, +} +DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…" +TRANSFER_RATE_WINDOW_SECONDS = 3.0 +TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 +TRANSFER_RATE_IDLE_SECONDS = 2.0 + + +def download_speed_setting_label(limit_mbps: float | None) -> str: + for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items(): + if preset == limit_mbps: + return label + return DOWNLOAD_SPEED_CUSTOM_LABEL + + +def download_speed_setting_value(label: str, custom_value: str) -> float | None: + if label in DOWNLOAD_SPEED_PRESETS_MBPS: + return DOWNLOAD_SPEED_PRESETS_MBPS[label] + if label != DOWNLOAD_SPEED_CUSTOM_LABEL: + raise ValueError(f"unsupported download speed choice: {label}") + try: + value = float(custom_value.strip()) + except ValueError as error: + raise ValueError("custom download speed must be a number in MB/s") from error + if not math.isfinite(value) or value <= 0 or value > 10_000: + raise ValueError( + "custom download speed must be greater than 0 and at most 10000 MB/s" + ) + return value + + +def download_activity_metadata( + *, + current_server: str | None, + retry_count: int, + retry_reason: str | None, + active_workers: int, + target_workers: int, +) -> str: + """Render compact download details for the existing activity heading.""" + + if retry_count < 0 or active_workers < 0 or target_workers < 0: + raise ValueError("download activity counts must not be negative") + parts: list[str] = [] + if current_server: + parts.append(f"CDN: {_ellipsize(current_server, 28)}") + if target_workers: + parts.append(f"Workers: {active_workers}/{target_workers}") + retry_text = f"Retries: {retry_count}" + if retry_reason: + retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})" + parts.append(retry_text) + return " · ".join(parts) + + +def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str: + normalized = " ".join(value.split()) + if len(normalized) <= limit: + return normalized + if keep_tail: + return f"…{normalized[-(limit - 1):]}" + return f"{normalized[:limit - 1]}…" + + +def operation_activity_text( + message: str, + *, + started_at: float | None, + step: int = 0, + total: int = 0, + detail: str | None = None, + timestamp: float | None = None, +) -> str: + """Render live operation progress without dropping its elapsed timer.""" + + if detail is not None: + base = detail + elif total > 0: + base = f"Step {step}/{total} · {message}" + else: + return message + if started_at is None: + return base + now = time.monotonic() if timestamp is None else timestamp + elapsed = int(max(0.0, now - started_at)) + return f"{base} · {elapsed}s elapsed" + + +def should_refresh_operation_elapsed( + *, + active: bool, + started_at: float | None, + stage_total: int, + detail: str | None, +) -> bool: + """Return whether the dashboard has operation text with a live timer.""" + + return ( + active + and started_at is not None + and (stage_total > 0 or detail is not None) + ) + + +class ProgressCoalescer: + """Keep only the latest cross-thread progress event for the Tk loop.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._latest: dict[str, object] | None = None + + def publish(self, event: Mapping[str, object]) -> None: + with self._lock: + self._latest = dict(event) + + def take(self) -> dict[str, object] | None: + with self._lock: + latest = self._latest + self._latest = None + return latest + + def clear(self) -> None: + with self._lock: + self._latest = None + + +class TransferRateEstimator: + """Estimate a stable byte rate from cumulative network-transfer samples.""" + + def __init__( + self, + window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS, + minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, + ) -> None: + if window_seconds <= 0: + raise ValueError("transfer-rate window must be positive") + if minimum_interval_seconds < 0: + raise ValueError("transfer-rate minimum interval must not be negative") + self._window_seconds = window_seconds + self._minimum_interval_seconds = minimum_interval_seconds + self._samples: deque[tuple[float, int]] = deque() + + def reset(self) -> None: + self._samples.clear() + + def update( + self, + transferred_bytes: int, + *, + timestamp: float | None = None, + ) -> float | None: + """Record one cumulative sample and return bytes per second when stable.""" + + if transferred_bytes < 0: + raise ValueError("transferred bytes must not be negative") + now = time.monotonic() if timestamp is None else timestamp + if self._samples: + last_time, last_bytes = self._samples[-1] + if now <= last_time or transferred_bytes < last_bytes: + self.reset() + self._samples.append((now, transferred_bytes)) + cutoff = now - self._window_seconds + while len(self._samples) > 2 and self._samples[1][0] <= cutoff: + self._samples.popleft() + if len(self._samples) < 2: + return None + first_time, first_bytes = self._samples[0] + elapsed = now - first_time + if elapsed < self._minimum_interval_seconds: + return None + return max(0.0, (transferred_bytes - first_bytes) / elapsed) + + def is_idle( + self, + threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS, + *, + timestamp: float | None = None, + ) -> bool: + """Return whether no cumulative sample arrived within the threshold.""" + + if threshold_seconds < 0: + raise ValueError("idle threshold must not be negative") + if not self._samples: + return False + now = time.monotonic() if timestamp is None else timestamp + return now - self._samples[-1][0] >= threshold_seconds + + +def as_non_negative_int(value: object) -> int: + return value if isinstance(value, int) and value >= 0 else 0 + + +def as_non_negative_float(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + number = float(value) + return number if math.isfinite(number) and number >= 0 else None + + +def format_bytes(value: int) -> str: + units = ("B", "KiB", "MiB", "GiB") + amount = float(value) + for unit in units: + if amount < 1024 or unit == units[-1]: + return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}" + amount /= 1024 + return f"{value} B" + + +def format_transfer_rate(bytes_per_second: float) -> str: + return f"{format_bytes(max(0, round(bytes_per_second)))}/s" + + +def format_eta(seconds: float | None) -> str: + if seconds is None or not math.isfinite(seconds) or seconds < 0: + return "calculating…" + total_seconds = max(0, math.ceil(seconds)) + hours, remainder = divmod(total_seconds, 60 * 60) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"{hours}h {minutes:02d}m" + if minutes: + return f"{minutes}m {seconds:02d}s" + return f"{seconds}s" diff --git a/ptlinux/ui/viewmodels/health.py b/ptlinux/ui/viewmodels/health.py new file mode 100644 index 0000000..8680284 --- /dev/null +++ b/ptlinux/ui/viewmodels/health.py @@ -0,0 +1,122 @@ +"""Official server-health presentation and request state.""" + +from __future__ import annotations + +from dataclasses import dataclass +import time +from typing import Callable + +from ...server_status import RealmServerStatus, ServerStatusError, fetch_server_status + + +SERVER_STATUS_STALE_SECONDS = 5 * 60 + + +@dataclass(frozen=True) +class ServerStatusPresentation: + """Text and Tk style for one official realm-health result.""" + + text: str + style: str + + +@dataclass(frozen=True) +class ServerStatusCheck: + """One measured attempt to retrieve official realm health.""" + + statuses: tuple[RealmServerStatus, ...] | None + checked_at: float + duration_seconds: float + error: str | None = None + + +def perform_server_status_check( + *, + fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status, + monotonic: Callable[[], float] = time.monotonic, + wall_clock: Callable[[], float] = time.time, +) -> ServerStatusCheck: + """Measure one official status request without leaking its endpoint.""" + + started_at = monotonic() + try: + statuses = fetcher() + except ServerStatusError as error: + duration_seconds = max(0.0, monotonic() - started_at) + return ServerStatusCheck( + statuses=None, + checked_at=wall_clock(), + duration_seconds=duration_seconds, + error=str(error), + ) + duration_seconds = max(0.0, monotonic() - started_at) + return ServerStatusCheck( + statuses=statuses, + checked_at=wall_clock(), + duration_seconds=duration_seconds, + ) + + +def server_status_check_text( + check: ServerStatusCheck, + *, + timestamp: float | None = None, +) -> str: + """Render fetch age, latency, and freshness for the hidden debug view.""" + + now = time.time() if timestamp is None else timestamp + age_seconds = max(0.0, now - check.checked_at) + whole_seconds = int(age_seconds) + if whole_seconds < 60: + age = f"{whole_seconds}s" + elif whole_seconds < 60 * 60: + age = f"{whole_seconds // 60}m" + elif whole_seconds < 24 * 60 * 60: + age = f"{whole_seconds // (60 * 60)}h" + else: + age = f"{whole_seconds // (24 * 60 * 60)}d" + latency_milliseconds = max(0, round(check.duration_seconds * 1000)) + timing = f"checked {age} ago · {latency_milliseconds} ms" + if check.error is not None: + return f"failed · {timing} · {check.error}" + freshness = "stale" if age_seconds >= SERVER_STATUS_STALE_SECONDS else "fresh" + return f"{freshness} · {timing}" + + +def _server_component_text(name: str, online: bool | None) -> str: + if online is True: + state = "up" + elif online is False: + state = "down" + else: + state = "unknown" + return f"{name} {state}" + + +def server_status_presentation( + status: RealmServerStatus, + *, + show_debug_details: bool = False, +) -> ServerStatusPresentation: + if status.online is True: + text = f"● {status.name}" + style = "ServerOnline.TLabel" + elif status.online is False: + text = f"● {status.name} · offline" + style = "ServerOffline.TLabel" + else: + text = f"○ {status.name} · status unknown" + style = "ServerUnknown.TLabel" + if show_debug_details: + details: list[str] = [] + if status.players_online is not None: + label = "player" if status.players_online == 1 else "players" + details.append(f"{status.players_online} {label}") + details.extend( + ( + _server_component_text("login", status.login_online), + _server_component_text("game", status.game_online), + ) + ) + text = " · ".join((text, *details)) + return ServerStatusPresentation(text=text, style=style) diff --git a/ptlinux/ui/viewmodels/installation.py b/ptlinux/ui/viewmodels/installation.py new file mode 100644 index 0000000..7b6fd31 --- /dev/null +++ b/ptlinux/ui/viewmodels/installation.py @@ -0,0 +1,46 @@ +"""Presentation state for managed-game installation operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GameSyncPresentation: + """User-facing wording for one managed-game synchronization.""" + + operation_label: str + file_progress_verb: str + + +def game_sync_presentation( + *, + installed: bool, + repair: bool = False, +) -> GameSyncPresentation: + """Distinguish a fresh download from an update or repair.""" + + if not installed: + return GameSyncPresentation( + operation_label=( + "Installing and verifying official game files…" + if repair + else "Installing official game files…" + ), + file_progress_verb="Downloading", + ) + if repair: + return GameSyncPresentation( + operation_label="Checking every game file against the official depot…", + file_progress_verb="Repairing", + ) + return GameSyncPresentation( + operation_label="Synchronizing official game files…", + file_progress_verb="Updating", + ) + + +def game_file_progress_status(current_file: str, verb: str) -> str: + """Render the current depot asset with the operation's exact intent.""" + + return f"{verb} {current_file}"