-
Notifications
You must be signed in to change notification settings - Fork 0
Rendering Rich Text and Images
The content tier sits on top of the cell buffer: styled, wrappable rich text; inline images that pick the best terminal graphics protocol at paint time; and oversized "banner" text. You reach for it whenever you need more than a single styled run — a paragraph that wraps and ellipsizes, an OSC 8 hyperlink that survives a line break, a logo, or a headline rendered three rows tall.
Everything here funnels through one interface, IContent, with two operations: Measure (how
many cells do I want, given this envelope?) and Paint (draw into this rectangle). The richest
implementations chain across delivery mechanisms — an image tries Kitty graphics, then iTerm2,
then Sixel, then a glyph placeholder — and the call site never sees the branching.
If you've used WPF, the rich-text model maps cleanly: RichText is the document, TextFormatter
is the layout engine (TextWrapping → WrapMode, TextTrimming → TextTrimming,
TextAlignment → TextAlignment), and FormattedText is the laid-out result you paint.
The pipeline is author → format → paint:
- Build a
RichTextdocument (markup string or fluent builder). - Run it through a
TextFormatter.Format(...)against a column budget to get an immutableFormattedText. -
PainttheFormattedTextinto aCellBufferView.
using Cursorial.Rendering.Text;
// 1. Author with BBcode-style markup.
RichText doc = TextMarkup.Parse(
"[b]Status:[/b] [fg=green]online[/fg]. See [link=https://example.com]the dashboard[/link].");
// 2. Lay it out against a 40-column budget (caps drive color/underline quantization).
var formatter = new TextFormatter { Wrap = WrapMode.WordWrap, Alignment = TextAlignment.Left };
FormattedText formatted = formatter.Format(doc, availableColumns: 40, capabilities: caps);
// 3. Paint into the buffer at a rectangle.
formatted.Paint(buffer.View(new Rect(2, 1, 40, 5)), new Rect(0, 0, 40, 5), caps);Format is pure — call it once and re-Paint the result every frame; it only needs to re-run
when the text or the column budget changes. availableColumns must be positive; an optional
maxRows applies a document-level row cap and trims the last visible line per the formatter's
Trim rule.
TextMarkup.Parse(string) (or Parse(string, TextMarkupOptions)) accepts a strict BBcode-style
grammar. Unknown tags, mismatched closers, and malformed attributes throw FormatException with
a position-anchored message — markup errors surface loudly rather than rendering garbage.
Inline tags (wrap a span of text):
-
[b]…[/b],[i]…[/i],[u]…[/u],[s]…[/s]— bold, italic, underline, strikethrough. -
[fg=color]…[/fg],[bg=color]…[/bg]— foreground / background. Color formats: a name (red,brightblue), a palette index (0–255), or hex (#ff0000or#f00). -
[link=uri]…[/link]— an OSC 8 hyperlink (the[url=…]spelling is accepted too). -
[font=name]…[/font]— per-grapheme remap; built-ins arefullwidth,doublestruck,smallcaps,superscript,subscript. -
[brush=value]…[/brush]— attach an opaque brush tag for the Drawing layer to color the runs with a gradient. Requires aTextMarkupOptions.BrushResolver; the Drawing layer supplies one, so plainCursorial.Renderinguse raises a parse error for[brush].
Block / self-closing tags:
-
[br/]— a hard line break inside the current paragraph. -
[hr]or[hr=style]— a horizontal rule. Styles:light,heavy,double,dashed,dotted, or a literal glyph. -
[p wrap=… align=… trim=… maxlines=N]…[/p]— an explicit paragraph block with its own wrap mode (word|character|nowrap|overflow), alignment (left|right|center|justify), trimming (none|clip|char|word), and line cap. -
[content=name/]— embed a namedIContent(an image, an icon) into the paragraph flow as an indivisible word. Register names throughTextMarkupOptions.Content.
Escape a literal bracket or backslash with \[, \], \\.
For programmatic assembly, drive RichTextBuilder directly — it's what the markup parser uses
under the hood. Style/map/hyperlink layers push and pop with disposable scopes that mirror markup
nesting:
using Cursorial.Output;
using Cursorial.Rendering.Text;
var builder = new RichTextBuilder(defaultStyle: default);
builder.Paragraph(WrapMode.WordWrap, TextAlignment.Left);
builder.Run("Build ");
using (builder.Push(new Style().WithAttributes(TextAttributes.Bold)))
builder.Run("high-quality");
builder.Run(" terminal UIs. ")
.Hyperlink("Docs", "https://example.com") // one-shot link run
.LineBreak()
.Run("Cross-platform .NET.");
RichText doc = builder.Build();Inline calls (Run, LineBreak, Hyperlink, InlineContent) accumulate into the open
paragraph; block calls (Paragraph, HorizontalRule, Figlet, SizedText, Content) close the
open paragraph and start a new block. Build() materializes the immutable RichText.
TextFormatter carries document-wide defaults (Wrap, Alignment, Trim, Ellipsis,
TabWidth) that each paragraph can override. The behaviors mirror WPF:
-
WrapMode—NoWrap,WordWrap(default),WordWrapOverflow(long words spill past the edge rather than break),CharacterWrap(break at any grapheme — the right mode for CJK). -
TextTrimming—None,ClipFromEnd,CharacterEllipsis,WordEllipsis. The ellipsis glyph defaults to…and is configurable viaTextFormatter.Ellipsis. -
TextAlignment—Left,Right,Center,Justify.
Two properties carry through line breaks faithfully, which is the whole point of formatting rich text rather than slicing strings yourself:
-
Styled runs keep their
Stylewhen a line wraps — color and attributes don't reset at the break. - OSC 8 hyperlinks are re-bracketed on each line a link spans, so a wrapped link stays a single clickable target across rows.
FormattedText is an IContent, so you can also nest a laid-out block inside another paragraph
via [content=…/] or RichTextBuilder.InlineContent.
Image draws an encoded picture (ImageData wrapping PNG / JPEG / GIF bytes) at a cell
footprint, choosing a transport at paint time from the negotiated terminal capabilities:
Kitty graphics → iTerm2 inline images → Sixel → a cell-rectangle placeholder.
Kitty is preferred (PNG only via the encoded-bytes path); iTerm2 is the practical fallback on
iTerm2 and WezTerm and accepts PNG/JPEG/GIF; Sixel is the broad legacy fallback (PNG only —
Cursorial decodes and resamples it internally). When no graphics protocol is available, the image
paints a placeholder rectangle with a centered [image] label so the reserved region stays
visually obvious.
using Cursorial.Rendering.Content;
using Cursorial.Rendering.Imaging;
var data = new ImageData(pngBytes, ImageFormat.Png, requestedSize: new Size(20, 10));
var image = new Image(data);
// Or load by URI (embedded://, file://, or a path relative to AppContext.BaseDirectory):
var fromUri = new Image(new Uri("file:///assets/logo.png"), renderSize: new Size(20, 10));
image.Paint(buffer.View(new Rect(0, 0, 20, 10)), new Rect(0, 0, 20, 10), style: default, caps);Supply a cell footprint on exactly one axis (e.g. new Size(20, 0)) and the other is derived from
the image's native aspect ratio, with the cell's pixel shape factored in when the terminal
reported it.
Icon is a small Image (default 2×1 cells) that renders as a real picture where a graphics
protocol exists and as a configured glyph — an emoji or symbol — when it doesn't, or when the
image fails to load. Resource loading happens once, at construction; ImageLoaded tells you which
branch was taken.
var icon = Icon.FromEmbedded(
assemblyName: "MyApp",
resourceName: "Assets.warning.png",
fallbackGlyph: "⚠"); // shown when no graphics protocol / load failure
// Icon.FromFile(path, glyph) and the (Uri, glyph) constructor are also available.Images and icons attach a fragment to the buffer's sidecar rather than writing cells. The
FrameRenderer snapshots registered fragments each frame
and skips re-emitting an unchanged one — same fragment identity, same anchor, same anchor
style. Reusing the same Image instance across frames is the diff-friendly default, so a static
logo costs nothing to leave on screen. The renderer also re-emits when a fragment that was present
last frame is gone, clearing its footprint.
ScaledText renders a short string larger than one cell. Like images, it picks its path at paint
time: Kitty's OSC 66 text-sizing protocol where the terminal honors it, falling back to a
bundled FIGlet face (or a decorated/monospace font) everywhere else. The fallback face is
chosen from the requested TextSizing.Scale unless you pass an explicit one.
using Cursorial.Output;
using Cursorial.Rendering.Content;
// Scale 3 — a triple-size headline. OSC 66 on Kitty; a FIGlet face elsewhere.
var headline = new ScaledText("READY", new TextSizing(Scale: 3));
headline.Paint(buffer.View(new Rect(0, 0, 40, 8)), new Rect(0, 0, 40, 8), style: titleStyle, caps);TextSizing is a value type from Cursorial.Output — Scale (1–7), Width, fractional
Numerator/Denominator, and vertical/horizontal block alignment; default is "normal text".
ScaledText.Measure reports the footprint the chosen path wants; if scaled text would overflow
the column budget it degrades to a monospace rendering that fits. For raw OSC 66 emission without
the cell grid, see TextSizingWriter in Core output.
- Cell buffer & renderer — the surface this content paints into and the diff renderer that emits image fragments.
-
Scenes, brushes & charts — the retained-scene layer that
wires up the
[brush=…]gradient resolver and draws over this content. -
Core output —
Style,Color, OSC 8 hyperlinks, and theTextSizingWriterprimitive. - Core text utilities — the grapheme-width and ANSI-aware wrapping primitives this layer measures with.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative