Skip to main content

Draw directly with Ultraviolet

Ultraviolet is the low-level rendering layer beneath Artisanal's TUI runtime and widget framework. Most applications do not need to begin here. Use it directly when you want to draw cells, compose layers, decode terminal input, build a renderer, or work with terminal image protocols.

New code should import package:ultraviolet/ultraviolet.dart. package:artisanal/uv.dart remains as a compatibility export.

The basic idea

UV models the terminal as layers of drawable cells. The architecture consists of:

  • Cell: A single character with style, link, and display width
  • Buffer: A 2D grid of cells representing screen state
  • Screen: Abstract drawing surface interface
  • Canvas: Immediate-mode drawing with composition
  • UvTerminalRenderer: Efficient diff-based ANSI output
  • Terminal: Lifecycle management and I/O orchestration
import 'dart:io';

import 'package:ultraviolet/ultraviolet.dart';

void main() async {
final terminal = Terminal();
await terminal.start();

// Draw a red "H" at (0, 0)
terminal.setCell(0, 0, Cell(
content: 'H',
style: UvStyle(fg: UvColor.rgb(255, 0, 0)),
));

terminal.draw();
await terminal.stop();
}

Cell and UvStyle

Cell

A Cell represents a single terminal cell containing:

  • content: The grapheme (character or emoji)
  • style: Visual attributes via UvStyle
  • link: Optional hyperlink via Link (OSC 8)
  • width: Display width in columns (1 for normal, 2 for wide characters)
  • drawable: Optional embedded drawable for images/graphics
// Basic cell creation
final cell = Cell(
content: 'A',
style: const UvStyle(fg: UvColor.rgb(255, 0, 0)),
link: const Link(url: 'https://example.com'),
);

// Create from grapheme with automatic width calculation
final wideCell = Cell.newCell(WidthMethod.wcwidth, ''); // width = 2

// Empty cell (space)
final empty = Cell.emptyCell();

// Check cell state
print(cell.isZero); // No content, style, or link
print(cell.isEmpty); // Plain space with no attributes

UvStyle

UvStyle holds visual attributes for a cell:

final style = UvStyle(
fg: UvColor.rgb(255, 255, 255), // Foreground color
bg: UvColor.basic16(4), // Background color (blue)
underlineColor: UvColor.indexed256(196), // Underline color (red)
underline: UnderlineStyle.curly, // Underline variant
attrs: Attr.bold | Attr.italic, // Text attributes
);

// Available attributes (bitmask)
Attr.bold // Bold text
Attr.faint // Dim/faint text
Attr.italic // Italic text
Attr.blink // Slow blink
Attr.rapidBlink // Fast blink
Attr.reverse // Reverse video
Attr.conceal // Hidden text
Attr.strikethrough // Strikethrough

// Underline styles
UnderlineStyle.none
UnderlineStyle.single
UnderlineStyle.double
UnderlineStyle.curly
UnderlineStyle.dotted
UnderlineStyle.dashed

// Copy with modifications
final newStyle = style.copyWith(
fg: UvColor.basic16(2),
clearBg: true, // Remove background color
);

Link enables OSC 8 hyperlinks:

final link = Link(
url: 'https://example.com',
params: 'id=my-link', // Optional parameters
);

// Check if link is empty
print(link.isZero); // true if no URL or params

UvColor Types

UV provides three color representations matching terminal capabilities:

UvBasic16

The standard 16-color ANSI palette (8 normal + 8 bright):

// Normal colors (0-7): black, red, green, yellow, blue, magenta, cyan, white
final red = UvColor.basic16(1);
final blue = UvColor.basic16(4);

// Bright variants
final brightRed = UvColor.basic16(1, bright: true);
final brightWhite = UvColor.basic16(7, bright: true);

UvIndexed256

The 256-color xterm palette:

// Indices 0-15: standard colors
// Indices 16-231: 6x6x6 color cube
// Indices 232-255: grayscale ramp

final orange = UvColor.indexed256(208);
final gray = UvColor.indexed256(240);

UvRgb

True 24-bit color with optional alpha:

final coral = UvColor.rgb(255, 127, 80);
final semiTransparent = UvColor.rgb(255, 0, 0, a: 128);

Buffer

Buffer is a 2D grid of Lines containing Cells. It tracks modifications ("touched" lines) for efficient incremental rendering.

Creating and Resizing

// Create an 80x24 buffer
final buf = Buffer.create(80, 24);

// Resize (preserves content where possible)
buf.resize(120, 40);

// Get dimensions
print(buf.width()); // 120
print(buf.height()); // 40
print(buf.bounds()); // Rectangle(0, 0, 120, 40)

Cell Access

// Get a cell
final cell = buf.cellAt(10, 5);

// Set a cell (automatically marks line as touched)
buf.setCell(10, 5, Cell(content: 'X'));

// Access a line directly
final line = buf.line(5);
line?.set(10, Cell(content: 'Y'));

// Mark cells as dirty for re-rendering
buf.touch(10, 5);
buf.touchLine(0, 5, 80); // Touch 80 cells starting at x=0

Fill and Clear Operations

// Fill entire buffer
buf.fill(Cell(content: '.', style: UvStyle(fg: UvColor.basic16(8))));

// Fill a rectangular area
buf.fillArea(
Cell(content: '#'),
rect(5, 5, 20, 10),
);

// Clear (fill with spaces)
buf.clear();
buf.clearArea(rect(0, 0, 40, 12));

Line Operations (ANSI IL/DL semantics)

// Insert 3 blank lines at row 10
buf.insertLine(10, 3, Cell.emptyCell());

// Delete 2 lines at row 5
buf.deleteLine(5, 2, Cell.emptyCell());

// Insert/delete within a region
buf.insertLineArea(10, 2, null, rect(0, 5, 80, 20));
buf.deleteLineArea(8, 1, null, rect(0, 5, 80, 20));

// Cell insert/delete (ICH/DCH)
buf.insertCell(10, 5, 3, null); // Insert 3 cells at (10, 5)
buf.deleteCell(10, 5, 2, null); // Delete 2 cells at (10, 5)

Dirty Tracking

Buffer supports optional per-cell dirty tracking to allow renderers to skip unchanged regions. Enable it at creation time:

// Create a buffer with dirty-bit tracking enabled
final buf = Buffer.create(80, 24, tracksDirty: true);

// Mark a single cell as dirty explicitly
buf.markDirty(x, y);

// Check whether any row is dirty
if (buf.dirtyRows[y]) { ... }

// Check the dirty-bit for a specific cell column
// dirtyBits is a List<Uint32List>; each word covers 32 columns.
final word = buf.dirtyBits[y][x >> 5];
final bit = (word >> (x & 31)) & 1;

When tracksDirty is false (the default), dirtyRows and dirtyBits are empty lists and dirty marking is a no-op. The UV terminal renderer uses dirty tracking internally when it operates on an OwnedCellScreen to limit the number of ANSI sequences emitted per frame.

Cloning

// Clone entire buffer
final copy = buf.clone();

// Clone a region
final region = buf.cloneArea(rect(10, 10, 30, 15));

Rendering to String

// Render with ANSI sequences
final ansiOutput = buf.render();

// Plain text (no escapes)
final plainText = buf.toString();

Screen

Screen is the abstract interface for a 2D drawing surface:

abstract class Screen {
Rectangle bounds();
Cell? cellAt(int x, int y);
void setCell(int x, int y, Cell? cell);
WidthMethod widthMethod();
}

Optional Fast-Path Interfaces

Implementations can provide optimized operations:

// ClearableScreen - fast full clear
abstract class ClearableScreen implements Screen {
void clear();
}

// ClearAreaScreen - fast region clear
abstract class ClearAreaScreen implements Screen {
void clearArea(Rectangle area);
}

// FillableScreen - fast full fill
abstract class FillableScreen implements Screen {
void fill(Cell? cell);
}

// FillAreaScreen - fast region fill
abstract class FillAreaScreen implements Screen {
void fillArea(Cell? cell, Rectangle area);
}

// CloneableScreen - clone backing buffer
abstract class CloneableScreen implements Screen {
Object clone();
}

// CloneAreaScreen - clone region
abstract class CloneAreaScreen implements Screen {
Object? cloneArea(Rectangle area);
}

// OwnedCellScreen - take ownership of a cell (zero-copy fast path)
abstract class OwnedCellScreen implements Screen {
void setCellOwned(int x, int y, Cell? cell);
}

OwnedCellScreen lets implementations take ownership of an already-allocated Cell instead of cloning it, reducing allocations in high-frequency paint loops. Only pass cells that are not shared with another buffer or line.

ScreenBuffer

ScreenBuffer is a concrete implementation combining Screen with a Buffer:

final screen = ScreenBuffer(80, 24);

// Use as Screen
screen.setCell(0, 0, Cell(content: 'H'));

// Access underlying buffer
screen.buffer.fill(Cell.emptyCell());

Canvas

Canvas provides immediate-mode drawing with composition support:

final canvas = Canvas(80, 24);

// Basic operations
canvas.setCell(5, 5, Cell(content: '*'));
canvas.clear();
canvas.resize(100, 30);

// Compose drawables
canvas.compose(StyledString('Hello, World!'));
canvas.compose(roundedBorder());

// Render to string
final output = canvas.render();

Composition Pattern

final canvas = Canvas(40, 10);

// Build up layers
canvas
..compose(normalBorder())
..compose(StyledString('\x1b[1mTitle\x1b[0m'));

// Or chain with method return
canvas.compose(drawer1).compose(drawer2);

Drawable Interface

Any object that can draw itself onto a Screen:

abstract interface class Drawable {
void draw(Screen screen, Rectangle area);
Rectangle bounds();
}

Built-in Drawables

StyledString

Renders ANSI-styled text with automatic SGR/OSC parsing:

final styled = StyledString('\x1b[1;31mBold Red\x1b[0m Normal');

// Get required bounds
final bounds = styled.bounds(); // Rectangle for text dimensions

// Draw into a screen region
styled.draw(screen, rect(0, 0, 40, 5));

// With word wrapping
final wrapped = StyledString('Long text...', wrap: true);

// With truncation indicator
final truncated = StyledString('Very long...', tail: '...');

UvBorder

Draws configurable borders around regions:

// Built-in border styles
final border = normalBorder(); // ┌─┐│└─┘
final rounded = roundedBorder(); // ╭─╮│╰─╯
final thick = thickBorder(); // ┏━┓┃┗━┛
final double = doubleBorder(); // ╔═╗║╚═╝
final block = blockBorder(); // ████
final ascii = asciiBorder(); // +-+|+-+
final hidden = hiddenBorder(); // Spaces

// Apply style to all sides
final styledBorder = roundedBorder().style(
UvStyle(fg: UvColor.basic16(6)), // Cyan
);

// Apply hyperlink
final linkedBorder = normalBorder().link(
Link(url: 'https://example.com'),
);

// Draw into region
styledBorder.draw(screen, rect(0, 0, 20, 10));

EmptyDrawable

A no-op drawable for fallback scenarios:

final placeholder = EmptyDrawable(width: 10, height: 5);
placeholder.draw(screen, area); // Does nothing

Layer System

Layers enable z-ordered composition with hit-testing:

Layer

// Create from string or Drawable
final layer = newLayer('Hello')
..setId('greeting')
..setX(10)
..setY(5)
..setZ(1); // Higher z = on top

// Add child layers
layer.addLayers([
newLayer('Child 1')..setX(2)..setY(1),
newLayer('Child 2')..setX(2)..setY(2),
]);

// Find nested layer by ID
final found = layer.getLayer('child-id');

// Get max z in subtree
final maxZ = layer.maxZ();

Compositor

Manages layer composition, drawing, and hit-testing:

// Create with initial layers
final comp = Compositor([
newLayer('Background')..setZ(0),
newLayer('Foreground')..setZ(1)..setId('fg'),
]);

// Add more layers
comp.addLayers([newLayer('Overlay')..setZ(2)]);

// Refresh after modifications
comp.refresh();

// Get computed bounds
final bounds = comp.bounds();

// Draw to screen
comp.draw(screen, bounds);

// Render to string
final output = comp.render();

// Hit testing - find top layer at position
final hit = comp.hit(15, 10);
if (!hit.isEmpty) {
print('Hit layer: ${hit.id}');
print('Layer bounds: ${hit.bounds}');
}

// Lookup by ID
final layer = comp.getLayer('fg');

LayerHit

Result of hit-testing:

final hit = comp.hit(x, y);
hit.id // Layer ID (empty if no hit)
hit.layer // Layer instance (null if no hit)
hit.bounds // Layer bounds (null if no hit)
hit.isEmpty // true if no layer was hit

Geometry

Position

A 2D integer coordinate:

final pos = Position(10, 5);
print(pos.x); // 10
print(pos.y); // 5

Rectangle

Inclusive-exclusive bounds [min, max):

// Create from origin and size
final r = rect(10, 5, 30, 20); // x=10, y=5, width=30, height=20

// Or with explicit bounds
final r2 = Rectangle(minX: 10, minY: 5, maxX: 40, maxY: 25);

// Properties
print(r.width); // 30
print(r.height); // 20
print(r.isEmpty); // false

// Point containment
print(r.contains(Position(15, 10))); // true

// Rectangle containment
print(r.containsRect(rect(12, 8, 10, 5))); // true

// Overlap detection
print(r.overlaps(rect(35, 20, 20, 10))); // true

// Union (bounding box of both)
final union = r.union(rect(50, 30, 10, 10));

// Intersection
final inter = r.intersect(rect(20, 10, 30, 20));

Layout Helpers

Utilities for splitting screen regions:

Constraints

// Fixed size (absolute cells)
final fixed = Fixed(20);
print(fixed.apply(80)); // 20 (clamped to available)

// Percentage
final percent = Percent(30);
print(percent.apply(100)); // 30

// Ratio helper
final half = ratio(1, 2); // 50%

Splitting

final area = rect(0, 0, 80, 24);

// Horizontal split (left/right)
final (:left, :right) = splitHorizontal(area, Fixed(20));
// left: rect(0, 0, 20, 24)
// right: rect(20, 0, 60, 24)

// Vertical split (top/bottom)
final (:top, :bottom) = splitVertical(area, Percent(30));
// top: rect(0, 0, 80, 7)
// bottom: rect(0, 7, 80, 17)

Positioning Helpers

final area = rect(0, 0, 80, 24);

// Centered rectangle
centerRect(area, 40, 10); // Centered 40x10

// Corner/edge positions
topLeftRect(area, 20, 5); // Top-left 20x5
topCenterRect(area, 20, 5); // Top-center 20x5
topRightRect(area, 20, 5); // Top-right 20x5
leftCenterRect(area, 20, 5); // Left-center 20x5
rightCenterRect(area, 20, 5); // Right-center 20x5
bottomLeftRect(area, 20, 5); // Bottom-left 20x5
bottomCenterRect(area, 20, 5); // Bottom-center 20x5
bottomRightRect(area, 20, 5); // Bottom-right 20x5

Terminal Events and Input

Event Types

All events extend the sealed Event class:

Key Events

// KeyPressEvent - key was pressed
// KeyReleaseEvent - key was released (with Kitty keyboard protocol)

terminal.events.listen((event) {
if (event is KeyPressEvent) {
final key = event.key();

// Match by string (supports multiple alternatives)
if (event.matchString('q', 'Q', 'ctrl+c')) {
// Quit
}

// Get human-readable keystroke
print(event.keystroke()); // "ctrl+shift+A"
}
});

Mouse Events

// MouseClickEvent - button pressed
// MouseReleaseEvent - button released
// MouseWheelEvent - scroll wheel
// MouseMotionEvent - mouse moved/dragged

if (event is MouseClickEvent) {
final mouse = event.mouse();
print('Click at (${mouse.x}, ${mouse.y})');
print('Button: ${MouseButton.toName(mouse.button)}');
print('Modifiers: ${mouse.mod}'); // KeyMod bitmask
}

Window Events

// WindowSizeEvent - terminal resized
if (event is WindowSizeEvent) {
print('New size: ${event.width}x${event.height}');
print('Pixels: ${event.widthPx}x${event.heightPx}');
terminal.resize(event.width, event.height);
}

// FocusEvent - terminal gained focus
// BlurEvent - terminal lost focus

Paste Events

// PasteEvent - bracketed paste content
if (event is PasteEvent) {
print('Pasted: ${event.content}');
}

Device Reports

// PrimaryDeviceAttributesEvent - DA1 response
if (event is PrimaryDeviceAttributesEvent) {
print('Attributes: ${event.attrs}');
// attr 4 = Sixel support
}

// KeyboardEnhancementsEvent - Kitty keyboard protocol
if (event is KeyboardEnhancementsEvent) {
if (event.supportsKeyReleases) {
// Enable key release reporting
}
}

// KittyGraphicsEvent - Kitty graphics protocol response
// BackgroundColorEvent - terminal background color
// ColorPaletteEvent - palette color response

EventDecoder

Decodes raw terminal bytes into events:

final decoder = EventDecoder(
legacy: LegacyKeyEncoding()
.ctrlM(true) // Ctrl+M as Enter
.backspace(true), // Legacy backspace handling
);

// Decode bytes
final (consumed, event) = decoder.decode(bytes);
if (event != null) {
// Handle event
}

Mouse Handling

MouseMode

Controls mouse reporting:

MouseMode.none // No mouse reporting
MouseMode.cellMotion // Button events + motion while a button is pressed
MouseMode.allMotion // All motion events

MouseButton

Button identifiers:

MouseButton.none
MouseButton.left
MouseButton.middle
MouseButton.right
MouseButton.wheelUp
MouseButton.wheelDown
MouseButton.wheelLeft
MouseButton.wheelRight
MouseButton.button4
MouseButton.button5

// Get name
MouseButton.toName(MouseButton.left); // "left"

Mouse Payload

final mouse = Mouse(
x: 10,
y: 5,
button: MouseButton.left,
mod: KeyMod.ctrl | KeyMod.shift,
);

print(mouse); // "ctrl+shift+left (10,5)"

Terminal Capabilities

TerminalCapabilities tracks discovered terminal features:

final caps = TerminalCapabilities(env: Platform.environment.entries
.map((e) => '${e.key}=${e.value}').toList());

// Graphics support
caps.hasKittyGraphics // Kitty graphics protocol
caps.hasSixel // Sixel graphics
caps.hasITerm2 // iTerm2 image protocol

// Keyboard
caps.hasKeyboardEnhancements // Kitty keyboard protocol

// Colors
caps.hasBackgroundColor // Background color reported
caps.backgroundColor // UvRgb? of background
caps.hasColorPalette // Palette entries reported
caps.palette // Map<int, UvRgb> of palette

// Primary device attributes
caps.primaryAttributes // List<int> from DA1

// Update from events
caps.updateFromEvent(event); // Returns true if changed

Capability Detection Flow

final terminal = Terminal();
await terminal.start();

// Queries are sent automatically
// Listen for capability events
terminal.events.listen((event) {
if (terminal.capabilities.updateFromEvent(event)) {
// Capabilities changed - adapt rendering
if (terminal.capabilities.hasKittyGraphics) {
// Use Kitty graphics
}
}
});

Rendering Pipeline

UvTerminalRenderer

The core renderer computes minimal ANSI diffs:

final sink = StringBuffer();
final renderer = UvTerminalRenderer(sink, env: env, isTty: true);

// Resize
renderer.resize(80, 24);

// Render buffer (computes diff from previous frame)
renderer.render(buffer);

// Flush to sink
renderer.flush();

// Force full redraw
renderer.redraw(buffer);

// Mark for clear on next render
renderer.erase();

Terminal Mode Control

// Alternate screen
renderer.enterAltScreen();
renderer.exitAltScreen();

// Cursor
renderer.hideCursor();
renderer.showCursor();
renderer.moveTo(10, 5);

// Mouse
renderer.enableMouseAllEvents();
renderer.disableMouseAllEvents();

// Paste
renderer.enableBracketedPaste();
renderer.disableBracketedPaste();

// Focus
renderer.enableFocusReporting();
renderer.disableFocusReporting();

// Kitty keyboard
renderer.pushKeyboardEnhancements(flags);
renderer.popKeyboardEnhancements();

Render Metrics

Track rendering performance:

final metrics = renderer.metrics;

// Frame timing
metrics.frameCount // Total frames
metrics.skippedFrames // Frames with no changes
metrics.lastFrameTime // Duration since last frame
metrics.averageFrameTime // Rolling average

// Render timing
metrics.lastRenderDuration // Time spent in render()
metrics.averageRenderDuration // Rolling average

// FPS
metrics.currentFps // Based on last frame
metrics.averageFps // Rolling average
metrics.minFps // Slowest in window
metrics.maxFps // Fastest in window

// Utilization
metrics.renderTimePercentage // % of frame time in render()

// Display
print(metrics.summary());
// "FPS: 60.0 (58.5-61.2) | Frame: 16ms | Render: 450µs (2.8%) | Frames: 1000 (skipped: 50)"

Optimization Flags

// Enable scroll optimization (uses IL/DL instead of redraw)
renderer.setScrollOptim(true);

// Fullscreen mode (enables more optimizations)
renderer.setFullscreen(true);

// Relative cursor movement
renderer.setRelativeCursor(true);

// Tab stops for cursor movement
renderer.setTabStops(8);

// Backspace as movement
renderer.setBackspace(true);

Performance Internals

The UV system applies several optimizations that are transparent to callers but relevant when diagnosing performance or porting the library:

Cell style/link packingUvStyle and Link are stored as compact integer bitfields rather than heap-allocated objects. Cell display width is stored in the same word with widened bit capacity so wide characters (width 2) and image cells are represented without extra allocations. This keeps per-cell memory low and improves cache locality for large buffers.

Unicode string width cachingvisibleLength and stringWidth calls for multi-character grapheme strings are memoized in a bounded LruCache. This avoids re-scanning long styled strings (e.g. repeated StyledString draws). Emoji presentation sequences (\uFE0F) are guarded with a separate fast-path to prevent double-counting their display width.

expandTabs fast-path — Tab-expansion uses a protocol-aware path that correctly handles Kitty and Sixel display widths. A fast-path skips tab expansion entirely for strings that contain no \t characters, which is the common case in programmatically generated output.

Truecolor SGR clamping — RGB components in UvRgb are clamped and normalized to the [0, 255] byte range when generating ANSI SGR sequences. This prevents invalid escape sequences from truncating or wrapping terminals that do not clamp out-of-range values themselves.

Render primitive microbenchmarks — The UV renderer suite is covered by a suite of focused microbenchmarks (pkg:ultraviolet/benchmark/) that track per-frame buffer diff time, cell write throughput, and ANSI serialization speed. Run with dart run benchmark/ from the ultraviolet package.


Buffer Filters and Render Sinks

import 'dart:io';

import 'package:ultraviolet/ultraviolet.dart';

Future<void> main() async {
const width = 40;
const height = 10;
final sink = BufferRenderSink(width: width, height: height);
final filter = LiquifyFilter(strength: 2.5);

for (var frame = 0; frame < 20; frame++) {
final buffer = Buffer.create(width, height);
_drawBox(buffer, 0, 0, width, height);
_drawText(buffer, 2, 2, 'LIQUIFY FILTER');
_drawText(buffer, 2, 4, 'frame ${frame.toString().padLeft(2)}');

final filtered = sink.render(buffer, [filter], dt: 1 / 30);

stdout.write('\x1B[2J\x1B[H');
stdout.write(filtered.render());
stdout.write('\n');
await Future<void>.delayed(const Duration(milliseconds: 60));
}
}

void _drawText(Buffer buffer, int x, int y, String text) {
for (var i = 0; i < text.length; i++) {
_setCell(buffer, x + i, y, text[i]);
}
}

void _drawBox(Buffer buffer, int x, int y, int width, int height) {
if (width < 2 || height < 2) return;
_setCell(buffer, x, y, '+');
_setCell(buffer, x + width - 1, y, '+');
_setCell(buffer, x, y + height - 1, '+');
_setCell(buffer, x + width - 1, y + height - 1, '+');

for (var i = 1; i < width - 1; i++) {
_setCell(buffer, x + i, y, '-');
_setCell(buffer, x + i, y + height - 1, '-');
}

for (var j = 1; j < height - 1; j++) {
_setCell(buffer, x, y + j, '|');
_setCell(buffer, x + width - 1, y + j, '|');
}
}

void _setCell(Buffer buffer, int x, int y, String ch) {
final line = buffer.line(y);
if (line == null) return;
line.set(x, Cell(content: ch));
}

Color Matrix Effects

effects.dart provides a ColorMatrix primitive (a 4×5 RGBA coefficient matrix) and a ColorMatrixFilter that plugs into the BufferFilter pipeline. Effects operate on cell style colors (fg, bg, underline color) while leaving glyph content, display width, links, and drawables untouched.

ColorMatrix

A ColorMatrix is a row-major 4×5 matrix of double coefficients. Each of the four rows transforms one channel (R, G, B, A). The fifth column in each row is an additive bias.

import 'package:ultraviolet/ultraviolet.dart';

// Built-in presets
final grayscale = ColorMatrix.grayscale(); // sRGB luminance
final inverted = ColorMatrix.invert(); // flip RGB
final brightened = ColorMatrix.gain(1.25); // scale all channels
final dimmed = ColorMatrix.attenuation(0.6);

// Tint toward a color
final warmed = ColorMatrix.tint(const UvRgb(255, 180, 80), amount: 0.4);

// Chain two matrices
final combined = grayscale.followedBy(warmed);

// Collapse a list of matrices in order
final stacked = ColorMatrix.compose([grayscale, inverted, dimmed]);

// Apply directly to a UvStyle
final styled = UvStyle(fg: const UvRgb(0, 200, 255));
final transformed = grayscale.transformStyle(styled);

ColorMatrixFilter

ColorMatrixFilter wraps a ColorMatrix as a BufferFilter so it can be used directly in a BufferRenderSink:

import 'package:ultraviolet/ultraviolet.dart';

final sink = BufferRenderSink(width: 80, height: 24);

// Grayscale all foreground colors (preserve background)
final grayFg = ColorMatrixFilter.grayscale(background: false);

// Tint everything toward amber
final amber = ColorMatrixFilter.tint(
const UvRgb(255, 191, 96),
amount: 0.6,
);

final filtered = sink.render(sourceBuffer, [grayFg, amber], dt: 1 / 60);
renderer.render(filtered);

Named factory constructors on ColorMatrixFilter:

ConstructorEffect
ColorMatrixFilter.identity()No-op pass-through
ColorMatrixFilter.grayscale()sRGB luminance desaturation
ColorMatrixFilter.invert()Flip RGB channels
ColorMatrixFilter.gain(amount)Scale all channels
ColorMatrixFilter.attenuation(amount)Scale channels ≤ 1
ColorMatrixFilter.tint(color, amount)Blend toward a color
ColorMatrixFilter.multiply(color)Per-channel multiply

Each factory accepts foreground, background, and underlineColor boolean flags to restrict which style components are transformed.


Post-Processing Filters

filters.dart provides spatial and temporal post-processing filters that extend the BufferFilter base class. They are designed to compose with ColorMatrixFilter inside a BufferRenderSink pipeline.

import 'package:ultraviolet/ultraviolet.dart';

final sink = BufferRenderSink(width: 80, height: 24);

// Combine several filters in one pass
final crt = CrtFilter(
distortion: 0.22,
vignette: 0.16,
scanline: 0.10,
rollingBar: 0.08,
);

final filtered = sink.render(sourceBuffer, [crt], dt: elapsedSeconds);
renderer.render(filtered);

Available Filters

FilterEffect
LiquifyFilterDamped noise-field displacement
VignetteFilterRadial edge darkening
ScanlineFilterCRT horizontal scan-line dimming
WaveDistortionFilterSinusoidal cell displacement
GhostingFilterTemporal afterimage persistence trail
CrtFilterComposite CRT preset (wave + vignette + scanlines)
AtmosphereFilterGentle background bloom and vignette

Composite Presets

High-level presets compose multiple primitives into a single CompositeFilter:

// Warm amber monochrome monitor
final amber = AmberTerminalFilter(tint: 0.62, attenuation: 0.96);

// Green phosphor CRT
final phosphor = PhosphorFilter(tint: 0.58, distortion: 0.08);

// Phosphor with persistence trail
final trail = PhosphorTrailFilter(persistence: 0.42);

// Amber with trail
final amberTrail = AmberTrailFilter(persistence: 0.38);

// CRT with trail
final crtTrail = CrtTrailFilter(persistence: 0.32);

BufferRenderSink

BufferRenderSink is the entry point for all filter pipelines:

final sink = BufferRenderSink(width: 80, height: 24);

// Resize when the terminal changes
sink.resize(newWidth, newHeight);

// Apply a filter list each frame
final result = sink.render(myBuffer, [filter1, filter2], dt: 1 / 60);

Internally the sink double-buffers render targets so filters never read and write the same buffer simultaneously.

For detailed source API, see package:ultraviolet/ultraviolet.dart.


Image Drawables

The UV system supports rendering images in the terminal via multiple graphics protocols. All image rendering is abstracted behind the Drawable interface, allowing portable code that adapts to terminal capabilities automatically.

Drawable Interface

abstract interface class Drawable {
/// Render to the given screen within the specified rectangle.
void draw(Screen screen, Rectangle rect);

/// The bounds (width, height) this drawable wants.
Rectangle bounds();
}

EmptyDrawable is a no-op implementation used as a fallback when no image protocol is available.

Auto-Detection

Use bestImageDrawableForTerminal() to automatically select the best protocol based on detected terminal capabilities:

import 'package:ultraviolet/ultraviolet.dart';
import 'package:image/image.dart' as img;

final image = img.Image(width: 100, height: 60);
final drawable = terminal.bestImageDrawableForTerminal(
image,
columns: 20,
rows: 10,
);

drawable.draw(screen, rect(0, 0, 20, 10));

The selection priority is: Kitty > iTerm2 > Sixel > HalfBlock. The HalfBlock fallback always works (requires only true color support).

Protocol Implementations

KittyImageDrawable

Uses the Kitty Graphics Protocol to transmit images as base64-encoded PNG data. Supported in Kitty, WezTerm, and other compatible terminals.

final drawable = KittyImageDrawable(
image,
id: 1, // Optional image ID (auto-assigned if omitted)
columns: 20,
rows: 10,
quiet: 2, // Suppress terminal responses (0/1/2)
);
ParameterTypeDescription
imageimg.ImageSource image
idint?Image ID (auto-assigned via KittyImage.getNextImageId())
columnsintWidth in terminal cells
rowsintHeight in terminal cells
quietintQuiet mode: 0 = normal, 1 = suppress OK, 2 = suppress all

How it works: The image is PNG-encoded, base64-encoded, then sent as a single escape sequence placed in the top-left cell. Remaining cells are marked as occupied (width=0, empty content). Use deleteSequence() to clean up the image when no longer needed.

ITerm2ImageDrawable

Uses the iTerm2 Inline Images Protocol. Supported in iTerm2, WezTerm, and other compatible terminals.

final drawable = ITerm2ImageDrawable(
image,
name: 'photo.png', // Optional display name
columns: 20,
rows: 10,
);
ParameterTypeDescription
imageimg.ImageSource image
nameString?Optional file name for the image
columnsintWidth in terminal cells
rowsintHeight in terminal cells

How it works: The image is PNG-encoded, base64-encoded, and transmitted via an OSC 1337 escape sequence. Like Kitty, it uses a single escape sequence in the top-left cell with remaining cells marked as occupied.

SixelImageDrawable

Uses the Sixel graphics format, a legacy protocol with broad support across terminals (xterm, mlterm, foot, WezTerm, etc.).

final drawable = SixelImageDrawable(
image,
columns: 20,
rows: 10,
maxColors: 256, // Color palette size (default: 256)
cellPixelWidth: 8, // Pixels per cell horizontally
cellPixelHeight: 16, // Pixels per cell vertically
);
ParameterTypeDescription
imageimg.ImageSource image
columnsintWidth in terminal cells
rowsintHeight in terminal cells
maxColorsintMax palette colors (default: 256)
cellPixelWidthint?Pixel width per cell (for resize calculation)
cellPixelHeightint?Pixel height per cell (for resize calculation)

How it works: The image is resized to the target pixel dimensions (columns * cellPixelWidth x rows * cellPixelHeight), height is rounded up to a multiple of 6 (Sixel band height), colors are quantized to maxColors, then encoded as Sixel data. Single escape sequence in top-left cell.

HalfBlockImageDrawable

Unicode half-block fallback that works in any terminal with true color support. Uses the (upper half block) character with foreground=top pixel and background=bottom pixel to achieve 2 vertical pixels per cell.

final drawable = HalfBlockImageDrawable(
image,
columns: 20,
rows: 10,
);
ParameterTypeDescription
imageimg.ImageSource image
columnsintWidth in terminal cells
rowsintHeight in terminal cells

How it works: Unlike the other protocols, HalfBlock renders per-cell — each cell gets the character with the top pixel as foreground color and the bottom pixel as background color. The image is resized to columns x rows * 2 pixels to fill the grid. No special terminal protocol support is required.

Custom Drawables

Implement the Drawable interface to create custom drawables:

class AsciiArtDrawable implements Drawable {
final String art;
final int width;
final int height;

AsciiArtDrawable(this.art, {required this.width, required this.height});


Rectangle bounds() => rect(0, 0, width, height);


void draw(Screen screen, Rectangle rect) {
final lines = art.split('\n');
for (var y = 0; y < lines.length && y < rect.height; y++) {
for (var x = 0; x < lines[y].length && x < rect.width; x++) {
final line = screen.line(rect.y + y);
if (line != null) {
line.set(rect.x + x, Cell(content: lines[y][x]));
}
}
}
}
}

Best Practices

  • Use auto-detection (bestImageDrawableForTerminal()) for cross-terminal portability instead of hardcoding a specific protocol.
  • Clean up Kitty images by calling deleteSequence() when removing images from the screen, to free terminal-side resources.
  • Reuse drawables when redrawing the same image — avoid re-encoding on every frame. Cache the Drawable instance and call draw() repeatedly.
  • Consider HalfBlock for testing — it requires no special terminal support and is useful for development on terminals without image protocol support.

Style Bridge

style_ops.dart bridges UV styles with color profiles:

Profile Conversion

import 'package:ultraviolet/ultraviolet.dart' as uv;

// Convert style to respect terminal capabilities
final converted = uv.convertStyle(style, uv.Profile.ansi256);

// True color -> unchanged
// ANSI 256 -> RGB downsampled to 256
// ANSI -> RGB/256 downsampled to 16
// ASCII/NoTTY -> colors stripped

SGR Generation

// Full SGR sequence for a style
final sgr = uv.styleToSgr(style);
// "\x1b[1;3;38;2;255;0;0m" for bold italic red

// Diff sequence between styles (minimal change)
final diff = uv.styleDiff(oldStyle, newStyle);

Quick Start

Basic Terminal Application

import 'package:ultraviolet/ultraviolet.dart';

Future<void> main() async {
final terminal = Terminal();

try {
await terminal.start();
terminal.enterAltScreen();
terminal.hideCursor();
terminal.enableMouse();

// Main loop
await for (final event in terminal.events) {
if (event is KeyPressEvent && event.matchString('q', 'ctrl+c')) {
break;
}

if (event is WindowSizeEvent) {
terminal.resize(event.width, event.height);
}

// Clear and draw
terminal.clear();
terminal.setCell(0, 0, Cell(
content: 'Press Q to quit',
style: UvStyle(fg: UvColor.basic16(2)),
));
terminal.draw();
}
} finally {
terminal.disableMouse();
terminal.showCursor();
terminal.exitAltScreen();
await terminal.stop();
}
}

Using Canvas and Layers

import 'package:ultraviolet/ultraviolet.dart';

void main() {
// Create a canvas
final canvas = Canvas(60, 20);

// Build layers
final compositor = Compositor([
newLayer(roundedBorder())
..setId('border')
..setZ(0),
newLayer(StyledString('\x1b[1mWelcome!\x1b[0m'))
..setId('title')
..setX(2)
..setY(0)
..setZ(1),
newLayer(StyledString('Content goes here...'))
..setId('content')
..setX(2)
..setY(2)
..setZ(1),
]);

// Render
compositor.draw(canvas, canvas.bounds());
print(canvas.render());
}

Image Rendering

import 'package:ultraviolet/ultraviolet.dart';
import 'package:image/image.dart' as img;

Future<void> renderImage(Terminal terminal, img.Image image) async {
// Get best drawable for terminal capabilities
final drawable = terminal.bestImageDrawableForTerminal(
image,
columns: 40,
rows: 20,
);

// Draw to terminal
drawable.draw(terminal, rect(0, 0, 40, 20));
terminal.draw();
}

Reference

Exports from package:ultraviolet/ultraviolet.dart

// `package:artisanal/uv.dart` (compatibility) re-exports the same API set.

// Core types
Terminal, Buffer, Line, LineData, Cell, Link, UvStyle
UvColor, UvBasic16, UvIndexed256, UvRgb

// Geometry
Position, Rectangle, rect

// Drawing
Screen, Canvas, Drawable, EmptyDrawable
StyledString, newStyledString, StyleState, LinkState, readStyle, readLink
UvBorder

// Layers
Layer, Compositor, LayerHit, newLayer

// Layout
splitHorizontal, splitVertical, Fixed, Percent

// Rendering
UvTerminalRenderer, RenderMetrics

// Events
Event, KeyEvent, KeyPressEvent, KeyReleaseEvent
MouseEvent, MouseClickEvent, MouseReleaseEvent, MouseWheelEvent, MouseMotionEvent
WindowSizeEvent, FocusEvent, BlurEvent, PasteEvent
KittyGraphicsEvent, PrimaryDeviceAttributesEvent, KeyboardEnhancementsEvent

// Input
EventDecoder, LegacyKeyEncoding
MouseMode, MouseButton

// Capabilities
TerminalCapabilities

Where to go next