* desktop: do not fail startup when the system theme detector cannot load JNA The detector was created in top-level vals of Resources.desktop.kt and Theme.desktop.kt. When JNA's native library cannot be mapped (for example a noexec JNA temp directory), the LinkageError ended the app before any window opened. The detector is now one shared value that is null in that case, and the theme falls back to light. * desktop: turn off the macOS theme detector's log When macOS leaves the AppleInterfaceStyle default unset (light mode), the library's isDark() throws a NullPointerException inside the library, logs it as an error with a stack trace and returns false. slf4j-simple can only set a level per logger, so the whole com.jthemedetecor.MacOSThemeDetector logger is turned off.
20 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 47): LIGHT or DARK, serialized as "light" / "dark".
4. Theme Types
AppColors (line 54)
@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 107)
@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 141)
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 184)
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 225)
@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 305)
@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 476)
@Serializable
data class ThemeModeOverrides(
val light: ThemeModeOverride? = null,
val dark: ThemeModeOverride? = null,
val chatLayout: ChatLayoutMode? = null
)
Container for per-user or per-chat overrides, with separate light and dark mode variants, and the chat layout chosen for the profile or the chat (both colour modes). Stored on the User model as uiThemes.
layoutOnly(): the overrides with the colours and wallpapers removed,nullwithout a layout. Used by "Reset to app theme", "Remove image" and the app theme destination of Appearance, so the layout is kept.ThemeModeOverrides?.withChatLayout(chatLayout): the overrides with the layout set,nullwhen empty.
ThemeModeOverride (line 494)
@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? = systemThemeDetector
?.apply { registerListener(::reactOnDarkThemeChanges) }
@Composable
actual fun isSystemInDarkTheme(): Boolean = try {
detector?.isDark ?: false
} 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.
systemThemeDetector is declared in Resources.desktop.kt as OsThemeDetector.getDetector() guarded by catch (_: LinkageError). It is null when JNA's native library cannot be loaded; the app then starts with the light theme, isInNightMode() and isSystemInDarkTheme() return false, and no listener is registered.
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) |