Skip to content

Styling System #53

@al6uiz

Description

@al6uiz

Styling

This document describes MewUI's styling system — a code-first, AOT-friendly approach to reusable, state-aware visual customization.


1. Overview

MewUI's styling system is built around the following principles:

  • Code-first: styles are C# lambdas, not XML or CSS
  • AOT-friendly: no reflection, no dynamic dispatch — only generic interfaces and typed delegates
  • Non-destructive: state-based colors are resolved at render time, not by mutating element properties
  • Composable: styles can extend other styles; containers can propagate styles to descendants

Relationship to Theme

The Theme system provides app-wide defaults (palette, metrics, fonts). Styles sit on top of the theme and allow per-control or per-scope visual overrides without touching the theme itself.

Theme (global defaults)
  ↓  overridden by
StyleScope rules (container-level)
  ↓  overridden by
StyleName (per-element named style)
  ↓  (user can still set individual properties on top)

2. StyleSheet

StyleSheet is a named registry of styles. It lives on Application (global) or on a Window (local). When looking up a style, MewUI searches the nearest StyleSheet up the element tree.

2.1 Registering styles

// Application-level (global)
Application.Create()
    .UseStyles(styles => styles
        .Define("btn-primary", (Button b, Theme t) =>
        {
            b.Padding = new Thickness(12, 5);
        })
    )
    .Run(mainWindow);

// Or imperatively before Run(...)
app.Styles
    .Define("btn-primary", (Button b, Theme t) =>
    {
        b.Padding = new Thickness(12, 5);
    });

2.2 Applying a style to an element

var btn = new Button { Content = "Save", StyleName = "btn-primary" };

The style callback is invoked:

  • When StyleName is first assigned
  • Whenever the theme changes

2.3 Composing styles

A style can extend another by name. The base style runs first; the new style then applies its overrides on top.

app.Styles
    .Define("btn-base", (Button b, Theme t) =>
    {
        b.Padding = new Thickness(12, 5);
        b.BorderThickness = new Thickness(1);
    })
    .Define("btn-primary", "btn-base", (Button b, Theme t) =>
    {
        b.BorderBrush = t.Palette.Accent;
    })
    .Define("btn-danger", "btn-base", (Button b, Theme t) =>
    {
        b.BorderBrush = Colors.Red;
    });

3. StyleScope

StyleScope is attached to a container element. It applies matching style rules automatically to all descendants of that container.

var toolbar = new StackPanel
{
    Orientation = Orientation.Horizontal,
    Scope = new StyleScope()
        .Add<Button>("btn-compact")            // named style from StyleSheet
        .Add<Label>((l, t) => l.FontSize = 11) // inline rule
};

Rules are matched by element type (is T check). Multiple scopes can be nested; rules from outer scopes apply first, inner scopes override.

Scope resolution order

Window.Scope  →  outer Panel.Scope  →  inner Panel.Scope  →  element.StyleName
   (lowest priority)                                           (highest priority)

4. State-aware styling

State-based colors (hover, press, focus, disabled, selected) are resolved at render time rather than by mutating element properties. This avoids unnecessary layout invalidation and preserves explicitly set property values.

4.1 ElementState

[Flags]
public enum ElementState
{
    Normal   = 0,
    Hovered  = 1 << 0,
    Pressed  = 1 << 1,
    Focused  = 1 << 2,
    Disabled = 1 << 3,
    Selected = 1 << 4,
}

4.2 ColorRole

ColorRole identifies a color slot on a control. Built-in roles are predefined; custom controls can declare their own.

// Built-in roles
ColorRole.Background
ColorRole.Foreground
ColorRole.BorderBrush

// Custom control declares its own roles
public class CardControl : Control
{
    public static readonly ColorRole StripeColor = ColorRole.Create();
    public static readonly ColorRole HeaderBg    = ColorRole.Create();
}

4.3 ControlColors

ControlColors holds per-state color functions for a single ColorRole.

new ControlColors
{
    Normal   = t => t.Palette.Accent,
    Hovered  = t => t.Palette.AccentHovered,
    Pressed  = t => t.Palette.AccentPressed,
    Focused  = t => t.Palette.FocusRing,
    Disabled = t => t.Palette.Disabled,
}

Resolution priority within ControlColors: Disabled > Pressed > Hovered > Selected > Normal.
If a state has no entry, the system falls back to Normal.

4.4 StyleDefinition<T>

StyleDefinition<T> bundles color roles, a layout callback, and an optional state callback into a single style object.

app.Styles.Define("btn-primary", new StyleDefinition<Button>()
    .Set(ColorRole.Background, new ControlColors
    {
        Normal   = t => t.Palette.Accent,
        Hovered  = t => t.Palette.AccentHovered,
        Pressed  = t => t.Palette.AccentPressed,
        Disabled = t => t.Palette.Disabled,
    })
    .Set(ColorRole.Foreground, new ControlColors
    {
        Normal   = _ => Colors.White,
        Disabled = t => t.Palette.DisabledText,
    })
    .Set(ColorRole.BorderBrush, new ControlColors
    {
        Normal  = t => t.Palette.Accent,
        Focused = t => t.Palette.FocusRing,
    })
    // Layout properties: applied once per theme change
    .WithLayout((b, t) =>
    {
        b.Padding = new Thickness(12, 5);
        b.BorderThickness = new Thickness(1);
    })
);

4.5 How controls consume state colors

Controls query their resolved color at render time using ResolveColor. This method returns the style-overridden color, or the provided fallback if no style is set.

// Inside Button.OnRender (example):
var bg = ResolveColor(ColorRole.Background, theme,
    fallback: IsPressed   ? theme.Palette.ButtonPressed :
              IsMouseOver ? theme.Palette.ButtonHovered :
                            theme.Palette.ButtonFace);

Controls that do not use ResolveColor are unaffected by the styling system — their behavior is unchanged.


5. State-dependent layout properties

For layout properties (e.g., BorderThickness, Padding) that must change with state, use the OnState callback. Unlike color roles, layout mutations trigger InvalidateMeasure, which is the correct behavior for structural changes.

app.Styles.Define("textbox-outline", new StyleDefinition<TextBox>()
    .Set(ColorRole.BorderBrush, new ControlColors
    {
        Normal  = t => t.Palette.ControlBorder,
        Focused = t => t.Palette.Accent,
    })
    .WithLayout((tb, t) => tb.Padding = t.Metrics.InputPadding)
    .WithOnState((tb, t, state) =>
    {
        tb.BorderThickness = state.Has(ElementState.Focused)
            ? new Thickness(2)
            : new Thickness(1);
    })
);

Layout jump warning: changing BorderThickness on state transition shrinks the available content area. To avoid visible jumps, consider keeping BorderThickness constant and expressing the focus indicator through color only.


6. Custom control extension

Custom controls can define their own ColorRole keys and participate in the styling system with no changes to the framework.

public class StatusCard : Control
{
    // Declare roles as static fields
    public static readonly ColorRole IndicatorColor = ColorRole.Create();
    public static readonly ColorRole HeaderBg       = ColorRole.Create();

    protected override void OnRender(IGraphicsContext g, Rect bounds, Theme theme)
    {
        var bg        = ResolveColor(ColorRole.Background,  theme, theme.Palette.Surface);
        var header    = ResolveColor(HeaderBg,              theme, theme.Palette.SubtleFill);
        var indicator = ResolveColor(IndicatorColor,        theme, theme.Palette.Accent);

        g.FillRect(bg, bounds);
        g.FillRect(header, headerBounds);
        g.FillRect(indicator, indicatorBounds);
    }
}

// Style definition uses the same .Set() API for custom roles
app.Styles.Define("status-card-info", new StyleDefinition<StatusCard>()
    .Set(ColorRole.Background, new ControlColors { Normal = t => t.Palette.Surface })
    .Set(StatusCard.HeaderBg,  new ControlColors { Normal = t => t.Palette.SubtleFill })
    .Set(StatusCard.IndicatorColor, new ControlColors
    {
        Normal  = t => t.Palette.Accent,
        Hovered = t => t.Palette.AccentHovered,
    })
);

7. Execution timing summary

Property category When applied Triggers
ColorRole / ControlColors At render time InvalidateVisual (on state change)
WithLayout callback On theme change InvalidateMeasure
WithOnState callback On state change InvalidateMeasure
StyleScope inline rules On theme change Same as WithLayout

8. Complete example

app.Styles
    // Simple named style (layout only)
    .Define("btn-base", (Button b, Theme t) =>
    {
        b.Padding = new Thickness(12, 5);
        b.BorderThickness = new Thickness(1);
    })

    // State-aware style extending btn-base
    .Define("btn-primary", "btn-base", new StyleDefinition<Button>()
        .Set(ColorRole.Background, new ControlColors
        {
            Normal   = t => t.Palette.Accent,
            Hovered  = t => t.Palette.AccentHovered,
            Pressed  = t => t.Palette.AccentPressed,
            Disabled = t => t.Palette.Disabled,
        })
        .Set(ColorRole.Foreground, new ControlColors
        {
            Normal   = _ => Colors.White,
            Disabled = t => t.Palette.DisabledText,
        })
        .Set(ColorRole.BorderBrush, new ControlColors
        {
            Normal  = t => t.Palette.Accent,
            Focused = t => t.Palette.FocusRing,
        })
    );

// Container scope: all Buttons below get "btn-base"
var actionBar = new StackPanel
{
    Orientation = Orientation.Horizontal,
    Scope = new StyleScope().Add<Button>("btn-base")
};

// Individual override
var saveBtn   = new Button { Content = "Save",   StyleName = "btn-primary" };
var cancelBtn = new Button { Content = "Cancel", StyleName = "btn-base" };

actionBar.Children.Add(saveBtn);
actionBar.Children.Add(cancelBtn);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    Features

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions