Added resolution settings and updater modal with rollback functionality for launcher versions.

This commit is contained in:
2026-09-02 23:18:16 +02:00
parent bb90efa9d1
commit ba079ef6fa
4 changed files with 1065 additions and 213 deletions

View File

@@ -74,6 +74,7 @@ class LauncherUpdateCheck:
reason: str reason: str
manifest_url: str manifest_url: str
manifest: UpdateManifest manifest: UpdateManifest
checked_at: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -110,6 +111,26 @@ class LauncherRollbackResult:
restart_required: bool restart_required: bool
@dataclass(frozen=True)
class LauncherInstalledRelease:
"""One validated release retained in the launcher's version store."""
version: str
installed_at: str
directory: str
active: bool
previous: bool
@dataclass(frozen=True)
class LauncherUpdateHistory:
"""Validated launcher releases available for inspection or rollback."""
current_version: str | None
previous_version: str | None
releases: tuple[LauncherInstalledRelease, ...]
def default_public_key_path() -> Path: def default_public_key_path() -> Path:
return Path(__file__).with_name(PUBLIC_KEY_FILENAME) return Path(__file__).with_name(PUBLIC_KEY_FILENAME)
@@ -157,7 +178,12 @@ def check_for_launcher_update(
) )
_verify_manifest_signature(manifest_bytes, signature_bytes, key) _verify_manifest_signature(manifest_bytes, signature_bytes, key)
manifest = parse_update_manifest(manifest_bytes) manifest = parse_update_manifest(manifest_bytes)
return _evaluate_launcher_update(manifest, current_version, url) return _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=datetime.now().astimezone().isoformat(timespec="seconds"),
)
def read_cached_launcher_update_check( def read_cached_launcher_update_check(
@@ -198,13 +224,21 @@ def read_cached_launcher_update_check(
manifest_value = value.get("manifest") manifest_value = value.get("manifest")
if not isinstance(manifest_value, dict): if not isinstance(manifest_value, dict):
return None return None
checked_at = _cached_timestamp(value.get("checked_at"))
if checked_at is None:
return None
try: try:
manifest = parse_update_manifest( manifest = parse_update_manifest(
(json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8") (json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8")
) )
except (LauncherUpdateError, TypeError, ValueError): except (LauncherUpdateError, TypeError, ValueError):
return None return None
return _evaluate_launcher_update(manifest, current_version, url) return _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=checked_at,
)
def cache_launcher_update_check( def cache_launcher_update_check(
@@ -223,7 +257,8 @@ def cache_launcher_update_check(
cache_path, cache_path,
{ {
"schema": UPDATE_CHECK_CACHE_SCHEMA, "schema": UPDATE_CHECK_CACHE_SCHEMA,
"checked_at": datetime.now().astimezone().isoformat(), "checked_at": check.checked_at
or datetime.now().astimezone().isoformat(timespec="seconds"),
"current_version": check.current_version, "current_version": check.current_version,
"manifest_url": check.manifest_url, "manifest_url": check.manifest_url,
"public_key_sha256": key_sha256, "public_key_sha256": key_sha256,
@@ -239,6 +274,8 @@ def _evaluate_launcher_update(
manifest: UpdateManifest, manifest: UpdateManifest,
current_version: str, current_version: str,
manifest_url: str, manifest_url: str,
*,
checked_at: str | None = None,
) -> LauncherUpdateCheck: ) -> LauncherUpdateCheck:
current = _parse_version(current_version, "current launcher version") current = _parse_version(current_version, "current launcher version")
latest = _parse_version(manifest.version, "manifest version") latest = _parse_version(manifest.version, "manifest version")
@@ -269,6 +306,7 @@ def _evaluate_launcher_update(
reason=reason, reason=reason,
manifest_url=manifest_url, manifest_url=manifest_url,
manifest=manifest, manifest=manifest,
checked_at=checked_at,
) )
@@ -408,12 +446,22 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
raise LauncherUpdateError("the current launcher release link is missing") raise LauncherUpdateError("the current launcher release link is missing")
if previous is None: if previous is None:
raise LauncherUpdateError("no previous launcher release is available") raise LauncherUpdateError("no previous launcher release is available")
current_version = _validate_release_directory(current) current_release = current
previous_version = _validate_release_directory(previous) previous_release = previous
if current == previous: current_version = _validate_release_directory(current_release)
previous_version = _validate_release_directory(previous_release)
if current_release == previous_release:
raise LauncherUpdateError("current and previous launcher releases are identical") raise LauncherUpdateError("current and previous launcher releases are identical")
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases) _replace_release_link(
_replace_release_link(paths.launcher_current, previous, paths.launcher_releases) paths.launcher_previous,
current_release,
paths.launcher_releases,
)
_replace_release_link(
paths.launcher_current,
previous_release,
paths.launcher_releases,
)
_fsync_directory(paths.app_home) _fsync_directory(paths.app_home)
_write_json( _write_json(
paths.launcher_update_state, paths.launcher_update_state,
@@ -422,19 +470,64 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
"rolled_back_at": datetime.now().astimezone().isoformat(), "rolled_back_at": datetime.now().astimezone().isoformat(),
"from_version": current_version, "from_version": current_version,
"to_version": previous_version, "to_version": previous_version,
"release_directory": str(previous), "release_directory": str(previous_release),
"previous_release": str(current), "previous_release": str(current_release),
}, },
) )
return LauncherRollbackResult( return LauncherRollbackResult(
from_version=current_version, from_version=current_version,
to_version=previous_version, to_version=previous_version,
current_release=str(previous), current_release=str(previous_release),
previous_release=str(current), previous_release=str(current_release),
restart_required=True, restart_required=True,
) )
def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory:
"""Return validated retained releases without trusting directory names alone."""
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
previous = _release_from_link(paths.launcher_previous, paths.launcher_releases)
try:
candidates = tuple(paths.launcher_releases.iterdir())
except FileNotFoundError:
candidates = ()
except OSError as exc:
raise LauncherUpdateError(f"cannot inspect installed launcher releases: {exc}") from exc
releases: list[LauncherInstalledRelease] = []
for candidate in candidates:
if _VERSION_PATTERN.fullmatch(candidate.name) is None:
continue
try:
version = _validate_release_directory(candidate)
installed_at = datetime.fromtimestamp(
candidate.stat().st_mtime
).astimezone().isoformat(timespec="seconds")
except (LauncherUpdateError, OSError, ValueError, OverflowError):
continue
if version != candidate.name:
continue
resolved = candidate.resolve()
releases.append(
LauncherInstalledRelease(
version=version,
installed_at=installed_at,
directory=str(resolved),
active=resolved == current,
previous=resolved == previous,
)
)
releases.sort(
key=lambda release: _parse_version(release.version, "launcher release version"),
reverse=True,
)
return LauncherUpdateHistory(
current_version=_release_version(releases, active=True),
previous_version=_release_version(releases, previous=True),
releases=tuple(releases),
)
def parse_update_manifest(data: bytes) -> UpdateManifest: def parse_update_manifest(data: bytes) -> UpdateManifest:
"""Strictly parse one already-authenticated manifest.""" """Strictly parse one already-authenticated manifest."""
@@ -857,9 +950,15 @@ def _activate_release(paths: AppPaths, release: Path) -> Path | None:
raise LauncherUpdateError( raise LauncherUpdateError(
f"launcher previous path is not a symbolic link: {paths.launcher_previous}" f"launcher previous path is not a symbolic link: {paths.launcher_previous}"
) )
if current is not None and current != release: if current is not None:
_validate_release_directory(current) current_release = current
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases) if current_release != release:
_validate_release_directory(current_release)
_replace_release_link(
paths.launcher_previous,
current_release,
paths.launcher_releases,
)
_replace_release_link(paths.launcher_current, release, paths.launcher_releases) _replace_release_link(paths.launcher_current, release, paths.launcher_releases)
_fsync_directory(paths.app_home) _fsync_directory(paths.app_home)
return current return current
@@ -980,6 +1079,30 @@ def _parse_published_at(value: str) -> datetime:
return parsed return parsed
def _cached_timestamp(value: object) -> str | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return value
def _release_version(
releases: list[LauncherInstalledRelease],
*,
active: bool = False,
previous: bool = False,
) -> str | None:
for release in releases:
if (active and release.active) or (previous and release.previous):
return release.version
return None
def _required_string(value: dict[str, object], key: str) -> str: def _required_string(value: dict[str, object], key: str) -> str:
item = value.get(key) item = value.get(key)
if not isinstance(item, str) or not item: if not isinstance(item, str) or not item:

View File

@@ -16,21 +16,27 @@ from .paths import AppPaths
SETTINGS_SCHEMA = 1 SETTINGS_SCHEMA = 1
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated" _TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
_RETIRED_DISPLAY_SETTINGS = frozenset(
{
"width",
"height",
"output_width",
"output_height",
"refresh_rate",
"fullscreen",
"scaler",
"filter",
}
)
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
realm: str = "Babel" realm: str = "Babel"
width: int = 1600 ui_scale: str = "auto"
height: int = 900
output_width: int = 1600
output_height: int = 900
refresh_rate: int = 60
fullscreen: bool = False
borderless: bool = False borderless: bool = False
suppress_unresponsive_warnings: bool = True suppress_unresponsive_warnings: bool = True
scaler: str = "fit"
filter: str = "linear"
runtime: str = "auto" runtime: str = "auto"
proton_sync: str = "auto" proton_sync: str = "auto"
proton_path: str | None = None proton_path: str | None = None
@@ -136,20 +142,13 @@ def _read_settings_object(path: Path) -> dict[str, object] | None:
def _settings_from_object(value: dict[str, object]) -> Settings: def _settings_from_object(value: dict[str, object]) -> Settings:
settings = Settings( settings = Settings(
realm=_string(value, "realm", "Babel").strip(), realm=_string(value, "realm", "Babel").strip(),
width=_integer(value, "width", 1600), ui_scale=_string(value, "ui_scale", "auto"),
height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600),
output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False),
borderless=_migrated_borderless(value), borderless=_migrated_borderless(value),
suppress_unresponsive_warnings=_boolean( suppress_unresponsive_warnings=_boolean(
value, value,
"suppress_unresponsive_warnings", "suppress_unresponsive_warnings",
True, True,
), ),
scaler=_string(value, "scaler", "fit"),
filter=_string(value, "filter", "linear"),
runtime=_string(value, "runtime", "auto"), runtime=_string(value, "runtime", "auto"),
proton_sync=_string(value, "proton_sync", "auto"), proton_sync=_string(value, "proton_sync", "auto"),
proton_path=_optional_string(value, "proton_path"), proton_path=_optional_string(value, "proton_path"),
@@ -192,6 +191,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
value.get("schema") != SETTINGS_SCHEMA value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value) or not _SETTINGS_FIELD_NAMES.issubset(value)
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
or bool(_RETIRED_DISPLAY_SETTINGS.intersection(value))
) )
@@ -201,6 +201,8 @@ def _write_settings(
existing: dict[str, object] | None, existing: dict[str, object] | None,
) -> None: ) -> None:
payload = dict(existing or {}) payload = dict(existing or {})
for name in _RETIRED_DISPLAY_SETTINGS:
payload.pop(name, None)
payload.update(asdict(settings)) payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
@@ -242,33 +244,13 @@ def _validate(settings: Settings) -> None:
or any(ord(character) < 32 for character in settings.realm) or any(ord(character) < 32 for character in settings.realm)
): ):
raise ValueError("realm must be a non-empty display name") raise ValueError("realm must be a non-empty display name")
for name, value in ( if settings.ui_scale not in UI_SCALE_CHOICES:
("width", settings.width), raise ValueError(f"unsupported launcher UI scale: {settings.ui_scale}")
("height", settings.height),
("output_width", settings.output_width),
("output_height", settings.output_height),
):
if value < 640 or value > 16384:
raise ValueError(f"{name} must be between 640 and 16384")
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
raise ValueError("refresh_rate must be between 24 and 1000")
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}")
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}: if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
raise ValueError(f"unsupported runtime selection: {settings.runtime}") raise ValueError(f"unsupported runtime selection: {settings.runtime}")
if settings.proton_sync not in {"auto", "fsync", "esync"}: if settings.proton_sync not in {"auto", "fsync", "esync"}:
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}") raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
def _integer(value: dict[str, object], key: str, default: int) -> int:
item = value.get(key, default)
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError(f"{key} must be an integer")
return item
def _boolean(value: dict[str, object], key: str, default: bool) -> bool: def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
item = value.get(key, default) item = value.get(key, default)
if not isinstance(item, bool): if not isinstance(item, bool):

View File

@@ -26,6 +26,7 @@ _DIALOG_PALETTES: dict[str, DialogPalette] = {
"error": DialogPalette("#cf604c", "#3a1d19", "×"), "error": DialogPalette("#cf604c", "#3a1d19", "×"),
"question": DialogPalette("#d98927", "#3a2819", "?"), "question": DialogPalette("#d98927", "#3a2819", "?"),
} }
_BASE_TK_SCALING = 96.0 / 72.0
def dialog_palette(kind: DialogKind) -> DialogPalette: def dialog_palette(kind: DialogKind) -> DialogPalette:
@@ -111,6 +112,16 @@ def dragged_window_geometry(
) )
def widget_ui_scale(widget: Any) -> float:
"""Return the launcher scale already configured on a Tk interpreter."""
try:
scaling = float(widget.tk.call("tk", "scaling"))
except (AttributeError, TypeError, ValueError):
return 1.0
return min(2.0, max(1.0, scaling / _BASE_TK_SCALING))
def _geometry_offset(value: int) -> str: def _geometry_offset(value: int) -> str:
return f"+{value}" if value >= 0 else str(value) return f"+{value}" if value >= 0 else str(value)
@@ -138,7 +149,7 @@ def _bind_window_drag(handle: Any, *widgets: Any) -> None:
class DashboardChrome: class DashboardChrome:
"""Controller for one frameless launcher window and its controls.""" """Controller for one launcher window and its custom controls."""
def __init__( def __init__(
self, self,
@@ -310,9 +321,10 @@ def create_window_chrome(
show_minimize: bool = False, show_minimize: bool = False,
allow_close: bool = True, allow_close: bool = True,
) -> DashboardChrome: ) -> DashboardChrome:
"""Install polished managed chrome around one launcher-owned Tk window.""" """Install the launcher's custom frame and window controls."""
import tkinter as tk import tkinter as tk
ui_scale = widget_ui_scale(root)
managed, identity_configured = _configure_x11_window( managed, identity_configured = _configure_x11_window(
root, root,
@@ -338,7 +350,7 @@ def create_window_chrome(
title_bar = tk.Frame( title_bar = tk.Frame(
surface, surface,
background="#17100d", background="#17100d",
height=32, height=round(32 * ui_scale),
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
cursor="fleur", cursor="fleur",
@@ -354,7 +366,7 @@ def create_window_chrome(
factor = icon_subsample_factor( factor = icon_subsample_factor(
window_icon.width(), window_icon.width(),
window_icon.height(), window_icon.height(),
20, round(20 * ui_scale),
) )
title_icon = window_icon.subsample(factor, factor) title_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError): except (tk.TclError, ValueError):
@@ -363,8 +375,8 @@ def create_window_chrome(
mark = tk.Label( mark = tk.Label(
title_bar, title_bar,
image=title_icon, image=title_icon,
width=22, width=round(22 * ui_scale),
height=22, height=round(22 * ui_scale),
background="#17100d", background="#17100d",
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
@@ -374,8 +386,8 @@ def create_window_chrome(
else: else:
mark = tk.Frame( mark = tk.Frame(
title_bar, title_bar,
width=22, width=round(22 * ui_scale),
height=22, height=round(22 * ui_scale),
background="#17100d", background="#17100d",
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
@@ -414,10 +426,12 @@ def create_window_chrome(
font=("Sans", 12), font=("Sans", 12),
width=4, width=4,
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=2,
highlightbackground="#17100d",
highlightcolor="#f4bd61",
relief="flat", relief="flat",
cursor="hand2", cursor="hand2",
takefocus=False, takefocus=True,
) )
close_button.pack(side="right", fill="y") close_button.pack(side="right", fill="y")
minimize_button: Any = None minimize_button: Any = None
@@ -432,10 +446,12 @@ def create_window_chrome(
font=("Sans", 10, "bold"), font=("Sans", 10, "bold"),
width=4, width=4,
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=2,
highlightbackground="#17100d",
highlightcolor="#f4bd61",
relief="flat", relief="flat",
cursor="hand2", cursor="hand2",
takefocus=False, takefocus=True,
) )
minimize_button.pack(side="right", fill="y") minimize_button.pack(side="right", fill="y")
content = tk.Frame( content = tk.Frame(
@@ -489,9 +505,10 @@ def show_themed_dialog(
cancel_text: str = "CANCEL", cancel_text: str = "CANCEL",
window_icon: Any | None = None, window_icon: Any | None = None,
) -> bool: ) -> bool:
"""Show one frameless modal dialog matching the native launcher palette.""" """Show one custom-framed modal dialog matching the launcher palette."""
import tkinter as tk import tkinter as tk
ui_scale = widget_ui_scale(parent)
palette = dialog_palette(kind) palette = dialog_palette(kind)
dialog = tk.Toplevel(parent) dialog = tk.Toplevel(parent)
@@ -524,7 +541,11 @@ def show_themed_dialog(
border.pack(fill="both", expand=True) border.pack(fill="both", expand=True)
surface = tk.Frame(border, background="#191311") surface = tk.Frame(border, background="#191311")
surface.pack(fill="both", expand=True, padx=1, pady=1) surface.pack(fill="both", expand=True, padx=1, pady=1)
header = tk.Frame(surface, background="#24170f", height=66) header = tk.Frame(
surface,
background="#24170f",
height=round(66 * ui_scale),
)
header.pack(fill="x") header.pack(fill="x")
header.pack_propagate(False) header.pack_propagate(False)
tk_accent = tk.Frame(header, background=palette.accent, width=5) tk_accent = tk.Frame(header, background=palette.accent, width=5)
@@ -560,21 +581,24 @@ def show_themed_dialog(
font=("Sans", 12), font=("Sans", 12),
width=4, width=4,
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=2,
highlightbackground="#24170f",
highlightcolor="#f4bd61",
relief="flat", relief="flat",
cursor="hand2", cursor="hand2",
takefocus=False, takefocus=True,
) )
close_button.pack(side="right", fill="y") close_button.pack(side="right", fill="y")
body = tk.Frame(surface, background="#191311", padx=20, pady=20) body = tk.Frame(surface, background="#191311", padx=20, pady=20)
body.pack(fill="both", expand=True) body.pack(fill="both", expand=True)
modal_icon_size = round(46 * ui_scale)
modal_icon: Any | None = None modal_icon: Any | None = None
if window_icon is not None: if window_icon is not None:
try: try:
factor = icon_subsample_factor( factor = icon_subsample_factor(
window_icon.width(), window_icon.width(),
window_icon.height(), window_icon.height(),
46, modal_icon_size,
) )
modal_icon = window_icon.subsample(factor, factor) modal_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError): except (tk.TclError, ValueError):
@@ -582,8 +606,8 @@ def show_themed_dialog(
if modal_icon is not None: if modal_icon is not None:
icon_host = tk.Frame( icon_host = tk.Frame(
body, body,
width=46, width=modal_icon_size,
height=46, height=modal_icon_size,
background="#191311", background="#191311",
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
@@ -601,24 +625,24 @@ def show_themed_dialog(
else: else:
icon = tk.Canvas( icon = tk.Canvas(
body, body,
width=46, width=modal_icon_size,
height=46, height=modal_icon_size,
background="#191311", background="#191311",
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
) )
icon.create_oval( icon.create_oval(
3, round(3 * ui_scale),
3, round(3 * ui_scale),
43, modal_icon_size - round(3 * ui_scale),
43, modal_icon_size - round(3 * ui_scale),
fill=palette.accent_soft, fill=palette.accent_soft,
outline=palette.accent, outline=palette.accent,
width=2, width=2,
) )
icon.create_text( icon.create_text(
23, modal_icon_size // 2,
23, modal_icon_size // 2,
text=palette.glyph, text=palette.glyph,
fill=palette.accent, fill=palette.accent,
font=("Sans", 17, "bold"), font=("Sans", 17, "bold"),
@@ -632,7 +656,7 @@ def show_themed_dialog(
font=("Sans", 10), font=("Sans", 10),
justify="left", justify="left",
anchor="w", anchor="w",
wraplength=420, wraplength=round(420 * ui_scale),
) )
message_label.pack(side="left", fill="both", expand=True, anchor="n") message_label.pack(side="left", fill="both", expand=True, anchor="n")
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12) footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
@@ -660,7 +684,9 @@ def show_themed_dialog(
padx=16, padx=16,
pady=7, pady=7,
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=2,
highlightbackground="#100d0c",
highlightcolor="#f4bd61",
relief="flat", relief="flat",
cursor="hand2", cursor="hand2",
) )
@@ -681,12 +707,16 @@ def show_themed_dialog(
) )
cancel_button.pack(side="right", padx=(0, 8)) cancel_button.pack(side="right", padx=(0, 8))
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label) _bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False))
dialog.bind("<Escape>", lambda _event: finish(False)) dialog.bind("<Escape>", lambda _event: finish(False))
dialog.bind("<Return>", lambda _event: finish(True)) dialog.bind("<Return>", lambda _event: finish(True))
parent.update_idletasks() parent.update_idletasks()
dialog.update_idletasks() dialog.update_idletasks()
width = 560 width = min(round(560 * ui_scale), max(1, dialog.winfo_screenwidth() - 64))
height = max(218, dialog.winfo_reqheight()) height = min(
max(round(218 * ui_scale), dialog.winfo_reqheight()),
max(1, dialog.winfo_screenheight() - 64),
)
dialog.geometry( dialog.geometry(
centered_window_geometry( centered_window_geometry(
width, width,

File diff suppressed because it is too large Load Diff