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
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);
});
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');