mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Make lock surfaces keyboard-operable and safe on a 768px screen
gatelock v0.3.0. Every change here works around a Tk *default*, not an application mistake -- which is why they belong in the shared backend rather than being fixed four times. On a lock surface these are not accessibility niceties. The lockers hold a global input grab with VT switching disabled, so a control the keyboard cannot reach, or a button clipped off the bottom of the screen, is a bricked machine. LockConfig gains a type scale, a spacing scale and a focus-ring token: - font() applies Tk's sign convention. Tk encodes the unit in the SIGN of the font size -- positive is points, negative is pixels -- so passing the design system's px values straight through rendered every string ~30% oversized (measured: body at 16pt has a 26px linespace where 16px gives 20). That alone pushed diet_guard's meal gate to 866px of content inside a 739px pane on a 1366x768 screen. - focus_kwargs() sets highlightcolor, the *focused* ring. Tk's default is a 1px BLACK ring, invisible against the palette's #211D1B background. Note highlightbackground is the UNfocused ring: setting only that one inverts the affordance, which is what diet_guard was doing. _keyboard.py collects three Tk defaults that each make a form unusable: - tk.Text traps Tab (inserts a literal tab and refocuses itself) and no-ops Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab. - tk.Button binds <space> on X11 but never <Return>, so the key a user reaches for to submit does nothing. - Escape needs binding at the toplevel to work wherever focus happens to sit. _scrollable.py adds ScrollableSurface, a viewport that content can actually be reached in: - A place()-centred frame takes its *requested* size and is clipped SYMMETRICALLY, so overflow removes the heading and the submit button at the same time, with no scrollbar and no hint anything is missing. - A bare tk.Canvas has no class key bindings, so ::tk::FocusOK rejects it as a focus stop and the only way to scroll is dragging the thumb with a pointer. - Canvas clipping does not unmap children, so scrolled-out fields stay in the tab ring while Tk never scrolls to follow focus -- Tab lands on fields the user can neither see nor reveal. - A Canvas holds content in a canvas *item*, so it never propagates the content's requested size; a toplevel told to size itself has nothing to grow to. _request_content_size() fixes that, clamped to the display. center_when_fits keeps short screens looking exactly as they did, so the viewport can replace a centred frame without a visual change. A scrollable dialog Toplevel is deliberately NOT the mechanism: on an override-redirect lock surface a second toplevel steals the Tk grab, which the recovery tick then kills -- the 2026-07-26 failure where a frozen sport selector logged a walk as table tennis. unified-design-system gains the two rule families this implements (pointer-free operability, 1366x768 usable / 1024x600 graceful), a focus-ring token, and the px/pt sign convention written down so it cannot recur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
206740889c
commit
f0be8c5919
@ -25,6 +25,7 @@ from gatelock._escape import (
|
||||
EscapeTracker,
|
||||
)
|
||||
from gatelock._guards import assert_not_under_pytest, wait_for_x_server
|
||||
from gatelock._keyboard import bind_activate, bind_cancel, escape_text_tab_trap
|
||||
from gatelock._outputs import (
|
||||
Output,
|
||||
OutputEnumerator,
|
||||
@ -36,6 +37,7 @@ from gatelock._outputs import (
|
||||
)
|
||||
from gatelock._recovery import RecoveryLoop, RecoveryReport
|
||||
from gatelock._root import GateRoot
|
||||
from gatelock._scrollable import ScrollableSurface
|
||||
from gatelock._surfaces import (
|
||||
SurfaceBuilder,
|
||||
SurfaceDelta,
|
||||
@ -70,14 +72,18 @@ __all__ = [
|
||||
"RandrBackend",
|
||||
"RecoveryLoop",
|
||||
"RecoveryReport",
|
||||
"ScrollableSurface",
|
||||
"SurfaceBuilder",
|
||||
"SurfaceDelta",
|
||||
"SurfaceInfo",
|
||||
"SurfaceSet",
|
||||
"assert_not_under_pytest",
|
||||
"bind_activate",
|
||||
"bind_cancel",
|
||||
"default_runtime_dir",
|
||||
"disable_vt_switching",
|
||||
"enumerate_outputs",
|
||||
"escape_text_tab_trap",
|
||||
"grab_strength",
|
||||
"mirror_text_widgets",
|
||||
"needs_backdrop_root",
|
||||
|
||||
103
gatelock/gatelock/_keyboard.py
Normal file
103
gatelock/gatelock/_keyboard.py
Normal file
@ -0,0 +1,103 @@
|
||||
r"""Keyboard-operability helpers for lock surfaces.
|
||||
|
||||
Every function here works around a **Tk default**, not an application mistake.
|
||||
That is why they belong in the shared backend: the same three defects otherwise
|
||||
recur in every locker, and each one is a lockout rather than an annoyance,
|
||||
because a lock surface holds a global input grab with VT switching disabled.
|
||||
There is no other window to click and no console to escape to.
|
||||
|
||||
The defaults, verified against Tk 8.6's own class bindings:
|
||||
|
||||
- ``tk.Text`` traps Tab. ``bind Text <Key-Tab>`` runs
|
||||
``tk::TextInsert %W \\t; focus %W; break``, so Tab inserts a literal tab and
|
||||
refocuses the widget, and ``<Shift-Tab>`` is bound to do nothing. The only
|
||||
built-in exits are ``Ctrl+Tab``/``Ctrl+Shift+Tab``, which no UI advertises.
|
||||
- ``tk.Button`` ignores Enter. On X11 ``bind Button <space>`` exists but there
|
||||
is **no** ``<Return>`` binding, and ``entry.tcl``/``spinbox.tcl`` bind
|
||||
``<Return>`` to ``{# nothing}``. So Space works and Enter -- the key a user
|
||||
reaches for to submit -- does nothing at all.
|
||||
- Focus is invisible. Tk ships ``highlightcolor="#000000"`` at
|
||||
``highlightthickness=1``, i.e. a black ring on the palette's near-black
|
||||
background. See :meth:`gatelock.LockConfig.focus_kwargs`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
def escape_text_tab_trap(text: tk.Text) -> None:
|
||||
"""Make Tab / Shift-Tab move focus out of a ``tk.Text``.
|
||||
|
||||
Without this a keyboard-only user who tabs into a multi-line field cannot
|
||||
get back out to the submit button, which on a lock surface means they
|
||||
cannot satisfy the lock at all.
|
||||
|
||||
Returning ``"break"`` is what stops Tk's own class binding from also
|
||||
running and inserting the tab character.
|
||||
|
||||
Args:
|
||||
text: The widget to un-trap.
|
||||
"""
|
||||
|
||||
def _forward(_event: tk.Event) -> str:
|
||||
text.tk_focusNext().focus_set()
|
||||
return "break"
|
||||
|
||||
def _backward(_event: tk.Event) -> str:
|
||||
text.tk_focusPrev().focus_set()
|
||||
return "break"
|
||||
|
||||
text.bind("<Tab>", _forward, add="+")
|
||||
text.bind("<Shift-Tab>", _backward, add="+")
|
||||
# X11 delivers Shift+Tab as its own keysym, which <Shift-Tab> alone misses.
|
||||
text.bind("<ISO_Left_Tab>", _backward, add="+")
|
||||
|
||||
|
||||
def bind_activate(widget: tk.Widget) -> None:
|
||||
"""Make Enter activate a button-like widget, as well as Space.
|
||||
|
||||
Tk binds only ``<space>`` on X11, so every button in an unmodified lock UI
|
||||
is Space-only. Both the main Return key and the keypad's Enter are bound,
|
||||
since they are distinct keysyms.
|
||||
|
||||
Args:
|
||||
widget: A widget exposing ``invoke()`` (Button, Checkbutton, ...).
|
||||
"""
|
||||
|
||||
def _activate(_event: tk.Event) -> str:
|
||||
widget.invoke()
|
||||
return "break"
|
||||
|
||||
widget.bind("<Return>", _activate, add="+")
|
||||
widget.bind("<KP_Enter>", _activate, add="+")
|
||||
|
||||
|
||||
def bind_cancel(widget: tk.Misc, callback: object) -> None:
|
||||
"""Bind Escape on a widget's toplevel to a cancel/back action.
|
||||
|
||||
Bound at the toplevel rather than the widget so Escape works wherever focus
|
||||
currently sits, which is the point -- a user who has tabbed somewhere
|
||||
unexpected still needs the way back.
|
||||
|
||||
This is for *cancel* affordances only. Some lockers deliberately refuse
|
||||
dismissal (an alarm you must solve a challenge to silence); making their
|
||||
sanctioned back/cancel button reachable does not weaken that, but do not
|
||||
use this to add an exit a lock is designed not to have.
|
||||
|
||||
Args:
|
||||
widget: Any widget in the target toplevel.
|
||||
callback: Zero-argument callable invoked on Escape.
|
||||
"""
|
||||
if not callable(callback):
|
||||
message = "callback must be callable"
|
||||
raise TypeError(message)
|
||||
|
||||
def _cancel(_event: tk.Event) -> str:
|
||||
callback()
|
||||
return "break"
|
||||
|
||||
widget.winfo_toplevel().bind("<Escape>", _cancel, add="+")
|
||||
299
gatelock/gatelock/_scrollable.py
Normal file
299
gatelock/gatelock/_scrollable.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""A keyboard-scrollable, focus-following viewport for lock-surface content.
|
||||
|
||||
Lock surfaces cannot solve "content taller than the screen" the obvious way.
|
||||
A scrollable dialog would be a second ``Toplevel``, and on an
|
||||
override-redirect lock surface a second toplevel steals the Tk grab, which the
|
||||
recovery tick then kills a second later -- the 2026-07-26 failure where a
|
||||
frozen sport selector logged a walk as table tennis. So the viewport has to
|
||||
live inside the surface's own widget tree, which is what this provides.
|
||||
|
||||
Three defects this exists to prevent, all of them Tk defaults rather than
|
||||
oversights in any one app:
|
||||
|
||||
1. **Centered content clips symmetrically.** A ``place``-centered frame takes
|
||||
its *requested* size, so when it is too tall the parent shears equal
|
||||
amounts off the top *and* the bottom -- losing the header and the submit
|
||||
button together, with no scrollbar and no hint anything is missing. This
|
||||
top-anchors content inside a scroll region instead.
|
||||
2. **A Canvas viewport is not keyboard-scrollable.** ``tk.Canvas`` has no
|
||||
class-level key bindings at all, so ``::tk::FocusOK`` rejects it as a focus
|
||||
stop and the only way to move the view is dragging the scrollbar thumb with
|
||||
a pointer. This makes the canvas focusable and binds PageUp/PageDown,
|
||||
Home/End and arrows.
|
||||
3. **Focus walks into invisible widgets.** Canvas clipping does not *unmap* a
|
||||
child, so every scrolled-out-of-view field stays ``winfo viewable`` and
|
||||
stays in the tab chain, while Tk never scrolls to follow focus. Tabbing
|
||||
therefore lands on fields the user cannot see and cannot bring into view.
|
||||
:meth:`ScrollableSurface.track_focus` fixes that by scrolling the focused
|
||||
widget into view.
|
||||
|
||||
Wheel scrolling is bound too, but as an addition -- never as the only path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gatelock._window import LockConfig
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Tk reports wheel notches as Button-4/5 on X11 rather than <MouseWheel>.
|
||||
_WHEEL_UP = "<Button-4>"
|
||||
_WHEEL_DOWN = "<Button-5>"
|
||||
# Scroll step in "units" for arrow keys; pages use the viewport height.
|
||||
_ARROW_UNITS = 3
|
||||
# Extra px kept visible around a widget scrolled into view, so a focused
|
||||
# field never sits flush against the viewport edge.
|
||||
_FOCUS_MARGIN_PX = 12
|
||||
|
||||
|
||||
class ScrollableSurface:
|
||||
"""A scroll viewport whose content is reachable by keyboard alone.
|
||||
|
||||
Build it inside a lock surface, pack widgets into :attr:`content`, then
|
||||
call :meth:`finalize` once the content exists.
|
||||
|
||||
Attributes:
|
||||
container: The frame to place/pack into the parent surface.
|
||||
canvas: The clipping viewport. Focusable, unlike a bare Canvas.
|
||||
content: The frame to build UI into.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Misc,
|
||||
config: LockConfig,
|
||||
*,
|
||||
show_scrollbar: bool = True,
|
||||
center_when_fits: bool = False,
|
||||
) -> None:
|
||||
"""Create the viewport.
|
||||
|
||||
Args:
|
||||
parent: The surface (or notebook tab) to build inside.
|
||||
config: Token source for colors and the focus ring.
|
||||
show_scrollbar: Draw a scrollbar alongside the canvas. Even when
|
||||
False the content stays keyboard-scrollable; the bar is a
|
||||
pointer affordance and a visual hint, not the mechanism.
|
||||
center_when_fits: Vertically centre the content while it fits, and
|
||||
fall back to top-anchored scrolling once it does not. This lets
|
||||
a viewport replace a ``place``-centred frame without changing
|
||||
how short screens look, while removing the failure mode that
|
||||
made centring dangerous -- a centred frame that overflows is
|
||||
clipped at *both* edges at once, losing the heading and the
|
||||
submit button together with no scrollbar to recover either.
|
||||
"""
|
||||
self._config = config
|
||||
self._center_when_fits = center_when_fits
|
||||
self.container = tk.Frame(parent, bg=config.bg)
|
||||
self.canvas = tk.Canvas(
|
||||
self.container,
|
||||
bg=config.bg,
|
||||
highlightthickness=config.focus_thickness,
|
||||
highlightcolor=config.focus_ring,
|
||||
highlightbackground=config.bg,
|
||||
borderwidth=0,
|
||||
# Tk gives Canvas no key bindings, so it is not a focus stop by
|
||||
# default and the viewport becomes pointer-only. Opt in.
|
||||
takefocus=True,
|
||||
)
|
||||
self._scrollbar: tk.Scrollbar | None = None
|
||||
if show_scrollbar:
|
||||
self._scrollbar = tk.Scrollbar(
|
||||
self.container,
|
||||
orient="vertical",
|
||||
command=self.canvas.yview,
|
||||
# A Scrollbar has key bindings, so it lands in the tab ring --
|
||||
# and would otherwise keep Tk's black default ring, invisible
|
||||
# against bg. It stays focusable rather than being opted out:
|
||||
# its own Prior/Next/arrow class bindings are a legitimate
|
||||
# second way to scroll.
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
self._scrollbar.pack(side="right", fill="y")
|
||||
self.canvas.configure(yscrollcommand=self._scrollbar.set)
|
||||
self.canvas.pack(side="left", fill="both", expand=True)
|
||||
|
||||
self.content = tk.Frame(self.canvas, bg=config.bg)
|
||||
# anchor="n": top-anchored, so overflow scrolls instead of shearing
|
||||
# the header and the submit button off opposite edges.
|
||||
self._content_id = self.canvas.create_window(
|
||||
(0, 0), window=self.content, anchor="n"
|
||||
)
|
||||
self.content.bind("<Configure>", self._on_content_resize, add="+")
|
||||
self.canvas.bind("<Configure>", self._on_canvas_resize, add="+")
|
||||
self._bind_scroll_keys()
|
||||
|
||||
def _on_content_resize(self, _event: tk.Event) -> None:
|
||||
"""Keep the scroll region matched to the content's real size."""
|
||||
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
||||
self._reposition()
|
||||
|
||||
def _on_canvas_resize(self, event: tk.Event) -> None:
|
||||
"""Center the content horizontally and match the viewport width.
|
||||
|
||||
Matching the width is what lets ``wraplength`` and ``justify=center``
|
||||
behave the same as they did before the viewport was introduced.
|
||||
"""
|
||||
self.canvas.itemconfigure(self._content_id, width=event.width)
|
||||
self._reposition()
|
||||
|
||||
def _reposition(self) -> None:
|
||||
"""Place the content window, centring vertically only while it fits."""
|
||||
width = self.canvas.winfo_width()
|
||||
top = 0
|
||||
if self._center_when_fits:
|
||||
spare = self.canvas.winfo_height() - self.content.winfo_reqheight()
|
||||
top = max(0, spare // 2)
|
||||
self.canvas.coords(self._content_id, width / 2, top)
|
||||
|
||||
def _bind_scroll_keys(self) -> None:
|
||||
"""Bind keyboard and wheel scrolling.
|
||||
|
||||
PageUp/PageDown/Home/End go on the *toplevel* where possible: in a
|
||||
fullscreen lock those keys have no competing meaning, and binding there
|
||||
means they work wherever focus currently sits rather than only when the
|
||||
canvas holds it. Arrows bind to the canvas only -- bound globally they
|
||||
would fight Listbox, Spinbox and Entry navigation.
|
||||
"""
|
||||
paging = (
|
||||
("<Prior>", self._page_up),
|
||||
("<Next>", self._page_down),
|
||||
("<Home>", self._scroll_home),
|
||||
("<End>", self._scroll_end),
|
||||
)
|
||||
target: tk.Misc = self.canvas
|
||||
try:
|
||||
target = self.canvas.winfo_toplevel()
|
||||
except (tk.TclError, KeyError, AttributeError):
|
||||
# Reachable when the widget tree's root is not a real toplevel --
|
||||
# notably under tests that mock the Tk root. Degrade to binding on
|
||||
# the canvas rather than failing to build the viewport at all:
|
||||
# paging then works while the viewport has focus, which is strictly
|
||||
# better than no keyboard scrolling. Logged rather than swallowed
|
||||
# so a production occurrence is visible.
|
||||
_logger.warning(
|
||||
"no toplevel for scroll viewport; paging keys bound to the "
|
||||
"canvas only, so they work only while it holds focus"
|
||||
)
|
||||
for seq, handler in paging:
|
||||
target.bind(seq, handler, add="+")
|
||||
self.canvas.bind("<Up>", self._line_up, add="+")
|
||||
self.canvas.bind("<Down>", self._line_down, add="+")
|
||||
for seq, handler in (
|
||||
(_WHEEL_UP, self._line_up),
|
||||
(_WHEEL_DOWN, self._line_down),
|
||||
):
|
||||
self.canvas.bind(seq, handler, add="+")
|
||||
self.content.bind(seq, handler, add="+")
|
||||
|
||||
def _page_up(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_scroll(-1, "pages")
|
||||
|
||||
def _page_down(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_scroll(1, "pages")
|
||||
|
||||
def _line_up(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_scroll(-_ARROW_UNITS, "units")
|
||||
|
||||
def _line_down(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_scroll(_ARROW_UNITS, "units")
|
||||
|
||||
def _scroll_home(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_moveto(0.0)
|
||||
|
||||
def _scroll_end(self, _event: tk.Event | None = None) -> None:
|
||||
self.canvas.yview_moveto(1.0)
|
||||
|
||||
def track_focus(self, widget: tk.Misc | None = None) -> None:
|
||||
"""Scroll the focused descendant into view, recursively binding.
|
||||
|
||||
Call after the content is built. Every descendant gets a ``<FocusIn>``
|
||||
handler, because a clipped widget stays in the tab chain: without this,
|
||||
Tab walks the user onto fields that are scrolled out of sight with no
|
||||
keyboard way to reveal them.
|
||||
|
||||
Args:
|
||||
widget: Subtree root to bind. Defaults to :attr:`content`.
|
||||
"""
|
||||
target = self.content if widget is None else widget
|
||||
target.bind("<FocusIn>", self._on_descendant_focus, add="+")
|
||||
for child in target.winfo_children():
|
||||
self.track_focus(child)
|
||||
|
||||
def _on_descendant_focus(self, event: tk.Event) -> None:
|
||||
"""Scroll so the newly focused widget is fully visible."""
|
||||
self.scroll_into_view(event.widget)
|
||||
|
||||
def scroll_into_view(self, widget: tk.Misc) -> None:
|
||||
"""Scroll the viewport minimally so ``widget`` is fully visible.
|
||||
|
||||
No-op when the content already fits, or when the widget is not laid
|
||||
out yet (a zero height means Tk has not placed it).
|
||||
"""
|
||||
self.canvas.update_idletasks()
|
||||
span = self._content_span()
|
||||
view_h = self.canvas.winfo_height()
|
||||
if span <= view_h or widget.winfo_height() <= 0:
|
||||
return
|
||||
top = widget.winfo_rooty() - self.content.winfo_rooty()
|
||||
bottom = top + widget.winfo_height()
|
||||
offset = self.canvas.canvasy(0)
|
||||
margin = _FOCUS_MARGIN_PX
|
||||
if top - margin < offset:
|
||||
target = max(0, top - margin)
|
||||
elif bottom + margin > offset + view_h:
|
||||
target = min(span - view_h, bottom + margin - view_h)
|
||||
else:
|
||||
return
|
||||
self.canvas.yview_moveto(target / span)
|
||||
|
||||
def _content_span(self) -> int:
|
||||
"""Return the scrollable content height in px."""
|
||||
box = self.canvas.bbox("all")
|
||||
return (box[3] - box[1]) if box else 0
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Settle geometry, wire focus-following, and reset to the top.
|
||||
|
||||
Idempotent, so it is safe to call again after the content changes.
|
||||
"""
|
||||
self.content.update_idletasks()
|
||||
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
||||
self._request_content_size()
|
||||
self.track_focus()
|
||||
self.canvas.yview_moveto(0.0)
|
||||
self._reposition()
|
||||
|
||||
def _request_content_size(self) -> None:
|
||||
"""Ask for a canvas big enough for the content, clamped to the screen.
|
||||
|
||||
A ``Canvas`` holds its content in a canvas *item*, not as a
|
||||
geometry-managed child, so it never propagates the content's requested
|
||||
size into its own. A toplevel told to size itself (``geometry("")``)
|
||||
therefore has nothing to grow to and stays at its minimum -- which is
|
||||
how a 862px-wide button row ended up sheared inside a 700px window even
|
||||
after a viewport was added.
|
||||
|
||||
Requesting the content's size fixes that where there is room, and the
|
||||
clamp keeps a window from growing off a small display; anything still
|
||||
too big scrolls instead of clipping. On a lock surface the container is
|
||||
``place``d at ``relwidth/relheight=1``, which overrides this request --
|
||||
correctly, since there the surface size is the screen.
|
||||
"""
|
||||
wanted_w = self.content.winfo_reqwidth()
|
||||
wanted_h = self.content.winfo_reqheight()
|
||||
bar = self._scrollbar.winfo_reqwidth() if self._scrollbar else 0
|
||||
self.canvas.configure(
|
||||
width=max(1, min(wanted_w, self.canvas.winfo_screenwidth() - bar)),
|
||||
height=max(1, min(wanted_h, self.canvas.winfo_screenheight())),
|
||||
)
|
||||
|
||||
def fits(self) -> bool:
|
||||
"""Whether the content currently fits without scrolling."""
|
||||
self.canvas.update_idletasks()
|
||||
return self._content_span() <= self.canvas.winfo_height()
|
||||
@ -49,6 +49,8 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
GrabKind = Literal["none", "local", "global"]
|
||||
LockMode = Literal["soft", "hard"]
|
||||
TypeRole = Literal["display", "title", "subtitle", "body", "label", "caption"]
|
||||
SpaceStep = Literal["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
|
||||
# Periodic no-op so a grabbed, event-starved loop keeps handing control back
|
||||
# to Python, letting SIGTERM/SIGINT be serviced promptly.
|
||||
@ -105,10 +107,20 @@ class LockConfig:
|
||||
must pick ``fg`` vs. ``on_fill`` based on the widget's own
|
||||
background, never hardcode one for all buttons.
|
||||
font_family: Default font family for lock-window widgets.
|
||||
focus_ring: Color of the *focused* widget's highlight ring. Defaults to
|
||||
``accent``, because Tk's own default is black -- invisible against
|
||||
``bg``. Pass to ``highlightcolor``; note ``highlightbackground`` is
|
||||
the *unfocused* ring, so setting that one inverts the affordance.
|
||||
focus_thickness: Ring width in px. Never set 0 on a focusable widget.
|
||||
type_display, type_title, type_subtitle, type_body, type_label,
|
||||
type_caption: The type scale, in **pixels**. Do not pass these to
|
||||
Tk directly -- use :meth:`font`, which applies the sign convention.
|
||||
space_xs, space_sm, space_md, space_lg, space_xl, space_xxl: The 4px
|
||||
spacing scale, in pixels. Use for ``padx``/``pady``/``ipadx``.
|
||||
|
||||
All color/font defaults come from the ``unified-design-system`` skill
|
||||
(``~/.claude/skills/unified-design-system/references/tokens.md``) -- the
|
||||
same palette used by every one of kuhy's apps, Flutter and web included.
|
||||
All color/font defaults come from the ``unified-design-system`` docs
|
||||
(``~/utils/unified-design-system/tokens.md``) -- the same palette used by
|
||||
every one of kuhy's apps, Flutter and web included.
|
||||
"""
|
||||
|
||||
mode: LockMode = "hard"
|
||||
@ -131,6 +143,78 @@ class LockConfig:
|
||||
danger: str = "#E2585F"
|
||||
on_fill: str = "#211D1B"
|
||||
font_family: str = "Arial"
|
||||
focus_ring: str = "#B8862E"
|
||||
focus_thickness: int = 2
|
||||
# Type scale in PIXELS (unified-design-system tokens.md). Convert via
|
||||
# font(); a raw positive value handed to Tk means *points*, ~37% bigger.
|
||||
type_display: int = 32
|
||||
type_title: int = 24
|
||||
type_subtitle: int = 20
|
||||
type_body: int = 16
|
||||
type_label: int = 14
|
||||
type_caption: int = 12
|
||||
# 4px spacing scale, in pixels.
|
||||
space_xs: int = 4
|
||||
space_sm: int = 8
|
||||
space_md: int = 16
|
||||
space_lg: int = 24
|
||||
space_xl: int = 32
|
||||
space_xxl: int = 48
|
||||
|
||||
def type_px(self, role: TypeRole = "body") -> int:
|
||||
"""Return the type-scale size for ``role``, in pixels."""
|
||||
return int(getattr(self, f"type_{role}"))
|
||||
|
||||
def space(self, step: SpaceStep = "md") -> int:
|
||||
"""Return the spacing-scale value for ``step``, in pixels."""
|
||||
return int(getattr(self, f"space_{step}"))
|
||||
|
||||
def font(
|
||||
self,
|
||||
role: TypeRole = "body",
|
||||
*,
|
||||
bold: bool = False,
|
||||
family: str | None = None,
|
||||
scale: float = 1.0,
|
||||
) -> tuple[str, int] | tuple[str, int, str]:
|
||||
"""Return a Tk font tuple for a type-scale role, sized in **pixels**.
|
||||
|
||||
Tk encodes the unit in the *sign* of the size: positive means points,
|
||||
negative means pixels. The design-system scale is in pixels, so passing
|
||||
e.g. ``type_body`` (16) straight to Tk yields 16 *points* -- about 37%
|
||||
larger than intended (measured: linespace 26px vs 19px). Inflating
|
||||
every string by a third is enough on its own to push a layout off a
|
||||
768px-tall screen, which is exactly what happened to the diet_guard
|
||||
meal gate. Always build lock-window fonts through this method.
|
||||
|
||||
Args:
|
||||
role: Type-scale role.
|
||||
bold: Append Tk's ``"bold"`` weight.
|
||||
family: Override the font family. Defaults to ``font_family``.
|
||||
scale: Multiplier for display-only emphasis (e.g. an oversized
|
||||
countdown). Kept explicit so outliers are visible rather than
|
||||
hidden behind a fresh literal.
|
||||
|
||||
Returns:
|
||||
A Tk font tuple with a negative (pixel) size.
|
||||
"""
|
||||
px = max(1, round(self.type_px(role) * scale))
|
||||
name = family if family is not None else self.font_family
|
||||
return (name, -px, "bold") if bold else (name, -px)
|
||||
|
||||
def focus_kwargs(self) -> dict[str, str | int]:
|
||||
"""Return widget kwargs that make focus visible on this palette.
|
||||
|
||||
``highlightcolor`` is the *focused* ring; ``highlightbackground`` is the
|
||||
unfocused one. Both are set so the widget shows a subdued edge when
|
||||
unfocused and the accent ring when focused -- rather than Tk's default
|
||||
black-on-``bg``, which reads as no ring at all.
|
||||
"""
|
||||
return {
|
||||
"highlightcolor": self.focus_ring,
|
||||
"highlightbackground": self.bg,
|
||||
"highlightthickness": self.focus_thickness,
|
||||
}
|
||||
|
||||
def resolved_overrideredirect(self) -> bool:
|
||||
"""Return the effective overrideredirect setting."""
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gatelock"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
||||
|
||||
@ -144,10 +144,109 @@ per-repo duplicate, since all three already depend on it. Extending
|
||||
(`/usr/bin/python3 -c "import gatelock"`) since it backs 3 live systemd
|
||||
services — a dev-venv-only check is not sufficient.
|
||||
|
||||
## Pointer-free + small-screen: per-stack patterns
|
||||
|
||||
The rules themselves are in [`tokens.md`](tokens.md) (*Pointer-free
|
||||
operability*, *Screen size*). This is how to implement them per stack. Both are
|
||||
machine-checked — a static lint in pre-commit plus a render test in each repo's
|
||||
suite — so treat these as the shape the checks expect.
|
||||
|
||||
### Flutter
|
||||
|
||||
```dart
|
||||
// Reach: never a bare GestureDetector for an action.
|
||||
InkWell(onTap: select, child: cell) // focusable + Enter/Space
|
||||
// A destructive action needs a focusable control, not just onLongPress:
|
||||
IconButton(icon: Icon(Icons.delete), onPressed: confirmDelete)
|
||||
|
||||
// App-level accelerators. On the Chrome --app desktop surface, avoid chords
|
||||
// Chrome reserves (Ctrl+N/T/W/Shift+N) — they never reach Flutter.
|
||||
Shortcuts(shortcuts: {
|
||||
SingleActivator(LogicalKeyboardKey.keyS, control: true): SaveIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
}, child: Actions(actions: {...}, child: child));
|
||||
|
||||
// Size: budget height, clamp width.
|
||||
final short = MediaQuery.sizeOf(context).height < 700; // 768 minus chrome
|
||||
ConstrainedBox(constraints: BoxConstraints(maxWidth: kProseMaxWidth), child: prose);
|
||||
```
|
||||
|
||||
Any screen whose content can grow needs a `SingleChildScrollView`/`ListView` —
|
||||
a `Column` with one `Expanded` child does not scroll, it just collapses the
|
||||
flexible child until it is unusable. Bottom sheets need `isScrollControlled:
|
||||
true` plus an inner scroll view.
|
||||
|
||||
Note on **Flutter web** (todo-app and diet-guard's desktop surface are the web
|
||||
build in a Chrome `--app` window, not the Linux embedder): the semantics tree is
|
||||
not built unless assistive tech is detected, so `Semantics` is not a substitute
|
||||
for real focusability — use focusable widgets, and treat `Semantics` as
|
||||
additive.
|
||||
|
||||
### Web
|
||||
|
||||
`:focus-visible` must be styled everywhere, and never removed:
|
||||
|
||||
```css
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
/* Never `outline: none` without an equally visible replacement. */
|
||||
```
|
||||
|
||||
Verify at the required viewports with no horizontal scroll and no *inner*
|
||||
scrollbar: `1366x768` and `1024x600` at minimum. Prefer a compact baseline that
|
||||
grows, over a desktop layout that must shrink.
|
||||
|
||||
### Python/Tkinter
|
||||
|
||||
Font sizes are **negative** (see `tokens.md` — positive means points and
|
||||
inflates everything ~37%). The focus ring and both scales live on
|
||||
`gatelock.LockConfig` so all four lockers inherit them:
|
||||
|
||||
```python
|
||||
# focused ring — NOT highlightbackground, which is the *unfocused* ring
|
||||
widget.configure(highlightcolor=cfg.focus_ring, highlightthickness=2)
|
||||
widget.configure(font=(cfg.font_family, -cfg.type_body)) # negative == pixels
|
||||
```
|
||||
|
||||
Tk specifics that are easy to get wrong, all verified against Tk 8.6's own
|
||||
class bindings:
|
||||
|
||||
- `tk.Button` binds `<space>` on X11 but **not** `<Return>`; `Entry`/`Spinbox`
|
||||
bind `<Return>` to nothing. Bind `<Return>` yourself for submit.
|
||||
- `tk.Text` traps `<Tab>` (inserts a literal tab, refocuses itself) and makes
|
||||
`<Shift-Tab>` a no-op. Rebind both to move focus, or the box is a dead end.
|
||||
- `tk.Canvas` has no class key bindings, so a Canvas scroll viewport is not even
|
||||
a focus stop. Bind `<Prior>`/`<Next>`/arrows, **and** add a `<FocusIn>` handler
|
||||
on children that scrolls the focused child into view — clipping does not
|
||||
unmap children, so focus walks into invisible fields by default.
|
||||
- `OptionMenu`/`Menubutton` default to `takefocus=0`. On lock surfaces they are
|
||||
banned anyway (a posted menu steals the grab); use radio buttons.
|
||||
- `ttk.Notebook` needs an explicit `enable_traversal()` for `Ctrl+Tab` /
|
||||
`Ctrl+PageDown` / `Alt`+mnemonic.
|
||||
- Verify the focus ring by *walking* it (`tk_focusNext`), not by reading code.
|
||||
|
||||
Measure the height rather than eyeballing it — this is how the meal-gate
|
||||
overflow was found, and it is what the render half of the gate does:
|
||||
|
||||
```bash
|
||||
xvfb-run -s "-screen 0 1366x768x24" python3 -m your_pkg.tests.measure_layout
|
||||
# assert frame.winfo_reqheight() <= available_pane_height
|
||||
```
|
||||
|
||||
Measure both empty **and** fully populated (a day of meals logged, history rows
|
||||
present) — the empty case is not the worst case.
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Don't use `ColorScheme.fromSeed` for the unified palette — it can't
|
||||
reproduce hand-picked hex values exactly.
|
||||
- Don't pass a positive font size to Tk — that's points, ~37% oversized.
|
||||
- Don't make long-press, hover, or drag the only route to an action.
|
||||
- Don't set `highlightthickness=0` on anything focusable, or set
|
||||
`highlightbackground` when you meant `highlightcolor`.
|
||||
- Don't centre-anchor content that can overflow — it clips symmetrically, taking
|
||||
the header and the submit button with it.
|
||||
- Don't tune a layout against a 1080p screen, and don't test responsiveness only
|
||||
at phone-portrait sizes; neither exercises 1366x768 or 1024x600.
|
||||
- Don't add shadows to dark surfaces, or mix shadow + border depth on the
|
||||
same element (see `tokens.md`'s shadow policy).
|
||||
- Don't invent a new spacing/radius/font value outside the scales in
|
||||
|
||||
@ -98,6 +98,26 @@ contexts only.
|
||||
Line length (rule 21): prose/paragraph blocks capped at `max-width: 40rem`
|
||||
(~640px, ~65–70 characters).
|
||||
|
||||
### The sizes above are **pixels**. On Tk that means a negative number.
|
||||
|
||||
Tk encodes the unit in the *sign* of the font size: **positive = points,
|
||||
negative = pixels**. So `("Arial", 16)` is 16 **points**, not 16 pixels, and
|
||||
renders ~37% larger than this scale specifies. Measured:
|
||||
|
||||
| Spec | `size=16` (points) | `size=-16` (pixels) |
|
||||
| ---- | ------------------ | ------------------- |
|
||||
| `linespace` | 26px | 19px |
|
||||
| `ascent` | 21px | 15px |
|
||||
|
||||
Always write the negative form in Tk — `font=(family, -16)` for `body`. Passing
|
||||
the positive px value straight through inflates every string in the app by a
|
||||
third, which is enough on its own to push a 768px-tall layout off-screen (it
|
||||
did: it was the root cause of the diet-guard meal gate needing 974px in a 724px
|
||||
pane). This is a silent failure — nothing errors, the text is just wrong-sized —
|
||||
so it is checked by the gate rather than left to review.
|
||||
|
||||
Flutter `fontSize` and CSS `px` are already pixels; no sign convention there.
|
||||
|
||||
## Buttons
|
||||
|
||||
Horizontal padding = 2× vertical (rule 22). Canonical: vertical `12px`,
|
||||
@ -121,3 +141,85 @@ at the same z-level.
|
||||
|
||||
Icons adjacent to text render at ~72% opacity of the text's color, or use the
|
||||
`muted` role instead of the full-strength text role — never equal contrast.
|
||||
|
||||
## Pointer-free operability (required)
|
||||
|
||||
**Every action must be reachable and activatable with the keyboard alone.** No
|
||||
mouse, no touchpad, no touch. This is not an accessibility nicety here: several
|
||||
of these apps are *lockers* that hold a global input grab with VT switching
|
||||
disabled, so a control only a pointer can reach is not an inconvenience — it is
|
||||
a bricked machine.
|
||||
|
||||
Six things must hold on every surface:
|
||||
|
||||
1. **No pointer-only handler.** Any `onTap`/`onLongPress`/`<Button-1>`/click
|
||||
handler needs a keyboard-reachable equivalent on the same action. A bare
|
||||
`GestureDetector` and an `onLongPress`-only destructive action are both
|
||||
defects. Long-press in particular has *no* keyboard analogue — never make it
|
||||
the only path to an action.
|
||||
2. **Everything interactive is a focus stop.** Widgets that opt out of the focus
|
||||
ring (`takefocus=0`, which Tk's `Menubutton`/`OptionMenu` default to) are
|
||||
unreachable. Verify by walking the real ring, not by reading the code.
|
||||
3. **Focus is visible.** See the focus-ring token below — the platform default
|
||||
is a black ring, which vanishes on `ink`.
|
||||
4. **Focus never lands somewhere invisible.** If a container scrolls, focus
|
||||
entering a clipped child must scroll it into view. Clipping does not remove a
|
||||
widget from the focus ring, so this is the default failure, not an edge case.
|
||||
5. **Scrolling is keyboard-driven.** A scroll container needs `PageUp`/`PageDown`
|
||||
and arrow keys, not just a draggable thumb and a wheel.
|
||||
6. **Confirm/submit and cancel/back have accelerators.** `Enter` submits, `Escape`
|
||||
cancels. On Tk, note `Enter` does **not** activate a `tk.Button` on X11 (only
|
||||
`Space` does) and is bound to nothing on `Entry`/`Spinbox` — you must bind it.
|
||||
|
||||
### The focus-ring token
|
||||
|
||||
| Token | Value | Notes |
|
||||
| ------------ | ---------- | -------------------------------------------- |
|
||||
| `focus-ring` | `#B8862E` (= `accent`) | 2px, offset 1px. |
|
||||
|
||||
Platform defaults are wrong on this palette: Tk ships
|
||||
`highlightcolor="#000000"` with `highlightthickness=1`, i.e. a black ring on
|
||||
`ink` (`#211D1B`) — invisible. Set `focus-ring` explicitly. Never
|
||||
`highlightthickness=0` on something focusable.
|
||||
|
||||
⚠️ On Tk, `highlightbackground` is the **unfocused** ring and `highlightcolor`
|
||||
is the **focused** one. Setting only `highlightbackground` inverts the
|
||||
affordance — the widget outlines when it is *not* focused and goes black when it
|
||||
*is*. Set `highlightcolor`.
|
||||
|
||||
### Escape hatches vs. deliberate gating
|
||||
|
||||
Some apps intentionally resist dismissal (an alarm you must solve a challenge to
|
||||
silence, a meal gate you must log a meal to clear). That gating is a feature and
|
||||
this rule does not override it. What the rule requires is that the *sanctioned*
|
||||
paths — solve the challenge, use the budgeted escape hatch, submit the form — are
|
||||
fully keyboard-operable. Making a gate keyboard-reachable is not weakening it.
|
||||
|
||||
## Screen size (required)
|
||||
|
||||
| Target | Requirement |
|
||||
| ------ | ----------- |
|
||||
| **1366x768** | Fully usable. Nothing clipped, nothing unreachable. This is the primary machine. |
|
||||
| **1024x600** | Degrades gracefully — scrolls rather than clips. No hard minimum size above this. |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Budget the height, don't assume it.** 768px is the design target, not 1080.
|
||||
Mixing a proportional content area with fixed chrome (`height = 0.7·H` above a
|
||||
fixed-height button row) silently works at 1080 and fails at 768 — compute the
|
||||
content budget as `available − measured chrome`.
|
||||
2. **Any view that can grow must scroll.** Lists, forms, anything whose height
|
||||
depends on data (a day's log, an error string, accumulated history). An
|
||||
unbounded error message is a real overflow source.
|
||||
3. **Never centre unscrollable content that can overflow.** A centre-anchored
|
||||
container clips **symmetrically** when it is too tall — losing the header *and*
|
||||
the submit button, with no scrollbar and no indication anything is missing.
|
||||
Top-anchor it inside a scroll container instead.
|
||||
4. **Clamp width as well.** Prose stays at the 40rem/640px cap (rule 21) and
|
||||
single-purpose inputs get a sensible max — a 4-digit number field stretched to
|
||||
1334px is a violation even though nothing is clipped.
|
||||
5. **Don't let text scale break layout.** Rows that must not overflow use `Wrap`/
|
||||
`Flexible` rather than a bare `Row`.
|
||||
|
||||
`1366x768` and `1024x600` are landscape and *short*. Phone-portrait test sizes do
|
||||
not exercise either constraint, so both belong in the test matrix explicitly.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user