mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
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>
265 lines
9.5 KiB
Python
265 lines
9.5 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 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,
|
|
*,
|
|
font_size: int = 36,
|
|
color: str | None = None,
|
|
pady: int = 20,
|
|
) -> WidgetGroup:
|
|
"""Create and pack a bold label on every monitor."""
|
|
label = self.container.child_widgets(
|
|
tk.Label,
|
|
text=text,
|
|
font=(self._colors.font_family, font_size, "bold"),
|
|
fg=color or self._colors.fg,
|
|
bg=self._colors.bg,
|
|
)
|
|
label.pack(pady=pady)
|
|
return label
|
|
|
|
def _text(
|
|
self,
|
|
text: str,
|
|
*,
|
|
font_size: int = 18,
|
|
color: str | None = None,
|
|
pady: int = 10,
|
|
) -> WidgetGroup:
|
|
"""Create and pack a non-bold text label on every monitor."""
|
|
label = self.container.child_widgets(
|
|
tk.Label,
|
|
text=text,
|
|
font=(self._colors.font_family, font_size),
|
|
fg=color or self._colors.fg,
|
|
bg=self._colors.bg,
|
|
)
|
|
label.pack(pady=pady)
|
|
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_family, 24, "bold"),
|
|
bg=bg,
|
|
fg=self._fg_for(bg),
|
|
width=width,
|
|
command=command,
|
|
cursor="hand2" if self.demo_mode else "",
|
|
relief="flat",
|
|
padx=24,
|
|
pady=12,
|
|
**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."""
|
|
frame = self.container.child_frame(bg=self._colors.bg)
|
|
frame.pack(pady=20)
|
|
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=8, fill="x")
|
|
row.child_widgets(
|
|
tk.Label,
|
|
text=label,
|
|
font=(self._colors.font_family, 14),
|
|
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_family, 14),
|
|
bg=self._colors.field_bg,
|
|
fg=self._colors.fg,
|
|
insertbackground=self._colors.fg,
|
|
**self._colors.focus_kwargs(),
|
|
)
|
|
entries.pack(side="top", anchor="w", pady=4)
|
|
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=8, fill="x")
|
|
row.child_widgets(
|
|
tk.Label,
|
|
text=label,
|
|
font=(self._colors.font_family, 14),
|
|
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_family, 14),
|
|
bg=self._colors.field_bg,
|
|
fg=self._colors.fg,
|
|
insertbackground=self._colors.fg,
|
|
**self._colors.focus_kwargs(),
|
|
)
|
|
boxes.pack(side="top", anchor="w", pady=4)
|
|
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
|