ShadowRenderer
ShadowRenderer
is a virtual terminal renderer for building efficient terminal-based UIs. It manages an internal content buffer and a view buffer to minimize redraws, supports scrolling, viewport resizing, and styled text output.
Features
- Partial screen updates for performance
- Content scrolling
- Adjustable viewport dimensions
- Rich text and ANSI styling support
- Efficient diffing algorithm for minimal redraws
Performance Considerations
The Shadow Renderer is optimized for scenarios where:
- You're building interactive terminal UIs with frequent updates
- You need to manage content larger than the visible viewport
- You want to avoid screen flicker from complete redraws
The diffing algorithm ensures minimal terminal I/O operations by tracking which cells have changed and only updating those specific positions.
Imports
You can import the ANSI component in two ways:
import { ShadowRenderer } from '@remotex-labs/xansi/shadow.service';
or
import { ShadowRenderer } from '@remotex-labs/xansi';
Creating a Renderer
// Create a renderer at row 2, column 3 with viewport size 80x24
const renderer = new ShadowRenderer(24, 80, 2, 3);
terminalHeight
– number of rows in the viewportterminalWidth
– number of columns in the viewporttopPosition
– top offset within the terminalleftPosition
– left offset within the terminal
Writing Text
renderer.writeText(0, 0, 'Hello World'); // Top-left corner
renderer.writeText(5, 10, 'Menu Options', true); // Clear existing content before writing
renderer.render(); // Display changes
row
– 0-based row index in viewportcolumn
– 0-based column index in viewporttext
– string to displayclean
– optional; clears existing content if true
WARNING
If the text
parameter contains newline characters (\n
), writeText
will only process content up to the first newline. For multi-line text, use writeBlock
instead.
Writing Blocks of Text
The writeBlock
method allows you to write multi-line text in a single operation:
import { ShadowRenderer } from '@remotex-labs/xansi';
const renderer = new ShadowRenderer(10, 80, 0, 0);
// Method 1: Write a multi-line block using a string with newlines const
menuText = '1. File Operations\n2. Edit Options\n3. View Settings\n4. Exit Application';
renderer.writeBlock(3, 5, menuText);
// Method 2: Write a multi-line block using an array of strings const
warningLines = [ 'WARNING:', 'Unsaved changes will be lost!' ];
renderer.writeBloc(3, 5, warningLines);
row
- Starting row position (0-based)column
- Starting column position (0-based)text
- Content to write, which can be either:- A string that will be automatically split at newline characters (
\n
) - An array of strings, where each element represents a line
- A string that will be automatically split at newline characters (
clean
- Optional; when set totrue
, clears existing content before writing the line
This method automatically allocates new rows in the content buffer as needed, making it suitable for rendering large blocks of text that can be scrolled with the renderer.
STOP
This method overrides all existing content in the rows it writes to. The new text will replace any content previously written to these rows.
Basic Usage
import { ShadowRenderer, writeRaw, ANSI } from '@remotex-labs/xansi';
const topLeft = new ShadowRenderer(5, 40, 0, 0);
const topRight = new ShadowRenderer(5, 40, 0, 40);
const left = new ShadowRenderer(6, 40, 5, 5);
const right = new ShadowRenderer(6, 40, 5, 45);
writeRaw(ANSI.HIDE_CURSOR);
writeRaw(ANSI.CLEAR_SCREEN);
const letters = 'abcdefghijklmnopqrstuvwxyz';
let topIndex = 0;
let index = 0;
setInterval(() => {
for (let i = 0; i < 5; i++) {
const letterIndex = (topIndex + i) % letters.length;
const letter = letters[letterIndex];
topLeft.writeText(i, i, letter); // example: row = col = i
topRight.writeText(i, i, letter); // example: row = col = i
}
topLeft.render();
topRight.render();
topIndex = (topIndex + 1) % letters.length;
}, 1000); // 1000ms = 1 second
setInterval(() => {
for (let i = 0; i < 5; i++) {
// Go backward from index: index, index-1, ..., index-4
const letterIndex = (index - i + letters.length) % letters.length;
const letter = letters[letterIndex];
left.writeText(i, i, letter); // Display diagonally
right.writeText(i, i, letter); // Display diagonally
}
left.render();
right.render();
// Move backward in the alphabet
index = (index - 1 + letters.length) % letters.length;
}, 1000);
Viewport Management
import { ShadowRenderer } from '@remotex-labs/xansi';
// Create a renderer that occupies the top part of the terminal
const renderer = new ShadowRenderer(10, 80, 0, 0);
// Reposition the renderer to create space for a header
renderer.top = 3; // Now starts at row 3 (below a header area)
renderer.left = 2; // Indented by 2 columns
// Resize the renderer to accommodate a sidebar
renderer.width = 70; // Reduce width to leave space for a sidebar
// Handle terminal resize events
process.stdout.on('resize', () => {
// Adjust to new terminal dimensions
renderer.width = process.stdout.columns - 10; // Leave 10 columns for sidebar
renderer.height = process.stdout.rows - 5; // Leave 5 rows for header/footer
// Force redraw after resize
renderer.render(true);
});
Flushing to Terminal
- Sends all rendered content to the terminal output
- Clears the internal buffer after flushing
- Does not require a further call to
render()
CAUTION
This method immediately writes all buffered content to the standard output. Unlike render()
which updates the terminal view while maintaining content in the buffer, flushToTerminal()
outputs the content and then clears the buffer. After calling this method, any previously rendered content will no longer be available in the renderer's buffer.
import { ShadowRenderer } from '@remotex-labs/xansi';
const renderer = new ShadowRenderer(10, 80, 0, 0);
// Write some content renderer.
writeText(0, 0, 'Hello World');
renderer.writeBlock(2, 0, 'Line 1\nLine 2\nLine 3');
// Flush all content to the terminal
renderer.flushToTerminal();
Content Scrolling
import * as readline from 'readline';
import { ShadowRenderer } from '@services/shadow.service';
import { ANSI, writeRaw } from '@components/ansi.component';
// Create a renderer with viewport 10 rows x 50 columns at the top 3 left corners
const renderer = new ShadowRenderer(10, 50, 3, 0);
// Write all content to the renderer
for (let i = 0; i < 100; i++) {
renderer.writeText(i, 0, `Item ${ i + 1 }: Scrollable content example`);
}
// Initial render
renderer.render();
// Setup keyboard input for scrolling
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.on('keypress', (str, key) => {
if (key.ctrl && key.name === 'c') {
// Clear screen and exit on Ctrl+C
writeRaw(ANSI.CLEAR_SCREEN);
writeRaw(ANSI.SHOW_CURSOR);
process.exit(0);
}
switch (key.name) {
case 'up':
renderer.scroll -= 1; // Scroll up one row
break;
case 'down':
renderer.scroll += 1; // Scroll down one row
break;
case 'pageup':
renderer.scroll -= 5; // Scroll up five rows
break;
case 'pagedown':
renderer.scroll += 5; // Scroll down five rows
break;
}
});
Advanced Rendering Techniques
import { ShadowRenderer, writeRaw, ANSI, xterm } from '@remotex-labs/xansi';
// Create a renderer for a modal dialog
const renderer = new ShadowRenderer(10, 50, 5, 20);
writeRaw(ANSI.CLEAR_SCREEN);
// Create a modal dialog with title and content
function showModal(title, content) {
// Hide cursor during rendering to prevent flicker
writeRaw(ANSI.HIDE_CURSOR);
try {
// Clear previous content
renderer.clear();
const totalWidth = 40;
const labelWithPadding = ` ${ title } `;
const lineWidth = totalWidth - 2;
const dashCount = lineWidth - labelWithPadding.length;
const leftDashes = Math.floor(dashCount / 2);
const rightDashes = dashCount - leftDashes;
const line = '┌' + '─'.repeat(leftDashes) + xterm.hex('#bf1e1e').bold.dim(labelWithPadding) + '─'.repeat(rightDashes) + '┐';
// Draw border and title
renderer.writeText(0, 0, line);
// Draw content
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
renderer.writeText(i + 2, 2, lines[i]);
}
// Draw bottom border
renderer.writeText(9, 0, '└' + '─'.repeat(38) + '┘');
// Replace content with clean flag to ensure no artifacts
renderer.writeText(8, 2, xterm.dim('Press any key to continue...'), true);
// Force complete redraw
renderer.render(true);
} finally {
// Always restore cursor visibility
writeRaw(ANSI.SHOW_CURSOR);
}
}
// Usage example
showModal('Information', 'The operation completed successfully.\nAll files have been processed.');
Terminal Bouncing Blocks Animation
This TypeScript program creates a visually engaging terminal animation featuring multiple colored blocks (█) that bounce around your terminal window.
import { createInterface } from 'readline';
import { ShadowRenderer, writeRaw, ANSI, xterm } from '@remotex-labs/xansi';
interface BallInterface {
x: number;
y: number;
dx: number;
dy: number;
colorStep: number;
prevColor: string;
nextColor: string;
}
class BouncingBalls {
private renderer: ShadowRenderer;
private width = 0;
private height = 0;
private intervalId: NodeJS.Timeout | null = null;
private balls: BallInterface[] = [];
private fadeSteps = 30;
private ballCount = 15;
constructor() {
this.renderer = new ShadowRenderer(this.height, this.width, 0, 0);
this.updateTerminalSize();
process.stdout.on('resize', () => this.updateTerminalSize());
writeRaw(ANSI.HIDE_CURSOR);
writeRaw(ANSI.CLEAR_SCREEN);
// Initialize multiple balls
this.initializeBalls();
}
start(frameRate = 50): void {
if (this.intervalId) clearInterval(this.intervalId);
this.intervalId = setInterval(() => {
this.updateBallPositions();
this.drawBalls();
}, frameRate);
createInterface({ input: process.stdin, output: process.stdout });
process.stdin?.setRawMode?.(true);
process.stdin.on('data', (key) => {
if (key.toString() === '\u0003' || key.toString().toLowerCase() === 'q') {
this.stop();
process.exit(0);
}
});
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
writeRaw(ANSI.CLEAR_SCREEN);
writeRaw(ANSI.SHOW_CURSOR);
}
private initializeBalls(): void {
this.balls = [];
for (let i = 0; i < this.ballCount; i++) {
const x = Math.floor(Math.random() * (this.width - 4));
const y = Math.floor(Math.random() * (this.height - 2));
const dx = Math.random() > 0.5 ? 1 : -1;
const dy = Math.random() > 0.5 ? 1 : -1;
const initialColor = this.getColor(x, y);
this.balls.push({
x,
y,
dx,
dy,
colorStep: 0,
prevColor: initialColor,
nextColor: initialColor
});
}
}
private updateTerminalSize(): void {
this.width = process.stdout.columns || 80;
this.height = process.stdout.rows || 24;
this.renderer.width = this.width;
this.renderer.height = this.height;
// Reinitialize balls when terminal size changes
this.initializeBalls();
}
private getColor(x: number, y: number): string {
const r = (x * 5) % 255;
const g = (y * 5) % 255;
const b = ((x + y) * 3) % 255;
return `#${ r.toString(16).padStart(2, '0') }${ g.toString(16).padStart(2, '0') }${ b.toString(16).padStart(2, '0') }`;
}
private hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
]
: [ 255, 255, 255 ];
}
private interpolateColor(c1: string, c2: string, t: number): string {
const [ r1, g1, b1 ] = this.hexToRgb(c1);
const [ r2, g2, b2 ] = this.hexToRgb(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `#${ [ r, g, b ].map((v) => v.toString(16).padStart(2, '0')).join('') }`;
}
private getFadedColor(ball: BallInterface): string {
const t = ball.colorStep / this.fadeSteps;
const color = this.interpolateColor(ball.prevColor, ball.nextColor, t);
ball.colorStep++;
if (ball.colorStep > this.fadeSteps) {
ball.colorStep = 0;
ball.prevColor = ball.nextColor;
ball.nextColor = this.getColor(ball.x, ball.y);
}
return color;
}
private drawBalls(): void {
for (const ball of this.balls) {
const color = this.getFadedColor(ball);
this.renderer.writeText(ball.y, ball.x, xterm.hex(color).bold('█'), true);
}
this.renderer.render();
}
private updateBallPositions(): void {
for (const ball of this.balls) {
ball.x += ball.dx;
ball.y += ball.dy;
// Bounce off walls
if (ball.x <= 0 || ball.x >= this.width - 1) {
ball.dx = -ball.dx;
ball.x = Math.max(0, Math.min(this.width - 1, ball.x));
}
if (ball.y <= 0 || ball.y >= this.height - 1) {
ball.dy = -ball.dy;
ball.y = Math.max(0, Math.min(this.height - 1, ball.y));
}
}
}
}
const balls = new BouncingBalls();
balls.start(15); // smoother animation
console.log('Press q or Ctrl+C to exit');
Terminal Bouncing Block Animation
A mesmerizing terminal-based animation featuring a colored block character (█) that bounces around your terminal window. This program creates a simple yet captivating visual effect using ANSI terminal capabilities.
import { ShadowRenderer, writeRaw, ANSI, xterm } from '@remotex-labs/xansi';
import { createInterface } from 'readline';
class BouncingBall {
private x = 0;
private y = 0;
private dx = 1;
private dy = 1;
private width = 0;
private height = 0;
private intervalId: NodeJS.Timeout | null = null;
private colorStep = 0;
private fadeSteps = 30;
private prevColor = '#000000';
private nextColor = '#ffffff';
private renderer: ShadowRenderer;
constructor() {
this.renderer = new ShadowRenderer(this.height, this.width, 0, 0);
this.updateTerminalSize();
process.stdout.on('resize', () => this.updateTerminalSize());
writeRaw(ANSI.HIDE_CURSOR);
writeRaw(ANSI.CLEAR_SCREEN);
this.resetPosition();
this.prevColor = this.getColor(this.x, this.y);
this.nextColor = this.prevColor;
}
private updateTerminalSize(): void {
this.width = process.stdout.columns || 80;
this.height = process.stdout.rows || 24;
this.renderer.width = this.width;
this.renderer.height = this.height;
if (this.x >= this.width - 4 || this.y >= this.height - 2) {
this.resetPosition();
}
}
private resetPosition(): void {
this.x = Math.floor(this.width / 2);
this.y = Math.floor(this.height / 2);
}
private getColor(x: number, y: number): string {
const r = (x * 5) % 255;
const g = (y * 5) % 255;
const b = ((x + y) * 3) % 255;
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
private hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
]
: [255, 255, 255];
}
private interpolateColor(c1: string, c2: string, t: number): string {
const [r1, g1, b1] = this.hexToRgb(c1);
const [r2, g2, b2] = this.hexToRgb(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}
private getFadedColor(): string {
const t = this.colorStep / this.fadeSteps;
const color = this.interpolateColor(this.prevColor, this.nextColor, t);
this.colorStep++;
if (this.colorStep > this.fadeSteps) {
this.colorStep = 0;
this.prevColor = this.nextColor;
this.nextColor = this.getColor(this.x, this.y);
}
return color;
}
private drawBall(): void {
const color = this.getFadedColor();
this.renderer.writeText(this.y, this.x, xterm.hex(color).bold('█'), true);
this.renderer.writeText(this.y, this.x + 1, xterm.hex(color).bold('█'), true);
this.renderer.render();
}
private updateBallPosition(): void {
this.x += this.dx;
this.y += this.dy;
if (this.x <= 0 || this.x >= this.width - 3) {
this.dx = -this.dx;
this.x = Math.max(0, Math.min(this.width - 3, this.x));
}
if (this.y <= 0 || this.y >= this.height - 2) {
this.dy = -this.dy;
this.y = Math.max(0, Math.min(this.height - 2, this.y));
}
}
public start(frameRate = 50): void {
if (this.intervalId) clearInterval(this.intervalId);
this.intervalId = setInterval(() => {
this.updateBallPosition();
this.drawBall();
}, frameRate);
const rl = createInterface({ input: process.stdin, output: process.stdout });
process.stdin?.setRawMode?.(true);
process.stdin.on('data', (key) => {
if (key.toString() === '\u0003' || key.toString().toLowerCase() === 'q') {
this.stop();
process.exit(0);
}
});
}
public stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
writeRaw(ANSI.CLEAR_SCREEN);
writeRaw(ANSI.SHOW_CURSOR);
}
}
const ball = new BouncingBall();
ball.start(15); // smoother animation
console.log('Press q or Ctrl+C to exit');