From fea60814c2af0870ea03bd8a07c9b27945f25d49 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Mon, 3 Aug 2026 16:04:27 +0200 Subject: [PATCH] Size the alarm's type in pixels, and gate every screen on fitting 768px Every font here was a positive number handed to Tk, which reads that as *points*: "WAKE UP!" at 48 rendered around a third larger than the 48px intended, and the challenge code at 72 larger still. LockConfig.font() applies Tk's sign convention, so the sizes now come from the design-system roles (with an explicit scale where a challenge is deliberately oversized) and the paddings from the spacing scale. The alarm places a centred frame, which shears equal amounts off the top AND the bottom when it outgrows the display -- on this screen that means losing "WAKE UP!" and the answer entry at once, on a window whose whole purpose is to be answered before it will stop. scripts/verify_screen_fits.py renders every challenge kind plus the escape hatch against a real 1366x768 X server and fails if any overflows; it runs as a pre-commit hook. Each kind is measured separately rather than one at random -- the flash kind draws its code half again as large as the rest, so a random sample would have passed or failed by dice roll. Fixed while sweeping: maybe_offer() reached for a `config` that was never in its scope, so revealing the escape button raised NameError. Its own tests caught it. gatelock is pinned to v0.4.0, which stops the shared viewport scrolling itself to follow the app's own focus_set() (2026-08-03, screen-locker). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF --- .pre-commit-config.yaml | 9 +- pyproject.toml | 2 +- requirements.txt | 2 +- scripts/verify_screen_fits.py | 170 ++++++++++++++++++++++++++++++++++ wake_alarm/_alarm.py | 4 +- wake_alarm/_alarm_view.py | 36 +++---- wake_alarm/_escape_flow.py | 32 +++---- 7 files changed, 217 insertions(+), 38 deletions(-) create mode 100755 scripts/verify_screen_fits.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9dd7a8d..25642f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -96,7 +96,7 @@ repos: additional_dependencies: - pytest - python-kasa - - 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 # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) @@ -139,6 +139,13 @@ repos: entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- language: system types: [shell] + - id: screens-fit + name: Every lock screen fits 1366x768 (no scrolling required) + entry: python3 scripts/verify_screen_fits.py + language: system + files: '^wake_alarm/.*\.py$' + pass_filenames: false + require_serial: true - id: max-file-length name: Max file length (500 lines) entry: python3 scripts/check_file_length.py diff --git a/pyproject.toml b/pyproject.toml index e736f7e..72d11ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.0.0" description = "Phone-driven wake alarm with fan ramp and smart-plug control" requires-python = ">=3.10" 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", "python-kasa>=0.7", ] diff --git a/requirements.txt b/requirements.txt index d48dacb..2c49b98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 -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 mypy>=1.8.0 pre-commit>=3.6.0 pylint>=3.0.0 diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py new file mode 100755 index 0000000..1c8993b --- /dev/null +++ b/scripts/verify_screen_fits.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Assert the alarm's screens fit a 1366x768 display. + +The alarm ``place``s a centred frame, and a ``place``d frame takes its +*requested* size: when it is taller than the screen the parent shears equal +amounts off the top **and** the bottom -- which here means losing "WAKE UP!" +and the answer entry at once, on a screen whose entire purpose is to be +answered before it will stop. There is no scrollbar and nothing to indicate +anything is missing, so "it fits" is the only thing keeping it usable. + +Runs the real builders against a real (throwaway) X display, because widget +heights come from the font engine and nothing short of rendering can answer +this honestly: + + python3 ~/wake-alarm/scripts/verify_screen_fits.py +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tkinter as tk +from typing import TYPE_CHECKING + +from gatelock import LockConfig, measure_fit, report_fit + +from wake_alarm._alarm_view import _build_alarm_view +from wake_alarm._challenges import ( + _make_flash_challenge, + _make_math_challenge, + _make_reverse_challenge, + _make_sequence_challenge, + _make_sort_challenge, +) +from wake_alarm._escape_flow import EscapeCallbacks, EscapeDialog, build_tracker + +if TYPE_CHECKING: + from collections.abc import Callable + + from gatelock import FitResult, ScrollableSurface + +_logger = logging.getLogger(__name__) + +INNER_ENV = "_SCREEN_FITS_CHECK_INNER" + +# The panel this app has to fit: the machine it runs on. +WIDTH = 1366 +HEIGHT = 768 + + +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 the size under test, so the surface is sized by the + # geometry the harness sets rather than clamped by the display. + "-s", + "-screen 0 1600x1200x24", + sys.executable, + str(Path(__file__).resolve()), + ], + check=False, + env=env, + ).returncode + + +def _placed_child(parent: tk.Misc) -> tk.Misc: + """Return the ``place``d descendant that carries the screen's height. + + ``place`` does not propagate a child's size to its parent, so measuring + the parent would report an empty screen. Falls back to the parent itself + when nothing is placed, which measures correctly for packed layouts. + """ + for child in parent.winfo_children(): + if child.winfo_manager() == "place": + return child + found = _placed_child(child) + if found is not child: + return found + return parent + + +# Every challenge kind, not a random one: the flash kind renders its code at +# 2.25x the display token where the others use 1.5x, and the sequence kind's +# display string is the longest. A hook that picked one at random would pass +# or fail by dice roll -- which is worse than no hook. +_CHALLENGE_KINDS = ( + ("math", _make_math_challenge), + ("flash", _make_flash_challenge), + ("sort", _make_sort_challenge), + ("sequence", _make_sequence_challenge), + ("reverse", _make_reverse_challenge), +) + + +def _build_alarm(surface: ScrollableSurface, make: Callable[[], object]) -> tk.Misc: + """Paint one dismiss challenge and return the frame to measure.""" + _build_alarm_view( + surface.content, + make(), + LockConfig(), + on_submit=lambda _event: None, + entry_var=tk.StringVar(master=surface.content), + ) + return _placed_child(surface.content) + + +def _build_escape(surface: ScrollableSurface) -> tk.Misc: + """Paint the escape-hatch form and return the frame to measure.""" + dialog = EscapeDialog( + surface.content, + LockConfig(), + build_tracker(), + EscapeCallbacks(on_granted=lambda: None, on_cancel=lambda: None), + ) + dialog.frame.pack() + return dialog.frame + + +def measure_all() -> list[FitResult]: + """Measure every screen at the supported size.""" + results = [ + measure_fit( + f"dismiss-challenge:{kind}", + lambda surface, make=make: _build_alarm(surface, make), + width=WIDTH, + height=HEIGHT, + ) + for kind, make in _CHALLENGE_KINDS + ] + results.append( + measure_fit("escape-hatch", _build_escape, width=WIDTH, height=HEIGHT) + ) + 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 alarm screen against a %dx%d panel:", WIDTH, HEIGHT) + if report_fit(measure_all()): + _logger.info("\nOK: every screen fits.") + return 0 + _logger.error( + "\nAt least one screen is taller than the display. A place-centred " + "surface clips at BOTH edges when that happens, so the overflow is " + "not merely hidden -- it is unrecoverable." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index 27dfaf7..eac53f4 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -290,10 +290,10 @@ class WakeAlarm: tk.Label( container, text=msg, - font=("Arial", 36, "bold"), + font=cfg.font("display", bold=True), fg=color, bg=cfg.bg, - ).pack(pady=30) + ).pack(pady=cfg.space("lg")) self.root.after(3000, self._lock.close) diff --git a/wake_alarm/_alarm_view.py b/wake_alarm/_alarm_view.py index 656cd21..c013b53 100644 --- a/wake_alarm/_alarm_view.py +++ b/wake_alarm/_alarm_view.py @@ -197,46 +197,48 @@ def _build_challenge( title_label = tk.Label( parent, text="WAKE UP!", - font=("Arial", 48, "bold"), + font=config.font("display", bold=True, scale=1.5), fg=config.danger, bg=config.bg, ) - title_label.pack(pady=20) + title_label.pack(pady=config.space("md")) round_label = tk.Label( parent, text=f"Round 1 / {DISMISS_ROUNDS_REQUIRED}", - font=("Arial", 24, "bold"), + font=config.font("title", bold=True), fg=config.warning, bg=config.bg, ) - round_label.pack(pady=5) + round_label.pack(pady=config.space("xs")) info_label = tk.Label( parent, text=challenge.hint, - font=("Arial", 18), + font=config.font("body"), fg=config.fg, bg=config.bg, ) - info_label.pack(pady=10) + info_label.pack(pady=config.space("sm")) # Non-flash types use a smaller font because their display text is wider. - code_font_size = ( - 48 if challenge.kind in ("math", "sort", "sequence", "reverse") else 72 + # Multiples of the display token rather than raw sizes, so the challenge + # scales with the rest of the screen (and is sized in px, not points). + code_scale = ( + 1.5 if challenge.kind in ("math", "sort", "sequence", "reverse") else 2.25 ) code_label = tk.Label( parent, text=challenge.display, - font=("Courier", code_font_size, "bold"), + font=config.font("display", bold=True, family="Courier", scale=code_scale), fg=config.success, bg=config.bg, ) - code_label.pack(pady=30) + code_label.pack(pady=config.space("lg")) entry = tk.Entry( parent, - font=("Courier", 36), + font=config.font("display", family="Courier"), justify="center", width=12, textvariable=entry_var, @@ -246,27 +248,27 @@ def _build_challenge( # window and a global grab, that is the only feedback available. **config.focus_kwargs(), ) - entry.pack(pady=10) + entry.pack(pady=config.space("sm")) entry.focus_set() entry.bind("", on_submit) status_label = tk.Label( parent, text="", - font=("Arial", 18), + font=config.font("body"), fg=config.danger, bg=config.bg, ) - status_label.pack(pady=10) + status_label.pack(pady=config.space("sm")) timer_label = tk.Label( parent, text="", - font=("Arial", 14), + font=config.font("label"), fg=config.muted, bg=config.bg, ) - timer_label.pack(pady=5) + timer_label.pack(pady=config.space("xs")) return ( _AlarmLabels( @@ -307,7 +309,7 @@ def _build_alarm_view( escape_button = tk.Button( challenge_frame, text="I can't do this right now", - font=(config.font_family, 14), + font=config.font("label"), bg=config.field_bg, fg=config.muted, **config.focus_kwargs(), diff --git a/wake_alarm/_escape_flow.py b/wake_alarm/_escape_flow.py index fe3b0cf..de3af6c 100644 --- a/wake_alarm/_escape_flow.py +++ b/wake_alarm/_escape_flow.py @@ -108,28 +108,28 @@ class EscapeDialog: self._status = tk.Label( self.frame, text=tracker.budget_summary(), - font=(config.font_family, 16), + font=config.font("body"), fg=config.muted, bg=config.bg, ) - self._status.pack(pady=6) + self._status.pack(pady=config.space("xs")) tk.Label( self.frame, text="Why can you not do this right now?", - font=(config.font_family, 22, "bold"), + font=config.font("subtitle", bold=True), fg=config.fg, bg=config.bg, - ).pack(pady=8) + ).pack(pady=config.space("sm")) tk.Label( self.frame, text=tracker.format_recent(), - font=("Courier", 11), + font=config.font("caption", family="Courier"), fg=config.muted, bg=config.bg, justify="left", - ).pack(pady=6) + ).pack(pady=config.space("xs")) description = tk.Text( self.frame, @@ -154,38 +154,38 @@ class EscapeDialog: severity=self._field("Severity 1-10", config), description=description, ) - description.pack(pady=6) + description.pack(pady=config.space("xs")) self._complaint = tk.Label( self.frame, text="", - font=(config.font_family, 14), + font=config.font("label"), fg=config.danger, bg=config.bg, ) - self._complaint.pack(pady=4) + self._complaint.pack(pady=config.space("xs")) self._submit = tk.Button( self.frame, text="Submit", - font=(config.font_family, 16, "bold"), + font=config.font("body", bold=True), bg=config.warning, fg=config.on_fill, command=self.submit, **config.focus_kwargs(), ) - self._submit.pack(pady=6) + self._submit.pack(pady=config.space("xs")) back = tk.Button( self.frame, text="Back to the alarm", - font=(config.font_family, 14), + font=config.font("label"), bg=config.field_bg, fg=config.fg, command=callbacks.on_cancel, **config.focus_kwargs(), ) - back.pack(pady=4) + back.pack(pady=config.space("xs")) # Tk binds only on Button on X11, never , so Enter -- # the key a user reaches for to submit a form -- did nothing at all. @@ -202,12 +202,12 @@ class EscapeDialog: tk.Label( self.frame, text=label, - font=(config.font_family, 13), + font=config.font("caption"), fg=config.muted, bg=config.bg, ).pack() entry = tk.Entry(self.frame, width=40, **config.focus_kwargs()) - entry.pack(pady=2) + entry.pack(pady=config.space("xs")) return entry def _draft(self) -> EscapeDraft: @@ -284,7 +284,7 @@ class EscapeHatch: return for button in self._views.escape_buttons: with contextlib.suppress(tk.TclError): - button.pack(pady=8) + button.pack(pady=self._views.config.space("sm")) def open(self) -> None: """Swap every surface over to the justification form."""