screen-locker/screen_locker/_heat_skip.py
Krzysztof Rudnicki a79c4fdd05 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
2026-08-03 15:57:48 +02:00

163 lines
5.3 KiB
Python

"""Mixin: heat-skip dialog and log entry for the screen locker."""
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
# of a locally-invented token block -- this was the one file in the package
# that already knew centralizing tokens was worth doing; now it points at
# the actual shared source instead of its own unshared copy.
_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."""
def _show_heat_skip_dialog(self, temp: float) -> bool:
"""Show a modal confirmation dialog for skipping due to extreme heat.
Creates a temporary Tk root (destroyed before the main GateRoot is
initialised) so this can be called early in the startup flow. Returns
True if the user confirms the skip, False if they decline.
"""
result: list[bool] = [False]
# Use the root itself (not a Toplevel) so we can go fullscreen.
# This window is destroyed before the main GateRoot is created.
root = tk.Tk()
root.title("Extreme Heat")
root.configure(bg=_COLORS.bg)
root.attributes("-fullscreen", True)
root.attributes("-topmost", True)
root.grab_set()
root.focus_force()
def _on_skip() -> None:
result[0] = True
root.destroy()
def _on_no() -> None:
result[0] = False
root.destroy()
build_heat_skip_content(root, temp, on_skip=_on_skip, on_decline=_on_no)
bind_cancel(root, _on_no)
root.mainloop()
return result[0]
def _save_heat_skip_log(self, temp: float) -> None:
"""Append a heat_skip entry to workout_log.json."""
self.workout_data = {
"type": "heat_skip",
"temperature_celsius": str(round(temp)),
"city": HEAT_SKIP_CITY,
}
self.save_workout_log()