#!/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 absent: it is a resizable window, not a lock surface. Not a pytest test: conftest replaces ``tk`` so no test reaches a display, and heights need real rendering. It re-executes itself under Xvfb: cd ~/src/screen-locker && python3 -m scripts.verify_screen_fits Run as a *module*: ``-m`` puts the repo root on ``sys.path``, so ``screen_locker`` resolves from the checkout (as a path it died in CI). """ from __future__ import annotations from dataclasses import dataclass import logging import os from pathlib import Path 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._manual_workout_dialog import ManualWorkoutDialogMixin 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 from scripts._fit_extra_screens import EXTRA_SCREENS from scripts._xvfb_reexec import reexec_under_xvfb 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 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!" ) # sickdays' FreeDayConflictError text: its longest refusal. SICK_REFUSAL = ( "2026-10-16 is a free day -- release it with 'freedays release' before " "marking it sick" ) class ScreenHost( UIWidgetsMixin, UIFlowsMixin, UIFlowsRelaxedMixin, 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._relaxed_day_mode = False # Deliberately NOT the real log.json: is_budget_exhausted() reads it, # and the real file's content depends on today's actual usage -- # exhausting the manual-workout budget in production made this crash # (paint_manual_form_with_error touches _mw_error_label, which the # budget-exhausted branch of _show_manual_workout_form never sets). # A path that can never exist keeps every measured screen's shape # independent of live data, which is the same guarantee every other # attribute on this class already gives (see class docstring). self.log_file = ( Path(__file__).resolve().parent.parent / "screen_locker" / "fit_check_never_exists.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_refused(self) -> None: """Paint the screen shown when today cannot be marked sick.""" self._show_sick_refused(SICK_REFUSAL) def paint_phone_penalty(self) -> None: """Paint the no-phone penalty screen (countdown + I'm sick).""" self._show_phone_penalty(LONG_MESSAGE) def paint_relaxed_prompt(self) -> None: """Paint the optional Tue-Thu prompt (Skip / Log / I'm sick).""" self._start_relaxed_day_flow() def paint_relaxed_retry(self) -> None: """Paint the relaxed-day (Tue-Thu) retry screen.""" self._show_relaxed_retry(LONG_MESSAGE, "no_workout") @dataclass(frozen=True) class Screen: """One screen to measure, named by the paint call that produces it.""" name: str paint: Callable[[ScreenHost], 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-refused", ScreenHost.paint_sick_refused), Screen("phone-penalty", ScreenHost.paint_phone_penalty), Screen("relaxed-day-prompt", ScreenHost.paint_relaxed_prompt), Screen("lockout", ScreenHost.lockout), Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry), ) 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( inner_env=INNER_ENV, module="scripts.verify_screen_fits" ) _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())