Reworked UI for easier workflow.
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user