Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/adr/0019-ui-modules-self-gate-preconditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# UI modules self-gate their preconditions (enter-then-back-out), not a menu hard-block

A screen that depends on a runtime precondition is still opened normally from the
menu; the **module itself** checks the precondition and, when it is unmet,
renders an explanatory notice in place of its normal UI and makes its inputs
inert. The user reads the notice and **backs out** (LEFT/Cancel) on their own. We
do **not** block navigation at the menu layer.

The motivating case is manual **Set Time/Date** (`UITimeEntry`, chaining to
`UIDateEntry`). Entry is interpreted in the observer's **local timezone**, which
we only derive from a **location fix** — `callbacks.set_time` / `set_datetime`
read `shared_state.location().timezone`. Without a fix the entered time would be
localised against a bogus UTC zone (the bug behind [ADR-0018](./0018-civil-datetime-stored-utc-aware.md)).
So the screen requires a location. This ADR fixes *where* that requirement is
enforced: inside the screen, not at the menu item that opens it.

## The navigation mechanic that makes a hard-block possible

`UITextMenu.key_right` (`ui/text_menu.py`) dispatches a menu item's `"callback"`
**before** its `"class"`, and the callback's return value propagates straight out
of `key_right`: a callback that returns without pushing anything simply cancels
the navigation. A `"class"` item is pushed unconditionally; its optional
`pre_callback` runs first but its return value is **discarded**, so a
`pre_callback` cannot refuse the push. Therefore a plain `"callback"` is the
*only* lever that can decline to open a screen — and this ADR chooses not to pull
it.

## Considered options

- **Menu-layer hard block (a gate callback).** The first cut of this change
(PR #512) routed Set Time/Date through `callbacks.enter_time_entry`, which
checked `location().lock` and, when unlocked, showed a 2-second "Set location
first" popup and returned **without** opening the screen. Rejected: it hides
the feature — the user never sees the screen and never learns what it needs —
and it splits the precondition across two layers, forcing the menu callback to
know an internal fact about the screen (that it needs a location's zone) that
already lives in the screen's own callbacks.
- **Self-gating module (chosen).** The precondition lives in exactly one place —
the module that depends on it. The feature stays discoverable; the on-screen
message teaches the user what to do; the menu stays a dumb router that always
opens the screen.
- **Disable / grey out the menu item.** Rejected: the menu has no "disabled item"
affordance, and a greyed item is just a quieter hard-block with the same
discoverability loss and no room to explain *why* or *what to do next*.

## Consequences

- **The check is live** (re-read every frame via `_location_locked`), so the
entry boxes appear the instant a fix locks while the user is sitting on the
screen — no need to back out and re-enter.
- **Every module owning the precondition self-gates, even when it is currently
unreachable while unmet.** `UIDateEntry` is only reached by confirming the
(already gated) time screen, so it cannot be entered without a fix today; it
still carries the same guard so it stays correct if it is ever surfaced
directly (e.g. relocated in the menu tree).
- **The exit callback must be suppressed when gated.** A module fires its
`custom_callback` (`set_time` / `set_datetime`) from `inactive()` on the way
out; a gated screen early-returns there so it never runs the callback against
the bogus UTC zone the gate exists to prevent.
- **Rendering is shared, the precondition is not.** A reusable
`UIModule.draw_gate_message()` draws the centred notice + a Cancel hint; the
precondition predicate stays local to each screen, so the base owns the generic
"how to show a gate" and each module owns "what it needs." This mirrors the
self-contained-in-the-UI stance of [ADR-0005](./0005-focus-hfd-self-contained-in-ui.md).
- **Trade-off accepted:** a hard-block would save one wasted keypress (the user
opens a screen they cannot use yet). We judge discoverability and single-owner
precondition logic worth that keypress.
- Companion glossary: [`docs/ax/ui/CONTEXT.md`](../ax/ui/CONTEXT.md) (Self-gating
module).
4 changes: 4 additions & 0 deletions docs/ax/ui/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ _Avoid_: display, screen driver (in code-arg context).
`active`/`inactive` fire when a module reaches / leaves the top of the stack; `update` is the per-frame redraw a module overrides; `screen_update` draws the title bar and finalises the frame.
_Avoid_: on_show / on_hide, render (use `update`/`screen_update`).

**Self-gating module**:
A module that enforces its own runtime **precondition** rather than relying on the menu to block entry. It always opens; when the precondition is unmet it draws a "set X first" notice (via `UIModule.draw_gate_message`) instead of its normal UI, keeps its key handlers and exit callback inert, and lets the user **back out** with LEFT/Cancel. The precondition check is live (re-read each `update`). Example: `UITimeEntry`/`UIDateEntry` gate on a location fix. See [ADR 0019](../../adr/0019-ui-modules-self-gate-preconditions.md).
_Avoid_: gated menu item, hard block, disabled screen (the menu never refuses to open a self-gating module).

### Navigation

**MenuManager**:
Expand Down
29 changes: 29 additions & 0 deletions python/PiFinder/ui/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,35 @@ def clear_screen(self):
fill=self.colors.get(0),
)

def draw_gate_message(self, message: str) -> None:
"""Render a full-screen "precondition not met" notice into ``self.screen``.

A module that gates itself on a runtime precondition (e.g. a location
fix) draws this from ``update`` in place of its normal UI and returns
early; the user reads it and backs out with LEFT/Cancel rather than
being blocked from opening the screen at all (see ADR 0019). Newlines
in ``message`` split it into centred lines; a Cancel hint is pinned to
the bottom-left, mirroring the entry screens' legends.
"""
self.clear_screen()
font = self.fonts.bold
lines = message.split("\n")
line_h = font.height + 2
top = self.display_class.titlebar_height
block_h = line_h * len(lines)
y = top + max(4, (self.display_class.resY - top - block_h) // 2)
for line in lines:
text_w = font.font.getbbox(line)[2]
x = max(0, (self.display_class.resX - text_w) // 2)
self.draw.text((x, y), line, font=font.font, fill=self.colors.get(255))
y += line_h
self.draw.text(
(10, self.display_class.resY - self.fonts.base.height - 2),
_(" Cancel"),
font=self.fonts.base.font,
fill=self.colors.get(255),
)

def message(self, message, timeout: float = 2, size=None):
"""
Creates a box with text in the center of the screen.
Expand Down
35 changes: 28 additions & 7 deletions python/PiFinder/ui/dateentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ def draw_date_boxes(self):
fill=self.red,
)

def _location_locked(self) -> bool:
"""True when a location fix exists, so the date has a real local zone.

``set_datetime`` localises the entered date+time in the observer's zone,
which we only derive from a location fix. This screen is reached only
through the (equally gated) time screen today, but it self-gates on the
same precondition so it stays correct if it is ever surfaced directly
(see ADR 0019). The check is live, mirroring UITimeEntry.
"""
return bool(self.shared_state and self.shared_state.location().lock)

def draw_local_date_note(self):
note_y = self.text_y + self.box_height + 10
self.draw.text(
Expand Down Expand Up @@ -183,6 +194,8 @@ def validate_box(self, box_index, value):
return False

def key_number(self, number):
if not self._location_locked():
return # gated: no location fix, so ignore digit entry
current = self.boxes[self.current_box]
new_value = current + str(number)

Expand All @@ -197,13 +210,17 @@ def key_number(self, number):

def key_minus(self):
"""Delete last digit in current box or move to previous box if empty."""
if not self._location_locked():
return # gated: nothing to edit until a location fix exists
if self.boxes[self.current_box]:
self.boxes[self.current_box] = self.boxes[self.current_box][:-1]
else:
self.current_box = (self.current_box - 1) % 3

def key_right(self):
"""Confirm if all boxes filled, otherwise cycle to next box."""
if not self._location_locked():
return False # gated: don't confirm the date
if all(self.boxes) and self.current_box == 2:
self._confirmed = True
self.remove_from_stack()
Expand All @@ -221,18 +238,22 @@ def key_left(self) -> bool:

def inactive(self):
"""Called when the module is no longer active."""
if not self._location_locked():
return # gated: never fire set_datetime against a bogus (UTC) zone
if self._confirmed and self.custom_callback:
date_str = f"{self.boxes[0]}-{self.boxes[1]}-{self.boxes[2]}"
self.custom_callback(self, date_str)

def update(self, force=False):
self.draw.rectangle((0, 0, self.width, self.height), fill=self.black)

self.draw_date_boxes()

note_y = self.draw_local_date_note()
separator_y = self.draw_separator(note_y + 15)
self.draw_legend(separator_y)
if not self._location_locked():
# Self-gate on a location fix, mirroring UITimeEntry (see ADR 0019).
self.draw_gate_message(_("Set location\nfirst"))
else:
self.draw.rectangle((0, 0, self.width, self.height), fill=self.black)
self.draw_date_boxes()
note_y = self.draw_local_date_note()
separator_y = self.draw_separator(note_y + 15)
self.draw_legend(separator_y)

if self.shared_state:
self.shared_state.set_screen(self.screen)
Expand Down
3 changes: 3 additions & 0 deletions python/PiFinder/ui/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,9 @@ def _(key: str) -> Any:
},
{
"name": _("Set Time/Date"),
# UITimeEntry self-gates on a location fix (it needs
# the observer's zone); it shows a "set location
# first" notice and the user backs out. See ADR 0019.
"class": UITimeEntry,
"custom_callback": callbacks.set_time,
},
Expand Down
54 changes: 40 additions & 14 deletions python/PiFinder/ui/timeentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,30 @@ def draw_time_boxes(self):
fill=self.red,
)

def _location_locked(self) -> bool:
"""True when a location fix exists, so manual time has a real local zone.

Manual entry is interpreted in the observer's local timezone, which we
only derive from a location fix; without one ``set_time`` would silently
fall back to UTC. This screen self-gates on it (see ADR 0019): the user
may open it, but the entry boxes and the ``set_time`` callback stay
inert -- and a "set location first" notice shows -- until a fix arrives.
The check is live, so the boxes appear the moment a fix locks while the
screen is open, without needing to back out and re-enter.
"""
return bool(self.shared_state and self.shared_state.location().lock)

def draw_local_time_note(self):
# Add a note about local time
# Add a note about local time. The time is entered in the observer's
# local zone (this screen self-gates on a location fix -- see
# _location_locked / update -- so a zone is always known when the boxes
# are shown), so a fixed label suffices and we avoid overrunning the
# screen with a long IANA timezone name. Mirrors UIDateEntry's
# "Enter Local Date".
note_y = self.text_y + self.box_height + 10
if self.shared_state:
note_str = "Enter " + self.shared_state.location().timezone + " Time"
else:
note_str = "Enter Local Time"
self.draw.text(
(10, note_y),
_(note_str),
_("Enter Local Time"),
font=self.fonts.base.font,
fill=self.red,
)
Expand Down Expand Up @@ -169,6 +183,8 @@ def validate_box(self, box_index, value):
return False

def key_number(self, number):
if not self._location_locked():
return # gated: no location fix, so ignore digit entry
current = self.boxes[self.current_box]
new_value = current + str(number)

Expand All @@ -185,6 +201,8 @@ def key_number(self, number):

def key_minus(self):
"""Delete last digit in current box or move to previous box if empty"""
if not self._location_locked():
return # gated: nothing to edit until a location fix exists
if self.boxes[self.current_box]:
# Delete the last digit
self.boxes[self.current_box] = self.boxes[self.current_box][:-1]
Expand All @@ -194,6 +212,8 @@ def key_minus(self):

def key_right(self):
"""Confirm time and chain to date entry, or cycle to next box."""
if not self._location_locked():
return False # gated: don't confirm or chain to date entry
if all(self.boxes) and self.current_box == 2:
time_str = (
f"{self.boxes[0] or '00'}:{self.boxes[1] or '00'}"
Expand Down Expand Up @@ -223,20 +243,26 @@ def key_left(self) -> bool:

def inactive(self):
"""Called when the module is no longer the active module"""
if self._skip_callback:
if self._skip_callback or not self._location_locked():
# Gated (no location fix) or an explicit cancel: never fire set_time,
# which would interpret the entry against a bogus (UTC) zone.
return
if self.custom_callback:
time_str = f"{self.boxes[0] or '00'}:{self.boxes[1] or '00'}:{self.boxes[2] or '00'}"
self.custom_callback(self, time_str)

def update(self, force=False):
self.draw.rectangle((0, 0, self.width, self.height), fill=self.black)

self.draw_time_boxes()

note_y = self.draw_local_time_note()
separator_y = self.draw_separator(note_y + 15)
self.draw_legend(separator_y)
if not self._location_locked():
# Self-gate: entry is meaningless without a location's timezone, so
# show a "set location first" notice and let the user back out
# (see ADR 0019) instead of hard-blocking entry from the menu.
self.draw_gate_message(_("Set location\nfirst"))
else:
self.draw.rectangle((0, 0, self.width, self.height), fill=self.black)
self.draw_time_boxes()
note_y = self.draw_local_time_note()
separator_y = self.draw_separator(note_y + 15)
self.draw_legend(separator_y)

if self.shared_state:
self.shared_state.set_screen(self.screen)
Expand Down
Loading
Loading