mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-15 08:30:58 +01:00
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>
140 lines
4.5 KiB
Python
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()
|