Theme System
Style.qml colour themes and fonts, Album Art Capture (k-means palettes from cover art), colour transitions, the single-preset EnvironmentTheme, Spacing.qml layout scaling and the GUI scale slider, and the ThemedIcon SVG tinting pipeline
Overview
OCTAVE's look is driven by three pragma Singleton QML objects declared in frontend/qmldir and imported everywhere as App.* (import "." as App from frontend/, import ".." as App from frontend/settings/). Files that live in frontend/ itself can also reference them unqualified (SettingsMenu.qml writes Style.assetBase).
+----------------------+ +------------------------+ +----------------------+
| Style.qml | | EnvironmentTheme.qml | | Spacing.qml |
| | | | | |
| COLOURS + FONTS | | STRUCTURE (one preset) | | SIZES |
| - 17 built-in themes | | - cardRadius, chip / | | - dp(), dpMin() |
| - custom themes | | button / switch radii| | - scaledSize() |
| - Album Art Capture | | - feature flags, all | | - scalePx() |
| - ~60 colour tokens, | | false in "Standard" | | - percentage tokens |
| each with Behavior | | | | for every screen |
| - fontFamily, fonts | | | | - globalScale / auto |
| - assetBase URL | | | | scale factors |
+----------------------+ +------------------------+ +----------------------+
| | |
+--------------------------+----------------------------+
|
Wired at startup by frontend/Main.qml from settingsManager
(themeSetting, fontSetting, uiScale, colorTransitionMs, ...)
The backend owns persistence only. SettingsManager stores themeSetting, fontSetting, uiScale, colorTransitionMs, songLengthTransition, customThemes and albumArtColors; MediaManager produces Album Art Capture palettes. Everything else is QML.
Style.qml — Theme Resolution
The palettes are data. The built-in table, its order (themeNames) and the picker's light / dark grouping live in frontend/themes.json, read by the C++ ThemeCatalog (src/managers/themecatalog.{h,cpp}, context property and service object themeCatalog). Style.qml's themes, lightThemeNames and darkThemeNames bind to it. ThemeCatalog also publishes activeThemeName and activeTheme, the palette in force resolved by the same three rules below from settingsManager.themeSetting / customThemeData / albumArtColors, so another frontend draws the user's theme by subscribing to one property. Animation, transition timing and the token properties stay in QML.
frontend/Style.qml holds the built-in palettes (from themeCatalog), the custom-theme map, the live album-art palette, the font registry, and one reactive colour property per UI token. The activeTheme object is what every token binds to, resolved in priority order:
property var activeTheme: {
// 1. Album Art Capture with a live palette
if (currentTheme === "Album Art Capture" && albumArtTheme)
return albumArtTheme
// 2. Custom themes saved by the user
if (customThemes[currentTheme])
return customThemes[currentTheme]
// 3. Built-in themes, with a hard fallback
return themes[currentTheme] || themes["SolarizedLight"]
}
Two setters exist. setTheme(name) validates the name against themes and customThemes, records lastStaticTheme (unless the name is Album Art Capture, in which case it arms _firstAlbumArtUpdate) and assigns currentTheme, letting the Behaviors animate. setThemeInstant(name) does the same but zeroes colorTransitionMs for the duration of the switch and restores it with Qt.callLater. Main.qml uses setThemeInstant for the startup load and for every manual theme change; the Display page's chips call setTheme after persisting through settingsManager.save_theme_setting().
Other public members: getAllThemeNames() (built-ins minus "Album Art Capture", plus custom names), getLightThemeNames() / getDarkThemeNames(), customThemes (assigned whole from settingsManager.customThemeData), updateAlbumArtTheme(themeObject), isAlbumArtCaptureActive, lastStaticTheme, and the read-only URL assetBase (Qt.resolvedUrl("assets/")).
Built-in Themes
Seventeen palettes are defined in frontend/themes.json (themeCatalog.themes): eight light, eight dark, plus the "Album Art Capture" placeholder. The picker order below is hand-tuned in lightThemeNames / darkThemeNames ("so the swatch palette flows nicely"). The default on a fresh install is CosmicVoyager (themeSetting default in src/managers/settingsmanager.cpp).
Light
| Theme | base | baseAlt | accent | text.primary |
|---|---|---|---|---|
| SolarizedLight | #FDF6E3 | #EEE8D5 | #268BD2 | #073642 |
| ArcticChill | #F0F4F8 | #DCE5EE | #0EA5E9 | #0F2942 |
| Parchment | #F5EBD8 | #E8DBC1 | #8B4513 | #2A1A0A |
| Sunshine | #FFFCF2 | #FFF6D8 | #F97316 | #3A1E03 |
| MintGarden | #F0F9F4 | #DDF0E2 | #16A34A | #0F2A1A |
| RoseQuartz | #FBF2F4 | #F5E0E5 | #BE185D | #3A0E1F |
| LavenderMist | #F5F3FA | #E9E4F2 | #6D28D9 | #1F0E3D |
| SlateCloud | #F1F5F9 | #E2E8F0 | #475569 | #0F172A |
Dark
| Theme | base | baseAlt | accent | text.primary |
|---|---|---|---|---|
| AmberConsole | #000000 | #0A0A0A | #FFB000 | #FFE89C |
| NeonMatrix | #0C0C0C | #161616 | #00FF41 | #CCFFCC |
| ForestEmerald | #0A1A14 | #122620 | #10B981 | #D1FAE5 |
| CosmicVoyager (default) | #0A0E17 | #151C29 | #00BFFF | #E0F2FF |
| TechnoRetro | #1E1014 | #2A1B22 | #FF00AA | #F5E0FF |
| QuantumNebula | #0F0F1A | #17172A | #9C27B0 | #F0E6FF |
| CrimsonEmber | #1A0808 | #241010 | #DC2626 | #FFE4E4 |
| AutumnCascade | #2D1E12 | #3E2918 | #E67E22 | #FDF2E9 |
Placeholder
| Theme | base | baseAlt | accent | Note |
|---|---|---|---|---|
| Album Art Capture | #1a1a2e | #16213e | #e94560 | Only used when no palette has been extracted yet; excluded from getAllThemeNames() and surfaced as a toggle, not a chip. |
Themes differ in how much per-button variety they carry. The themes added with the swatch redesign (commit 7ac3c31: ArcticChill, Parchment, Sunshine, MintGarden, RoseQuartz, LavenderMist, SlateCloud, AmberConsole, plus CrimsonEmber and ForestEmerald) give each bottom-bar destination its own hue, while SolarizedLight and NeonMatrix tint every button with the accent.
Theme Object Structure
Every theme — built-in, custom, or extracted — is a plain object with the same core top-level keys. MediaManager::_generate_theme_from_colors() emits exactly this shape as JSON, which is why an extracted palette can be dropped straight into activeTheme:
{
"base": "#0C0C0C", // backgroundColor, sidebarColor, rowBackgroundColor
"baseAlt": "#161616", // headerBackgroundColor, contentColor, bottom bar gradient
"accent": "#00FF41", // accent, clockTextColor, metadataColor, active toggles
"text": { "primary": "#CCFFCC", "secondary": "#88CC88" },
"states": { "hover": "#202020", "paused": "#1C1C1C", "playing": "#282828" },
"sliders": { "volume": "#00FF41", "media": "#88CC88", "settings": "#00FF41" },
"bottombar": {
"previous", "play", "pause", "next", "volume", "shuffle", "toggleShade",
"homeButton", "obdButton", "mediaButton", "sensorButton",
"settingsButton", "androidAutoButton", "phoneMirrorButton"
},
"mediaroom": { "previous", "play", "pause", "next", "left", "right", "shuffle", "toggleShade" },
"mainmenu": { "mediaContainer": "#005500" },
"obd": { "boxBackground": "#161616", "barColor": "#33FF66" }
// optional: "obd.labelColor", "obd.valueColor", "status": { connected, disconnected, ... }, "albumArt"
}
No shipped theme defines status, obd.labelColor or obd.valueColor; the tokens that read them fall back to fixed colours or to the text colours. bottombar.sensorButton was added in commit 6735de5 ("sensor button now affected by theme colors") to every built-in theme and to the palette generator; Style.bottomBarSensorButton falls back to homeButton so older persisted album-art JSON without the key still tints the icon.
Theme Picker and Swatch Chips
The picker lives in Display → Appearance (frontend/settings/DisplaySettingsPage.qml) and is built from three SettingsChips rows (see Settings UI → Reusable Components):
- Light and Dark rows use
App.Style.getLightThemeNames()/getDarkThemeNames(). Each chip is a square swatch whosechipPalettes[name]is the array[accent, baseAlt, base]drawn as three horizontal bands, top to bottom, bybuildThemeChipPalettes(). - Album Art Theme (visible only when the user has saved one) lists
settingsManager.customThemes; a theme that carries analbumArtURL renders as an image chip (chipImages), otherwise it falls back to a palette swatch. These chips aredeletableItems: press-and-hold opens the confirmation popup, and deleting the active theme first switches toCosmicVoyager.
The highlighted chip is activeThemeName: when Album Art Capture is on it shows App.Style.lastStaticTheme (the theme "borrowed" behind the live palette, defaulting to CosmicVoyager). Picking any chip calls save_theme_setting(name) + App.Style.setTheme(name), which implicitly turns Album Art Capture off.
Below the chips a SettingSubcategory titled "Album Art Capture" carries the on/off switch (togglable: true, checked when themeSetting === "Album Art Capture"). Turning it on stores the current theme in lastStaticTheme, saves the setting, calls setTheme and immediately asks mediaManager.extract_colors_from_album_art(get_current_file()); turning it off restores lastStaticTheme. Its body holds the "Transition Speed" slider and the "Match Song Length" toggle described under Colour Transitions.
Custom (Saved Album Art) Themes
Custom themes are created from the Media Room: while Album Art Capture is active, the album-art popup's "Save as Theme" button (frontend/MediaRoom.qml) calls
settingsManager.save_custom_theme(themeName, App.Style.albumArtTheme, mediaRoom.currentAlbumArt)
using the album name (or the track name, or "Custom Theme") as the key. SettingsManager::save_custom_theme() copies the cover image into the app data directory, add its file:// URL as the theme's albumArt key, and store the object under customThemes in settingsConfigure.json. The customThemeData property holds every saved theme (name → object, as on disk) and delete_custom_theme(name) removes one (and its copied art).
Main.qml loads them before applying the saved theme so that a custom theme selected last session passes the setTheme guard:
App.Style.customThemes = settingsManager.customThemeData
On settingsManager.customThemesChanged the same assignment runs again, in one shot — the comment explains that clearing and refilling would briefly make activeTheme fall back to SolarizedLight if a custom theme is active. The Display page listens to App.Style.customThemesUpdated to refresh chip options, palettes, images and the deletable list.
Album Art Capture
"Album Art Capture" turns the current cover into a full theme. The work is split between the C++ managers (image analysis, palette generation, persistence) and QML (applying and animating the result).
Triggers
- One rule, in
NowPlaying: whilethemeSettingis "Album Art Capture", the colours follow the art of whatever is playing, the phone's while it has the media controls, else the local track's. It extracts whennowPlaying.artUrlchanges (a new track, a source switch, the phone's art arriving after its metadata, the restored track at startup; the same art keeps the same colours, so nothing runs) and when the theme is chosen. Local art goes throughextract_colors_from_album_art(file), the phone's throughextract_colors_from_image(artUrl). - On demand: Media Room's "Save as Theme" popup calls
nowPlaying.extractArtColors(). - Until open-backend #5 the rule lived in four places (MediaManager's own hooks and a startup timer,
Main.qmltwice, the Display-page toggle), so a theme switch extracted twice and the toggle ignored the phone.
Extraction Algorithm
- Load the cover with
QImage, convert toFormat_RGBA8888, scale to 50×50 ignoring aspect ratio — 2500 RGB samples. - Run k-means with
k = 5, at most 10 iterations, initial centroids picked by shuffling with a localstd::mt19937fixed at seed 42, stopping early when no centroid moves by more than 1 per channel. - Sort the five centroids by vibrancy (HSV saturation × value), most vibrant first.
- Average the luminance (
0.299R + 0.587G + 0.114B) of the first 1000 pixels; below 0.5 the image is "dark" and the generated theme uses dark bases. _generate_theme_from_colors(colors, isDark)derives the accents (primary: saturation ×1.4, brightness ×1.2; secondary ×1.3/×1.15; tertiary ×1.25/×1.1; quaternary ×1.2/×1.05), dimmer nav variants (×0.7 and ×0.75 brightness), picks white or near-black text with WCAG relative-luminance contrast checks, and assembles the ten-key theme object above. Distinct accents are assigned per bottom-bar button (OBD = quaternary, media = secondary, settings = tertiary, sensor = primary) so the bar stays legible.
Threading and Delivery
MediaManager runs the extraction off the GUI thread with QtConcurrent::run and a QFutureWatcher. A job counter (m_colorJob) makes a newer request supersede an older one, so a fast skip through several tracks applies only the last palette, and a repeat request for the same file within 2 s is ignored (the restore-playback path and the 1 s startup timer ask for the same track).
When the manager has a settings manager attached (Main.qml calls mediaManager.connect_settings_manager(settingsManager) at startup) the result goes to settingsManager.set_album_art_colors(json), which persists it as the albumArtColors string and emits albumArtColorsChanged. The albumArtColors property is that palette as an object, so Main.qml's handler passes it straight to App.Style.updateAlbumArtTheme(colors) and src/main.cpp reads the LED accent from it, with no JSON parsing in either. The albumColorsExtracted(json) signal on mediaManager is only emitted when no settings manager is connected (the tests); nothing in the app listens to it.
Applying in Style.qml
function updateAlbumArtTheme(newTheme) {
albumArtTheme = newTheme
if (currentTheme === "Album Art Capture") {
// First paint after entering Album Art mode is instant — only
// subsequent song changes fade with the configured speed.
var instantFirst = _firstAlbumArtUpdate
var savedDuration = colorTransitionMs
if (instantFirst) colorTransitionMs = 0
// Force activeTheme to re-evaluate by toggling currentTheme
let savedTheme = currentTheme
currentTheme = ""
currentTheme = savedTheme
if (instantFirst) {
_firstAlbumArtUpdate = false
Qt.callLater(function() { colorTransitionMs = savedDuration })
}
albumArtThemeUpdated()
}
}
The implementation also toggles currentTheme to force theme re-evaluation. This is an explicit compatibility step in this code, not a general rule that assigning a new object to a QML var fails to notify bindings. At startup Main.qml primes albumArtTheme from the persisted settingsManager.albumArtColors before calling setThemeInstant("Album Art Capture"), so the placeholder palette never paints.
Colour Token Reference
Every property below is declared as property color X: activeTheme... followed by Behavior on X { ColorAnimation { duration: colorTransitionMs } }. This is the complete list.
Surfaces
| Token | Source |
|---|---|
backgroundColor, rowBackgroundColor, sidebarColor, bottomBarGradientStart | base |
headerBackgroundColor, contentColor, bottomBarGradientEnd | baseAlt |
hoverColor | states.hover |
hoverPausedColor, pausedHighlightColor | states.paused |
hoverPlayingColor | states.playing |
playingHighlightColor | states.playing |
bottomBarToggleShade | bottombar.toggleShade |
mediaRoomToggleShade | mediaroom.toggleShade |
mediaContentArea | mainmenu.mediaContainer |
obdBoxBackground | obd.boxBackground |
Text and Accent
| Token | Source |
|---|---|
primaryTextColor, headerTextColor | text.primary |
secondaryTextColor | text.secondary |
accent, clockTextColor, metadataColor, bottomBarActiveToggleButton | accent |
obdLabelColor | obd.labelColor, else secondaryTextColor |
obdValueColor | obd.valueColor, else primaryTextColor |
Bottom Bar and Media Room Icons
| Token | Source |
|---|---|
bottomBarPreviousButton, bottomBarPlayButton, bottomBarPauseButton, bottomBarNextButton, bottomBarVolumeButton, bottomBarShuffleButton | bottombar.previous / play / pause / next / volume / shuffle |
bottomBarHomeButton, bottomBarOBDButton, bottomBarMediaButton, bottomBarSettingsButton, bottomBarAndroidAutoButton, bottomBarPhoneMirrorButton | bottombar.homeButton / obdButton / mediaButton / settingsButton / androidAutoButton / phoneMirrorButton |
bottomBarSensorButton | bottombar.sensorButton || bottombar.homeButton |
mediaRoomPreviousButton, mediaRoomPlayButton, mediaRoomPauseButton, mediaRoomNextButton, mediaRoomLeftButton, mediaRoomRightButton | mediaroom.previous / play / pause / next / left / right |
mediaRoomToggleButton | mediaroom.shuffle |
Sliders and Bars
| Token | Source |
|---|---|
volumeSliderColor, mediaRoomSeekColor | sliders.volume |
mediaRoomSlider | sliders.media |
settingsSliderColor | sliders.settings |
obdBarColor | obd.barColor |
Status Colours
Read from an optional status object; every shipped theme uses the fallback.
| Token | Fallback |
|---|---|
statusConnected | #44AA44 |
statusDisconnected | #AA4444 |
statusError | #F44336 |
statusWarning | #FF9800 |
statusInfo | #2196F3 |
statusSuccess | #4CAF50 |
statusDanger | #e74c3c |
Colour Transitions
colorTransitionMs (default 1000, persisted as settingsManager.colorTransitionMs and loaded by Main.qml at startup) is the duration of every token's ColorAnimation. In practice it only governs album-art driven shifts on song change: startup and manual theme picks go through setThemeInstant(), and the first paint after entering Album Art Capture is forced instant by _firstAlbumArtUpdate.
- Transition Speed slider (Display → Appearance → Album Art Capture): 0–2000 ms in steps of 50, "0 = instant". Moves are debounced 100 ms before
save_color_transition_ms()andApp.Style.colorTransitionMsare updated. - Match Song Length toggle (
songLengthTransition): Main.qml'sConnectionsonmediaManager.durationChangedsetscolorTransitionMs = durationMs, so the palette drifts across the whole track. Turning it off restores the saved value fromsettingsManager.colorTransitionMs.
Behavior on is an animation object; with roughly sixty colour tokens a palette change runs sixty ColorAnimations in parallel. A transition speed of 0 disables the animated interpolation; theme changes still update bindings and render the resulting colors.
Font System
App.Style.fontFamily is the default text family used throughout the UI (the root ApplicationWindow also binds font.family to it):
property string fontFamily: currentFont === "System Default"
? systemDefaultFont
: (fontFamilyMap[currentFont] || systemDefaultFont)
Discovery happens in C++ at startup: FontCatalog (src/util/fontcatalog.cpp, context property fontCatalog) registers every *.ttf / *.otf in frontend/assets/fonts/ with QFontDatabase and lists them as {name, family}; Main.qml hands that to App.Style.registerFonts(fontNames, familyMap) in its Component.onCompleted. Display names are derived from filenames — the extension is stripped and - / _ become spaces — and mapped to the family name Qt reports, because the two frequently differ. The shipped files are GoogleSans-Regular.ttf and JetBrainsMono-Regular.ttf, so the picker offers "System Default", "GoogleSans Regular" and "JetBrainsMono Regular". Main.qml then applies settingsManager.fontSetting with App.Style.setFont(), which accepts only names present in availableFonts (or "System Default") and otherwise leaves currentFont unchanged; registration is synchronous, so the saved font is always there by then.
The Font picker (Display → Appearance → Font) is a SettingsChips row with chipFonts: each chip renders the first word of its own name in that font (fontSizeMode: Text.Fit, minimum dp(8)) so the user sees the typeface before choosing it. Selecting a chip calls save_font_setting() and App.Style.setFont(); the fontChanged signal fires for components that need to react beyond the property binding.
SettingsManager defaults fontSetting to "GoogleSans Regular", the display name Main.qml derives from GoogleSans-Regular.ttf, and migrate the legacy value "Google Sans" to it when loading settingsConfigure.json (the old name failed the availableFonts check and left the UI in the system font).
EnvironmentTheme.qml — Structural Tokens
frontend/EnvironmentTheme.qml exposes one read-only object, active, holding the radii, widths and feature flags that the settings component library reads as App.EnvironmentTheme.active.*. There is no longer a picker or a map of presets. The header comment records the history: OCTAVE once shipped selectable "environments" (Spacecraft, Deep Sea) with their own canvas effect layers; they were never finished, the picker was removed, and only the "Standard" values remain. The singleton was kept so the roughly forty existing bindings keep working unchanged, and the comment says to restore a map plus a notifiable current-environment property if presets are ever revived (the old ones are in git history: af2e804 added them, 484bc76 removed the last picker files).
| Group | Key | Value |
|---|---|---|
| Hub card | hubCardRadius | 12 |
| Card | cardRadius / cornerBrackets / accentBorder / accentBorderOpacity | 12 / false / false / 0 |
| Divider | dividerAnimated / dividerGradient / dividerHeight | false / false / 1 |
| Button | buttonRadius / buttonGlowBorder / buttonSolidFill | 6 / false / true |
| Slider | sliderTickMarks / sliderHandleGlow / sliderHandleRadius | false / false / -1 (use Spacing.overallSliderRadius) |
| Switch (compact toggle) | switchRadius / switchKnobRadius | 8 / 6 |
| Toggle (full toggle) | toggleTrackRadius / toggleHandleRadius / toggleRectShadow | 10 / 8 / false |
| Labels | labelUppercase / labelLetterSpacing | false / 0 |
| Value display | valueDisplayBrackets / valueAccentColor | false / false |
| Sidebar / nav | sidebarGrid / navItemRadius | false / 8 |
| Chips | chipRadius / chipAccentBorder | 8 / false |
| Section header | sectionHeaderLines | false |
| Text field | textFieldRadius / textFieldCornerMarks | 4 / false |
| Dropdown / checkbox | dropdownRadius / checkboxRadius | 4 / 4 |
| Segmented control | segmentGlowBar | false |
| Radio | radioSquare | false |
| Terminal | terminalRadius / terminalAccentBorder / terminalCornerBrackets / terminalScanlines / terminalHeaderAccent / terminalAccentScroll | 4 / false / false / false / false / false |
| Enhanced effects | scanlineOverlay / pulsingElements / contentHudLines / dividerDiamondRotate | all false |
| Deep Sea effects | sidebarBubbles / contentSonar / dividerSonarPing / cardGlassEffect | all false |
The components still contain the code paths those flags enable (corner brackets, HUD lines, glow borders, tick marks, the sonar and pulse variants of SettingsDivider, and so on), all inert. Commit 6735de5 tightened the Standard values — switch/toggle/chip radii went from "pill" (-1) to fixed 8/6/10/8/8 — which is the source of the slightly squared switches in today's UI.
Spacing.qml — Layout Scaling
frontend/Spacing.qml turns the window size into pixel values. Main.qml calls App.Spacing.updateDimensions(width, height) at startup and on every resize; applicationWidth / applicationHeight feed dozens of derived int properties.
Scale Factor Chain
property real globalScale: 1.0 // user preference (settingsManager.uiScale)
property real autoScaleFactor: 1.0 // screen detection (screenAutoScale context property)
property real effectiveScale: autoScaleFactor * globalScale // for dp()
property real baseScale: 0.6 * globalScale // for scaledSize()
property real textScale: 1.0 // multiplies overallText only
property real controlScale: 1.0 // declared, unused
Two families of value exist. Hard-coded pixel sizes written for a 720p design (dp(140), dp(8)) use effectiveScale, which includes screen auto-detection. Percentage tokens (0.125 of the window height for the bottom bar) already follow the screen size, so they only receive the original 0.6 design factor and the user's multiplier through baseScale.
Token Groups
| Group | Examples |
|---|---|
| Core | overallMargin (1 %), overallText (4.5 % × textScale), overallSpacing (5 %), overallSliderWidth/Height (6 %), overallSliderRadius (3 %), overallRadius (1 %) — all of the shorter window edge |
| Section spacing | sectionSpacing (5 %), rowSpacing (1.5 %), columnSpacing (2 %) |
| Bottom bar | bottomBarHeight, per-button widths derived from normalButtonSize = 0.085 (see Bottom Bar → Sizing Tokens) |
| Media room / media player | Album art 60 %, play button 30 %, header/row heights, column widths, text sizes |
| Settings | settingsNavWidth (30 % of width), settingsButtonHeight (20 % of height), settingsContentMargin (5 %), formElementHeight (10 % of height), settingsHubGridSpacing (2 %), settingsHubCardHeight (22 %), settingsCarouselCardWidth/Height (78 % / 82 %, not passed through scaledSize) |
| Main menu | OBD text/data sizes, song/artist/time text, play and nav button sizes, slider height and handle |
Scaling Functions
function dp(size) { return Math.round(size * effectiveScale) }
function dpMin(size, floor) { return Math.max(floor, Math.round(size * effectiveScale)) }
function scaledSize(baseSize){ return Math.round(baseSize * baseScale) }
function scalePx(size) { return Math.round(size * baseScale * (Math.min(applicationWidth, applicationHeight) / 1000)) }
function updateDimensions(width, height) { applicationWidth = width; applicationHeight = height; dimensionsChanged() }
dp()converts a 720p reference pixel value.dpMin()adds a floor, used for radii that must never go sub-pixel (dpMin(8, 2)).scaledSize()is applied to the percentage tokens inside Spacing.qml itself.scalePx()scales relative to the shorter edge per 1000 px.dimensionsChangedis avarproperty holding an empty function, not a QML signal; callers can replace it with a callback.
App.Spacing.dp(). Each declares function dp(size) { return Math.round(size * (App.Spacing.effectiveScale || 1.0)) } locally because singleton functions misbehave on Qt for Android (see the comment in Style.qml next to assetBase). Only singleton properties are read across the boundary.
GUI Scale Slider and Auto-Scale
Auto-scale. src/main.cpp computes screenAutoScale before QML loads and expose it as a context property; Main.qml copies it into App.Spacing.autoScaleFactor. It divides the shorter edge of primaryScreen()->availableSize() by 720 and clamps to [0.4, 3.0]; the comment explains that Android reports the natural portrait size even when the app is locked landscape, and using the height alone produced a roughly 3× factor there.
User scale. settingsManager.uiScale (default 1.0) is loaded into App.Spacing.globalScale at startup. SettingsManager migrates a stored value of exactly 0.6 (the old design factor that used to be persisted) to 1.0. The slider lives in Display → Layout → "UI Scaling":
SettingsSlider {
from: 0.5; to: 2.0; stepSize: 0.05
value: App.Spacing.globalScale
// Commit on release only. Updating globalScale mid-drag re-lays out
// the slider itself (track + handle scale with globalScale), so the
// handle jumps relative to the finger.
onPressedChanged: {
if (!pressed && settingsManager) {
settingsManager.save_ui_scale(value)
App.Spacing.globalScale = value
}
}
}
Commit fe567f3 ("fix gui scale slider in frontend") replaced a 100 ms debounce with this commit-on-release rule; the ValueDisplay next to it still shows the live percentage as feedback. The hub layouts show the same number as "Scale: 100%".
ThemedIcon and the SVG Assets
frontend/ThemedIcon.qml packages the hidden-Image-plus-ColorOverlay pattern that the bottom bar hand-rolls at every button:
| Property | Default | Purpose |
|---|---|---|
source (url) | "" | A single-fill SVG; relative paths resolve against the importing file, so callers use App.Style.assetBase + "cat_display.svg" |
color | #FFFFFF | Tint applied to every opaque pixel (tiles pass App.Style.accent) |
shadow, shadowColor, shadowOffsetX/Y | true, 40 % black, 1, 2 | A second ColorOverlay of the same image, offset — "cheap, not a blur, no per-frame cost" |
fillRatio | 1.0 | Fraction of the item used by the image (the bottom bar's buttons use 0.7) |
The image is rendered at sourceSize = 2× its display size with mipmap: true, which keeps SVG edges crisp when the tile scales up. Both overlays are hidden until Image.Ready.
The assets it draws were added in commit 93d0fc9 as 22 flat white SVGs (<g fill="#FFFFFF">) in frontend/assets/: category icons cat_display.svg, cat_about.svg and a tile_*.svg set for the settings tiles. The tile set has since been removed: settings tiles show each setting's live state instead of an icon (see Settings UI). The bottom-bar icons (home_button, obd_button, media_button, sensor_button, settings_button, android_auto_button, phone_mirror_button, transport and mute icons) follow the same single-fill rule; a second colour in an asset would collapse into the tint.
How Components Consume the Theme
// Colour: binds to an animated token
color: App.Style.backgroundColor
color: Qt.rgba(App.Style.accent.r, App.Style.accent.g, App.Style.accent.b, 0.2) // translucent accent
// Structure: reads a fixed EnvironmentTheme value, scaled
radius: dpMin(App.EnvironmentTheme.active.cardRadius, 2)
visible: App.EnvironmentTheme.active.cornerBrackets // always false today
// Typography
font.family: App.Style.fontFamily
font.pixelSize: App.Spacing.overallText * 1.5
// Icons
App.ThemedIcon { source: App.Style.assetBase + "cat_display.svg"; color: App.Style.accent }
Translucent surfaces are built from tokens at runtime (primaryTextColor at 5–7 % for cards, accent at 18–25 % for selection) rather than being separate theme keys, which keeps the theme object small and lets Album Art Capture palettes work without extra fields.
Customization Guide
Creating a New Built-in Theme
- Add an entry to
themesin frontend/themes.json with all ten keys, includingbottombar.sensorButton, and its name tothemeNames. - Append its name to
lightThemeNamesordarkThemeNamesthere — the picker rows are built from those arrays, not fromObject.keys(themes).tst_themecatalogchecks every palette has the ten keys and every listed name exists. - Update Settings Reference if the default changes.
Adding a New Font
- Drop a
.ttfor.otffile into frontend/assets/fonts/. FontCatalogregisters it at next launch; the display name is the filename minus extension with-/_turned into spaces.
The Symbol Font (icon glyphs)
Qt on Android has no working symbol-font fallback: its hardcoded fallback list names Droid fonts that modern Android no longer ships, so a glyph missing from the UI font renders as a box (v0.9.2 showed boxes for the ✗ status marks and the ⌨ keyboard button on a Galaxy S22). Every Text that draws a symbol rather than words therefore sets font.family: App.Style.symbolFont. That resolves to the family of frontend/assets/fonts/symbols/OCTAVESymbols-Regular.ttf, a 10 KB OFL subset of Noto Sans Symbols 2 (plus the music notes from Noto Sans Symbols) renamed "OCTAVE Symbols" as the OFL's Reserved Font Name clause requires; FontCatalog registers it too (fontCatalog.symbolFamily), Main.qml sets App.Style.symbolFontFamily from it, and it lives in a subfolder so it is not offered as a UI font. If it fails to load, symbolFont falls back to fontFamily. When a new symbol appears in the QML, add its code point to tools/fonts/build_symbol_font.py and rerun it (needs fonttools); glyphs currently covered: × ⌖ ⌨ ⌫ ▲ ▶ ▼ ◉ ◬ ● ☀ ♩ ♪ ♫ ⚠ ✓ ✗ ⬇ ⭮ 🎧 ↔ ↕. Two glyphs were swapped for ones the Noto fonts have: the settings widgets' refresh arrow is ⭮ (U+2B6E, not ↻) and Sensor Menu's roll icon is ↔ (not ⇄).
Adding a Structural Token
- Add the key to the
activeobject in frontend/EnvironmentTheme.qml. - Read it as
App.EnvironmentTheme.active.key; there is no runtime switching, so a change is a code change.
Adding a Themed Icon
- Draw a 600×600 SVG with a single
<g fill="#FFFFFF">group, centred in the viewBox, and save it in frontend/assets/. - Reference it as
App.Style.assetBase + "name.svg"from any directory depth.
Design Decisions
| Decision | Rationale (from code comments and commit history) |
|---|---|
One reactive property color per token, each with a Behavior |
Components bind to a colour, never to activeTheme.x.y directly, so a palette swap animates everywhere at once and Album Art Capture can change the palette on every track without any component knowing. |
setThemeInstant() for startup and manual picks |
Comment: "the configured colorTransitionMs only applies to album-art-driven color shifts on song change". A user choosing a theme expects it immediately; a fade is only pleasant when it is ambient. |
| First album-art paint is instant | Comment on _firstAlbumArtUpdate: entering the mode (or starting with it on) should snap to the current song's colours; only real song changes honour the transition speed. |
Persist the last extracted palette (albumArtColors) |
Main.qml primes albumArtTheme from it before switching, "so the placeholder Album Art colors never paint". SettingsManager::set_album_art_colors() writes the JSON to settingsConfigure.json on every extraction. |
| Album Art Capture is a toggle, not a chip | Comment on getAllThemeNames(): it is "surfaced as a toggle under the chips, not as a selectable theme"; lastStaticTheme exists so that turning it off restores the prior choice. |
| Deterministic k-means (seed 42, 50×50 downscale, k = 5, 10 iterations) | The clustering is seeded with a fixed std::mt19937, so repeated extraction of the same cover gives the same palette; the 50×50 downscale caps the work at 2500 samples, and it runs on a worker with job supersession so skipping tracks applies only the last result. |
| Per-button accents in generated palettes | The generator deliberately assigns different extracted centroids to OBD, media, settings and sensor buttons "for variety", boosting saturation and brightness so the icons stay visible against the derived base. |
Delivery through settingsManager.albumArtColors rather than a signal argument |
Main.qml's onAlbumArtColorsChanged handler reads the persisted albumArtColors property instead of the signal argument, so the palette it applies is always the stored one. The albumColorsExtracted signal remains as a fallback. |
| Rebuild the custom-theme map atomically | Assigning a new object avoids briefly clearing customThemes, which "would make activeTheme fall back to SolarizedLight if a custom theme is currently active". |
assetBase is a property, not a function |
Comment in Style.qml: singleton functions are unreliable on Android ("same Qt bug the local dp() wrappers work around"), and a URL resolved in frontend/ spares callers from guessing how many ../ they need. |
| EnvironmentTheme kept as a one-object singleton | Comment: the presets "were never finished and the picker was removed", but the singleton stays "so the ~40 existing EnvironmentTheme.active.X bindings keep working unchanged". |
Two scale factors (effectiveScale vs baseScale) |
Comments in Spacing.qml: hard-coded 720p pixel values need screen auto-detection, percentage tokens already adapt to the screen and "only need the original 0.6 design factor times the user's preference multiplier". |
| Auto-scale from the shorter screen edge (C++) | main.cpp comment: Android reports the natural portrait size even when locked landscape; using height alone gave a ~3× factor. |
| Scale slider commits on release | Updating globalScale mid-drag re-lays out the slider under the finger; deferring keeps the geometry steady (commit fe567f3). |
Flat white SVGs plus ColorOverlay |
One asset serves every theme; ThemedIcon's offset second overlay reproduces the drop-shadow weight the old Unicode glyph tiles had, without a blur effect. |