mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Stop the lock screen scrolling itself, and make every screen fit 768px
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.
The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:
* Every font size and pad now goes through the design-system tokens rather
than a literal. A positive size means *points* to Tk, so "36" rendered
~37% larger than the 36px intended; that alone was most of the overflow,
on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
the three reflection boxes share rows instead of taking one each.
Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.
Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.
Tests: 896 pass. The focus/scroll helpers shipped in 3e77f4e had no coverage
at all; test_focus_and_scroll.py covers them now.
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
3e77f4e232
commit
a79c4fdd05
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 <space> on Button, and there was no <Escape>. 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 <space> on Button, and there was no <Escape>. 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()
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 <Tab> insert a literal tab and refocus the widget, and binds
|
||||
# <Shift-Tab> 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)
|
||||
|
||||
@ -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 <Tab> 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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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)
|
||||
|
||||
71
screen_locker/_unlock_view.py
Normal file
71
screen_locker/_unlock_view.py
Normal file
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
222
screen_locker/tests/test_focus_and_scroll.py
Normal file
222
screen_locker/tests/test_focus_and_scroll.py
Normal file
@ -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 <Tab> 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 "<Tab>" in bound
|
||||
assert "<Shift-Tab>" 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 "<<Paste>>" in bound
|
||||
assert "<Control-v>" in bound
|
||||
@ -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()
|
||||
|
||||
259
scripts/verify_screen_fits.py
Executable file
259
scripts/verify_screen_fits.py
Executable file
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user