screen-locker/screen_locker/_temperature_status_mixin.py
Krzysztof kuhy Rudnicki e5bfdd9a9a Make the Tk locker screens text-free
Every locker screen, the status window and the credit toast now show
glyphs (screen_locker/_glyphs.py) and numbers, coloured by state.
Sentences that carried a reason (verifier message, sync diagnosis,
sick refusal, form rejection) go to the log at warning instead.
New pre-commit hook text-free runs scripts/verify_text_free.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:20:23 +01:00

105 lines
4.5 KiB
Python

"""Live Warsaw-temperature background check mixin for ``StatusWindow``.
Fetches the same reading the real locker's heat-skip check would see
(``_temperature.fetch_current_temp_with_status``), in a background thread,
bounded to ``_temperature.HARD_TIMEOUT_SECONDS`` — display-only, never
writes to ``log.json``. Host class must provide:
- ``self.root`` — the Tk root, for ``root.after`` polling.
- ``self.temperature_fetcher`` — injected fetch callable (tests override it).
- ``self._temp_future`` / ``self._temp_result`` — state slots, initialized
by the host's ``__init__``.
- ``self._last_snapshot`` — the most recently rendered ``StatusSnapshot``.
- ``self.render(snapshot)`` — full redraw, called after a result resolves.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
from screen_locker._temperature import HARD_TIMEOUT_SECONDS, submit_background
if TYPE_CHECKING:
import tkinter as tk
from screen_locker._temperature import TemperatureCheck
_logger = logging.getLogger(__name__)
class TemperatureStatusMixin:
"""Provides the background Warsaw-temperature fetch/poll/render."""
def _section_temperature(self, parent: tk.Widget) -> None:
"""Render the live temperature against the heat-skip threshold.
The same reading the real locker's heat-skip check would see,
fetched in the background. textgate: was "Warsaw Temperature" and a
sentence per state; now ``▲`` with the reading (or wait / clock-fail /
fail glyphs), danger-coloured with ``↷`` when it would heat-skip.
"""
del parent
result = self._temp_result
palette = self._colors.palette
if result is None:
self._text(f"{g.HEAT} {g.WAIT}", role="body", color=palette.muted)
return
if result.timed_out:
_logger.warning(
"Temperature check timed out after ~%ds", int(HARD_TIMEOUT_SECONDS)
)
self._text(
f"{g.HEAT} {g.CLOCK}{g.FAIL}", role="body", color=palette.warning
)
return
if result.temp_celsius is None:
self._text(f"{g.HEAT} {g.FAIL}", role="body", color=palette.warning)
return
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
relation = "≥" if hot else "<"
self._text(
f"{g.HEAT} {result.temp_celsius:.0f}°C {relation} "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C" + (f" {g.SKIP}" if hot else ""),
role="body",
color=palette.danger if hot else palette.muted,
)
def _start_temperature_check(self) -> None:
"""Submit a background Warsaw-temperature fetch and start polling it.
Unlike ``_on_check_phone_clicked`` (an explicit, opt-in user action),
this runs on every window open and refresh, so the first poll is
always deferred through ``root.after`` rather than checked
synchronously — otherwise a worker thread that happens to finish
before this call returns would trigger a second, redundant render
from within the same call stack that hasn't finished the first one.
"""
# Shares _temperature.submit_background rather than spinning up its
# own ThreadPoolExecutor per open and per refresh: those workers are
# non-daemon and joined at interpreter exit, so one stalled fetch
# held the whole process open past sys.exit(0).
self._temp_future = submit_background(
lambda: self.temperature_fetcher(HEAT_SKIP_CITY)
)
self.root.after(500, self._poll_temperature_check)
def _poll_temperature_check(self) -> None:
"""Poll the background temperature-check future until it resolves."""
if self._temp_future is not None and self._temp_future.done():
self._on_temperature_check_result(self._temp_future.result())
else:
self.root.after(500, self._poll_temperature_check)
def _on_temperature_check_result(self, result: TemperatureCheck) -> None:
"""Store the resolved temperature check and redraw to show it.
Re-renders the last snapshot already on screen rather than
re-gathering from disk — the temperature reading is independent of
on-disk workout state, so there's nothing new to read.
"""
self._temp_result = result
self.render(self._last_snapshot)