From 4a5e350baf8a0a0626c56cdb6824e011f720cee1 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Mon, 3 Aug 2026 16:00:13 +0200 Subject: [PATCH] Fit the meal gate on 1366x768 with a full day logged, and stop the auto-scroll gatelock 0.4.0 (pinned here) stops the viewport following the app's own focus_set(): on 2026-08-03 that moved the screen locker's lock screen to mid-content and back with nobody touching the keyboard, and this gate uses the same viewport. Scrolling now happens only on the user's own keypress, wheel or click. That makes fitting the screen the gate's own responsibility rather than something a scrollbar covers for, and with a day of meals logged this one did not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the title is one step down the scale, and the gaps around the description, suggestions, macros and footer are one step tighter) for 718px, with room for the dashboard to keep growing. Its layout test now requires the gate to *fit* rather than merely be reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no machine runs it, this gate cannot reach it without dropping content, and a target nobody uses is a constraint that only ever costs. The probe presses Tab before moving focus, because a bare focus_set() no longer scrolls -- it would have been testing a path no user can take. The Tk fakes learned pack_forget(), which the viewport calls to take the scrollbar down whenever the content fits. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF --- .pre-commit-config.yaml | 2 +- diet_guard/_gatelock_ui.py | 23 ++++++++--------- diet_guard/tests/_gate_layout_probe.py | 7 +++++ diet_guard/tests/_tk_fakes.py | 12 +++++++++ diet_guard/tests/test_gate_layout_fits.py | 30 ++++++++++++++-------- diet_guard/tests/test_gatelock_keyboard.py | 24 ++++++++--------- pyproject.toml | 2 +- requirements.txt | 2 +- 8 files changed, 64 insertions(+), 38 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaa851b..0fc1453 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -101,7 +101,7 @@ repos: additional_dependencies: - pytest - requests - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync - mcp - pydantic diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index 9771993..784807d 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -22,11 +22,10 @@ from typing import TYPE_CHECKING from gatelock import LockConfig, ScrollableSurface, escape_text_tab_trap from diet_guard._gatelock_buttons import make_button -from diet_guard._gatelock_spacing import MD, SM, XS +from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_typography import ( BODY, CAPTION, - DISPLAY, LABEL, SUBTITLE, TITLE, @@ -186,7 +185,7 @@ def _build_desc(parent: tk.Frame) -> tk.Text: # bg) the moment it took focus, exactly inverting the affordance. **_COLORS.focus_kwargs(), ) - text.pack(pady=(XS, SM)) + text.pack(pady=(XS, XS)) _escape_text_tab_trap(text) return text @@ -208,7 +207,7 @@ def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox: # visual indication of where focus was. **_COLORS.focus_kwargs(), ) - box.pack(pady=(0, SM)) + box.pack(pady=(0, XS)) return box @@ -227,7 +226,7 @@ def _build_amount_row( fg=FG, ).pack() row = tk.Frame(parent, bg=BG) - row.pack(pady=(XS, SM)) + row.pack(pady=(XS, XS)) amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount) amount_entry.pack(side="left", ipady=XS) _build_unit_selector(row, vars_, on_unit_change) @@ -310,7 +309,7 @@ def _build_macro_section( ).pack(side="left") row = tk.Frame(parent, bg=BG) - row.pack(pady=(XS, SM)) + row.pack(pady=(XS, XS)) macros = _MacroEntries( kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal), protein=_macro_cell(root, row, "P", vars_.entries.protein), @@ -332,7 +331,7 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None: font=(_FONT, TITLE, "bold"), bg=BG, fg=_ACCENT, - ).pack(pady=(MD, 0)) + ).pack(pady=(SM, 0)) tk.Label( parent, textvariable=vars_.dashboard, @@ -374,7 +373,7 @@ def build_layout( tk.Label( body, text="🍽 Diet Gate", - font=(_FONT, DISPLAY, "bold"), + font=(_FONT, TITLE, "bold"), bg=BG, fg=_ACCENT, ).pack(pady=(0, XS)) @@ -386,7 +385,7 @@ def build_layout( fg=FG, wraplength=_WRAP_PX, justify="center", - ).pack(pady=(0, MD)) + ).pack(pady=(0, SM)) desc_text = _build_desc(body) suggestion_box = _build_suggestion_box(body) @@ -415,14 +414,14 @@ def build_layout( fg=_ACCENT, wraplength=_WRAP_PX, justify="center", - ).pack(pady=(XS, SM)) + ).pack(pady=(XS, XS)) make_button( body, text="Log & Continue", variant="primary", command=callbacks.on_submit, - ).pack(pady=(XS, SM)) + ).pack(pady=(XS, XS)) # Manual pull for a meal already logged on another device (the phone) but # not yet propagated to this machine -- saves re-typing it to unlock. @@ -432,7 +431,7 @@ def build_layout( variant="secondary", command=callbacks.on_fetch_sync, bold=False, - ).pack(pady=(0, SM)) + ).pack(pady=(0, XS)) status_label = tk.Label( body, diff --git a/diet_guard/tests/_gate_layout_probe.py b/diet_guard/tests/_gate_layout_probe.py index 003a044..971632d 100644 --- a/diet_guard/tests/_gate_layout_probe.py +++ b/diet_guard/tests/_gate_layout_probe.py @@ -239,6 +239,13 @@ def _probe_viewport( target = _deepest_focusable(inner) last_visible = False if target is not None: + # A real keypress first, then the focus it would have moved. Since + # 2026-08-03 gatelock only scrolls to follow focus the *user* moved: + # it used to follow programmatic focus too, and because the apps + # re-focus a widget on every repaint, the screen scrolled itself to + # mid-content and back while the user was doing nothing. A bare + # focus_set() here would therefore test a path no user can take. + target.event_generate("", when="now") target.focus_set() root.update() root.update_idletasks() diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index c23717d..338d374 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -125,10 +125,16 @@ class FakeWidget: def __init__(self, *args: object, **kwargs: object) -> None: self.configured: dict[str, object] = dict(kwargs) + self.packed = False def pack(self, *args: object, **kwargs: object) -> FakeWidget: + self.packed = True return self + def pack_forget(self, *args: object, **kwargs: object) -> None: + """Un-pack. The viewport hides its scrollbar whenever content fits.""" + self.packed = False + def place(self, *args: object, **kwargs: object) -> FakeWidget: return self @@ -161,6 +167,9 @@ class FakeWidget: def cget(self, key: str) -> object: return self.configured.get(key, "") + def focus_get(self) -> None: + """Nothing holds focus in a fake tree.""" + # Geometry queries. The scroll viewport asks for these to size itself and # to decide whether content still fits; zero means "nothing measured yet", # which is the honest answer for a fake with no layout. @@ -313,6 +322,9 @@ class FakeCanvas(FakeWidget): def cget(self, key: str) -> object: return self.configured.get(key, "") + def focus_get(self) -> None: + """Nothing holds focus in a fake tree.""" + class FakeStyle: """A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing.""" diff --git a/diet_guard/tests/test_gate_layout_fits.py b/diet_guard/tests/test_gate_layout_fits.py index 9b9c907..21c6e9f 100644 --- a/diet_guard/tests/test_gate_layout_fits.py +++ b/diet_guard/tests/test_gate_layout_fits.py @@ -24,11 +24,13 @@ import pytest from diet_guard.tests._gate_layout_probe import measure -# The screen sizes tokens.md requires: 1366x768 fully usable (the primary -# machine), 1024x600 degrading gracefully. Both are landscape and SHORT -- -# phone-portrait test sizes exercise neither constraint, which is why the -# overflow survived a test suite that already pinned surface sizes. -_REQUIRED_SIZES = [(1366, 768), (1024, 600)] +# The screen the gate must fit: the primary machine's 1366x768 panel. It is +# landscape and SHORT -- phone-portrait test sizes exercise neither +# constraint, which is why the overflow survived a test suite that already +# pinned surface sizes. Panels below 768px get gatelock's best-effort +# compaction (``gatelock._density``) but are not gated: no machine runs one, +# and the gate cannot be trimmed to 600px without dropping content. +_REQUIRED_SIZES = [(1366, 768)] @pytest.mark.parametrize(("screen_w", "screen_h"), _REQUIRED_SIZES) @@ -59,12 +61,18 @@ def test_overflow_is_always_scrollable(screen_w: int, screen_h: int) -> None: assert result.last_widget_visible_after_focus, result.describe() -def test_fresh_boot_fits_the_primary_screen() -> None: - """With nothing logged, the gate fits 1366x768 without scrolling. +@pytest.mark.parametrize("populated", [False, True]) +def test_gate_fits_the_primary_screen(*, populated: bool) -> None: + """The gate fits 1366x768 without scrolling, empty or with a day logged. - Scrolling is a correctness backstop, not the intended daily experience: an - empty form that already needs scrolling on the primary machine means the - layout has drifted and the next addition will be worse. + Scrolling is a correctness backstop, not the intended experience -- and + since 2026-08-03 it is also *only* reachable by the user's own keypress or + click, because a viewport that followed the app's own ``focus_set()`` + scrolled the screen under a user who had touched nothing. A gate that + needs scrolling therefore needs the user to discover that it does. + + ``populated`` is the worst case: the dashboard grows one line per logged + meal, so a layout that fits on a fresh boot can still overflow by dinner. """ - result = measure(1366, 768, populated=False) + result = measure(1366, 768, populated=populated) assert not result.needs_scroll, result.describe() diff --git a/diet_guard/tests/test_gatelock_keyboard.py b/diet_guard/tests/test_gatelock_keyboard.py index 19dd560..2a84f90 100644 --- a/diet_guard/tests/test_gatelock_keyboard.py +++ b/diet_guard/tests/test_gatelock_keyboard.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any import pytest -from diet_guard import _gatelock_buttons, _gatelock_ui +from diet_guard import _gatelock_buttons, _gatelock_typography, _gatelock_ui from diet_guard._gatelock_ui import UNIT_GRAMS, UNIT_ITEMS from diet_guard.tests import _gate_keyboard_probe from diet_guard.tests.conftest import _FAKE_TK, FakeRadiobutton @@ -194,11 +194,11 @@ class TestTypeScaleIsPixels: @pytest.mark.parametrize( "size", [ - _gatelock_ui.DISPLAY, - _gatelock_ui.SUBTITLE, - _gatelock_ui.BODY, - _gatelock_ui.LABEL, - _gatelock_ui.CAPTION, + _gatelock_typography.DISPLAY, + _gatelock_typography.SUBTITLE, + _gatelock_typography.BODY, + _gatelock_typography.LABEL, + _gatelock_typography.CAPTION, ], ) def test_sizes_are_negative(self, size: int) -> None: @@ -211,12 +211,12 @@ class TestTypeScaleIsPixels: def test_scale_matches_the_design_system_magnitudes(self) -> None: """The magnitudes are still tokens.md's 32/24/20/16/14/12 scale.""" - assert abs(_gatelock_ui.DISPLAY) == 32 - assert abs(_gatelock_ui.TITLE) == 24 - assert abs(_gatelock_ui.SUBTITLE) == 20 - assert abs(_gatelock_ui.BODY) == 16 - assert abs(_gatelock_ui.LABEL) == 14 - assert abs(_gatelock_ui.CAPTION) == 12 + assert abs(_gatelock_typography.DISPLAY) == 32 + assert abs(_gatelock_typography.TITLE) == 24 + assert abs(_gatelock_typography.SUBTITLE) == 20 + assert abs(_gatelock_typography.BODY) == 16 + assert abs(_gatelock_typography.LABEL) == 14 + assert abs(_gatelock_typography.CAPTION) == 12 class TestRealFocusRing: diff --git a/pyproject.toml b/pyproject.toml index 8c672b2..42f1d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking" requires-python = ">=3.10" dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock", + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync", "requests>=2.31.0", ] diff --git a/requirements.txt b/requirements.txt index 0ca5e59..99e388d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync -gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock +gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock mcp>=1.9 mypy>=1.8.0 pre-commit>=3.6.0