Skip to content

Core Output

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

Output writers, style & color

The output layer of Cursorial.Core is everything you need to emit correct VT escape sequences without hand-rolling byte strings: a byte sink, a Style/Color value-type vocabulary, a family of pure byte-emitting writers (SGR, cursor, screen, hyperlinks, sized text), and a capability-aware quantizer that down-converts a style to what the terminal can actually render. Reach for these directly when you want "print red bold text and move the cursor" without pulling in the cell buffer and renderer above — or use them as the substrate that the renderer sits on.

The design goal is composability: every writer targets IBufferWriter<byte>, so the same call feeds a live terminal (via a session's sink), an in-memory ArrayBufferWriter<byte> for tests, or a recorded trace.

The byte sink

IOutputByteSink is the destination for bytes headed to the terminal — the output counterpart to IInputByteSource. It exposes a single Writer of type System.IO.Pipelines.PipeWriter, used with the standard pipe protocol (get memory, advance, flush). A TerminalSession hands you one as session.Output.

Two contract rules:

  • Never call PipeWriter.Complete yourself. Completing the underlying transport is the sink's job and happens through IAsyncDisposable.DisposeAsync.
  • Do call FlushAsync when bytes must reach the terminal before further work proceeds — most importantly during capability negotiation, where the app waits for a response before continuing.
var frame = new ArrayBufferWriter<byte>();
SgrEncoder.WriteAbsolute(frame, style);
// ... append more sequences into the same scratch buffer ...
await session.Output.Writer.WriteAsync(frame.WrittenMemory);
await session.Output.Writer.FlushAsync();

Style, color, and attributes

Style is a readonly record struct carrying foreground/background Color, a TextAttributes flag set, an UnderlineStyle shape, an independent underline Color, and an optional OSC 8 Hyperlink. default(Style) (also Style.Default) means "no styling" — both colors default, no attributes. Equality is component-wise, so a stable style produces no diff. Compose fluently with allocation-free With* helpers; each returns a new value:

var style = Style.Default
    .WithForeground(Color.FromRgb(64, 224, 208))   // truecolor
    .WithBackground(Color.FromPalette(0))           // ANSI palette index
    .WithAttributes(TextAttributes.Bold | TextAttributes.Italic);

The helper set: WithForeground, WithBackground, WithAttributes (replace the whole set), AddAttributes / RemoveAttributes (toggle bits), WithUnderlineStyle, WithUnderlineColor, and WithHyperlink (a Hyperlink overload and a (uri, id?) overload). Style.Transparent is the compositing identity used by the drawing layer (all colors Color.Transparent).

Color

Color is a value type discriminated by ColorKind:

  • Color.Default — the terminal's default for whatever slot it lands in (no SGR color emitted).
  • Color.FromPalette(byte) — an index into the 256-color palette; indices 0–15 are the ANSI base colors.
  • Color.FromRgb(r, g, b) — fully opaque 24-bit truecolor.
  • Color.FromRgba(r, g, b, a) — truecolor with an explicit alpha channel.

Convenience factories: Color.FromHex("#RRGGBB") (also 3-digit #RGB), Color.FromHsv(h, s, v, a), Color.Transparent (Rgb with alpha 0). The Colors static class has named constants (Colors.TrueWhite, Colors.TrueBlack, ANSI palette entries, and more). Read the channels via Red/Green/Blue/PaletteIndex/ Alpha; query with IsDefault/IsOpaque/IsTransparent.

Alpha semantics. Alpha (0 = transparent, 255 = opaque) only takes effect during layered drawing into a cell buffer, where the active blending mode composites the source over the existing cell. Alpha is meaningful only for Rgb colors — palette and default colors short-circuit through compositing because they have no known RGB equivalent to mix against. Terminal output is always opaque: the cell buffer stores the fully composited result and the renderer emits SGR for it as an opaque color. (Compositing itself lives one layer up in Cursorial.Rendering.)

Attributes and underlines

TextAttributes is a [Flags] enum — Bold, Faint, Italic, Underline, Blink, Inverse, Hidden, Strikethrough, Overline — combined with bitwise OR. The presence of an underline is the Underline flag; the shape is the separate UnderlineStyle enum (Single, Double, Curly, Dotted, Dashed), because the shapes don't combine. The shape is only emitted when the Underline flag is set, and it maps to the SGR 4:n colon sub-parameter form for the extended shapes.

SGR encoding

SgrEncoder turns a Style into SGR escape-sequence bytes. It is pure — no capability awareness, no "previously emitted" state — with three operations:

  • WriteReset(writer) — emit CSI 0 m, resetting all SGR state.
  • WriteAbsolute(writer, in style) — emit SGR 0 plus the parameters needed to express the style, putting the terminal into a known state regardless of what was active. The right call for one-off styled output.
  • WriteDelta(writer, in from, in to) — emit only the parameters that differ between two styles (including reset codes for attributes that turn off). Writes zero bytes when the styles are equal. This is what the diff renderer uses when painting many adjacent runs.
var buffer = new ArrayBufferWriter<byte>();
SgrEncoder.WriteAbsolute(buffer, Style.Default.WithForeground(Color.FromPalette(1)));
// emit your glyph bytes here...
SgrEncoder.WriteReset(buffer);

SgrEncoder knows the SGR fine print so you don't have to: the shared SGR 22 reset for both Bold and Faint, the 4:n colon form for extended underline shapes, and the SGR 58/59 underline-color extension.

Cursor, screen, and hyperlinks

CursorWriter emits cursor positioning, visibility, and shape sequences. Positions are 0-based in the API, 1-based on the wire — the writers translate at the boundary, so you stay in cell coordinates throughout.

  • WriteMoveTo(writer, column, row) (CUP), WriteColumnAbsolute (CHA), WriteRowAbsolute (VPA).
  • Relative moves WriteMoveUp / WriteMoveDown / WriteMoveLeft / WriteMoveRight (a count of zero or less is a no-op).
  • WriteSavePosition / WriteRestorePosition (DECSC / DECRC).
  • WriteHide / WriteShow (DEC mode 25).
  • WriteShape(writer, CursorShape) (DECSCUSR) with the seven xterm shapes, including CursorShape.Default ("restore the terminal default") and the blinking/steady block, underline, and bar forms.

ScreenWriter covers screen-level sequences:

  • Clear: WriteClearScreen, WriteClearScreenAfter / Before, WriteClearScreenAndScrollback; and the line variants WriteClearLine, WriteClearLineAfter / Before.
  • Alternate screen buffer: WriteEnterAlternateScreen / WriteLeaveAlternateScreen (DECSET/DECRST 1049 — saves and restores the cursor). Wrap any full-screen UI in these so the user's shell scrollback reappears on exit.
  • Autowrap: WriteDisableAutowrap / WriteEnableAutowrap (DECAWM). Cell-grid renderers should disable autowrap for the session — they always position via CUP, and leaving it on invites a deferred-wrap drift bug on some terminals.
  • Scroll region: WriteScrollRegion(writer, topRow, bottomRow) (DECSTBM, 0-based inclusive rows on the API), WriteResetScrollRegion, and WriteScrollUp / WriteScrollDown(writer, lines) (SU/SD).

HyperlinkWriter emits OSC 8 hyperlink anchors:

HyperlinkWriter.WriteHyperlink(buffer, "https://example.com", "Open the docs");
// or open/close manually around styled runs:
HyperlinkWriter.WriteOpen(buffer, "https://example.com", id: "doc-1");
// ...emit link text...
HyperlinkWriter.WriteClose(buffer);

Gate emission on TextStylingCapabilities.Hyperlinks if you're targeting legacy terminals. The optional id groups adjacent or wrapped runs into one logical link — give the same id to every anchor that should highlight together.

Sized text (Kitty OSC 66)

TextSizingWriter emits the Kitty OSC 66 text-sizing protocol, driven by a TextSizing value:

var sizing = new TextSizing(Scale: 2, Width: 0);          // double-tall, auto width
TextSizingWriter.WriteSplit(buffer, sizing, "BIG TITLE"); // chunks long strings on grapheme boundaries

TextSizing carries the spec parameters (Scale/Width/Numerator/Denominator plus Vertical/Horizontal alignment); default / TextSizing.Normal is plain text and emits an empty metadata block. Write emits a single sequence (caller respects the 4096-byte payload cap); WriteSplit chunks at grapheme-cluster boundaries when the payload exceeds the cap, so a multi-codepoint glyph is never split mid-sequence. Sized text bypasses the cell grid — it's drawn at a fixed position rather than encoded as cell contents. A non-supporting terminal renders the text at normal size and ignores the OSC.

Capability-aware quantization

StyleQuantizer adapts a Style down to what a given terminal can render. Construct it once with an OutputCapabilities and reuse it — it holds no state beyond its capabilities, and Quantize returns an unchanged copy of any already-renderable style.

var quantizer = new StyleQuantizer(session.Capabilities.Output);
Style safe = quantizer.Quantize(style);    // RGB -> palette/16 as needed, drops unsupported attrs
SgrEncoder.WriteAbsolute(buffer, safe);

The typical pipeline is style → StyleQuantizer.Quantize → SgrEncoder.Write{Absolute|Delta} — keeping the encoder pure (it emits exactly what the style describes) and the quantizer the single place capability decisions live. What it adjusts:

  • Color depth — RGB → 256-color (xterm's 6×6×6 cube + grayscale ramp) when truecolor is unavailable; palette > 15 → 16 via channel-on thresholds.
  • Underline shape — extended shapes collapse to Single when the terminal lacks extended-underline support; colored underline is dropped when unsupported.
  • Attributes — drops attribute bits the terminal doesn't honor.

The cell-buffer renderer in Cursorial.Rendering does this for you when constructed with an OutputCapabilities — it holds a StyleQuantizer and runs every cell through it before diffing. Quantize by hand only when you're driving the writers directly.

Output capabilities

OutputCapabilities is the aggregate describing which output features a terminal honors, broken into categorized records (each with a None static for defaults). You get a realized instance from negotiation as session.Capabilities.Output:

  • Color (ColorCapabilities) — Depth (a ColorDepth of NoColor/Ansi16/Ansi256/Truecolor, ordered so depth >= ColorDepth.Ansi256 is meaningful), TruecolorVerified (truecolor was empirically round-tripped, not merely advertised), OscPaletteSet, and the queried DefaultForeground / DefaultBackground / DefaultCursorColor (nullable — useful for light-vs-dark detection and accent-color picking).
  • Styling (TextStylingCapabilities) — Italic, Underline, ExtendedUnderline, ColoredUnderline, Strikethrough, Overline, Hyperlinks.
  • TextSizing (TextSizingCapabilities) — the OSC 66 sub-features.
  • Graphics, Cursor, Window, Protocol — image protocols, cursor shape/color control, window manipulation, and output-side protocols (synchronized output, etc.).

These are realized capabilities — features a terminal claims but doesn't honor are reported unavailable. See Capability negotiation for how the negotiator builds this surface.

See also

  • Capability negotiation — how OutputCapabilities is detected and realized.
  • Cell buffer & renderer — the diffing layer that composes these writers.
  • Text utilities — grapheme widths and ANSI-aware wrapping that pair with styled output.
  • Input — the input counterpart, with the parallel IInputByteSource abstraction.

Clone this wiki locally