Style System¶
ORCA's style system implements a CSS-like approach for setting component properties on visual objects.
Style classes are attached to objects at load time; stylesheet rules map selectors to property values and are resolved when Object.ThemeChanged is dispatched.
Overview¶
Object
└─ StyleController (attach-only component)
├─ classes: style_class* ─── linked list of parsed class tokens
└─ stylesheet: style_rule* ─── linked list of CSS-like rules
When styles are applied, each class token on the object is looked up in the stylesheet chain.
Matching rules write their property values directly on the object.
Pseudo-state rules (:hover, :focus, :active, :dark) are applied only when the matching condition is active.
StyleController component¶
StyleController is an attach-only component defined in source/core/core.xml and implemented in source/core/components/StyleController.c.
It is automatically attached to every Node2D (and any class that lists StyleController as a parent) because Node2D declares parent="Node,StyleController" in UIKit.xml.
// Retrieve the component pointer; returns NULL if not attached
struct StyleController* sc = GetStyleController(object);
Fields¶
| Field | Type | Description |
|---|---|---|
classes |
style_class* |
Linked list of parsed style class tokens (owned by this component) |
stylesheet |
style_rule* |
Linked list of per-object stylesheet rules (owned by this component) |
A second global stylesheet (static_sheet in StyleController.c) holds rules that are visible to all objects and checked before the per-object chain.
Style classes¶
A style class identifies an object's logical role (e.g., "button", "primary") and optional pseudo-states.
Syntax¶
| Part | Example | Description |
|---|---|---|
| Base name | button |
Lookup key matched against stylesheet selectors |
| Pseudo-state(s) | :hover, :focus, :active, :dark |
Gate the class — the rule fires only when this state is active |
| Opacity | /50 |
0–100 integer applied to the alpha channel of color properties |
Multiple pseudo-states can be chained: button:hover:focus requires both hover and focus to be active.
Setting classes from XML¶
The class XML attribute is parsed by StyleController.AddClasses() at load time.
Tokens are space-separated; each token is parsed into a style_class node.
Setting classes from Lua¶
-- At object creation (via the property table):
local btn = UIKit.Button { class = "primary:hover" }
-- At runtime (assign a space-separated class string):
btn.class = "selected"
C API¶
// Parse a full space-separated class attribute string (called during XML load)
OBJ_ParseClassAttribute(lpObject_t obj, const char* classAttr);
Stylesheet rules¶
A stylesheet rule maps a selector and a property name to a string value.
The selector is a class name (with or without a leading .); the optional pseudo-states on the selector gate when the rule fires.
Rule structure¶
Loading stylesheets from Lua¶
addStyleRule() is a Lua method available on any Node2D (or any object with StyleController).
It registers rules on the calling object's StyleController.stylesheet.
-- Attach a stylesheet to a specific object
btn:addStyleRule(".button", {
Background = "#3c6",
Foreground = "white",
Width = 120,
Height = 40,
})
-- Pseudo-state rules
btn:addStyleRule(".button:hover", {
Background = "#5e8",
})
btn:addStyleRule(".button:active", {
Background = "#2a4",
})
Rules for the same selector can be split across multiple addStyleRule() calls; they are appended to the list and applied in order.
@apply directive¶
The @apply key inside a rule table includes the rules from another selector (similar to Tailwind/PostCSS @apply):
btn:addStyleRule(".special-button", {
["@apply"] = "button", -- inherit all ".button" rules
Background = "#a0f", -- override background
})
C API¶
// Register a single rule on an object (selector may include a leading '.')
OBJ_AddStyleClass(
lpObject_t obj,
const char* name, // e.g., "button" or ".button"
const char* property, // e.g., "Background"
const char* value, // e.g., "#3c6"
uint32_t flags // STYLE_HOVER | STYLE_FOCUS etc., or 0
);
Lua API¶
-- addStyleRule is exposed on every Node2D via core_export.c
obj:addStyleRule(".selector[:pseudo-state...]", { property = value, ... })
Applying styles¶
Object.ThemeChanged — the style trigger¶
Styles are applied by sending Object.ThemeChanged or StyleController.ThemeChanged to an object.
StyleController handles both messages and recalculates all matching rules.
Objects without a StyleController silently ignore the message.
Both messages have a single field:
| Field | Type | Description |
|---|---|---|
recursive |
bool_t |
When TRUE, the message is forwarded to every child object |
// C — send the typed message directly:
_SendMessage(object, Object, ThemeChanged, .recursive = TRUE);
-- Lua — raises StyleController.ThemeChanged on this object (applies styles)
node:ThemeChanged()
-- With recursive descent into children:
node:ThemeChanged(StyleController_ThemeChangedEventArgs{ recursive = true })
!!! tip "Lua event syntax"
self:ThemeChanged() works because ThemeChanged is an event property on StyleController.
Accessing any event property as self.EventName returns a callable closure that sends the message.
This pattern applies to all component events — for example, player:Play() sends AnimationPlayer.Play.
See Lua events and properties below.
Resolution order¶
When StyleController receives Object.ThemeChanged for an object:
PROP_ClearSpecializedresets any previously applied specialised values.- Body rules — if the object is a root node (no parent), rules with the selector
"body"in its own stylesheet are applied first. - Per-class rules — for each
style_classattached to the object: - If the class has pseudo-state flags (
STYLE_HOVER, etc.), the current object state is tested; the class is skipped when the state does not match. OBJ_EnumStyleClasses()walks the global stylesheet first, then each ancestor's stylesheet bottom-up, and invokes_ApplyRulefor every matching rule.- Specialised-flag guard — for each property hit by a state-gated rule,
PF_SPECIALIZEDis set. Any subsequent un-gated (default) rule for the same property is skipped whenPF_SPECIALIZEDis already set, ensuring the state-specific value wins. - Recursive descent — if
recursive=TRUE,Object.ThemeChangedwithrecursive=TRUEis sent to every direct child.
When styles are applied automatically¶
- On
Object.ThemeChanged(non-recursive) — sent automatically by the engine when hover state changes (CORE_UpdateHover) or focus changes (OBJ_SetFocus).StyleControllerhandles this message directly; no intermediate Node handler is needed. - On
Object.PropertyChangedfor theclassproperty — when Lua setsnode.class = "…". - Explicitly by application code after state changes.
Pseudo-states¶
| Token | Flag | Condition checked by OBJ_GetStyleFlags() |
|---|---|---|
hover |
STYLE_HOVER |
core_GetHover() == object |
focus |
STYLE_FOCUS |
core_GetFocus() == object |
active |
STYLE_SELECT |
OBJ_GetFlags(object) & OF_SELECTED |
dark |
STYLE_DARK |
axIsDarkTheme() (system-wide) |
Example: dark-mode background¶
screen:addStyleRule(".card", {
Background = "white",
})
screen:addStyleRule(".card:dark", {
Background = "#1e1e2e",
})
When the user switches to dark mode, Object.ThemeChanged is broadcast, StyleController re-runs style application, and the :dark rule takes precedence.
Opacity modifier¶
Color properties can be dimmed using the /N opacity syntax on the class token:
After the color value is resolved, the alpha channel is overwritten with N / 100.0.
Data structures¶
style_class (internal)¶
struct style_class {
struct style_class* next;
shortStr_t value; // base class name (e.g., "button", not "button:hover")
byte_t flags; // STYLE_HOVER | STYLE_FOCUS | STYLE_DARK | STYLE_SELECT
byte_t opacity; // 0–100 (default 100)
};
style_rule (internal rule record)¶
struct style_rule {
struct style_rule* next;
uint32_t class_id; // FNV1a32 of base selector (without leading '.')
uint32_t prop_id; // FNV1a32 of property name
uint32_t flags; // pseudo-state gate mask (same bit layout as style_class.flags)
shortStr_t classname; // selector string (e.g., ".button")
shortStr_t name; // property name (e.g., "Background")
shortStr_t value; // property value string (e.g., "#3c6")
};
class_id and prop_id are pre-computed at rule-insertion time for O(1) matching during resolution.
Object.Release and memory¶
StyleController handles Object.Release and calls OBJ_ClearStyleClasses(), which frees both the classes and stylesheet linked lists.
The global static_sheet is never freed (it lives for the duration of the process).
Complete Lua example¶
local core = require "orca.core"
local ui = require "orca.UIKit"
local screen = ui.Screen { Width = 800, Height = 600, ResizeMode = "NoResize" }
-- Define a global stylesheet on the root screen
screen:addStyleRule(".btn", {
Background = "#4a90d9",
Foreground = "white",
CornerRadius = 6,
Padding = 8,
})
screen:addStyleRule(".btn:hover", {
Background = "#3a80c9",
})
screen:addStyleRule(".btn:active", {
Background = "#2a70b9",
})
-- Create a button and assign the "btn" class
local myBtn = screen + ui.Button {
class = "btn",
Text = "Click me",
Width = 120,
Height = 36,
}
-- Apply styles initially
myBtn:ThemeChanged()
Lua events and properties¶
ORCA objects expose a uniform Lua API for reading and writing properties and for sending messages through the component property system.
Property access¶
Any property declared in a component's XML definition can be read and written directly on the Lua object:
-- Read a property
local w = node.Width -- returns the current Width value
-- Write a property
node.Width = 120 -- sets Width; may trigger layout/render
node.Text = "Hello"
node.Color = "#4a90d9"
Setting a property that is bound to layout or rendering automatically marks the object dirty and triggers the next paint cycle.
Events (self:EventName)¶
Every message declared in a component's <messages> block becomes an event property accessible via self.EventName. Reading the property returns a callable closure that sends the message synchronously:
-- AnimationPlayer events
player:Play() -- sends AnimationPlayer.Play
player:Stop() -- sends AnimationPlayer.Stop
player:Pause() -- sends AnimationPlayer.Pause
-- StyleController.ThemeChanged — apply styles
node:ThemeChanged() -- non-recursive (default)
-- Pass an EventArgs table for messages that carry data:
node:ThemeChanged(StyleController_ThemeChangedEventArgs{ recursive = true })
The pattern works for any component that defines messages:
| Call | Component | Message sent |
|---|---|---|
player:Play() |
AnimationPlayer |
AnimationPlayer.Play |
player:Stop() |
AnimationPlayer |
AnimationPlayer.Stop |
node:ThemeChanged() |
StyleController |
StyleController.ThemeChanged |
Event callbacks (self.EventName = function)¶
Assigning a Lua function to an event property registers it as a callback that fires when the corresponding message is dispatched to the object:
-- Called when the animation finishes
function player:Completed(event, sender)
print("animation done")
end
-- Equivalent to:
player.Completed = function(self, event, sender)
print("animation done")
end
The callback receives (self, event, sender) where event is the EventArgs struct pushed as a userdata (or nil for messages with no fields) and sender is the originating object.