- name
- menu-patterns
- description
- Menu system patterns for building settings screens, modals, and preference items. Use BEFORE building any menu UI to ensure consistency with input handling, visibility, and footer hints.
# Menu System Patterns
Standardized patterns for constructing menus across the Argosy launcher, covering preference item types, input behavior, visibility/disabled handling, footer hints, modals, and section navigation.
> V2 NOTE: `design-handoff/CONTROL-FOUNDATIONS.md` is the design authority and the V2
> components are SHIPPED: `CyclePreference` renders `EnumValueControl` and opens
> `EnumPickerModal` on A, `SliderPreference` renders `StepperControl`, and the private
> `InputButton.hidePriority()` in `FooterHint.kt` implements the V2 shed order. Non-negotiables from the spec:
> A/Confirm means enter/commit/toggle, NEVER adjust; focus never moves an element
> (fill/stripe/ring/halo only); inline affordances are always visible on every row;
> menu rows are 40dp (52 two-line).
---
## Menu Item Types
### 1. Toggle (SwitchPreference)
Boolean on/off states. V2 silhouette is BOXY (rounded-square track + narrow vertical knob),
not an M3 switch.
| Input | Behavior |
|-------|----------|
| A / Tap | Toggle state |
| LEFT / RIGHT | left=off, right=on (V2) |
**Examples:** Haptic feedback, sync toggles, screen dimmer
---
### 2. Enum (CyclePreference)
One control, two access paths (V2).
| Input | Behavior |
|-------|----------|
| A / Tap value | OPEN full option list as a modal - A never adjusts |
| LEFT / RIGHT / Tap triangle | Cycle one option (wrap behavior configurable per item) |
**Visual (V2):** small FILLED triangles `< value >` that rhyme with the d-pad glyphs, never
text chevrons. Tint accent on focus; pressed direction flashes.
**Wrap Configuration:** `CyclePreference` takes no wrap parameter. Wrap is decided by the
section's own input handler when it cycles the value.
**Shipped:** `CyclePreference` (`ui/components/PreferenceItem.kt`) renders
`EnumValueControl` (`ui/primitives/Controls.kt`) inline and opens `EnumPickerModal`
(`ui/components/EnumPickerModal.kt`) on A/Tap.
**Examples:** Theme mode, controller layout, border style
**Options are tokens, labels are resources.** A cycle's selection mechanism is
`options.indexOf(currentValue)`, and the value it writes back is an element of
`options`, so those entries are the persisted DataStore tokens and must stay byte-exact
literals. Only the rendered label becomes a `stringResource`. `LibretroSettingDef.Cycle`
models this directly: `options` (on-disk, upstream-exact) alongside a parallel `labels` list
of `@StringRes` ids, and `labelResFor(value)` maps a stored option to its label. Getting it backwards is silent - a miss coerces to index 0 rather
than throwing, so the user's setting resets to the first option with nothing logged.
---
### 3. Stepper (SliderPreference)
Discrete stepped values; V2 silhouette is `- value +`.
| Input | Behavior |
|-------|----------|
| A | No action - A never adjusts (V2) |
| LEFT / RIGHT / Tap - or + | Adjust value, clamp at bounds with haptic feedback |
**Boundary Behavior:**
- Clamp at min/max, trigger `HapticPattern.BOUNDARY_HIT`
**Shipped:** `SliderPreference` (`ui/components/PreferenceItem.kt`) renders
`StepperControl` (`ui/primitives/Controls.kt`); A does not adjust.
**Examples:** UI scale, blur amount, dim level
---
### 4. Track Slider (TrackSliderPreference)
Draggable slider track for continuous or fine-grained values.
| Input | Behavior |
|-------|----------|
| Drag | Adjust value continuously via touch |
| LEFT / RIGHT | Adjust by step amount, clamp at bounds with haptic |
| A / Tap | No action (use drag or D-pad) |
**Responsive Layout:**
- Wide displays (ULTRA_WIDE, WIDE): Slider track takes right 50% of row
- Square/Tall displays (STANDARD, TALL, ULTRA_TALL): Slider takes full width below title
```kotlin
val aspectRatioClass = LocalUiScale.current.aspectRatioClass
val isWideDisplay = aspectRatioClass == AspectRatioClass.ULTRA_WIDE ||
aspectRatioClass == AspectRatioClass.WIDE
```
**Examples:** LED brightness. `AmbientLedSection` is the only user of
`TrackSliderPreference`. The quick settings panel has its own `QuickSliderRow`
(`ui/components/QuickSettingsRows.kt`), shared by every slider on that panel.
---
### 4b. Switched Slider (QuickSwitchedSliderRow)
An on/off setting that also has a level (haptics + strength, music on/off + volume). One row,
not a toggle row plus a slider row.
| Input | Behavior |
|-------|----------|
| A / Tap chip | Toggle on/off; sound and haptic fire on the press |
| LEFT / RIGHT | Adjust the level while on; boundary sound while off or at either end |
| Drag | Adjust the level while on |
- The leading icon is a square chip (`SwitchChip`, `ui/primitives/SwitchedSlider.kt`):
accent-filled when on, neutral with the icon struck through when off.
- Turning off keeps the stored level; turning on restores it. Never write the level to 0 to
mean "off".
- Motion: off collapses the fill and thumb toward the track centre, then dims the track to
`switchedSlider.offTrackAlpha`; on runs the reverse with the thumb last. A change mid-run
continues from the current point (`rememberSwitchedPhase`). Reduced motion is one step.
- Value text crossfades to "Off"; a faint outline of the level stays on the track.
---
### 4c. Segmented Control (SegmentedControl)
An enum of up to four options shown all at once (`ui/primitives/Controls.kt`). Five or more
options use `CyclePreference` instead.
| Input | Behavior |
|-------|----------|
| LEFT / RIGHT | Move the selection one segment and apply it; boundary sound at either end |
| A | Nothing, silently (`InputResult.handled(SoundType.SILENT)`) |
| Tap segment | Select and apply |
- Short enums sit inline on the label line (`segmentedInlineWidth`); longer labels put the
control on line two at full width.
- Selected segment: `surfaceBase` fill + accent label at rest; solid accent + on-accent label
while the row holds focus. The selection slides over `Motion.durationMicro` and crossfades
under reduced motion.
---
### 5. Counter
Discrete integer ranges (subset of stepper behavior).
| Input | Behavior |
|-------|----------|
| A | No action - A never adjusts |
| LEFT / RIGHT | Increment/decrement by 1, clamp with `HapticPattern.BOUNDARY_HIT` |
**Examples:** Ratings (1-10), concurrent downloads, enum-as-counter
---
### 6. Action (ActionPreference)
Buttons that trigger operations.
| Input | Behavior |
|-------|----------|
| A / Tap | Execute action |
| LEFT / RIGHT | No action |
**Examples:** Clear cache, start migration, open downloads
---
### 7. Navigation (NavigationPreference)
Links to other screens or sections.
| Input | Behavior |
|-------|----------|
| A / Tap | Navigate to target |
| LEFT / RIGHT | No action |
**Examples:** Platform settings, emulator configuration
---
### 8. Info (InfoPreference)
Read-only display values.
| Input | Behavior |
|-------|----------|
| All inputs | No click/confirm handling |
`InfoPreference` takes `isFocused: Boolean` and renders a focused state. Focusability is
decided by the sealed item's `isFocusable`, not by the component, and Info rows are commonly
focusable - see `SteamItem.GnStatus`, `InstallTriage` and `AccountInfo` in
`ui/screens/settings/sections/SteamSection.kt`.
**Examples:** Version number, stats, storage usage
---
### 9. Color Picker (ColorPickerPreference / HueSliderPreference)
Color selection.
| Input | Behavior |
|-------|----------|
| A / Tap | Open picker or select preset |
| LEFT / RIGHT | Adjust hue value |
**Examples:** Accent color, secondary color
---
### 10. Expandable (ExpandablePreference)
Collapsible groups with child items.
| Input | Behavior |
|-------|----------|
| A / Tap | Toggle expanded/collapsed |
| LEFT / RIGHT | No action (or navigate children if expanded) |
**Examples:** Platform settings groups
---
## Visibility & Disabled Handling
### Two-Layer System
**Layer 1: Item Visibility (`visibleWhen`)** - real example from
`ui/components/QuickSettingsModels.kt`:
```kotlin
data object FanSpeed : QuickSettingsItem(
"fanSpeed", QuickSettingsPage.PERFORMANCE, QuickSettingsGroup.FAN,
visibleWhen = { it.deviceSettingsSupported && it.fanMode == FanMode.CUSTOM },
needsDeviceAccess = true
)
```
**Layer 2: Disabled Behavior (`DisabledBehavior`)**
| Behavior | Visible? | Focusable? | Use Case |
|----------|----------|------------|----------|
| `HIDDEN` | No | No | Default - item removed from list |
| `LOCKED` | Yes | No | Show greyed-out, can't interact |
### Index Management
```
visibleItems() = items where visibleWhen(state) OR disabledBehavior == LOCKED
focusableItems() = visibleItems filtered by isFocusable(item)
focusIndex = position in focusableItems (gamepad navigation)
listIndex = position in visibleItems (LazyColumn rendering)
```
**Conversion Functions:**
- `focusIndexOf(item, state)` - Item -> focus index
- `itemAtFocusIndex(index, state)` - Focus index -> Item
- `focusToListIndex(focusIndex, state)` - Focus index -> list index
- `maxFocusIndex(state)` - Maximum valid focus index
### Navigation Rules
When an item becomes hidden/disabled:
1. Focus index may become invalid
2. Clamp focus to `maxFocusIndex(state)` after state changes
3. If focused item becomes unfocusable, move to next focusable item
---
## Side Panel Sections (header with rule)
Side panels (the quick settings panel) use flat rows, never cards. Each section starts with
`PanelSectionHeader` (`ui/primitives/RuledSectionHeader.kt`): the section name in capitals,
`textDim`, `labelMedium` with `quickPanel.sectionLabelTrackingSp` tracking, followed by a
hairline rule that fills the row, with a `spacingMd` gap above. The save modal's section
header is the same `RuledSectionHeader` in its title form; reuse it, never copy it. The
string resource keeps normal case; capitals are applied at render.
Rows in a side panel are 40dp (52dp two-line) and take `FocusIndicators(fill, ring)` as a
rounded rect inset from the panel edges. Value text is `textDim` at rest and accent on focus.
Rows a missing permission blocks render dimmed (`quickPanel.disabledContentAlpha`) and are
left out of d-pad traversal; a notice row with an action button above them says why.
## Construction Patterns
### Sealed Class Item Hierarchy
```kotlin
private sealed class SectionItem(
val key: String,
val visibleWhen: (SectionState) -> Boolean = { true }
) {
data object Toggle1 : SectionItem("toggle1")
data object Slider1 : SectionItem(
key = "slider1",
visibleWhen = { it.someCondition }
)
companion object {
val ALL = listOf(Toggle1, Slider1, ...)
}
}
```
### Layout Manager
```kotlin
private val sectionLayout = SettingsLayout<SectionItem, SectionState>(
allItems = SectionItem.ALL,
isFocusable = { it !is SectionItem.Header },
visibleWhen = { item, state -> item.visibleWhen(state) },
sectionOf = { it.section }
)
```
### Render Shell
`SectionPaneLayout` (`ui/screens/settings/components/SectionPaneLayout.kt`) is the shell
settings sections render into. It owns `SectionFocusedScroll` and switches between a
split-pane layout (wide displays with 2+ named sections: nav rail on the left, content on
the right) and a plain sticky-header LazyColumn. A new section renders through it rather
than building its own LazyColumn.
### Focus Check Pattern
```kotlin
fun isFocused(item: SectionItem): Boolean =
uiState.focusedIndex == sectionLayout.focusIndexOf(item, state)
```
### Input Handler Pattern
`SettingsInputHandler.dispatch` runs every button in a fixed order: `ModalInputRouter.intercept`
first, then the section handler, then a per-button fallback when the section returns UNHANDLED.
A section handler owns LEFT/RIGHT adjust and section-specific buttons. It does not own A or Back:
- A falls through to `viewModel.handleConfirm()`, which is `routeConfirm` in
`SettingsConfirmRouter`. Wire a confirmable row there, never in the section handler.
- Back never reaches a section handler. `SettingsInputHandler.onBack` asks
`ModalInputRouter` and then calls `viewModel.navigateBack()` (see Settings Back Stack below).
Section handler, LEFT/RIGHT only (shape of `LightSectionsInput`):
```kotlin
override fun onLeft(): InputResult = handleLeftRight(-1)
override fun onRight(): InputResult = handleLeftRight(1)
private fun handleLeftRight(direction: Int): InputResult {
val state = viewModel.uiState.value
return when (sectionItemAtFocusIndex(state.focusedIndex, state)) {
SectionItem.Slider1 -> {
viewModel.adjustSlider1(direction * SettingsInputHandler.SLIDER_STEP)
InputResult.HANDLED
}
SectionItem.Enum1 -> {
viewModel.cycleEnum1(direction)
InputResult.HANDLED
}
else -> InputResult.UNHANDLED
}
}
```
Confirm, in `routeConfirm`:
```kotlin
SettingsSection.JELLYFIN -> routeJellyfinConfirm(vm, state)
```
---
## Footer Hints (V2: the control is the guide)
The inline affordance carries the interaction; the bar is not a crutch. It exists to surface
what is NOT obvious from the focused control.
### What earns a hint (V2)
- Screen-global verbs (filter, search, compose), shoulder paging (LB/RB), genuinely
non-obvious bound buttons (X/Y, triggers).
- A / B / d-pad / Back are LOW priority: alone they never justify a bar. If they are the only
candidates, show no bar (it collapses by sliding below the edge, never blanks).
- One app-root bottom bar (singleton). No modal or drawer owns its own footer.
**Exception:** a modal may render its hints inline when it sits above an overlay that
covers the root footer bar, because the root bar is not visible there. Pass
`inlineFooterHints = true` with `footerHints` to `Modal`. A modal the root bar is still
visible under keeps its hints in the root bar.
### Space-Constrained Filtering (Auto-Hide Order, V2)
Shed the OBVIOUS guides first; never drop a non-obvious hint to keep an obvious one:
1. D-pad hints (first to hide)
Ver en GitHub