This spec proposes an opt-in brush concept (solid colors and gradients) for terminal rendering in XenoAtom.Terminal.UI.
Motivations:
The primary design goal is low impact: we do not want to refactor the entire framework to be brush-aware, and we do not want to bloat the per-cell Style representation.
Style unchanged (still stores concrete per-cell colors, packed and allocation-free).Style or CellBuffer.Today, rendering is ultimately a CellBuffer of:
Style (foreground/background colors + flags).Style is intentionally compact; it cannot reasonably store brush references, arrays of stops, or other rich styling objects.
Therefore, brushes must be applied by controls during rendering by sampling the brush and writing the resulting concrete colors into the CellBuffer.
Translucent effects should use RGBA colors. The CellBuffer already applies alpha blending when an overlay style
contains explicit RGBA colors, which keeps "highlight overlays" consistent with existing style merging behavior.
Controls:
TextBlockTextFigletBrush kinds:
SolidLinearGradientMulti-line TextBlock mapping:
Interpolation defaults:
Color for a given cell within a rectangular region.Bounds, sometimes per-line).Oklab).This section describes the intended public surface area. Exact API names can be adjusted during implementation.
V1:
Solid: constant colorLinearGradient: interpolate between multiple stops along a lineFuture candidates:
RadialGradientnamespace XenoAtom.Terminal.UI.Styling;
public enum BrushKind
{
Solid,
LinearGradient,
}
public enum BrushTileMode
{
Clamp,
Repeat,
Mirror,
}
public readonly record struct GradientStop(float Offset, Color Color);
public readonly record struct GradientPoint(float X, float Y);
public readonly record struct Brush
{
public BrushKind Kind { get; }
public BrushTileMode TileMode { get; }
public ColorMixSpace? MixSpaceOverride { get; }
// Solid
public Color SolidColor { get; }
// Linear gradient (relative points)
public GradientPoint Start { get; }
public GradientPoint End { get; }
public ReadOnlyMemory<GradientStop> Stops { get; }
public static Brush Solid(Color color, ColorMixSpace? mix = null);
public static Brush LinearGradient(
GradientPoint start,
GradientPoint end,
ReadOnlyMemory<GradientStop> stops,
BrushTileMode tileMode = BrushTileMode.Clamp,
ColorMixSpace? mix = null);
public Color Sample(int cellX, int cellY, in Geometry.Rectangle brushRect, ColorMixSpace defaultMixSpace);
}
Stops must contain at least 2 stops.Offset (either required or enforced by construction).Color.Default is not allowed for stops in V1 (it is not a concrete color for interpolation).Rationale for disallowing Color.Default:
Default resolution order:
brush.MixSpaceOverride if setTheme.GradientMixSpace (new theme token; default recommended: ColorMixSpace.Oklab)Draft theme token:
public ColorMixSpace GradientMixSpace { get; init; } = ColorMixSpace.Oklab;
Rationale:
Oklab tends to look perceptually better for gradients.Sampling uses cell coordinates and a provided brushRect.
For a cell (x, y) inside brushRect, compute normalized coordinates (center-of-cell sampling):
u = (x - brushRect.X + 0.5f) / brushRect.Widthv = (y - brushRect.Y + 0.5f) / brushRect.HeightIf brushRect.Width <= 0 or brushRect.Height <= 0, sampling should return the first stop (or solid color) to keep behavior defined.
For a linear gradient from Start to End (both expressed in relative brush coordinates):
(u, v) to parameter t using projection onto the gradient axis.t.Color.Mix(a, b, t, mixSpace)Clamp: clamp t to [0..1]Repeat: t = t - floor(t)Mirror: triangle-wave mirroring across integer boundariesStyleStyle remains per-cell and contains concrete colors only.
Brushes are applied at render time by:
Color into the Style used for that cell.To avoid duplicating grapheme and wide-glyph handling, V1 should add opt-in helpers (likely as CellBuffer extension methods) such as:
FillRectWithBrush(...) (for background/foreground fill)WriteTextWithBrush(...) (for per-cell foreground/background sampling when writing text)Important details:
string creation, no per-cell collections).V1 integrates only TextBlock and TextFiglet.
Extend TextBlockStyle:
Brush? ForegroundBrushBrush? BackgroundBrushPrecedence:
ForegroundBrush is set, it overrides Foreground for glyph cells.BackgroundBrush is set, it overrides Background where background is applied.Brush rect mapping:
brushRect = BoundslineY, use:
brushRect = new Rectangle(Bounds.X, lineY, Bounds.Width, 1)Background behavior:
FillBackground == true and BackgroundBrush is set, fill the entire bounds using the brush (sample per cell).FillBackground == false, apply background brush only to the cells written by the text (consistent with current background application rules).Extend TextFigletStyle:
Brush? ForegroundBrushBrush? BackgroundBrushBrush rect mapping:
Bounds by default. This naturally supports diagonal gradients across the figlet area.Behavior:
ForegroundBrush is set, sample per glyph cell and set the foreground.BackgroundBrush is set, apply it to glyph cells (V1 keeps background "glyph-only" unless a clear FillBackground use case appears).ForegroundBrush = LinearGradient((0,0), (1,0), [red -> yellow -> green])ForegroundBrush = LinearGradient((0,0), (1,1), [accent -> white])Start/End positions or use Repeat/Mirror tile modes to move a band.Unit tests:
Color.Default (V1)ColorMixSpace modesIntegration (render-level) tests:
TextBlock:
FillBackground + BackgroundBrush fills the entire bounds with sampled colorsTextFiglet: