screen-locker/screen_locker/_heat_skip.py
Krzysztof Rudnicki 3e77f4e232 Make every lock screen keyboard-operable, and add the missing installer
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.

Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.

install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.

The keyboard work, all of it Tk defaults rather than local mistakes:

- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
  #211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
  had no indication of where they were. Now set in the _ui_widgets factory, so
  a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
  a documented no-op arguing "buttons only, nothing to focus" -- exactly
  backwards: buttons are what needs focusing when there is no other window on
  screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
  Return and no Escape. It now focuses the *declining* option, so a blind
  Return keeps the workout rather than silently skipping it.

The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.

All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.

Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.

The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:25:45 +02:00

140 lines
4.5 KiB
Python

"""Mixin: heat-skip dialog and log entry for the screen locker."""
from __future__ import annotations
import logging
import tkinter as tk
from gatelock import LockConfig, bind_activate, bind_cancel
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
_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"
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()
# 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()
def _on_no() -> None:
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)
bind_cancel(root, _on_no)
decline_button.focus_set()
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()