mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
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
288 lines
11 KiB
Python
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
|