Added download settings and optimized the downloader system with concurrency.
This commit is contained in:
366
ptlinux/ui.py
366
ptlinux/ui.py
@@ -63,6 +63,8 @@ from .news import (
|
||||
fetch_official_logo,
|
||||
read_cached_broadcast,
|
||||
read_cached_news,
|
||||
read_cached_news_image,
|
||||
read_cached_official_logo,
|
||||
)
|
||||
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
|
||||
from .paths import AppPaths
|
||||
@@ -120,6 +122,14 @@ PROTON_SYNC_LABELS: dict[str, str] = {
|
||||
"Fsync": "fsync",
|
||||
"Esync": "esync",
|
||||
}
|
||||
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…"
|
||||
NEWS_THREE_COLUMN_WIDTH = 900
|
||||
NEWS_TWO_COLUMN_WIDTH = 620
|
||||
STACKED_CONTROLS_WIDTH = 860
|
||||
@@ -305,6 +315,62 @@ def setting_value(label: str, choices: Mapping[str, str]) -> str:
|
||||
raise ValueError(f"unsupported setting choice: {label}") from error
|
||||
|
||||
|
||||
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 call_optional(callback: Callable[..., None] | None, *arguments: object) -> None:
|
||||
if callback is not None:
|
||||
callback(*arguments)
|
||||
@@ -1813,6 +1879,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_text = tk.StringVar(value="Click Play to start your journey")
|
||||
speed_text = tk.StringVar(value="")
|
||||
client_activity_text = tk.StringVar(value="")
|
||||
download_context_text = tk.StringVar(value="")
|
||||
activity_meta_text = tk.StringVar(value="")
|
||||
|
||||
def refresh_activity_meta_text() -> None:
|
||||
activity_meta_text.set(
|
||||
download_context_text.get() or client_activity_text.get()
|
||||
)
|
||||
|
||||
def set_download_context_text(value: str) -> None:
|
||||
download_context_text.set(value)
|
||||
refresh_activity_meta_text()
|
||||
|
||||
activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame")
|
||||
activity_heading.pack(fill="x")
|
||||
ttk.Label(
|
||||
@@ -1822,7 +1900,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
).pack(side="left", anchor="w")
|
||||
ttk.Label(
|
||||
activity_heading,
|
||||
textvariable=client_activity_text,
|
||||
textvariable=activity_meta_text,
|
||||
style="Muted.TLabel",
|
||||
).pack(side="right", anchor="e")
|
||||
progress_bar = ttk.Progressbar(
|
||||
@@ -1839,7 +1917,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_details,
|
||||
textvariable=speed_text,
|
||||
style="Speed.TLabel",
|
||||
width=15,
|
||||
width=28,
|
||||
anchor="e",
|
||||
).pack(side="right")
|
||||
ttk.Label(
|
||||
@@ -1857,6 +1935,24 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
).pack(side="left", fill="y", padx=(0, 8))
|
||||
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
|
||||
server_status_content.pack(side="left", fill="y", anchor="w")
|
||||
log_toggle = tk.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.StringVar(value="")
|
||||
ttk.Label(
|
||||
server_status_row,
|
||||
@@ -1969,26 +2065,6 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
log.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
activity_window.withdraw()
|
||||
activity_footer = ttk.Frame(frame, style="Root.TFrame")
|
||||
activity_footer.pack(fill="x", pady=(3, 0))
|
||||
log_toggle = tk.Button(
|
||||
activity_footer,
|
||||
text=">_",
|
||||
background="#0c0908",
|
||||
foreground="#f8fafc",
|
||||
activebackground="#24170f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Monospace", 8, "bold"),
|
||||
padx=3,
|
||||
pady=2,
|
||||
borderwidth=0,
|
||||
highlightthickness=2,
|
||||
highlightbackground="#0c0908",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
takefocus=True,
|
||||
)
|
||||
log_toggle.pack(side="left", anchor="sw")
|
||||
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
progress_updates = ProgressCoalescer()
|
||||
@@ -2111,28 +2187,67 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if phase == "manifest":
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
set_operation_stage(1, 3, "Checking official CDN manifests…")
|
||||
return
|
||||
progress_bar.stop()
|
||||
progress_bar.configure(mode="determinate")
|
||||
if bytes_total:
|
||||
progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total))
|
||||
bytes_remaining = _as_int(event.get("bytes_remaining"))
|
||||
if "bytes_remaining" not in event:
|
||||
bytes_remaining = max(0, bytes_total - bytes_completed)
|
||||
percent = min(100, round((bytes_completed / bytes_total) * 100))
|
||||
operation_progress_detail = (
|
||||
f"{files_completed}/{files_total} files · "
|
||||
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}"
|
||||
f"{percent}% · {files_completed:,}/{files_total:,} files · "
|
||||
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)} · "
|
||||
f"{_format_bytes(bytes_remaining)} remaining"
|
||||
)
|
||||
if phase == "downloading":
|
||||
bytes_transferred = _as_int(event.get("bytes_transferred"))
|
||||
rate = transfer_rate.update(bytes_transferred)
|
||||
speed_text.set(format_transfer_rate(rate) if rate is not None else "0 B/s")
|
||||
estimated_rate = transfer_rate.update(bytes_transferred)
|
||||
reported_rate = _as_float(
|
||||
event.get("transfer_rate_bytes_per_second")
|
||||
)
|
||||
rate = reported_rate if reported_rate is not None else estimated_rate
|
||||
eta_seconds = _as_float(event.get("eta_seconds"))
|
||||
if eta_seconds is None and rate is not None and rate > 0:
|
||||
eta_seconds = bytes_remaining / rate
|
||||
speed_text.set(
|
||||
f"{format_transfer_rate(rate) if rate is not None else '0 B/s'} · "
|
||||
f"ETA {format_eta(eta_seconds)}"
|
||||
)
|
||||
current_server = event.get("current_server")
|
||||
active_workers = _as_int(event.get("active_workers"))
|
||||
target_workers = _as_int(event.get("target_workers"))
|
||||
retry_reason = event.get("retry_reason")
|
||||
set_download_context_text(
|
||||
download_activity_metadata(
|
||||
current_server=(
|
||||
current_server
|
||||
if isinstance(current_server, str) and current_server
|
||||
else None
|
||||
),
|
||||
retry_count=_as_int(event.get("retry_count")),
|
||||
retry_reason=(
|
||||
retry_reason
|
||||
if isinstance(retry_reason, str) and retry_reason
|
||||
else None
|
||||
),
|
||||
active_workers=active_workers,
|
||||
target_workers=target_workers,
|
||||
)
|
||||
)
|
||||
else:
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
else:
|
||||
progress_bar.configure(maximum=3, value=3)
|
||||
operation_progress_detail = "Game files are already up to date."
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
progress_text.set(activity_text())
|
||||
if isinstance(current_file, str) and current_file:
|
||||
status.set(
|
||||
@@ -2159,6 +2274,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
operation_progress_detail = None
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
progress_bar.stop()
|
||||
progress_bar.configure(
|
||||
mode="determinate",
|
||||
@@ -2370,6 +2486,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
set_busy(True)
|
||||
status.set(label)
|
||||
progress_text.set("Waiting for the operation to report progress…")
|
||||
@@ -2971,7 +3088,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
|
||||
def worker(news_article: NewsArticle) -> None:
|
||||
image = fetch_news_image(news_article, paths.news_image_cache)
|
||||
image = (
|
||||
read_cached_news_image(news_article, paths.news_image_cache)
|
||||
if startup_settings.low_bandwidth_mode
|
||||
else fetch_news_image(news_article, paths.news_image_cache)
|
||||
)
|
||||
events.put(("news_image", (news_article.id, image)))
|
||||
|
||||
for article in image_articles:
|
||||
@@ -3087,7 +3208,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
def load_official_logo() -> None:
|
||||
def worker() -> None:
|
||||
image = fetch_official_logo(paths.logo_cache)
|
||||
image = (
|
||||
read_cached_official_logo(paths.logo_cache)
|
||||
if startup_settings.low_bandwidth_mode
|
||||
else fetch_official_logo(paths.logo_cache)
|
||||
)
|
||||
events.put(("logo_image", image))
|
||||
|
||||
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
|
||||
@@ -3529,6 +3654,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
latest_installation_status = installation
|
||||
additional_client_ready = game_clients.running_count > 0 and installation.installed
|
||||
client_activity_text.set(game_client_activity_text(game_clients.running_count))
|
||||
refresh_activity_meta_text()
|
||||
play_button.configure(
|
||||
text="PLAY" if additional_client_ready else installation.action.value
|
||||
)
|
||||
@@ -3685,16 +3811,42 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
events.put(("version", snapshot))
|
||||
|
||||
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
|
||||
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
|
||||
threading.Thread(target=patches_worker, name="ptlinux-patches", daemon=True).start()
|
||||
if startup_settings.low_bandwidth_mode:
|
||||
append_log(
|
||||
"Low-bandwidth mode: using cached news, broadcasts, patch notes, "
|
||||
"and artwork."
|
||||
)
|
||||
for component, message in (
|
||||
("broadcast", "Cached broadcast ready"),
|
||||
("news", "Cached news ready"),
|
||||
("patches", "Cached patch notes ready"),
|
||||
("launcher_update", "Cached launcher update status ready"),
|
||||
):
|
||||
mark_startup_component_ready(component, message)
|
||||
else:
|
||||
threading.Thread(
|
||||
target=broadcast_worker,
|
||||
name="ptlinux-broadcast",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=news_worker,
|
||||
name="ptlinux-news",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=patches_worker,
|
||||
name="ptlinux-patches",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=server_status_worker,
|
||||
name="ptlinux-server-status",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
|
||||
refresh_launcher_update_check()
|
||||
if not startup_settings.low_bandwidth_mode:
|
||||
refresh_launcher_update_check()
|
||||
|
||||
def start_remote_content_refresh() -> None:
|
||||
nonlocal remote_refresh_started
|
||||
@@ -3723,6 +3875,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
progress_bar.stop()
|
||||
close_game_preloader()
|
||||
if not ready:
|
||||
@@ -3756,6 +3909,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
set_download_context_text("")
|
||||
progress_bar.stop()
|
||||
close_game_preloader()
|
||||
root.deiconify()
|
||||
@@ -4556,7 +4710,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
).pack(anchor="w", pady=(1, 0))
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="Tune launcher accessibility, the game window, and runtime preferences.",
|
||||
text=(
|
||||
"Tune launcher accessibility, downloads, the game window, and runtime "
|
||||
"preferences."
|
||||
),
|
||||
background="#24170f",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
@@ -4586,6 +4743,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
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,
|
||||
@@ -4673,6 +4845,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
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(),
|
||||
@@ -4685,6 +4861,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
),
|
||||
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 error:
|
||||
show_themed_dialog(
|
||||
@@ -4695,14 +4873,22 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
saved_limit = (
|
||||
f"{updated_settings.download_speed_limit_mbps:g} MB/s"
|
||||
if updated_settings.download_speed_limit_mbps is not None
|
||||
else "unlimited"
|
||||
)
|
||||
append_log(
|
||||
f"Saved launcher settings to {paths.settings_file}: "
|
||||
f"runtime={updated_settings.runtime}, "
|
||||
f"proton_sync={updated_settings.proton_sync}"
|
||||
f"proton_sync={updated_settings.proton_sync}, "
|
||||
f"download_limit={saved_limit}, "
|
||||
f"low_bandwidth={updated_settings.low_bandwidth_mode}"
|
||||
)
|
||||
status.set("Settings saved.")
|
||||
progress_text.set(
|
||||
"Game settings apply on the next launch; launcher scale applies after restart."
|
||||
"Game and download settings apply to the next operation; launcher scale "
|
||||
"and cached-content mode apply after restart."
|
||||
)
|
||||
if show_dashboard_debug:
|
||||
refresh_dashboard_debug_disk_state()
|
||||
@@ -4782,13 +4968,97 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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=1, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
overrides_card.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
overrides_card,
|
||||
@@ -4813,7 +5083,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
|
||||
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
|
||||
footer.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
footer,
|
||||
@@ -4841,7 +5111,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
dialog.update_idletasks()
|
||||
settings_width, settings_height = fitted_window_size(
|
||||
842,
|
||||
760,
|
||||
880,
|
||||
scale=launcher_ui_scale,
|
||||
screen_width=dialog.winfo_screenwidth(),
|
||||
screen_height=dialog.winfo_screenheight(),
|
||||
@@ -5037,6 +5307,13 @@ def _as_int(value: object) -> int:
|
||||
return value if isinstance(value, int) and value >= 0 else 0
|
||||
|
||||
|
||||
def _as_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 dashboard_poll_interval(
|
||||
*,
|
||||
operation_active: bool,
|
||||
@@ -5064,3 +5341,16 @@ def _format_bytes(value: int) -> str:
|
||||
|
||||
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