Added debugger class for dashboard and other launcher util classes.
This commit is contained in:
213
ptlinux/dashboard_debug.py
Normal file
213
ptlinux/dashboard_debug.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Read-only state snapshots for the launcher's hidden debug view."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .import_state import game_import_requires_verification
|
||||||
|
from .paths import AppPaths
|
||||||
|
from .runtime import (
|
||||||
|
RuntimeErrorPT,
|
||||||
|
RuntimeSelection,
|
||||||
|
resolve_proton_sync_mode,
|
||||||
|
select_runtime,
|
||||||
|
)
|
||||||
|
from .settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeDebugInfo:
|
||||||
|
"""Configured, selected, and last-prepared runtime state."""
|
||||||
|
|
||||||
|
requested_kind: str
|
||||||
|
selected_kind: str | None
|
||||||
|
executable: str | None
|
||||||
|
proton_path: str | None
|
||||||
|
prefix: str | None
|
||||||
|
sync_configured: str
|
||||||
|
sync_effective: str | None
|
||||||
|
prepared_kind: str | None
|
||||||
|
prepared_proton_path: str | None
|
||||||
|
prepared_at: str | None
|
||||||
|
marker_matches_selection: bool | None
|
||||||
|
dxvk_d3d9_override: str | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VerificationDebugInfo:
|
||||||
|
"""Cached trust evidence from completed depot and import operations."""
|
||||||
|
|
||||||
|
import_state: str
|
||||||
|
import_verified_at: str | None
|
||||||
|
import_build: int | None
|
||||||
|
last_sync_at: str | None
|
||||||
|
last_full_verification_at: str | None
|
||||||
|
build: int | None
|
||||||
|
version: str | None
|
||||||
|
mode: str | None
|
||||||
|
files_total: int | None
|
||||||
|
files_downloaded: int | None
|
||||||
|
files_deleted: int | None
|
||||||
|
files_created: int | None
|
||||||
|
files_quarantined: int | None
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeSelector = Callable[[Settings], RuntimeSelection]
|
||||||
|
SyncResolver = Callable[[str], str]
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_runtime_debug(
|
||||||
|
paths: AppPaths,
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
selector: RuntimeSelector = select_runtime,
|
||||||
|
sync_resolver: SyncResolver = resolve_proton_sync_mode,
|
||||||
|
) -> RuntimeDebugInfo:
|
||||||
|
"""Resolve the next runtime and compare it with the preparation marker."""
|
||||||
|
|
||||||
|
marker = _read_json_object(paths.game_runtime_marker)
|
||||||
|
prepared_runtime: dict[str, object] | None = None
|
||||||
|
if marker is not None:
|
||||||
|
raw_prepared_runtime = marker.get("runtime")
|
||||||
|
if isinstance(raw_prepared_runtime, dict):
|
||||||
|
prepared_runtime = raw_prepared_runtime
|
||||||
|
prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None
|
||||||
|
prepared_proton_path = (
|
||||||
|
_text(prepared_runtime.get("proton_path")) if prepared_runtime else None
|
||||||
|
)
|
||||||
|
prepared_at = _text(marker.get("configured_at")) if marker else None
|
||||||
|
dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None
|
||||||
|
selection: RuntimeSelection | None = None
|
||||||
|
error: str | None = None
|
||||||
|
try:
|
||||||
|
selection = selector(settings)
|
||||||
|
except (OSError, RuntimeErrorPT, ValueError) as exc:
|
||||||
|
error = str(exc).strip() or type(exc).__name__
|
||||||
|
selected_kind = selection.kind if selection is not None else None
|
||||||
|
prefix_kind = selected_kind or prepared_kind
|
||||||
|
prefix = None
|
||||||
|
if prefix_kind == "umu":
|
||||||
|
prefix = str(paths.umu_prefix)
|
||||||
|
elif prefix_kind in {"proton7", "proton7-container"}:
|
||||||
|
prefix = str(paths.proton_prefix)
|
||||||
|
sync_effective = None
|
||||||
|
if prefix_kind in {"proton7", "proton7-container"}:
|
||||||
|
try:
|
||||||
|
sync_effective = sync_resolver(settings.proton_sync)
|
||||||
|
except ValueError as exc:
|
||||||
|
error = error or (str(exc).strip() or type(exc).__name__)
|
||||||
|
marker_matches = (
|
||||||
|
prepared_runtime == asdict(selection)
|
||||||
|
if prepared_runtime is not None and selection is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return RuntimeDebugInfo(
|
||||||
|
requested_kind=settings.runtime,
|
||||||
|
selected_kind=selected_kind,
|
||||||
|
executable=selection.executable if selection is not None else None,
|
||||||
|
proton_path=selection.proton_path if selection is not None else None,
|
||||||
|
prefix=prefix,
|
||||||
|
sync_configured=settings.proton_sync,
|
||||||
|
sync_effective=sync_effective,
|
||||||
|
prepared_kind=prepared_kind,
|
||||||
|
prepared_proton_path=prepared_proton_path,
|
||||||
|
prepared_at=prepared_at,
|
||||||
|
marker_matches_selection=marker_matches,
|
||||||
|
dxvk_d3d9_override=dxvk_override,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo:
|
||||||
|
"""Read cached verification state without hashing or changing game files."""
|
||||||
|
|
||||||
|
depot = _read_json_object(paths.native_depot_state)
|
||||||
|
if depot is not None and depot.get("dry_run") is not False:
|
||||||
|
depot = None
|
||||||
|
import_manifest_exists = paths.game_import_manifest.exists()
|
||||||
|
import_manifest = _read_json_object(paths.game_import_manifest)
|
||||||
|
if not import_manifest_exists:
|
||||||
|
import_state = "not applicable"
|
||||||
|
elif game_import_requires_verification(paths):
|
||||||
|
import_state = "pending"
|
||||||
|
else:
|
||||||
|
import_state = "verified"
|
||||||
|
import_verified_at = (
|
||||||
|
_text(import_manifest.get("verification_completed_at"))
|
||||||
|
if import_state == "verified" and import_manifest is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
import_build = (
|
||||||
|
_non_negative_int(import_manifest.get("verification_build"))
|
||||||
|
if import_state == "verified" and import_manifest is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
last_sync_at = _text(depot.get("completed_at")) if depot else None
|
||||||
|
full_verifications = [
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
last_sync_at if depot is not None and depot.get("repair") is True else None,
|
||||||
|
import_verified_at,
|
||||||
|
)
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
last_full_verification_at = (
|
||||||
|
max(full_verifications, key=_iso_timestamp)
|
||||||
|
if full_verifications
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return VerificationDebugInfo(
|
||||||
|
import_state=import_state,
|
||||||
|
import_verified_at=import_verified_at,
|
||||||
|
import_build=import_build,
|
||||||
|
last_sync_at=last_sync_at,
|
||||||
|
last_full_verification_at=last_full_verification_at,
|
||||||
|
build=_non_negative_int(depot.get("build")) if depot else None,
|
||||||
|
version=_text(depot.get("version")) if depot else None,
|
||||||
|
mode=(
|
||||||
|
"full repair"
|
||||||
|
if depot is not None and depot.get("repair") is True
|
||||||
|
else "update"
|
||||||
|
if depot is not None and depot.get("repair") is False
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
files_total=_non_negative_int(depot.get("files_total")) if depot else None,
|
||||||
|
files_downloaded=(
|
||||||
|
_non_negative_int(depot.get("files_downloaded")) if depot else None
|
||||||
|
),
|
||||||
|
files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None,
|
||||||
|
files_created=_non_negative_int(depot.get("files_created")) if depot else None,
|
||||||
|
files_quarantined=(
|
||||||
|
_non_negative_int(depot.get("files_quarantined")) if depot else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_object(path: Path) -> dict[str, object] | None:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object) -> str | None:
|
||||||
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_int(value: object) -> int | None:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_timestamp(value: str) -> float:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
return float("-inf")
|
||||||
59
ptlinux/desktop_identity.py
Normal file
59
ptlinux/desktop_identity.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Stable desktop identity shared by the launcher and Wine game window."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
APPLICATION_NAME = "PristonTale EU"
|
||||||
|
APPLICATION_WM_CLASS = "PristonTaleEU"
|
||||||
|
APPLICATION_WM_INSTANCE = "pristontale-eu"
|
||||||
|
APPLICATION_ICON_FILENAME = "eu.pristontale.PTLinux.png"
|
||||||
|
APPLICATION_ICON_VARIABLE = "PTLINUX_ICON_PATH"
|
||||||
|
|
||||||
|
|
||||||
|
def find_application_icon(
|
||||||
|
environment: Mapping[str, str] | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Find the installed icon, with source-tree fallbacks for development."""
|
||||||
|
|
||||||
|
env = os.environ if environment is None else environment
|
||||||
|
candidates: list[Path] = []
|
||||||
|
explicit = env.get(APPLICATION_ICON_VARIABLE)
|
||||||
|
if explicit:
|
||||||
|
candidates.append(Path(explicit).expanduser())
|
||||||
|
|
||||||
|
# Some distributions bundle the icon beside this module. The installer
|
||||||
|
# publishes the same asset through the user's XDG data directory.
|
||||||
|
candidates.append(Path(__file__).with_name(APPLICATION_ICON_FILENAME))
|
||||||
|
home_value = env.get("HOME")
|
||||||
|
if home_value:
|
||||||
|
home = Path(home_value).expanduser()
|
||||||
|
data_home_value = env.get("XDG_DATA_HOME")
|
||||||
|
data_home = (
|
||||||
|
Path(data_home_value).expanduser()
|
||||||
|
if data_home_value
|
||||||
|
else home / ".local/share"
|
||||||
|
)
|
||||||
|
candidates.append(data_home / "pixmaps" / APPLICATION_ICON_FILENAME)
|
||||||
|
default_icon = home / ".local/share/pixmaps" / APPLICATION_ICON_FILENAME
|
||||||
|
if default_icon not in candidates:
|
||||||
|
candidates.append(default_icon)
|
||||||
|
|
||||||
|
# The source checkout keeps installer assets under packaging/.
|
||||||
|
candidates.append(
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "packaging"
|
||||||
|
/ APPLICATION_ICON_FILENAME
|
||||||
|
)
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
resolved = candidate.resolve(strict=False)
|
||||||
|
if resolved in seen:
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
if resolved.is_file():
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
157
ptlinux/server_status.py
Normal file
157
ptlinux/server_status.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Official realm health used by the native launcher dashboard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from typing import Callable, Mapping, Protocol
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
|
||||||
|
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
|
||||||
|
SERVER_STATUS_ENDPOINT = (
|
||||||
|
"https://pristontale.eu/api/pristontale/developer"
|
||||||
|
"?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0"
|
||||||
|
)
|
||||||
|
MAX_SERVER_STATUS_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ServerStatusError(RuntimeError):
|
||||||
|
"""Raised when the official status response cannot be interpreted safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class ServerStatusResponse(Protocol):
|
||||||
|
def read(self, size: int = -1) -> bytes: ...
|
||||||
|
|
||||||
|
def __enter__(self) -> ServerStatusResponse: ...
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exception_type: type[BaseException] | None,
|
||||||
|
exception: BaseException | None,
|
||||||
|
traceback: object | None,
|
||||||
|
) -> bool | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
ServerStatusOpener = Callable[..., ServerStatusResponse]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RealmServerStatus:
|
||||||
|
name: str
|
||||||
|
online: bool | None
|
||||||
|
players_online: int | None
|
||||||
|
login_online: bool | None = None
|
||||||
|
game_online: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_server_status(
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 10.0,
|
||||||
|
opener: ServerStatusOpener = urlopen,
|
||||||
|
) -> tuple[RealmServerStatus, ...]:
|
||||||
|
request = Request(
|
||||||
|
SERVER_STATUS_ENDPOINT,
|
||||||
|
headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = opener(request, timeout=timeout_seconds)
|
||||||
|
with response:
|
||||||
|
payload = response.read(MAX_SERVER_STATUS_BYTES + 1)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ServerStatusError("official server status is unavailable") from exc
|
||||||
|
if len(payload) > MAX_SERVER_STATUS_BYTES:
|
||||||
|
raise ServerStatusError("official server status exceeds the 1 MiB limit")
|
||||||
|
return parse_server_status(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]:
|
||||||
|
try:
|
||||||
|
value = json.loads(payload.decode("utf-8-sig"))
|
||||||
|
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ServerStatusError("official server status is not valid JSON") from exc
|
||||||
|
# Historical versions returned an array containing the status object. The
|
||||||
|
# current endpoint returns that object directly.
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
value = value[0]
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ServerStatusError("official server status is not a realm object")
|
||||||
|
|
||||||
|
statuses: list[RealmServerStatus] = []
|
||||||
|
for raw_name, raw_components in value.items():
|
||||||
|
if not isinstance(raw_name, str) or raw_name.casefold() == "website":
|
||||||
|
continue
|
||||||
|
name = raw_name.strip()
|
||||||
|
if not name or len(name) > 64 or not isinstance(raw_components, Mapping):
|
||||||
|
continue
|
||||||
|
status = _parse_realm(name, raw_components)
|
||||||
|
if status is not None:
|
||||||
|
statuses.append(status)
|
||||||
|
if not statuses:
|
||||||
|
raise ServerStatusError("official server status contains no realms")
|
||||||
|
return tuple(statuses)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_realm(
|
||||||
|
name: str,
|
||||||
|
components: Mapping[object, object],
|
||||||
|
) -> RealmServerStatus | None:
|
||||||
|
service_states: list[bool] = []
|
||||||
|
login_online: bool | None = None
|
||||||
|
game_online: bool | None = None
|
||||||
|
players_online: int | None = None
|
||||||
|
for raw_key, raw_value in components.items():
|
||||||
|
if not isinstance(raw_key, str):
|
||||||
|
continue
|
||||||
|
key = raw_key.casefold()
|
||||||
|
if key == "users.online":
|
||||||
|
players_online = _non_negative_integer(raw_value)
|
||||||
|
continue
|
||||||
|
if not key.startswith("server."):
|
||||||
|
continue
|
||||||
|
state = _online_state(raw_value)
|
||||||
|
if state is None:
|
||||||
|
continue
|
||||||
|
service_states.append(state)
|
||||||
|
if "login" in key:
|
||||||
|
login_online = state
|
||||||
|
elif "game" in key:
|
||||||
|
game_online = state
|
||||||
|
if not service_states and players_online is None:
|
||||||
|
return None
|
||||||
|
online = all(service_states) if service_states else None
|
||||||
|
display_name = name[0].upper() + name[1:]
|
||||||
|
return RealmServerStatus(
|
||||||
|
name=display_name,
|
||||||
|
online=online,
|
||||||
|
players_online=players_online,
|
||||||
|
login_online=login_online,
|
||||||
|
game_online=game_online,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _online_state(value: object) -> bool | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value > 0
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip().casefold()
|
||||||
|
if text in {"online", "up", "true", "1"}:
|
||||||
|
return True
|
||||||
|
if text in {"offline", "down", "false", "0"}:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_integer(value: object) -> int | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int) and value >= 0:
|
||||||
|
return value
|
||||||
|
if isinstance(value, str) and value.isascii() and value.isdecimal():
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
680
ptlinux/tk_chrome.py
Normal file
680
ptlinux/tk_chrome.py
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Literal
|
||||||
|
|
||||||
|
from .desktop_identity import APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE
|
||||||
|
|
||||||
|
|
||||||
|
DialogKind = Literal["info", "success", "warning", "error", "question"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DialogPalette:
|
||||||
|
"""Colors and glyph used by one launcher dialog intent."""
|
||||||
|
|
||||||
|
accent: str
|
||||||
|
accent_soft: str
|
||||||
|
glyph: str
|
||||||
|
|
||||||
|
|
||||||
|
_DIALOG_PALETTES: dict[str, DialogPalette] = {
|
||||||
|
"info": DialogPalette("#d98927", "#3a2819", "i"),
|
||||||
|
"success": DialogPalette("#55b878", "#173122", "✓"),
|
||||||
|
"warning": DialogPalette("#e0a248", "#3b2a18", "!"),
|
||||||
|
"error": DialogPalette("#cf604c", "#3a1d19", "×"),
|
||||||
|
"question": DialogPalette("#d98927", "#3a2819", "?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_palette(kind: DialogKind) -> DialogPalette:
|
||||||
|
"""Return the stable visual palette for a themed launcher dialog."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _DIALOG_PALETTES[kind]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"unsupported launcher dialog kind: {kind}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def square_icon_layout(width: int, height: int) -> tuple[int, int, int]:
|
||||||
|
"""Return square canvas size and centered offsets for a window icon."""
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("icon dimensions must be positive")
|
||||||
|
size = max(width, height)
|
||||||
|
return size, (size - width) // 2, (size - height) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def icon_subsample_factor(width: int, height: int, max_dimension: int) -> int:
|
||||||
|
"""Return the integer Tk subsample needed to fit an image within a square."""
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("icon dimensions must be positive")
|
||||||
|
if max_dimension <= 0:
|
||||||
|
raise ValueError("maximum icon dimension must be positive")
|
||||||
|
largest_dimension = max(width, height)
|
||||||
|
return max(1, (largest_dimension + max_dimension - 1) // max_dimension)
|
||||||
|
|
||||||
|
|
||||||
|
def load_square_window_icon(master: Any, path: Path) -> Any:
|
||||||
|
"""Load a PNG into a square transparent Tk image for desktop shells."""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
source = tk.PhotoImage(master=master, file=str(path))
|
||||||
|
size, offset_x, offset_y = square_icon_layout(source.width(), source.height())
|
||||||
|
if source.width() == source.height():
|
||||||
|
return source
|
||||||
|
icon = tk.PhotoImage(master=master, width=size, height=size)
|
||||||
|
icon.tk.call(
|
||||||
|
str(icon),
|
||||||
|
"copy",
|
||||||
|
str(source),
|
||||||
|
"-to",
|
||||||
|
offset_x,
|
||||||
|
offset_y,
|
||||||
|
)
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
|
def centered_window_geometry(
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
*,
|
||||||
|
parent_x: int,
|
||||||
|
parent_y: int,
|
||||||
|
parent_width: int,
|
||||||
|
parent_height: int,
|
||||||
|
) -> str:
|
||||||
|
"""Return Tk geometry centered over a parent window."""
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("window dimensions must be positive")
|
||||||
|
x = parent_x + (max(1, parent_width) - width) // 2
|
||||||
|
y = parent_y + (max(1, parent_height) - height) // 2
|
||||||
|
return f"{width}x{height}{_geometry_offset(x)}{_geometry_offset(y)}"
|
||||||
|
|
||||||
|
|
||||||
|
def dragged_window_geometry(
|
||||||
|
pointer_x: int,
|
||||||
|
pointer_y: int,
|
||||||
|
*,
|
||||||
|
offset_x: int,
|
||||||
|
offset_y: int,
|
||||||
|
) -> str:
|
||||||
|
"""Return the Tk position for a pointer-driven custom title bar."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{_geometry_offset(pointer_x - offset_x)}"
|
||||||
|
f"{_geometry_offset(pointer_y - offset_y)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _geometry_offset(value: int) -> str:
|
||||||
|
return f"+{value}" if value >= 0 else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_window_drag(handle: Any, *widgets: Any) -> None:
|
||||||
|
position = [0, 0]
|
||||||
|
|
||||||
|
def begin(event: Any) -> None:
|
||||||
|
position[0] = int(event.x_root) - int(handle.winfo_x())
|
||||||
|
position[1] = int(event.y_root) - int(handle.winfo_y())
|
||||||
|
|
||||||
|
def move(event: Any) -> None:
|
||||||
|
handle.geometry(
|
||||||
|
dragged_window_geometry(
|
||||||
|
int(event.x_root),
|
||||||
|
int(event.y_root),
|
||||||
|
offset_x=position[0],
|
||||||
|
offset_y=position[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for widget in widgets:
|
||||||
|
widget.bind("<ButtonPress-1>", begin, add="+")
|
||||||
|
widget.bind("<B1-Motion>", move, add="+")
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardChrome:
|
||||||
|
"""Controller for one frameless launcher window and its controls."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: Any,
|
||||||
|
content: Any,
|
||||||
|
close_button: Any,
|
||||||
|
minimize_button: Any,
|
||||||
|
*,
|
||||||
|
managed: bool,
|
||||||
|
identity_configured: bool,
|
||||||
|
allow_close: bool,
|
||||||
|
) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.content = content
|
||||||
|
self.managed = managed
|
||||||
|
self.identity_configured = identity_configured
|
||||||
|
self._close_button = close_button
|
||||||
|
self._minimize_button = minimize_button
|
||||||
|
self._allow_close = allow_close
|
||||||
|
self._close_command: Callable[[], None] = root.destroy
|
||||||
|
if close_button is not None:
|
||||||
|
close_button.configure(command=self.close)
|
||||||
|
if minimize_button is not None:
|
||||||
|
minimize_button.configure(command=self.minimize)
|
||||||
|
root.bind("<Alt-F4>", self._close_from_event, add="+")
|
||||||
|
|
||||||
|
def set_close_command(self, command: Callable[[], None]) -> None:
|
||||||
|
self._close_command = command
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._allow_close:
|
||||||
|
self._close_command()
|
||||||
|
|
||||||
|
def minimize(self) -> None:
|
||||||
|
"""Ask the window manager to iconify the managed dashboard."""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.root.iconify()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _close_from_event(self, _event: Any) -> str:
|
||||||
|
self.close()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_x11_window(
|
||||||
|
root: Any,
|
||||||
|
*,
|
||||||
|
resource_name: str,
|
||||||
|
resource_class: str,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
"""Hide the X11 frame and give desktop shells a stable window identity."""
|
||||||
|
|
||||||
|
import ctypes.util
|
||||||
|
|
||||||
|
library_path = ctypes.util.find_library("X11")
|
||||||
|
if library_path is None:
|
||||||
|
return False, False
|
||||||
|
try:
|
||||||
|
x11 = ctypes.CDLL(library_path)
|
||||||
|
except OSError:
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
class XClassHint(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("res_name", ctypes.c_char_p),
|
||||||
|
("res_class", ctypes.c_char_p),
|
||||||
|
]
|
||||||
|
|
||||||
|
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||||
|
x11.XOpenDisplay.restype = ctypes.c_void_p
|
||||||
|
x11.XQueryTree.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.POINTER(ctypes.c_ulong),
|
||||||
|
ctypes.POINTER(ctypes.c_ulong),
|
||||||
|
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||||
|
ctypes.POINTER(ctypes.c_uint),
|
||||||
|
]
|
||||||
|
x11.XQueryTree.restype = ctypes.c_int
|
||||||
|
x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
|
||||||
|
x11.XInternAtom.restype = ctypes.c_ulong
|
||||||
|
x11.XChangeProperty.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.c_int,
|
||||||
|
ctypes.c_int,
|
||||||
|
ctypes.POINTER(ctypes.c_ubyte),
|
||||||
|
ctypes.c_int,
|
||||||
|
]
|
||||||
|
x11.XChangeProperty.restype = ctypes.c_int
|
||||||
|
x11.XSetClassHint.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.POINTER(XClassHint),
|
||||||
|
]
|
||||||
|
x11.XSetClassHint.restype = ctypes.c_int
|
||||||
|
x11.XFlush.argtypes = [ctypes.c_void_p]
|
||||||
|
x11.XFlush.restype = ctypes.c_int
|
||||||
|
x11.XFree.argtypes = [ctypes.c_void_p]
|
||||||
|
x11.XFree.restype = ctypes.c_int
|
||||||
|
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
|
||||||
|
x11.XCloseDisplay.restype = ctypes.c_int
|
||||||
|
root.update_idletasks()
|
||||||
|
display = x11.XOpenDisplay(None)
|
||||||
|
if display is None:
|
||||||
|
return False, False
|
||||||
|
children = ctypes.POINTER(ctypes.c_ulong)()
|
||||||
|
try:
|
||||||
|
root_window = ctypes.c_ulong()
|
||||||
|
wrapper_window = ctypes.c_ulong()
|
||||||
|
child_count = ctypes.c_uint()
|
||||||
|
queried = x11.XQueryTree(
|
||||||
|
display,
|
||||||
|
root.winfo_id(),
|
||||||
|
ctypes.byref(root_window),
|
||||||
|
ctypes.byref(wrapper_window),
|
||||||
|
ctypes.byref(children),
|
||||||
|
ctypes.byref(child_count),
|
||||||
|
)
|
||||||
|
if not queried or not wrapper_window.value:
|
||||||
|
return False, False
|
||||||
|
atom = x11.XInternAtom(display, b"_MOTIF_WM_HINTS", 0)
|
||||||
|
if not atom:
|
||||||
|
return False, False
|
||||||
|
motif_hints_decorations = 1 << 1
|
||||||
|
hints = (ctypes.c_ulong * 5)(motif_hints_decorations, 0, 0, 0, 0)
|
||||||
|
changed = x11.XChangeProperty(
|
||||||
|
display,
|
||||||
|
wrapper_window.value,
|
||||||
|
atom,
|
||||||
|
atom,
|
||||||
|
32,
|
||||||
|
0,
|
||||||
|
ctypes.cast(hints, ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
len(hints),
|
||||||
|
)
|
||||||
|
identity = XClassHint(
|
||||||
|
res_name=resource_name.encode("utf-8"),
|
||||||
|
res_class=resource_class.encode("utf-8"),
|
||||||
|
)
|
||||||
|
identity_changed = x11.XSetClassHint(
|
||||||
|
display,
|
||||||
|
wrapper_window.value,
|
||||||
|
ctypes.byref(identity),
|
||||||
|
)
|
||||||
|
x11.XFlush(display)
|
||||||
|
return bool(changed), bool(identity_changed)
|
||||||
|
finally:
|
||||||
|
if children:
|
||||||
|
x11.XFree(children)
|
||||||
|
x11.XCloseDisplay(display)
|
||||||
|
|
||||||
|
|
||||||
|
def create_window_chrome(
|
||||||
|
root: Any,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
subtitle: str,
|
||||||
|
show_title_bar: bool = True,
|
||||||
|
show_close: bool = True,
|
||||||
|
show_minimize: bool = False,
|
||||||
|
allow_close: bool = True,
|
||||||
|
) -> DashboardChrome:
|
||||||
|
"""Install polished managed chrome around one launcher-owned Tk window."""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
managed, identity_configured = _configure_x11_window(
|
||||||
|
root,
|
||||||
|
resource_name=APPLICATION_WM_INSTANCE,
|
||||||
|
resource_class=APPLICATION_WM_CLASS,
|
||||||
|
)
|
||||||
|
if not managed:
|
||||||
|
root.overrideredirect(True)
|
||||||
|
border = tk.Frame(
|
||||||
|
root,
|
||||||
|
background="#6a482d",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
border.pack(fill="both", expand=True)
|
||||||
|
surface = tk.Frame(
|
||||||
|
border,
|
||||||
|
background="#0c0908",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||||
|
title_bar = tk.Frame(
|
||||||
|
surface,
|
||||||
|
background="#17100d",
|
||||||
|
height=32,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
cursor="fleur",
|
||||||
|
)
|
||||||
|
if show_title_bar:
|
||||||
|
title_bar.pack(fill="x")
|
||||||
|
title_bar.pack_propagate(False)
|
||||||
|
tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
|
||||||
|
tk_accent.pack(side="left", fill="y")
|
||||||
|
mark = tk.Canvas(
|
||||||
|
title_bar,
|
||||||
|
width=22,
|
||||||
|
height=22,
|
||||||
|
background="#17100d",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
cursor="fleur",
|
||||||
|
)
|
||||||
|
mark.create_oval(3, 3, 19, 19, outline="#d98927", width=1)
|
||||||
|
mark.create_text(11, 11, text="PT", fill="#f5d18b", font=("Georgia", 6, "bold"))
|
||||||
|
mark.pack(side="left", padx=(9, 7))
|
||||||
|
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
|
||||||
|
identity.pack(side="left", fill="y")
|
||||||
|
title_label = tk.Label(
|
||||||
|
identity,
|
||||||
|
text=title,
|
||||||
|
background="#17100d",
|
||||||
|
foreground="#f2dfbd",
|
||||||
|
font=("Sans", 8, "bold"),
|
||||||
|
cursor="fleur",
|
||||||
|
)
|
||||||
|
title_label.pack(side="left", pady=(0, 1))
|
||||||
|
subtitle_label = tk.Label(
|
||||||
|
identity,
|
||||||
|
text=f" · {subtitle}",
|
||||||
|
background="#17100d",
|
||||||
|
foreground="#8f7b69",
|
||||||
|
font=("Sans", 7),
|
||||||
|
cursor="fleur",
|
||||||
|
)
|
||||||
|
subtitle_label.pack(side="left", pady=(0, 1))
|
||||||
|
close_button: Any = None
|
||||||
|
if show_close:
|
||||||
|
close_button = tk.Button(
|
||||||
|
title_bar,
|
||||||
|
text="×",
|
||||||
|
background="#17100d",
|
||||||
|
foreground="#c9b7a4",
|
||||||
|
activebackground="#a63f2f",
|
||||||
|
activeforeground="#ffffff",
|
||||||
|
font=("Sans", 12),
|
||||||
|
width=4,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
relief="flat",
|
||||||
|
cursor="hand2",
|
||||||
|
takefocus=False,
|
||||||
|
)
|
||||||
|
close_button.pack(side="right", fill="y")
|
||||||
|
minimize_button: Any = None
|
||||||
|
if show_minimize:
|
||||||
|
minimize_button = tk.Button(
|
||||||
|
title_bar,
|
||||||
|
text="—",
|
||||||
|
background="#17100d",
|
||||||
|
foreground="#c9b7a4",
|
||||||
|
activebackground="#312219",
|
||||||
|
activeforeground="#ffffff",
|
||||||
|
font=("Sans", 10, "bold"),
|
||||||
|
width=4,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
relief="flat",
|
||||||
|
cursor="hand2",
|
||||||
|
takefocus=False,
|
||||||
|
)
|
||||||
|
minimize_button.pack(side="right", fill="y")
|
||||||
|
content = tk.Frame(
|
||||||
|
surface,
|
||||||
|
background="#0c0908",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
content.pack(fill="both", expand=True)
|
||||||
|
if show_title_bar:
|
||||||
|
_bind_window_drag(root, title_bar, mark, identity, title_label, subtitle_label)
|
||||||
|
return DashboardChrome(
|
||||||
|
root,
|
||||||
|
content,
|
||||||
|
close_button,
|
||||||
|
minimize_button,
|
||||||
|
managed=managed,
|
||||||
|
identity_configured=identity_configured,
|
||||||
|
allow_close=allow_close,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard_chrome(
|
||||||
|
root: Any,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
subtitle: str,
|
||||||
|
) -> DashboardChrome:
|
||||||
|
"""Install the draggable frame and full controls used by the dashboard."""
|
||||||
|
|
||||||
|
return create_window_chrome(
|
||||||
|
root,
|
||||||
|
title=title,
|
||||||
|
subtitle=subtitle,
|
||||||
|
show_close=True,
|
||||||
|
show_minimize=True,
|
||||||
|
allow_close=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_themed_dialog(
|
||||||
|
parent: Any,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
kind: DialogKind = "info",
|
||||||
|
confirm: bool = False,
|
||||||
|
accept_text: str = "OK",
|
||||||
|
cancel_text: str = "CANCEL",
|
||||||
|
window_icon: Any | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Show one frameless modal dialog matching the native launcher palette."""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
palette = dialog_palette(kind)
|
||||||
|
dialog = tk.Toplevel(parent)
|
||||||
|
dialog.withdraw()
|
||||||
|
dialog.title(title)
|
||||||
|
dialog.configure(background=palette.accent)
|
||||||
|
dialog.resizable(False, False)
|
||||||
|
dialog.overrideredirect(True)
|
||||||
|
if window_icon is not None:
|
||||||
|
try:
|
||||||
|
dialog.iconphoto(False, window_icon)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
dialog.transient(parent)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
result = False
|
||||||
|
|
||||||
|
def finish(value: bool) -> None:
|
||||||
|
nonlocal result
|
||||||
|
result = value
|
||||||
|
try:
|
||||||
|
dialog.grab_release()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
border = tk.Frame(dialog, background=palette.accent)
|
||||||
|
border.pack(fill="both", expand=True)
|
||||||
|
surface = tk.Frame(border, background="#191311")
|
||||||
|
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||||
|
header = tk.Frame(surface, background="#24170f", height=66)
|
||||||
|
header.pack(fill="x")
|
||||||
|
header.pack_propagate(False)
|
||||||
|
tk_accent = tk.Frame(header, background=palette.accent, width=5)
|
||||||
|
tk_accent.pack(side="left", fill="y")
|
||||||
|
header_copy = tk.Frame(header, background="#24170f")
|
||||||
|
header_copy.pack(side="left", fill="both", expand=True, padx=16, pady=10)
|
||||||
|
eyebrow = tk.Label(
|
||||||
|
header_copy,
|
||||||
|
text="PRISTONTALE EU · LINUX LAUNCHER",
|
||||||
|
background="#24170f",
|
||||||
|
foreground=palette.accent,
|
||||||
|
font=("Sans", 7, "bold"),
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
eyebrow.pack(fill="x")
|
||||||
|
title_label = tk.Label(
|
||||||
|
header_copy,
|
||||||
|
text=title,
|
||||||
|
background="#24170f",
|
||||||
|
foreground="#fff0d2",
|
||||||
|
font=("Georgia", 15, "bold"),
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
title_label.pack(fill="x", pady=(1, 0))
|
||||||
|
close_button = tk.Button(
|
||||||
|
header,
|
||||||
|
text="×",
|
||||||
|
command=lambda: finish(False),
|
||||||
|
background="#24170f",
|
||||||
|
foreground="#c9b7a4",
|
||||||
|
activebackground="#a63f2f",
|
||||||
|
activeforeground="#ffffff",
|
||||||
|
font=("Sans", 12),
|
||||||
|
width=4,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
relief="flat",
|
||||||
|
cursor="hand2",
|
||||||
|
takefocus=False,
|
||||||
|
)
|
||||||
|
close_button.pack(side="right", fill="y")
|
||||||
|
body = tk.Frame(surface, background="#191311", padx=20, pady=20)
|
||||||
|
body.pack(fill="both", expand=True)
|
||||||
|
modal_icon: Any | None = None
|
||||||
|
if window_icon is not None:
|
||||||
|
try:
|
||||||
|
factor = icon_subsample_factor(
|
||||||
|
window_icon.width(),
|
||||||
|
window_icon.height(),
|
||||||
|
46,
|
||||||
|
)
|
||||||
|
modal_icon = window_icon.subsample(factor, factor)
|
||||||
|
except (tk.TclError, ValueError):
|
||||||
|
modal_icon = None
|
||||||
|
if modal_icon is not None:
|
||||||
|
icon_host = tk.Frame(
|
||||||
|
body,
|
||||||
|
width=46,
|
||||||
|
height=46,
|
||||||
|
background="#191311",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
icon_host.pack(side="left", anchor="n", padx=(0, 16))
|
||||||
|
icon_host.pack_propagate(False)
|
||||||
|
icon = tk.Label(
|
||||||
|
icon_host,
|
||||||
|
image=modal_icon,
|
||||||
|
background="#191311",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
icon.place(relx=0.5, rely=0.5, anchor="center")
|
||||||
|
else:
|
||||||
|
icon = tk.Canvas(
|
||||||
|
body,
|
||||||
|
width=46,
|
||||||
|
height=46,
|
||||||
|
background="#191311",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
icon.create_oval(
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
43,
|
||||||
|
43,
|
||||||
|
fill=palette.accent_soft,
|
||||||
|
outline=palette.accent,
|
||||||
|
width=2,
|
||||||
|
)
|
||||||
|
icon.create_text(
|
||||||
|
23,
|
||||||
|
23,
|
||||||
|
text=palette.glyph,
|
||||||
|
fill=palette.accent,
|
||||||
|
font=("Sans", 17, "bold"),
|
||||||
|
)
|
||||||
|
icon.pack(side="left", anchor="n", padx=(0, 16))
|
||||||
|
message_label = tk.Label(
|
||||||
|
body,
|
||||||
|
text=message,
|
||||||
|
background="#191311",
|
||||||
|
foreground="#ded1c4",
|
||||||
|
font=("Sans", 10),
|
||||||
|
justify="left",
|
||||||
|
anchor="w",
|
||||||
|
wraplength=420,
|
||||||
|
)
|
||||||
|
message_label.pack(side="left", fill="both", expand=True, anchor="n")
|
||||||
|
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
|
||||||
|
footer.pack(fill="x")
|
||||||
|
|
||||||
|
def button(
|
||||||
|
container: Any,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
command: Callable[[], None],
|
||||||
|
primary: bool,
|
||||||
|
) -> Any:
|
||||||
|
background = palette.accent if primary else "#2a201c"
|
||||||
|
foreground = "#17100d" if primary else "#ead8c2"
|
||||||
|
active_background = "#efae57" if primary else "#3a2b25"
|
||||||
|
return tk.Button(
|
||||||
|
container,
|
||||||
|
text=label,
|
||||||
|
command=command,
|
||||||
|
background=background,
|
||||||
|
foreground=foreground,
|
||||||
|
activebackground=active_background,
|
||||||
|
activeforeground="#ffffff" if not primary else "#17100d",
|
||||||
|
font=("Sans", 8, "bold"),
|
||||||
|
padx=16,
|
||||||
|
pady=7,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
relief="flat",
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
|
||||||
|
accept_button = button(
|
||||||
|
footer,
|
||||||
|
label=accept_text,
|
||||||
|
command=lambda: finish(True),
|
||||||
|
primary=True,
|
||||||
|
)
|
||||||
|
accept_button.pack(side="right")
|
||||||
|
if confirm:
|
||||||
|
cancel_button = button(
|
||||||
|
footer,
|
||||||
|
label=cancel_text,
|
||||||
|
command=lambda: finish(False),
|
||||||
|
primary=False,
|
||||||
|
)
|
||||||
|
cancel_button.pack(side="right", padx=(0, 8))
|
||||||
|
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
|
||||||
|
dialog.bind("<Escape>", lambda _event: finish(False))
|
||||||
|
dialog.bind("<Return>", lambda _event: finish(True))
|
||||||
|
parent.update_idletasks()
|
||||||
|
dialog.update_idletasks()
|
||||||
|
width = 560
|
||||||
|
height = max(218, dialog.winfo_reqheight())
|
||||||
|
dialog.geometry(
|
||||||
|
centered_window_geometry(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
parent_x=parent.winfo_rootx(),
|
||||||
|
parent_y=parent.winfo_rooty(),
|
||||||
|
parent_width=parent.winfo_width(),
|
||||||
|
parent_height=parent.winfo_height(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dialog.deiconify()
|
||||||
|
dialog.lift()
|
||||||
|
dialog.grab_set()
|
||||||
|
accept_button.focus_set()
|
||||||
|
parent.wait_window(dialog)
|
||||||
|
return result
|
||||||
Reference in New Issue
Block a user