SettingsMenu Orchestrator

frontend/SettingsMenu.qml is pushed onto the StackView by the bottom bar's Settings button with initialSection set to the last visited section. It renders no settings itself; it owns the page model, the visible-page model, the navigation API, and a Loader that instantiates one of four layout engines:

Item {
    id: settingsMenu
    objectName: "settingsMenu"
    required property var stackView
    required property var mainWindow
    required property string initialSection

    property string currentSection: ""
    property bool updateAvailable: networkManager && networkManager.updateStatus === "update-available"
    property string layoutStyle: settingsManager ? settingsManager.settingsLayoutStyle : "Sidebar"
    property bool isInitialLoad: true
}

function layoutSourceForStyle(style) {
    switch (style) {
        case "Carousel":  return "SettingsCarouselLayout.qml"
        case "Sidebar":   return "SettingsSidebarLayout.qml"
        case "Hub":       return "SettingsHubLayout.qml"
        case "Dashboard": return "SettingsDashboardLayout.qml"
        default:          return "SettingsSidebarLayout.qml"
    }
}

In Component.onCompleted it pre-loads the card-state cache (ExpandedCards.load(settingsManager)), builds hubModel, and adopts initialSection if it is a visible section. The layout Loader's onLoaded hands the layout a reference to the orchestrator and a live binding to hubModel; for the Carousel it also sets the initial currentIndex.

Navigation API

  • navigateToCategory(section) — sets currentSection, persists it with settingsManager.set_last_settings_section(), then either sets the Carousel's currentIndex or calls the layout's own navigateToCategory().
  • navigateToSubSection(section, sub) — delegates to the layout if it implements it (Dashboard does), otherwise falls back to the category.
  • navigateToHub() — called by the bottom bar when Settings is tapped while already open; forwards to the layout (Hub and Dashboard return to their card grid; the default grid layout closes its open card, see Grid Layout).
  • A Connections on settingsLayoutStyleChanged sets isInitialLoad = false and swaps the layout; the new engine then keeps currentSection instead of restoring the saved one.
  • A 16 ms polling Timer (scrollToSectionTimer) scrolls the Carousel's current page to a card found by findChildByObjectName() when pendingScrollTarget is set.

Page Model

pageModel is the single source of truth for the six pages:

readonly property var pageModel: [
    { name: "Display",     section: "displaySettings",     source: "settings/DisplaySettingsPage.qml",
      widget: "widgets/DisplayWidget.qml", icon: "☼", iconSource: Style.assetBase + "cat_display.svg",
      group: "Appearance",   subSections: ["Layout", "Appearance", "Clock"] },
    { name: "Media",       section: "mediaSettings",       ... iconSource: Style.assetBase + "cat_media.svg",
      group: "Appearance",   subSections: ["Library & Playback", "Now Playing"] },
    { name: "OBD",         section: "obdSettings",         ... iconSource: Style.assetBase + "cat_obd.svg",
      group: "Connectivity", subSections: ["Connection", "Adapters", "Parameters"] },
    { name: "Accessories", section: "accessoriesSettings", ... iconSource: Style.assetBase + "cat_accessories.svg",
      group: "Connectivity", desktopOnly: true,
      subSections: ["Volume Knob", "IMU Sensor", "Gesture Sensor", "Phone Dock"] },
    { name: "Device",      section: "deviceSettings",      ... iconSource: Style.assetBase + "cat_device.svg",
      group: "System",       subSections: ["Device", "Diagnostics"] },
    { name: "About",       section: "about",               ... iconSource: Style.assetBase + "cat_about.svg",
      group: "System",       subSections: [] }
]
KeyPurpose
nameLabel in tabs, sidebar rows and cards
sectionIdentifier used for navigation, persistence (lastSettingsSection) and the visibility map
sourcePage QML, relative to frontend/ (loaded by layouts in that directory; SettingsHubCard prepends "../")
widgetDashboard widget QML, relative to frontend/settings/
iconLegacy Unicode glyph, kept only as a fallback
iconSourceTheme-tinted SVG built from Style.assetBase; this is what renders
groupAppearance / Connectivity / System (carried in hubModel, currently informational)
desktopOnlybuildHubModel() drops the page when the isAndroid context property is true (Accessories)
subSectionsAnchor names for sub-navigation. Must equal the page's tileModel titles: lookup is by objectName, which pages set from modelData.title.

The Settings Pages

Five of the six pages are Flickables that declare a tileModel — an array of { cardId, title, summary, component } — and put each card's body in its own Component. The same components serve two renderings: the page's own stacked SettingsCard list, and the tile grid + popup that the Sidebar and Carousel layouts build when they detect a tileModel (see Tile Grids). Each tile page also has property bool renderCards: true, and its card Repeater only builds when it is true. The Sidebar layout loads pages with contentLoader.setSource(url, { renderCards: false }) instead of a source binding, so the flag is set before the page is built. Sidebar hides the page behind its tile grid, and building the hidden stacked cards anyway cost 50+ ms per section switch. About accepts the property too, so the Sidebar can pass it uniformly. About has no tiles and renders as a plain scrolling page in every layout. Every page also declares the local dp()/dpMin() wrappers.

Display — settings/DisplaySettingsPage.qml

TileControls (all persisted through settingsManager.save_*)
Layout (display_layout)UI Scaling slider 0.5–2.0 step 0.05, committed on release (see Theme System). Screen Dimensions: width (400–3840) and height (300–2160) text fields that resize mainWindow and call App.Spacing.updateDimensions(); a Fullscreen / Exit Fullscreen button and a Maximize button that also call save_window_state("fullscreen" | "windowed" | "maximized") so Main.qml can restore the window state at launch (this was the separate Window tile). Nav Bar Position segmented control bottom / side. Nav Bar Media Controls toggle. Settings Layout chips — only Grid (stored as "Sidebar") until the "Show experimental layouts" toggle reveals Carousel / Hub / Dashboard; the toggle is derived from the active layout (no extra persisted setting) and switching it off while on an experimental layout saves "Sidebar" so the user is never stranded on a hidden choice.
Appearance (display_appearance)Theme: Light and Dark swatch-chip rows, an "Album Art Theme" row of saved image chips, and the Album Art Capture sub-card with Transition Speed and Match Song Length. Font chips that render in their own typeface. Details: Theme Picker.
Clock (display_clock)Show Clock, Show Seconds toggles; Time Format segmented control ("24-hour" / "12-hour (AM/PM)" → save_clock_format(bool)); Clock Size: Auto toggle (save_clock_size_auto(bool), fits the clock to its nav bar slot) plus a 10–85 px slider, disabled while Auto is on.

Media — settings/MediaSettingsPage.qml

TileControls
Library & Playback (media_library)Library folder text field; Browse opens a FolderDialog whose result goes to settingsManager.save_media_folder_url(), which maps it to a path (src/util/pathurl.h: a file:// URL incl. a Windows drive letter; an Android Storage Access Framework tree to /storage/emulated/0/... or, for an SD card, /storage/<volume>/...; the library is read with QDir under the READ_MEDIA_AUDIO permission) and refuses anything else, which the page explains under the field instead of leaving an empty library; Scan (mediaManager.scan_library()) and Strip (strip_filenames()) buttons. Then the former Playback tile: Startup Volume slider 0–1 (displayed as sqrt(value)×100 % to match the quadratic curve, 100 ms debounce); Audio Output chips (“System default” plus each mediaManager.audioOutputs entry, saved with settingsManager.save_audio_output_device(id); the description names mediaManager.currentAudioOutput and says when the picked output is unplugged); Autoplay on Startup; Remember Shuffle State; Default to Library View (musicButtonDefaultPage = mediaPlayer / mediaRoom); Return to Library After Playing. Last, because it is tall, a TerminalFeedback panel fed by mediaManager.scanProgress.
Now Playing (media_now_playing)An L-shaped editor: a live MediaRoomLivePreview locked top-right (sized to the window's aspect ratio, at most 40 % of the card height), a left column and a bottom-right column of SettingCategory cards. Left: Music Player Object (Album art, Record player, MP3 player, CD player, Cassette player or iPod; 3D choices require Quick3D), Background Layout (Normal / 2x2 / 4x4), Background Blur 0–100, Dark Overlay 0–100 %, Rounded Album Art with a gated Corner Radius 2–32, Album Art Shadow, Vinyl Record Mode, Waveform Visualizer (one toggle — commit 118f484 collapsed the old tiered visualizer settings), 3D Album Preview with gated Side Cards angle 5–60° and opacity 10–100 %, Text Scroll Speed 1–10 s, 3D Button Tilt with gated Tilt Duration 50–500 ms. Bottom-right: four Presets (Minimal, Vinyl, Showcase 3D, Cinema) that write a bundle of settings in one tap, and a Track Transition grid of ten options (Crossfade, Slide, Vinyl Lift, Dissolve, Flip, Coverflow, Conveyor, Stack, Depth, Swing) that previews the chosen animation in the PiP. In the tile layouts this tile does not open the in-place popup — it opens the window-level Now Playing Studio.

OBD — settings/OBDSettingsPage.qml

Four tiles: Connection (obd_connection: status with colour map, vehicle, fast mode, reconnect attempts, live log), Adapters (obd_adapters: scan, port, manual entry), Parameters (obd_parameters: the circular-gauges style toggle, supported readings and Home-card selection), Black Box (obd_black_box: the trip recorder on/off, the all-PIDs sweep, the storage cap, recording status and storage used, buttons to the Trip History page, the trips folder and a two-tap "Clear history"). Commit 93d0fc9 split the former monolithic "Connection" card into four; the small Tuning tile was later folded back into Connection and Parameters. See OBD Manager for what the controls drive.

The Connection tile's status bar shows obdManager.connectionStatus as its label and takes its colour, its "Connecting" pulse and progress from obdManager.connectionState (import OCTAVE.OBD 1.0 for OBDManager.Connecting etc.), which OBDManager::stateFor() classifies from the status text: Connected (accent); Connecting (statusWarning): Connecting, Reconnecting, Stale Connection and the Android BLE steps Connecting BLE..., Discovering services..., Initializing ELM327..., Querying PIDs...; NoVehicle (statusInfo): No Vehicle; Disconnected (statusDisconnected); Failed (statusError): Device Not Found, Connection Failed, Error, No MAC Address, BLE connect failed, BLE error and any text the table doesn't know. Until open-backend #10 the page kept a string-keyed colour table, OBDHome and the connect button matched only the exact "Connecting" (so the BLE steps showed red with no spinner on Android), and the tile and widget classified by substring.

Accessories — settings/AccessoriesSettingsPage.qml

Volume Knob, IMU Sensor, Gesture Sensor, Phone Dock (Phone Mirror; Android Auto is shelved and hidden). Marked desktopOnly in the page model, so it is absent from the Android build's menu. See Hardware Managers and Phone Mirror / Android Auto.

Device — settings/DeviceSettingsPage.qml

Device (device_name; its tile shows the device name, with a status dot for the network) holds three sections: Name (text field → mainWindow.updateDeviceName(), which is also the window title), Network (connection dot, network name / "Not Connected", Refresh → networkManager.refreshNetwork()) and Power (a two-step "Shut Down" → "Confirm Shutdown" button that resets after 5 s and calls networkManager.shutdown_device()). Diagnostics (the tile is always there; its controls show when the diagnosticsManager context property exists — src/main.cpp registers it on every platform): a 2×2 GridLayout of SettingsButtons — Refresh (recentLogLines(200)), Copy (copyLogsToClipboard(400)), Export logs (exportLogs() → Downloads folder) and Open folder (visible only when canOpenFolder) — followed by a line with appVersion, backendName, deviceInfo and logDir, a status line, and a dp(180) monospace log tail scrolled to the bottom. Commit 7fd8405 moved the buttons into the grid because four labels side by side overflowed the card at 1276 px on the Pi and a Layout cannot shrink a button below its label. Diagnostics lived on the About page until it moved here.

About — settings/AboutPage.qml

  • A pulsing "OCTAVE" title (a dimmed copy behind the accent-coloured text, opacity 0.3↔0.7 every 600 ms).
  • Latest Build: commit hash, date and message from settingsManager.get_git_commit_hash() / _date() / _message().
  • Documentation: Open Wiki opens a reader inside OCTAVE’s page area, keeping the bottom or side navigation visible and usable. On desktop the reader is Qt WebEngine (settings/WikiWebView.qml); on Android, where WebEngine does not exist, WikiPopup.qml loads settings/WikiNativeView.qml instead — the same toolbar on Qt WebView (the phone's own WebView), reading the wiki that the APK carries under assets/wiki (file:///android_asset/wiki/index.html; main.cpp calls QtWebView::initialize() before the application object and exposes that URL as wikiHomeUrl). The native view has no user scripts, so the reader bootstrap is injected with runJavaScript() after every successful load. While another page is shown the popup is not just transparent and disabled but zero-sized and its reader loader hidden: an open Popup swallows every press inside its geometry regardless of enabled, which had left the media room untouchable after the wiki was opened, and on Android the native WebView ignores QML opacity. A compact toolbar provides Back, Forward, Home, and Close (or Escape), with Topics and Search controls. Pressing the app’s Settings navigation button while viewing the wiki closes the reader and returns to About. Choosing another app page suspends the reader in the background; returning with Settings restores the same page and scroll position. The in-app reader sizes its text automatically — no zoom buttons: the smallest size follows the screen scale and the UI Scaling setting, and on wider readers the text grows (up to 1.4×) to keep lines near 70 characters — and has a collapsible topic panel with large tap targets, and horizontally scrollable tables. Touchscreens use native swipe scrolling; mouse-emulating touchscreens can drag the article or topic list. The standard browser wiki retains its original layout. Desktop builds use Qt WebEngine; the reader is created on open and released only when explicitly closed. Source launches read the live wiki/ folder, while packaged native builds fall back to an embedded offline copy. Builds without WebEngine display an availability message.
  • Software Updates: Check for Updates (with the same orange pulsing dot as the bottom bar), a status dot coloured per updateStatus (checking = accent, up-to-date = green, update-available / diverged = orange, ahead = accent, error = red) and updateMessage; when an update is available it shows the remote commit and, if canSelfUpdate, either a "working tree dirty" warning or a two-step "Update Now" / "Confirm Update" button (5 s timeout) driving applySelfUpdate(), with fetching / error-retry / "Restart Now" states from selfUpdateStatus. Non-git installs see "Pull the latest changes from GitHub to update."
  • Open-Source Licenses (objectName: "licensesCard"): OCTAVE's GPL-3.0-or-later notice with the source link, then what this build bundles, each with its license and use. Every build lists Qt, the QR Code generator, the scrcpy server fork and the three bundled OFL-1.1 fonts (Google Sans, JetBrains Mono, OCTAVE Symbols); desktop adds TagLib, FFmpeg (the H.264-decoder-only build that decodes the phone mirror, LGPL-2.1+, and the copy inside Qt Multimedia that plays music, LGPL-2.1+ except GPL-2.0+ in the aarch64 AppImage) and adb; Android adds OpenSSL. "GPL-3.0 Text" and "Notices" open the full LICENSE / NOTICE in a scrollable popup through diagnosticsManager.licenseText().
  • Description text, the GitHub link, and the copyright line.

Tile Grids and the Card Popup

Commit 93d0fc9 gave every page a tile grid. Three components implement it:

  • settings/SettingsTilePage.qml — a GridLayout of SettingsTiles driven by tileModel. A columns property (default 0 = automatic) overrides the column count, and a non-zero rows switches the flow to top-to-bottom with that many rows per column (the settings grid uses this so a column holds one section). The grid's height is split evenly between its rows and every tile is sized to its share, so the grid always fits the space it is given (a fixed minimum tile height used to push the bottom row under the nav bar); a model entry's optional rowSpan makes a tile cover several rows; automatically, four tiles form a 2×2 grid and any other count uses max(2, ceil(sqrt(n))) columns. It emits tileSelected(cardId, originRect) with the tile's rectangle mapped into the grid's parent. The tapped tile stays drawn while its popup is open; the popup covers it once its background fills in.
  • settings/SettingsTile.qml — a rounded rectangle (primaryTextColor at 5 %, 10 % on hover, accent at 18 % when pressed, scale 0.97 while pressed) with a static bevel (1 px white 10 % top line, 1 px black 20 % bottom line), no icon: it shows the setting's name with its current state under it, the label / second-line pattern of Android Quick Settings and Apple Home tiles. The title (primaryTextColor, medium weight) is the largest text; the stateText line below it is secondaryTextColor, with an optional status dot before it (statusColor, statusVisible). Both are grouped and vertically centred, and sized from the tile width rather than the text, so every tile in a grid matches; a long title wraps to a second line ("Library & Playback") and anything longer elides rather than shrinks. When active is true (something on or connected) the tile is tinted with the accent at 16 % and the state line turns primary. A tile with no state shows its title alone (Adapters). Emits tileClicked(cardId).
  • settings/SettingsCardPopup.qml — a contentColor panel with a header row (a "→" glyph and the title, both turning accent on hover; tapping anywhere on the header emits backRequested()), a solid 1 px accent rule, and a Flickable body (inset overallMargin from the rule and from the bottom bar, the same gap the music library leaves) whose Loader instantiates the tile's component. The popup owns its hero morph (below); hosts only set open, originRect, title and contentComponent.

Each tileModel entry's summary is a function returning { state, active, status } (all optional). SettingsTilePage calls it inside the delegate's info binding, so the binding tracks whatever settingsManager or manager properties the function reads and the tile updates live (the clock tile ticks from a 1 s timer in DisplaySettingsPage) without the page rebuilding its tileModel. Summaries read only existing NOTIFY properties (obdManager.connectionStatus, networkManager.networkName, esp32VolumeManager.connected, …) or page properties fed by Connections (album count), so no manager needs a summary-specific API. Keep the state to about 14 characters (the settings grid elides past that), set active only for things that are on or connected, and leave the state out rather than fill it with an instruction.

The host (the default grid layout or a Carousel SettingsHubCard) detects a tile page in Loader.onLoaded by typeof item.tileModel !== "undefined", sets item.visible = false to hide the page's own stacked rendering, and shows the grid instead. Opening a tile records detailTile, detailCardId and originRect, and binds the popup's open to detailCardId !== ""; the popup then morphs from the tile:

SettingsCardPopup {   // inside the popup itself
    property real openProgress: open ? 1.0 : 0.0
    x: originRect.x * (1.0 - openProgress)
    y: originRect.y * (1.0 - openProgress)
    width:  originRect.width  + (parent.width  - originRect.width)  * openProgress
    height: originRect.height + (parent.height - originRect.height) * openProgress
    contentOpacity: Math.max(0.0, (openProgress - 0.45) / 0.55)   // content resolves in the second half
    Behavior on openProgress { enabled: _animEnabled; NumberAnimation { duration: open ? 320 : 260; easing.type: Easing.OutCubic } }
}

Details that keep the morph smooth:

  • No reflow while it grows. The body Flickable is sized to the fully-open popup (the parent's size) for the whole morph and clipped by the popup, so the card's text and controls are laid out once instead of re-wrapping every frame.
  • Cross-fade with the tile. The tile stays drawn underneath. The popup's background alpha is min(1, openProgress / 0.3), tinted like the tile (primaryTextColor at 5 % over contentColor, fading to plain contentColor) with the tile's corner radius shrinking to 0, so the tile dissolves into the card on open and reappears through it on close. Hiding the tile instead left a blank block over it for the slow tail of the close.
  • Content held through the close. The popup latches title and contentComponent and only drops them once openProgress reaches 0, so they fade with the shrink rather than vanishing on its first frame. The body's Loader unloads then, so Component.onDestruction still runs and page-scoped resources such as the Now Playing live preview stop. closeTile() clears detailCardId and nulls detailTile as before.
  • Section switch. When the section's page changes the host calls snapShut() first, which disables the animation for one frame so an open card closes at once instead of shrinking into the new section's grid.
  • _animEnabled is set with Qt.callLater after creation so the popup never animates at startup.

Now Playing Studio

The Media page's "Now Playing" tile (cardId === "media_now_playing") is special-cased in both tile hosts: instead of openTile() they map the tile rectangle to scene coordinates and call mainWindow.openNowPlayingStudio(rect). frontend/NowPlayingStudio.qml is instantiated once in Main.qml at z: 5, above the StackView. It performs the same hero-morph from the tile's rectangle to its target rectangle, which Main.qml sizes to exclude the bottom bar (so navigation stays usable). The studio's back header returns to the settings grid. Any stackView.currentItemChanged closes it. Its header comment defines a visual standard for the rows inside (named spacing tokens, labels left / controls right) and the helper components NowPlayingMiniToggle, NowPlayingVSlider and NowPlayingButtonGrid exist for it. Commit 484bc76 ("now playing settings update") introduced this arrangement and removed the last environment-preset files.

Layout Engines

Four engines render the same pages with different navigation. The grid is the default (settingsLayoutStyle defaults to "Sidebar", its stored name, in SettingsManager and in the orchestrator's fallback); the others are labelled experimental in the Display page and hidden behind a toggle. Each engine receives settingsMenu and hubModel from the orchestrator, defines the local dp() helpers, and shows the update dot on the About entry. When a page is loaded, every engine binds item.mainWindow, item.stackView and item.currentSection if the page declares those properties.

frontend/SettingsSidebarLayout.qml is the default layout. Its stored settingsLayoutStyle value is still "Sidebar", the layout it replaced, so existing settings files keep working; the Display page shows it as "Grid" (layoutLabel() / layoutStyleFor() on DisplaySettingsPage map between the two). It fills the whole content area with one SettingsTilePage holding every card of every visible section (allTiles), like an app drawer. There is no section list and no per-section view.

The grid fills top to bottom, one section per column under a small uppercase caption (the section name, from a Row laid out with the grid's own margin and spacing), with at most gridRows (4) tiles per column. sectionColumns builds the columns in pageModel order, and About's single tile finishes the last column if it has room, landing in the bottom-right corner. A column with fewer tiles stretches them to fill it, so short sections leave no gaps: the grid has gridSlots (12) rows and each tile's rowSpan is 12 divided by its column's tile count (12 divides evenly by 1–4). Desktop reads Display (3), Media (2), OBD (3), Accessories (4), Device (2) + About; Android has no Accessories. If the last column is full (for example Device hidden in the Toggle View popup), About gets a column of its own with no caption.

A hidden Repeater of Loaders builds one instance of each tile page with renderCards: false, because each tile's component lives inside its page. Tapping a tile opens its card in the SettingsCardPopup described above; the popup's back arrow, or tapping the bottom bar's Settings button (navigateToHub()), closes it. media_now_playing still opens the Now Playing studio. About has no tiles of its own, so it gets one tile (section:about) whose card loads the whole AboutPage.qml; its summary (_aboutSummary()) shows the app version and backend, plus an orange status dot when an update is available. navigateToCategory("about") (used when the wiki popup is closed from the Settings button) opens that card. After a rebuild (nav bar orientation change) the card in settingsMenu.initialTile is reopened once the pages have loaded.

frontend/SettingsCarouselLayout.qml shows a tab strip and a SwipeView. The strip is a Row of equal-width, dp(28)-tall tabs ((width - dp(4)×(n-1)) / n), each with a ThemedIcon (no shadow) and the page name; the current tab has an accent 20 % fill and 40 % border, others fade to 0.6 opacity. Tapping a tab sets swipeView.currentIndex; swiping updates currentSection and persists it.

Each swipe page is a settings/SettingsHubCard.qml: a translucent contentColor surface (92 %) with frosted highlight gradients, a lit top edge and an accent bottom rule, radius hubCardRadius. Its Loader is active only while the card is the current item (isCenter), so off-screen pages are not instantiated. The card mirrors the Sidebar's tile-mode state machine (grid + morph popup, Now Playing hand-off) and closes any open popup when it scrolls away from centre.

Hub Layout

frontend/SettingsHubLayout.qml has two states, "hub" and "detail", chosen in onSettingsMenuChanged: stay in detail after a mid-session layout switch, resume the last section on first launch if it is visible, otherwise show the hub. State changes cross-fade (150 ms out, 200 ms in).

The hub is a GridLayout of settings/SettingsHubGridCard.qmls, 3 columns when wider than 800 px else 2, with card heights computed to fill the view without scrolling. Each card shows the page name, a chevron, and passive info lines from getWidgetForSection():

SectionInfo lines
displaySettingsTheme: themeSetting; Scale: round(uiScale × 100)%
mediaSettingsAutoplay; Visualizer
obdSettingsStatus: obdManager.get_connection_status(); Port; Fast
accessoriesSettingsKnob: connected / disconnected (esp32VolumeManager.is_connected()); Gestures; Phone Dock (Phone Mirror enabled)
deviceSettingsName

The card model is rebuilt on hubModel changes, on return to the hub, and live on obdManager / esp32VolumeManager connectionStatusChanged while the hub is showing. The detail state has a heading bar ("← Section name", tapping it returns to the hub) over a page Loader with the same scroll-memory handling as the Sidebar. Hub detail does not use tile mode: pages render their stacked SettingsCard list.

Dashboard Layout

frontend/SettingsDashboardLayout.qml uses the same hub/detail state machine (without fade transitions). Its hub arranges cards in two rows: a featured top row of displaySettings, mediaSettings and obdSettings (in that order, only those visible) and a bottom row of the remaining visible pages in page-model order. Every card is a settings/SettingsDashboardCard.qml with cardSpan: 1 — there is no column spanning.

A dashboard card is a glassy contentColor surface (55 %) with a hover glow; its title row navigates to the page and its body is a Loader for the page's widget. When the widget exposes a navigateToSubSection(sub) signal the card connects it and re-emits subSectionSelected(section, sub); the layout then loads the page and, once the Flickable has laid out, scrolls to the SettingsCard whose objectName equals the sub-section name (16 ms polling timer). The detail view adds a dp(40) scroll rail on the left with a draggable accent handle (minimum dp(50) tall) whose position maps to contentY — a touch-friendly alternative to flicking a long page.

Reusable Components

All of these live in frontend/settings/ and import the parent directory as App. Radii and feature flags come from App.EnvironmentTheme.active (single fixed preset, see Theme System); the dormant "spacecraft" / "deep sea" branches noted below are compiled in but never active.

Containers

ComponentDetails
SettingsCardCollapsible card. Props cardId, title, description, expanded; default property alias cardContent. collapsible is true when cardId is set; the initial expanded value comes from ExpandedCards.isExpanded(cardId), which returns false for a card that has never been toggled, so cards start collapsed in the stacked renderings. Background primaryTextColor at 7 %, radius cardRadius, 250 ms height animation gated by _initialized; the header arrow (▼ / ▶) blinks through an opacity animation targeted by id — commit cda1182 ("Settings page opaque menu fix") changed that target from parent to the explicit ids collapseArrow / sectionArrow. Dormant: accent border, top highlight, glass gradient, CornerBrackets.
SettingCategoryLight card (5 % background, no border) for one setting or a tight group. title, description, default slot categoryContent (stacked) and inlineContent (right side of the title row). A flex spacer pushes inline controls to the right edge; when there are none it becomes the HUD line only if sectionHeaderLines is on.
SettingSubcategoryCard-within-a-card (4 % background, 7 % border, radius cardRadius × 0.7). With togglable: true the header carries a compact SettingsToggle and the body (height + opacity animated, enabled follows visibility) only shows while checked. A Connections re-asserts the toggle's checked from the parent because clicking the switch severs its binding. Reveal animation suppressed on first mount.
SettingsCollapsibleSectionHeader-plus-body collapsible without a card background; title, expanded (default true), default slot content.
SettingsSectionHeaderBold title at overallText × 1.5, optional description, dormant HUD line.
SettingsDivider1 px translucent line. A Loader picks the standard, "spacecraft" (gradient + pulse + rotating diamond) or "deep sea" (gradient + sonar ping) variant by flags; only the standard one is reachable.

Controls

ComponentDetails
SettingsTogglechecked, compact, text, activeColor, inactiveColor, font; signal toggled(bool). Compact mode: a dp(48)×dp(26) switch (accent when on, secondaryTextColor when off, white dp(20) knob, radii switchRadius/switchKnobRadius) with a bold label to its right; it hugs its content so it right-aligns inside SettingCategory. Full mode: dp(46) row with the label at 55 % width and a dp(72)×dp(36) track with a white handle carrying an inner accent dot, spring animation (OutBack, overshoot 0.6) and a press pulse; the handle's DropShadow is enabled because toggleRectShadow is false.
SettingsSliderA Slider with activeColor (default settingsSliderColor), visualValue, valueDisplay. Track: secondaryTextColor with an activeColor fill, half the overallSliderHeight; handle overallSliderWidth × overallSliderHeight, darkened 10 % while pressed. A z: 10 MouseArea absorbs presses on the track and passes through only presses on the handle, so the value can be changed by dragging only, never by tapping the track. Dormant: handle glow, 11 tick marks.
SettingsButtontext, tooltipText, buttonColor (default accent), bold; signal clicked(). Solid fill darkened 20 % on hover and 40 % on press, 1 px darker border, radius buttonRadius, white label, ToolTip after 300 ms. Preferred width follows the label plus overallSpacing × 1.5, minimum dp(70).
SettingsTextFieldTextField at formElementHeight, preferred width dp(500), max dp(800), hoverColor background, accent border while focused, hover tint. Dormant: eight corner-mark rectangles.
SettingsSegmentedControlRowLayout of equal segments (minimumWidth dp(80), formElementHeight), selected = accent with white text, others hoverColor. options, currentValue, and onSelected as a var callback property (assigned with onSelected: function(value) {...}), not a signal.
SettingsChipsA Flow of selectable chips. Props options, currentValue, chipColors (per-chip text colour), chipImages (album-art chips), chipPalettes (three-band swatch chips), chipFonts (font sample chips), deletableItems; callbacks onSelected, onItemDeleted. Text chips are pills sized to their label; image/palette chips are squares and font chips wide rectangles of formElementHeight × 2.4, drawn on #222222 with a dpMin(4, 2) accent border when selected. Text chips are laid out first, then a full-width 1 px break, then image chips on their own row. Deleting: an inline × on text chips, press-and-hold on image/palette chips; both go through a shared confirmation Popup. A DropShadow layer is enabled on the selected text chip (chipAccentBorder is false).
SettingLabel / SettingDescriptionLabel in primaryTextColor at overallText; word-wrapped Text in secondaryTextColor at 80 %.
ValueDisplayRead-out next to sliders; text alias, secondaryTextColor, dormant bracket / accent modes.

Tiles, Cards and Studio Helpers

ComponentDetails
SettingsTile, SettingsTilePage, SettingsCardPopupSee Tile Grids and the Card Popup.
ThemedIcon (frontend/ThemedIcon.qml)Tinted SVG with an offset shadow overlay; see Theme System.
CornerBrackets (frontend/CornerBrackets.qml)Pulsing accent corner marks; instantiated by cards and tiles with visible: cornerBrackets, which is always false. The pulse animation runs only while visible.
SettingsHubCardCarousel page card (see Carousel); props categoryName, section, categoryIcon, groupName, pageSource, isCenter, settingsMenu.
SettingsHubGridCardHub landing card with accent-tinted gradient, hover scale 1.02 / press 0.98, chevron, and widgetItems label/value rows in accent; signal categorySelected(section).
SettingsDashboardCardDashboard card with widget Loader and fallback info rows; signals categorySelected, subSectionSelected; background MouseArea at z: -1 so widget controls win.
MediaRoomLivePreviewLoads a real ../MediaRoom.qml at designWidth × designHeight (the live window size) inside a scaled stage, "Fit" (letterbox) or "Fill" (crop) mode, with a 2 px accent border on the exact visible rectangle. The inner Loader source is cleared while the preview is not visible so vinyl rotation and FFT analysis are not rendered twice. previewTrackTransition(direction, style) forwards a synthetic transition to the embedded room.
NowPlayingButtonGridTwo-column GridLayout of equal-width option buttons (selected = accent); signal selected(value).
NowPlayingMiniToggle (defined, not instantiated — the studio uses SettingsToggle)Label-left / switch-right row whose toggle has a fixed dp(104) width so every switch lands on the same x whether it reads "On" or "Off".
NowPlayingVSlider (defined, not instantiated — the studio uses SettingsSlider)Vertical slider tile (dp(70) wide, fills rail height) with label above and value below; from, to, stepSize, suffix, dimmed; signal moved(value).

Widget System

The Dashboard layout loads one interactive widget per page from frontend/settings/widgets/. Each is an Item with a cardSpan property (bound by the card; always 1 today) and, where it offers sub-section chips, a navigateToSubSection(string) signal.

WidgetShowsSub-section chips
DisplayWidget.qmlTheme, Scale and Font pills (font name truncated at 14 characters); Show Clock toggleLayout, Appearance, Clock
MediaWidget.qmlAutoplay, Visualizer and 3D Preview togglesLibrary (opens Library & Playback), Now Playing — one chip per Media tile
OBDWidget.qmlConnection status row with a reconnect glyph (hidden while connected); Fast toggle; Circular Gauges toggle (save_obd_card_style_circular() / obdCardStyleCircular)Connection, Parameters
AccessoriesWidget.qmlKnob pill (ESP32 connection), Gestures toggle (gestureSensorEnabled), Phone Dock pillVolume Knob, Gesture Sensor, Phone Dock
DeviceWidget.qmlName pill; network status row with a refresh glyph—
AboutWidget.qmlCommit hash; update Status pill; Check Updates button—

widgets/WidgetPill.qml is a label/value row on a 70 % backgroundColor pill with an optional pulsing status dot; widgets/WidgetChip.qml is a small accent-tinted chip with a clicked() signal. Widgets call App.Spacing.dp() directly rather than a local wrapper.

Section Visibility

buildHubModel() filters pageModel through settingsManager.is_settings_section_visible(section) (and drops desktopOnly pages on Android):

function buildHubModel() {
    var model = []
    var android = (typeof isAndroid !== "undefined" && isAndroid)
    for (var i = 0; i < pageModel.length; i++) {
        var page = pageModel[i]
        if (page.desktopOnly && android) continue
        if (!isVisible(page.section)) continue
        model.push({ name: page.name, section: page.section, source: page.source, icon: page.icon,
                     iconSource: page.iconSource || "", group: page.group || "", widget: page.widget || "" })
    }
    hubModel = model
}

SettingsManager persists the map as settingsMenuVisibility and exposes is_settings_section_visible(section) (unknown sections are treated as visible) and save_settings_section_visibility(section, visible), which emits settingsMenuVisibilityChanged; the orchestrator rebuilds hubModel on that signal. The shipped defaults are exactly the six pageModel sections — displaySettings, mediaSettings, obdSettings, accessoriesSettings, deviceSettings, about — all true, and a section missing from a stored settingsConfigure.json is filled in as visible when the file loads, so every page shows until the user hides it. The UI that writes the map is the "Toggle View" popup, opened by press-and-hold on the bottom bar's Settings button (see Bottom Bar).

Card State and Scroll Memory

ExpandedCards.js

A .pragma library module — one JavaScript instance shared by every importer — caching the expanded/collapsed state of SettingsCards:

// SettingsMenu.onCompleted, once per settings visit (guarded by _loaded)
ExpandedCards.load(settingsManager)     // reads generic setting "expandedCards"

// SettingsCard
property bool expanded: ExpandedCards.isExpanded(cardId)   // false when unknown
onExpandedChanged: { ExpandedCards.setExpanded(cardId, expanded); _saveTimer.restart() }   // 500 ms debounce
// _saveTimer → ExpandedCards.save(settingsManager) → settingsManager.save_setting("expandedCards", copy)

The cache exists so that creating a card never touches the disk and so the card does not hold a settingsManager binding (the comment cites binding re-evaluation cascades). SettingsManager provides the generic get_setting_with_default(key, default) / save_setting(key, value) pair the module uses.

ScrollMemory.js

Two lines: .pragma library and var positions = {}. Layouts store positions[section] = flickable.contentY on every scroll and restore it after the page loads. Because it is a library singleton the offsets survive layout switches within a session but not an app restart.

Adding a New Settings Page

  1. Create the page in frontend/settings/MySettingsPage.qml as a Flickable with the local dp() wrappers, a tileModel, one Component per card body, and the stacked fallback:
    property bool renderCards: true
    property var tileModel: [
        { cardId: "my_first", title: "First", component: firstContent },
        // ... at most 4 tiles: one column of the settings grid
    ]
    
    Component {
        id: firstContent
        ColumnLayout {
            width: parent ? parent.width : 0
            SettingCategory { title: "..."; /* controls */ }
        }
    }
    
    // Default rendering for Hub / Dashboard / About-style hosts; the tile hosts hide this.
    ColumnLayout {
        width: parent.width
        Repeater {
            model: pageRoot.renderCards ? pageRoot.tileModel : []
            SettingsCard {
                Layout.fillWidth: true
                objectName: modelData.title      // sub-section lookup key
                cardId: modelData.cardId
                title: modelData.title
                Loader { Layout.fillWidth: true; sourceComponent: modelData.component }
            }
        }
        Item { Layout.fillHeight: true; Layout.minimumHeight: App.Spacing.bottomBarHeight }
    }
    Declare property var mainWindow, stackView or currentSection only if you need them; the layouts bind them when present.
  2. Add a summary function returning { state, active, status } when useful. Tiles show labels and current state; category cards and tabs can still use a single-fill SVG through ThemedIcon.
  3. Optionally create a widget in frontend/settings/widgets/MyWidget.qml with a cardSpan property and, if it offers chips, a navigateToSubSection(string) signal whose values equal tile titles.
  4. Register in pageModel with subSections equal to the tile titles, and add desktopOnly: true if the page depends on desktop-only managers.
  5. Backend: add every new save_* slot and property to SettingsManager (src/managers/settingsmanager.{h,cpp}) and to Settings Reference; add the section to the settingsMenuVisibility defaults there if it should be hideable.
  6. Test all four layouts (Display → Layout → "Show experimental layouts").

Design Decisions

DecisionRationale (from code comments and commit history)
Card bodies as Components shared by two renderings Page comment: "shared between the Flickable rendering (below) and the SettingsCardPopup in the Sidebar layout". One definition of each setting serves the stacked list, the tile popup and the Carousel card.
Tile grids with a hero-morph popup instead of long scrolling pages Commit 93d0fc9: "tile grids for every page". Four large touch targets per page suit a dash-mounted screen; the popup "grows from the clicked tile's rect" so the user keeps spatial context, and content fades in only once "there's enough room for them".
Grid is the only non-experimental layout The Display page's description reads "Grid is the default. The other layouts are experimental." The experimental toggle is derived from the active layout "so the picker is always consistent — no extra persisted setting", and turning it off falls back to the grid so the user is not "stranded on a now-invisible choice".
Now Playing takes over the window Comments in SettingsHubCard and Main.qml: the studio "needs every pixel" and covers the StackView, but Main.qml reserves the bottom bar so navigation stays available (the settings sidebar is covered, so the studio's settings panel gets its width); any page change closes it "so it doesn't hang on top of an unrelated screen".
Live MediaRoom preview instead of a mock MediaRoomLivePreview renders "a real MediaRoom instance at the host window's design dimensions" so Now Playing changes are seen exactly as they will look, and unloads it while hidden to avoid "double-rendering vinyl rotation / FFT analysis".
closeTile() nulls the tile component Comment: without it "page-scoped resources (e.g. the Now Playing live PiP) would persist after the user backs out of the tile"; destroying the content is invisible because the body has already faded.
Pages hide themselves in tile hosts The host sets item.visible = false rather than the page knowing about layouts; a page only declares tileModel and works in all four engines.
subSections must equal tile titles Sub-navigation looks cards up by objectName, which pages set from modelData.title; a mismatch silently fails to scroll.
Card state in a .pragma library cache with a debounced write "Avoids disk I/O during card creation"; the card holds no settingsManager binding "to avoid binding re-evaluation cascades"; the 500 ms timer "coalesces rapid expand/collapse into a single disk write".
Carousel pages load only while centred pageLoader.active: hubCard.isCenter keeps five off-screen pages (each with terminals, live previews and OBD scans) from being instantiated.
Sliders drag-only, no track clicks SettingsSlider comment: "Block track clicks — only allow dragging the handle." A stray touch on a long track would otherwise jump a setting.
Scale slider commits on release Mid-drag updates re-lay out the slider itself (commit fe567f3); see Theme System.
Diagnostics buttons in a 2×2 grid Commit 7fd8405: four labels side by side overflowed at 1276 px on the Pi and "a Layout cannot shrink a SettingsButton below its label".
Two-step confirmations with a 5 s reset Shutdown and self-update both require a second tap within five seconds, with a pulsing warning text, instead of a modal dialog — cheaper to hit on a touchscreen and impossible to trigger with one accidental tap.
Themed SVG icons via Style.assetBase Commit 93d0fc9 and the Style.qml comment: settings pages and layouts sit at different directory depths, so a URL resolved in frontend/ avoids guessing ../ segments; a property rather than a function because of the Android singleton-function bug.
Accessories page is desktopOnly The serial knob, I²C sensors and adb-based phone dock are desktop hardware paths; Android is C++-only and omits those managers, so the page is dropped from the model rather than guarded control by control.
Update dot repeated on every layout's About entry The bottom bar's dot leads the user into Settings; each layout re-draws it on the About entry so the trail continues to the update controls.