mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This commit is contained in:
parent
1702685afd
commit
4a5e350baf
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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("<Key-Tab>", when="now")
|
||||
target.focus_set()
|
||||
root.update()
|
||||
root.update_idletasks()
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user