diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0508e9..a7f183a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -99,7 +99,7 @@ repos: - --jobs=4 additional_dependencies: - pytest - - 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 - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync - mcp - pydantic @@ -153,6 +153,13 @@ repos: entry: python3 scripts/check_silent_failures.py language: system types: [python] + - id: screens-fit + name: Every lock screen fits 1366x768 (no scrolling required) + entry: python3 scripts/verify_screen_fits.py + language: system + files: '^screen_locker/.*\.py$' + pass_filenames: false + require_serial: true - id: flutter-analyze name: flutter analyze (workout app) # env -u GIT_DIR: git exports it to hooks, and flutter shells out to diff --git a/pyproject.toml b/pyproject.toml index a0f850e..21a6473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" # crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport, # also used by diet_guard; here for phone-workout GitHub sync (Workstream C) 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", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync", ] diff --git a/requirements.txt b/requirements.txt index 0e74333..90f5659 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync -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 mcp>=1.9 mypy>=1.8.0 pre-commit>=3.6.0 diff --git a/screen_locker/_heat_skip.py b/screen_locker/_heat_skip.py index 32e0408..945397f 100644 --- a/screen_locker/_heat_skip.py +++ b/screen_locker/_heat_skip.py @@ -4,11 +4,15 @@ from __future__ import annotations import logging import tkinter as tk +from typing import TYPE_CHECKING from gatelock import LockConfig, bind_activate, bind_cancel from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD +if TYPE_CHECKING: + from collections.abc import Callable + _logger = logging.getLogger(__name__) # Sourced from the shared gatelock LockConfig (unified-design-system) instead @@ -19,6 +23,98 @@ _COLORS = LockConfig() _FONT = "monospace" +def build_heat_skip_content( + parent: tk.Misc, + temp: float, + *, + on_skip: Callable[[], None], + on_decline: Callable[[], None], +) -> tk.Frame: + """Build the heat-skip screen inside ``parent`` and return its frame. + + Separate from :meth:`HeatSkipMixin._show_heat_skip_dialog` because that + method owns a fullscreen ``tk.Tk()`` of its own, which + ``scripts/verify_screen_fits.py`` cannot measure. This is a lock screen + like any other -- grabbed, undecorated, ``place``-centred, so it clips at + *both* edges if it ever outgrows the display -- and it has to be in the + fit check for that reason. + """ + outer = tk.Frame(parent, bg=_COLORS.bg) + outer.place(relx=0.5, rely=0.5, anchor="center") + + tk.Label( + outer, + text="\u2600 Too hot to workout?", + font=_COLORS.font("body", bold=True, family=_FONT), + bg=_COLORS.bg, + fg=_COLORS.warning, + ).pack(pady=(0, _COLORS.space("sm"))) + + tk.Label( + outer, + text=( + f"{HEAT_SKIP_CITY}: {temp:.0f}\u00b0C" + f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)" + ), + font=_COLORS.font("body", family=_FONT), + bg=_COLORS.bg, + fg=_COLORS.muted, + ).pack(pady=_COLORS.space("xs")) + + tk.Label( + outer, + text="Skip today's workout due to extreme heat?", + font=_COLORS.font("body", family=_FONT), + bg=_COLORS.bg, + fg=_COLORS.muted, + ).pack(pady=(_COLORS.space("xs"), 0)) + + btn_frame = tk.Frame(outer, bg=_COLORS.bg) + btn_frame.pack(pady=_COLORS.space("lg")) + + skip_button = tk.Button( + btn_frame, + text="Skip workout", + command=on_skip, + bg=_COLORS.warning, + fg=_COLORS.on_fill, + activebackground=_COLORS.warning, + font=_COLORS.font("label", family=_FONT), + padx=_COLORS.space("md"), + pady=_COLORS.space("sm"), + relief="flat", + **_COLORS.focus_kwargs(), + ) + skip_button.pack(side="left", padx=_COLORS.space("sm")) + + decline_button = tk.Button( + btn_frame, + text="No, I'll workout", + command=on_decline, + bg=_COLORS.field_bg, + fg=_COLORS.fg, + activebackground=_COLORS.field_bg, + font=_COLORS.font("label", family=_FONT), + padx=_COLORS.space("md"), + pady=_COLORS.space("sm"), + relief="flat", + **_COLORS.focus_kwargs(), + ) + decline_button.pack(side="left", padx=_COLORS.space("sm")) + + # This modal grabs input and sits fullscreen with no window decoration, so + # the keyboard is the only way out for a pointerless user. It used to + # focus_force() the root and stop there: nothing was focused, no ring was + # visible (Tk's default is black on bg), Enter did nothing because Tk binds + # only on Button, and there was no . Default focus goes to + # the *declining* option, so a blind Return keeps the workout rather than + # silently skipping it. + for button in (skip_button, decline_button): + bind_activate(button) + decline_button.focus_set() + return outer + + class HeatSkipMixin: """Provides _show_heat_skip_dialog and _save_heat_skip_log.""" @@ -41,40 +137,6 @@ class HeatSkipMixin: root.grab_set() root.focus_force() - # Content centred on the fullscreen canvas - outer = tk.Frame(root, bg=_COLORS.bg) - outer.place(relx=0.5, rely=0.5, anchor="center") - - tk.Label( - outer, - text="☀ Too hot to workout?", - font=(_FONT, 18, "bold"), - bg=_COLORS.bg, - fg=_COLORS.warning, - ).pack(pady=(0, 10)) - - tk.Label( - outer, - text=( - f"{HEAT_SKIP_CITY}: {temp:.0f}°C" - f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}°C)" - ), - font=(_FONT, 16), - bg=_COLORS.bg, - fg=_COLORS.muted, - ).pack(pady=6) - - tk.Label( - outer, - text="Skip today's workout due to extreme heat?", - font=(_FONT, 16), - bg=_COLORS.bg, - fg=_COLORS.muted, - ).pack(pady=(6, 0)) - - btn_frame = tk.Frame(outer, bg=_COLORS.bg) - btn_frame.pack(pady=28) - def _on_skip() -> None: result[0] = True root.destroy() @@ -83,47 +145,8 @@ class HeatSkipMixin: result[0] = False root.destroy() - skip_button = tk.Button( - btn_frame, - text="Skip workout", - command=_on_skip, - bg=_COLORS.warning, - fg=_COLORS.on_fill, - activebackground=_COLORS.warning, - font=(_FONT, 14), - padx=16, - pady=8, - relief="flat", - **_COLORS.focus_kwargs(), - ) - skip_button.pack(side="left", padx=12) - - decline_button = tk.Button( - btn_frame, - text="No, I'll workout", - command=_on_no, - bg=_COLORS.field_bg, - fg=_COLORS.fg, - activebackground=_COLORS.field_bg, - font=(_FONT, 14), - padx=16, - pady=8, - relief="flat", - **_COLORS.focus_kwargs(), - ) - decline_button.pack(side="left", padx=12) - - # This modal grabs input and sits fullscreen with no window decoration, - # so the keyboard is the only way out for a pointerless user. It used to - # focus_force() the root and stop there: nothing was focused, no ring was - # visible (Tk's default is black on bg), Enter did nothing because Tk - # binds only on Button, and there was no . Default focus - # goes to the *declining* option, so a blind Return keeps the workout - # rather than silently skipping it. - for button in (skip_button, decline_button): - bind_activate(button) + build_heat_skip_content(root, temp, on_skip=_on_skip, on_decline=_on_no) bind_cancel(root, _on_no) - decline_button.focus_set() root.mainloop() diff --git a/screen_locker/_manual_workout_dialog.py b/screen_locker/_manual_workout_dialog.py index 6345986..bd8921c 100644 --- a/screen_locker/_manual_workout_dialog.py +++ b/screen_locker/_manual_workout_dialog.py @@ -39,7 +39,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): def _show_manual_workout_form(self) -> None: """Render the manual-workout evidence form, or a budget-exhausted note.""" self.clear_container() - self._label("Log Manual Workout", color=self._colors.accent, pady=10) + self._label("Log Manual Workout", color=self._colors.accent, pad="xs") if _manual_workout.is_budget_exhausted(self.log_file): self._text( "Manual-workout budget exhausted for this window.", @@ -56,10 +56,13 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): bg=self._colors.field_bg, command=self._on_manual_workout_cancelled, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) return self._text( - _manual_workout.budget_summary(self.log_file), color=self._colors.accent + _manual_workout.budget_summary(self.log_file), + role="label", + color=self._colors.accent, + pad="xs", ) self._build_manual_workout_form() @@ -89,7 +92,10 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): copy per monitor would show two different parts of one form. """ form = tk.Frame(self.container.first, bg=self._colors.bg) - form.pack(fill="both", expand=True, pady=10) + # No outer gap: the budget line above and the first section heading + # below already separate the grid, and on a 1024x600 panel this form + # fits by single-digit pixels. + form.pack(fill="both", expand=True) form.grid_columnconfigure(0, weight=1, uniform="mw") form.grid_columnconfigure(1, weight=1, uniform="mw") return form @@ -137,7 +143,12 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): sport_frame.grid_columnconfigure(0, weight=1, uniform="mwact") sport_frame.grid_columnconfigure(1, weight=1, uniform="mwact") sport_frame.grid( - row=act_row, column=0, columnspan=2, sticky="ew", padx=12, pady=6 + row=act_row, + column=0, + columnspan=2, + sticky="ew", + padx=self._colors.space("sm"), + pady=self._colors.space("xs"), ) self._build_table_tennis_fields(self._mw_tt_frame) self._build_other_sport_fields(self._mw_other_frame) @@ -170,7 +181,9 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):", ) - self._mw_error_label = self._text("", color=self._colors.danger, pady=8) + self._mw_error_label = self._text( + "", role="label", color=self._colors.danger, pad="xs" + ) button_row = self._button_row() self._button( button_row, @@ -178,14 +191,14 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): bg=self._colors.accent, command=self._submit_manual_workout_form, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._button( button_row, "BACK", bg=self._colors.field_bg, command=self._on_manual_workout_cancelled, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) def _mw_sport_row(self, parent: tk.Widget) -> None: """Add the sport-selector radio buttons; swaps the activity section. @@ -213,10 +226,10 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): tk.Label( row, text="Sport:", - font=(self._colors.font_family, 16), + font=self._colors.font("body"), fg=self._colors.fg, bg=self._colors.bg, - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) for label in _manual_workout.SPORT_LABELS.values(): tk.Radiobutton( row, @@ -225,7 +238,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): variable=self._mw_sport_var, # Radiobutton's command takes no argument -- bind the label. command=lambda chosen=label: self._on_mw_sport_changed(chosen), - font=(self._colors.font_family, 16), + font=self._colors.font("body"), fg=self._colors.fg, bg=self._colors.bg, activeforeground=self._colors.fg, @@ -235,7 +248,7 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): # form, which likely added to it reading as dead. selectcolor=self._colors.field_bg, highlightthickness=0, - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) self._mw_grid(parent, row, full=True) def _on_mw_sport_changed(self, selected_label: str) -> None: diff --git a/screen_locker/_manual_workout_widgets.py b/screen_locker/_manual_workout_widgets.py index bb90152..01e85b4 100644 --- a/screen_locker/_manual_workout_widgets.py +++ b/screen_locker/_manual_workout_widgets.py @@ -45,8 +45,10 @@ class ManualWorkoutFormWidgetsMixin: column=col, columnspan=2 if full else 1, sticky="ew", - padx=12, - pady=8, + padx=self._colors.space("sm"), + # "xs", not "sm": with ten cells plus headings this gap is paid + # per row, and the form has to fit a 768px panel in full. + pady=self._colors.space("xs"), ) counters[id(parent)] = idx + (2 if full else 1) @@ -69,7 +71,7 @@ class ManualWorkoutFormWidgetsMixin: label = tk.Label( parent, text=title, - font=(self._colors.font_family, 20, "bold"), + font=self._colors.font("body", bold=True), fg=self._colors.accent, bg=self._colors.bg, anchor="w", @@ -94,24 +96,29 @@ class ManualWorkoutFormWidgetsMixin: """ var = tk.StringVar() cell = tk.Frame(parent, bg=self._colors.bg) + # Label beside the field, not above it. Stacked, each of the ten cells + # cost two lines plus the gap between them, and the form measured + # 1320px against a 768px panel; side-by-side each cell is one line + # tall. The lock cannot scroll its way out of not fitting, so rows this + # form does not need are rows it must not spend. tk.Label( cell, text=label, - font=(self._colors.font_family, 16), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, anchor="w", - ).pack(fill="x") + ).pack(side="left", padx=(0, self._colors.space("xs"))) entry = tk.Entry( cell, textvariable=var, - font=(self._colors.font_family, 18), + font=self._colors.font("label"), bg=self._colors.field_bg, fg=self._colors.fg, insertbackground=self._colors.fg, **self._colors.focus_kwargs(), ) - entry.pack(fill="x", pady=4) + entry.pack(side="left", fill="x", expand=True) disable_paste(entry) if focus: entry.focus_set() @@ -127,50 +134,55 @@ class ManualWorkoutFormWidgetsMixin: tk.Label( row, text=label, - font=(self._colors.font_family, 16), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) tk.Spinbox( row, from_=frm, to=to, textvariable=var, width=4, - font=(self._colors.font_family, 16), + font=self._colors.font("label"), **self._colors.focus_kwargs(), - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) self._mw_grid(parent, row) return var def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text: - """Add a full-width label + multi-line Text cell and return the widget.""" + """Add a half-width label + multi-line Text cell and return the widget. + + Half-width, not full: three full-width reflection boxes were three + whole rows of a form that has to fit a 768px panel, and two of them + sit side by side just as legibly. + """ cell = tk.Frame(parent, bg=self._colors.bg) tk.Label( cell, text=label, - font=(self._colors.font_family, 16), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, anchor="w", - ).pack(fill="x", pady=(8, 0)) + ).pack(fill="x") text_widget = tk.Text( cell, - height=3, - font=(self._colors.font_family, 14), + height=2, + font=self._colors.font("label"), bg=self._colors.field_bg, fg=self._colors.fg, insertbackground=self._colors.fg, **self._colors.focus_kwargs(), ) - text_widget.pack(pady=4, fill="x") + text_widget.pack(pady=self._colors.space("xs"), fill="x") disable_paste(text_widget) # Tk makes insert a literal tab and refocus the widget, and binds # to nothing, so an untreated Text is a keyboard dead end -- # and the only exits (Ctrl+Tab / Ctrl+Shift+Tab) are advertised nowhere. # On this form that means never reaching SUBMIT. escape_text_tab_trap(text_widget) - self._mw_grid(parent, cell, full=True) + self._mw_grid(parent, cell) return text_widget def _mw_rpe_row(self, parent: tk.Widget) -> None: @@ -182,17 +194,17 @@ class ManualWorkoutFormWidgetsMixin: f"RPE — perceived exertion " f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):" ), - font=(self._colors.font_family, 16), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) tk.Spinbox( row, from_=MANUAL_WORKOUT_RPE_MIN, to=MANUAL_WORKOUT_RPE_MAX, textvariable=self._mw_rpe_var, width=4, - font=(self._colors.font_family, 16), + font=self._colors.font("label"), **self._colors.focus_kwargs(), - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) self._mw_grid(parent, row) diff --git a/screen_locker/_sick_dialog.py b/screen_locker/_sick_dialog.py index a7d5e7f..3dcd186 100644 --- a/screen_locker/_sick_dialog.py +++ b/screen_locker/_sick_dialog.py @@ -37,26 +37,26 @@ class SickDialogMixin: history = _sick_tracker.load_history() self._sick_history_cache: SickHistory = history self.clear_container() - self._label("Sick Day Request", color=self._colors.warning, pady=10) + self._label("Sick Day Request", color=self._colors.warning, pad="sm") self._text(_sick_tracker.budget_summary(history), color=self._colors.warning) recent = _sick_tracker.format_recent_justifications(history) if recent: self._text( - "Recent sick days:", font_size=14, color=self._colors.muted, pady=8 + "Recent sick days:", role="label", color=self._colors.muted, pad="sm" ) - self._text(recent, font_size=14, color=self._colors.muted, pady=8) + self._text(recent, role="label", color=self._colors.muted, pad="sm") had_commitment = _sick_tracker.had_commitment_for_today(history) if had_commitment: self._text( "⚠ Yesterday you committed to working out today.", - font_size=18, + role="body", color=self._colors.danger, ) self._text( "Breaking the commitment costs 2 sick-budget days.", - font_size=14, + role="label", color=self._colors.danger, ) @@ -65,14 +65,14 @@ class SickDialogMixin: def _build_justification_form(self, *, had_commitment: bool) -> None: """Add justification form fields and submit button to the container.""" form = self.container.child_frame(bg=self._colors.bg) - form.pack(pady=10) + form.pack(pady=self._colors.space("sm")) self._sick_symptom_var = tk.StringVar() self._sick_onset_var = tk.StringVar() self._sick_severity_var = tk.IntVar(value=5) self._sick_text_widget = self._add_form_widgets(form) - self._sick_error_label = self._text("", color=self._colors.danger, pady=8) + self._sick_error_label = self._text("", color=self._colors.danger, pad="sm") button_row = self._button_row() # Starts disabled during the forced-read delay -- field_bg (our @@ -85,14 +85,14 @@ class SickDialogMixin: command=self._submit_sick_justification, width=12, ) - self._sick_submit_button.pack(side="left", padx=10) + self._sick_submit_button.pack(side="left", padx=self._colors.space("sm")) self._button( button_row, "BACK", bg=self._colors.field_bg, command=self._start_phone_check, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) if had_commitment: self._sick_submit_button.config(state="disabled") @@ -113,37 +113,37 @@ class SickDialogMixin: variable=self._sick_onset_var, ) sev_row = parent.child_frame(bg=self._colors.bg) - sev_row.pack(pady=8) + sev_row.pack(pady=self._colors.space("sm")) sev_row.child_widgets( tk.Label, text="Severity (1-10):", - font=(self._colors.font_family, 14), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, - ).pack(side="left", padx=5) + ).pack(side="left", padx=self._colors.space("xs")) sev_row.child_widgets( tk.Spinbox, from_=1, to=10, textvariable=self._sick_severity_var, width=4, - font=(self._colors.font_family, 14), - ).pack(side="left", padx=5) + font=self._colors.font("label"), + ).pack(side="left", padx=self._colors.space("xs")) parent.child_widgets( tk.Label, text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"), - font=(self._colors.font_family, 14), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, - ).pack(pady=8) + ).pack(pady=self._colors.space("sm")) text_widgets = TextGroup( list( parent.child_widgets( tk.Text, width=60, height=6, - font=(self._colors.font_family, 14), + font=self._colors.font("label"), bg=self._colors.field_bg, fg=self._colors.fg, insertbackground=self._colors.fg, @@ -151,7 +151,7 @@ class SickDialogMixin: ) ) ) - text_widgets.pack(pady=8) + text_widgets.pack(pady=self._colors.space("sm")) for text_widget in text_widgets: _disable_paste(text_widget) # Tk traps inside a Text (inserts a tab, refocuses itself) and @@ -218,16 +218,16 @@ class SickDialogMixin: self.clear_container() self._label( "Commit to working out tomorrow?", - font_size=32, + role="display", color=self._colors.warning, - pady=20, + pad="md", ) # ~80 chars at font_size 16 would render as one unbroken line past the # ~70-char readable range (rule 21) -- wrap it explicitly. prompt = self._text( "If you say YES and skip via 'I'm sick' tomorrow, " "the sick day costs 2x normal.", - font_size=16, + role="body", ) prompt.config(wraplength=560, justify="center") self._commitment_done_fn = on_done @@ -243,14 +243,14 @@ class SickDialogMixin: bg=self._colors.success, command=lambda: self._answer_commitment(commit=True), width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._button( row, "NO", bg=self._colors.field_bg, command=lambda: self._answer_commitment(commit=False), width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._tick_commitment_timeout() def _tick_commitment_timeout(self) -> None: diff --git a/screen_locker/_temperature_status_mixin.py b/screen_locker/_temperature_status_mixin.py index 455b244..34b9a9a 100644 --- a/screen_locker/_temperature_status_mixin.py +++ b/screen_locker/_temperature_status_mixin.py @@ -37,25 +37,25 @@ class TemperatureStatusMixin: fetched in the background. """ del parent - self._label("Warsaw Temperature", font_size=16, pady=8) + self._label("Warsaw Temperature", role="body", pad="sm") result = self._temp_result if result is None: self._text( - "Checking Warsaw temperature…", font_size=12, color=self._colors.muted + "Checking Warsaw temperature…", role="caption", color=self._colors.muted ) return if result.timed_out: self._text( f"Warsaw temperature check timed out after ~" f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.", - font_size=12, + role="caption", color=self._colors.warning, ) return if result.temp_celsius is None: self._text( "Warsaw temperature check failed (network/API error).", - font_size=12, + role="caption", color=self._colors.warning, ) return @@ -63,13 +63,13 @@ class TemperatureStatusMixin: self._text( f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold " f"{HEAT_SKIP_TEMP_THRESHOLD}°C)", - font_size=12, + role="caption", color=self._colors.warning if hot else self._colors.muted, ) if hot: self._text( "Would trigger heat-skip today.", - font_size=11, + role="caption", color=self._colors.danger, ) diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 07ae997..b7029f5 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -19,13 +19,22 @@ if TYPE_CHECKING: class UIFlowsMixin: """Mixin providing UI flow logic for the screen locker.""" - def _start_phone_check(self) -> None: - """Check phone for today's workout immediately at startup.""" + def paint_phone_check(self) -> None: + """Paint the "checking phone" screen, without starting the check. + + Split from :meth:`_start_phone_check` so the fit check + (``scripts/verify_screen_fits.py``) can measure this screen without + starting a background thread that talks to a phone over adb. + """ self.clear_container() self._label( - "Checking phone...", font_size=36, color=self._colors.warning, pady=30 + "Checking phone...", role="display", color=self._colors.warning, pad="lg" ) - self._text("Looking for today's workout in StrongLifts...", font_size=18) + self._text("Looking for today's workout in StrongLifts...", role="body") + + def _start_phone_check(self) -> None: + """Check phone for today's workout immediately at startup.""" + self.paint_phone_check() executor = ThreadPoolExecutor(max_workers=1) self._phone_future = executor.submit(self._verify_phone_workout) executor.shutdown(wait=False) @@ -43,7 +52,7 @@ class UIFlowsMixin: """Show TRY AGAIN and (if budget allows) I'm sick after a failed check.""" self.clear_container() self._label( - "No Workout Found", font_size=36, color=self._colors.danger, pady=20 + "No Workout Found", role="display", color=self._colors.danger, pad="md" ) self._text(message, color=self._colors.warning) history = _sick_tracker.load_history() @@ -58,7 +67,7 @@ class UIFlowsMixin: bg=self._colors.accent, command=self._start_phone_check, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) if _sick_tracker.is_budget_exhausted(history): self._text( "Sick budget exhausted. No 'I'm sick' option available.", @@ -71,7 +80,7 @@ class UIFlowsMixin: bg=self._colors.warning, command=self.ask_if_sick, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) if _manual_workout.is_budget_exhausted(self.log_file): self._text( "Manual-workout budget exhausted. No manual-log option available.", @@ -84,7 +93,7 @@ class UIFlowsMixin: bg=self._colors.accent, command=self._show_manual_workout_form, width=16, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) def _on_manual_workout_saved(self, entry: dict) -> None: """Show confirmation and unlock after a manual-workout entry is built.""" @@ -92,12 +101,13 @@ class UIFlowsMixin: self.clear_container() self._label( "✓ Manual Workout Logged!", - font_size=42, + role="display", + scale=1.3, color=self._colors.success, - pady=30, + pad="lg", ) - self._text(entry.get("source", ""), font_size=20, color=self._colors.success) - self._text("Unlocking...", font_size=18, color=self._colors.muted) + self._text(entry.get("source", ""), role="subtitle", color=self._colors.success) + self._text("Unlocking...", role="body", color=self._colors.muted) unlock_delay = 1500 if self.demo_mode else 2000 self.root.after(unlock_delay, self.unlock_screen) @@ -113,12 +123,13 @@ class UIFlowsMixin: self.clear_container() self._label( "✓ Workout Verified!", - font_size=42, + role="display", + scale=1.3, color=self._colors.success, - pady=30, + pad="lg", ) - self._text(message, font_size=20, color=self._colors.success) - self._text("Unlocking...", font_size=18, color=self._colors.muted) + self._text(message, role="subtitle", color=self._colors.success) + self._text("Unlocking...", role="body", color=self._colors.muted) unlock_delay = 1500 if self.demo_mode else 2000 self.root.after(unlock_delay, self.unlock_screen) elif status == "too_short": @@ -155,9 +166,9 @@ class UIFlowsMixin: """ self.clear_container() self._label( - "Checking RunnerUp...", font_size=36, color=self._colors.warning, pady=30 + "Checking RunnerUp...", role="display", color=self._colors.warning, pad="lg" ) - self._text("Looking for today's run in RunnerUp...", font_size=18) + self._text("Looking for today's run in RunnerUp...", role="body") executor = ThreadPoolExecutor(max_workers=1) self._runnerup_future = executor.submit(self._verify_runnerup_workout) executor.shutdown(wait=False) @@ -174,12 +185,13 @@ class UIFlowsMixin: self.clear_container() self._label( "✓ Run Verified!", - font_size=42, + role="display", + scale=1.3, color=self._colors.success, - pady=30, + pad="lg", ) - self._text(message, font_size=20, color=self._colors.success) - self._text("Unlocking...", font_size=18, color=self._colors.muted) + self._text(message, role="subtitle", color=self._colors.success) + self._text("Unlocking...", role="body", color=self._colors.muted) unlock_delay = 1500 if self.demo_mode else 2000 self.root.after(unlock_delay, self.unlock_screen) else: @@ -228,18 +240,19 @@ class UIFlowsMixin: countdown: int, ) -> None: """Display sick day UI labels and countdown.""" - self._label("Sick Day Mode", color=self._colors.warning, pady=20) + self._label("Sick Day Mode", color=self._colors.warning, pad="md") self._text(status_text, color=status_color) minutes = countdown // 60 self._text( f"Please wait ~{minutes} min before unlocking...", - font_size=24, - pady=20, + role="title", + pad="md", ) self.sick_countdown_label = self._label( str(countdown), - font_size=80, - pady=30, + role="display", + scale=2.5, + pad="lg", ) def _update_sick_countdown(self) -> None: @@ -275,14 +288,16 @@ class UIFlowsMixin: self.clear_container() self.lockout_label = self._label( f"Go work out!\nLocked for {self.lockout_time} seconds", - font_size=48, + role="display", + scale=1.5, color=self._colors.danger, - pady=30, + pad="lg", ) self.countdown_label = self._label( str(self.lockout_time), - font_size=120, - pady=30, + role="display", + scale=3.75, + pad="lg", ) self.remaining_time = self.lockout_time self.update_lockout_countdown() @@ -323,22 +338,23 @@ class UIFlowsMixin: ) self._label( "Cannot Verify Workout", - font_size=36, + role="display", color=self._colors.warning, - pady=20, + pad="md", ) self._text(message, color=self._colors.warning) self._text( "Connect phone via ADB to skip this wait,\n" "or wait for the penalty timer.\n\n" "Note: Phone must be rooted and StrongLifts installed.", - font_size=18, + role="body", ) self.phone_penalty_remaining = delay self.phone_penalty_label = self._label( str(delay), - font_size=80, - pady=20, + role="display", + scale=2.5, + pad="md", ) self._update_phone_penalty() diff --git a/screen_locker/_ui_flows_relaxed.py b/screen_locker/_ui_flows_relaxed.py index d0d0185..7231c20 100644 --- a/screen_locker/_ui_flows_relaxed.py +++ b/screen_locker/_ui_flows_relaxed.py @@ -22,13 +22,13 @@ class UIFlowsRelaxedMixin: self.clear_container() self._label( "Verifying Workout", - font_size=36, + role="display", color=self._colors.warning, - pady=30, + pad="lg", ) self._text( "Checking phone for today's workout...", - font_size=18, + role="body", ) executor = ThreadPoolExecutor(max_workers=1) self._phone_future = executor.submit(self._verify_phone_workout) @@ -58,15 +58,16 @@ class UIFlowsRelaxedMixin: self.clear_container() self._label( "✓ Workout Verified!", - font_size=42, + role="display", + scale=1.3, color=self._colors.success, - pady=30, + pad="lg", ) - self._text(message, font_size=20, color=self._colors.success) + self._text(message, role="subtitle", color=self._colors.success) if adjusted: self._text( "Shutdown time moved later!", - font_size=20, + role="subtitle", color=self._colors.warning, ) self.root.after(2000, self.close) @@ -78,9 +79,9 @@ class UIFlowsRelaxedMixin: self.clear_container() self._label( "Workout Not Found", - font_size=36, + role="display", color=self._colors.danger, - pady=20, + pad="md", ) self._text(message, color=self._colors.warning) frame = self._button_row() @@ -90,14 +91,14 @@ class UIFlowsRelaxedMixin: bg=self._colors.accent, command=self._start_verify_workout_check, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._button( frame, "Close", bg=self._colors.field_bg, command=self.close, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) # ------------------------------------------------------------------ # Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping) @@ -113,16 +114,16 @@ class UIFlowsRelaxedMixin: self.clear_container() self._label( "Optional Day (Tue / Wed / Thu)", - font_size=30, + role="title", color=self._colors.warning, - pady=20, + pad="md", ) self._text( f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n" "No penalty for skipping today.", - font_size=20, + role="subtitle", color=self._colors.muted, - pady=10, + pad="sm", ) frame = self._button_row() self._button( @@ -131,22 +132,22 @@ class UIFlowsRelaxedMixin: bg=self._colors.success, command=self.close, width=18, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._button( frame, "Log Stronglift Workout", bg=self._colors.accent, command=self._start_relaxed_phone_check, width=20, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) def _start_relaxed_phone_check(self) -> None: """Run Stronglift check in relaxed mode (no screen grab, no sick option).""" self.clear_container() self._label( - "Checking phone...", font_size=36, color=self._colors.warning, pady=30 + "Checking phone...", role="display", color=self._colors.warning, pad="lg" ) - self._text("Looking for today's workout in StrongLifts...", font_size=18) + self._text("Looking for today's workout in StrongLifts...", role="body") executor = ThreadPoolExecutor(max_workers=1) self._phone_future = executor.submit(self._verify_phone_workout) executor.shutdown(wait=False) @@ -178,7 +179,7 @@ class UIFlowsRelaxedMixin: """Show retry and skip-close when workout not found in relaxed mode.""" self.clear_container() self._label( - "No Workout Found", font_size=36, color=self._colors.danger, pady=20 + "No Workout Found", role="display", color=self._colors.danger, pad="md" ) self._text(f"❌ {message}\n\nReason: {status}", color=self._colors.warning) frame = self._button_row() @@ -188,11 +189,11 @@ class UIFlowsRelaxedMixin: bg=self._colors.accent, command=self._start_relaxed_phone_check, width=12, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) self._button( frame, "Close (Skip)", bg=self._colors.success, command=self.close, width=14, - ).pack(side="left", padx=10) + ).pack(side="left", padx=self._colors.space("sm")) diff --git a/screen_locker/_ui_widgets.py b/screen_locker/_ui_widgets.py index fbfefa1..155432e 100644 --- a/screen_locker/_ui_widgets.py +++ b/screen_locker/_ui_widgets.py @@ -11,6 +11,8 @@ from gatelock import bind_activate, escape_text_tab_trap if TYPE_CHECKING: from collections.abc import Callable + from gatelock import SpaceStep, TypeRole + from screen_locker._surface_group import FrameGroup, WidgetGroup @@ -52,38 +54,54 @@ class UIWidgetsMixin: self, text: str, *, - font_size: int = 36, + role: TypeRole = "display", + scale: float = 1.0, color: str | None = None, - pady: int = 20, + pad: SpaceStep = "md", ) -> WidgetGroup: - """Create and pack a bold label on every monitor.""" + """Create and pack a bold label on every monitor. + + ``role``/``pad`` name a design-system token instead of a pixel count. + That is what makes a screen shrink on a short display: the tokens are + compacted per screen height (``gatelock._density``), and a raw literal + is not. It also fixes the unit bug the literals carried -- Tk reads a + positive size as *points*, so ``36`` rendered ~37% larger than the + 36px it was meant to be, on every screen. + + ``scale`` is for deliberate emphasis (a hero countdown), kept explicit + so an outlier is visible rather than hidden behind a fresh literal. + """ label = self.container.child_widgets( tk.Label, text=text, - font=(self._colors.font_family, font_size, "bold"), + font=self._colors.font(role, bold=True, scale=scale), fg=color or self._colors.fg, bg=self._colors.bg, ) - label.pack(pady=pady) + label.pack(pady=self._colors.space(pad)) return label def _text( self, text: str, *, - font_size: int = 18, + role: TypeRole = "body", + scale: float = 1.0, color: str | None = None, - pady: int = 10, + pad: SpaceStep = "sm", ) -> WidgetGroup: - """Create and pack a non-bold text label on every monitor.""" + """Create and pack a non-bold text label on every monitor. + + See :meth:`_label` for why these are token names, not pixel counts. + """ label = self.container.child_widgets( tk.Label, text=text, - font=(self._colors.font_family, font_size), + font=self._colors.font(role, scale=scale), fg=color or self._colors.fg, bg=self._colors.bg, ) - label.pack(pady=pady) + label.pack(pady=self._colors.space(pad)) return label def _fg_for(self, bg: str) -> str: @@ -138,15 +156,15 @@ class UIWidgetsMixin: buttons = parent.child_widgets( tk.Button, text=text, - font=(self._colors.font_family, 24, "bold"), + font=self._colors.font("title", bold=True), bg=bg, fg=self._fg_for(bg), width=width, command=command, cursor="hand2" if self.demo_mode else "", relief="flat", - padx=24, - pady=12, + padx=self._colors.space("lg"), + pady=self._colors.space("sm"), **self._colors.focus_kwargs(), ) # Every per-monitor copy, not just `.first` -- one flow paints N screens @@ -156,9 +174,14 @@ class UIWidgetsMixin: return buttons def _button_row(self) -> FrameGroup: - """Create and pack a horizontal button container on every monitor.""" + """Create and pack a horizontal button container on every monitor. + + The gap is "sm" rather than "md" because this row is the last thing + on every screen: the pixels it spends are the ones the manual-workout + form has left over on a 1024x600 panel. + """ frame = self.container.child_frame(bg=self._colors.bg) - frame.pack(pady=20) + frame.pack(pady=self._colors.space("sm")) return frame def _add_label_entry( @@ -181,11 +204,11 @@ class UIWidgetsMixin: window on screen to make that obvious. """ row = parent.child_frame(bg=self._colors.bg) - row.pack(pady=8, fill="x") + row.pack(pady=self._colors.space("sm"), fill="x") row.child_widgets( tk.Label, text=label, - font=(self._colors.font_family, 14), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, anchor="w", @@ -194,13 +217,13 @@ class UIWidgetsMixin: tk.Entry, textvariable=variable, width=50, - font=(self._colors.font_family, 14), + font=self._colors.font("label"), bg=self._colors.field_bg, fg=self._colors.fg, insertbackground=self._colors.fg, **self._colors.focus_kwargs(), ) - entries.pack(side="top", anchor="w", pady=4) + entries.pack(side="top", anchor="w", pady=self._colors.space("xs")) for entry in entries: disable_paste(entry) if focus: @@ -223,11 +246,11 @@ class UIWidgetsMixin: into it can never reach the submit button. """ row = parent.child_frame(bg=self._colors.bg) - row.pack(pady=8, fill="x") + row.pack(pady=self._colors.space("sm"), fill="x") row.child_widgets( tk.Label, text=label, - font=(self._colors.font_family, 14), + font=self._colors.font("label"), fg=self._colors.fg, bg=self._colors.bg, anchor="w", @@ -237,13 +260,13 @@ class UIWidgetsMixin: width=60, height=height, wrap="word", - font=(self._colors.font_family, 14), + font=self._colors.font("label"), bg=self._colors.field_bg, fg=self._colors.fg, insertbackground=self._colors.fg, **self._colors.focus_kwargs(), ) - boxes.pack(side="top", anchor="w", pady=4) + boxes.pack(side="top", anchor="w", pady=self._colors.space("xs")) for box in boxes: disable_paste(box) escape_text_tab_trap(box) diff --git a/screen_locker/_unlock_view.py b/screen_locker/_unlock_view.py new file mode 100644 index 0000000..2626d1c --- /dev/null +++ b/screen_locker/_unlock_view.py @@ -0,0 +1,71 @@ +"""The success screen shown once a workout has been credited. + +Split out of ``screen_lock`` so that module stays inside the repo's 400-line +cap; this is pure view code (paint the credit, then hand off to the +commitment prompt or the close), which is exactly the kind of thing the other +``_ui_*`` mixins already own. +""" + +from __future__ import annotations + +from screen_locker._constants import EXTRA_BENEFITS_FILE +from screen_locker._extra_benefits import current_streak + +_UNLOCK_DELAY_MS = 1500 + +# Workout kinds that carry a commitment prompt on the way out: they are the +# ones the user actually performed today, so "will you work out tomorrow?" is +# a question worth asking. +_COMMITTABLE = ("phone_verified", "runnerup_verified", "manual_workout") + + +class UnlockViewMixin: + """Paints the unlock screen and routes to whatever follows it.""" + + def unlock_screen(self) -> None: + """Apply workout credit and display the success message.""" + credit = self._apply_workout_credit() + + self.clear_container() + self._label( + "Great job! 💪", + role="display", + scale=1.5, + color=self._colors.success, + pad="lg", + ) + if credit.shutdown_adjusted: + self._text( + "Shutdown time +2h later! 🎁", + role="title", + color=self._colors.warning, + ) + if credit.extra_bonus_delta > 0: + self._text( + f"Extra workout today! +{credit.extra_bonus_delta}h tonight", + role="subtitle", + color=self._colors.warning, + ) + if credit.new_debt is not None: + self._text( + f"Workout debt: {credit.new_debt}", + role="subtitle", + color=self._colors.warning + if credit.new_debt > 0 + else self._colors.muted, + ) + streak = current_streak(EXTRA_BENEFITS_FILE) + if streak >= 1: + self._text( + f"🔥 {streak}-week streak (5+ workouts each)", + role="label", + color=self._colors.muted, + ) + self._text("Screen Unlocked!", role="display", pad="md") + if self.workout_data.get("type") in _COMMITTABLE: + self.root.after( + _UNLOCK_DELAY_MS, + lambda: self._show_commitment_prompt(on_done=self.close), + ) + else: + self.root.after(_UNLOCK_DELAY_MS, self.close) diff --git a/screen_locker/_window_setup.py b/screen_locker/_window_setup.py index 4762b21..3ed35e5 100644 --- a/screen_locker/_window_setup.py +++ b/screen_locker/_window_setup.py @@ -201,7 +201,7 @@ class WindowSetupMixin: close_btn = tk.Button( self.root, text="✕ Close Demo", - font=(self._colors.font_family, 12), + font=self._colors.font("caption"), bg=self._colors.danger, fg=self._colors.on_fill, command=self.close, diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 1777a32..7d0b7f4 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -38,7 +38,6 @@ from screen_locker._constants import ( ) from screen_locker._early_bird import EarlyBirdMixin from screen_locker._extra_benefits import ( - current_streak, process_week_transition, weekly_shutdown_bonus_hours, ) @@ -57,6 +56,7 @@ from screen_locker._temperature import fetch_current_temp_with_status from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin +from screen_locker._unlock_view import UnlockViewMixin from screen_locker._weekly_check import ( WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, @@ -119,6 +119,7 @@ class ScreenLocker( UIFlowsMixin, UIFlowsRelaxedMixin, UIWidgetsMixin, + UnlockViewMixin, ): """Screen locker that requires workout logging to unlock.""" @@ -315,52 +316,6 @@ class ScreenLocker( if bonus > 0 and self._adjust_shutdown_time_by(bonus): _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus) - def unlock_screen(self) -> None: - """Apply workout credit and display success message.""" - credit = self._apply_workout_credit() - - self.clear_container() - self._label("Great job! 💪", font_size=48, color=self._colors.success, pady=30) - if credit.shutdown_adjusted: - self._text( - "Shutdown time +2h later! 🎁", - font_size=24, - color=self._colors.warning, - ) - if credit.extra_bonus_delta > 0: - self._text( - f"Extra workout today! +{credit.extra_bonus_delta}h tonight", - font_size=20, - color=self._colors.warning, - ) - if credit.new_debt is not None: - self._text( - f"Workout debt: {credit.new_debt}", - font_size=20, - color=self._colors.warning - if credit.new_debt > 0 - else self._colors.muted, - ) - streak = current_streak(EXTRA_BENEFITS_FILE) - if streak >= 1: - self._text( - f"🔥 {streak}-week streak (5+ workouts each)", - font_size=14, - color=self._colors.muted, - ) - self._text("Screen Unlocked!", font_size=36, pady=20) - if self.workout_data.get("type") in ( - "phone_verified", - "runnerup_verified", - "manual_workout", - ): - self.root.after( - 1500, - lambda: self._show_commitment_prompt(on_done=self.close), - ) - else: - self.root.after(1500, self.close) - def close(self) -> None: """Close the application and exit.""" if self._lock is not None: diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index e43f908..0e248d9 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -115,7 +115,7 @@ class StatusWindow( """Redraw the whole window from *snapshot*.""" self._last_snapshot = snapshot self.clear_container() - self._label("Workout Status", font_size=28, pady=15) + self._label("Workout Status", role="title", pad="md") self._section_today(self.container, snapshot.today) self._section_week(self.container, snapshot.week) self._section_lock_explanation(self.container, snapshot.lock_explanation) @@ -130,9 +130,9 @@ class StatusWindow( color = ( self._colors.success if status == "verified" else self._colors.warning ) - self._text(f"Phone check ({status}): {message}", font_size=16, color=color) + self._text(f"Phone check ({status}): {message}", role="body", color=color) if self._credit_message is not None: - self._text(self._credit_message, font_size=16, color=self._colors.success) + self._text(self._credit_message, role="body", color=self._colors.success) # "Check Phone"/"Log Manual Workout" are the primary write actions # (accent, high contrast per rule 3); "Refresh"/"Close" are secondary # utility actions (muted) -- two tiers instead of four arbitrary hues. @@ -143,7 +143,7 @@ class StatusWindow( bg=self._colors.accent, command=self._on_check_phone_clicked, width=14, - ).pack(side="left", padx=8) + ).pack(side="left", padx=self._colors.space("sm")) if not _manual_workout.is_budget_exhausted(self.log_file): self._button( frame, @@ -151,21 +151,21 @@ class StatusWindow( bg=self._colors.accent, command=self._show_manual_workout_form, width=16, - ).pack(side="left", padx=8) + ).pack(side="left", padx=self._colors.space("sm")) self._button( frame, "Refresh", bg=self._colors.field_bg, command=self._on_refresh_clicked, width=10, - ).pack(side="left", padx=8) + ).pack(side="left", padx=self._colors.space("sm")) self._button( frame, "Close", bg=self._colors.field_bg, command=self.root.destroy, width=8, - ).pack(side="left", padx=8) + ).pack(side="left", padx=self._colors.space("sm")) def _section_today(self, parent: tk.Widget, day: DayStatus) -> None: """Render today's outcome.""" @@ -176,19 +176,19 @@ class StatusWindow( ) self._text( f"{mark} Today ({day.label}): {entry_str}", - font_size=18, + role="body", color=self._colors.success if day.counted else self._colors.warning, ) if day.source: # Secondary provenance note on an already-shown entry -- a # deliberately caption-sized exception to the 16px floor. - self._text(day.source, font_size=12, color=self._colors.muted, pady=4) + self._text(day.source, role="caption", color=self._colors.muted, pad="xs") def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None: """Render this ISO week's per-day breakdown and totals.""" del parent self._label( - f"This Week: {week.counted_count}/{week.minimum}", font_size=18, pady=8 + f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm" ) for day in week.days: mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·") @@ -197,20 +197,20 @@ class StatusWindow( ) self._text( f"{mark} {day.label}: {entry_str}", - font_size=16, + role="body", color=self._colors.muted, - pady=4, + pad="xs", ) if week.remaining > 0: self._text( f"Need {week.remaining} more this week.", - font_size=16, + role="body", color=self._colors.warning, ) elif week.extra > 0: self._text( f"{week.extra} above the weekly minimum!", - font_size=16, + role="body", color=self._colors.success, ) @@ -219,27 +219,27 @@ class StatusWindow( ) -> None: """Render why the lock did/didn't fire today, plus its evaluation trace.""" del parent - self._label("Why the lock did/didn't fire", font_size=16, pady=8) + self._label("Why the lock did/didn't fire", role="body", pad="sm") self._text( expl.reason, - font_size=16, + role="body", color=self._colors.danger if expl.fired else self._colors.success, ) if expl.auto_upgrade.would_attempt: self._text( f"Pending auto-upgrade: {expl.auto_upgrade.reason}", - font_size=14, + role="label", color=self._colors.warning, ) def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: """Render rolling sick-day budget usage.""" del parent - self._label("Sick Budget", font_size=16, pady=8) + self._label("Sick Budget", role="body", pad="sm") self._text( f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} " f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}", - font_size=16, + role="body", color=self._colors.danger if sick.exhausted else self._colors.muted, ) @@ -248,11 +248,11 @@ class StatusWindow( ) -> None: """Render rolling manual-workout budget usage.""" del parent - self._label("Manual Workout Budget", font_size=16, pady=8) + self._label("Manual Workout Budget", role="body", pad="sm") self._text( f"{manual.used_7d}/{manual.budget_7d} week · " f"{manual.used_30d}/{manual.budget_30d} month", - font_size=16, + role="body", color=self._colors.danger if manual.exhausted else self._colors.muted, ) @@ -261,18 +261,18 @@ class StatusWindow( ) -> None: """Render tonight's live config, rest-of-week, and next-week preview.""" del parent - self._label("Shutdown Time", font_size=16, pady=8) + self._label("Shutdown Time", role="body", pad="sm") if shutdown.tonight is not None: mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight self._text( f"Live config — Mon-Wed {mon_wed_hour:02d}:00, " f"Thu-Sun {thu_sun_hour:02d}:00", - font_size=16, + role="body", ) else: self._text( "Live shutdown config unavailable.", - font_size=16, + role="body", color=self._colors.warning, ) # Rest-of-week/next-week/explanation are speculative annotations, not @@ -282,16 +282,18 @@ class StatusWindow( rest_line = ", ".join( f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week ) - self._text(f"Rest of week: {rest_line}", font_size=12, color=self._colors.muted) + self._text( + f"Rest of week: {rest_line}", role="caption", color=self._colors.muted + ) next_line = ", ".join( f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview ) self._text( f"Next week (speculative): {next_line}", - font_size=12, + role="caption", color=self._colors.muted, ) - self._text(shutdown.explanation, font_size=12, color=self._colors.muted) + self._text(shutdown.explanation, role="caption", color=self._colors.muted) def _on_refresh_clicked(self) -> None: """Clear any stale phone-check/credit/temperature results, re-check both.""" diff --git a/screen_locker/tests/test_focus_and_scroll.py b/screen_locker/tests/test_focus_and_scroll.py new file mode 100644 index 0000000..6e76d12 --- /dev/null +++ b/screen_locker/tests/test_focus_and_scroll.py @@ -0,0 +1,222 @@ +"""Keyboard focus and viewport settling on the lock surfaces. + +The lock holds a *global* grab with VT switching disabled: a surface where +nothing is focused leaves a keyboard-only user typing into the void, and a +viewport that is never re-settled after a repaint reports the wrong size for +the screen it is showing. Both were shipped without tests in 3e77f4e, and the +scroll behaviour they drive is what moved the screen on its own on +2026-08-03 (see ``gatelock/tests/test_scrollable.py`` for that half). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +from screen_locker._surface_group import FrameGroup +from screen_locker._window_setup import _first_focusable +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +def _widget(cls: str, *, takefocus: str = "", children: list | None = None): + """A widget stand-in that answers only what ``_first_focusable`` asks.""" + widget = MagicMock() + widget.winfo_class.return_value = cls + widget.cget.return_value = takefocus + widget.winfo_children.return_value = children or [] + return widget + + +class TestFirstFocusable: + """Which widget the lock hands the keyboard to when a screen appears.""" + + def test_picks_the_first_focusable_child(self) -> None: + """A button after a label is still the first thing focus can land on.""" + label = _widget("Label") + button = _widget("Button") + parent = _widget("Frame", children=[label, button]) + + assert _first_focusable(parent) is button + + def test_descends_into_containers(self) -> None: + """Buttons live inside a row frame, not directly on the surface.""" + button = _widget("Button") + row = _widget("Frame", children=[button]) + parent = _widget("Frame", children=[row]) + + assert _first_focusable(parent) is button + + def test_skips_widgets_that_opted_out(self) -> None: + """``takefocus=0`` means the widget is not in the tab ring at all.""" + opted_out = _widget("Button", takefocus="0") + real = _widget("Entry") + parent = _widget("Frame", children=[opted_out, real]) + + assert _first_focusable(parent) is real + + def test_returns_none_when_nothing_can_take_focus(self) -> None: + """A caller must be able to tell "nothing to focus" from "focused".""" + parent = _widget("Frame", children=[_widget("Label")]) + + assert _first_focusable(parent) is None + + +@pytest.mark.usefixtures("mock_sys_exit") +class TestFocusOnAppear: + """``on_focus_ready`` is what makes a fresh screen keyboard-operable.""" + + def test_focuses_the_first_focusable_widget( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """The hook exists so a button-only screen does not start unfocused.""" + locker = create_locker(mock_tk, tmp_path) + button = _widget("Button") + locker.container = FrameGroup([_widget("Frame", children=[button])]) + + locker.on_focus_ready(MagicMock()) + + button.focus_set.assert_called_once_with() + + def test_no_live_output_is_not_an_error( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """The lock is still held with every monitor dark; nothing to focus.""" + locker = create_locker(mock_tk, tmp_path) + + locker.on_focus_ready(None) # must not raise + + def test_a_surface_with_nothing_focusable_is_left_alone( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """A pure status screen has no controls, and that is a valid state.""" + locker = create_locker(mock_tk, tmp_path) + locker.container = FrameGroup([_widget("Frame", children=[_widget("Label")])]) + + locker.on_focus_ready(MagicMock()) # must not raise + + def test_no_surfaces_at_all(self, mock_tk: MagicMock, tmp_path: Path) -> None: + """Focus is requested before any surface exists during startup races.""" + locker = create_locker(mock_tk, tmp_path) + locker.container = FrameGroup([]) + + locker.on_focus_ready(MagicMock()) # must not raise + + def test_focus_first_button_takes_the_primary_surface( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """One screen paints N monitors; the keyboard is at exactly one.""" + locker = create_locker(mock_tk, tmp_path) + button = _widget("Button") + row = FrameGroup([_widget("Frame", children=[_widget("Label"), button])]) + + locker._focus_first_button(row) + + button.focus_set.assert_called_once_with() + + def test_focus_first_button_without_a_button( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """A row of plain labels must not raise looking for a button.""" + locker = create_locker(mock_tk, tmp_path) + row = FrameGroup([_widget("Frame", children=[_widget("Label")])]) + + locker._focus_first_button(row) # must not raise + + +@pytest.mark.usefixtures("mock_sys_exit") +class TestSurfacesSettle: + """Every repaint has to re-settle the viewport it painted into.""" + + def test_settle_finalizes_every_scroller( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """One monitor per scroller, and each one measures its own screen.""" + locker = create_locker(mock_tk, tmp_path) + first, second = MagicMock(), MagicMock() + locker._scrollers.clear() + locker._scrollers.update({"eDP-1": first, "HDMI-1": second}) + + locker.settle_surfaces() + + first.finalize.assert_called_once_with() + second.finalize.assert_called_once_with() + + def test_clearing_the_container_schedules_a_settle( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """The hook that makes settling unforgettable across ~19 repaint sites. + + Scheduled rather than run inline: running it here would measure an + empty container, since the caller has not painted the new screen yet. + """ + locker = create_locker(mock_tk, tmp_path) + locker.root.after_idle.reset_mock() + + locker.clear_container() + + locker.root.after_idle.assert_called_once_with(locker.settle_surfaces) + + def test_a_dark_output_drops_its_scroller( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """A monitor that goes away must not be settled or painted again.""" + locker = create_locker(mock_tk, tmp_path) + surface = MagicMock() + surface.output_name = "HDMI-1" + locker._scrollers["HDMI-1"] = MagicMock() + + locker.teardown_surface(surface) + + assert "HDMI-1" not in locker._scrollers + + +@pytest.mark.usefixtures("mock_sys_exit") +class TestMultiLineField: + """``_add_label_text`` is the only Tab-safe multi-line field helper.""" + + def test_builds_a_label_and_a_text_box_on_every_monitor( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """Both halves fan out, like every other widget helper here.""" + locker = create_locker(mock_tk, tmp_path) + parent = locker.container.child_frame(bg=locker._colors.bg) + mock_tk.Text.reset_mock() + + boxes = locker._add_label_text(parent, label="Why?", height=3) + + assert mock_tk.Text.call_count == len(locker.container.surfaces) + assert mock_tk.Text.call_args.kwargs["height"] == 3 + assert boxes.first is not None + + def test_the_box_is_not_a_keyboard_dead_end( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + """Tk makes insert a tab character and refocus the same widget. + + Untreated, that traps a keyboard user inside the field with no + advertised way out -- on this lock, that means never reaching SUBMIT. + """ + locker = create_locker(mock_tk, tmp_path) + parent = locker.container.child_frame(bg=locker._colors.bg) + + boxes = locker._add_label_text(parent, label="Why?") + + bound = [call.args[0] for box in boxes for call in box.bind.call_args_list] + assert "" in bound + assert "" in bound + + def test_paste_is_disabled(self, mock_tk: MagicMock, tmp_path: Path) -> None: + """The justification has to be typed, like every other field here.""" + locker = create_locker(mock_tk, tmp_path) + parent = locker.container.child_frame(bg=locker._colors.bg) + + boxes = locker._add_label_text(parent, label="Why?") + + bound = [call.args[0] for box in boxes for call in box.bind.call_args_list] + assert "<>" in bound + assert "" in bound diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index f7a9790..29f868f 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -88,7 +88,7 @@ class TestUnlockScreenExtras: return_value=5, ), patch( - "screen_locker.screen_lock.current_streak", + "screen_locker._unlock_view.current_streak", return_value=0, ), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), @@ -124,7 +124,7 @@ class TestUnlockScreenExtras: return_value=5, ), patch( - "screen_locker.screen_lock.current_streak", + "screen_locker._unlock_view.current_streak", return_value=0, ), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), @@ -155,7 +155,7 @@ class TestUnlockScreenExtras: "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), - patch("screen_locker.screen_lock.current_streak", return_value=0), + patch("screen_locker._unlock_view.current_streak", return_value=0), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), ): locker.unlock_screen() @@ -181,7 +181,7 @@ class TestUnlockScreenExtras: "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), - patch("screen_locker.screen_lock.current_streak", return_value=0), + patch("screen_locker._unlock_view.current_streak", return_value=0), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), ): locker.unlock_screen() @@ -210,7 +210,7 @@ class TestUnlockScreenExtras: return_value=3, ), patch( - "screen_locker.screen_lock.current_streak", + "screen_locker._unlock_view.current_streak", return_value=3, ), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), @@ -235,7 +235,7 @@ class TestUnlockScreenExtras: "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), - patch("screen_locker.screen_lock.current_streak", return_value=0), + patch("screen_locker._unlock_view.current_streak", return_value=0), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), ): locker.unlock_screen() @@ -257,7 +257,7 @@ class TestUnlockScreenExtras: "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), - patch("screen_locker.screen_lock.current_streak", return_value=0), + patch("screen_locker._unlock_view.current_streak", return_value=0), patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None), ): locker.unlock_screen() diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py new file mode 100755 index 0000000..86b27f9 --- /dev/null +++ b/scripts/verify_screen_fits.py @@ -0,0 +1,259 @@ +#!/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 deliberately absent: it is a normal, resizable, +dismissible window rather than a lock surface, so a user who needs more of it +can resize it. Every screen that grabs the display is here. + +Not a pytest test because ``screen_locker/tests/conftest.py`` replaces the +whole ``tk`` module for every test so the suite can never reach a display, and +widget heights can only be measured by really rendering them. Like +``verify_lock_popup_safety.py``, this re-executes itself under Xvfb: + + python3 ~/screen-locker/scripts/verify_screen_fits.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import os +from pathlib import Path +import shutil +import subprocess +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._heat_skip import build_heat_skip_content +from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin +from screen_locker._sick_dialog import SickDialogMixin +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 + +if TYPE_CHECKING: + from collections.abc import Callable + import tkinter as tk + + 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!" +) + + +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 every size under test, so each surface is sized by + # the geometry this harness sets rather than clamped by the display. + "-s", + "-screen 0 1600x1200x24", + sys.executable, + str(Path(__file__).resolve()), + ], + check=False, + env=env, + ).returncode + + +class ScreenHost( + UIWidgetsMixin, + UIFlowsMixin, + UIFlowsRelaxedMixin, + SickDialogMixin, + 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.verify_only = False + self.log_file = ( + Path(__file__).resolve().parent.parent + / "screen_locker" + / "workout_log.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(self) -> None: + """Paint the sick-day justification screen.""" + self._show_sick_justification() + + def paint_relaxed_retry(self) -> None: + """Paint the relaxed-day (Tue-Thu) retry screen.""" + self._show_relaxed_retry(LONG_MESSAGE, "no_workout") + + def paint_verify_retry(self) -> None: + """Paint the verify-mode retry screen.""" + self._show_verify_retry(LONG_MESSAGE) + + +@dataclass(frozen=True) +class Screen: + """One screen to measure, named by the paint call that produces it.""" + + name: str + paint: Callable[[ScreenHost], None] + + +def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc: + """Paint the heat-skip screen and return the frame to measure. + + Built through its extracted content builder because in production it owns + a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure. + """ + return build_heat_skip_content( + surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: 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-justification", ScreenHost.paint_sick), + Screen("lockout", ScreenHost.lockout), + Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry), + Screen("verify-retry", ScreenHost.paint_verify_retry), +) + +# Screens built without a ScreenHost, measured through their own builder. +EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),) + + +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() + + _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())