* 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 * ios: terminal chat layout, chosen per app, profile and chat * ios: event time reserve of three spaces, app layout picker as Kotlin * ios: spec and plan of the terminal chat layout * ios: classic message text at 87% in dark themes, as terminal rows * ios: terminal gaps scaled by the body size, 17/16 of Kotlin values * ios: terminal quote sender at 13.5pt, thumbnail at the item end * ios: product docs of the terminal layout, plans updated for iOS * ios: chat scenes rendered to PNG by a unit test in the simulator CI * ios: snapshots rendered by the smoke test script, workflow unchanged * ios: terminal names at 13.5 x 17/16 pt, as the gaps * ios: Markdown views at the 87% message colour in dark themes * ios: terminal quote thumbnail 12pt after the quote text or the bar * ios: terminal rule-only message widened by a wider quote or header * ios: member avatars scaled with the text size, as in the chat list * ios: snapshot rows at their own height, as list cells, so text wraps * ios: member avatars back at 34pt, for the snapshot before scaling * ios: member avatars scaled with the text size, as in the chat list * ui: terminal quote thumbnail 12dp after the quote text or the bar * ios: classic group bubble width follows the scaled member avatar * ci: ios-sim README describes the snapshots rendered after the smoke test * ios: scratch, padding variants for the I7 and I8 renders, to revert * ios: revert the scratch padding variants after their renders * ui: contrast of the voice message button in terminal rows * ios: scratch, spacing variants and list margins to render, to revert * ios: revert the scratch spacing variants after their renders * ios: snapshots at a fixed date, in UTC and the en_US locale * ios: snapshots drawn as 8-bit sRGB without alpha * ci: compare iOS snapshots with the references in the repository * ci: record and check iOS snapshot references on GitHub's runner * ios: scratch, list margins on the remaining screens, to revert * ios: revert the scratch list margins after their measurement * ci: snapshot scripts executable, as the other CI scripts * ios: scratch, voice circle variants in light themes, to revert * ios: revert the scratch voice circle variants * ios: one 12pt padding at the edges and after avatars in terminal rows * ios: classic group sender name on the bubble edge * ui: one 12dp padding at the edges and after avatars in terminal rows * ios: one heading scale in both chat layouts * ios: snapshot references of the chat scenes * docs: leave the headings and classic lines of the plan to ep/classic-bubbles * ios: declare terminal layout functions inside types * ios: keep layout constants at file scope * ios: keep file-scope functions, move only extension members into types * ios: snapshot tests record row heights at first layout and the appearance layout switch * ios: appearance preview follows the chat layout setting at once * ios: snapshot test measures first-layout rows by SwiftUI and by cells * ios: chat rows measured at the chat width when they first appear * ios: snapshot test checks that chat rows keep their first height after a reload * ios, ci: references of terminal and classic chat scenes, dark message text * ios: message text at 95% in dark themes * ios, ci: references with message text at 95% in dark themes * ios: media in terminal rows end at the time's right inset * ios, ci: references with terminal media at the time's right inset --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
19 KiB
SimpleX Chat iOS -- Theme Engine
Technical specification for the theming system: ThemeManager, default themes, customization layers, wallpapers, and YAML export.
Related specs: State Management | Architecture | README Related product: Product Overview
Source: ThemeManager.swift | AppearanceSettings.swift | ThemeTypes.swift | ChatWallpaperTypes.swift | Theme.swift
Table of Contents
- Overview
- ThemeManager
- Default Themes
- Customization Layers
- Color System
- Wallpapers
- Chat Bubble Styling
- Color Scheme Mode
- YAML Export/Import
1. Overview
The theme engine provides a layered customization system where themes can be overridden at multiple levels: global defaults, per-user, and per-chat.
Theme Resolution Order (most specific wins):
┌─────────────────────┐
│ Per-chat override │ apiSetChatUIThemes(chatId:, themes:)
├─────────────────────┤
│ Per-user override │ apiSetUserUIThemes(userId:, themes:)
├─────────────────────┤
│ App settings theme │ themeOverridesDefault (UserDefaults)
├─────────────────────┤
│ Base theme │ Light / Dark / SimpleX / Black
└─────────────────────┘
The resolved theme is published as AppTheme.shared and consumed by all SwiftUI views via @EnvironmentObject.
2. ThemeManager (L15)
File: Shared/Theme/ThemeManager.swift
Static utility class that resolves the current theme by merging all customization layers.
ActiveTheme
The resolved theme output:
struct ActiveTheme: Equatable {
let name: String // Theme name (e.g., "light", "dark", "simplex", "black", "system")
let base: DefaultTheme // Base theme enum
let colors: Colors // Resolved color palette
let appColors: AppColors // App-specific colors (sent/received bubbles, etc.)
var wallpaper: AppWallpaper // Resolved wallpaper
}
Key Static Methods
| Method | Purpose | Line |
|---|---|---|
applyTheme(_:) |
Apply a theme by name, updates AppTheme.shared |
L124 |
currentColors(...) |
Resolve full theme from all layers | L64 |
defaultActiveTheme(_:) |
Get default theme override from app settings | L48 |
currentThemeOverridesForExport(...) |
Get current overrides for YAML export | L105 |
adjustWindowStyle() |
Adjust window style after theme change | L136 |
changeDarkTheme(_:) |
Change the dark theme variant | L166 |
saveAndApplyThemeColor(...) |
Save and apply a theme color override | L173 |
applyThemeColor(...) |
Apply a theme color to a binding | L186 |
saveAndApplyWallpaper(...) |
Save and apply a wallpaper change | L191 |
copyFromSameThemeOverrides(...) |
Copy overrides from matching theme | L213 |
applyWallpaper(...) |
Apply wallpaper to a binding | L256 |
saveAndApplyThemeOverrides(...) |
Save and apply full theme overrides | L267 |
resetAllThemeColors(_:) |
Reset all color overrides (CodableDefault) | L288 |
resetAllThemeColors(_:) |
Reset all color overrides (Binding) | L302 |
removeTheme(_:) |
Remove a saved theme by ID | L311 |
Theme Resolution Algorithm
currentColors() in ThemeManager.swift:
- Determine base theme from
currentThemeDefault:- If
"system": use light or dark based onsystemInDarkThemeCurrently - Dark mode maps to
systemDarkThemeDefault(Dark, SimpleX, or Black)
- If
- Get base color palette (
LightColorPalette,DarkColorPalette,SimplexColorPalette,BlackColorPalette) - Look up app settings theme override (
themeOverridesDefault) - Look up per-user theme override (
User.uiThemes) - Look up per-chat theme override (from ChatInfo)
- Look up wallpaper preset colors (if wallpaper has preset color overrides)
- Merge layers: base <- app override <- preset wallpaper colors <- per-user <- per-chat
- Return
ActiveThemewith resolved colors, app colors, and wallpaper
3. Default Themes
Four built-in themes with pre-defined color palettes:
| Theme | Enum | Key Characteristics |
|---|---|---|
| Light | DefaultTheme.LIGHT |
White background, standard colors |
| Dark | DefaultTheme.DARK |
Dark gray background, light text |
| SimpleX | DefaultTheme.SIMPLEX |
Brand purple accents, dark background |
| Black | DefaultTheme.BLACK |
Pure black background (OLED), high contrast |
DefaultTheme Enum
enum DefaultTheme {
case LIGHT
case DARK
case SIMPLEX
case BLACK
static let SYSTEM_THEME_NAME = "SYSTEM"
var themeName: String { ... }
var mode: DefaultThemeMode { ... } // .light or .dark
}
Color Palettes
Each base theme defines two palette types:
Colors: Standard UI colors (primary, background, surface, error, onBackground, onSurface)AppColors: App-specific colors (sentMessage, receivedMessage, title, primaryVariant2)
Palette instances:
LightColorPalette/LightColorPaletteAppDarkColorPalette/DarkColorPaletteAppSimplexColorPalette/SimplexColorPaletteAppBlackColorPalette/BlackColorPaletteApp
4. Customization Layers
Layer 1: App Settings Theme
Stored in themeOverridesDefault (UserDefaults). Contains [ThemeOverrides] -- an array of theme overrides, one per base theme.
ThemeOverrides
struct ThemeOverrides: Codable {
var base: DefaultTheme
var colors: ThemeColors? // Color overrides
var wallpaper: ThemeWallpaper? // Wallpaper setting
}
Layer 2: Per-User Theme
Stored on the User object (User.uiThemes: ThemeModeOverrides?), persisted in the Haskell database via apiSetUserUIThemes(userId:, themes:).
ThemeModeOverrides
struct ThemeModeOverrides: Codable {
var light: ThemeModeOverride?
var dark: ThemeModeOverride?
var chatLayout: ChatLayout?
}
chatLayout: the chat layout chosen for the profile or the chat (Chat Layout below). withChatLayout(_:) sets it and returns nil for overrides left empty. Theme writes keep it: "Reset to app theme" and "Remove image" keep only the layout (layoutOnly()).
ThemeModeOverride
struct ThemeModeOverride: Codable {
var mode: DefaultThemeMode?
var colors: ThemeColors?
var wallpaper: ThemeWallpaper?
var type: WallpaperType? // Computed from wallpaper
}
Layer 3: Per-Chat Theme
Stored per-chat via apiSetChatUIThemes(chatId:, themes:). Same ThemeModeOverrides structure.
Chat Layout
Values of ChatLayout: .classic, .terminal, .unknown(String). Terminal rows: chat view spec, section 9.
app chatLayoutDefault (DEFAULT_CHAT_LAYOUT in UserDefaults); AppSettings.uiChatLayout
profile User.uiThemes.chatLayout
chat uiThemes.chatLayout of the contact or group
chat profile Contact.chatLayout, GroupProfile.chatLayout (ChatInfo.chatLayout)
Pickers, "Chat layout":
Appearance, app destination ChatLayoutPicker: Classic, Terminal → chatLayoutDefault
Appearance, profile destination ChatLayoutPicker: "<app layout> (default)" → nil; other layout
→ themeUserDestination.withChatLayout, saveThemeToDatabase
chat theme screen ChatLayoutSection, first section of ChatWallpaperEditor (layoutSection)
"<default> (chat)" when ChatInfo.chatLayout is the default, else "<default> (default)" → nil
other layout → chat uiThemes.withChatLayout, apiSetChatUIThemes, ChatModel.updateChatInfo
app destination selected ThemeDestinationPicker: currentUser.uiThemes = uiThemes.layoutOnly()
Override Merging
Colors are merged field-by-field: if a more-specific layer defines a color, it overrides; if nil, falls through to the next layer.
5. Color System
File: SimpleXChat/Theme/ThemeTypes.swift
ThemeColors
Overridable color definitions:
struct ThemeColors: Codable {
var primary: String? // Primary brand color
var primaryVariant: String? // Primary variant
var secondary: String? // Secondary color
var secondaryVariant: String? // Secondary variant
var background: String? // Main background
var surface: String? // Card/surface background
var title: String? // Title text color
var primaryVariant2: String? // Additional variant
var sentMessage: String? // Sent message bubble
var sentQuote: String? // Sent quote background
var receivedMessage: String? // Received message bubble
var receivedQuote: String? // Received quote background
}
Colors are stored as hex strings (e.g., "#FF6600") and converted to SwiftUI Color values at resolution time.
Colors (Resolved Palette)
struct Colors {
var isLight: Bool
var primary: Color
var primaryVariant: Color
var secondary: Color
var secondaryVariant: Color
var background: Color
var surface: Color
var error: Color
var onBackground: Color
var onSurface: Color
// ... etc
}
AppColors (Resolved App-Specific)
struct AppColors {
var title: Color
var primaryVariant2: Color
var sentMessage: Color
var sentQuote: Color
var receivedMessage: Color
var receivedQuote: Color
}
6. Wallpapers
File: SimpleXChat/Theme/ChatWallpaperTypes.swift
Preset Wallpapers
6 built-in wallpaper presets:
| Preset | ID | Description |
|---|---|---|
| Cats | cats |
Cat-themed pattern |
| Flowers | flowers |
Floral pattern |
| Hearts | hearts |
Heart pattern |
| Kids | kids |
Children's pattern |
| School | school |
School/notebook pattern (default) |
| Travel | travel |
Travel-themed pattern |
Each preset defines per-theme color tints (PresetWallpaper.colors[DefaultTheme]) that subtly adjust the color palette to complement the wallpaper.
Custom Wallpapers
Users can set a custom image as wallpaper:
- Stored in
Documents/wallpapers/directory - Scaled and tiled to fill the chat background
- Custom wallpapers can be combined with color overrides
Chats in the terminal layout draw the wallpaper background colour without the image; their theme screens show presets as colours and hide the custom image tile, image scale and wallpaper tint (chat view spec, section 9).
WallpaperType
enum WallpaperType {
case preset(filename: String, scale: Float?) // Built-in wallpaper
case image(filename: String, scale: Float?) // Custom image
case empty // No wallpaper
}
AppWallpaper (Resolved)
struct AppWallpaper {
var background: Color? // Background color override
var tint: Color? // Tint/overlay color
var type: WallpaperType
}
7. Chat Bubble Styling
Configurable bubble appearance properties:
| Property | Description | Stored In |
|---|---|---|
chatItemRoundness |
Corner radius of message bubbles | App settings |
chatItemTail |
Whether bubbles have a tail/arrow | App settings |
| Avatar corner radius | Roundness of profile avatars | App settings |
These are configured in Shared/Views/UserSettings/AppearanceSettings.swift (L26).
8. Color Scheme Mode
System Follow
When theme is set to "system" (DefaultTheme.SYSTEM_THEME_NAME):
- Light mode: uses
DefaultTheme.LIGHTpalette - Dark mode: uses the configured dark theme (
systemDarkThemeDefault), which can be Dark, SimpleX, or Black
Forced Mode
Users can force light or dark mode regardless of system setting by selecting a specific theme other than "system".
Detection
var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
ChatModel.currentUser setter triggers ThemeManager.applyTheme() to handle per-user theme overrides when switching users.
9. YAML Export/Import
Theme configurations can be exported as YAML for sharing:
Export
ThemeManager.currentThemeOverridesForExport() generates a ThemeOverrides representing the current resolved theme, which is then serialized to YAML using the Yams library.
Import
YAML theme strings are parsed back into ThemeOverrides and applied as app settings theme overrides.
Key functions in AppearanceSettings.swift:
| Function | Purpose | Line |
|---|---|---|
ImportExportThemeSection |
UI section for import/export controls | L603 |
ThemeImporter |
ViewModifier for YAML file import | L640 |
decodeYAML(_:) |
Parse YAML string into Decodable type | L1150 |
encodeThemeOverrides(_:) |
Encode ThemeOverrides to YAML string | L1160 |
Toolbar Material
ToolbarMaterial controls the navigation bar appearance:
- Configurable opacity/material (translucent, opaque)
- Stored in app settings
Source Files
| File | Path | Key Definitions |
|---|---|---|
| Theme manager | Shared/Theme/ThemeManager.swift |
ThemeManager (L15), ActiveTheme (L17) |
| Theme types & colors | SimpleXChat/Theme/ThemeTypes.swift |
DefaultTheme (L13), Colors (L44), AppColors (L90), AppWallpaper (L142), ThemeColors (L230), ThemeWallpaper (L302), ThemeOverrides (L385), ThemeModeOverrides (L570), ThemeModeOverride (L591) |
| Wallpaper types | SimpleXChat/Theme/ChatWallpaperTypes.swift |
PresetWallpaper (L13), WallpaperType (L311) |
| Color utilities | SimpleXChat/Theme/Color.swift |
Hex color conversion |
| App theme observable | Shared/Theme/Theme.swift |
AppTheme (L22), CurrentColors (L14), systemInDarkThemeCurrently (L95) |
| Appearance settings UI | Shared/Views/UserSettings/AppearanceSettings.swift |
AppearanceSettings (L26), ToolbarMaterial (L319), ImportExportThemeSection (L603) |
| Theme mode editor | Shared/Views/Helpers/ThemeModeEditor.swift |
Theme mode selection UI |
| Haskell theme types | ../../src/Simplex/Chat/Types/UITheme.hs |
Server-side theme persistence |