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."""