Skip to main content

menu-patterns

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.

معلومات المصدر

المستودع
rommapp/argosy-launcher
آخر نشاط في المصدر
٦ أكتوبر ٢٠٢٦ في ٠٧:١٣
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٦٣٣
التفرعات
٤٧

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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)
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub