Reworked UI for easier workflow.
This commit is contained in:
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