Added resolution settings and updater modal with rollback functionality for launcher versions.
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
975
ptlinux/ui.py
975
ptlinux/ui.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user