Skip to content

Drawing Scenes Brushes and Charts

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

Drawing: scenes, brushes & charts

Cursorial.Drawing is a retained, cached-raster drawing layer that sits on top of the cell buffer. The unit of work is a Scene — a buffer you paint through a DrawingContext and that re-rasters only when you mark it dirty — composited onto a target by a SceneCompositor that repaints only what changed. Reach for it when you want WPF/Avalonia-style brushes, gradients, a pen/box engine with automatic junctions, soft shadows, or charts, but you don't need the full UI framework.

If you've used WPF's DrawingContext / Brush / Pen, the shapes will be familiar. The big difference is that a terminal cell shows exactly one solid color, so an IBrush is resolved to a scalar Color per cell at draw time — it never lives inside a cell or a Style.

Scenes and the draw delegate

A Scene owns its own cell buffer, cleared to transparent so unpainted cells contribute nothing when composited. You paint by passing a delegate to Draw:

using Cursorial.Drawing;
using Cursorial.Drawing.Media;
using Cursorial.Output;          // Color, Colors, TextAttributes
using Cursorial.Rendering;       // Rect

var scene = Scene.Create(columns: 24, rows: 8);

scene.Draw(ctx =>
{
    ctx.FillRectangle(new Rect(0, 0, 24, 8),
                      new LinearGradientBrush(Colors.Cyan, Colors.Blue));
    ctx.DrawBox(new Rect(0, 0, 24, 8), Pens.Light);     // junctions auto-resolve where strokes meet
    ctx.DrawText(2, 2, "Cached & composited", Colors.White);
});

Key facts about a scene:

  • It is a cached raster. Draw re-rasters only when the scene IsDirty. The expensive work — gradient sampling, junction resolution, text layout, curve interpolation — lives behind that gate, so re-calling Draw on an unchanged scene costs nothing.
  • You own invalidation. The layer is memoryless (it does not record draw ops, so it cannot auto-detect content change or re-flow on resize). Call scene.Invalidate() when your content changes; invalidation is coarse (whole-scene).
  • RasterVersion is a monotonic counter bumped on each actual re-raster (never on a clean no-op). Snapshot it before a compositing pass and compare after: a stable version means the raster content is unchanged and prior composite output is still valid. (This is exactly what the compositor uses internally.)
  • A scene does not know where it sits — it composites onto a target; placement is the compositor's job.

Use Scene.Create(columns, rows) for a persistent (owner-held) scene. For transient, per-frame scenes, rent from a ScenePool (below) instead so buffers don't churn.

The draw surface (DrawingContext)

DrawingContext is the authoring surface handed to your Draw delegate. Coordinates are integer cells. The methods you'll use most:

  • Set(column, row, grapheme, style) — a single scalar cell write.
  • FillRectangle(rect, brush) (or a Color) — fills cell backgrounds only, leaving any glyph beneath showing through on composite. This is the scene's transparency model: a translucent sampled color is stored verbatim so the compositor can blend it. An overload takes separate brushBounds so a gradient can span a larger extent than the painted region.
  • FillOpaque(rect, brush, attributes) — fills with space-bearing cells that occlude (hide) any glyph on a lower layer. Use it for opaque panels, modals, and menus drawn over content. To draw a bordered opaque panel, fill then stroke the border with overwrite: true: ctx.FillOpaque(rect, color); ctx.DrawBox(rect, pen, overwrite: true);.
  • DrawText(column, row, text, foreground, background?, baseStyle) — multi-line-capable text. A Color overload covers the solid case; an IBrush overload samples the brush per cell (so a gradient colors the text glyph by glyph). It interprets \r\n / \n / \r as line breaks, is grapheme-aware (wide clusters occupy two cells), and returns the text's bounding box as a Size.
  • Pen strokesDrawLine, DrawBox, DrawRectangle (box + optional fill), and the titled variants below. Each has both a Pen and a solid-Color overload.

Pen strokes, junctions, and figures

Pen strokes are deferred: they accumulate so box-drawing junctions form automatically across separate DrawLine/DrawBox calls, then flush once after your draw delegate returns — and they flush last, so existing glyphs (like text) survive a box edge crossing them (text beats decoration). An axis-aligned line uses box-drawing glyphs and forms junctions; a diagonal line rasterizes into braille dots at sub-cell resolution.

scene.Draw(ctx =>
{
    ctx.DrawBox(new Rect(0, 0, 20, 5), Pens.Light);
    ctx.DrawLine(10, 0, 10, 4, Pens.Light);   // a T-junction forms where this meets the box's top edge
});

Wrap related strokes in a figure (using var figure = ctx.BeginFigure();) when you want junctions to form among themselves but not with strokes outside the group — and to make a pen's gradient sample against the union bounds of the figure's own strokes. BeginFigure(rect) pins explicit brush bounds instead. Figures do not nest.

Titled panels

DrawTitledBox strokes a box with a title laid onto the top edge, the rule splitting around the label so it never overstrikes the text. DrawPanel is the one-call "group box": an optional fill, a pen border, and an optional PanelTitle.

ctx.DrawPanel(new Rect(0, 0, 30, 6),
              pen:   Pens.Rounded.WithColor(Colors.Cyan),
              fill:  new SolidColorBrush(Color.FromRgb(20, 24, 40)),
              title: new PanelTitle("Status") { Position = TitlePosition.Center });

A PanelTitle lifts implicitly from a string for the common left-aligned case, and carries its own brush, position, and SGR attributes.

Shadows

DrawDropShadow(element, geometry, shadowColor) paints a soft drop shadow cast by an element's silhouette — draw it before the element. DrawInnerShadow(...) insets a shadow inside an element's edges — draw it after the fill it insets. Both take a ShadowGeometry (radius, strength, which ShadowEdges cast); the parameterless Drop() / Inner() factories give a soft black default.

ctx.DrawDropShadow(panelRect, ShadowGeometry.Drop(radius: 2), Color.FromRgba(0, 0, 0, 200));
ctx.DrawPanel(panelRect, Colors.White, fill: Brushes.Black);

The shadow color must be RGB (a Default/palette color carries no alpha to fade). The translucent shadow background rides to the compositor (darkening the layer beneath at composite time); a same-scene glyph it falls on is dimmed at draw time.

Clip & translate stack

DrawingContext carries a nesting clip/translate stack honored by every draw path — the per-cell writes, the text and content paths, shadows, titled panels, and the deferred pen strokes and chart braille:

  • PushClip(rect) bounds subsequent draws to a rectangle (intersected with the current clip).
  • PushTranslate(columns, rows) shifts subsequent draw coordinates (offsets may be negative — content scrolled above/left of its viewport).
  • Push(clip, translateColumns, translateRows) is the common "give this widget a viewport" call.

Each returns a DrawingStateScope you dispose (via using) to pop. IsVisible(column, row) is the cheap pre-test for whether a draw would land inside the active clip.

using (ctx.Push(clip: new Rect(0, 0, 10, 4), translateRows: -scrollOffset))
{
    ctx.DrawText(0, 0, longBody, Colors.White);   // clipped to the viewport, scrolled by the translate
}

Brushes and pens

A brush is an immutable color source (IBrush) sampled per cell. The built-ins live in Cursorial.Drawing.Media:

  • SolidColorBrush — one color (with optional Opacity 0–1). A Color converts implicitly to a SolidColorBrush, and the Brushes static class offers shared singletons (Brushes.Red, Brushes.Transparent, Brushes.Default, …).
  • LinearGradientBrush — a sweep between RelativePoint endpoints (default a horizontal left→right sweep). The two-color constructor new LinearGradientBrush(start, end) is the common case; the stops-list constructor takes any number of GradientStops.
  • RadialGradientBrush — an SVG-style two-point radial (center, focal point, X/Y radii as fractions of the bounds).
  • TileBrush / ImageBrush — sample a decoded RGBA image across the paint bounds. ImageBrush maps one image into the bounds per a Stretch mode; TileBrush repeats a tile per a TileMode. Both have a FromPng(...) factory.

A Pen is a stroke definition: a color source (Brush) plus glyph-selecting attributes — Weight (Light / Heavy / Double), Corners, Dash, EndCap, Junction mode, GlyphSet (Unicode or ASCII), and SGR Attributes. Color is kept separate from stroke attributes, so the Pens presets are keyed on attributes and you add color at the call site:

var pen = Pens.Heavy.WithColor(Colors.LightGreen).WithCorners(CornerStyle.Rounded);
ctx.DrawBox(new Rect(0, 0, 12, 4), pen);

// default(Pen) is a usable light/sharp/solid pen at the terminal default color:
ctx.DrawBox(new Rect(0, 0, 12, 4), default);

A terminal stroke is always one cell wide — Weight selects a glyph family, never pixel thickness. There is no implicit Color → Pen (or Color → IBrush) conversion; every pen-typed draw method ships a sibling Color overload.

Compositing scenes

A SceneCompositor composites an ordered z-stack of SceneLayers onto a target buffer. Each layer pairs a Scene with CompositeParameters — an integer cell offset, a uniform opacity (0–255), an optional clip, and an optional blend mode. (Affine rotation/scale are deliberately absent — a cell grid can't express sub-cell transforms.)

using Cursorial.Rendering;

var compositor = new SceneCompositor(Style.Default);    // or a stored backdrop CellBuffer
var target = new CellBuffer(80, 24);

scene.Draw(draw);     // owner-driven re-raster happens first

ReadOnlySpan<SceneLayer> layers =
[
    new SceneLayer(background),                                       // lower (composited first)
    new SceneLayer(scene, new CompositeParameters(offsetColumn: 4, offsetRow: 2, opacity: 220)),
];

bool changed = compositor.Composite(layers, target.AsView());
// changed == false when nothing moved or re-rastered: the target is left untouched and your
// frame renderer's own diff emits nothing.

The compositing invariant is what makes retained translucent scenes correct: a scene cell is never composited onto a previously-composited cell — it is always composited onto the base (or a lower scene freshly composited onto base, in z-order). Compositing a translucent scene onto its own prior output each frame would saturate and drift; compositing onto base each frame is stable. The compositor maintains the target incrementally (treat it as retained — don't clear it between frames): it computes the dirty-region union from any scene that re-rastered (a bumped RasterVersion) or any layer whose parameters changed, resets just that union to base, re-composites the intersecting layers bottom-up, and marks the union dirty so a dirty-region-restricted renderer gets a correct bounded repaint.

Pooling transient scenes

ScenePool recycles scene backing buffers so per-frame scenes don't allocate. Rent(columns, rows) reuses a freed buffer of the exact same dimensions when one is available; scene.Dispose() returns the buffer to its pool. Persistent (cached) scenes are owner-held and made with Scene.Create instead.

var pool = new ScenePool();
using var frameScene = pool.Rent(40, 10);   // returns to the pool on dispose
frameScene.Draw(ctx => ctx.DrawText(0, 0, "transient", Colors.White));

Charts

Cursorial.Drawing.Charts renders line / scatter / bar charts (plus sparklines) into a rectangular area of a DrawingContext. A chart is a draw-op, not a surface — wrap one in a scene yourself. There are two ways to use them.

One-liner extension methods on DrawingContext for the common cases (no chart object needed):

using Cursorial.Drawing.Charts;

scene.Draw(ctx =>
{
    ReadOnlySpan<double> samples = [3, 1, 4, 1, 5, 9, 2, 6];
    ctx.BarChart(new Rect(0, 0, 30, 8), samples, Colors.Cyan);
    ctx.Sparkline(0, 9, width: 30, samples, Colors.LightGreen);

    ReadOnlySpan<PointD> series = [new(0, 0), new(1, 2), new(2, 1), new(3, 3)];
    ctx.LineChart(new Rect(0, 11, 30, 8), series, Colors.Yellow, CurveInterpolation.CatmullRom);
    ctx.ScatterChart(new Rect(0, 20, 30, 8), series, Colors.Magenta, MarkerStyle.Dot);
});

Chart objects for the richer options — construct one (e.g. new BarChart(values, brush), new LineChart(points, color), new MultiLineChart(series)) configured with init properties, then call ctx.Chart(area, chart):

var chart = new BarChart(samples, new LinearGradientBrush(Colors.Cyan, Colors.Blue))
{
    Orientation = BarOrientation.Vertical,
    Gap = 1,
};
scene.Draw(ctx => ctx.Chart(new Rect(0, 0, 40, 10), chart));

Notable behaviors:

  • Bars are signed and zero-anchored. Positives grow toward the far edge, negatives toward the near edge, with fractional Block-Elements glyphs so lengths aren't cell-quantized. An explicit Range is always expanded to include zero (a bar axis that omits zero misrepresents magnitude).
  • Line/scatter charts use braille for sub-cell resolution; CurveInterpolation controls how points connect (Linear, CatmullRom, …), and NaN values are gaps in the line.
  • MultiLineChart shares one axis range across several ChartSeries so they align; ResolveRange() gives you that range to hand to an Axes frame.
  • Every chart brush is sampled per cell against the chart area, so a gradient flows across the whole plot.
  • Chart implementations clip to the context bounds and never throw.

See also

Clone this wiki locally