Files
simplex-chat/apps/multiplatform/spec/services/theme.md
T
Evgeny @ SimpleX ChatandEvgeny Poberezkin 7810fc4752 core, ui: terminal chat layout for LLM and service bot chats (#7811)
* plan: UI improvements to improve LLM usage via SimpleX

* core, ui: types for chat layout

* core, ui: settings for alternative terminal-like chat layout for managing LLMs or other service bots

* plan: message priority

* fixes

* query plan

* update plans

* remove silent messages plan

* rename migrations

* implement layout

* amend

* fix

* fix

* fix

* improve

* time on the right, sender name in blue

* no avatar in direct chats

* better previews

* update, fixes

* update

* time gap

* changes

* ui: terminal layout line heights, gaps and time placement

* ui: simplify terminal meta layout, correct its docs and tests

* ui: right-to-left emoji edge in terminal rows, time placement tests

* android, desktop: raise Kotlin compiler memory to 4 GB

* ui: terminal captions as follow-up text, classic event separators

* ui: terminal media edge and overlay time, plan decisions

* ui: simplify terminal row separation, correct doc line references

* ui: keep the media time inside terminal rows, restore caption conditions

* ui: terminal heading capitals at large font scales, idle live message time

* ui: rename the classic voice-with-text flag, correct the meta copy docs

* ui: smaller terminal layout diff, sender gap and meta names

* ui: terminal row as tall as the media time

* docs: remove the chat layout section from the bots readme

* docs: shorten the terminal layout section of the chat view

* core: address settings API as on master, /auto_accept reads the stored layout

* ui: smaller terminal headings

* ui: classic sender rows and avatar rule as on master

* ui: classic reply quote text colour as on master

* ios: keep the chat layout setting with other UI settings

* ui: go-to button at the end of terminal rows, no gutter in direct chats

* ui: forwarded arrow on the header line of terminal rows, above the time

* ui: forwarded arrow centred above the time line of terminal rows

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-10-25 18:03:11 +00:00

20 KiB

Theme Engine

Table of Contents

  1. Overview
  2. ThemeManager
  3. Default Themes
  4. Theme Types
  5. Color System
  6. SimpleXTheme Composable
  7. Platform Theme
  8. YAML Import/Export
  9. 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:

  1. Determines the non-system theme name (resolving SYSTEM to light or dark based on systemInDarkThemeCurrently).
  2. Selects the base theme palette (LIGHT/DARK/SIMPLEX/BLACK).
  3. Finds the matching ThemeOverrides from appSettingsTheme based on wallpaper type and theme name.
  4. Selects the perUserTheme for the current light/dark mode.
  5. Resolves wallpaper preset colors if applicable.
  6. Merges all color layers via toColors(), toAppColors(), and toAppWallpaper().

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:

  1. Gets or creates ThemeOverrides for the current base theme.
  2. Calls withUpdatedColor() to update the specific ThemeColor.
  3. Updates currentThemeIds mapping.
  4. 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

Theme.kt:

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)

Theme.kt L54:

@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)

Theme.kt L107:

@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)

Theme.kt L184:

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)

Theme.kt L225:

@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 runtime AppWallpaper.
  • withFilledWallpaperBase64(): Embeds the image as base64 for export.
  • importFromString(): Saves a base64 image to disk and returns a copy with imageFile set.
  • from(type, background, tint): Factory from WallpaperType.

ThemeOverrides (line 305)

Theme.kt L305:

@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)

Theme.kt L476:

@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, null without 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, null when empty.

ThemeModeOverride (line 494)

Theme.kt L494:

@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

Theme.kt line 773:

@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit)

The root theme composable that wraps all app content:

  1. System dark mode tracking (line 781): Uses snapshotFlow on isSystemInDarkTheme() to call reactOnDarkThemeChanges() when the system theme changes. This triggers ThemeManager.applyTheme(SYSTEM) if the app is in system theme mode.

  2. User theme tracking (line 790): Monitors chatModel.currentUser.value?.uiThemes and re-applies the theme when the active user changes.

  3. MaterialTheme wrapping (line 797): Provides theme.colors to MaterialTheme, plus custom CompositionLocal providers:

    • LocalContentColor -- set to MaterialTheme.colors.onBackground
    • LocalAppColors -- the AppColors instance (remembered and updated)
    • LocalAppWallpaper -- the AppWallpaper instance (remembered and updated)
    • LocalDensity -- scaled by desktopDensityScaleMultiplier and fontSizeMultiplier
  4. SimpleXThemeOverride (line 825): A variant that accepts an explicit ActiveTheme for 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

Theme.kt line 763:

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

Files.kt line 125:

fun readThemeOverrides(): List<ThemeOverrides>
  1. Reads themes.yaml from preferencesDir.
  2. Parses the YAML node tree.
  3. Extracts the themes list.
  4. Deserializes each entry as ThemeOverrides, skipping entries that fail to parse (with error logging).
  5. Calls skipDuplicates() to remove entries with the same type+base combination.

writeThemeOverrides

Files.kt line 151:

fun writeThemeOverrides(overrides: List<ThemeOverrides>): Boolean
  1. Serializes ThemesFile(themes = overrides) to YAML string.
  2. Writes to a temporary file in preferencesTmpDir.
  3. Atomically moves the temp file to themes.yaml using Files.move with REPLACE_EXISTING.
  4. 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)