19 KiB
Theme Engine
Table of Contents
- Overview
- ThemeManager
- Default Themes
- Theme Types
- Color System
- SimpleXTheme Composable
- Platform Theme
- YAML Import/Export
- Source Files
Executive Summary
The SimpleX Chat theme engine implements a four-level cascade: per-chat theme overrides take precedence over per-user overrides, which take precedence over global (app-settings) overrides, which take precedence over built-in presets. Four preset themes exist (LIGHT, DARK, SIMPLEX, BLACK), each defining a Material Colors palette and custom AppColors for chat-specific elements. Themes support wallpaper customization (preset patterns or custom images) with background and tint color overrides. Theme configuration is persisted as YAML and can be imported/exported. The SimpleXTheme composable wraps MaterialTheme with additional CompositionLocal providers for app colors and wallpaper.
1. Overview
Theme resolution follows a priority chain:
per-chat override > per-user override > global override > preset default
At each level, individual color properties can be overridden. Unspecified properties fall through to the next level. The resolution is performed by ThemeManager.currentColors(), which merges all levels into a single ActiveTheme containing Material Colors, AppColors, and AppWallpaper.
Wallpapers follow the same cascade, with additional support for preset wallpapers (built-in patterns like SCHOOL) and custom images. Wallpaper presets can define their own color overrides that sit between the global override and the base preset.
2. ThemeManager
ThemeManager.kt (241 lines)
A singleton object that manages theme state, persistence, and resolution.
Core resolution
currentColors() (line 57):
fun currentColors(
themeOverridesForType: WallpaperType?,
perChatTheme: ThemeModeOverride?,
perUserTheme: ThemeModeOverrides?,
appSettingsTheme: List<ThemeOverrides>
): ActiveTheme
This is the core resolution function. It:
- Determines the non-system theme name (resolving
SYSTEMto light or dark based onsystemInDarkThemeCurrently). - Selects the base theme palette (LIGHT/DARK/SIMPLEX/BLACK).
- Finds the matching
ThemeOverridesfromappSettingsThemebased on wallpaper type and theme name. - Selects the
perUserThemefor the current light/dark mode. - Resolves wallpaper preset colors if applicable.
- Merges all color layers via
toColors(),toAppColors(), andtoAppWallpaper().
Returns ActiveTheme(name, base, colors, appColors, wallpaper).
Theme application
applyTheme() (line 105):
Persists the theme name, recalculates CurrentColors, and updates Android system bar appearance:
fun applyTheme(theme: String) {
if (appPrefs.currentTheme.get() != theme) {
appPrefs.currentTheme.set(theme)
}
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
}
changeDarkTheme() (line 115):
Sets the dark mode variant (DARK, SIMPLEX, or BLACK) and recalculates colors.
Color and wallpaper modification
saveAndApplyThemeColor() (line 120):
Persists a single color change to the global theme overrides:
- Gets or creates
ThemeOverridesfor the current base theme. - Calls
withUpdatedColor()to update the specificThemeColor. - Updates
currentThemeIdsmapping. - Recalculates
CurrentColors.
applyThemeColor() (line 132):
In-memory-only color change (for per-chat/per-user theme editing before save).
saveAndApplyWallpaper() (line 136):
Persists wallpaper type change. Finds or creates matching ThemeOverrides (matching by wallpaper type + theme name), updates the wallpaper, and persists.
Reset
resetAllThemeColors() (global) (line 204):
Resets all custom colors in the current global theme override to defaults. Preserves wallpaper but clears its background and tint overrides.
resetAllThemeColors() (per-chat/per-user) (line 213):
In-memory reset of a ThemeModeOverride state.
Import/Export
saveAndApplyThemeOverrides() (line 188):
Imports a complete ThemeOverrides (from YAML). Handles wallpaper image import (base64 to file), replaces existing override for the same type, and applies.
currentThemeOverridesForExport() (line 92):
Exports the fully resolved current theme as a ThemeOverrides with all colors filled and wallpaper image embedded as base64.
Utility
colorFromReadableHex() (line 224):
Parses #AARRGGBB hex string to Color.
toReadableHex() (line 227):
Converts Color to #AARRGGBB hex string with intelligent alpha handling.
3. Default Themes
enum class DefaultTheme {
LIGHT, DARK, SIMPLEX, BLACK;
companion object {
const val SYSTEM_THEME_NAME: String = "SYSTEM"
}
}
| Theme | mode |
Description |
|---|---|---|
LIGHT |
LIGHT | Standard light theme with white/light gray surfaces |
DARK |
DARK | Standard dark theme with dark gray surfaces |
SIMPLEX |
DARK | SimpleX branded dark theme with deep blue background and cyan accent |
BLACK |
DARK | AMOLED-optimized pure black theme |
SYSTEM is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime.
DefaultThemeMode (line 46): LIGHT or DARK, serialized as "light" / "dark".
4. Theme Types
AppColors (line 53)
@Stable
class AppColors(
title: Color,
primaryVariant2: Color,
sentMessage: Color,
sentQuote: Color,
receivedMessage: Color,
receivedQuote: Color,
)
Mutable state properties (for efficient recomposition) representing chat-specific colors not covered by Material's Colors.
AppWallpaper (line 106)
@Stable
class AppWallpaper(
background: Color? = null,
tint: Color? = null,
type: WallpaperType = WallpaperType.Empty,
)
Represents the active wallpaper state with optional background color, tint overlay, and wallpaper type (Empty, Preset, or Image).
ThemeColor (line 140)
Enum of all customizable color slots:
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT
Each has a fromColors() method to extract the current value and a text property for UI display.
ThemeColors (line 183)
Serializable data class with optional hex color strings for each slot. Uses @SerialName annotations for YAML compatibility (accent for primary, accentVariant for primaryVariant, menus for surface, etc.).
ThemeWallpaper (line 224)
@Serializable
data class ThemeWallpaper(
val preset: String? = null, // Preset wallpaper name
val scale: Float? = null, // Wallpaper scale factor
val scaleType: WallpaperScaleType? = null, // Fill/fit mode
val background: String? = null, // Background color hex
val tint: String? = null, // Tint overlay color hex
val image: String? = null, // Base64-encoded image (for import/export)
val imageFile: String? = null, // Local image file name
)
Key methods:
toAppWallpaper(): Converts to runtimeAppWallpaper.withFilledWallpaperBase64(): Embeds the image as base64 for export.importFromString(): Saves a base64 image to disk and returns a copy withimageFileset.from(type, background, tint): Factory fromWallpaperType.
ThemeOverrides (line 304)
@Serializable
data class ThemeOverrides(
val themeId: String = UUID.randomUUID().toString(),
val base: DefaultTheme,
val colors: ThemeColors = ThemeColors(),
val wallpaper: ThemeWallpaper? = null,
)
A complete theme override entry. Multiple can coexist (one per wallpaper type per base theme). The themeId is a UUID for identity tracking. Key methods:
isSame(type, themeName): Matches by wallpaper type and base theme.withUpdatedColor(name, color): Returns a copy with one color changed.toColors(),toAppColors(),toAppWallpaper(): Merge with base theme and per-user/per-chat overrides.
ThemeModeOverrides (line 475)
@Serializable
data class ThemeModeOverrides(
val light: ThemeModeOverride? = null,
val dark: ThemeModeOverride? = null,
)
Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the User model as uiThemes.
ThemeModeOverride (line 487)
@Serializable
data class ThemeModeOverride(
val mode: DefaultThemeMode = CurrentColors.value.base.mode,
val colors: ThemeColors = ThemeColors(),
val wallpaper: ThemeWallpaper? = null,
)
A single mode's override with colors and wallpaper. Has withUpdatedColor() and removeSameColors() (strips colors that match base defaults).
5. Color System
Four built-in color palettes, each consisting of a Material Colors and an AppColors:
DarkColorPalette (line 634)
| Property | Value | Notes |
|---|---|---|
primary |
SimplexBlue |
#0088ff |
surface |
#222222 |
|
sentMessage |
#18262E |
Dark blue-gray |
receivedMessage |
#262627 |
Neutral dark |
LightColorPalette (line 656)
| Property | Value | Notes |
|---|---|---|
primary |
SimplexBlue |
#0088ff |
surface |
White |
|
sentMessage |
#E9F7FF |
Light blue |
receivedMessage |
#F5F5F6 |
Near-white |
SimplexColorPalette (line 678)
| Property | Value | Notes |
|---|---|---|
primary |
#70F0F9 |
Cyan |
primaryVariant |
#1298A5 |
Dark cyan |
background |
#111528 |
Deep navy |
surface |
#121C37 |
Dark navy |
title |
#267BE5 |
Blue |
BlackColorPalette (line 701)
| Property | Value | Notes |
|---|---|---|
primary |
#0077E0 |
Darker blue |
background |
#070707 |
Near-black |
surface |
#161617 |
Very dark |
sentMessage |
#18262E |
Same as Dark |
receivedMessage |
#1B1B1B |
Very dark |
6. SimpleXTheme Composable
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit)
The root theme composable that wraps all app content:
-
System dark mode tracking (line 781): Uses
snapshotFlowonisSystemInDarkTheme()to callreactOnDarkThemeChanges()when the system theme changes. This triggersThemeManager.applyTheme(SYSTEM)if the app is in system theme mode. -
User theme tracking (line 790): Monitors
chatModel.currentUser.value?.uiThemesand re-applies the theme when the active user changes. -
MaterialTheme wrapping (line 797): Provides
theme.colorstoMaterialTheme, plus customCompositionLocalproviders:LocalContentColor-- set toMaterialTheme.colors.onBackgroundLocalAppColors-- theAppColorsinstance (remembered and updated)LocalAppWallpaper-- theAppWallpaperinstance (remembered and updated)LocalDensity-- scaled bydesktopDensityScaleMultiplierandfontSizeMultiplier
-
SimpleXThemeOverride(line 825): A variant that accepts an explicitActiveThemefor per-chat theme previews and overlays.
CompositionLocal access
val MaterialTheme.appColors: AppColors // via LocalAppColors
val MaterialTheme.wallpaper: AppWallpaper // via LocalAppWallpaper
Global state
CurrentColors (line 727): A MutableStateFlow<ActiveTheme> that holds the current resolved theme. Updated by ThemeManager.applyTheme() and collected by SimpleXTheme.
systemInDarkThemeCurrently (line 724): Tracks the current system dark mode state.
7. Platform Theme
isSystemInDarkTheme
Android (Theme.android.kt):
@Composable
actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
Delegates to the standard Compose function which reads Configuration.uiMode.
Desktop (Theme.desktop.kt):
private val detector: OsThemeDetector = OsThemeDetector.getDetector()
.apply { registerListener(::reactOnDarkThemeChanges) }
@Composable
actual fun isSystemInDarkTheme(): Boolean = try {
detector.isDark
} catch (e: Exception) {
false // Fallback for macOS exceptions
}
Uses the jSystemThemeDetector library (OsThemeDetector). The detector also registers a listener that calls reactOnDarkThemeChanges() proactively when the OS theme changes, ensuring the app responds even outside of composition.
reactOnDarkThemeChanges
fun reactOnDarkThemeChanges(isDark: Boolean) {
systemInDarkThemeCurrently = isDark
if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME
&& CurrentColors.value.colors.isLight == isDark) {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
Only triggers a theme switch if the app is in SYSTEM mode and the current light/dark state disagrees with the OS.
8. YAML Import/Export
Theme overrides are persisted in themes.yaml (located in preferencesDir).
readThemeOverrides
fun readThemeOverrides(): List<ThemeOverrides>
- Reads
themes.yamlfrompreferencesDir. - Parses the YAML node tree.
- Extracts the
themeslist. - Deserializes each entry as
ThemeOverrides, skipping entries that fail to parse (with error logging). - Calls
skipDuplicates()to remove entries with the same type+base combination.
writeThemeOverrides
fun writeThemeOverrides(overrides: List<ThemeOverrides>): Boolean
- Serializes
ThemesFile(themes = overrides)to YAML string. - Writes to a temporary file in
preferencesTmpDir. - Atomically moves the temp file to
themes.yamlusingFiles.movewithREPLACE_EXISTING. - Thread-safe via
synchronized(lock).
YAML format
themes:
- themeId: "uuid-string"
base: "LIGHT"
colors:
accent: "#ff0088ff"
background: "#ffffffff"
sentMessage: "#ffe9f7ff"
wallpaper:
preset: "school"
scale: 1.0
background: "#ccffffff"
tint: "#22000000"
Uses the kaml YAML library for serialization. ThemeColors uses @SerialName annotations for cross-platform YAML key compatibility (e.g., accent for primary, menus for surface).
9. Source Files
| File | Path | Lines | Description |
|---|---|---|---|
ThemeManager.kt |
common/src/commonMain/.../ui/theme/ThemeManager.kt |
241 | Theme resolution, persistence, color/wallpaper management |
Theme.kt |
common/src/commonMain/.../ui/theme/Theme.kt |
848 | Type definitions, color palettes, SimpleXTheme composable |
Theme.android.kt |
common/src/androidMain/.../ui/theme/Theme.android.kt |
6 | Android isSystemInDarkTheme |
Theme.desktop.kt |
common/src/desktopMain/.../ui/theme/Theme.desktop.kt |
25 | Desktop isSystemInDarkTheme via OsThemeDetector |
Files.kt |
common/src/commonMain/.../platform/Files.kt |
191 | readThemeOverrides() (L125), writeThemeOverrides() (L151) |