Files
pristontale-eu-linux/ptlinux/tk_chrome.py

681 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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