Skip to main content

Rendering

Understanding unblessed's rendering system and optimization techniques.

Overview

unblessed uses a smart rendering system that minimizes terminal updates for smooth, efficient TUIs. The rendering pipeline tracks changes, computes diffs, and sends only necessary updates to the terminal.

The Rendering Pipeline

1. Dirty Tracking

Widgets are marked as "dirty" when they change:

box.setContent('New content');  // Marks box as dirty
box.style.fg = 'red'; // Marks box as dirty
box.hide(); // Marks box as dirty

Dirty flag: Internal flag indicating the widget needs re-rendering.

2. Screen Render

Call screen.render() to update the display:

// Make multiple changes
box1.setContent('Changed');
box2.style.bg = 'blue';
list.select(5);

// Single render updates all changes
screen.render();

Why batch: More efficient than rendering after each change.

3. Widget Rendering

Each dirty widget renders its content to an internal buffer:

class Element {
render() {
// 1. Calculate position and size
const { left, top, width, height } = this._getCoords();

// 2. Render content with styles
const lines = this._renderContent();

// 3. Apply borders
if (this.border) {
this._renderBorder();
}

// 4. Update screen buffer
this.screen._buf.push(left, top, lines);
}
}

4. Screen Diff

The screen compares the new buffer with the previous frame:

class Screen {
render() {
// Collect all dirty widgets
const dirty = this._collectDirty();

// Render each dirty widget
for (const widget of dirty) {
widget.render();
}

// Compute diff between old and new buffers
const diff = this._diff(this._obuf, this._buf);

// Send minimal updates to terminal
this._write(diff);

// Swap buffers
this._obuf = this._buf;
this._buf = [];
}
}

5. Terminal Update

Only changed cells are written to the terminal:

// Example diff output
[
{ x: 10, y: 5, text: 'Hello', attr: { fg: 'cyan' } },
{ x: 10, y: 6, text: 'World', attr: { fg: 'cyan' } },
]

// Sent to terminal as escape sequences
\x1b[5;10H\x1b[36mHello\x1b[0m
\x1b[6;10H\x1b[36mWorld\x1b[0m

Smart Rendering Features

Smart CSR (Change Scroll Region)

Efficiently scroll content using terminal scroll regions:

const screen = new Screen({
smartCSR: true // Enable smart scroll regions
});

How it works: When scrolling, uses \x1b[L (insert line) and \x1b[M (delete line) instead of redrawing everything.

Benefits:

  • Faster scrolling in lists and logs
  • Reduced flickering
  • Less bandwidth

Fast CSR

Even more aggressive scroll optimization:

const screen = new Screen({
smartCSR: true,
fastCSR: true // More aggressive optimization
});

Trade-off: May have minor visual artifacts on some terminals.

Full Unicode

Support for wide characters and emojis:

const screen = new Screen({
fullUnicode: true // Enable full Unicode support
});

Features:

  • Correct width calculation for emoji and CJK characters
  • Proper alignment with wide characters
  • Better international support

Rendering Modes

Synchronous Rendering

Default mode - renders immediately:

box.setContent('Update');
screen.render(); // Blocks until complete

Use case: Simple apps, immediate feedback needed

Deferred Rendering

Batch multiple updates:

// Queue multiple renders
box1.setContent('A');
screen.render(); // Queued
box2.setContent('B');
screen.render(); // Queued
box3.setContent('C');
screen.render(); // Queued

// Next tick: all renders batched into one

How: Uses process.nextTick() or requestAnimationFrame() to batch.

Benefits:

  • Fewer actual renders
  • Better performance
  • Smoother animations

Manual Control

Disable auto-rendering for fine control:

const screen = new Screen({
autoRender: false
});

// Make changes
box.setContent('A');
box.style.fg = 'red';

// Manually trigger render when ready
screen.render();

Optimization Techniques

1. Batch Updates

// ❌ Inefficient - renders 3 times
box.setContent('Line 1');
screen.render();
box.setContent('Line 2');
screen.render();
box.setContent('Line 3');
screen.render();

// ✅ Efficient - renders once
box.setContent('Line 1');
box.setContent('Line 2');
box.setContent('Line 3');
screen.render();

2. Avoid Unnecessary Renders

// ❌ Renders even if content unchanged
box.setContent(box.content);
screen.render();

// ✅ Check before updating
if (newContent !== box.content) {
box.setContent(newContent);
screen.render();
}

3. Use Hidden Widgets

// ❌ Renders offscreen widget
const hiddenBox = new Box({
parent: screen,
top: 1000, // Offscreen
content: 'Hidden'
});

// ✅ Use hidden flag
const hiddenBox = new Box({
parent: screen,
hidden: true, // Not rendered
content: 'Hidden'
});

// Show when needed
hiddenBox.show();
screen.render();

4. Limit Scrollable Content

// ❌ Renders all 10,000 lines
const log = new Log({
parent: screen,
scrollable: true,
content: tenThousandLines
});

// ✅ Limit visible content
const log = new Log({
parent: screen,
scrollable: true,
scrollback: 1000, // Keep only last 1000 lines
content: tenThousandLines
});

5. Throttle Rapid Updates

let lastRender = 0;
const throttle = 16; // ~60 FPS

function updateProgress(value: number) {
progressBar.setProgress(value);

const now = Date.now();
if (now - lastRender > throttle) {
screen.render();
lastRender = now;
}
}

Layout Calculation

Coordinate System

Widgets use a hierarchical coordinate system:

// Screen coordinates
screen: { x: 0, y: 0, width: 80, height: 24 }

// Parent box
parent: { x: 10, y: 5, width: 60, height: 14 }

// Child box (relative to parent)
child: {
left: 5, // Actual x: 10 + 5 = 15
top: 2, // Actual y: 5 + 2 = 7
width: 30, // Actual width: 30
height: 8 // Actual height: 8
}

Size Calculation

Supports various size formats:

// Absolute
{ width: 50, height: 20 }
// Rendered as: 50x20

// Percentage
{ width: '50%', height: '80%' }
// Parent 100x30: 50x24

// Calculated
{ width: '100%-10', height: '100%-5' }
// Parent 100x30: 90x25

// Shrink to content
{ width: 'shrink', height: 'shrink' }
// Fits content exactly

Position Calculation

Flexible positioning:

// Absolute
{ top: 5, left: 10 }

// Center
{ top: 'center', left: 'center' }
// Centered in parent

// Right/Bottom aligned
{ right: 5, bottom: 2 }
// 5 from right, 2 from bottom

// Mixed
{ top: 'center', left: 5 }
// Vertically centered, 5 from left

Buffer Management

Double Buffering

unblessed uses double buffering to prevent tearing:

class Screen {
_buf: Buffer[]; // Current buffer being built
_obuf: Buffer[]; // Previous rendered buffer

render() {
// Build new frame in _buf
this._renderWidgets();

// Diff against _obuf
const changes = this._diff(this._obuf, this._buf);

// Write changes
this._write(changes);

// Swap buffers
[this._buf, this._obuf] = [this._obuf, this._buf];
}
}

Benefits:

  • No visual tearing
  • Minimal terminal updates
  • Smooth animations

Buffer Format

Each buffer cell contains:

interface Cell {
ch: string; // Character(s)
attr: {
fg: number; // Foreground color
bg: number; // Background color
bold: boolean;
underline: boolean;
// ... other attributes
};
}

Rendering Performance

Benchmarks

On modern hardware:

OperationTime
Empty screen~6.5ms
100 boxes~11ms
1K list items~187ms
Full screen update~15ms

Profiling

Profile rendering performance:

console.time('render');
screen.render();
console.timeEnd('render');
// render: 12.456ms

// Detailed profiling
const start = process.hrtime.bigint();
screen.render();
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1_000_000;
console.log(`Render took ${ms.toFixed(3)}ms`);

Common Issues

Flickering

Cause: Rendering too frequently or clearing screen

Solution:

// ❌ Causes flicker
screen.clear();
screen.render();

// ✅ Use dirty tracking
box.setContent('Update');
screen.render(); // Only updates changed area

Slow Rendering

Cause: Too many widgets or large content

Solution:

// ✅ Optimize widget count
// Use single scrollable box instead of many small boxes

// ✅ Limit content
log.setContent(content.slice(-1000)); // Last 1000 lines

// ✅ Use hidden flag
widget.hidden = !shouldShow;

Rendering Artifacts

Cause: Terminal doesn't support CSR or Unicode

Solution:

const screen = new Screen({
smartCSR: false, // Disable CSR if issues
fullUnicode: false, // Disable Unicode if needed
dockBorders: true // Prevent border artifacts
});

Best Practices

1. Render Once Per Frame

// ✅ Good
function updateDashboard() {
header.setContent(getHeader());
sidebar.setItems(getItems());
content.setContent(getContent());
screen.render(); // Single render
}

2. Use RAF for Animations

function animate() {
progressBar.setProgress(++value);
screen.render();

if (value < 100) {
requestAnimationFrame(animate);
}
}

3. Debounce Rapid Events

let timeout: NodeJS.Timeout;

input.on('keypress', (ch, key) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
updateSearch(input.getValue());
screen.render();
}, 100);
});

4. Profile Before Optimizing

// Measure actual performance
console.time('render');
screen.render();
console.timeEnd('render');

// Only optimize if slow (>16ms for 60fps)

Next Steps