pour-preset-hierarchy
Pour Preset Hierarchy — Spec
Motivation
The TUI preset selector was a single-line Left/Right cycler. With 9+ presets from brewer × bean × intent combinations, linear cycling became unusable. The fix: a hierarchical drilldown overlay that mirrors the user's mental model (brewer → bean → intent), declared per module in config.
Config Schema
Add preset_axes to any module in config.toml:
[modules.coffee]
mode = "create"
path = "Coffee/log.md"
preset_axes = ["method", "bean"]
preset_axes is a Vec<String> of field names, in drilldown order. Empty or absent → no picker; the legacy Left/Right cycler stays.
Validation rules (lazy, checked at form open): an axis name is invalid if:
- The field does not exist in the module
- The field type is
composite_array - The field has
preset_exclude = true
Invalid axes produce a footer warning but do not prevent the form from opening; the cycler falls back.
Data Model
src/data/preset_tree.rs exposes three pure functions:
pub fn build(presets: &[PresetEntry], axes: &[String]) -> PresetTree;
pub fn validate_axes(axes: &[String], fields: &[FieldConfig]) -> Result<(), Vec<AxisError>>;
pub fn suggest_preset_name(values: &HashMap<String,String>, axes: &[String]) -> String;
PresetTree has two lists at root: roots (axis-drilled branches, sorted alphabetically) and ungrouped (presets missing any axis value, shown at the bottom of root).
TreeNode is either:
Branch { axis_value, children, count }— a grouping node; count aggregates all leaves belowLeaf { preset_name, description }— a selectable preset
Rules:
- Branches are sorted alphabetically at every level.
- Leaves preserve the saved order from
presets.json. - A preset whose axis value is absent or empty is placed in
ungrouped. - A single child at a level is still shown (no auto-drill).
UX — Drilldown Picker
Open: bare p on the preset row, or Ctrl+P from any non-editing context (no picker fires when preset_axes is empty — falls through to cycler).
Navigation inside the picker:
| Key | Action |
|---|---|
↑ / ↓ |
Navigate items in the current level |
Enter |
Drill into a branch, or apply a leaf preset |
Backspace / ← |
Pop back one level (or close at root) |
Esc |
Cancel — no preset applied |
Header: breadcrumb showing the drilled path + next axis label, e.g. V60 ▸ bean.
Footer hint: ↑↓ nav Enter select Bksp/← back Esc cancel
Scroll: viewport tracks selected item; a selected/total (pct%) indicator appears when the list exceeds the visible area.
Identity Migration
FormState.selected_preset: usize (positional) is replaced by selected_preset_name: Option<String> (name-keyed). This makes the selection survive preset reorder operations without needing to recompute an index.
All code paths that previously compared selected_preset == 0 / selected_preset > 0 now check selected_preset_name.is_none() / .is_some().
Inline Row Display
When preset_axes is non-empty:
- The
◂ name ▸chevrons are not shown —preplaces cycling. - The footer shows
p pickerinstead of←→ cycle. Left/Rightbare keys on the preset row are no-ops (Ctrl+Left/Right reorder still works).
When preset_axes is empty:
- Chevrons and cycler behaviour are unchanged.
Save Dialog Auto-Suggest (Phase C)
When opening the save dialog for a new preset (no preset currently selected), the name field is pre-filled with:
<axis0_value> · <axis1_value> · ...
Empty axis values are skipped — no trailing separator. The middle-dot separator is U+00B7 surrounded by spaces.
When editing an existing preset, the name field is pre-filled with the existing name (unchanged behaviour).
The user can edit the suggested name freely. Any keystroke in the name field sets name_was_user_edited = true.
Overwrite Confirm
If the user enters a name that collides with an existing preset (and is not editing that same preset), the first Enter shows:
Overwrite "<name>"? Enter confirm Esc cancel
A second Enter proceeds. Any keystroke clears the confirm state.
Edge Cases
- Ungrouped: presets with a missing or empty axis value appear under a flat "Ungrouped" entry at the bottom of the root list.
- Single child: a branch with a single child still displays the level — no auto-drill.
- Numeric axis values:
dose: "18"renders as18 ▸ bean. Allowed; no coercion. - Axis value containing
·: would produce an ambiguous auto-suggested name. Not sanitized; documented here as known-but-allowed. - Validation warning: a typo in
preset_axes(e.g."beann") surfaces as a footer warning. The form still opens; the cycler is active as fallback.
Known Limitations
- Picker scroll viewport: the viewport is hardcoded to 20 rows in the key handler (
Downkey clamps atviewport_offset + 20). On terminals shorter than ~25 rows the lower portion of long preset lists becomes keyboard-unreachable. Tracked for follow-up; the fix is to compute window height from the terminal area at key-handling time rather than using a constant.
PWA Forward-Compatibility
The preset_axes config key is per-module in config.toml and is included in GET /api/v1/config responses when the PWA roll-forward is implemented. The data shape (a flat list of PresetEntry with their values map) is forward-compatible with a mobile drilldown — the same config drives both surfaces. PWA drilldown is a separate plan; this spec gates it.