"""Read-only Tkinter status window plus a lightweight i3blocks summary CLI. Opening or refreshing this window only ever reads files already on disk (via ``_status_data.gather_status``). The live Warsaw temperature (see ``_section_temperature``) is fetched via ``_temperature.fetch_current_temp_with_status`` on open and on every refresh, in its own background thread, bounded to ``_temperature.HARD_TIMEOUT_SECONDS`` — the same call the real locker's heat-skip check makes, so the window shows what the lock actually sees. That fetch is display-only and never writes. Two user-initiated buttons *do* write to ``log.json`` (never a silent log): * "Check Phone" runs ``PhoneVerificationMixin._verify_phone_workout`` and, when that finds nothing, ``RunnerUpVerificationMixin._verify_runnerup_workout`` as a fallback — the same StrongLifts→RunnerUp chain the locked screen uses. On a verified workout it writes the entry and applies the shutdown reward via ``_apply_workout_credit`` (guarded against double-crediting on the same day), mirroring the submit/poll/``Future`` idiom of ``_ui_flows.py``. * "Log Manual Workout" is a user-initiated, explicit evidence-form submission (see ``_manual_workout_dialog.ManualWorkoutDialogMixin``). """ from __future__ import annotations from pathlib import Path import sys import tkinter as tk from typing import TYPE_CHECKING from gatelock import LockConfig from screen_locker import _glyphs as g from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._status_data import ( StatusSnapshot, format_summary_line, gather_status, ) from screen_locker._status_projection import compliance_state_word from screen_locker._status_sections import StatusSectionsMixin from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier from screen_locker._surface_group import FrameGroup from screen_locker._sync_status import format_sync_line, gather_sync_status from screen_locker._temperature import ( fetch_current_temp_with_status, ) from screen_locker._temperature_status_mixin import TemperatureStatusMixin from screen_locker._ui_widgets import UIWidgetsMixin if TYPE_CHECKING: from collections.abc import Callable from concurrent.futures import Future from screen_locker._temperature import TemperatureCheck from screen_locker.screen_lock import ScreenLocker _DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "log.json" _STATUS_COLORS = LockConfig() class StatusWindow( UIWidgetsMixin, ManualWorkoutDialogMixin, TemperatureStatusMixin, PhoneCheckMixin, StatusSectionsMixin, ): """Thin Tk view over a :class:`StatusSnapshot`. Formatting logic lives in ``_status_data``, not here. """ def __init__( self, root: tk.Tk, snapshot: StatusSnapshot, *, on_refresh: Callable[[], None], log_file: Path = _DEFAULT_LOG_FILE, verifier_factory: Callable[[Path], ScreenLocker] = _make_bare_verifier, temperature_fetcher: Callable[ [str], TemperatureCheck ] = fetch_current_temp_with_status, ) -> None: """Build the window's container and render *snapshot* immediately.""" self.root = root self.on_refresh = on_refresh self.log_file = log_file self.verifier_factory = verifier_factory self.temperature_fetcher = temperature_fetcher self.demo_mode = True # only affects UIWidgetsMixin._button's cursor self._phone_future: Future[tuple[str | None, str, str, str | None]] | None = ( None ) self._phone_check_result: tuple[str, str] | None = None self._credit_message: str | None = None self._temp_future: Future[TemperatureCheck] | None = None self._temp_result: TemperatureCheck | None = None self._last_snapshot = snapshot self._colors = _STATUS_COLORS # A group of one: this window has no lock and no per-output surfaces, # but it shares UIWidgetsMixin's factory with the locker, which now # builds through a group. Filling the window rather than centring, so # this is built here instead of via FrameGroup.single(). frame = tk.Frame(root, bg=self._colors.palette.bg) frame.pack(fill="both", expand=True) self.container = FrameGroup([frame]) self._start_temperature_check() self.render(snapshot) def render(self, snapshot: StatusSnapshot) -> None: """Redraw the whole window from *snapshot*.""" self._last_snapshot = snapshot self.clear_container() # textgate: the "Workout Status" heading is gone; see the sections. self._section_today(self.container, snapshot.today) self._section_week(self.container, snapshot.week) self._section_streak(self.container, snapshot.streak, snapshot.week_rewards) self._section_lock_explanation(self.container, snapshot.lock_explanation) self._section_temperature(self.container) self._section_manual_workout_budget( self.container, snapshot.manual_workout_budget ) self._section_sync_backend(self.container) self._section_shutdown(self.container, snapshot.shutdown) if self._phone_check_result is not None: status, message = self._phone_check_result color = ( self._colors.palette.success if status == "verified" else self._colors.palette.warning ) self._text(message, role="title", color=color) if self._credit_message is not None: self._text( self._credit_message, role="body", color=self._colors.palette.success ) # Check phone / manual log are the primary write actions (accent, # high contrast per rule 3); refresh / close are secondary utility # actions (muted) -- two tiers instead of four arbitrary hues. Was # "Check Phone" / "Log Manual Workout" / "Refresh" / "Close". frame = self._button_row() buttons = [(g.PHONE, self._colors.palette.accent, self._on_check_phone_clicked)] if not self._mw_budget_closed(): buttons.append( (g.FORM, self._colors.palette.accent, self._show_manual_workout_form) ) buttons += [ (g.RETRY, self._colors.palette.field_bg, self._on_refresh_clicked), (g.CLOSE, self._colors.palette.field_bg, self.root.destroy), ] for glyph, bg, command in buttons: self._button(frame, glyph, bg=bg, command=command, width=4).pack( side="left", padx=self._colors.space("sm") ) def _on_refresh_clicked(self) -> None: """Clear any stale phone-check/credit/temperature results, re-check both.""" self._phone_check_result = None self._credit_message = None self._temp_result = None self._start_temperature_check() self.on_refresh() def _on_manual_workout_saved(self, entry: dict) -> None: """Persist the manual-workout entry via a bare verifier, then refresh. Unlike the phone check, this one deliberate path does write to ``log.json`` — the user just explicitly filled out and submitted the evidence form, so this is not a silent log. Uses the same ``_apply_workout_credit`` the locked screen's ``unlock_screen`` uses, so this voluntary path earns the identical shutdown-later bonus / extra-bonus credit (guarded against being applied twice on the same day). """ verifier = self.verifier_factory(self.log_file) verifier.workout_data = entry credit = verifier._apply_workout_credit() self._credit_message = "\n".join( self._credit_result_lines(f"{g.FORM} {g.OK}", credit) ) self.render(gather_status()) def _on_manual_workout_cancelled(self) -> None: """Return to the main status view without saving anything.""" self.render(gather_status()) def main(argv: list[str] | None = None) -> None: """Entry point: ``--summary``/``--state``/``--sync`` print one line. With no flag, opens the status window. """ args = sys.argv[1:] if argv is None else argv if "--summary" in args: print(format_summary_line(gather_status())) return if "--state" in args: print(compliance_state_word(gather_status())) return if "--sync" in args: # On-disk only, like the other two -- safe on an i3blocks tick. print(format_sync_line(gather_sync_status())) return root = tk.Tk() root.title("") root.configure(bg=_STATUS_COLORS.palette.bg) root.minsize(560, 200) def refresh() -> None: window.render(gather_status()) window = StatusWindow(root, gather_status(), on_refresh=refresh) root.mainloop() if __name__ == "__main__": main()