Theming
The Malena theme system lets you define a complete visual identity — colors, typography, and spacing — and apply it globally across every component at once. Switch between themes at runtime with a single call.
How Themes Work
A theme is a plain struct of design tokens: color roles, typography settings, and geometry values. Any component that inherits the Themeable trait automatically subscribes to ThemeManager and has its onThemeApplied() method called whenever the active theme changes.
The flow looks like this:
ThemeManager::apply() or ThemeManager::set()
→ ThemeManager notifies all Themeable subscribers
→ each component's onThemeApplied(const Theme&) is called
→ component re-styles itself from the new token values
Components built into Malena (buttons, inputs, panels, toggles, etc.) already implement this protocol — they restyle themselves automatically. When you create your own components you can opt into theming by inheriting Themeable and overriding onThemeApplied().
ThemeManager defaults to ml::DarkTheme. All components that check the theme in their constructor will use dark-theme values from the start.
Built-in Themes
Malena ships two themes out of the box.
| Type | Description |
|---|---|
ml::DarkTheme | Deep charcoal backgrounds, purple primary accents. The default. |
ml::LightTheme | White/near-white surfaces, purple primary accents. |
Apply either one directly with ThemeManager::set():
// Switch to light theme
ml::ThemeManager::set(ml::LightTheme());
// Switch back to dark
ml::ThemeManager::set(ml::DarkTheme());
// Read a value from the active theme
const ml::Theme& t = ml::ThemeManager::get();
sf::Color bg = t.background;
The call to set() immediately notifies all Themeable components, so the UI updates on the same frame.
ml::Theme carries 14 color roles, 3 typography values, and 3 geometry values. See the Full API section for the complete list.
Creating a Custom Theme
A custom theme is a struct that inherits ml::Theme and overrides whatever tokens you want in its constructor. Tokens you don't override inherit their default values.
#include <Malena/Malena.h>
struct NeonTheme : ml::Theme
{
NeonTheme()
{
// Color roles
primary = sf::Color(0, 255, 180); // bright cyan-green
secondary = sf::Color(0, 200, 255); // electric blue
surface = sf::Color(10, 10, 20); // near-black
background = sf::Color(5, 5, 15);
onSurface = sf::Color(220, 255, 245); // light mint text
onPrimary = sf::Color(0, 0, 0); // black on bright buttons
onBackground = sf::Color(200, 240, 230);
muted = sf::Color(60, 100, 90);
border = sf::Color(0, 180, 130);
borderFocus = sf::Color(0, 255, 180);
error = sf::Color(255, 60, 100);
success = sf::Color(0, 255, 140);
disabled = sf::Color(20, 30, 25);
onDisabled = sf::Color(60, 100, 90);
// Geometry
radius = 4.f; // sharper corners
spacing = 6.f;
borderThickness = 1.f;
}
};
Apply it directly:
ml::ThemeManager::set(NeonTheme());
font field is a raw pointer (const sf::Font*) defaulting to nullptr. Components that draw text fall back to the framework default font when this is null. Set it to point at a font loaded through FontManager if you want a custom typeface across your whole UI.
Registering Themes in a Manifest
When your application has multiple named themes (for example, different game modes), the cleanest approach is to register them in a Manifest and switch between them by enum key.
#include <Malena/Malena.h>
#include "NeonTheme.h"
class GameManifest : public ml::Manifest
{
public:
// One enum value per theme your app supports
enum class Themes { Main, GameOver, Pause };
// Register themes once at static-init time
inline static const auto _ = []()
{
set(Themes::Main, ml::DarkTheme());
set(Themes::GameOver, NeonTheme());
set(Themes::Pause, ml::LightTheme());
return 0;
}();
};
Then switch themes anywhere in your application with a single call:
// When the game ends:
ml::ThemeManager::apply<GameManifest>(GameManifest::Themes::GameOver);
// When the player pauses:
ml::ThemeManager::apply<GameManifest>(GameManifest::Themes::Pause);
// Back to the main theme:
ml::ThemeManager::apply<GameManifest>(GameManifest::Themes::Main);
apply<Manifest>(key) when themes are registered in a manifest and you want to switch by name. Use set(theme) for one-off or programmatic theme changes where you have a theme instance already.
Calling apply() from a plugin
Plugins work the same way — call ml::ThemeManager::apply() or ml::ThemeManager::set() from onLoad() or in response to a message:
void onLoad() override
{
onMessage<bool>(GameEvent::GameOver, [](const bool&) {
ml::ThemeManager::apply<GameManifest>(GameManifest::Themes::GameOver);
});
}
Making a Component Themeable
To make your own component respond to theme changes, inherit ml::Themeable and override onThemeApplied(). The Themeable constructor automatically registers with ThemeManager, and the destructor automatically unregisters — there is no manual setup required.
#include <Malena/Malena.h>
class MyCard : public ml::ComponentWith<ml::DefaultManifest>,
public ml::Themeable
{
public:
MyCard()
{
_bg.setSize({200.f, 120.f});
_label.setString("Hello");
// Apply the current theme immediately on construction
onThemeApplied(ml::ThemeManager::get());
}
void draw(sf::RenderTarget& target, sf::RenderStates states) const override
{
target.draw(_bg, states);
target.draw(_label, states);
}
void update() override {}
protected:
void onThemeApplied(const ml::Theme& theme) override
{
if (isThemeLocked()) return;
_bg.setFillColor(theme.surface);
_bg.setOutlineColor(theme.border);
_bg.setOutlineThickness(theme.borderThickness);
_label.setFillColor(theme.onSurface);
_label.setCharacterSize(theme.fontSize);
}
private:
sf::RectangleShape _bg;
sf::Text _label;
};
ThemeManager notifies only when the theme changes. To style correctly from the first frame, call onThemeApplied(ml::ThemeManager::get()) at the end of your constructor, as shown above.
Inheritance order matters
Themeable inherits ml::Trait, which is a lightweight base. Place Themeable after the primary base class in your inheritance list:
// Good — component base first, traits after
class MyWidget : public ml::ComponentWith<MyManifest>,
public ml::Themeable
{ ... };
// If you have multiple traits, list them in any order after the base
class MyPanel : public ml::ComponentWith<MyManifest>,
public ml::Themeable,
public ml::Draggable
{ ... };
Locking and Per-Component Overrides
Themeable provides two independent lock mechanisms. These let you give individual components a fixed look even when the global theme changes.
Theme lock
A theme-locked component ignores all future ThemeManager notifications. Its current visual state is frozen until you unlock it.
// Lock — component keeps its current colors even if ThemeManager fires
myButton.lockTheme();
// Unlock — component will react to the next theme change
myButton.unlockTheme();
// Check state
if (myButton.isThemeLocked()) { ... }
onThemeApplied(ml::ThemeManager::get()) manually after unlocking.
Settings lock
A settings-locked component blocks the batch applySettings() path but still accepts explicit individual setter calls. This is useful when you want automatic theme-driven styling to stop, but still need to call setFillColor() or similar directly.
// Block batch applySettings() — explicit setters still work
myToggle.lockSettings();
// Override a specific color directly (always works, regardless of locks)
myToggle.setTrackOnColor(sf::Color::Red);
myToggle.unlockSettings();
Combining locks with per-component overrides
// Apply the global theme first, then lock and override one color
ml::ThemeManager::set(ml::DarkTheme());
myAccentButton.lockTheme(); // freeze theme updates
myAccentButton.setFillColor(sf::Color(255, 80, 0)); // custom orange, persists
Full API
ThemeManager
| Method | Description |
|---|---|
ThemeManager::get() | Return a const Theme& to the currently active theme. |
ThemeManager::set<T>(theme) | Apply a theme instance directly. T must derive from ml::Theme. |
ThemeManager::apply<Manifest>(key) | Apply a theme registered in a manifest by enum key. |
ThemeManager::subscribe(Themeable*) | Called automatically by Themeable's constructor. Do not call manually. |
ThemeManager::unsubscribe(Themeable*) | Called automatically by Themeable's destructor. Do not call manually. |
ThemeManager::shutdown() | Called by ApplicationBase on exit. Clears all subscribers. |
Themeable trait
| Method | Description |
|---|---|
lockTheme() | Ignore future ThemeManager notifications. |
unlockTheme() | Resume reacting to theme changes. Does not re-apply immediately. |
isThemeLocked() | Return true if this component ignores theme changes. |
lockSettings() | Block batch applySettings(). Explicit setters still work. |
unlockSettings() | Allow applySettings() again. |
isSettingsLocked() | Return true if applySettings() is blocked. |
onThemeApplied(const Theme&) | Pure virtual. Override to re-style the component from the new theme. |
Theme color tokens
| Token | Purpose | DarkTheme default |
|---|---|---|
primary | Brand color — buttons, active states, highlights | rgb(100, 60, 200) (purple) |
secondary | Accent — hover states, secondary actions | rgb(70, 130, 230) (blue) |
surface | Card and input backgrounds | rgb(40, 40, 40) |
background | Page / scene background | rgb(20, 20, 20) |
onSurface | Text / icons on surface | White |
onPrimary | Text / icons on primary | White |
onBackground | Text / icons on background | rgb(220, 220, 220) |
muted | Placeholder, disabled, description text | rgb(120, 120, 120) |
border | Outline on inactive components | rgb(100, 100, 100) |
borderFocus | Outline on focused components | rgb(100, 60, 200) (purple) |
error | Error / invalid state | rgb(220, 70, 70) |
success | Success / confirm state | rgb(70, 200, 100) |
disabled | Disabled component fill | rgb(60, 60, 60) |
onDisabled | Text / icon on disabled components | rgb(120, 120, 120) |
Theme typography tokens
| Token | Type | Purpose |
|---|---|---|
font | const sf::Font* | Global font pointer. nullptr = use framework default. |
fontSize | unsigned int | Base font size in points. Default: 14. |
fontSizeSmall | unsigned int | Small size for captions/descriptions. Default: 11. |
fontSizeLarge | unsigned int | Large size for headings/titles. Default: 18. |
Theme geometry tokens
| Token | Type | Purpose |
|---|---|---|
radius | float | Default corner radius for rounded shapes. Default: 8.f. |
spacing | float | Base padding / gap unit. Default: 8.f. |
borderThickness | float | Default outline thickness. Default: 1.5f. |