screen-locker/screen_locker/_window_setup.py
Krzysztof Rudnicki 3e77f4e232 Make every lock screen keyboard-operable, and add the missing installer
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.

Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.

install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.

The keyboard work, all of it Tk defaults rather than local mistakes:

- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
  #211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
  had no indication of where they were. Now set in the _ui_widgets factory, so
  a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
  a documented no-op arguing "buttons only, nothing to focus" -- exactly
  backwards: buttons are what needs focusing when there is no other window on
  screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
  Return and no Escape. It now focuses the *declining* option, so a blind
  Return keeps the workout rather than silently skipping it.

The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.

All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.

Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.

The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:25:45 +02:00

242 lines
10 KiB
Python

"""Auxiliary (non-lock) window setup for ScreenLocker.
The fullscreen lock-window mechanics (overrideredirect, input grab,
VT-disable) now live in the shared ``gatelock`` package. This module keeps
only the screen-locker-specific windows that are never the lock itself: the
post-sick-day verification window, the demo close button, and the optional
relaxed-day prompt.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import (
RANK_SCREEN_LOCKER,
Arbiter,
LockWindow,
ScrollableSurface,
)
from screen_locker._surface_group import FrameGroup
if TYPE_CHECKING:
from gatelock import SurfaceInfo
# Tk classes that accept keyboard focus by default. Checking ``takefocus`` alone
# is not enough: it is "" on most widgets, which means "ask ::tk::FocusOK", so an
# empty value indicates a default-focusable widget rather than an opted-out one.
_FOCUSABLE_CLASSES = frozenset(
{"Button", "Checkbutton", "Entry", "Listbox", "Radiobutton", "Spinbox", "Text"}
)
def _first_focusable(widget: tk.Misc) -> tk.Misc | None:
"""Return the first descendant that would accept keyboard focus."""
for child in widget.winfo_children():
if str(child.cget("takefocus")) == "0":
continue
if child.winfo_class() in _FOCUSABLE_CLASSES:
return child
deeper = _first_focusable(child)
if deeper is not None:
return deeper
return None
class WindowSetupMixin:
"""Mixin providing the screen-locker-specific auxiliary windows."""
# Declared because this mixin both reads and *reassigns* it; without the
# annotation mypy cannot infer the attribute's type through the mixin.
container: FrameGroup
@property
def _scrollers(self) -> dict[str, ScrollableSurface]:
"""Viewports for the live outputs, keyed by output name.
Held so a repaint can reset the scroll position and re-arm
focus-following on the newly built widgets.
Created lazily by this mixin rather than initialised by the host, so the
mixin owns its own state: ``build_surface`` can be called before the
host's ``__init__`` finishes (``LockWindow.setup()`` invokes it), and
requiring the host to remember an assignment is the kind of coupling
that breaks quietly when a new host appears.
"""
# hasattr rather than try/except: an AttributeError here would be a real
# bug worth surfacing, and catching it to mean "first call" is exactly
# the swallowed-exception pattern this repo bans.
if not hasattr(self, "_scroller_map"):
self._scroller_map: dict[str, ScrollableSurface] = {}
return self._scroller_map
def _build_lock_window(self) -> LockWindow:
"""Claim the screen at this app's rank and build the lock.
The arbiter is published here and then handed to ``LockWindow``,
which owns releasing it on close -- so it is deliberately not kept
as an attribute of the locker.
"""
arbiter = Arbiter(
"screen_locker",
RANK_SCREEN_LOCKER,
grab=self._colors.resolved_grab(),
disable_vt=self._colors.resolved_disable_vt(),
)
arbiter.publish()
arbiter.acquire_holder()
lock = LockWindow(self.root, self._colors, hooks=self, arbiter=arbiter)
lock.setup()
return lock
def _ensure_container(self) -> None:
"""Give the non-lock windows a scrollable container group of one.
The verify and relaxed-day windows never build a lock, so nothing
called ``build_surface`` and the group is still empty.
They get the same viewport the lock surfaces do rather than a bare
centred frame: their content already exceeds their window (see
:meth:`_setup_aux_window`), and a centred frame clips symmetrically
instead of scrolling. ``center_when_fits`` preserves the centred look
for the screens that do fit.
"""
if self.container.surfaces:
return
scroller = ScrollableSurface(self.root, self._colors, center_when_fits=True)
scroller.container.pack(fill="both", expand=True)
self._scrollers["__aux__"] = scroller
self.container = FrameGroup([scroller.content])
def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None:
"""Add one scrollable, keyboard-reachable container for a live output.
Only the container is built here, not the screen inside it: which
screen is showing depends on flow state the lock knows nothing
about, so the caller repaints through the usual flow entry point
once the surface exists.
This was a bare ``place(relx=0.5, rely=0.5, anchor="center")`` frame.
A ``place``d frame takes its *requested* size and is clipped by its
parent, so any screen taller than the display lost content off the top
**and** the bottom simultaneously -- the heading and the submit button
at once -- with no scrollbar and nothing to indicate anything was
missing. On a 768px panel several screens do exceed the display (the
status view stacks a data-independent seven-day block plus four
buttons), and this is a hard lock with a global grab and VT switching
disabled, so unreachable content is unrecoverable.
``center_when_fits`` keeps short screens looking exactly as they did.
"""
scroller = ScrollableSurface(parent, self._colors, center_when_fits=True)
scroller.container.place(relx=0, rely=0, relwidth=1, relheight=1)
self._scrollers[surface.output_name] = scroller
self.container.add(scroller.content, surface.output_name)
def teardown_surface(self, surface: SurfaceInfo) -> None:
"""Forget the container and viewport for an output that went dark."""
self.container.discard(surface.output_name)
self._scrollers.pop(surface.output_name, None)
def settle_surfaces(self) -> None:
"""Re-arm focus-following and reset scroll after a repaint.
Called once a flow has finished painting a screen. Focus-following has
to be re-armed because it binds ``<FocusIn>`` per widget, and a repaint
replaces those widgets; the scroll reset matters because a new screen
should start at its top, not wherever the previous one was left.
"""
for scroller in self._scrollers.values():
scroller.finalize()
def on_focus_ready(self, surface: SurfaceInfo | None) -> None:
"""Focus the first button so the lock is operable without a pointer.
This fires once, when the lock is mapped and grabbed. At that moment the
screen is the phone-check status -- buttons only -- which this hook used
to treat as "nothing to focus". That reasoning was wrong: buttons are
exactly what needs focusing. The lock holds a *global* grab with VT
switching disabled and there is no other window on screen, so starting
with nothing focused meant a keyboard-only user had to blind-Tab into a
ring that (before the focus-ring fix) was invisible black-on-black, with
no way to tell what was selected or whether the keypress had landed.
The screens that take typed input (the manual-workout form, the sick
dialog) still focus their own first field as they are built; this only
covers the button-only screens that previously focused nothing.
``surface`` is None when no output is live at all -- the lock is held
with nothing to show, which is still a valid state and needs no focus.
"""
if surface is None:
return
self._focus_first_focusable()
def _focus_first_focusable(self) -> None:
"""Focus the first keyboard-focusable widget on the primary surface.
Walks the tree rather than taking a widget reference, because which
screen is painted depends on flow state this mixin does not track.
"""
if not self.container.surfaces:
return
found = _first_focusable(self.container.first)
if found is not None:
found.focus_set()
def on_callback_error(self) -> None:
"""Surfaced via GateRoot's logging already; no extra action yet."""
def on_close(self) -> None:
"""No extra hardware/state beyond what close() already handles."""
def _setup_verify_window(self) -> None:
"""Configure window for post-sick-day workout verification."""
self._setup_aux_window(min_width=600, min_height=400)
def _setup_demo_close_button(self) -> None:
"""Add close button for demo mode."""
close_btn = tk.Button(
self.root,
text="✕ Close Demo",
font=(self._colors.font_family, 12),
bg=self._colors.danger,
fg=self._colors.on_fill,
command=self.close,
cursor="hand2",
)
close_btn.place(x=10, y=10)
def _setup_relaxed_day_window(self) -> None:
"""Configure a small non-locking window for the optional Tue-Thu prompt."""
self._setup_aux_window(min_width=700, min_height=450)
def _setup_aux_window(self, *, min_width: int, min_height: int) -> None:
"""Configure a non-locking window that grows to fit its own content.
These two windows used to hardcode ``geometry("600x400")`` and
``geometry("700x450")``. Both were too small for what they display: the
relaxed-day prompt's own button row requests **862px** against a 700px
window, so it was sheared at *both* edges -- centred content clipped
symmetrically, exactly as on the lock surfaces, and pre-dating the
keyboard work by a wide margin (Tk's default focus ring accounts for
4px of it).
So the size is left to Tk (``geometry("")`` means "fit the content") with
the old values kept only as a *minimum*, and clamped to the display so a
wide screen's content cannot push the window off-screen. Content that
still does not fit scrolls, because ``_ensure_container`` wraps these
windows in a viewport too.
"""
self.root.configure(bg=self._colors.bg, cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close)
# "" hands sizing back to the geometry manager instead of pinning it.
self.root.geometry("")
self.root.minsize(
min(min_width, self.root.winfo_screenwidth()),
min(min_height, self.root.winfo_screenheight()),
)
self.root.maxsize(self.root.winfo_screenwidth(), self.root.winfo_screenheight())