diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index cf6c26b..7aafb36 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -74,6 +74,7 @@ class LauncherUpdateCheck: reason: str manifest_url: str manifest: UpdateManifest + checked_at: str | None = None @dataclass(frozen=True) @@ -110,6 +111,26 @@ class LauncherRollbackResult: restart_required: bool +@dataclass(frozen=True) +class LauncherInstalledRelease: + """One validated release retained in the launcher's version store.""" + + version: str + installed_at: str + directory: str + active: bool + previous: bool + + +@dataclass(frozen=True) +class LauncherUpdateHistory: + """Validated launcher releases available for inspection or rollback.""" + + current_version: str | None + previous_version: str | None + releases: tuple[LauncherInstalledRelease, ...] + + def default_public_key_path() -> Path: return Path(__file__).with_name(PUBLIC_KEY_FILENAME) @@ -157,7 +178,12 @@ def check_for_launcher_update( ) _verify_manifest_signature(manifest_bytes, signature_bytes, key) manifest = parse_update_manifest(manifest_bytes) - return _evaluate_launcher_update(manifest, current_version, url) + return _evaluate_launcher_update( + manifest, + current_version, + url, + checked_at=datetime.now().astimezone().isoformat(timespec="seconds"), + ) def read_cached_launcher_update_check( @@ -198,13 +224,21 @@ def read_cached_launcher_update_check( manifest_value = value.get("manifest") if not isinstance(manifest_value, dict): return None + checked_at = _cached_timestamp(value.get("checked_at")) + if checked_at is None: + return None try: manifest = parse_update_manifest( (json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8") ) except (LauncherUpdateError, TypeError, ValueError): return None - return _evaluate_launcher_update(manifest, current_version, url) + return _evaluate_launcher_update( + manifest, + current_version, + url, + checked_at=checked_at, + ) def cache_launcher_update_check( @@ -223,7 +257,8 @@ def cache_launcher_update_check( cache_path, { "schema": UPDATE_CHECK_CACHE_SCHEMA, - "checked_at": datetime.now().astimezone().isoformat(), + "checked_at": check.checked_at + or datetime.now().astimezone().isoformat(timespec="seconds"), "current_version": check.current_version, "manifest_url": check.manifest_url, "public_key_sha256": key_sha256, @@ -239,6 +274,8 @@ def _evaluate_launcher_update( manifest: UpdateManifest, current_version: str, manifest_url: str, + *, + checked_at: str | None = None, ) -> LauncherUpdateCheck: current = _parse_version(current_version, "current launcher version") latest = _parse_version(manifest.version, "manifest version") @@ -269,6 +306,7 @@ def _evaluate_launcher_update( reason=reason, manifest_url=manifest_url, manifest=manifest, + checked_at=checked_at, ) @@ -408,12 +446,22 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult: raise LauncherUpdateError("the current launcher release link is missing") if previous is None: raise LauncherUpdateError("no previous launcher release is available") - current_version = _validate_release_directory(current) - previous_version = _validate_release_directory(previous) - if current == previous: + current_release = current + previous_release = previous + current_version = _validate_release_directory(current_release) + previous_version = _validate_release_directory(previous_release) + if current_release == previous_release: raise LauncherUpdateError("current and previous launcher releases are identical") - _replace_release_link(paths.launcher_previous, current, paths.launcher_releases) - _replace_release_link(paths.launcher_current, previous, paths.launcher_releases) + _replace_release_link( + paths.launcher_previous, + current_release, + paths.launcher_releases, + ) + _replace_release_link( + paths.launcher_current, + previous_release, + paths.launcher_releases, + ) _fsync_directory(paths.app_home) _write_json( paths.launcher_update_state, @@ -422,19 +470,64 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult: "rolled_back_at": datetime.now().astimezone().isoformat(), "from_version": current_version, "to_version": previous_version, - "release_directory": str(previous), - "previous_release": str(current), + "release_directory": str(previous_release), + "previous_release": str(current_release), }, ) return LauncherRollbackResult( from_version=current_version, to_version=previous_version, - current_release=str(previous), - previous_release=str(current), + current_release=str(previous_release), + previous_release=str(current_release), restart_required=True, ) +def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: + """Return validated retained releases without trusting directory names alone.""" + + current = _release_from_link(paths.launcher_current, paths.launcher_releases) + previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) + try: + candidates = tuple(paths.launcher_releases.iterdir()) + except FileNotFoundError: + candidates = () + except OSError as exc: + raise LauncherUpdateError(f"cannot inspect installed launcher releases: {exc}") from exc + releases: list[LauncherInstalledRelease] = [] + for candidate in candidates: + if _VERSION_PATTERN.fullmatch(candidate.name) is None: + continue + try: + version = _validate_release_directory(candidate) + installed_at = datetime.fromtimestamp( + candidate.stat().st_mtime + ).astimezone().isoformat(timespec="seconds") + except (LauncherUpdateError, OSError, ValueError, OverflowError): + continue + if version != candidate.name: + continue + resolved = candidate.resolve() + releases.append( + LauncherInstalledRelease( + version=version, + installed_at=installed_at, + directory=str(resolved), + active=resolved == current, + previous=resolved == previous, + ) + ) + releases.sort( + key=lambda release: _parse_version(release.version, "launcher release version"), + reverse=True, + ) + return LauncherUpdateHistory( + current_version=_release_version(releases, active=True), + previous_version=_release_version(releases, previous=True), + releases=tuple(releases), + ) + + def parse_update_manifest(data: bytes) -> UpdateManifest: """Strictly parse one already-authenticated manifest.""" @@ -857,9 +950,15 @@ def _activate_release(paths: AppPaths, release: Path) -> Path | None: raise LauncherUpdateError( f"launcher previous path is not a symbolic link: {paths.launcher_previous}" ) - if current is not None and current != release: - _validate_release_directory(current) - _replace_release_link(paths.launcher_previous, current, paths.launcher_releases) + if current is not None: + current_release = current + if current_release != release: + _validate_release_directory(current_release) + _replace_release_link( + paths.launcher_previous, + current_release, + paths.launcher_releases, + ) _replace_release_link(paths.launcher_current, release, paths.launcher_releases) _fsync_directory(paths.app_home) return current @@ -980,6 +1079,30 @@ def _parse_published_at(value: str) -> datetime: return parsed +def _cached_timestamp(value: object) -> str | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None or parsed.utcoffset() is None: + return None + return value + + +def _release_version( + releases: list[LauncherInstalledRelease], + *, + active: bool = False, + previous: bool = False, +) -> str | None: + for release in releases: + if (active and release.active) or (previous and release.previous): + return release.version + return None + + def _required_string(value: dict[str, object], key: str) -> str: item = value.get(key) if not isinstance(item, str) or not item: diff --git a/ptlinux/settings.py b/ptlinux/settings.py index f21f4fd..50d7053 100644 --- a/ptlinux/settings.py +++ b/ptlinux/settings.py @@ -16,21 +16,27 @@ from .paths import AppPaths SETTINGS_SCHEMA = 1 _TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated" +UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"}) +_RETIRED_DISPLAY_SETTINGS = frozenset( + { + "width", + "height", + "output_width", + "output_height", + "refresh_rate", + "fullscreen", + "scaler", + "filter", + } +) @dataclass(frozen=True) class Settings: realm: str = "Babel" - width: int = 1600 - height: int = 900 - output_width: int = 1600 - output_height: int = 900 - refresh_rate: int = 60 - fullscreen: bool = False + ui_scale: str = "auto" borderless: bool = False suppress_unresponsive_warnings: bool = True - scaler: str = "fit" - filter: str = "linear" runtime: str = "auto" proton_sync: str = "auto" proton_path: str | None = None @@ -136,20 +142,13 @@ def _read_settings_object(path: Path) -> dict[str, object] | None: def _settings_from_object(value: dict[str, object]) -> Settings: settings = Settings( realm=_string(value, "realm", "Babel").strip(), - width=_integer(value, "width", 1600), - height=_integer(value, "height", 900), - output_width=_integer(value, "output_width", 1600), - output_height=_integer(value, "output_height", 900), - refresh_rate=_integer(value, "refresh_rate", 60), - fullscreen=_boolean(value, "fullscreen", False), + ui_scale=_string(value, "ui_scale", "auto"), borderless=_migrated_borderless(value), suppress_unresponsive_warnings=_boolean( value, "suppress_unresponsive_warnings", True, ), - scaler=_string(value, "scaler", "fit"), - filter=_string(value, "filter", "linear"), runtime=_string(value, "runtime", "auto"), proton_sync=_string(value, "proton_sync", "auto"), proton_path=_optional_string(value, "proton_path"), @@ -192,6 +191,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool: value.get("schema") != SETTINGS_SCHEMA or not _SETTINGS_FIELD_NAMES.issubset(value) or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True + or bool(_RETIRED_DISPLAY_SETTINGS.intersection(value)) ) @@ -201,6 +201,8 @@ def _write_settings( existing: dict[str, object] | None, ) -> None: payload = dict(existing or {}) + for name in _RETIRED_DISPLAY_SETTINGS: + payload.pop(name, None) payload.update(asdict(settings)) payload["schema"] = SETTINGS_SCHEMA payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True @@ -242,33 +244,13 @@ def _validate(settings: Settings) -> None: or any(ord(character) < 32 for character in settings.realm) ): raise ValueError("realm must be a non-empty display name") - for name, value in ( - ("width", settings.width), - ("height", settings.height), - ("output_width", settings.output_width), - ("output_height", settings.output_height), - ): - if value < 640 or value > 16384: - raise ValueError(f"{name} must be between 640 and 16384") - if settings.refresh_rate < 24 or settings.refresh_rate > 1000: - raise ValueError("refresh_rate must be between 24 and 1000") - if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}: - raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}") - if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}: - raise ValueError(f"unsupported legacy filter setting: {settings.filter}") + if settings.ui_scale not in UI_SCALE_CHOICES: + raise ValueError(f"unsupported launcher UI scale: {settings.ui_scale}") if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}: raise ValueError(f"unsupported runtime selection: {settings.runtime}") if settings.proton_sync not in {"auto", "fsync", "esync"}: raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}") - -def _integer(value: dict[str, object], key: str, default: int) -> int: - item = value.get(key, default) - if isinstance(item, bool) or not isinstance(item, int): - raise ValueError(f"{key} must be an integer") - return item - - def _boolean(value: dict[str, object], key: str, default: bool) -> bool: item = value.get(key, default) if not isinstance(item, bool): diff --git a/ptlinux/tk_chrome.py b/ptlinux/tk_chrome.py index ff105df..c57125f 100644 --- a/ptlinux/tk_chrome.py +++ b/ptlinux/tk_chrome.py @@ -26,6 +26,7 @@ _DIALOG_PALETTES: dict[str, DialogPalette] = { "error": DialogPalette("#cf604c", "#3a1d19", "×"), "question": DialogPalette("#d98927", "#3a2819", "?"), } +_BASE_TK_SCALING = 96.0 / 72.0 def dialog_palette(kind: DialogKind) -> DialogPalette: @@ -111,6 +112,16 @@ def dragged_window_geometry( ) +def widget_ui_scale(widget: Any) -> float: + """Return the launcher scale already configured on a Tk interpreter.""" + + try: + scaling = float(widget.tk.call("tk", "scaling")) + except (AttributeError, TypeError, ValueError): + return 1.0 + return min(2.0, max(1.0, scaling / _BASE_TK_SCALING)) + + def _geometry_offset(value: int) -> str: return f"+{value}" if value >= 0 else str(value) @@ -138,7 +149,7 @@ def _bind_window_drag(handle: Any, *widgets: Any) -> None: class DashboardChrome: - """Controller for one frameless launcher window and its controls.""" + """Controller for one launcher window and its custom controls.""" def __init__( self, @@ -310,9 +321,10 @@ def create_window_chrome( show_minimize: bool = False, allow_close: bool = True, ) -> DashboardChrome: - """Install polished managed chrome around one launcher-owned Tk window.""" + """Install the launcher's custom frame and window controls.""" import tkinter as tk + ui_scale = widget_ui_scale(root) managed, identity_configured = _configure_x11_window( root, @@ -338,7 +350,7 @@ def create_window_chrome( title_bar = tk.Frame( surface, background="#17100d", - height=32, + height=round(32 * ui_scale), borderwidth=0, highlightthickness=0, cursor="fleur", @@ -354,7 +366,7 @@ def create_window_chrome( factor = icon_subsample_factor( window_icon.width(), window_icon.height(), - 20, + round(20 * ui_scale), ) title_icon = window_icon.subsample(factor, factor) except (tk.TclError, ValueError): @@ -363,8 +375,8 @@ def create_window_chrome( mark = tk.Label( title_bar, image=title_icon, - width=22, - height=22, + width=round(22 * ui_scale), + height=round(22 * ui_scale), background="#17100d", borderwidth=0, highlightthickness=0, @@ -374,8 +386,8 @@ def create_window_chrome( else: mark = tk.Frame( title_bar, - width=22, - height=22, + width=round(22 * ui_scale), + height=round(22 * ui_scale), background="#17100d", borderwidth=0, highlightthickness=0, @@ -414,10 +426,12 @@ def create_window_chrome( font=("Sans", 12), width=4, borderwidth=0, - highlightthickness=0, + highlightthickness=2, + highlightbackground="#17100d", + highlightcolor="#f4bd61", relief="flat", cursor="hand2", - takefocus=False, + takefocus=True, ) close_button.pack(side="right", fill="y") minimize_button: Any = None @@ -432,10 +446,12 @@ def create_window_chrome( font=("Sans", 10, "bold"), width=4, borderwidth=0, - highlightthickness=0, + highlightthickness=2, + highlightbackground="#17100d", + highlightcolor="#f4bd61", relief="flat", cursor="hand2", - takefocus=False, + takefocus=True, ) minimize_button.pack(side="right", fill="y") content = tk.Frame( @@ -489,9 +505,10 @@ def show_themed_dialog( cancel_text: str = "CANCEL", window_icon: Any | None = None, ) -> bool: - """Show one frameless modal dialog matching the native launcher palette.""" + """Show one custom-framed modal dialog matching the launcher palette.""" import tkinter as tk + ui_scale = widget_ui_scale(parent) palette = dialog_palette(kind) dialog = tk.Toplevel(parent) @@ -524,7 +541,11 @@ def show_themed_dialog( border.pack(fill="both", expand=True) surface = tk.Frame(border, background="#191311") surface.pack(fill="both", expand=True, padx=1, pady=1) - header = tk.Frame(surface, background="#24170f", height=66) + header = tk.Frame( + surface, + background="#24170f", + height=round(66 * ui_scale), + ) header.pack(fill="x") header.pack_propagate(False) tk_accent = tk.Frame(header, background=palette.accent, width=5) @@ -560,21 +581,24 @@ def show_themed_dialog( font=("Sans", 12), width=4, borderwidth=0, - highlightthickness=0, + highlightthickness=2, + highlightbackground="#24170f", + highlightcolor="#f4bd61", relief="flat", cursor="hand2", - takefocus=False, + takefocus=True, ) close_button.pack(side="right", fill="y") body = tk.Frame(surface, background="#191311", padx=20, pady=20) body.pack(fill="both", expand=True) + modal_icon_size = round(46 * ui_scale) modal_icon: Any | None = None if window_icon is not None: try: factor = icon_subsample_factor( window_icon.width(), window_icon.height(), - 46, + modal_icon_size, ) modal_icon = window_icon.subsample(factor, factor) except (tk.TclError, ValueError): @@ -582,8 +606,8 @@ def show_themed_dialog( if modal_icon is not None: icon_host = tk.Frame( body, - width=46, - height=46, + width=modal_icon_size, + height=modal_icon_size, background="#191311", borderwidth=0, highlightthickness=0, @@ -601,24 +625,24 @@ def show_themed_dialog( else: icon = tk.Canvas( body, - width=46, - height=46, + width=modal_icon_size, + height=modal_icon_size, background="#191311", borderwidth=0, highlightthickness=0, ) icon.create_oval( - 3, - 3, - 43, - 43, + round(3 * ui_scale), + round(3 * ui_scale), + modal_icon_size - round(3 * ui_scale), + modal_icon_size - round(3 * ui_scale), fill=palette.accent_soft, outline=palette.accent, width=2, ) icon.create_text( - 23, - 23, + modal_icon_size // 2, + modal_icon_size // 2, text=palette.glyph, fill=palette.accent, font=("Sans", 17, "bold"), @@ -632,7 +656,7 @@ def show_themed_dialog( font=("Sans", 10), justify="left", anchor="w", - wraplength=420, + wraplength=round(420 * ui_scale), ) message_label.pack(side="left", fill="both", expand=True, anchor="n") footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12) @@ -660,7 +684,9 @@ def show_themed_dialog( padx=16, pady=7, borderwidth=0, - highlightthickness=0, + highlightthickness=2, + highlightbackground="#100d0c", + highlightcolor="#f4bd61", relief="flat", cursor="hand2", ) @@ -681,12 +707,16 @@ def show_themed_dialog( ) cancel_button.pack(side="right", padx=(0, 8)) _bind_window_drag(dialog, header, header_copy, eyebrow, title_label) + dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False)) dialog.bind("", lambda _event: finish(False)) dialog.bind("", lambda _event: finish(True)) parent.update_idletasks() dialog.update_idletasks() - width = 560 - height = max(218, dialog.winfo_reqheight()) + width = min(round(560 * ui_scale), max(1, dialog.winfo_screenwidth() - 64)) + height = min( + max(round(218 * ui_scale), dialog.winfo_reqheight()), + max(1, dialog.winfo_screenheight() - 64), + ) dialog.geometry( centered_window_geometry( width, diff --git a/ptlinux/ui.py b/ptlinux/ui.py index d4ab430..a842b8a 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -14,7 +14,6 @@ import subprocess import sys import threading import time -import webbrowser from typing import TYPE_CHECKING, Callable, Iterator, Mapping if TYPE_CHECKING: @@ -70,8 +69,10 @@ 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 ( @@ -89,7 +90,39 @@ from .tk_chrome import ( MAX_NEWS_CARDS = 3 -WINDOW_SIZE = "1002x784" +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", +} +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 @@ -183,6 +216,100 @@ class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" +def launch_external_url(url: str) -> None: + """Open one web URL without leaking desktop-toolkit diagnostics to stderr.""" + + if not url.casefold().startswith("https://"): + raise DashboardError("only HTTPS links can be opened") + try: + process = subprocess.Popen( + ["xdg-open", url], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as error: + raise DashboardError(f"could not start the desktop web browser: {error}") from error + threading.Thread( + target=process.wait, + name="ptlinux-desktop-url-opener", + daemon=True, + ).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 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.""" @@ -787,17 +914,9 @@ def run_dashboard(paths: AppPaths) -> None: if icon is not None: window.iconphoto(False, icon) - root.geometry(WINDOW_SIZE) - root.resizable(False, False) root.configure(background="#0c0908") root.withdraw() - dashboard_chrome = create_dashboard_chrome( - root, - title="PRISTONTALE EU", - subtitle="NATIVE LINUX LAUNCHER", - window_icon=window_icon, - ) - dashboard_content = dashboard_chrome.content + root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") try: startup_settings = ensure_settings(paths) except (OSError, ValueError) as startup_error: @@ -811,6 +930,36 @@ def run_dashboard(paths: AppPaths) -> None: ) root.destroy() return + try: + detected_dpi = float(root.winfo_fpixels("1i")) + except (tk.TclError, TypeError, ValueError): + detected_dpi = BASE_DISPLAY_DPI + launcher_ui_scale = resolve_ui_scale(startup_settings.ui_scale, detected_dpi) + news_card_height = round(NEWS_CARD_HEIGHT * launcher_ui_scale) + news_image_width = round(NEWS_IMAGE_WIDTH * launcher_ui_scale) + news_image_height = round(NEWS_IMAGE_HEIGHT * launcher_ui_scale) + patch_card_wrap_length = round(PATCH_CARD_WRAP_LENGTH * launcher_ui_scale) + patch_change_wrap_length = round(PATCH_CHANGE_WRAP_LENGTH * launcher_ui_scale) + try: + root.tk.call("tk", "scaling", BASE_TK_SCALING * launcher_ui_scale) + except tk.TclError: + pass + initial_width, initial_height = fitted_window_size( + WINDOW_WIDTH, + WINDOW_HEIGHT, + scale=launcher_ui_scale, + screen_width=root.winfo_screenwidth(), + screen_height=root.winfo_screenheight(), + ) + root.geometry(f"{initial_width}x{initial_height}") + root.resizable(False, False) + dashboard_chrome = create_dashboard_chrome( + root, + title="PRISTONTALE EU", + subtitle="NATIVE LINUX LAUNCHER", + window_icon=window_icon, + ) + dashboard_content = dashboard_chrome.content style = ttk.Style(root) style.theme_use("clam") @@ -931,6 +1080,35 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -946,7 +1124,10 @@ def run_dashboard(paths: AppPaths) -> None: bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#4a3528", + focuscolor="#f4bd61", + focusthickness=2, ) + style.map("Settings.TEntry", bordercolor=[("focus", "#f4bd61")]) style.configure( "Settings.TCombobox", fieldbackground="#100d0c", @@ -954,11 +1135,14 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1015,6 +1199,8 @@ def run_dashboard(paths: AppPaths) -> None: background="#191311", foreground="#d8cbbd", font=("Sans", 9), + focuscolor="#f4bd61", + focusthickness=2, ) style.map( "Settings.TCheckbutton", @@ -1060,12 +1246,13 @@ def run_dashboard(paths: AppPaths) -> None: bordercolor="#32251f", borderwidth=1, relief="flat", + focusthickness=0, ) style.map( "NewsTab.TButton", background=[("active", "#2a201c")], foreground=[("active", "#f0d7a2")], - bordercolor=[("active", "#4a3528")], + bordercolor=[("focus", "#f4bd61"), ("active", "#4a3528")], ) style.configure( "NewsTabActive.TButton", @@ -1076,11 +1263,13 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1123,11 +1312,14 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1138,11 +1330,14 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1153,11 +1348,14 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1168,11 +1366,14 @@ def run_dashboard(paths: AppPaths) -> None: 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", @@ -1377,7 +1578,7 @@ def run_dashboard(paths: AppPaths) -> None: def finish_dashboard_transition() -> None: set_root_opacity(1.0) root.lift() - root.focus_set() + play_button.focus_set() root.after_idle(start_remote_content_refresh) def fade_dashboard_in(step: int = 0) -> None: @@ -1530,18 +1731,21 @@ def run_dashboard(paths: AppPaths) -> None: 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)) @@ -1557,14 +1761,25 @@ def run_dashboard(paths: AppPaths) -> None: ) 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") + 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") + 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)) @@ -1573,7 +1788,12 @@ def run_dashboard(paths: AppPaths) -> None: 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") + play_button = ttk.Button( + activity_action, + text="PLAY", + style="Play.TButton", + underline=0, + ) play_button.pack(fill="both", expand=True) realm_selector = ttk.Combobox( activity_action, @@ -1650,7 +1870,7 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(side="left", anchor="w") news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) - news_panel.pack(fill="x", pady=(0, 8), before=controls) + 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") @@ -1660,12 +1880,14 @@ def run_dashboard(paths: AppPaths) -> None: 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( @@ -1682,7 +1904,8 @@ def run_dashboard(paths: AppPaths) -> None: background="#191311", highlightthickness=0, borderwidth=0, - height=252, + height=round(252 * launcher_ui_scale), + takefocus=True, ) news_canvas.grid(row=0, column=0, sticky="nsew") news_scrollbar = ttk.Scrollbar( @@ -1722,7 +1945,14 @@ def run_dashboard(paths: AppPaths) -> None: activity_window = tk.Toplevel(root) activity_window.title("PristonTale EU — Activity") - activity_window.geometry("800x400") + 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) @@ -1739,8 +1969,10 @@ def run_dashboard(paths: AppPaths) -> None: ) log.pack(fill="both", expand=True, padx=10, pady=10) activity_window.withdraw() + activity_footer = ttk.Frame(frame, style="Root.TFrame") + activity_footer.pack(fill="x", pady=(3, 0)) log_toggle = tk.Button( - frame, + activity_footer, text=">_", background="#0c0908", foreground="#f8fafc", @@ -1750,16 +1982,21 @@ def run_dashboard(paths: AppPaths) -> None: padx=3, pady=2, borderwidth=0, - highlightthickness=0, + highlightthickness=2, + highlightbackground="#0c0908", + highlightcolor="#f4bd61", relief="flat", + takefocus=True, ) - log_toggle.place(x=0, rely=1.0, anchor="sw") + log_toggle.pack(side="left", anchor="sw") events: queue.Queue[tuple[str, object]] = queue.Queue() progress_updates = ProgressCoalescer() transfer_rate = TransferRateEstimator() news_image_targets: dict[int, tk.Canvas] = {} news_image_references: dict[int, tk.PhotoImage] = {} + news_card_widgets: list[tk.Frame] = [] + news_layout_columns = MAX_NEWS_CARDS startup_news_images_pending: set[int] = set() active_news_tab = "news" rendered_news_articles: tuple[NewsArticle, ...] | None = None @@ -1773,6 +2010,9 @@ 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 dashboard_debug_window: tk.Toplevel | None = None dashboard_debug_output: tk.Text | None = None game_clients = GameClientTracker() @@ -1806,6 +2046,8 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_check: LauncherUpdateCheck | None = None runtime_compatibility: RuntimeCompatibility | None = None compatibility_check_generation = 0 + controls_are_stacked: bool | None = None + responsive_layout_after: str | None = None buttons = ( play_button, update_button, @@ -2253,7 +2495,17 @@ def run_dashboard(paths: AppPaths) -> None: ).start() def open_article(url: str) -> None: - webbrowser.open(url) + try: + launch_external_url(url) + except DashboardError as error: + append_log(f"Could not open external link: {error}") + show_themed_dialog( + root, + title="Open web link", + message=str(error), + kind="error", + window_icon=window_icon, + ) def scroll_news_view(event: object) -> str | None: if active_news_tab != "patches": @@ -2290,6 +2542,10 @@ def run_dashboard(paths: AppPaths) -> None: def open_card(_event: object) -> None: open_article(url) + def activate_card(_event: object) -> str: + open_article(url) + return "break" + def bind_widget(widget: object) -> None: bind = getattr(widget, "bind") configure = getattr(widget, "configure") @@ -2303,6 +2559,11 @@ def run_dashboard(paths: AppPaths) -> None: for child in getattr(widget, "winfo_children")(): bind_widget(child) + card.configure(takefocus=True) + card.bind("", enter) + card.bind("", leave) + card.bind("", activate_card) + card.bind("", activate_card) bind_widget(card) def show_broadcast(broadcast: Broadcast | None) -> None: @@ -2346,6 +2607,30 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(anchor="w", fill="x", pady=(3, 0)) make_news_card_clickable(card, broadcast.url) + def layout_news_cards() -> None: + nonlocal news_layout_columns + columns = news_card_columns(max(1, root.winfo_width()), launcher_ui_scale) + news_layout_columns = columns + for column in range(MAX_NEWS_CARDS): + news_content.grid_columnconfigure( + column, + weight=1 if column < columns else 0, + uniform="news-card" if column < columns else "", + ) + for row in range(MAX_NEWS_CARDS): + news_content.grid_rowconfigure(row, weight=0) + for index, card in enumerate(news_card_widgets): + row, column = divmod(index, columns) + news_content.grid_rowconfigure(row, weight=1) + card.grid( + row=row, + column=column, + sticky="nsew", + padx=3, + pady=3, + ) + root.after_idle(update_news_viewport) + def render_news(articles: tuple[NewsArticle, ...]) -> None: nonlocal rendered_news_articles visible_articles = articles[:MAX_NEWS_CARDS] @@ -2353,10 +2638,13 @@ def run_dashboard(paths: AppPaths) -> None: return news_image_targets.clear() news_image_references.clear() + news_card_widgets.clear() for child in news_content.winfo_children(): child.destroy() for column in range(MAX_NEWS_CARDS): news_content.grid_columnconfigure(column, weight=0, uniform="") + for row in range(MAX_NEWS_CARDS): + news_content.grid_rowconfigure(row, weight=0) if not visible_articles: ttk.Label( news_content, @@ -2370,42 +2658,42 @@ def run_dashboard(paths: AppPaths) -> None: ) rendered_news_articles = visible_articles return - for column in range(len(visible_articles)): - news_content.grid_columnconfigure(column, weight=1, uniform="news-card") - news_content.grid_rowconfigure(0, weight=1) for index, article in enumerate(visible_articles): card = tk.Frame( news_content, background="#211916", highlightbackground="#4a3528", highlightthickness=1, - height=NEWS_CARD_HEIGHT, + height=news_card_height, padx=8, pady=8, ) card.grid_propagate(False) - card.grid( - row=0, - column=index, - sticky="nsew", - padx=3, - pady=3, - ) + news_card_widgets.append(card) image_canvas = tk.Canvas( card, background="#211916", - width=NEWS_IMAGE_WIDTH, - height=NEWS_IMAGE_HEIGHT, + width=news_image_width, + height=news_image_height, highlightthickness=0, borderwidth=0, ) image_canvas.pack(fill="x") image_canvas.create_text( - NEWS_IMAGE_WIDTH // 2, - NEWS_IMAGE_HEIGHT // 2, + news_image_width // 2, + news_image_height // 2, text="Loading official artwork…" if article.image_url else "Official news", fill="#a8998a", font=("Sans", 9), + tags=("centered-content",), + ) + image_canvas.bind( + "", + lambda event: event.widget.coords( + "centered-content", + max(1, int(event.width)) // 2, + max(1, int(event.height)) // 2, + ), ) if article.image_url: news_image_targets[article.id] = image_canvas @@ -2413,10 +2701,10 @@ def run_dashboard(paths: AppPaths) -> None: card, text=article.title, style="NewsTitle.TLabel", - wraplength=NEWS_IMAGE_WIDTH, + wraplength=news_image_width, justify="left", ).pack(anchor="w", fill="x", pady=(6, 0)) - meta = " · ".join(part for part in (article.published_at, article.author) if part) + meta = article.published_at if meta: ttk.Label(card, text=meta, style="NewsMeta.TLabel").pack(anchor="w", pady=(2, 4)) tk.Label( @@ -2425,12 +2713,13 @@ def run_dashboard(paths: AppPaths) -> None: background="#211916", foreground="#c9baaa", font=("Sans", 9), - wraplength=NEWS_IMAGE_WIDTH, + wraplength=news_image_width, justify="left", height=3, anchor="nw", ).pack(anchor="w", fill="x") make_news_card_clickable(card, article.url) + layout_news_cards() load_news_images(visible_articles) rendered_news_articles = visible_articles @@ -2453,7 +2742,7 @@ def run_dashboard(paths: AppPaths) -> None: background="#211916", foreground="#fff0d2", font=("Sans", 12, "bold"), - wraplength=PATCH_CARD_WRAP_LENGTH, + wraplength=patch_card_wrap_length, justify="left", anchor="w", ).pack(anchor="w", fill="x") @@ -2492,7 +2781,7 @@ def run_dashboard(paths: AppPaths) -> None: background="#211916", foreground="#ded1c4", font=("Sans", 10), - wraplength=PATCH_CARD_WRAP_LENGTH, + wraplength=patch_card_wrap_length, justify="left", anchor="w", ).pack(anchor="w", fill="x", pady=(9, 3)) @@ -2537,7 +2826,7 @@ def run_dashboard(paths: AppPaths) -> None: background="#211916", foreground="#ded1c4", font=("Sans", 10), - wraplength=PATCH_CHANGE_WRAP_LENGTH, + wraplength=patch_change_wrap_length, justify="left", anchor="w", ).pack(side="left", anchor="w", fill="x", expand=True) @@ -2659,6 +2948,11 @@ def run_dashboard(paths: AppPaths) -> None: news_scrollbar.grid_remove() root.after_idle(update_news_viewport) + def switch_news_tab_from_keyboard(tab: str, button: ttk.Button) -> str: + switch_news_tab(tab) + button.focus_set() + return "break" + news_tab_button.configure(command=lambda: switch_news_tab("news")) patches_tab_button.configure(command=lambda: switch_news_tab("patches")) @@ -2703,11 +2997,12 @@ def run_dashboard(paths: AppPaths) -> None: if image_data is None: target.delete("all") target.create_text( - NEWS_IMAGE_WIDTH // 2, - NEWS_IMAGE_HEIGHT // 2, + news_image_width // 2, + news_image_height // 2, text="Artwork unavailable", fill="#a8998a", font=("Sans", 8), + tags=("centered-content",), ) return try: @@ -2715,19 +3010,20 @@ def run_dashboard(paths: AppPaths) -> None: except tk.TclError: target.delete("all") target.create_text( - NEWS_IMAGE_WIDTH // 2, - NEWS_IMAGE_HEIGHT // 2, - text="Artwork unavailable", - fill="#a8998a", - font=("Sans", 8), - ) + news_image_width // 2, + news_image_height // 2, + text="Artwork unavailable", + fill="#a8998a", + font=("Sans", 8), + tags=("centered-content",), + ) return target_width = target.winfo_width() target_height = target.winfo_height() if target_width <= 1: - target_width = NEWS_IMAGE_WIDTH + target_width = news_image_width if target_height <= 1: - target_height = NEWS_IMAGE_HEIGHT + target_height = news_image_height if image.width() < target_width or image.height() < target_height: factor = max( math.ceil(target_width / image.width()), @@ -2740,7 +3036,12 @@ def run_dashboard(paths: AppPaths) -> None: image = image.subsample(factor, factor) news_image_references[article_id] = image target.delete("all") - target.create_image(target_width // 2, target_height // 2, image=image) + target.create_image( + target_width // 2, + target_height // 2, + image=image, + tags=("centered-content",), + ) def complete_news_image(value: object) -> None: install_news_image(value) @@ -2760,11 +3061,12 @@ def run_dashboard(paths: AppPaths) -> None: continue target.delete("all") target.create_text( - NEWS_IMAGE_WIDTH // 2, - NEWS_IMAGE_HEIGHT // 2, + news_image_width // 2, + news_image_height // 2, text="Artwork unavailable", fill="#a8998a", font=("Sans", 8), + tags=("centered-content",), ) startup_news_images_pending.clear() @@ -3335,6 +3637,7 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_badge.pack_forget() if log_reason: append_log(check.reason) + call_optional(launcher_update_dialog_refresh) def refresh_launcher_update_check() -> None: def worker() -> None: @@ -3706,6 +4009,7 @@ 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) mark_startup_component_ready( "launcher_update", "Launcher update check complete", @@ -3741,67 +4045,368 @@ def run_dashboard(paths: AppPaths) -> None: return launcher_child_command(*arguments) def run_launcher_update(required_reason: str | None = None) -> None: - check = launcher_update_check - if check is None: - launcher_update_badge.pack_forget() - launcher_update_button.configure( - text="CHECKING…", - style="Secondary.TButton", - state="disabled", - ) - if required_reason is not None: - status.set("Checking for a compatible launcher update…") - progress_text.set("Play remains disabled until this release is supported.") - refresh_launcher_update_check() + 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 - if not check.available: - if required_reason is None: - show_themed_dialog( - root, - title="Launcher is up to date", - message=check.reason, - kind="success", - window_icon=window_icon, - ) - else: - show_themed_dialog( - root, - title="Compatibility update required", - message=( - "The installed game needs a launcher compatibility update:\n\n" - f"{required_reason}\n\n{check.reason}" - ), - kind="warning", - window_icon=window_icon, - ) - return - if not check.supported: - show_themed_dialog( - root, - title="Manual launcher update required", - message=check.reason, - kind="warning", - window_icon=window_icon, - ) - return - if not show_themed_dialog( - root, - title="Update launcher", - message=( - f"Install signed launcher version {check.latest_version} and restart now?" - ), - kind="question", - confirm=True, - accept_text="INSTALL & RESTART", + + 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, - ): - return - run_operation( - f"Installing launcher {check.latest_version}…", - command_for("launcher-update"), - restart_on_success=True, + 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() + launcher_update_button.configure(command=run_launcher_update) def start_game_client() -> None: @@ -3913,7 +4518,6 @@ def run_dashboard(paths: AppPaths) -> None: dialog.withdraw() settings_window = dialog dialog.title("PristonTale EU — Settings") - dialog.geometry("842x714") dialog.resizable(False, False) dialog.configure(background="#0c0908") dialog.transient(root) @@ -3931,10 +4535,8 @@ def run_dashboard(paths: AppPaths) -> None: settings_header = tk.Frame( settings_chrome.content, background="#24170f", - height=118, ) settings_header.pack(fill="x") - settings_header.pack_propagate(False) 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) @@ -3954,7 +4556,7 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(anchor="w", pady=(1, 0)) tk.Label( header_copy, - text="Tune the game window, runtime, and compatibility preferences.", + text="Tune launcher accessibility, the game window, and runtime preferences.", background="#24170f", foreground="#c9baaa", font=("Sans", 9), @@ -3970,11 +4572,18 @@ def run_dashboard(paths: AppPaths) -> None: 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=current_settings.runtime) - proton_sync_value = tk.StringVar(value=current_settings.proton_sync) + 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 "") @@ -4067,9 +4676,13 @@ def run_dashboard(paths: AppPaths) -> None: 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=runtime_value.get(), - proton_sync=proton_sync_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, ) @@ -4087,8 +4700,10 @@ def run_dashboard(paths: AppPaths) -> None: f"runtime={updated_settings.runtime}, " f"proton_sync={updated_settings.proton_sync}" ) - status.set("Settings saved. They will be used on the next game launch.") - progress_text.set("Open Settings again at any time to adjust the launcher.") + status.set("Settings saved.") + progress_text.set( + "Game settings apply on the next launch; launcher scale applies after restart." + ) if show_dashboard_debug: refresh_dashboard_debug_disk_state() update_dashboard_debug_view(force=True) @@ -4126,6 +4741,14 @@ def run_dashboard(paths: AppPaths) -> None: 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, @@ -4148,15 +4771,15 @@ def run_dashboard(paths: AppPaths) -> None: "Runtime", "Compatibility layer", runtime_value, - ("auto", "proton7-container", "proton7", "umu"), + tuple(RUNTIME_LABELS), ) add_choice( rendering_card, 3, "Proton synchronization", - "Auto uses esync on CachyOS", + "Automatic selects the compatible mode", proton_sync_value, - ("auto", "fsync", "esync"), + tuple(PROTON_SYNC_LABELS), ) overrides_card = ttk.LabelFrame( @@ -4213,11 +4836,20 @@ def run_dashboard(paths: AppPaths) -> None: ).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, + 760, + scale=launcher_ui_scale, + screen_width=dialog.winfo_screenwidth(), + screen_height=dialog.winfo_screenheight(), + ) dialog.geometry( centered_window_geometry( - 842, - 714, + settings_width, + settings_height, parent_x=root.winfo_rootx(), parent_y=root.winfo_rooty(), parent_width=root.winfo_width(), @@ -4268,10 +4900,94 @@ def run_dashboard(paths: AppPaths) -> None: if isinstance(width, int) and width > 0: news_canvas.itemconfigure(news_window, width=width) + def apply_responsive_layout() -> None: + nonlocal controls_are_stacked, responsive_layout_after + responsive_layout_after = None + width = max(1, root.winfo_width()) + stacked = dashboard_controls_stacked(width, launcher_ui_scale) + if stacked != controls_are_stacked: + controls_are_stacked = stacked + if stacked: + game_actions.grid_configure( + row=0, + column=0, + columnspan=2, + padx=0, + pady=(0, 4), + ) + system_actions.grid_configure( + row=1, + column=0, + columnspan=2, + padx=0, + pady=(4, 0), + ) + else: + game_actions.grid_configure( + row=0, + column=0, + columnspan=1, + padx=(0, 4), + pady=0, + ) + system_actions.grid_configure( + row=0, + column=1, + columnspan=1, + padx=(4, 0), + pady=0, + ) + if news_card_columns(width, launcher_ui_scale) != news_layout_columns: + layout_news_cards() + + def schedule_responsive_layout(event: object | None = None) -> None: + nonlocal responsive_layout_after + if event is not None and getattr(event, "widget", root) is not root: + return + if responsive_layout_after is not None: + try: + root.after_cancel(responsive_layout_after) + except tk.TclError: + pass + responsive_layout_after = root.after_idle(apply_responsive_layout) + + def invoke_shortcut(button: ttk.Button) -> str: + button.invoke() + return "break" + + def scroll_news_keyboard(units: int) -> str: + news_canvas.yview_scroll(units, "units") + return "break" + news_content_host.bind("", update_news_viewport) news_content.bind("", update_news_viewport) patches_content.bind("", update_news_viewport) news_canvas.bind("", resize_news_content) + news_canvas.bind("", lambda _event: news_canvas.focus_set(), add="+") + news_canvas.bind("", lambda _event: scroll_news_keyboard(-1)) + news_canvas.bind("", lambda _event: scroll_news_keyboard(1)) + news_canvas.bind("", lambda _event: scroll_news_keyboard(-8)) + news_canvas.bind("", lambda _event: scroll_news_keyboard(8)) + news_canvas.bind("", lambda _event: news_canvas.yview_moveto(0.0)) + news_canvas.bind("", lambda _event: news_canvas.yview_moveto(1.0)) + news_tab_button.bind( + "", + lambda _event: switch_news_tab_from_keyboard("patches", patches_tab_button), + ) + patches_tab_button.bind( + "", + lambda _event: switch_news_tab_from_keyboard("news", news_tab_button), + ) + root.bind("", lambda _event: invoke_shortcut(play_button)) + root.bind("", lambda _event: invoke_shortcut(update_button)) + root.bind("", lambda _event: invoke_shortcut(repair_button)) + root.bind("", lambda _event: invoke_shortcut(import_button)) + root.bind("", lambda _event: invoke_shortcut(doctor_button)) + root.bind("", lambda _event: invoke_shortcut(settings_button)) + root.bind("", lambda _event: invoke_shortcut(launcher_update_button)) + root.bind("", lambda _event: invoke_shortcut(log_toggle)) + root.bind("", schedule_responsive_layout, add="+") + activity_window.bind("", lambda _event: hide_activity_window()) for scroll_widget in ( news_canvas, news_content_host, @@ -4281,6 +4997,7 @@ def run_dashboard(paths: AppPaths) -> None: scroll_widget.bind("", scroll_news_view, add="+") scroll_widget.bind("", scroll_news_view, add="+") scroll_widget.bind("", scroll_news_view, add="+") + apply_responsive_layout() startup_overlay.lift() startup_status.set("Restoring dashboard content…") startup_overlay.update_idletasks()