mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 12:27:32 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This commit is contained in:
parent
1e465e0d2c
commit
fea60814c2
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
170
scripts/verify_screen_fits.py
Executable file
170
scripts/verify_screen_fits.py
Executable file
@ -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())
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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("<Return>", 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(),
|
||||
|
||||
@ -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 <space> on Button on X11, never <Return>, 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."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user