UI Theming
The KeenEyes.UI.Themes library adds OS-aware theme detection and automatic style application to the KeenEyes.UI system, using contracts defined in KeenEyes.UI.Themes.Abstractions.
Overview
A theme (ITheme) bundles a semantic ColorPalette with a set of methods that produce a UIStyle for each kind of widget (buttons, panels, inputs, menus, modals, scrollbars, tooltips). The ThemePlugin registers two built-in themes, "Light" and "Dark", detects the operating system's color scheme on Windows, macOS, and Linux, and can automatically switch between them when the OS preference changes. Individual UI entities opt in to automatic styling by attaching a UIThemed component; a system then keeps their UIStyle in sync with the active theme.
Quick Start
Installation
using KeenEyes.UI;
using KeenEyes.UI.Themes;
using KeenEyes.UI.Themes.Abstractions;
using var world = new World();
// UI before Themes: the theme applicator styles UIStyle components
// created by the UI system's widgets.
world.InstallPlugin(new UIPlugin());
world.InstallPlugin(new ThemePlugin());
// Access the theme context extension
var theme = world.GetExtension<IThemeContext>();
Installing ThemePlugin does the following:
- Detects a platform-appropriate
ISystemThemeProvider(Windows, macOS, or Linux; a no-op fallback otherwise) and uses it to pick the initial "Light" or "Dark" theme. - Registers the built-in
LightThemeandDarkThemeunder the names"Light"and"Dark". - Exposes a
ThemeContextas theIThemeContextworld extension. - Registers the
UIThemedcomponent. - Adds
ThemeApplicatorSystemto theSystemPhase.LateUpdatephase atorder: -20, so it runs before layout/render systems in that phase.
Core Concepts
IThemeContext
IThemeContext (implemented by ThemeContext) is the entry point for working with themes at runtime. Retrieve it with world.GetExtension<IThemeContext>().
var theme = world.GetExtension<IThemeContext>();
// Inspect the active theme
var isDark = theme.CurrentTheme.BaseTheme == SystemTheme.Dark;
// Follow the OS preference automatically
theme.FollowSystemTheme = true;
// Or switch manually by name (also sets FollowSystemTheme to false)
theme.SetTheme("Dark");
// Register a custom theme (can reuse "Light"/"Dark" to override the built-ins)
theme.RegisterTheme("HighContrast", new MyHighContrastTheme());
// React to changes
world.Subscribe<ThemeChangedEvent>(e =>
Console.WriteLine($"Theme changed to {e.NewTheme.Name}"));
Key members:
| Member | Description |
|---|---|
CurrentTheme |
The active ITheme. |
SystemTheme |
The OS's current preference (SystemTheme.Unknown/Light/Dark/HighContrast). |
FollowSystemTheme |
When true, theme switches automatically follow OS changes. Setting a theme manually via SetTheme sets this back to false. |
SetTheme(ITheme) / SetTheme(string) |
Activates a theme directly, or by registered name (returns false if the name is unknown). |
RegisterTheme(string, ITheme) |
Registers a theme under a name; built-in "Light"/"Dark" names can be overridden. |
GetTheme(string) |
Looks up a registered theme, or null. |
RegisteredThemes |
All registered theme names. |
OnThemeChanged |
Action<ThemeChangedEvent> raised on theme changes (also broadcast on the world via world.Send). |
ITheme and ColorPalette
ITheme exposes a Name, a BaseTheme (SystemTheme.Light or SystemTheme.Dark, used to pick correct contrast), a Colors palette, and one style-producing method per UIComponentType: GetButtonStyle, GetPanelStyle, GetInputStyle, GetMenuStyle, GetMenuItemStyle, GetModalStyle, GetScrollbarTrackStyle, GetScrollbarThumbStyle, and GetTooltipStyle. The interactive variants (button, input, menu item, scrollbar thumb) accept a UIInteractionState so the returned UIStyle can react to hover/press/focus.
ColorPalette is a set of semantic Vector4 (RGBA, 0-1) colors rather than raw hex values, so themes stay legible regardless of light/dark base: Background, Surface, SurfaceElevated, Primary, PrimaryVariant, Secondary, Accent, TextPrimary, TextSecondary, TextDisabled, TextOnPrimary, Success, Warning, Error, Info, Border, BorderFocused, Divider, HoverOverlay, PressedOverlay, and DisabledOverlay.
The built-in LightTheme and DarkTheme (in KeenEyes.UI.Themes.Themes) show the expected pattern for a custom theme - a ColorPalette plus per-component style logic:
public UIStyle GetButtonStyle(UIInteractionState state)
{
var baseColor = Colors.Primary;
if (state.HasFlag(UIInteractionState.Pressed))
{
baseColor = Colors.PrimaryVariant;
}
else if (state.HasFlag(UIInteractionState.Hovered))
{
baseColor = BlendColor(Colors.Primary, Colors.HoverOverlay);
}
return new UIStyle
{
BackgroundColor = baseColor,
BorderColor = Vector4.Zero,
BorderWidth = 0,
CornerRadius = 4,
Padding = new UIEdges(12, 8, 12, 8)
};
}
To ship a custom theme, implement ITheme and register it with theme.RegisterTheme("MyTheme", new MyTheme()).
UIThemed and ThemeApplicatorSystem
UIThemed is the marker component that opts a UI entity into automatic styling. It carries a UIComponentType that tells ThemeApplicatorSystem which ITheme method to call. Use the static factories (UIThemed.Button, UIThemed.Panel, UIThemed.Input, UIThemed.Menu, UIThemed.MenuItem, UIThemed.Modal, or UIThemed.For(UIComponentType) for the remaining types) when building an entity:
using System.Numerics;
using KeenEyes.UI.Abstractions;
using KeenEyes.UI.Themes;
var button = world.Spawn()
.With(new UIElement { Visible = true, RaycastTarget = true })
.With(UIRect.Fixed(x: 0, y: 0, width: 120, height: 32))
.With(theme.CurrentTheme.GetButtonStyle(UIInteractionState.None))
.With(new UIInteractable { CanClick = true, CanFocus = true })
.With(UIThemed.Button)
.Build();
Every frame, ThemeApplicatorSystem queries entities with both UIStyle and UIThemed, reads the entity's UIInteractionState from its UIInteractable component (if present), and overwrites UIStyle with the result of the matching ITheme method. Entities with a UIDisabledTag are skipped so their current style is preserved. Because the system runs in SystemPhase.LateUpdate at order: -20, styles are refreshed before later layout/render systems read them, and every entity picks up a theme switch on the very next frame without any manual re-styling.
System Theme Detection
ISystemThemeProvider abstracts OS-level theme queries: IsAvailable, SupportsRuntimeNotification, GetCurrentTheme(), and an OnThemeChanged event for platforms that support live notifications. ThemePlugin selects a concrete provider (WindowsThemeProvider, MacOSThemeProvider, LinuxThemeProvider, or FallbackThemeProvider when the platform isn't recognized) and forwards its SystemThemeChangedEvent onto the world's messaging system whenever IThemeContext.FollowSystemTheme is enabled.
Performance
ThemeApplicatorSystem only visits entities that have both UIStyle and UIThemed; UI elements that don't opt in to theming are never touched. Style updates just replace the UIStyle struct - since UIStyle is a plain-data component, this is a cheap per-entity write, not a re-layout, and only occurs while the theme is actively changing (the query still runs every LateUpdate, so keep the themed-entity count proportional to what's on screen).
Next Steps
- UI Guide - the retained-mode UI system, widgets, and
WidgetFactory - Plugins Guide - how
IWorldPlugininstallation, extensions, and system registration work - Systems Guide - system phases and ordering
- 9-Slice Theming Design - original exploratory design document (note: the shipped
ThemePlugin/IThemeAPI described above supersedes the theme-system sketch in that document)