diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4471e54..0b34e79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -138,7 +138,7 @@ repos: - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays - - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays - mcp>=2,<3 - pydantic - id: pylint @@ -160,7 +160,7 @@ repos: - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays - - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays - mcp>=2,<3 - pydantic diff --git a/pyproject.toml b/pyproject.toml index b012e5f..fdd332b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time", - "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays", + "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 36414d6..0e28630 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ pytest-xdist==3.8.0 # module imports it itself, so it is declared rather than assumed. requests==2.34.2 ruff==0.17.1 -sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays types-requests==2.33.0.20261006 # Direct dependency since the Firebase session stream (_session_stream) catches # urllib3's own errors on the long-lived SSE read; requests pulls it in, but diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 466f89a..dc07eff 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -9,12 +9,13 @@ from screen_locker import _manual_workout from screen_locker._source_health import collect_source_findings, explain_findings from screen_locker._ui_flows_lockout import LockoutFlowMixin from screen_locker._ui_flows_sick import SickDayFlowMixin +from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin if TYPE_CHECKING: from collections.abc import Callable -class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin): +class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin): """Mixin providing UI flow logic for the screen locker. The sick-day and lockout/penalty flows live in sibling modules and are diff --git a/screen_locker/_ui_flows_sick.py b/screen_locker/_ui_flows_sick.py index 9e8b5f2..d989a9a 100644 --- a/screen_locker/_ui_flows_sick.py +++ b/screen_locker/_ui_flows_sick.py @@ -13,6 +13,10 @@ the retry screen, the no-phone penalty screen and both relaxed-day screens. A refusal (a free day, an unreadable pool) is shown on screen and the lock stays up. +Next to the button sits a small "choose lockers…" link: the same day, but +with some lockers kept (``_ui_flows_sick_panel``). The button itself still +keeps nothing. + Split out of :mod:`screen_locker._ui_flows` to keep every file under the 250-line cap. Composed back into ``UIFlowsMixin`` there. """ @@ -41,6 +45,8 @@ class SickDayFlowMixin: command=self.mark_sick_and_unlock, width=12, ).pack(side="left", padx=self._colors.space("sm")) + # The keep-aware alternative (``_ui_flows_sick_panel``), next to it. + self._choose_lockers_link(frame) def mark_sick_and_unlock(self) -> None: """Mark today sick, lift tonight's shutdown to 20:00 and unlock.""" @@ -48,17 +54,27 @@ class SickDayFlowMixin: if not result.ok: self._show_sick_refused(result.message) return + self._raise_sick_floor() + # Best effort and off the UI thread: the unlock never waits for it. + push_in_background() + self._unlock_sick_day( + f"Marked today sick from the lock screen. {result.message}" + ) + + def _raise_sick_floor(self) -> None: + """Lift tonight's shutdown to the sick floor (a no-op unless sick).""" # Now, not at the next 5-minute tick: while this window is up that # tick stands down at the instance lock. with locked_shutdown_state(SHUTDOWN_BASE_FILE): self._apply_sick_floor() - # Best effort and off the UI thread: the unlock never waits for it. - push_in_background() - self._record_decision( - locked=False, - reason="sick_day_marked", - detail=f"Marked today sick from the lock screen. {result.message}", - ) + + def _unlock_sick_day(self, detail: str) -> None: + """Record the sick-day unlock and close the lock. + + Shared by the one-tap button and the keep panel; neither pushes here + (the panel starts its own sync). + """ + self._record_decision(locked=False, reason="sick_day_marked", detail=detail) self.close() def _show_sick_refused(self, message: str) -> None: diff --git a/screen_locker/_ui_flows_sick_panel.py b/screen_locker/_ui_flows_sick_panel.py new file mode 100644 index 0000000..15e7a9c --- /dev/null +++ b/screen_locker/_ui_flows_sick_panel.py @@ -0,0 +1,161 @@ +"""The "choose lockers…" link: sickdays' KeepPanel, inside the lock window. + +The one-tap "I'm sick" button (``_ui_flows_sick``) keeps nothing. This link, +next to it, opens sickdays' embeddable :class:`~sickdays.gui.KeepPanel` so +today can be marked sick with some lockers still on. It is built in-process +on the lock's own surfaces: a separate window would get no input under the +lock's X grab. + +The panel covers each surface on a full-size backdrop instead of replacing +the screen under it, so closing it reveals exactly the screen it was opened +from -- a running penalty countdown or phone check is neither restarted nor +skipped. The panel does the write and starts its own background sync; what +the lock does afterwards depends on the pool, not on the entry: + +* sick and the workout lock stands down -> the one-tap path's after-effects + (sick floor, decision, close), without a second mark or push; +* sick but the workout lock kept -> sick floor (20:00 applies on any sick + day), the lock stays up with a short note; +* cleared, or an unchanged Save -> the panel closes, nothing else. +""" + +from __future__ import annotations + +import logging +import tkinter as tk +from typing import TYPE_CHECKING + +from gatelock import bind_activate +import sickdays +from sickdays.gui import KeepPanel + +from screen_locker._sick import stands_down_workout +from screen_locker._surface_group import FrameGroup + +if TYPE_CHECKING: + from collections.abc import Callable + + from gatelock import LockConfig + +_logger = logging.getLogger(__name__) + +LINK_TEXT = "choose lockers…" +KEPT_NOTE = "Workout lock kept — finish the workout to unlock." + + +def build_sick_panel( + parent: tk.Misc, + colors: LockConfig, + *, + on_saved: Callable[[sickdays.SickDay | None], None], + on_cancel: Callable[[], None], + paths: sickdays.Paths | None = None, +) -> tk.Frame: + """Centre today's KeepPanel in ``parent`` and return the frame holding it. + + Separate from the mixin so ``scripts/verify_screen_fits.py`` can measure + it on a throwaway pool (``paths``) instead of the live one. + """ + outer = tk.Frame(parent, bg=colors.palette.bg) + outer.place(relx=0.5, rely=0.5, anchor="center") + KeepPanel( + outer, sickdays.today(), on_saved=on_saved, on_cancel=on_cancel, paths=paths + ).pack() + return outer + + +class SickPanelFlowMixin: + """The keep-aware sick-day panel, layered over the current lock screen.""" + + _sick_overlay: FrameGroup | None = None + + def _choose_lockers_link(self, frame: FrameGroup) -> None: + """Pack the small secondary link that opens the panel into ``frame``.""" + palette = self._colors.palette + links = frame.child_widgets( + tk.Button, + text=LINK_TEXT, + font=self._colors.font("label"), + bg=palette.bg, + fg=palette.muted, + activebackground=palette.field_bg, + activeforeground=palette.fg, + command=self.open_sick_panel, + cursor="hand2" if self.demo_mode else "", + relief="flat", + borderwidth=0, + padx=self._colors.space("sm"), + pady=self._colors.space("xs"), + **self._colors.focus_kwargs(), + ) + for link in links: + bind_activate(link) + links.pack(side="left", padx=self._colors.space("sm")) + + def open_sick_panel(self) -> None: + """Lay today's KeepPanel over every monitor's surface.""" + overlay = self._new_sick_overlay() + for backdrop in overlay: + build_sick_panel( + backdrop, + self._colors, + on_saved=self._on_sick_panel_saved, + on_cancel=self._close_sick_panel, + ) + + def _new_sick_overlay(self) -> FrameGroup: + """Replace any open overlay with a fresh full-surface backdrop group.""" + self._close_sick_panel() + backdrops: list[tk.Frame] = [] + for surface in self.container.surfaces: + backdrop = tk.Frame(surface.winfo_toplevel(), bg=self._colors.palette.bg) + backdrop.place(relx=0, rely=0, relwidth=1, relheight=1) + backdrop.lift() + backdrops.append(backdrop) + self._sick_overlay = FrameGroup(backdrops) + return self._sick_overlay + + def _close_sick_panel(self) -> None: + """Remove the overlay; the lock screen under it was never touched.""" + if self._sick_overlay is not None: + self._sick_overlay.destroy() + self._sick_overlay = None + + def _on_sick_panel_saved(self, entry: sickdays.SickDay | None) -> None: + """React to the panel's write; the pool is already up to date.""" + self._close_sick_panel() + if entry is None or not entry.is_sick: + _logger.warning( + "Sick panel saved without a sick day (%s); the lock stays as it was", + "unchanged" if entry is None else "cleared", + ) + return + self._raise_sick_floor() + kept = ", ".join(sorted(entry.keep)) or "nothing" + if stands_down_workout(): + self._unlock_sick_day( + f"Marked today sick from the lock screen's keep panel (kept: {kept})." + ) + return + _logger.warning("Sick day keeps the workout lock (kept: %s); staying up", kept) + self._show_kept_note() + + def _show_kept_note(self) -> None: + """Say the lock stays because the workout locker was kept.""" + box = self._new_sick_overlay().child_frame(bg=self._colors.palette.bg) + for frame in box: + frame.place(relx=0.5, rely=0.5, anchor="center") + box.child_widgets( + tk.Label, + text=KEPT_NOTE, + font=self._colors.font("title", bold=True), + fg=self._colors.palette.warning, + bg=self._colors.palette.bg, + ).pack(pady=self._colors.space("md")) + self._button( + box, + "Back", + bg=self._colors.palette.accent, + command=self._close_sick_panel, + width=12, + ).pack(pady=self._colors.space("sm")) diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index 1b70f95..c960f26 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -63,16 +63,34 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> background push never reaches Firebase. """ import sickdays + import sickdays.gui # so its modules are in sys.modules below pool = sickdays.Paths.under(tmp_path / "sickdays") # Every sickdays module that bound ``resolve_paths`` by name: the readers - # (``_api``, which ``stands_down`` / ``kept_lockers`` load through) and the - # writers (``set_kept_lockers`` lives in ``_keep_api``). Patching only - # ``_api`` once let a keep edit land in the developer's real pool. + # (``_api``, which ``stands_down`` / ``kept_lockers`` load through), the + # writers (``set_kept_lockers`` lives in ``_keep_api``) and the embedded + # KeepPanel (``sickdays.gui._panel``). Patching only ``_api`` once let a + # keep edit land in the developer's real pool. ``sync_quietly`` is bound + # by name the same way (the panel's background push), so it goes too. for name, module in list(sys.modules.items()): - if name.startswith("sickdays.") and hasattr(module, "resolve_paths"): + if not name.startswith("sickdays"): + continue + if hasattr(module, "resolve_paths"): monkeypatch.setattr(module, "resolve_paths", lambda paths: paths or pool) - monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False) + if hasattr(module, "sync_quietly"): + monkeypatch.setattr(module, "sync_quietly", lambda **_kw: False) + + +@pytest.fixture(autouse=True) +def _no_real_keep_panel() -> Iterator[None]: + """Never build sickdays' real ``KeepPanel`` (a real ``tk.Frame``) in a test. + + The lock's "choose lockers" overlay embeds it; under the mocked ``tk`` a + real one would still reach Tcl and write the pool on a click. Tests that + drive the panel swap in their own recording fake on top of this one. + """ + with patch("screen_locker._ui_flows_sick_panel.KeepPanel"): + yield @pytest.fixture(autouse=True) diff --git a/screen_locker/tests/_tk_mocks.py b/screen_locker/tests/_tk_mocks.py index d7362f8..10a1021 100644 --- a/screen_locker/tests/_tk_mocks.py +++ b/screen_locker/tests/_tk_mocks.py @@ -31,6 +31,7 @@ _TK_MODULES = ( "screen_locker.status_view", "screen_locker._heat_skip", "screen_locker._surface_group", + "screen_locker._ui_flows_sick_panel", # The scroll viewport the lock surfaces are built from lives in gatelock # and imports tkinter independently, so it needs the same mock -- otherwise # container.first is a *real* tk.Frame whose winfo_children() cannot be diff --git a/screen_locker/tests/test_sick_panel.py b/screen_locker/tests/test_sick_panel.py new file mode 100644 index 0000000..4a61423 --- /dev/null +++ b/screen_locker/tests/test_sick_panel.py @@ -0,0 +1,250 @@ +"""The lock's "choose lockers" link: sickdays' KeepPanel, embedded in the lock. + +A recording fake replaces the panel. The pool is written for real before +``on_saved`` fires: the lock branches on the pool, not on the entry. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +import sickdays +import sickdays.gui._panel + +from screen_locker import _ui_flows_sick, _ui_flows_sick_panel +from screen_locker._ui_flows_sick_panel import KEPT_NOTE, LINK_TEXT, build_sick_panel +from screen_locker.tests._sick_helpers import mark_sick +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from datetime import date + from pathlib import Path + + from screen_locker.screen_lock import ScreenLocker + + +_MADE: list[FakePanel] = [] + + +class FakePanel: + """Records how the lock built it; ``on_saved``/``on_cancel`` are driven.""" + + def __init__( + self, + parent: MagicMock, + day: date, + *, + on_saved: Callable[[sickdays.SickDay | None], None], + on_cancel: Callable[[], None] | None = None, + paths: sickdays.Paths | None = None, + ) -> None: + """Keep every argument for the assertions.""" + self.parent = parent + self.day = day + self.on_saved = on_saved + self.on_cancel = on_cancel + self.paths = paths + self.packed = False + _MADE.append(self) + + def pack(self, **_kwargs: object) -> None: + """Record that the lock showed it.""" + self.packed = True + + +def _frame(*args: object, **_kwargs: object) -> MagicMock: + """A distinct mock frame that remembers its parent, like Tk's ``master``.""" + frame = MagicMock() + frame.master = args[0] if args else None + frame.winfo_children.return_value = [] + return frame + + +@pytest.fixture +def panels() -> Iterator[list[FakePanel]]: + """Every KeepPanel the lock builds, in order.""" + _MADE.clear() + with patch.object(_ui_flows_sick_panel, "KeepPanel", FakePanel): + yield _MADE + + +@pytest.fixture +def locker( + mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path +) -> ScreenLocker: + """A retry-screen lock whose floor and close are observable.""" + built = create_locker(mock_tk, tmp_path) + mock_tk.Frame.side_effect = _frame + object.__setattr__(built, "_apply_sick_floor", MagicMock()) + object.__setattr__(built, "close", MagicMock()) + return built + + +def _backdrop(panel: FakePanel) -> MagicMock: + """The full-surface frame the panel's centred box sits on.""" + return panel.parent.master + + +def _texts(mock: MagicMock) -> list[str]: + return [str(c.kwargs.get("text", "")) for c in mock.call_args_list] + + +class TestLink: + def test_the_link_sits_next_to_the_one_tap_button( + self, mock_tk: MagicMock, locker: ScreenLocker + ) -> None: + locker._show_retry_and_sick("nope") + buttons = mock_tk.Button.call_args_list + texts = _texts(mock_tk.Button) + assert "I'm sick" in texts + assert LINK_TEXT in texts + sick = next(c for c in buttons if c.kwargs.get("text") == "I'm sick") + link = next(c for c in buttons if c.kwargs.get("text") == LINK_TEXT) + assert sick.kwargs["command"] == locker.mark_sick_and_unlock + assert link.kwargs["command"] == locker.open_sick_panel + assert texts.index(LINK_TEXT) == texts.index("I'm sick") + 1 + + def test_opening_builds_todays_panel_on_a_backdrop( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + assert len(panels) == len(locker.container.surfaces) + panel = panels[0] + assert panel.day == sickdays.today() + assert panel.packed + assert panel.on_cancel is not None + _backdrop(panel).place.assert_called_once_with( + relx=0, rely=0, relwidth=1, relheight=1 + ) + + @pytest.mark.usefixtures("dual_output") + def test_every_monitor_gets_a_panel( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + assert len(panels) == 2 + + def test_reopening_closes_the_previous_panel( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + first = _backdrop(panels[0]) + locker.open_sick_panel() + first.destroy.assert_called_once_with() + + def test_build_sick_panel_passes_paths_through( + self, mock_tk: MagicMock, panels: list[FakePanel], tmp_path: Path + ) -> None: + paths = sickdays.Paths.under(tmp_path / "pool") + saved, cancel = MagicMock(), MagicMock() + build_sick_panel( + MagicMock(), MagicMock(), on_saved=saved, on_cancel=cancel, paths=paths + ) + assert panels[0].paths is paths + assert panels[0].on_saved is saved + assert panels[0].on_cancel is cancel + + +class TestSaved: + @pytest.fixture(autouse=True) + def _no_mark_no_push(self) -> Iterator[tuple[MagicMock, MagicMock]]: + with ( + patch.object(_ui_flows_sick, "mark_today_sick") as mark, + patch.object(_ui_flows_sick, "push_in_background") as push, + ): + yield mark, push + # The panel already wrote and started its own sync. + mark.assert_not_called() + push.assert_not_called() + + def test_sick_and_standing_down_unlocks_like_the_one_tap( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + day = mark_sick(keep=[sickdays.LOCKER_LEETCODE]) + entry = sickdays.SickDay( + day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_LEETCODE}) + ) + with patch("screen_locker._startup_checks.record_decision") as record: + panels[0].on_saved(entry) + locker._apply_sick_floor.assert_called_once_with() + assert record.call_args.args[0].reason == "sick_day_marked" + locker.close.assert_called_once_with() + + def test_sick_but_workout_kept_stays_locked_with_a_note( + self, mock_tk: MagicMock, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + day = mark_sick(keep=[sickdays.LOCKER_WORKOUT]) + entry = sickdays.SickDay( + day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_WORKOUT}) + ) + panels[0].on_saved(entry) + # The 20:00 floor applies on any sick day, keep or not. + locker._apply_sick_floor.assert_called_once_with() + locker.close.assert_not_called() + backdrop.destroy.assert_called_once_with() + assert KEPT_NOTE in _texts(mock_tk.Label) + back = next( + c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back" + ) + back.kwargs["command"]() + assert locker._sick_overlay is None + + def test_cleared_closes_the_panel_and_leaves_the_lock( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + entry = sickdays.SickDay(day=sickdays.today(), is_sick=False) + panels[0].on_saved(entry) + backdrop.destroy.assert_called_once_with() + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() + + def test_unchanged_save_closes_the_panel_only( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + panels[0].on_saved(None) + backdrop.destroy.assert_called_once_with() + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() + + def test_cancel_closes_the_panel_only( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + assert panels[0].on_cancel is not None + panels[0].on_cancel() + backdrop.destroy.assert_called_once_with() + assert locker._sick_overlay is None + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() + + +class TestOneTapUnchanged: + def test_one_tap_still_keeps_nothing_and_builds_no_panel( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + with patch.object(_ui_flows_sick, "push_in_background") as push: + locker.mark_sick_and_unlock() + assert sickdays.is_sick_day() is True + assert sickdays.kept_lockers() == frozenset() + push.assert_called_once_with() + locker.close.assert_called_once_with() + assert panels == [] + + +def test_the_pool_fixture_reaches_the_embedded_panel(tmp_path: Path) -> None: + """``sickdays.gui._panel`` binds its own ``resolve_paths``/``sync_quietly``.""" + assert sickdays.gui._panel.resolve_paths(None).log == ( + sickdays.Paths.under(tmp_path / "sickdays").log + ) + assert sickdays.gui._panel.sync_quietly() is False diff --git a/scripts/_fit_extra_screens.py b/scripts/_fit_extra_screens.py new file mode 100644 index 0000000..faaa90e --- /dev/null +++ b/scripts/_fit_extra_screens.py @@ -0,0 +1,62 @@ +"""Lock screens the fit check measures through their own builders. + +Split out of ``verify_screen_fits`` for the 250-line cap. These screens are +not painted by a ``ScreenHost`` flow: heat-skip owns a fullscreen ``tk.Tk()`` +of its own in production, and the "choose lockers" panel is sickdays' +KeepPanel laid over the lock rather than a repaint of it. +""" + +from __future__ import annotations + +from pathlib import Path +import tempfile +from typing import TYPE_CHECKING + +from gatelock import LockConfig +import sickdays + +from screen_locker._heat_skip import build_heat_skip_content +from screen_locker._ui_flows_sick_panel import build_sick_panel + +if TYPE_CHECKING: + import tkinter as tk + + from gatelock import ScrollableSurface + + +def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc: + """Paint the heat-skip screen and return the frame to measure. + + Built through its extracted content builder because in production it owns + a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure. + """ + return build_heat_skip_content( + surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None + ) + + +def _build_sick_panel(surface: ScrollableSurface) -> tk.Misc: + """Paint the "choose lockers" panel on a throwaway pool and return it. + + Today is marked sick there first, because that variant is the tallest + (Save + Clear + Cancel) and because a pool that does not exist keeps the + measurement independent of the live sick-day and free-day pools. The panel + reads the pool once, while it is built, so the directory can go after. + """ + with tempfile.TemporaryDirectory(prefix="fit-sickdays-") as tmp: + pool = sickdays.Paths.under(Path(tmp)) + sickdays.add_sick_day(sickdays.today(), is_free=lambda _day: False, paths=pool) + return build_sick_panel( + surface.content, + LockConfig(), + on_saved=lambda _entry: None, + on_cancel=lambda: None, + paths=pool, + ) + + +# Screens built without a ScreenHost, measured through their own builder. +EXTRA_SCREENS: tuple[tuple[str, object], ...] = ( + ("heat-skip", _build_heat_skip), + ("sick-keep-panel", _build_sick_panel), +) diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py index 85251cb..3efa4ce 100755 --- a/scripts/verify_screen_fits.py +++ b/scripts/verify_screen_fits.py @@ -32,17 +32,16 @@ from typing import TYPE_CHECKING from gatelock import LockConfig, measure_fit, report_fit from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS -from screen_locker._heat_skip import build_heat_skip_content from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._surface_group import FrameGroup from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin +from scripts._fit_extra_screens import EXTRA_SCREENS from scripts._xvfb_reexec import reexec_under_xvfb if TYPE_CHECKING: from collections.abc import Callable - import tkinter as tk from gatelock import FitResult, ScrollableSurface @@ -180,17 +179,6 @@ class Screen: paint: Callable[[ScreenHost], None] -def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc: - """Paint the heat-skip screen and return the frame to measure. - - Built through its extracted content builder because in production it owns - a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure. - """ - return build_heat_skip_content( - surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None - ) - - SCREENS: tuple[Screen, ...] = ( Screen("phone-check", ScreenHost.paint_phone_check), Screen("no-workout-retry", ScreenHost.paint_retry), @@ -203,9 +191,6 @@ SCREENS: tuple[Screen, ...] = ( Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry), ) -# Screens built without a ScreenHost, measured through their own builder. -EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),) - def measure_all() -> list[FitResult]: """Measure every screen at every supported size."""