mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 06:10:58 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
leetcode-guard's study mode has to stop the re-assertion loop and the output-change watcher before it releases the grab, and could only reach them as LockWindow._recovery / _detector -- attributes 0.8.0 folded into the arming bundle, which broke that documented private dependency. They are public read-only properties now, next to `surfaces`. _window no longer re-exports the LockConfig family; it was the one thing keeping it over the 250-line cap, and every importer now takes those names from gatelock._config (or the package) directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Composite widgets every gate app was rebuilding from the same tokens.
|
|
|
|
``LockConfig`` already owns the palette and the type scale, but a palette is
|
|
not a component: four apps each turned the same tokens into the same button,
|
|
the same section heading and the same body row, and drifted apart doing it.
|
|
This module is the shared result, extracted only where two or more repos
|
|
already had a structurally similar implementation.
|
|
|
|
The two traps below are Tk defaults rather than oversights in any one app,
|
|
and both copies of the button had to solve them independently:
|
|
|
|
1. **``<Return>`` does nothing on a ``tk.Button``.** Tk's X11 class bindings
|
|
give the widget ``<space>`` and nothing else, so a focused button is
|
|
Space-only and Enter -- the key a user actually reaches for -- silently
|
|
does nothing. :func:`make_button` binds it.
|
|
2. **The default focus ring is 1px black**, which against ``#211D1B`` reads
|
|
as no ring at all, so keyboard users cannot see where focus is. Every
|
|
widget here takes its ring from ``LockConfig.focus_kwargs()``.
|
|
|
|
See :class:`ButtonStyle` for why emphasis is a ``variant`` and not a colour.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock._widget_fills import ButtonVariant, _button_fills, _lighten
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from gatelock._config import LockConfig, TypeRole
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ButtonStyle:
|
|
"""How a button reads: its emphasis role, and whether the label is bold.
|
|
|
|
``variant`` lives here rather than as a loose colour pair because it is
|
|
the whole point of the API: it picks the text colour *from* the fill, so
|
|
a call site structurally cannot pair a foreground with a background it
|
|
does not contrast against.
|
|
|
|
Attributes:
|
|
variant: Emphasis role. ``primary`` is the one high-emphasis action
|
|
per screen; ``secondary`` stays low-contrast and ``danger`` reads
|
|
as an alarm.
|
|
bold: Whether the label is bold. Gate buttons are, except for a few
|
|
low-emphasis ones ("Fetch from sync", "Close Demo") that would
|
|
otherwise shout as loudly as the action they sit beside.
|
|
"""
|
|
|
|
variant: ButtonVariant = "primary"
|
|
bold: bool = True
|
|
|
|
|
|
DEFAULT_WRAP = 880
|
|
"""Fallback wrap width for :func:`row`, matching a default window geometry.
|
|
|
|
Passed in rather than hardcoded because the first version of the status panel
|
|
wrapped at a fixed 900px inside a 720px window, so every long explanation ran
|
|
off the right edge -- in the one panel whose entire job is explaining things.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RowStyle:
|
|
"""How one :func:`row` reads: its colour, size and wrap width.
|
|
|
|
Grouped because they are a single decision made together, and because
|
|
the overwhelmingly common row -- ordinary body text -- passes none of
|
|
them.
|
|
|
|
Attributes:
|
|
color: Overrides ``LockConfig.fg``. Status colours are load-bearing
|
|
in a gate: red means the gate is holding you, amber means
|
|
something needs attention, green means clear. Nothing should be
|
|
coloured merely to look lively.
|
|
role: Type-scale role.
|
|
wrap: Wrap width in px, clamped to a usable minimum when applied.
|
|
"""
|
|
|
|
color: str | None = None
|
|
role: TypeRole = "label"
|
|
wrap: int = DEFAULT_WRAP
|
|
|
|
|
|
# Horizontal padding is 2x vertical (tokens.md "Buttons"; both on the 4px
|
|
# spacing scale).
|
|
_BUTTON_PADY = 12
|
|
_BUTTON_PADX = 24
|
|
# Narrowest wrap worth honouring; below this a "wrapped" line is one word.
|
|
_MIN_WRAP = 320
|
|
|
|
|
|
# The one high-emphasis button per screen reads larger than everything else
|
|
# (rule 3: size is another axis of prominence, alongside colour); every other
|
|
# variant shares the smaller chrome size.
|
|
_BUTTON_ROLES: dict[ButtonVariant, TypeRole] = {
|
|
"primary": "body",
|
|
"secondary": "label",
|
|
"danger": "label",
|
|
}
|
|
|
|
|
|
def make_button(
|
|
parent: tk.Misc,
|
|
config: LockConfig,
|
|
text: str,
|
|
command: Callable[[], None],
|
|
style: ButtonStyle | None = None,
|
|
) -> tk.Button:
|
|
"""Build a button from the shared primary/secondary/danger palette.
|
|
|
|
Binds ``<Return>`` and ``<KP_Enter>`` as well as relying on ``command``,
|
|
because Tk gives ``tk.Button`` only ``<space>`` on X11 -- without this
|
|
every button in a gate is Space-only and Enter silently does nothing.
|
|
|
|
Args:
|
|
parent: Widget to build into.
|
|
config: Token source for colours, type scale and the focus ring.
|
|
text: The button's label.
|
|
command: Invoked on click, Space, Return and KP_Enter alike.
|
|
style: Emphasis role and label weight. Defaults to a bold primary,
|
|
which is what most gate buttons are.
|
|
|
|
Returns:
|
|
The configured button. Not packed -- the caller places it.
|
|
"""
|
|
style = style if style is not None else ButtonStyle()
|
|
variant = style.variant
|
|
fill, text_color = _button_fills(config)[variant]
|
|
button = tk.Button(
|
|
parent,
|
|
text=text,
|
|
font=config.font(_BUTTON_ROLES[variant], bold=style.bold),
|
|
bg=fill,
|
|
fg=text_color,
|
|
activebackground=_lighten(fill),
|
|
activeforeground=text_color,
|
|
cursor="hand2",
|
|
padx=_BUTTON_PADX,
|
|
pady=_BUTTON_PADY,
|
|
command=command,
|
|
**config.focus_kwargs(),
|
|
)
|
|
|
|
def _activate(_event: tk.Event) -> str:
|
|
button.invoke()
|
|
return "break"
|
|
|
|
button.bind("<Return>", _activate, add="+")
|
|
button.bind("<KP_Enter>", _activate, add="+")
|
|
return button
|
|
|
|
|
|
def heading(parent: tk.Misc, config: LockConfig, text: str) -> tk.Label:
|
|
"""Build and pack a section title.
|
|
|
|
Returns:
|
|
The packed label, so a caller can restyle or destroy it.
|
|
"""
|
|
label = tk.Label(
|
|
parent,
|
|
text=text,
|
|
font=config.font("body", bold=True),
|
|
fg=config.palette.accent,
|
|
bg=config.palette.bg,
|
|
anchor="w",
|
|
)
|
|
label.pack(
|
|
fill="x",
|
|
padx=config.space("lg"),
|
|
pady=(config.space("md"), config.space("xs")),
|
|
)
|
|
return label
|
|
|
|
|
|
def row(
|
|
parent: tk.Misc,
|
|
config: LockConfig,
|
|
text: str,
|
|
style: RowStyle | None = None,
|
|
) -> tk.Label:
|
|
"""Build and pack one line of body text, wrapped to the style's width.
|
|
|
|
The three styling options travel together in :class:`RowStyle` rather
|
|
than as loose keywords, because they are one decision ("how does this
|
|
line read?") rather than three, and because a row is written far more
|
|
often than it is restyled -- the common call passes no style at all.
|
|
|
|
``wrap`` lives on the style rather than in module state: the donor kept
|
|
the current width in a module-level dict, which made every row's
|
|
wrapping depend on whichever window rendered most recently.
|
|
|
|
Args:
|
|
parent: Widget to build into.
|
|
config: Token source for colours and the type scale.
|
|
text: The line to show.
|
|
style: Colour, type-scale role and wrap width. Defaults to ordinary
|
|
body text at :data:`DEFAULT_WRAP`.
|
|
|
|
Returns:
|
|
The packed label.
|
|
"""
|
|
style = style if style is not None else RowStyle()
|
|
color, role, wrap = style.color, style.role, style.wrap
|
|
label = tk.Label(
|
|
parent,
|
|
text=text,
|
|
font=config.font(role),
|
|
fg=color if color is not None else config.palette.fg,
|
|
bg=config.palette.bg,
|
|
anchor="w",
|
|
justify="left",
|
|
wraplength=max(_MIN_WRAP, wrap),
|
|
)
|
|
label.pack(fill="x", padx=config.space("xl"), pady=1)
|
|
return label
|