screen-locker/screen_locker/_ui_widgets.py
Krzysztof Rudnicki a79c4fdd05 Stop the lock screen scrolling itself, and make every screen fit 768px
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.

The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:

* Every font size and pad now goes through the design-system tokens rather
  than a literal. A positive size means *points* to Tk, so "36" rendered
  ~37% larger than the 36px intended; that alone was most of the overflow,
  on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
  the three reflection boxes share rows instead of taking one each.

Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.

Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.

Tests: 896 pass. The focus/scroll helpers shipped in 3e77f4e had no coverage
at all; test_focus_and_scroll.py covers them now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 15:57:48 +02:00

288 lines
11 KiB
Python

"""UI widget helper methods mixin for the screen locker."""
from __future__ import annotations
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import bind_activate, escape_text_tab_trap
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock import SpaceStep, TypeRole
from screen_locker._surface_group import FrameGroup, WidgetGroup
def disable_paste(widget: tk.Widget) -> None:
"""Disable paste in a Tk Entry/Text widget.
Friction-only: a determined user can still bypass via xdotool, but the
point is removing the trivial Ctrl+V shortcut so the user must
actually type their input.
"""
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
with contextlib.suppress(tk.TclError, AttributeError):
widget.bind(sequence, lambda _e: "break")
class UIWidgetsMixin:
"""Mixin providing low-level widget creation helpers."""
def clear_container(self) -> None:
"""Remove all widgets from the main container, on every monitor.
Also schedules the surface viewports to re-settle once the new screen
has been painted. Every repaint in the app starts by calling this, so
hooking it here is what makes focus-following and scroll-reset
unforgettable -- there are ~19 repaint sites, and "remember to call
settle after painting" is a rule that would eventually be missed, on a
lock where a missed re-arm means unreachable content.
``after_idle`` is what defers the work until *after* the caller has
finished building widgets; running it inline here would settle an empty
container. Repeated calls within one repaint coalesce harmlessly.
"""
self.container.clear()
settle = getattr(self, "settle_surfaces", None)
if settle is not None:
self.root.after_idle(settle)
def _label(
self,
text: str,
*,
role: TypeRole = "display",
scale: float = 1.0,
color: str | None = None,
pad: SpaceStep = "md",
) -> WidgetGroup:
"""Create and pack a bold label on every monitor.
``role``/``pad`` name a design-system token instead of a pixel count.
That is what makes a screen shrink on a short display: the tokens are
compacted per screen height (``gatelock._density``), and a raw literal
is not. It also fixes the unit bug the literals carried -- Tk reads a
positive size as *points*, so ``36`` rendered ~37% larger than the
36px it was meant to be, on every screen.
``scale`` is for deliberate emphasis (a hero countdown), kept explicit
so an outlier is visible rather than hidden behind a fresh literal.
"""
label = self.container.child_widgets(
tk.Label,
text=text,
font=self._colors.font(role, bold=True, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
)
label.pack(pady=self._colors.space(pad))
return label
def _text(
self,
text: str,
*,
role: TypeRole = "body",
scale: float = 1.0,
color: str | None = None,
pad: SpaceStep = "sm",
) -> WidgetGroup:
"""Create and pack a non-bold text label on every monitor.
See :meth:`_label` for why these are token names, not pixel counts.
"""
label = self.container.child_widgets(
tk.Label,
text=text,
font=self._colors.font(role, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
)
label.pack(pady=self._colors.space(pad))
return label
def _fg_for(self, bg: str) -> str:
"""Return the correct label color for a widget with background `bg`.
The four filled semantic surfaces (accent/success/warning/danger)
all sit in the same mid-light lightness band, so `fg` (near-white,
meant for the neutral `bg`/`field_bg` surfaces) under-contrasts on
every one of them -- `on_fill` (dark) is required instead. Deriving
this from `bg` means a new filled-button call site can't reintroduce
the bug by omission.
"""
fills = {
self._colors.accent,
self._colors.success,
self._colors.warning,
self._colors.danger,
}
return self._colors.on_fill if bg in fills else self._colors.fg
def _button(
self,
parent: FrameGroup,
text: str,
*,
bg: str,
command: Callable[[], None],
width: int = 10,
) -> WidgetGroup:
"""Create a styled button on every monitor (caller must pack).
Every copy gets the *same* ``command``, so pressing the button on
whichever screen the user is actually looking at does the one thing
once -- the same trick the shared entry variable plays for typing.
Padding follows the shared 2x horizontal:vertical ratio (rule 22);
`relief="flat"` is the one depth technique every button in the app
uses now (rule 27), matching what `_heat_skip.py` already did alone.
Two keyboard properties are wired here rather than per call site, so a
new button cannot omit them:
* A **visible focus ring** (`focus_kwargs`). Tk's default is a 1px
*black* ring, which on ``bg`` (#211D1B) is invisible -- and
`relief="flat"` removes the depth cue too, so an unringed button
gives a keyboard user no indication of where they are.
* **Enter activates.** Tk's X11 class bindings give ``Button`` only
``<space>``; there is no ``<Return>`` binding at all. Under a global
grab with no pointer, "the obvious key does nothing" is the
difference between satisfying the lock and being stuck at it.
"""
buttons = parent.child_widgets(
tk.Button,
text=text,
font=self._colors.font("title", bold=True),
bg=bg,
fg=self._fg_for(bg),
width=width,
command=command,
cursor="hand2" if self.demo_mode else "",
relief="flat",
padx=self._colors.space("lg"),
pady=self._colors.space("sm"),
**self._colors.focus_kwargs(),
)
# Every per-monitor copy, not just `.first` -- one flow paints N screens
# and the user may be looking at any of them.
for button in buttons:
bind_activate(button)
return buttons
def _button_row(self) -> FrameGroup:
"""Create and pack a horizontal button container on every monitor.
The gap is "sm" rather than "md" because this row is the last thing
on every screen: the pixels it spends are the ones the manual-workout
form has left over on a 1024x600 panel.
"""
frame = self.container.child_frame(bg=self._colors.bg)
frame.pack(pady=self._colors.space("sm"))
return frame
def _add_label_entry(
self,
parent: FrameGroup,
*,
label: str,
variable: tk.StringVar,
focus: bool = False,
) -> None:
"""Add a label + single-line entry pair on every monitor.
All copies share one ``variable``, so typing on any screen is the
same text everywhere and reading it back needs no idea which monitor
the user actually used.
``focus`` gives the primary surface's copy the keyboard focus. Exactly
one field per screen should ask: the lock takes a global grab, so with
nothing focused the user's typing goes nowhere and there is no other
window on screen to make that obvious.
"""
row = parent.child_frame(bg=self._colors.bg)
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(side="top", anchor="w")
entries = row.child_widgets(
tk.Entry,
textvariable=variable,
width=50,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
entries.pack(side="top", anchor="w", pady=self._colors.space("xs"))
for entry in entries:
disable_paste(entry)
if focus:
entries.first.focus_set()
def _add_label_text(
self,
parent: FrameGroup,
*,
label: str,
height: int = 4,
) -> WidgetGroup:
"""Add a label + multi-line ``Text`` pair on every monitor.
Use this instead of building a bare ``tk.Text``: it un-traps Tab.
Tk makes ``<Tab>`` insert a literal tab and refocus the widget, and
binds ``<Shift-Tab>`` to nothing, so an untreated multi-line field is a
keyboard dead end whose only exits (``Ctrl+Tab``/``Ctrl+Shift+Tab``) are
not advertised anywhere. On a lock surface that means a user who tabs
into it can never reach the submit button.
"""
row = parent.child_frame(bg=self._colors.bg)
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(side="top", anchor="w")
boxes = row.child_widgets(
tk.Text,
width=60,
height=height,
wrap="word",
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
boxes.pack(side="top", anchor="w", pady=self._colors.space("xs"))
for box in boxes:
disable_paste(box)
escape_text_tab_trap(box)
return boxes
def _focus_first_button(self, row: FrameGroup) -> None:
"""Give the primary surface's first button keyboard focus.
The lock takes a global grab and there is no other window on screen, so
a surface that starts with *nothing* focused leaves the user pressing
keys into the void with no visible ring to hint where focus went. The
retry screen (TRY AGAIN / I'm sick / Log Manual Workout) had exactly
that problem, because ``on_focus_ready`` is a documented no-op.
"""
for child in row.first.winfo_children():
if child.winfo_class() == "Button":
child.focus_set()
return