Reworked UI for easier workflow.
This commit is contained in:
13
install.sh
13
install.sh
@@ -150,6 +150,8 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|
|||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.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/launcher-update-public.pem" \
|
||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
||||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||||
@@ -209,15 +211,20 @@ install -d -m 0755 \
|
|||||||
"${STAGING_RELEASE}/ptlinux" \
|
"${STAGING_RELEASE}/ptlinux" \
|
||||||
"${STAGING_RELEASE}/ptlinux/native" \
|
"${STAGING_RELEASE}/ptlinux/native" \
|
||||||
"${STAGING_RELEASE}/ptdiag"
|
"${STAGING_RELEASE}/ptdiag"
|
||||||
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py
|
shopt -s globstar nullglob
|
||||||
|
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"**/*.py
|
||||||
do
|
do
|
||||||
case "${SOURCE_MODULE##*/}" in
|
RELATIVE_MODULE="${SOURCE_MODULE#"${SOURCE_ROOT}/ptlinux/"}"
|
||||||
|
case "$RELATIVE_MODULE" in
|
||||||
desktop_entry.py|launcher_wrapper.py)
|
desktop_entry.py|launcher_wrapper.py)
|
||||||
continue
|
continue
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/"
|
install -D -m 0644 \
|
||||||
|
"$SOURCE_MODULE" \
|
||||||
|
"${STAGING_RELEASE}/ptlinux/${RELATIVE_MODULE}"
|
||||||
done
|
done
|
||||||
|
shopt -u globstar nullglob
|
||||||
install -m 0644 \
|
install -m 0644 \
|
||||||
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||||
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
|
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ def _self_check_release(release: Path, expected_version: str) -> None:
|
|||||||
check = (
|
check = (
|
||||||
"import sys; "
|
"import sys; "
|
||||||
"sys.path.insert(0, sys.argv[1]); "
|
"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)"
|
"raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
150
ptlinux/ui/__init__.py
Normal file
150
ptlinux/ui/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
1
ptlinux/ui/components/__init__.py
Normal file
1
ptlinux/ui/components/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Reusable Tk dashboard components."""
|
||||||
119
ptlinux/ui/components/action_panel.py
Normal file
119
ptlinux/ui/components/action_panel.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
33
ptlinux/ui/components/broadcasts_panel.py
Normal file
33
ptlinux/ui/components/broadcasts_panel.py
Normal file
@@ -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)
|
||||||
74
ptlinux/ui/components/header.py
Normal file
74
ptlinux/ui/components/header.py
Normal file
@@ -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)
|
||||||
108
ptlinux/ui/components/news_panel.py
Normal file
108
ptlinux/ui/components/news_panel.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
163
ptlinux/ui/components/progress_panel.py
Normal file
163
ptlinux/ui/components/progress_panel.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
58
ptlinux/ui/components/server_status.py
Normal file
58
ptlinux/ui/components/server_status.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
459
ptlinux/ui/components/theme.py
Normal file
459
ptlinux/ui/components/theme.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
434
ptlinux/ui/components/update_dialog.py
Normal file
434
ptlinux/ui/components/update_dialog.py
Normal file
@@ -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("<Escape>", 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)
|
||||||
File diff suppressed because it is too large
Load Diff
1
ptlinux/ui/dialogs/__init__.py
Normal file
1
ptlinux/ui/dialogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Modal and auxiliary windows owned by the native dashboard."""
|
||||||
50
ptlinux/ui/dialogs/activity.py
Normal file
50
ptlinux/ui/dialogs/activity.py
Normal file
@@ -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)
|
||||||
514
ptlinux/ui/dialogs/settings_dialog.py
Normal file
514
ptlinux/ui/dialogs/settings_dialog.py
Normal file
@@ -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(
|
||||||
|
"<<ComboboxSelected>>",
|
||||||
|
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("<Escape>", lambda _event: close_settings())
|
||||||
|
dialog.bind("<Control-s>", 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
|
||||||
1
ptlinux/ui/viewmodels/__init__.py
Normal file
1
ptlinux/ui/viewmodels/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pure state and presentation models used by the native dashboard."""
|
||||||
480
ptlinux/ui/viewmodels/dashboard.py
Normal file
480
ptlinux/ui/viewmodels/dashboard.py
Normal file
@@ -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
|
||||||
240
ptlinux/ui/viewmodels/downloader.py
Normal file
240
ptlinux/ui/viewmodels/downloader.py
Normal file
@@ -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"
|
||||||
122
ptlinux/ui/viewmodels/health.py
Normal file
122
ptlinux/ui/viewmodels/health.py
Normal file
@@ -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)
|
||||||
46
ptlinux/ui/viewmodels/installation.py
Normal file
46
ptlinux/ui/viewmodels/installation.py
Normal file
@@ -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}"
|
||||||
Reference in New Issue
Block a user