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