mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Next to the one-tap "I'm sick" (unchanged: keeps nothing, unlocks), a small secondary link lays sickdays 0.3.0's embeddable KeepPanel over every surface, in-process so it gets input under the lock's grab. Closing the overlay reveals the untouched screen underneath, so no countdown or phone check is restarted or skipped. After the panel's own write + sync: sick with the workout lock standing down -> sick floor, decision, close (shared with the one-tap path, no second mark or push); sick with the workout kept -> sick floor and a note, lock stays; cleared / unchanged / cancel -> the panel just closes. Tests stub KeepPanel and redirect resolve_paths and sync_quietly in every sickdays.* module, sickdays.gui._panel included. The fit check measures the panel on a throwaway pool. Pins bumped to sickdays-v0.3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
236 lines
8.8 KiB
Python
Executable File
236 lines
8.8 KiB
Python
Executable File
#!/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())
|