-
Notifications
You must be signed in to change notification settings - Fork 8
Description
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
StyleNameis 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
BorderThicknesson state transition shrinks the available content area. To avoid visible jumps, consider keepingBorderThicknessconstant 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
Labels
Type
Projects
Status