-
Notifications
You must be signed in to change notification settings - Fork 0
UI Styling and Themes
Cursorial styles elements with reusable, CSS-like rules instead of per-element property assignment. A
Style carries a structural selector, a set of setters, and optional state and data conditions; the
styling engine matches it against the live element tree and arms each winning value at the right priority. On
top of that sits a WPF/Avalonia-style resource & theme system — keyed ResourceDictionarys, dynamic
resource references, and a light/dark × color-tier theme model that re-skins controls when the terminal's
palette or background changes.
Reach for styling when you want one rule to dress many elements, react to hover/focus/checked state, or swap an app's whole look by changing a theme. If you only need to set a property on one element, set it directly — local values always win over styles.
Kinship. Selectors are close to Avalonia's (
Type.class:pseudo > child, the fluentSelectorsbuilder,^-nested styles). Resources and{StaticResource}/{DynamicResource}mirror WPF. TheWhen/DataConditionconjunct is theDataTriggerequivalent.
A Style is a bundle of Setters activated by a selector. Author one in code with the fluent Set helper, or
declaratively in XAML:
using Cursorial.UI;
using Cursorial.UI.Controls;
var primary = new Style("Button.primary")
.Set(Control.BackgroundProperty, accentBrush)
.Set(Control.ForegroundProperty, inkBrush);
// Scope it to a subtree…
panel.Styles.Add(primary);
// …or to the whole application.
app.Styles.Add(primary);-
Style.Set<T>(StyledProperty<T>, T)is the low-friction, type-checked authoring path — a wrong-type constant is a compile error, not a runtime surprise. It returnsthisfor chaining. -
Settersis the content collection (new Setter(property, value)); setter values are constants (validated and converted once when the style seals) orUIProperty.UnsetValue(a valueless setter that contributes nothing while active). -
Stylesis the ordered collection. Two homes:UIElement.Styles(applies to the element and its subtree) andUIApplication.Styles(applies to every shown tree). A style added to an attached collection is sealed automatically — validated, flattened, compiled, then immutable and freely shareable across collections. Mutating a sealed style throws. -
UIElement.Styleis a single explicit attachment that beats every selector-matched rule. It must be selector-less or^-rooted (a named, selector-matched style belongs in aStylescollection).
Styles inside a Styles collection must have a selector (or a key, for keyed-theme entries); a
selector-less rule there is an attach-time error.
The selector grammar is deliberately small. Build one with Selector.Parse(string), the fluent Selectors
builders, or the Style(string) / Style(Selector) constructors. The two forms are interchangeable —
Selectors.OfType<Pane>().Class("toolbar").Child().OfType<Button>() is structurally equal to
Selector.Parse("Pane.toolbar > Button").
| Form | Matches | Example |
|---|---|---|
Type |
elements of exactly that type | Button |
:is(Type) |
that type or a subclass | :is(ButtonBase) |
.class |
elements with that style class | .primary |
#name |
the element whose Name is set |
#submit |
:pseudo-class |
a pseudo-class state |
:pointerover, :checked
|
A B |
descendant — B under A
|
Menu MenuItem |
A > B |
direct child | StackPanel > Button |
A /template/ B |
a part in A's control template |
Button /template/ #border |
A, B |
a selector list — each member is its own rule | Button, RepeatButton |
prefix|Type |
a namespace-qualified type (XAML) | controls|MyWidget |
Compounds combine simples: Button.primary:pointerover is one element that is a Button, has class
primary, and is hovered. Type tokens are matched by simple name through an ISelectorTypeResolver (the
default resolves registry-known and exported element types); the XAML loader supplies a namespace-aware
resolver so a prefix|Type token binds the document's xmlns.
A few constructs are unsupported by design and rejected with a clear error: :not(), :nth-* /
positional selectors, sibling combinators (+, ~), and attribute/property-value selectors.
-
BasedOninherits setters from another style (base setters first, derived appended, nearest-derived wins per property). The derived selector alone carries specificity —BasedOncontributes setters, never weight. Cycles are detected at seal time. -
Childrenholds nested styles whose selectors start with the^nesting anchor; they AND-compose with the parent's selector. AButton.primarystyle with a^:pointeroverchild compiles to one ruleButton.primary:pointerover— the Avalonia nested-style idiom.
var card = new Style("Card");
card.Children.Add(new Style("^:pointerover").Set(Card.ElevationProperty, 2));-
UIElement.Classes(aClassSet) holds the.classselector targets.Add/Remove/Replacere-match the element synchronously at the mutation site. Class names are interned strings;:-prefixed names are rejected (those are pseudo-classes).widget.Classes.Add("primary"); widget.Classes.Replace(["primary", "compact"]); // one restyle pass
-
Interaction pseudo-classes are framework-written and flow only through the element's interaction state — you select on them but never set them from app code. The vocabulary:
:pointerover,:pressed,:focus,:focus-within,:focus-visible,:active-window,:access-keys,:disabled,:modal-attention. -
Control-semantic pseudo-classes with no interaction bit (
:checked,:indeterminate,:open, …) come fromPseudoClasses.Set(":open", true)(aprotected internalsurface for control authors) or, more commonly, fromPseudoClassMapping— a control declares once in its static constructor that a property's value mirrors a pseudo-class:// In a control's static ctor: IsChecked drives :checked / :indeterminate. PseudoClassMapping.Register<ToggleButton, bool?>( ToggleButton.IsCheckedProperty, v => v switch { true => ":checked", null => ":indeterminate", _ => null }, ":checked", ":indeterminate");
After that,
Style("CheckBox:checked")just works with no styling code on the control.
A When condition gates a style on bound data, not structure — the DataTrigger equivalent. Style.When is
a conjunction (AND) of DataConditions; the style is active only when its selector matches and every
condition holds.
using Cursorial.UI;
using Cursorial.UI.Data; // DataCondition takes a BindingBase from here
var urgent = new Style("ListItem")
.Set(ListItem.ForegroundProperty, redBrush);
urgent.When.Add(new DataCondition(new Binding("Priority"), value: "High"));- Equality form (
new DataCondition(binding, value)) or predicate form (new DataCondition(binding, v => ...));Negateinverts the verdict. - An unresolved or
UnsetValuebinding value is unmet — fail-closed, even underNegate. - A
Whencondition only accepts a reflection-laneBindingin v1, and needs bothusing Cursorial.UI;andusing Cursorial.UI.Data;in scope. - Each condition adds specificity, so a
When-guarded style beats its unguarded base. ADataContextchange rebinds and re-evaluates automatically, and the result reaches layout the same frame.
There is no Or inside a When — express disjunction as two styles (via BasedOn or a selector list).
ResourceDictionary is keyed storage for shared values — brushes, pens, templates, anything. Keys are
string, Type, DataTemplateKey, or any object; values are anything except UIProperty.UnsetValue.
app.Resources["AccentBrush"] = SolidColorBrush.FromRgb(0x4F, 0x9C, 0xF5);
app.Resources.Add("Gap", 2);-
MergedDictionarieslayers other dictionaries in — own entries beat merged ones, and a later-merged dictionary beats an earlier one. -
ThemeDictionariesholds light/dark sub-dictionaries keyed by variant, selected by the activeActualThemeVariant. -
Seal()deep-freezes a dictionary so it's shareable; sealed dictionaries reject mutation.
FindResource / TryFindResource (extension methods on UIElement) walk the lookup chain:
element → logical ancestors (with the template-root hop) → visual root
→ UIApplication.Resources → UIApplication.Theme → CursorialTheme.BuiltIn
if (element.TryFindResource("AccentBrush", out var value))
apply((IBrush?)value);
var gap = (int)element.FindResource("Gap"); // throws ResourceNotFoundException on a miss-
{StaticResource}(XAML) resolves once at load time against the ambient resource scope — cheap, and the value never changes afterward. -
{DynamicResource}(XAML) andelement.SetResourceReference(property, key)(code) install a live reference: the value re-resolves whenever a resource pulse reaches the element's root — a theme flip, a dictionary swap, a nearer scope shadowing the key. A laterSetValueor binding evicts the reference cleanly.// Re-resolves on every theme flip. border.SetResourceReference(Control.BackgroundProperty, ThemeKeys.SurfaceBrush);
This dynamic spine is how the built-in control themes re-skin live: their color setters reference palette role
tokens (Theme.SurfaceBrush, Theme.SelectionBrush, …) via Style.SetResource, so a dark/light flip
re-colors every default-look control with zero template work.
The theme model is a 2-D axis: light/dark × negotiated color tier. ThemeVariant(ThemeBase Base, ColorDepth Tier) is the effective variant; UIApplication.ActualThemeVariant is the live value, derived per
axis from the terminal (background luminance for light/dark, the negotiated depth for the tier) unless
overridden:
app.RequestedThemeBase = ThemeBase.Light; // force light; null derives from the terminal background
app.RequestedColorTier = ColorDepth.Ansi256; // preview a lower tier; null uses the negotiated depth
app.ActualThemeVariantChanged += (_, _) => { /* react */ };
app.ResourcesChanged += (_, _) => { /* re-read dynamic resources */ };A base flip (light↔dark) re-resolves resources only; a tier flip also re-stamps the capability classes. The
root carries those capability classes from the effective tier so you can select on them:
caps-truecolor / caps-ansi256 / caps-ansi16 / caps-nocolor, plus caps-motion, caps-kitty-keyboard,
and caps-unicode / caps-ascii for glyph fallback. A nocolor terminal can then style with reverse-video and
attributes instead of color:
new Style(".caps-nocolor Button:focus")
.Set(TextElement.TextAttributesProperty, TextAttributes.Inverse);UIApplication.Resources is the app-level dictionary (the chain hop above every root). UIApplication.Theme is
an optional dictionary layered over CursorialTheme.BuiltIn — leave it null (the normal case) and
controls are still fully themed, because the chain always probes BuiltIn as its tail. Set Theme only to
override or extend the built-in look; chain order is Resources → Theme → BuiltIn, so your keys win and BuiltIn
fills the gaps.
A Control gets its visuals from a ControlTemplate (an ITemplateContent that expands into a fresh subtree
stamped with the control as TemplatedParent). The default look for each control type is a control theme —
a Type-keyed Style in CursorialTheme.BuiltIn (and re-authored declaratively in the optional XAML theme
overlay). To restyle one control type app-wide, add your own Type-keyed style; to retemplate one instance,
set Control.Template.
button.Template = new ControlTemplate(ctx =>
{
var border = new Border { Name = "border" };
// …build the part tree, bind parts back with {TemplateBinding} in XAML or TemplateBinding in code…
return border;
});A ControlTemplate carries its own Styles (armed on every instantiated part, one rung below page/theme
styles so an app style overrides a template default) and a Resources dictionary (the template-root chain hop).
Templates seal before instantiation and are then shareable across controls.
Selecting into a template uses the /template/ combinator: Button /template/ #border targets the part named
border inside any Button's template.
<ResourceDictionary xmlns="https://cursorial.dev/ui" xmlns:x="https://cursorial.dev/xaml">
<ResourceDictionary.Styles>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource Theme.WindowBackground}"/>
</Style>
<Style Selector="Button.primary:pointerover">
<Setter Property="Background" Value="{DynamicResource Theme.SelectionBrush}"/>
</Style>
<Style Selector=".caps-nocolor Button:focus">
<Setter Property="TextElement.TextAttributes" Value="Inverse"/>
</Style>
</ResourceDictionary.Styles>
</ResourceDictionary>StyleDiagnostics.Explain(element, property) renders the full sort-key derivation of a property's winning
value in one line — which rule won, at what specificity, and why — the first thing to reach for when a value
isn't what you expected. ResourceDiagnostics reports which key a resource-backed value resolved through.
- Controls — the catalog of templated controls these styles dress
-
Data binding — the
Bindingthat backs aWhencondition - XAML — authoring styles, resources, and templates declaratively
- UI overview — the property system, element tree, and where styling fits
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative