Skip to content

UI Styling and Themes

Mike Strobel edited this page Jun 26, 2026 · 1 revision

Styling & 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 fluent Selectors builder, ^-nested styles). Resources and {StaticResource}/{DynamicResource} mirror WPF. The When/DataCondition conjunct is the DataTrigger equivalent.

Style, Setter, Styles

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 returns this for chaining.
  • Setters is the content collection (new Setter(property, value)); setter values are constants (validated and converted once when the style seals) or UIProperty.UnsetValue (a valueless setter that contributes nothing while active).
  • Styles is the ordered collection. Two homes: UIElement.Styles (applies to the element and its subtree) and UIApplication.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.Style is a single explicit attachment that beats every selector-matched rule. It must be selector-less or ^-rooted (a named, selector-matched style belongs in a Styles collection).

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.

Selectors

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.

BasedOn and nesting

  • BasedOn inherits setters from another style (base setters first, derived appended, nearest-derived wins per property). The derived selector alone carries specificity — BasedOn contributes setters, never weight. Cycles are detected at seal time.
  • Children holds nested styles whose selectors start with the ^ nesting anchor; they AND-compose with the parent's selector. A Button.primary style with a ^:pointerover child compiles to one rule Button.primary:pointerover — the Avalonia nested-style idiom.
var card = new Style("Card");
card.Children.Add(new Style("^:pointerover").Set(Card.ElevationProperty, 2));

Classes and pseudo-classes on UIElement

  • UIElement.Classes (a ClassSet) holds the .class selector targets. Add / Remove / Replace re-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 from PseudoClasses.Set(":open", true) (a protected internal surface for control authors) or, more commonly, from PseudoClassMapping — 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.

When — binding-guarded styles

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 => ...)); Negate inverts the verdict.
  • An unresolved or UnsetValue binding value is unmet — fail-closed, even under Negate.
  • A When condition only accepts a reflection-lane Binding in v1, and needs both using Cursorial.UI; and using Cursorial.UI.Data; in scope.
  • Each condition adds specificity, so a When-guarded style beats its unguarded base. A DataContext change 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).

Resources

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);
  • MergedDictionaries layers other dictionaries in — own entries beat merged ones, and a later-merged dictionary beats an earlier one.
  • ThemeDictionaries holds light/dark sub-dictionaries keyed by variant, selected by the active ActualThemeVariant.
  • Seal() deep-freezes a dictionary so it's shareable; sealed dictionaries reject mutation.

Resolving resources

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

Static vs dynamic references

  • {StaticResource} (XAML) resolves once at load time against the ambient resource scope — cheap, and the value never changes afterward.

  • {DynamicResource} (XAML) and element.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 later SetValue or 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.

Theme variants

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);

Application Resources and Theme

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.

ControlTemplate and control themes

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.

A worked XAML example

<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>

Debugging

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.

See also

  • Controls — the catalog of templated controls these styles dress
  • Data binding — the Binding that backs a When condition
  • XAML — authoring styles, resources, and templates declaratively
  • UI overview — the property system, element tree, and where styling fits

Clone this wiki locally