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