Frontend Overview
QML architecture, the file inventory under frontend/, Main.qml structure, page routing, scaling, effects, shaders, and the bridge between the two backends and the UI
QML Architecture Overview
OCTAVE's frontend uses Qt Quick / QML. Most files import QtQuick 2.15; the optional vehicle and music-device scenes use unversioned Qt 6 imports and QtQuick3D. Backend managers expose playback, hardware and persistence through context properties, while QML implements presentation and interaction. The media components use unversioned imports and set the revisioned layer.live property through Binding { target: item.layer; property: "live"; value: ... }: their source comments document order-dependent Qt 6.7 failures with a direct layer.live: declaration.
ApplicationWindow (Main.qml) +--------------------------------------------------------------+ | mainContainer (Item, anchors.fill) | | +--------------------------------------------------------+ | | | StackView id: stackView objectName: "stackView" z:1 | | | | initialItem: MainMenu | | | | pushed lazily: MediaRoom, MediaPlayer, | | | | OBDHome, OBDMenu, OBDDiagnostics, SensorHome, | | | | SensorMenu, CarMenu, ClockMenu, SettingsMenu, | | | | PhoneMirrorView, AndroidAutoView, | | | | dashboards/editor/DashboardEditor | | | +--------------------------------------------------------+ | | | bottomBarShadow / sideBarShadow (gradient strips) z:2 | | | +--------------------------------------------------------+ | | | BottomBar (persistent nav + transport + volume) z:0 | | | +--------------------------------------------------------+ | | | NowPlayingStudio (window-level overlay) z:5 | | | +--------------------------------------------------------+ | | | globalFullScreenFlash (Rectangle) z:999999 | | | | globalAuraFlash (4 edge gradients) z:999999 | | | +--------------------------------------------------------+ | +--------------------------------------------------------------+ | Fonts from fontCatalog (registered in C++ at startup) | | Timers: navReloadTimer (150 ms) | +--------------------------------------------------------------+
Key architectural points:
- Single window — one
ApplicationWindowhosts everything, including full-window overlays (Now Playing Studio, shift-light flash). QML popups and dialogs stay in that window; native file pickers and the Android Auto DHU may use platform windows. - StackView navigation — pages are pushed/popped on a
StackView; there is no router. Most navigation pages are instantiated on first visit or during prewarming and then cached (stackView.openPage(), see the page cache), so later visits re-push the same instance. - Persistent BottomBar — visible below (horizontal) or to the left of (vertical "side" mode) the StackView, except under a page that declares
hidesNavigation: true(the dashboard editor): while that page is current,mainWindow.navigationHiddenhides the bar and its shadow and the StackView fills the window. See Bottom Bar. - Singletons declared in qmldir —
Style,Spacing,EnvironmentTheme,OBDParameterModel,dashboards/WidgetCatalog, andMusicDevicePreferencearepragma Singletonobjects. - No page transitions — all six StackView transitions (
pushEnter/Exit,popEnter/Exit,replaceEnter/Exit) arenull. - No global z-order tricks beyond the ones in the diagram: the StackView sits above the BottomBar (
z: 1vs0), shadows atz: 2, the studio atz: 5, overlays atz: 999999.
File Inventory
Everything the UI needs lives under frontend/. The top level is flat; only settings, gauges, dashboards, shaders and assets get subdirectories.
| File | Role | Documented in |
|---|---|---|
| Main.qml | ApplicationWindow, StackView, overlays, font scan, shift light, navigation helpers | this page |
| qmldir | Registers the six singletons (see Import Path) | this page |
| Style.qml, Spacing.qml, EnvironmentTheme.qml | Design-token singletons: colors/fonts/themes, scaled dimensions, structural tokens | Theme System, GUI Scale |
| BottomBar.qml | Persistent navigation, transport, volume | Bottom Bar |
| MainMenu.qml, HomeOBDView.qml | Home screen and its OBD card grid | Pages, Components |
| MediaRoom.qml, MediaPlayer.qml | Now-playing view, library browser | Pages |
| MusicDevicePreference.qml, MusicDeviceScene.qml, MusicDeviceCover.qml, MusicDeviceArtSurface.qml | Playback-device preference singleton and optional Quick3D music-device visuals | Components |
| NowPlayingStudio.qml | Window-level immersive editor for Now Playing visuals | Pages |
| AlbumArtCarousel.qml, CoverflowCard.qml, ThemedIcon.qml, CornerBrackets.qml, TerminalFeedback.qml, ErrorToast.qml | Reusable components | Components |
| OBDHome.qml, OBDMenu.qml, OBDDiagnostics.qml, OBDParameterModel.qml | OBD hub, live gauges/dashboards, DTC reader, PID singleton | Pages, Components |
| SensorHome.qml, SensorMenu.qml, SensorCard.qml, GForceDisplay.qml | Sensor hub, IMU readouts, sensor cards | Pages, Components |
| CarMenu.qml, JeepVehicle.qml, JeepHinge.qml, JeepLamp.qml, JeepLink.qml, JeepSteering.js | Quick3D vehicle-attitude view and the rigged Jeep TJ | Pages, Components |
| ClockMenu.qml | Digital + analog clock page, opened by the BottomBar clock or navigateTo | Pages |
| PhoneMirrorView.qml, AndroidAutoView.qml | Phone mirroring and Android Auto DHU views | Pages, Phone Mirror & Android Auto |
| SettingsMenu.qml + SettingsHubLayout.qml, SettingsSidebarLayout.qml, SettingsCarouselLayout.qml, SettingsDashboardLayout.qml | Settings orchestrator and its four selectable layouts | Pages, Settings UI |
| settings/ (pages, cards, toggles, sliders, widgets/) | Settings pages and the settings component library | Settings UI |
| gauges/, dashboards/ (DashboardRenderer.qml, WidgetCatalog.qml, editor/, presets/, widgets/) | Gauge primitives, JSON-driven dashboards, the in-app editor | Gauges & Dashboards |
| shaders/ | dissolve_squares.frag source, its compiled .qsb, and shaders.qrc | Shaders |
| assets/ | Flat-white SVG icons (*_button.svg, cat_*.svg), missing_art.png, fonts/ (GoogleSans-Regular.ttf, JetBrainsMono-Regular.ttf), jeep_tj/ (rig parts), jeep_tj_2003.glb (monolithic preview), vehicle_studio.hdr, readme/ (README screenshots and .webp clips produced by the screenshot sweeper), -src/ (Illustrator .ai design sources for the logo and transport icons), Isabela/ (an older set of seven transport/clock SVGs that nothing references any more) | this page, Components |
Main.qml Structure
frontend/Main.qml is an ApplicationWindow that orchestrates the entire UI. Its top-level declaration:
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.folderlistmodel 2.15
import "." as App
ApplicationWindow {
id: mainWindow
visible: true
title: deviceName
minimumWidth: 400
minimumHeight: 300
font.family: App.Style.fontFamily !== ""
? App.Style.fontFamily : systemDefaultFont
// Desktop: saved size. Android: leave unset so the OS owns sizing
// (a hardcoded size defeats the display-cutout pillarboxing).
width: isAndroid ? undefined : screenWidth
height: isAndroid ? undefined : screenHeight
}
The window captures systemDefaultFont (its own font.family) in Component.onCompleted before any custom font is applied, and also stores it in Style.systemDefaultFont, so Style.fontFamily always resolves to a real family.
Key Properties
| Property | Source | Purpose |
|---|---|---|
deviceName | settingsManager.deviceName | Window title |
theme, fontSetting | settingsManager.themeSetting / fontSetting | Mirrors of the saved theme and font names |
startUpVolume, showClock, clockFormat24Hour, clockSize, lastSettingsSection | settingsManager | Mirrors kept on the window for child pages |
screenWidth / screenHeight | settingsManager.screenWidth / screenHeight (defaults 1280×720) | Persisted window dimensions |
isVerticalLayout | settingsManager.bottomBarOrientation === "side" | Side bar vs bottom bar mode |
nowPlayingStudioOpen, nowPlayingStudioOriginRect | openNowPlayingStudio(rect) / closeNowPlayingStudio() | Drive the studio overlay and its hero-morph origin |
Window Management
Window dimensions are persisted bidirectionally on desktop. On resize, onWidthChanged / onHeightChanged write back to the settings manager (the only writer: the Display page's size fields resize the window and let this save it; skipped on Android, whose window is the screen) and recompute Spacing. The manager clamps to 320–7680 × 300–4320 (320 is also minimumWidth, so the browser demo fits a 360 px phone held upright) and ignores an unchanged value, and its disk writes are batched (500 ms):
onWidthChanged: {
if (settingsManager && width > 0 && width >= minimumWidth) {
if (!isAndroid)
settingsManager.save_screen_width(width)
screenWidth = width
App.Spacing.updateDimensions(width, height)
}
}
The reverse direction (settings page → window) is a Connections block on settingsManager handling onScreenWidthChanged / onScreenHeightChanged; both are skipped when isAndroid. Window state (fullscreen / maximized / normal) is restored in Component.onCompleted from settingsManager.get_window_state().
onClosing handler calls mediaManager._save_playback_state(), then accepts the close. Nothing else is torn down from QML; managers clean up from their backend destructors / aboutToQuit.
Page Routing Mechanism
OCTAVE uses Qt's StackView for page navigation. The only statically instantiated page is the home screen:
StackView {
id: stackView
objectName: "stackView"
z: 1
anchors {
left: isVerticalLayout ? bottomBar.right : parent.left
right: parent.right
top: parent.top
bottom: isVerticalLayout ? parent.bottom : bottomBar.top
}
initialItem: MainMenu {
stackView: stackView
windowWidth: mainWindow.width
windowHeight: mainWindow.height
}
pushEnter: null; pushExit: null
popEnter: null; popExit: null
replaceEnter: null; replaceExit: null
}
Lazy loading and the page cache
Most navigation pages are built once, on demand or by prewarming, and then retained. The dashboard editor is owned by the StackView and destroyed when popped. The StackView in Main.qml owns a small cache, stackView._pageCache, keyed by file name ("OBDMenu.qml"), and helper functions that every navigation site goes through:
| Function | Behaviour |
|---|---|
cachedPage(qmlFile, props) | Returns the cached instance, building it on first use with Qt.createComponent() + createObject(stackView, …). stackView and mainWindow are always supplied; props adds or overrides (for example initialSection for SettingsMenu, windowWidth/windowHeight for MainMenu) and only applies the first time. Load or instantiation failures are logged with console.warn("[NAV] …") and return null. |
openPage(qmlFile, props) | Shows the page: a no-op if it is already currentItem, pop(page) back to it if it is somewhere in the stack, push(page) otherwise. Returns the page or null. This is what the BottomBar, MainMenu tiles, OBDHome / SensorHome subpage cards, MediaPlayer ("play, then show MediaRoom") and the command server's navigateTo() all call. |
forgetPage(qmlFile) | Drops the entry and hands the instance back; the caller destroys it once it is out of the stack. Used only by the orientation-change handling of SettingsMenu. If Settings is on screen, navReloadTimer builds a fresh instance, replace()s it in, then destroy()s the old one. If a cached Settings page is off the stack, it is simply forgotten and destroyed, and the next visit builds one for the new orientation. |
prewarmPage(qmlFile, props, done) | Builds a page into the cache in the background with asynchronous incubation, then calls done(page). The page is created with visible: false; StackView shows it on its first push. The page's width and height are bound to the StackView's, because StackView only sizes a page when it is pushed. An unsized page would load its images at the wrong size and give the warm render nothing to draw. page is null if it was already cached or failed to build. |
Pre-building. Main.qml builds the main navigation destinations in the background, one at a time. It waits for startup to settle (400 ms without a frame swap, or 60 timely frames), with a 30-second cap and a 3-second fallback when no frames arrive. The queue includes the chosen media page, remembered sensor and OBD pages, SettingsMenu and the other media page; Quick3D pages run last. Asynchronous incubation spreads creation across frames, and a navigation request can force an in-flight build to complete. Between pages, the queue rests for twice the previous build-and-warm-render time, clamped to 150 ms–4 s, and backs off while a pointer is down or for 350 ms after a press. Pages can add to it later with mainWindow.prewarmPageSoon(file) (pagePrewarmTimer.enqueue()): the page joins the startup list if that hasn't been built yet, the end of a running queue, or restarts a finished one; already built or building is a no-op. SensorHome uses it the first time it is shown to have CarMenu built in the background, since a cold CarMenu build froze the first 3D View tap for about 1.2 s on a phone, without paying for CarMenu in sessions that never open Sensor Home.
A ShaderEffectSource then warms each page offscreen for two frames. The page is briefly visible, so onVisibleChanged handlers can run, but StackView.onActivated does not. Keep per-visit work in the activation handler and gate repeating animations and timers on visibility. MediaRoom keeps its optional 3D device scene loaded between visits to avoid rebuilding it.
The same applies to repeating Timers and to high-rate data. The title marquees (Home, MediaRoom) run only while their page is visible, and their onFinished restart() is guarded too, because restart() bypasses the running binding. MediaRoom ignores the 10 Hz positionChanged ticks while hidden (StackView.onActivated re-reads the position). On the Orange Pi, a hidden pre-built MediaRoom scrolling a long title and following playback cost 15–25% of a core at idle, found in 0.9.4 pre-release testing.
Pre-building pitfalls found in 0.9.4 testing. Pre-building compiles pages at a different moment than a first visit does, and on Qt 6.7 (the AppImage and Android) that exposed an order-dependent compiler bug. A layer.live: declaration (a revisioned Qt 6.5 property) sometimes failed with ".live is not available due to component versioning", and the failed type stayed cached, so MediaRoom could not load for the rest of the session. Never declare layer.live:; set it with Binding { target: x.layer; property: "live"; value: ... }. Also remember that the warm render briefly makes each pre-built page visible, so bindings keyed on visible (for example OBDMenu's dashboard spec) run once during pre-building.
Why a cache: a nav press used to instantiate the destination from scratch on the GUI thread — OBDMenu is ~3,200 lines of QML, MediaPlayer and MediaRoom ~2,000 each — which was the visible pause between tapping a bar button and the page appearing on the Orange Pi. Re-pushing an existing item costs a frame. The second reason is a leak that the cache closes: StackView only destroys items it created itself from a URL or Component; an item pushed as an instance is merely hidden and re-parented on pop. Every nav press therefore left another live, hidden copy of the page behind the stack, timers and bindings included. The main cache keeps one instance per file. The initial MainMenu is separate from a cached MainMenu used when preserving the wiki reader.
Because pages are long-lived, per-visit work goes in the attached StackView.onActivated handler rather than Component.onCompleted (which now runs once). MediaPlayer reloads playlists, the current track, the scroll position and the album-art refresh there; MediaRoom calls mainContent.syncNowPlaying(); AndroidAutoView launches or reattaches the DHU; PhoneMirrorView calls startMirror(), which only syncs the flag when the stream is already running. OBDDiagnostics already handled this with onVisibleChanged. Transitions are null, so onActivated fires synchronously inside push()/pop().
Pages declare what they need: MediaPlayer, OBDHome, OBDMenu, OBDDiagnostics, SensorHome, SensorMenu and ClockMenu use required property StackView stackView and required property ApplicationWindow mainWindow; CarMenu requires only stackView; SettingsMenu additionally requires string initialSection; MainMenu, MediaRoom, PhoneMirrorView and AndroidAutoView use plain (optional) properties. cachedPage() always passes stackView and mainWindow, so only initialSection has to come from the caller.
Programmatic navigation
navigateTo(pageName) holds a page map used by the command server (src/dev/coreactions.cpp, enabled with --profile) and the screenshot sweeper:
function navigateTo(pageName) {
var pageMap = {
"MainMenu.qml": { file: "MainMenu.qml", props: { stackView, windowWidth, windowHeight } },
"MediaRoom.qml": { file: "MediaRoom.qml", props: { stackView } },
"MediaPlayer.qml": { file: "MediaPlayer.qml", props: { stackView, mainWindow } },
"OBDHome.qml": { ... }, "OBDMenu.qml": { ... }, "OBDDiagnostics.qml": { ... },
"SettingsMenu.qml": { file: "SettingsMenu.qml", props: { stackView, mainWindow, initialSection: "displaySettings" } },
"PhoneMirrorView.qml": { ... }, "AndroidAutoView.qml": { ... },
"SensorHome.qml": { ... }, "SensorMenu.qml": { ... }, "CarMenu.qml": { ... },
"ClockMenu.qml": { file: "ClockMenu.qml", props: { stackView, mainWindow } }
} // 14 entries
...
}
Companion helpers: navigateHome() (stackView.pop(null)), navigateBack() (pops one if depth > 1), and navigateToSettings(section), which exists because SettingsMenu has a required initialSection; if Settings is already the current page it calls currentItem.navigateToCategory(section) instead of stacking a second copy. OBDMenu pushes the dashboard editor by URL ("dashboards/editor/DashboardEditor.qml"); MainMenu opens OBDMenu through openPage().
Each entry is handed to stackView.openPage(entry.file, entry.props), so the command server shares the page cache with the BottomBar and never fails on a null createObject() (cachedPage() supplies stackView and mainWindow itself). navigateToSettings(section) opens the cached SettingsMenu and then calls its navigateToCategory(), so the sweeper lands on the requested section even when the page already existed. ClockMenu is also opened by tapping the BottomBar clock.
StackView anchoring
When the bottom bar is vertical (side mode) the StackView fills from the bar's right edge to the window's right edge; when horizontal, from the top of the window to the bar's top. The BottomBar places and sizes itself with bindings: about 10 % of the window width as a side bar, 12.5 % of the window height along the bottom, within limits (see Bottom Bar → Dual Orientation).
IMU Rate Gating by Page Demand
The BerryIMU worker samples the I²C sensors at a ~200 Hz target on its own thread and emits seven signals per tick into QML at 60 Hz (EMIT_INTERVAL = 0.016 in src/managers/berryimumanager.cpp). Pushing ~440 queued signals per second into pages that do not display them pegged the QML thread (the comment in OBDParameterModel.qml records 100 % CPU while idle on the Orange Pi), so Main.qml decides from the current page whether the full-rate stream is needed and calls berryIMU.setActive(bool). Sampling and Madgwick fusion continue at full rate; only the emission into QML drops to IDLE_EMIT_INTERVAL = 0.2 (5 Hz) so the settings status stays fresh (commit 94d61be). The rule, in a Connections block on stackView:
// frontend/ImuDemand.qml, dropped into each page that shows IMU readings
App.ImuDemand { consumer: "sensorMenu"; wanted: sensorMenu.visible }
// MainMenu: only while a home card shows an IMU reading
App.ImuDemand {
consumer: "mainMenu"
wanted: mainMenu.visible && settingsManager.homeOBDParameters.some(function(id) {
return App.OBDParameterModel.sensorParameters.indexOf(id) !== -1 })
}
Each page that shows IMU readings drops in an ImuDemand (frontend/ImuDemand.qml), which calls berryIMU.setDemand(consumer, wanted); the manager runs at full rate while any consumer wants it. The consumers are CarMenu, SensorMenu, SensorHome, OBDMenu and MainMenu while a home card shows one of the IMU ids (OBDParameterModel.sensorParameters: PITCH, ROLL, HEADING, ALTITUDE, ACCEL_MAG, LATERAL_G, LONGITUDINAL_G, BARO_TEMP). Until open-backend #10 Main.qml decided from a list of page names on every StackView change.
On Android, BerryIMUManager reads the phone's own sensors through QtSensors, and setActive(false) stops them (accelerometer, compass, pressure) rather than lowering the rate: Android treats a data rate as a hint and kept delivering about 25 readings a second. setActive(true) restarts them if the IMU is enabled; connected stays true while they are paused. Not yet measured on a phone (TODO/sensor-driven-ui-cost.md).
Import Path and Singletons
On desktop src/main.cpp adds frontend/ to the QML import path and loads frontend/Main.qml from disk. Android loads the same tree from packaged assets:
// src/main.cpp — desktop: frontend/ next to the binary or one level up (build/ -> repo root)
engine.addImportPath(frontendDir.absolutePath());
engine.load(QUrl::fromLocalFile(frontendDir.absoluteFilePath("Main.qml")));
// src/main.cpp — Android: frontend/ is bundled as assets
engine.addImportPath(QStringLiteral("assets:/frontend"));
engine.load(QUrl(QStringLiteral("assets:/frontend/Main.qml")));
CMakeLists.txt sets QT_QML_IMPORT_PATH to the same directory for tooling (qmllint) and, on Android, QT_QML_ROOT_PATH so the frontend is packed into the APK. Because the directory is on the import path, any QML file in it is importable by type name, and frontend/qmldir declares the singletons:
singleton Style 1.0 Style.qml
singleton Spacing 1.0 Spacing.qml
singleton EnvironmentTheme 1.0 EnvironmentTheme.qml
singleton OBDParameterModel 1.0 OBDParameterModel.qml
singleton WidgetCatalog 1.0 dashboards/WidgetCatalog.qml
singleton MusicDevicePreference 1.0 MusicDevicePreference.qml
Files at the top level use import "." as App and reach the singletons as App.Style, App.Spacing, App.EnvironmentTheme, App.OBDParameterModel; settings/ files use import ".." as App, and dashboards/widgets/ use import "../.." as App. Icon paths are built from Style.assetBase (a readonly property url resolved in frontend/) so subdirectory callers never guess ../ depth. EnvironmentTheme now exposes a single fixed active object (the "Standard" look); the earlier Spacecraft / Deep Sea presets were removed and every EnvironmentTheme.active.X flag such as cornerBrackets, terminalScanlines or cardGlassEffect is permanently false.
Qt Quick Controls "Basic" Style
src/main.cpp forces the Qt Quick Controls style to Basic with QQuickStyle::setStyle("Basic") before the engine is created. The comment gives the reason — "required for full Slider customization": the app replaces the background, handle and contentItem of its sliders, toggles and popups, which platform styles do not allow. Five files also import QtQuick.Controls.Basic explicitly (MainMenu.qml, MediaRoom.qml, BottomBar.qml, AndroidAutoView.qml, PhoneMirrorView.qml) so their customised controls resolve to the Basic implementations regardless of the platform default.
Accessing Backend Managers (Context Properties)
Backend managers are registered as QML context properties before Main.qml loads — in src/main.cpp via ctx->setContextProperty(...). They appear as globals in every QML file:
// Call slots / invokables
mediaManager.next_track()
settingsManager.save_theme_setting(newTheme)
// Read properties
text: settingsManager.deviceName
// React to signals
Connections {
target: mediaManager
function onAlbumColorsExtracted(themeJson) { ... }
}
Context properties used by the frontend are listed below. Platform and build options decide which implementation sits behind a name (mobile builds register stub classes under the same names).
| Context Property | Class | Purpose |
|---|---|---|
settingsManager | SettingsManager | All persistent settings, theme, layout |
diagnosticsManager | DiagnosticsManager | Read / export logs from the head unit (Settings > Device > Diagnostics) |
clock | Clock | time (property) / timeChanged(str): time string in the saved format |
networkManager | NetworkManager | Connectivity, update checks |
volumeController | VolumeController | Quadratic volume curve + dispatch to every output |
mediaManager | MediaManager | Local playback, library, playlists, metadata, album art |
audioAnalyzer | AudioAnalyzer | FFT waveform visualization |
obdManager | OBDManager | OBD-II live data and DTCs |
esp32VolumeManager | ESP32VolumeManager | Wireless rotary encoder |
berryIMU | BerryIMUManager | Accelerometer / gyro / magnetometer / barometer |
gestureSensor | GestureManager | PAJ7620U2 gesture sensor |
androidAutoManager | AndroidAutoManager | Android Auto via DHU (the dhuframe image provider is registered alongside it) |
phoneMirrorManager | PhoneMirrorManager | Built-in scrcpy-protocol client |
obdAdapterFinder | OBDAdapterFinder | Adapter discovery and pairing for OBDAdapterSheet |
assetStore | AssetStore | Resolves bundled 3D assets to filesystem paths when needed |
wikiViewerAvailable, wikiHomeUrl | bool / URL | In-app wiki availability and entry URL |
units | Units | Display rules in one place (src/util/units.{h,cpp}): formatDuration(ms) ("m:ss", "h:mm:ss" from an hour, bad input "0:00"; the media pages, the music device scene and MediaManager's library total), formatSeconds(s), cardinal(deg) (8-point compass), cToF, mToFt, mphToKph |
hasQuick3D, hasQuick3DAssetUtils | bool | Optional QML modules installed (QtQuick3D; its AssetUtils plus QtQuick.Timeline), from their qmldir in the import paths without loading them (src/util/qmlmodules.cpp). SensorHome's 3D card follows hasQuick3D; MusicDevicePreference skips its load test when hasQuick3DAssetUtils is false |
dashboardManager | DashboardManager | Dashboard presets + user JSON dashboards |
screenAutoScale | real | Screen-detected scale factor (see GUI Scale) |
isAndroid | bool | true only in the Android build |
perfMonitor | PerfMonitor | Only with --profile in an OCTAVE_DEV_TOOLS build |
One custom QML type is registered rather than exposed as a property: qmlRegisterType<EmbeddedDhuItem>("OCTAVE.AndroidAuto", 1, 0, "EmbeddedDhuItem") in src/main.cpp (see AndroidAutoView). Phone mirroring no longer registers a type: the QML VideoOutput hands its videoSink to phoneMirrorManager.videoSink.
Guarding Optional Globals with typeof
Context properties that exist in one build but not another are referenced through typeof x !== "undefined" so a missing global is a falsy branch rather than a ReferenceError. The pattern also appears as target: (typeof foo !== "undefined" && foo) ? foo : null on Connections blocks. Where it is used today:
isAndroid— BottomBar hides the Android Auto and Phone Mirror buttons whenisAndroidis true;SettingsMenu.buildHubModel()dropsdesktopOnlypages (Accessories) on Android.screenAutoScale— Main.qml only applies it if defined.berryIMU— Main.qml's gating, the Accessories settings page (its values reach QML throughparamStore).paramStore— the live value of every PID and IMU reading, one notifying key per id:OBDParameterModelmirrors it.dashboardManager— OBDMenu builds its registry with the built-in "grid" sentinel alone when the manager is absent; dashboards/editor/DashboardEditor.qml guards its save/load and draft-rescue paths the same way.esp32VolumeManager,gestureSensor,diagnosticsManager,phoneMirrorManager,mediaManager,settingsManager— settings pages, hub widgets, the media views, the two dashboard media widgets and Main.qml's album-art handlers.typeof obj.method === "function"— feature probes on items and managers:currentItem.navigateToHub,mainWindow.openNowPlayingStudio,roomLoader.item.previewTrackTransition, and in the OBD settings pageobdManager.connect_to_adapter/set_target_address/platform_hint.
Everything else assumes the manager exists and binds to it directly.
GUI Scale
All sizes in the UI are derived from two multipliers held in the Spacing singleton:
property real globalScale: 1.0 // user preference (settingsManager.uiScale)
property real autoScaleFactor: 1.0 // screen detection (screenAutoScale)
property real effectiveScale: autoScaleFactor * globalScale
property real baseScale: 0.6 * globalScale // for percentage-based properties
function dp(size) { return Math.round(size * effectiveScale) }
function dpMin(size, floor) { return Math.max(floor, Math.round(size * effectiveScale)) }
screenAutoScale is computed in src/main.cpp as the shorter edge of primaryScreen().availableSize() / 720, clamped to [0.4, 3.0], so the factor is orientation-independent (Android reports the natural portrait size even when locked landscape). Main.qml applies it as Spacing.autoScaleFactor and then sets Spacing.globalScale = settingsManager.uiScale. Percentage-based tokens (overallMargin, overallText, settingsNavWidth, …) are recomputed from applicationWidth/Height whenever Spacing.updateDimensions(w, h) is called.
assets:/ with their functions un-callable (properties still work). Many pages and components therefore define a local function dp(size) { return Math.round(size * (App.Spacing.effectiveScale || 1.0)) } with identical math to Spacing.dp, and Style.assetBase is a property rather than a function for the same reason. The comment block in MediaPlayer.qml documents the bug; the two-line wrapper is copied verbatim into ~40 files.
Portrait Layouts (phones)
The app is designed landscape-first (a dash screen), but the browser demo is also opened on phones held upright. Spacing.isPortrait (applicationHeight > applicationWidth) is the one switch; pages built as side-by-side panels read it and stack instead. Landscape is untouched by every rule below.
| Page | Portrait layout |
|---|---|
| MainMenu.qml (Home) | Media panel on top (48% of the height, album art shrinks to fit), OBD cards below; HomeOBDView.qml goes to 2 columns from 2 cards up |
| MediaRoom.qml | The landscape pieces stacked: the volume row at the top and the seek bar at the bottom (both centred, 90% wide; the seek bar's times sit under the slider), the visualizer just above the seek bar, and between them the album art (a square a little under the volume row, no side cards, AlbumArtCarousel.artFill 0.92), the title / artist under it, and the transport row (shuffle moved in left of Previous, balanced by an empty slot after Next) midway between the title and the visualizer. The art's height is what the title and keys leave, so it shrinks instead of pushing them into the bottom bar at any screenAutoScale. The blurred album-art background stays. The media-room row heights in Spacing come from the shorter window side |
| MediaPlayer.qml | Title and Artist columns only (60 / 40); Spacing.mediaPlayerRowHeight is taken from the shorter window edge, so rows keep their landscape height |
| SettingsSidebarLayout.qml (Grid) | settings/SettingsTilePage.qml's sections mode: one scrolling list, each section's caption above its tiles, two across |
| OBDDiagnostics.qml | The four read buttons 2×2, Clear DTCs across the width, the terminal below taking the rest |
| SensorMenu.qml | 2×3 instead of 3×2 |
Check a change at phone sizes with the screenshot sweep: OCTAVE_BIN=$PWD/build/octave python -m dev.screenshots.auto --xvfb --size 430x932 --out $PWD/dev/shots_portrait (and 360x780 for the narrowest phones), with a temporary HOME / XDG_CONFIG_HOME.
Font Loading
src/util/fontcatalog.{h,cpp} (context property fontCatalog, built in main.cpp before the engine loads) registers every *.ttf / *.otf in frontend/assets/fonts with QFontDatabase::addApplicationFontFromData (the bytes, so assets: paths work on Android) and publishes fonts as {name, family} maps: the display name is the filename with the extension stripped and -/_ → space (JetBrainsMono-Regular.ttf becomes "JetBrainsMono Regular"), the family is what Qt reports. Main.qml's Component.onCompleted passes them to Style.registerFonts(names, familyMap), sets Style.symbolFontFamily from fontCatalog.symbolFamily and applies settingsManager.fontSetting, all synchronously, so the saved font shows on the first frame. Two selectable text fonts ship: GoogleSans-Regular and JetBrainsMono-Regular. The OCTAVE Symbols font lives in symbols/ and is not offered in the picker. Until open-backend #13 Main.qml scanned the folder with a FolderListModel and loaded each file with a dynamic FontLoader, and a font still loading when the setting was applied was refused (the UI stayed in the system font); tests/cpp/tst_fontcatalog.cpp covers the new path.
Album Art Capture Pipeline
When the "Album Art Capture" theme is active, colors extracted from the current album art become the UI palette. The extraction is triggered in C++ (nowPlaying follows the live source's art, see Theme System); Main.qml only applies the result and sets the transition length:
// settingsManager.albumArtColorsChanged — the palette is a theme object
function onAlbumArtColorsChanged() {
var colors = settingsManager.albumArtColors
if (Object.keys(colors).length > 0) App.Style.updateAlbumArtTheme(colors)
}
// nowPlaying.durationChanged — "transition = song length", the live source's duration
function onDurationChanged() {
if (settingsManager.songLengthTransition && nowPlaying.durationMs > 0)
App.Style.colorTransitionMs = nowPlaying.durationMs
}
At startup, if the saved theme is Album Art Capture and settingsManager.albumArtColors is non-empty, Main.qml primes Style.updateAlbumArtTheme() before setThemeInstant() so the placeholder palette never paints. Manual theme switches are always instant (Style.setThemeInstant); the transition duration only applies to album-art-driven shifts. The palette arrives as an object (albumArtColors is a map; the file keeps a JSON string), so QML parses nothing.
Shift Light Overlays
Main.qml draws the window-wide part of the RPM shift light (the per-card light on the home screen lives in HomeOBDView). It holds no shift-light state: which flag is active, its resolved fields and the flash phase all come from shiftLightManager, which is fed by obdManager.rpmChanged in C++.
- Full-screen flash —
globalFullScreenFlashis aRectanglefillingmainContaineratz: 999999,enabled: falseso input passes through. Visible whileshiftLightManager.litand the active flag hasfullScreenFlash; colour and opacity fromactiveFlag.color/activeFlag.fullScreenFlashOpacity(the manager has already applied the globalrpm_fullscreen_flash_opacityfallback). - Aura flash —
globalAuraFlashpaints four edge gradients (top/bottom/left/right) whose depth isactiveFlag.auraSpread× the window size atactiveFlag.auraFlashOpacity, shown whilelitand the flag hasauraFlash. - Flash — nothing to do here:
littoggles at the flag'sflashSpeedinside the manager, so the overlays and the home-card lamp flash in step.
mainContainer as the last children so it renders above the StackView and BottomBar without a separate window or Popup, which keeps it working identically on every platform.
Settings Manager Connections
A Connections { target: settingsManager } block keeps the window in sync with settings changed elsewhere:
onThemeSettingChanged—Style.setThemeInstant(newTheme)(Album Art Capture extraction isnowPlaying's, in C++).onFontSettingChanged—Style.setFont(...).onSongLengthTransitionChanged— when the feature is turned off, restoreStyle.colorTransitionMs = settingsManager.colorTransitionMs.onBottomBarOrientationChanged— updatesisVerticalLayoutandbottomBar.isVertical, re-assertsstackView.z = 1; bottomBar.z = 0, and if the current page is Settings, restartsnavReloadTimer(150 ms), whichstackView.replace()s the Settings page with a fresh instance on the same section (pendingSection).onScreenWidthChanged/onScreenHeightChanged— resize the window (desktop only).onAlbumArtColorsChanged— see above.
Custom themes are hot-reloaded from a hand-made settingsManager.customThemesChanged.connect(...): the whole Style.customThemes object is rebuilt and assigned in one shot (assigning an empty object first would briefly make the active custom theme fall back to SolarizedLight), then Style.customThemesUpdated() is emitted so pickers refresh. Main.qml also exposes updateDeviceName(), updateFont() and updateStartupVolume() for pages that want to write through the window (the window size has one writer, its resize handlers).
Drop Shadows
The separation between the StackView and the BottomBar is a 12-px gradient strip, not a DropShadow effect — one Rectangle per orientation at z: 2:
Rectangle { // horizontal mode
id: bottomBarShadow; visible: !isVerticalLayout
anchors.bottom: bottomBar.top; height: 12
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: "#30000000" }
}
}
Rectangle { // side mode
id: sideBarShadow; visible: isVerticalLayout
anchors.left: bottomBar.right; width: 12
gradient: Gradient { orientation: Gradient.Horizontal; ... }
}
Graphical Effects: Qt5Compat vs MultiEffect
Two effect modules coexist:
Qt5Compat.GraphicalEffectssuppliesDropShadow,OpacityMaskandColorOverlay. It is imported by AlbumArtCarousel, CoverflowCard, ThemedIcon, MainMenu, MediaRoom, MediaPlayer, OBDMenu, BottomBar and several settings components.DropShadowappears in MediaRoom.qml (6), OBDMenu.qml (6), settings/AccessoriesSettingsPage.qml (6), BottomBar.qml (2), settings/SettingsChips.qml (2), and once each in CoverflowCard.qml, MainMenu.qml and settings/SettingsToggle.qml — 25 in total. The album-art shadow is the only one gated by a user setting (showAlbumArtShadow→CoverflowCard.showShadow), and it is rendered as alayer.effectwhoselayer.liveis switched off while any carousel animation runs. MediaPlayer.qml and ThemedIcon.qml deliberately avoid the effect: the song rows fake depth with an 8 % opacityRectangle("using Rectangle instead of effects"), and the icon shadow is a second offsetColorOverlay, "not a blur, so there is no per-frame cost".QtQuick.Effects(MultiEffect) is used for the two blurred album-art backgrounds, in MainMenu.qml and MediaRoom.qml, withblurMax: 64andblur: min(1, settingsManager.backgroundBlurRadius / 64).
QtQuick.Effects.MultiEffect. Other masks, tints and shadows still use Qt5Compat.GraphicalEffects, so both modules must be available at runtime.
Shaders
There is one custom shader. frontend/shaders/dissolve_squares.frag is a GLSL 4.40 fragment shader used by AlbumArtCarousel's "Dissolve" transition: it hashes each cell of a gridSize×gridSize grid over the texture coordinates to a pseudo-random threshold and reveals the new art with smoothstep(r - smoothness, r + smoothness, progress), so squares pop in at random as progress runs 0→1. The uniform block carries qt_Matrix, qt_Opacity, progress, gridSize, smoothness; the sampler is the hidden Image holding the incoming art. One ShaderEffect with a single draw call replaced an earlier 400-item grid of Rectangles.
Qt 6's RHI does not compile GLSL at runtime; ShaderEffect.fragmentShader must point at a pre-compiled .qsb bundle. The committed dissolve_squares.frag.qsb contains six variants — SPIR-V 1.0, GLSL ES 100, GLSL 120, GLSL 150, HLSL 5.0 and MSL 1.2 (checked with qsb -d) — which is what the standard invocation produces:
qsb --glsl "100 es,120,150" --hlsl 50 --msl 12 \
-o frontend/shaders/dissolve_squares.frag.qsb frontend/shaders/dissolve_squares.frag
qsb ships with the Qt Shader Tools module (qtshadertools). Regenerate the bundle whenever the .frag changes and commit both files; there is no build step that does it.
How the bundle is located differs by platform, in AlbumArtCarousel.qml:
fragmentShader: Qt.platform.os === "android"
? "qrc:/shaders/dissolve_squares.frag.qsb"
: "shaders/dissolve_squares.frag.qsb"
On desktop the path resolves relative to the QML file on disk. On Android the QML is served from assets:/, which the RHI ShaderEffect rejects (only qrc:/ or local files), so CMakeLists.txt compiles the .qsb into the executable with qt_add_resources(octave "shaders" PREFIX "/shaders" ...). frontend/shaders/shaders.qrc lists the same file for tooling.
objectName Conventions for Dev Scripting
objectNames exist for two consumers: Main.qml and BottomBar navigation logic (which pages are "OBD" or "sensor" pages, whether Settings is current, IMU gating), and the developer command server (src/dev/uidriver.cpp, enabled with --profile) that drives the running app for the screenshot sweeper (python -m dev.screenshots) and MCP tools. The server walks window.contentItem() recursively — so items inside Popups under Overlay are found too — and matches on objectName (preferring a visible match); its find_item, list_items, get_prop, set_prop, call, click and drag actions all key on it, "root" resolves to the ApplicationWindow, and get_current_page reads stackView.currentItem.objectName through the "stackView" name (navigate, open_settings, go_back and go_home call the navigateTo / navigateToSettings / navigateBack / navigateHome functions above). Conventions in use:
| Convention | Examples |
|---|---|
| Page root = camelCase file name | mainMenu, mediaRoom, mediaPlayer, obdHome, obdMenu, obdDiagnostics, sensorHome, sensorMenu, carMenu, settingsMenu; phoneMirrorView is declared on PhoneMirrorView itself; AndroidAutoView and ClockMenu have none |
| Prefix families for hub buttons | BottomBar tests name.indexOf("obd") === 0 and name.indexOf("sensor") === 0 || name === "carMenu" to decide "pop back to the hub" vs "jump to the remembered subpage" |
| Scriptable controls on a page | OBDMenu: dashboardsButton, newDashboardButton, resumeDraftButton, chooserScroll, dashCard_<id>, dashCard_<id>_<action>; CarMenu: imuZero, imuReset, jeepControl_<key>, jeepOpenAll, jeepSteering, centerWheels, jeepSpinWheels, jeepWheelSpeed |
| 3D scene nodes | jeepVehicle, driverHinge, passengerHinge, glassHinge, gateHinge, hoodHinge, driverWheel / passengerWheel (steering knuckles), driverFrontSpin / passengerFrontSpin / driverRearSpin / passengerRearSpin (axle spin), pitmanArm, dragLink, tieRodDriver / tieRodCenter / tieRodPassenger, lamps (fogLight, brakeLight, reverseLight, thirdBrakeLight, headlightGlow/headlightBeam), each JeepLamp's children <name>Glow / <name>Beam |
| Scriptable functions instead of names | OBDMenu.openDashboardChooser() / closeDashboardChooser() are called through the server's call action; settings cards are looked up by objectName set from their tile title (SettingsMenu.findChildByObjectName) |
Commit 19d8904 added imuZero / imuReset precisely so a script can level the vehicle view before a capture; commit 2fef514 added mainMenu for the IMU gating check.
Keyboard Handling
Main.qml installs no Keys handlers and no Shortcuts; the head unit is touch-first. Keyboard input reaches only focused text fields (the playlist-name fields in MediaPlayer, which call forceActiveFocus() when opened) and Popups with closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside (the album-art popup in MediaRoom; the playlist dropdown, song/playlist action menus and new-playlist dialog in MediaPlayer; the dashboard chooser and RPM settings popups in OBDMenu; the volume popups in BottomBar; SettingsChips and the OBD settings adapter popup), so Escape closes them. Developer keyboard controls (driving the simulated car of src/sim/) are part of the dev runtime in src/dev/ (octave --dev in an OCTAVE_DEV_TOOLS build), not in frontend/.
Initialization Sequence
Component.onCompleted in Main.qml runs, in order:
- Capture system font —
systemDefaultFont = font.family, mirrored toStyle.systemDefaultFont. - Load custom themes —
Style.customThemes = settingsManager.customThemeData(every saved theme, name → object, in one assignment); this must precedesetThemeso a saved custom theme resolves. - Prime album-art palette if the saved theme is Album Art Capture, then apply the theme with
Style.setThemeInstant(). - Load dimensions — width/height from settings (skipped on Android).
- Initialize Spacing —
Spacing.updateDimensions(width, height), thenautoScaleFactor = screenAutoScale, thenglobalScale = settingsManager.uiScale. - Color transition speed —
Style.colorTransitionMs = settingsManager.colorTransitionMs. - Connect media manager —
mediaManager.connect_settings_manager(settingsManager). - Orientation —
isVerticalLayoutfrombottomBarOrientation. - Restore window state — fullscreen / maximized.
- Custom-theme hot reload — connect
customThemesChanged.
Fonts are registered in C++ before the QML loads (fontCatalog) and applied in Main.qml's Component.onCompleted; the IMU gating handler runs from its own Component.onCompleted.
obdManager.rpmChanged and settingsManager.customThemesChanged are connected with .connect() in JavaScript rather than through Connections blocks; the comments give signal-delivery reliability as the reason.
Design Decisions
The reasons below are taken from code comments, commit messages and CLAUDE.md, not inferred.
One Main.qml, one StackView, pages created on demand
A single window with a StackView keeps the persistent BottomBar, the shift-light overlays and the Now Playing Studio in one scene graph, so overlays render above every page without secondary windows (the placement comment on globalFullScreenFlash calls this out as the cross-platform requirement). Pages are instantiated with Qt.createComponent() only when navigated to, so an install without the optional QtQuick3D module still boots — SensorHome greys its card out (from the hasQuick3D context property, a qmldir check in main.cpp that loads nothing) rather than the app failing at load (CMake comment, commit 0c29e64). All six StackView transitions are null ("Disable transitions for better performance"). Most navigation pages use the shared cache.
Context properties, not a QML module of registered types
Managers are plain QObjects registered by name in src/main.cpp; QML never imports a backend module, and the context-property name plus the slots/signals/properties behind it are the contract. Mobile builds swap in stub classes under the same names, so the same frontend/ tree loads everywhere. Registration happens before engine.load() because bindings evaluate on first load (the dashboardManager comment in src/main.cpp). The only registered type is EmbeddedDhuItem in the OCTAVE.AndroidAuto 1.0 module, because the DHU window has to be reparented into a real scene item; everything else is a context property.
"Basic" controls style
Set in src/main.cpp with the comment that Basic allows full Slider customisation. Platform styles (Fusion, Material, native) reserve parts of their delegates; Basic lets every background / handle / contentItem be replaced, which the themed sliders, toggles and popups depend on.
Explicit image decoding and warm caches
The media views control decoding rather than leaving it to the scene graph: MediaRoom pre-decodes prev/next album art in hidden 1×1 Images with cache: true on the next event-loop tick after a track change, using one batched get_neighbor_album_arts() call that keeps backend calls off the animation frame; CoverflowCard decodes at 2 × artSize and turns off smooth while animating; ThemedIcon supersamples SVGs at 2× "so edges stay crisp when the tile scales up"; MediaPlayer's list uses plain Images with sourceSize 1.5× inside a ListView with reuseItems. Heavy work such as FFT analysis is deferred until the carousel's settled signal so it never competes with animation frames.
Quick3D for the car view
The vehicle-attitude view needs a real 3D scene: IMU orientation arrives as a quaternion and is applied directly to a Node (no gimbal lock), the rigged Jeep has hinged doors, hood, tailgate, steering linkage and spinning wheels, and reflections come from an HDR light probe. QtQuick3D provides that with GLB loading through RuntimeLoader. It is deliberately optional: CMake links it when found, CI packages the assetimporters plugin so the glb parser ships, and the QML degrades (greyed card, "Requires the QtQuick3D module") when it is absent. See CarMenu and the Jeep rig.
objectName everywhere a script might look
OCTAVE is daily-driven in a vehicle, so verification is done against the real running app: the command server locates items by objectName, clicks them and grabs the window, and the README/wiki screenshots are produced that way. Naming the page roots also gives the BottomBar and the IMU gating a stable key that does not depend on object identity or file URLs.
Local dp() wrappers and property-only singletons
An Android bug makes singleton functions un-callable when QML is served from assets:/. Rather than fork the QML for mobile, many files carry a local dp() wrapper, and anything a singleton must offer to Android is a property (Spacing.effectiveScale, Style.assetBase).
typeof guards only for optional globals
QML checks for a manager's existence only for genuinely optional or platform-specific globals (isAndroid, hardware managers, dashboardManager, diagnosticsManager); everything else binds directly, so a missing symbol surfaces as a loud error instead of silently disabling a feature.