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
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 15:57:48 +02:00
parent 3e77f4e232
commit a79c4fdd05
18 changed files with 907 additions and 303 deletions

View File

@ -99,7 +99,7 @@ repos:
- --jobs=4
additional_dependencies:
- pytest
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
- mcp
- pydantic
@ -153,6 +153,13 @@ repos:
entry: python3 scripts/check_silent_failures.py
language: system
types: [python]
- id: screens-fit
name: Every lock screen fits 1366x768 (no scrolling required)
entry: python3 scripts/verify_screen_fits.py
language: system
files: '^screen_locker/.*\.py$'
pass_filenames: false
require_serial: true
- id: flutter-analyze
name: flutter analyze (workout app)
# env -u GIT_DIR: git exports it to hooks, and flutter shells out to

View File

@ -7,7 +7,7 @@ requires-python = ">=3.10"
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync",
]

View File

@ -4,7 +4,7 @@ bandit>=1.7.0
codespell>=2.2.0
coverage>=7.4.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
mcp>=1.9
mypy>=1.8.0
pre-commit>=3.6.0

View File

@ -4,11 +4,15 @@ from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import LockConfig, bind_activate, bind_cancel
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
# Sourced from the shared gatelock LockConfig (unified-design-system) instead
@ -19,6 +23,98 @@ _COLORS = LockConfig()
_FONT = "monospace"
def build_heat_skip_content(
parent: tk.Misc,
temp: float,
*,
on_skip: Callable[[], None],
on_decline: Callable[[], None],
) -> tk.Frame:
"""Build the heat-skip screen inside ``parent`` and return its frame.
Separate from :meth:`HeatSkipMixin._show_heat_skip_dialog` because that
method owns a fullscreen ``tk.Tk()`` of its own, which
``scripts/verify_screen_fits.py`` cannot measure. This is a lock screen
like any other -- grabbed, undecorated, ``place``-centred, so it clips at
*both* edges if it ever outgrows the display -- and it has to be in the
fit check for that reason.
"""
outer = tk.Frame(parent, bg=_COLORS.bg)
outer.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(
outer,
text="\u2600 Too hot to workout?",
font=_COLORS.font("body", bold=True, family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.warning,
).pack(pady=(0, _COLORS.space("sm")))
tk.Label(
outer,
text=(
f"{HEAT_SKIP_CITY}: {temp:.0f}\u00b0C"
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
),
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
).pack(pady=_COLORS.space("xs"))
tk.Label(
outer,
text="Skip today's workout due to extreme heat?",
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
).pack(pady=(_COLORS.space("xs"), 0))
btn_frame = tk.Frame(outer, bg=_COLORS.bg)
btn_frame.pack(pady=_COLORS.space("lg"))
skip_button = tk.Button(
btn_frame,
text="Skip workout",
command=on_skip,
bg=_COLORS.warning,
fg=_COLORS.on_fill,
activebackground=_COLORS.warning,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
relief="flat",
**_COLORS.focus_kwargs(),
)
skip_button.pack(side="left", padx=_COLORS.space("sm"))
decline_button = tk.Button(
btn_frame,
text="No, I'll workout",
command=on_decline,
bg=_COLORS.field_bg,
fg=_COLORS.fg,
activebackground=_COLORS.field_bg,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
relief="flat",
**_COLORS.focus_kwargs(),
)
decline_button.pack(side="left", padx=_COLORS.space("sm"))
# This modal grabs input and sits fullscreen with no window decoration, so
# the keyboard is the only way out for a pointerless user. It used to
# focus_force() the root and stop there: nothing was focused, no ring was
# visible (Tk's default is black on bg), Enter did nothing because Tk binds
# only <space> on Button, and there was no <Escape>. Default focus goes to
# the *declining* option, so a blind Return keeps the workout rather than
# silently skipping it.
for button in (skip_button, decline_button):
bind_activate(button)
decline_button.focus_set()
return outer
class HeatSkipMixin:
"""Provides _show_heat_skip_dialog and _save_heat_skip_log."""
@ -41,40 +137,6 @@ class HeatSkipMixin:
root.grab_set()
root.focus_force()
# Content centred on the fullscreen canvas
outer = tk.Frame(root, bg=_COLORS.bg)
outer.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(
outer,
text="☀ Too hot to workout?",
font=(_FONT, 18, "bold"),
bg=_COLORS.bg,
fg=_COLORS.warning,
).pack(pady=(0, 10))
tk.Label(
outer,
text=(
f"{HEAT_SKIP_CITY}: {temp:.0f}°C"
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}°C)"
),
font=(_FONT, 16),
bg=_COLORS.bg,
fg=_COLORS.muted,
).pack(pady=6)
tk.Label(
outer,
text="Skip today's workout due to extreme heat?",
font=(_FONT, 16),
bg=_COLORS.bg,
fg=_COLORS.muted,
).pack(pady=(6, 0))
btn_frame = tk.Frame(outer, bg=_COLORS.bg)
btn_frame.pack(pady=28)
def _on_skip() -> None:
result[0] = True
root.destroy()
@ -83,47 +145,8 @@ class HeatSkipMixin:
result[0] = False
root.destroy()
skip_button = tk.Button(
btn_frame,
text="Skip workout",
command=_on_skip,
bg=_COLORS.warning,
fg=_COLORS.on_fill,
activebackground=_COLORS.warning,
font=(_FONT, 14),
padx=16,
pady=8,
relief="flat",
**_COLORS.focus_kwargs(),
)
skip_button.pack(side="left", padx=12)
decline_button = tk.Button(
btn_frame,
text="No, I'll workout",
command=_on_no,
bg=_COLORS.field_bg,
fg=_COLORS.fg,
activebackground=_COLORS.field_bg,
font=(_FONT, 14),
padx=16,
pady=8,
relief="flat",
**_COLORS.focus_kwargs(),
)
decline_button.pack(side="left", padx=12)
# This modal grabs input and sits fullscreen with no window decoration,
# so the keyboard is the only way out for a pointerless user. It used to
# focus_force() the root and stop there: nothing was focused, no ring was
# visible (Tk's default is black on bg), Enter did nothing because Tk
# binds only <space> on Button, and there was no <Escape>. Default focus
# goes to the *declining* option, so a blind Return keeps the workout
# rather than silently skipping it.
for button in (skip_button, decline_button):
bind_activate(button)
build_heat_skip_content(root, temp, on_skip=_on_skip, on_decline=_on_no)
bind_cancel(root, _on_no)
decline_button.focus_set()
root.mainloop()

View File

@ -39,7 +39,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
def _show_manual_workout_form(self) -> None:
"""Render the manual-workout evidence form, or a budget-exhausted note."""
self.clear_container()
self._label("Log Manual Workout", color=self._colors.accent, pady=10)
self._label("Log Manual Workout", color=self._colors.accent, pad="xs")
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted for this window.",
@ -56,10 +56,13 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
bg=self._colors.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
return
self._text(
_manual_workout.budget_summary(self.log_file), color=self._colors.accent
_manual_workout.budget_summary(self.log_file),
role="label",
color=self._colors.accent,
pad="xs",
)
self._build_manual_workout_form()
@ -89,7 +92,10 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
copy per monitor would show two different parts of one form.
"""
form = tk.Frame(self.container.first, bg=self._colors.bg)
form.pack(fill="both", expand=True, pady=10)
# No outer gap: the budget line above and the first section heading
# below already separate the grid, and on a 1024x600 panel this form
# fits by single-digit pixels.
form.pack(fill="both", expand=True)
form.grid_columnconfigure(0, weight=1, uniform="mw")
form.grid_columnconfigure(1, weight=1, uniform="mw")
return form
@ -137,7 +143,12 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
sport_frame.grid_columnconfigure(0, weight=1, uniform="mwact")
sport_frame.grid_columnconfigure(1, weight=1, uniform="mwact")
sport_frame.grid(
row=act_row, column=0, columnspan=2, sticky="ew", padx=12, pady=6
row=act_row,
column=0,
columnspan=2,
sticky="ew",
padx=self._colors.space("sm"),
pady=self._colors.space("xs"),
)
self._build_table_tennis_fields(self._mw_tt_frame)
self._build_other_sport_fields(self._mw_other_frame)
@ -170,7 +181,9 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
)
self._mw_error_label = self._text("", color=self._colors.danger, pady=8)
self._mw_error_label = self._text(
"", role="label", color=self._colors.danger, pad="xs"
)
button_row = self._button_row()
self._button(
button_row,
@ -178,14 +191,14 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
bg=self._colors.accent,
command=self._submit_manual_workout_form,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
def _mw_sport_row(self, parent: tk.Widget) -> None:
"""Add the sport-selector radio buttons; swaps the activity section.
@ -213,10 +226,10 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
tk.Label(
row,
text="Sport:",
font=(self._colors.font_family, 16),
font=self._colors.font("body"),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
for label in _manual_workout.SPORT_LABELS.values():
tk.Radiobutton(
row,
@ -225,7 +238,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
variable=self._mw_sport_var,
# Radiobutton's command takes no argument -- bind the label.
command=lambda chosen=label: self._on_mw_sport_changed(chosen),
font=(self._colors.font_family, 16),
font=self._colors.font("body"),
fg=self._colors.fg,
bg=self._colors.bg,
activeforeground=self._colors.fg,
@ -235,7 +248,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
# form, which likely added to it reading as dead.
selectcolor=self._colors.field_bg,
highlightthickness=0,
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
self._mw_grid(parent, row, full=True)
def _on_mw_sport_changed(self, selected_label: str) -> None:

View File

@ -45,8 +45,10 @@ class ManualWorkoutFormWidgetsMixin:
column=col,
columnspan=2 if full else 1,
sticky="ew",
padx=12,
pady=8,
padx=self._colors.space("sm"),
# "xs", not "sm": with ten cells plus headings this gap is paid
# per row, and the form has to fit a 768px panel in full.
pady=self._colors.space("xs"),
)
counters[id(parent)] = idx + (2 if full else 1)
@ -69,7 +71,7 @@ class ManualWorkoutFormWidgetsMixin:
label = tk.Label(
parent,
text=title,
font=(self._colors.font_family, 20, "bold"),
font=self._colors.font("body", bold=True),
fg=self._colors.accent,
bg=self._colors.bg,
anchor="w",
@ -94,24 +96,29 @@ class ManualWorkoutFormWidgetsMixin:
"""
var = tk.StringVar()
cell = tk.Frame(parent, bg=self._colors.bg)
# Label beside the field, not above it. Stacked, each of the ten cells
# cost two lines plus the gap between them, and the form measured
# 1320px against a 768px panel; side-by-side each cell is one line
# tall. The lock cannot scroll its way out of not fitting, so rows this
# form does not need are rows it must not spend.
tk.Label(
cell,
text=label,
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x")
).pack(side="left", padx=(0, self._colors.space("xs")))
entry = tk.Entry(
cell,
textvariable=var,
font=(self._colors.font_family, 18),
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
entry.pack(fill="x", pady=4)
entry.pack(side="left", fill="x", expand=True)
disable_paste(entry)
if focus:
entry.focus_set()
@ -127,50 +134,55 @@ class ManualWorkoutFormWidgetsMixin:
tk.Label(
row,
text=label,
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
from_=frm,
to=to,
textvariable=var,
width=4,
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
**self._colors.focus_kwargs(),
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
self._mw_grid(parent, row)
return var
def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text:
"""Add a full-width label + multi-line Text cell and return the widget."""
"""Add a half-width label + multi-line Text cell and return the widget.
Half-width, not full: three full-width reflection boxes were three
whole rows of a form that has to fit a 768px panel, and two of them
sit side by side just as legibly.
"""
cell = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
cell,
text=label,
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x", pady=(8, 0))
).pack(fill="x")
text_widget = tk.Text(
cell,
height=3,
font=(self._colors.font_family, 14),
height=2,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
text_widget.pack(pady=4, fill="x")
text_widget.pack(pady=self._colors.space("xs"), fill="x")
disable_paste(text_widget)
# Tk makes <Tab> insert a literal tab and refocus the widget, and binds
# <Shift-Tab> to nothing, so an untreated Text is a keyboard dead end --
# and the only exits (Ctrl+Tab / Ctrl+Shift+Tab) are advertised nowhere.
# On this form that means never reaching SUBMIT.
escape_text_tab_trap(text_widget)
self._mw_grid(parent, cell, full=True)
self._mw_grid(parent, cell)
return text_widget
def _mw_rpe_row(self, parent: tk.Widget) -> None:
@ -182,17 +194,17 @@ class ManualWorkoutFormWidgetsMixin:
f"RPE — perceived exertion "
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
from_=MANUAL_WORKOUT_RPE_MIN,
to=MANUAL_WORKOUT_RPE_MAX,
textvariable=self._mw_rpe_var,
width=4,
font=(self._colors.font_family, 16),
font=self._colors.font("label"),
**self._colors.focus_kwargs(),
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
self._mw_grid(parent, row)

View File

@ -37,26 +37,26 @@ class SickDialogMixin:
history = _sick_tracker.load_history()
self._sick_history_cache: SickHistory = history
self.clear_container()
self._label("Sick Day Request", color=self._colors.warning, pady=10)
self._label("Sick Day Request", color=self._colors.warning, pad="sm")
self._text(_sick_tracker.budget_summary(history), color=self._colors.warning)
recent = _sick_tracker.format_recent_justifications(history)
if recent:
self._text(
"Recent sick days:", font_size=14, color=self._colors.muted, pady=8
"Recent sick days:", role="label", color=self._colors.muted, pad="sm"
)
self._text(recent, font_size=14, color=self._colors.muted, pady=8)
self._text(recent, role="label", color=self._colors.muted, pad="sm")
had_commitment = _sick_tracker.had_commitment_for_today(history)
if had_commitment:
self._text(
"⚠ Yesterday you committed to working out today.",
font_size=18,
role="body",
color=self._colors.danger,
)
self._text(
"Breaking the commitment costs 2 sick-budget days.",
font_size=14,
role="label",
color=self._colors.danger,
)
@ -65,14 +65,14 @@ class SickDialogMixin:
def _build_justification_form(self, *, had_commitment: bool) -> None:
"""Add justification form fields and submit button to the container."""
form = self.container.child_frame(bg=self._colors.bg)
form.pack(pady=10)
form.pack(pady=self._colors.space("sm"))
self._sick_symptom_var = tk.StringVar()
self._sick_onset_var = tk.StringVar()
self._sick_severity_var = tk.IntVar(value=5)
self._sick_text_widget = self._add_form_widgets(form)
self._sick_error_label = self._text("", color=self._colors.danger, pady=8)
self._sick_error_label = self._text("", color=self._colors.danger, pad="sm")
button_row = self._button_row()
# Starts disabled during the forced-read delay -- field_bg (our
@ -85,14 +85,14 @@ class SickDialogMixin:
command=self._submit_sick_justification,
width=12,
)
self._sick_submit_button.pack(side="left", padx=10)
self._sick_submit_button.pack(side="left", padx=self._colors.space("sm"))
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
if had_commitment:
self._sick_submit_button.config(state="disabled")
@ -113,37 +113,37 @@ class SickDialogMixin:
variable=self._sick_onset_var,
)
sev_row = parent.child_frame(bg=self._colors.bg)
sev_row.pack(pady=8)
sev_row.pack(pady=self._colors.space("sm"))
sev_row.child_widgets(
tk.Label,
text="Severity (1-10):",
font=(self._colors.font_family, 14),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
).pack(side="left", padx=self._colors.space("xs"))
sev_row.child_widgets(
tk.Spinbox,
from_=1,
to=10,
textvariable=self._sick_severity_var,
width=4,
font=(self._colors.font_family, 14),
).pack(side="left", padx=5)
font=self._colors.font("label"),
).pack(side="left", padx=self._colors.space("xs"))
parent.child_widgets(
tk.Label,
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
font=(self._colors.font_family, 14),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(pady=8)
).pack(pady=self._colors.space("sm"))
text_widgets = TextGroup(
list(
parent.child_widgets(
tk.Text,
width=60,
height=6,
font=(self._colors.font_family, 14),
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
@ -151,7 +151,7 @@ class SickDialogMixin:
)
)
)
text_widgets.pack(pady=8)
text_widgets.pack(pady=self._colors.space("sm"))
for text_widget in text_widgets:
_disable_paste(text_widget)
# Tk traps <Tab> inside a Text (inserts a tab, refocuses itself) and
@ -218,16 +218,16 @@ class SickDialogMixin:
self.clear_container()
self._label(
"Commit to working out tomorrow?",
font_size=32,
role="display",
color=self._colors.warning,
pady=20,
pad="md",
)
# ~80 chars at font_size 16 would render as one unbroken line past the
# ~70-char readable range (rule 21) -- wrap it explicitly.
prompt = self._text(
"If you say YES and skip via 'I'm sick' tomorrow, "
"the sick day costs 2x normal.",
font_size=16,
role="body",
)
prompt.config(wraplength=560, justify="center")
self._commitment_done_fn = on_done
@ -243,14 +243,14 @@ class SickDialogMixin:
bg=self._colors.success,
command=lambda: self._answer_commitment(commit=True),
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
row,
"NO",
bg=self._colors.field_bg,
command=lambda: self._answer_commitment(commit=False),
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._tick_commitment_timeout()
def _tick_commitment_timeout(self) -> None:

View File

@ -37,25 +37,25 @@ class TemperatureStatusMixin:
fetched in the background.
"""
del parent
self._label("Warsaw Temperature", font_size=16, pady=8)
self._label("Warsaw Temperature", role="body", pad="sm")
result = self._temp_result
if result is None:
self._text(
"Checking Warsaw temperature…", font_size=12, color=self._colors.muted
"Checking Warsaw temperature…", role="caption", color=self._colors.muted
)
return
if result.timed_out:
self._text(
f"Warsaw temperature check timed out after ~"
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
font_size=12,
role="caption",
color=self._colors.warning,
)
return
if result.temp_celsius is None:
self._text(
"Warsaw temperature check failed (network/API error).",
font_size=12,
role="caption",
color=self._colors.warning,
)
return
@ -63,13 +63,13 @@ class TemperatureStatusMixin:
self._text(
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
font_size=12,
role="caption",
color=self._colors.warning if hot else self._colors.muted,
)
if hot:
self._text(
"Would trigger heat-skip today.",
font_size=11,
role="caption",
color=self._colors.danger,
)

View File

@ -19,13 +19,22 @@ if TYPE_CHECKING:
class UIFlowsMixin:
"""Mixin providing UI flow logic for the screen locker."""
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
def paint_phone_check(self) -> None:
"""Paint the "checking phone" screen, without starting the check.
Split from :meth:`_start_phone_check` so the fit check
(``scripts/verify_screen_fits.py``) can measure this screen without
starting a background thread that talks to a phone over adb.
"""
self.clear_container()
self._label(
"Checking phone...", font_size=36, color=self._colors.warning, pady=30
"Checking phone...", role="display", color=self._colors.warning, pad="lg"
)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
self._text("Looking for today's workout in StrongLifts...", role="body")
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
self.paint_phone_check()
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
@ -43,7 +52,7 @@ class UIFlowsMixin:
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
self.clear_container()
self._label(
"No Workout Found", font_size=36, color=self._colors.danger, pady=20
"No Workout Found", role="display", color=self._colors.danger, pad="md"
)
self._text(message, color=self._colors.warning)
history = _sick_tracker.load_history()
@ -58,7 +67,7 @@ class UIFlowsMixin:
bg=self._colors.accent,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
if _sick_tracker.is_budget_exhausted(history):
self._text(
"Sick budget exhausted. No 'I'm sick' option available.",
@ -71,7 +80,7 @@ class UIFlowsMixin:
bg=self._colors.warning,
command=self.ask_if_sick,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted. No manual-log option available.",
@ -84,7 +93,7 @@ class UIFlowsMixin:
bg=self._colors.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
def _on_manual_workout_saved(self, entry: dict) -> None:
"""Show confirmation and unlock after a manual-workout entry is built."""
@ -92,12 +101,13 @@ class UIFlowsMixin:
self.clear_container()
self._label(
"✓ Manual Workout Logged!",
font_size=42,
role="display",
scale=1.3,
color=self._colors.success,
pady=30,
pad="lg",
)
self._text(entry.get("source", ""), font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
self._text(entry.get("source", ""), role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
@ -113,12 +123,13 @@ class UIFlowsMixin:
self.clear_container()
self._label(
"✓ Workout Verified!",
font_size=42,
role="display",
scale=1.3,
color=self._colors.success,
pady=30,
pad="lg",
)
self._text(message, font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
self._text(message, role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short":
@ -155,9 +166,9 @@ class UIFlowsMixin:
"""
self.clear_container()
self._label(
"Checking RunnerUp...", font_size=36, color=self._colors.warning, pady=30
"Checking RunnerUp...", role="display", color=self._colors.warning, pad="lg"
)
self._text("Looking for today's run in RunnerUp...", font_size=18)
self._text("Looking for today's run in RunnerUp...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
self._runnerup_future = executor.submit(self._verify_runnerup_workout)
executor.shutdown(wait=False)
@ -174,12 +185,13 @@ class UIFlowsMixin:
self.clear_container()
self._label(
"✓ Run Verified!",
font_size=42,
role="display",
scale=1.3,
color=self._colors.success,
pady=30,
pad="lg",
)
self._text(message, font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
self._text(message, role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:
@ -228,18 +240,19 @@ class UIFlowsMixin:
countdown: int,
) -> None:
"""Display sick day UI labels and countdown."""
self._label("Sick Day Mode", color=self._colors.warning, pady=20)
self._label("Sick Day Mode", color=self._colors.warning, pad="md")
self._text(status_text, color=status_color)
minutes = countdown // 60
self._text(
f"Please wait ~{minutes} min before unlocking...",
font_size=24,
pady=20,
role="title",
pad="md",
)
self.sick_countdown_label = self._label(
str(countdown),
font_size=80,
pady=30,
role="display",
scale=2.5,
pad="lg",
)
def _update_sick_countdown(self) -> None:
@ -275,14 +288,16 @@ class UIFlowsMixin:
self.clear_container()
self.lockout_label = self._label(
f"Go work out!\nLocked for {self.lockout_time} seconds",
font_size=48,
role="display",
scale=1.5,
color=self._colors.danger,
pady=30,
pad="lg",
)
self.countdown_label = self._label(
str(self.lockout_time),
font_size=120,
pady=30,
role="display",
scale=3.75,
pad="lg",
)
self.remaining_time = self.lockout_time
self.update_lockout_countdown()
@ -323,22 +338,23 @@ class UIFlowsMixin:
)
self._label(
"Cannot Verify Workout",
font_size=36,
role="display",
color=self._colors.warning,
pady=20,
pad="md",
)
self._text(message, color=self._colors.warning)
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"
"Note: Phone must be rooted and StrongLifts installed.",
font_size=18,
role="body",
)
self.phone_penalty_remaining = delay
self.phone_penalty_label = self._label(
str(delay),
font_size=80,
pady=20,
role="display",
scale=2.5,
pad="md",
)
self._update_phone_penalty()

View File

@ -22,13 +22,13 @@ class UIFlowsRelaxedMixin:
self.clear_container()
self._label(
"Verifying Workout",
font_size=36,
role="display",
color=self._colors.warning,
pady=30,
pad="lg",
)
self._text(
"Checking phone for today's workout...",
font_size=18,
role="body",
)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
@ -58,15 +58,16 @@ class UIFlowsRelaxedMixin:
self.clear_container()
self._label(
"✓ Workout Verified!",
font_size=42,
role="display",
scale=1.3,
color=self._colors.success,
pady=30,
pad="lg",
)
self._text(message, font_size=20, color=self._colors.success)
self._text(message, role="subtitle", color=self._colors.success)
if adjusted:
self._text(
"Shutdown time moved later!",
font_size=20,
role="subtitle",
color=self._colors.warning,
)
self.root.after(2000, self.close)
@ -78,9 +79,9 @@ class UIFlowsRelaxedMixin:
self.clear_container()
self._label(
"Workout Not Found",
font_size=36,
role="display",
color=self._colors.danger,
pady=20,
pad="md",
)
self._text(message, color=self._colors.warning)
frame = self._button_row()
@ -90,14 +91,14 @@ class UIFlowsRelaxedMixin:
bg=self._colors.accent,
command=self._start_verify_workout_check,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close",
bg=self._colors.field_bg,
command=self.close,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
# ------------------------------------------------------------------
# Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping)
@ -113,16 +114,16 @@ class UIFlowsRelaxedMixin:
self.clear_container()
self._label(
"Optional Day (Tue / Wed / Thu)",
font_size=30,
role="title",
color=self._colors.warning,
pady=20,
pad="md",
)
self._text(
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
font_size=20,
role="subtitle",
color=self._colors.muted,
pady=10,
pad="sm",
)
frame = self._button_row()
self._button(
@ -131,22 +132,22 @@ class UIFlowsRelaxedMixin:
bg=self._colors.success,
command=self.close,
width=18,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Log Stronglift Workout",
bg=self._colors.accent,
command=self._start_relaxed_phone_check,
width=20,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
def _start_relaxed_phone_check(self) -> None:
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
self.clear_container()
self._label(
"Checking phone...", font_size=36, color=self._colors.warning, pady=30
"Checking phone...", role="display", color=self._colors.warning, pad="lg"
)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
self._text("Looking for today's workout in StrongLifts...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
@ -178,7 +179,7 @@ class UIFlowsRelaxedMixin:
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label(
"No Workout Found", font_size=36, color=self._colors.danger, pady=20
"No Workout Found", role="display", color=self._colors.danger, pad="md"
)
self._text(f"❌ {message}\n\nReason: {status}", color=self._colors.warning)
frame = self._button_row()
@ -188,11 +189,11 @@ class UIFlowsRelaxedMixin:
bg=self._colors.accent,
command=self._start_relaxed_phone_check,
width=12,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close (Skip)",
bg=self._colors.success,
command=self.close,
width=14,
).pack(side="left", padx=10)
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -11,6 +11,8 @@ 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
@ -52,38 +54,54 @@ class UIWidgetsMixin:
self,
text: str,
*,
font_size: int = 36,
role: TypeRole = "display",
scale: float = 1.0,
color: str | None = None,
pady: int = 20,
pad: SpaceStep = "md",
) -> WidgetGroup:
"""Create and pack a bold label on every monitor."""
"""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_family, font_size, "bold"),
font=self._colors.font(role, bold=True, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
)
label.pack(pady=pady)
label.pack(pady=self._colors.space(pad))
return label
def _text(
self,
text: str,
*,
font_size: int = 18,
role: TypeRole = "body",
scale: float = 1.0,
color: str | None = None,
pady: int = 10,
pad: SpaceStep = "sm",
) -> WidgetGroup:
"""Create and pack a non-bold text label on every monitor."""
"""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_family, font_size),
font=self._colors.font(role, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
)
label.pack(pady=pady)
label.pack(pady=self._colors.space(pad))
return label
def _fg_for(self, bg: str) -> str:
@ -138,15 +156,15 @@ class UIWidgetsMixin:
buttons = parent.child_widgets(
tk.Button,
text=text,
font=(self._colors.font_family, 24, "bold"),
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=24,
pady=12,
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
@ -156,9 +174,14 @@ class UIWidgetsMixin:
return buttons
def _button_row(self) -> FrameGroup:
"""Create and pack a horizontal button container on every monitor."""
"""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=20)
frame.pack(pady=self._colors.space("sm"))
return frame
def _add_label_entry(
@ -181,11 +204,11 @@ class UIWidgetsMixin:
window on screen to make that obvious.
"""
row = parent.child_frame(bg=self._colors.bg)
row.pack(pady=8, fill="x")
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=(self._colors.font_family, 14),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
@ -194,13 +217,13 @@ class UIWidgetsMixin:
tk.Entry,
textvariable=variable,
width=50,
font=(self._colors.font_family, 14),
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=4)
entries.pack(side="top", anchor="w", pady=self._colors.space("xs"))
for entry in entries:
disable_paste(entry)
if focus:
@ -223,11 +246,11 @@ class UIWidgetsMixin:
into it can never reach the submit button.
"""
row = parent.child_frame(bg=self._colors.bg)
row.pack(pady=8, fill="x")
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=(self._colors.font_family, 14),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
@ -237,13 +260,13 @@ class UIWidgetsMixin:
width=60,
height=height,
wrap="word",
font=(self._colors.font_family, 14),
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=4)
boxes.pack(side="top", anchor="w", pady=self._colors.space("xs"))
for box in boxes:
disable_paste(box)
escape_text_tab_trap(box)

View File

@ -0,0 +1,71 @@
"""The success screen shown once a workout has been credited.
Split out of ``screen_lock`` so that module stays inside the repo's 400-line
cap; this is pure view code (paint the credit, then hand off to the
commitment prompt or the close), which is exactly the kind of thing the other
``_ui_*`` mixins already own.
"""
from __future__ import annotations
from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._extra_benefits import current_streak
_UNLOCK_DELAY_MS = 1500
# Workout kinds that carry a commitment prompt on the way out: they are the
# ones the user actually performed today, so "will you work out tomorrow?" is
# a question worth asking.
_COMMITTABLE = ("phone_verified", "runnerup_verified", "manual_workout")
class UnlockViewMixin:
"""Paints the unlock screen and routes to whatever follows it."""
def unlock_screen(self) -> None:
"""Apply workout credit and display the success message."""
credit = self._apply_workout_credit()
self.clear_container()
self._label(
"Great job! 💪",
role="display",
scale=1.5,
color=self._colors.success,
pad="lg",
)
if credit.shutdown_adjusted:
self._text(
"Shutdown time +2h later! 🎁",
role="title",
color=self._colors.warning,
)
if credit.extra_bonus_delta > 0:
self._text(
f"Extra workout today! +{credit.extra_bonus_delta}h tonight",
role="subtitle",
color=self._colors.warning,
)
if credit.new_debt is not None:
self._text(
f"Workout debt: {credit.new_debt}",
role="subtitle",
color=self._colors.warning
if credit.new_debt > 0
else self._colors.muted,
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"🔥 {streak}-week streak (5+ workouts each)",
role="label",
color=self._colors.muted,
)
self._text("Screen Unlocked!", role="display", pad="md")
if self.workout_data.get("type") in _COMMITTABLE:
self.root.after(
_UNLOCK_DELAY_MS,
lambda: self._show_commitment_prompt(on_done=self.close),
)
else:
self.root.after(_UNLOCK_DELAY_MS, self.close)

View File

@ -201,7 +201,7 @@ class WindowSetupMixin:
close_btn = tk.Button(
self.root,
text="✕ Close Demo",
font=(self._colors.font_family, 12),
font=self._colors.font("caption"),
bg=self._colors.danger,
fg=self._colors.on_fill,
command=self.close,

View File

@ -38,7 +38,6 @@ from screen_locker._constants import (
)
from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._extra_benefits import (
current_streak,
process_week_transition,
weekly_shutdown_bonus_hours,
)
@ -57,6 +56,7 @@ from screen_locker._temperature import fetch_current_temp_with_status
from screen_locker._ui_flows import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin
from screen_locker._unlock_view import UnlockViewMixin
from screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
@ -119,6 +119,7 @@ class ScreenLocker(
UIFlowsMixin,
UIFlowsRelaxedMixin,
UIWidgetsMixin,
UnlockViewMixin,
):
"""Screen locker that requires workout logging to unlock."""
@ -315,52 +316,6 @@ class ScreenLocker(
if bonus > 0 and self._adjust_shutdown_time_by(bonus):
_logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)
def unlock_screen(self) -> None:
"""Apply workout credit and display success message."""
credit = self._apply_workout_credit()
self.clear_container()
self._label("Great job! 💪", font_size=48, color=self._colors.success, pady=30)
if credit.shutdown_adjusted:
self._text(
"Shutdown time +2h later! 🎁",
font_size=24,
color=self._colors.warning,
)
if credit.extra_bonus_delta > 0:
self._text(
f"Extra workout today! +{credit.extra_bonus_delta}h tonight",
font_size=20,
color=self._colors.warning,
)
if credit.new_debt is not None:
self._text(
f"Workout debt: {credit.new_debt}",
font_size=20,
color=self._colors.warning
if credit.new_debt > 0
else self._colors.muted,
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"🔥 {streak}-week streak (5+ workouts each)",
font_size=14,
color=self._colors.muted,
)
self._text("Screen Unlocked!", font_size=36, pady=20)
if self.workout_data.get("type") in (
"phone_verified",
"runnerup_verified",
"manual_workout",
):
self.root.after(
1500,
lambda: self._show_commitment_prompt(on_done=self.close),
)
else:
self.root.after(1500, self.close)
def close(self) -> None:
"""Close the application and exit."""
if self._lock is not None:

View File

@ -115,7 +115,7 @@ class StatusWindow(
"""Redraw the whole window from *snapshot*."""
self._last_snapshot = snapshot
self.clear_container()
self._label("Workout Status", font_size=28, pady=15)
self._label("Workout Status", role="title", pad="md")
self._section_today(self.container, snapshot.today)
self._section_week(self.container, snapshot.week)
self._section_lock_explanation(self.container, snapshot.lock_explanation)
@ -130,9 +130,9 @@ class StatusWindow(
color = (
self._colors.success if status == "verified" else self._colors.warning
)
self._text(f"Phone check ({status}): {message}", font_size=16, color=color)
self._text(f"Phone check ({status}): {message}", role="body", color=color)
if self._credit_message is not None:
self._text(self._credit_message, font_size=16, color=self._colors.success)
self._text(self._credit_message, role="body", color=self._colors.success)
# "Check Phone"/"Log Manual Workout" are the primary write actions
# (accent, high contrast per rule 3); "Refresh"/"Close" are secondary
# utility actions (muted) -- two tiers instead of four arbitrary hues.
@ -143,7 +143,7 @@ class StatusWindow(
bg=self._colors.accent,
command=self._on_check_phone_clicked,
width=14,
).pack(side="left", padx=8)
).pack(side="left", padx=self._colors.space("sm"))
if not _manual_workout.is_budget_exhausted(self.log_file):
self._button(
frame,
@ -151,21 +151,21 @@ class StatusWindow(
bg=self._colors.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=8)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Refresh",
bg=self._colors.field_bg,
command=self._on_refresh_clicked,
width=10,
).pack(side="left", padx=8)
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close",
bg=self._colors.field_bg,
command=self.root.destroy,
width=8,
).pack(side="left", padx=8)
).pack(side="left", padx=self._colors.space("sm"))
def _section_today(self, parent: tk.Widget, day: DayStatus) -> None:
"""Render today's outcome."""
@ -176,19 +176,19 @@ class StatusWindow(
)
self._text(
f"{mark} Today ({day.label}): {entry_str}",
font_size=18,
role="body",
color=self._colors.success if day.counted else self._colors.warning,
)
if day.source:
# Secondary provenance note on an already-shown entry -- a
# deliberately caption-sized exception to the 16px floor.
self._text(day.source, font_size=12, color=self._colors.muted, pady=4)
self._text(day.source, role="caption", color=self._colors.muted, pad="xs")
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
"""Render this ISO week's per-day breakdown and totals."""
del parent
self._label(
f"This Week: {week.counted_count}/{week.minimum}", font_size=18, pady=8
f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm"
)
for day in week.days:
mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·")
@ -197,20 +197,20 @@ class StatusWindow(
)
self._text(
f"{mark} {day.label}: {entry_str}",
font_size=16,
role="body",
color=self._colors.muted,
pady=4,
pad="xs",
)
if week.remaining > 0:
self._text(
f"Need {week.remaining} more this week.",
font_size=16,
role="body",
color=self._colors.warning,
)
elif week.extra > 0:
self._text(
f"{week.extra} above the weekly minimum!",
font_size=16,
role="body",
color=self._colors.success,
)
@ -219,27 +219,27 @@ class StatusWindow(
) -> None:
"""Render why the lock did/didn't fire today, plus its evaluation trace."""
del parent
self._label("Why the lock did/didn't fire", font_size=16, pady=8)
self._label("Why the lock did/didn't fire", role="body", pad="sm")
self._text(
expl.reason,
font_size=16,
role="body",
color=self._colors.danger if expl.fired else self._colors.success,
)
if expl.auto_upgrade.would_attempt:
self._text(
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
font_size=14,
role="label",
color=self._colors.warning,
)
def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None:
"""Render rolling sick-day budget usage."""
del parent
self._label("Sick Budget", font_size=16, pady=8)
self._label("Sick Budget", role="body", pad="sm")
self._text(
f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} "
f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}",
font_size=16,
role="body",
color=self._colors.danger if sick.exhausted else self._colors.muted,
)
@ -248,11 +248,11 @@ class StatusWindow(
) -> None:
"""Render rolling manual-workout budget usage."""
del parent
self._label("Manual Workout Budget", font_size=16, pady=8)
self._label("Manual Workout Budget", role="body", pad="sm")
self._text(
f"{manual.used_7d}/{manual.budget_7d} week · "
f"{manual.used_30d}/{manual.budget_30d} month",
font_size=16,
role="body",
color=self._colors.danger if manual.exhausted else self._colors.muted,
)
@ -261,18 +261,18 @@ class StatusWindow(
) -> None:
"""Render tonight's live config, rest-of-week, and next-week preview."""
del parent
self._label("Shutdown Time", font_size=16, pady=8)
self._label("Shutdown Time", role="body", pad="sm")
if shutdown.tonight is not None:
mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight
self._text(
f"Live config — Mon-Wed {mon_wed_hour:02d}:00, "
f"Thu-Sun {thu_sun_hour:02d}:00",
font_size=16,
role="body",
)
else:
self._text(
"Live shutdown config unavailable.",
font_size=16,
role="body",
color=self._colors.warning,
)
# Rest-of-week/next-week/explanation are speculative annotations, not
@ -282,16 +282,18 @@ class StatusWindow(
rest_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week
)
self._text(f"Rest of week: {rest_line}", font_size=12, color=self._colors.muted)
self._text(
f"Rest of week: {rest_line}", role="caption", color=self._colors.muted
)
next_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview
)
self._text(
f"Next week (speculative): {next_line}",
font_size=12,
role="caption",
color=self._colors.muted,
)
self._text(shutdown.explanation, font_size=12, color=self._colors.muted)
self._text(shutdown.explanation, role="caption", color=self._colors.muted)
def _on_refresh_clicked(self) -> None:
"""Clear any stale phone-check/credit/temperature results, re-check both."""

View File

@ -0,0 +1,222 @@
"""Keyboard focus and viewport settling on the lock surfaces.
The lock holds a *global* grab with VT switching disabled: a surface where
nothing is focused leaves a keyboard-only user typing into the void, and a
viewport that is never re-settled after a repaint reports the wrong size for
the screen it is showing. Both were shipped without tests in 3e77f4e, and the
scroll behaviour they drive is what moved the screen on its own on
2026-08-03 (see ``gatelock/tests/test_scrollable.py`` for that half).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from screen_locker._surface_group import FrameGroup
from screen_locker._window_setup import _first_focusable
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
def _widget(cls: str, *, takefocus: str = "", children: list | None = None):
"""A widget stand-in that answers only what ``_first_focusable`` asks."""
widget = MagicMock()
widget.winfo_class.return_value = cls
widget.cget.return_value = takefocus
widget.winfo_children.return_value = children or []
return widget
class TestFirstFocusable:
"""Which widget the lock hands the keyboard to when a screen appears."""
def test_picks_the_first_focusable_child(self) -> None:
"""A button after a label is still the first thing focus can land on."""
label = _widget("Label")
button = _widget("Button")
parent = _widget("Frame", children=[label, button])
assert _first_focusable(parent) is button
def test_descends_into_containers(self) -> None:
"""Buttons live inside a row frame, not directly on the surface."""
button = _widget("Button")
row = _widget("Frame", children=[button])
parent = _widget("Frame", children=[row])
assert _first_focusable(parent) is button
def test_skips_widgets_that_opted_out(self) -> None:
"""``takefocus=0`` means the widget is not in the tab ring at all."""
opted_out = _widget("Button", takefocus="0")
real = _widget("Entry")
parent = _widget("Frame", children=[opted_out, real])
assert _first_focusable(parent) is real
def test_returns_none_when_nothing_can_take_focus(self) -> None:
"""A caller must be able to tell "nothing to focus" from "focused"."""
parent = _widget("Frame", children=[_widget("Label")])
assert _first_focusable(parent) is None
@pytest.mark.usefixtures("mock_sys_exit")
class TestFocusOnAppear:
"""``on_focus_ready`` is what makes a fresh screen keyboard-operable."""
def test_focuses_the_first_focusable_widget(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The hook exists so a button-only screen does not start unfocused."""
locker = create_locker(mock_tk, tmp_path)
button = _widget("Button")
locker.container = FrameGroup([_widget("Frame", children=[button])])
locker.on_focus_ready(MagicMock())
button.focus_set.assert_called_once_with()
def test_no_live_output_is_not_an_error(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The lock is still held with every monitor dark; nothing to focus."""
locker = create_locker(mock_tk, tmp_path)
locker.on_focus_ready(None) # must not raise
def test_a_surface_with_nothing_focusable_is_left_alone(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A pure status screen has no controls, and that is a valid state."""
locker = create_locker(mock_tk, tmp_path)
locker.container = FrameGroup([_widget("Frame", children=[_widget("Label")])])
locker.on_focus_ready(MagicMock()) # must not raise
def test_no_surfaces_at_all(self, mock_tk: MagicMock, tmp_path: Path) -> None:
"""Focus is requested before any surface exists during startup races."""
locker = create_locker(mock_tk, tmp_path)
locker.container = FrameGroup([])
locker.on_focus_ready(MagicMock()) # must not raise
def test_focus_first_button_takes_the_primary_surface(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""One screen paints N monitors; the keyboard is at exactly one."""
locker = create_locker(mock_tk, tmp_path)
button = _widget("Button")
row = FrameGroup([_widget("Frame", children=[_widget("Label"), button])])
locker._focus_first_button(row)
button.focus_set.assert_called_once_with()
def test_focus_first_button_without_a_button(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A row of plain labels must not raise looking for a button."""
locker = create_locker(mock_tk, tmp_path)
row = FrameGroup([_widget("Frame", children=[_widget("Label")])])
locker._focus_first_button(row) # must not raise
@pytest.mark.usefixtures("mock_sys_exit")
class TestSurfacesSettle:
"""Every repaint has to re-settle the viewport it painted into."""
def test_settle_finalizes_every_scroller(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""One monitor per scroller, and each one measures its own screen."""
locker = create_locker(mock_tk, tmp_path)
first, second = MagicMock(), MagicMock()
locker._scrollers.clear()
locker._scrollers.update({"eDP-1": first, "HDMI-1": second})
locker.settle_surfaces()
first.finalize.assert_called_once_with()
second.finalize.assert_called_once_with()
def test_clearing_the_container_schedules_a_settle(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The hook that makes settling unforgettable across ~19 repaint sites.
Scheduled rather than run inline: running it here would measure an
empty container, since the caller has not painted the new screen yet.
"""
locker = create_locker(mock_tk, tmp_path)
locker.root.after_idle.reset_mock()
locker.clear_container()
locker.root.after_idle.assert_called_once_with(locker.settle_surfaces)
def test_a_dark_output_drops_its_scroller(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A monitor that goes away must not be settled or painted again."""
locker = create_locker(mock_tk, tmp_path)
surface = MagicMock()
surface.output_name = "HDMI-1"
locker._scrollers["HDMI-1"] = MagicMock()
locker.teardown_surface(surface)
assert "HDMI-1" not in locker._scrollers
@pytest.mark.usefixtures("mock_sys_exit")
class TestMultiLineField:
"""``_add_label_text`` is the only Tab-safe multi-line field helper."""
def test_builds_a_label_and_a_text_box_on_every_monitor(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""Both halves fan out, like every other widget helper here."""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
mock_tk.Text.reset_mock()
boxes = locker._add_label_text(parent, label="Why?", height=3)
assert mock_tk.Text.call_count == len(locker.container.surfaces)
assert mock_tk.Text.call_args.kwargs["height"] == 3
assert boxes.first is not None
def test_the_box_is_not_a_keyboard_dead_end(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""Tk makes <Tab> insert a tab character and refocus the same widget.
Untreated, that traps a keyboard user inside the field with no
advertised way out -- on this lock, that means never reaching SUBMIT.
"""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
boxes = locker._add_label_text(parent, label="Why?")
bound = [call.args[0] for box in boxes for call in box.bind.call_args_list]
assert "<Tab>" in bound
assert "<Shift-Tab>" in bound
def test_paste_is_disabled(self, mock_tk: MagicMock, tmp_path: Path) -> None:
"""The justification has to be typed, like every other field here."""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
boxes = locker._add_label_text(parent, label="Why?")
bound = [call.args[0] for box in boxes for call in box.bind.call_args_list]
assert "<<Paste>>" in bound
assert "<Control-v>" in bound

View File

@ -88,7 +88,7 @@ class TestUnlockScreenExtras:
return_value=5,
),
patch(
"screen_locker.screen_lock.current_streak",
"screen_locker._unlock_view.current_streak",
return_value=0,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
@ -124,7 +124,7 @@ class TestUnlockScreenExtras:
return_value=5,
),
patch(
"screen_locker.screen_lock.current_streak",
"screen_locker._unlock_view.current_streak",
return_value=0,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
@ -155,7 +155,7 @@ class TestUnlockScreenExtras:
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
@ -181,7 +181,7 @@ class TestUnlockScreenExtras:
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
@ -210,7 +210,7 @@ class TestUnlockScreenExtras:
return_value=3,
),
patch(
"screen_locker.screen_lock.current_streak",
"screen_locker._unlock_view.current_streak",
return_value=3,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
@ -235,7 +235,7 @@ class TestUnlockScreenExtras:
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
@ -257,7 +257,7 @@ class TestUnlockScreenExtras:
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()

259
scripts/verify_screen_fits.py Executable file
View File

@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Assert every lock screen fits a 1366x768 display.
A lock surface is fullscreen, grabbed and (in production) VT-locked. Content
that does not fit is only reachable by scrolling a screen that should never
have needed a scrollbar -- and the scroll that revealed it used to happen on
its own, moving the screen under a user who had touched nothing (2026-08-03).
The runtime half of this guarantee is the ``warning``
``ScrollableSurface.finalize`` logs; this script is the half that runs before
the code ships, so a layout change cannot quietly reintroduce overflow.
``StatusWindow`` is deliberately absent: it is a normal, resizable,
dismissible window rather than a lock surface, so a user who needs more of it
can resize it. Every screen that grabs the display is here.
Not a pytest test because ``screen_locker/tests/conftest.py`` replaces the
whole ``tk`` module for every test so the suite can never reach a display, and
widget heights can only be measured by really rendering them. Like
``verify_lock_popup_safety.py``, this re-executes itself under Xvfb:
python3 ~/screen-locker/scripts/verify_screen_fits.py
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING
from gatelock import LockConfig, measure_fit, report_fit
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
from screen_locker._heat_skip import build_heat_skip_content
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._sick_dialog import SickDialogMixin
from screen_locker._surface_group import FrameGroup
from screen_locker._ui_flows import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
from gatelock import FitResult, ScrollableSurface
_logger = logging.getLogger(__name__)
INNER_ENV = "_SCREEN_FITS_CHECK_INNER"
# The panel this app has to support: the laptop it runs on. Shorter panels
# get gatelock's best-effort compaction (``gatelock._density``) but are not
# gated here -- no machine runs one, and a target nobody uses is a constraint
# that only ever costs.
SIZES: tuple[tuple[int, int], ...] = ((1366, 768),)
# Long enough to be realistic: a message that wraps to three lines is a
# different height than one that does not, and this is what the phone check
# actually hands the retry screen.
LONG_MESSAGE = (
"✘ Workout JSON is from 2026-07-27, not today\n"
"Neither StrongLifts nor RunnerUp found a workout today.\n"
"Go do your workout first!"
)
def reexec_under_xvfb() -> int:
"""Re-run this script on a throwaway X display, returning its exit code."""
xvfb_run = shutil.which("xvfb-run")
if xvfb_run is None:
_logger.error(
"xvfb-run not found. Install it with: sudo pacman -S --needed "
"xorg-server-xvfb"
)
return 1
env = dict(os.environ, **{INNER_ENV: "1"})
return subprocess.run(
[
xvfb_run,
"-a",
# Bigger than every size under test, so each surface is sized by
# the geometry this harness sets rather than clamped by the display.
"-s",
"-screen 0 1600x1200x24",
sys.executable,
str(Path(__file__).resolve()),
],
check=False,
env=env,
).returncode
class ScreenHost(
UIWidgetsMixin,
UIFlowsMixin,
UIFlowsRelaxedMixin,
SickDialogMixin,
ManualWorkoutDialogMixin,
):
"""The real screen-painting mixins, over a throwaway surface.
Only the attributes the paint methods read are set. Nothing that reaches a
phone, the network or a background thread is called: each screen is
painted directly, with the message its flow would have been handed.
The ``paint_*`` wrappers exist so the measurement list below never reaches
into the mixins' private surface from outside the class.
"""
def __init__(self, surface: ScrollableSurface) -> None:
"""Point the mixins at one surface's content frame."""
self.root = surface.content.winfo_toplevel()
self.demo_mode = False
self.verify_only = False
self.log_file = (
Path(__file__).resolve().parent.parent
/ "screen_locker"
/ "workout_log.json"
)
self._colors = LockConfig()
self.container = FrameGroup([surface.content])
self.lockout_time = 1800
self.workout_data: dict[str, str] = {}
# -- hooks the flows call on submit/cancel; no screen here is submitted --
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
"""Unused: no form is submitted."""
def _on_manual_workout_cancelled(self) -> None:
"""Unused: BACK is never pressed."""
def unlock_screen(self) -> None:
"""Unused: nothing unlocks during a measurement."""
def close(self) -> None:
"""Unused: no screen is closed during a measurement."""
def _verify_phone_workout(self) -> tuple[str, str]:
"""Never reached -- present so a stray call fails loudly, not silently."""
msg = "the fit check must not run the phone check"
raise AssertionError(msg)
# -- public paint wrappers, one per screen under measurement --
def paint_retry(self) -> None:
"""Paint the "no workout found" retry screen."""
self._show_retry_and_sick(LONG_MESSAGE)
def paint_manual_form(self) -> None:
"""Paint the manual-workout evidence form."""
self._show_manual_workout_form()
def paint_manual_form_with_error(self) -> None:
"""Paint the form as it looks after a rejected submission.
Measured separately because the validation message is a line the empty
form does not have, and "fits until the user gets something wrong" is
not fitting.
"""
self._show_manual_workout_form()
self._mw_error_label.config(
text=(
"Start time must look like HH:MM, and every reflection needs "
f"at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} characters."
)
)
def paint_sick(self) -> None:
"""Paint the sick-day justification screen."""
self._show_sick_justification()
def paint_relaxed_retry(self) -> None:
"""Paint the relaxed-day (Tue-Thu) retry screen."""
self._show_relaxed_retry(LONG_MESSAGE, "no_workout")
def paint_verify_retry(self) -> None:
"""Paint the verify-mode retry screen."""
self._show_verify_retry(LONG_MESSAGE)
@dataclass(frozen=True)
class Screen:
"""One screen to measure, named by the paint call that produces it."""
name: str
paint: Callable[[ScreenHost], None]
def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc:
"""Paint the heat-skip screen and return the frame to measure.
Built through its extracted content builder because in production it owns
a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure.
"""
return build_heat_skip_content(
surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None
)
SCREENS: tuple[Screen, ...] = (
Screen("phone-check", ScreenHost.paint_phone_check),
Screen("no-workout-retry", ScreenHost.paint_retry),
Screen("manual-workout-form", ScreenHost.paint_manual_form),
Screen("manual-workout-form+error", ScreenHost.paint_manual_form_with_error),
Screen("sick-justification", ScreenHost.paint_sick),
Screen("lockout", ScreenHost.lockout),
Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry),
Screen("verify-retry", ScreenHost.paint_verify_retry),
)
# Screens built without a ScreenHost, measured through their own builder.
EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),)
def measure_all() -> list[FitResult]:
"""Measure every screen at every supported size."""
results: list[FitResult] = []
for width, height in SIZES:
for screen in SCREENS:
def build(surface: ScrollableSurface, paint: Screen = screen) -> None:
paint.paint(ScreenHost(surface))
results.append(measure_fit(screen.name, build, width=width, height=height))
results.extend(
measure_fit(name, build, width=width, height=height)
for name, build in EXTRA_SCREENS
)
return results
def main() -> int:
"""Measure every screen and fail if any of them overflows."""
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
if os.environ.get(INNER_ENV) != "1":
return reexec_under_xvfb()
_logger.info("Measuring every lock screen against every supported panel:")
results = measure_all()
if report_fit(results):
_logger.info("\nOK: every screen fits without scrolling.")
return 0
_logger.error(
"\nAt least one screen needs a scrollbar to be usable. Shrink the "
"layout (design-system tokens, gatelock._density) rather than "
"accepting the scroll -- a lock screen the user has to scroll is the "
"defect this check exists to prevent."
)
return 1
if __name__ == "__main__":
sys.exit(main())