screen-locker/screen_locker/_heat_skip.py
Krzysztof kuhy Rudnicki 9b39196671 Take gatelock 0.8.1: nested LockConfig
The grab axes are grab=GrabPolicy(kind=..., disable_vt=...,
preempt_weaker_holder=...) and every colour read goes through
config.palette; nothing about the lock's behaviour changed. Also carries
the pylint hook on 4.0.8 with the source-only scope config; the gate
stays at 8.0 until the remaining findings are cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:52:38 +02:00

163 lines
5.4 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.palette.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.palette.bg,
fg=_COLORS.palette.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.palette.bg,
fg=_COLORS.palette.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.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=(_COLORS.space("xs"), 0))
btn_frame = tk.Frame(outer, bg=_COLORS.palette.bg)
btn_frame.pack(pady=_COLORS.space("lg"))
skip_button = tk.Button(
btn_frame,
text="Skip workout",
command=on_skip,
bg=_COLORS.palette.warning,
fg=_COLORS.palette.on_fill,
activebackground=_COLORS.palette.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.palette.field_bg,
fg=_COLORS.palette.fg,
activebackground=_COLORS.palette.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.palette.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 log.json."""
self.workout_data = {
"type": "heat_skip",
"temperature_celsius": str(round(temp)),
"city": HEAT_SKIP_CITY,
}
self.save_workout_log()