Skip to content
Gary Wu
Go back

LCD Display Protocols & LED Matrix Libraries

Edit page

Org Status: 🟒 Active Cloudflare: N/A Last Audited: 2026-04-28


Building a virtual LCD display means understanding the protocols that real displays speak. Whether you’re emulating an HD44780 character LCD in a macOS notch HUD, rendering RGB pixels on a canvas, or accepting commands from existing embedded libraries β€” the protocols and graphics primitives are the foundation.

This article covers the full stack: from the venerable HD44780 command set through modern RGB LED matrix libraries, color display controllers, graphics primitive APIs, network protocols for remote displays, and practical TypeScript implementations for virtual display engines.

What you’ll learn:


You’ve built a dot-matrix display renderer β€” a Canvas element drawing 5x7 pixel characters in a grid. It works. But it’s bespoke. Every feature you add (cursor blinking, display shifting, custom characters) is ad-hoc code with no standard behavior to reference.

Meanwhile, the embedded world has decades of established display protocols. The HD44780 has been the standard character LCD controller since the 1980s. Color displays like the ILI9341 and SSD1331 have well-documented command sets. Libraries like Adafruit GFX provide a universal graphics primitive API that works across hundreds of display types.

The gap: these protocols exist for hardware. Nobody has assembled a clear reference for implementing them in software β€” particularly for a Canvas-based virtual display on macOS.

What changes if you get this right:


1. The HD44780 Command Architecture

The Hitachi HD44780 is the most widely cloned LCD controller in history. Every 16x2 or 20x4 character LCD you’ve seen likely speaks HD44780. Understanding its command set is understanding the lingua franca of character displays.

The HD44780 has two registers accessed via the RS (Register Select) pin:

interface HD44780Registers {
  // RS=0: Instruction Register β€” accepts commands
  // RS=1: Data Register β€” accepts character data for DDRAM/CGRAM

  instructionRegister: number; // Write commands here
  dataRegister: number;       // Write display data here
}

interface HD44780State {
  // Display Data RAM β€” 80 bytes (what's shown on screen)
  ddram: Uint8Array;          // 80 bytes: row1[0..39], row2[64..103]
  ddramAddress: number;       // Current write position (0-79 or 64-103)

  // Character Generator RAM β€” 64 bytes (custom characters)
  cgram: Uint8Array;          // 64 bytes: 8 characters Γ— 8 rows
  cgramAddress: number;       // Current CGRAM write position

  // Cursor position
  cursorRow: number;
  cursorCol: number;

  // Display control flags
  displayOn: boolean;
  cursorOn: boolean;
  cursorBlink: boolean;

  // Entry mode flags
  incrementMode: boolean;     // true=increment, false=decrement
  displayShift: boolean;      // true=shift display on write

  // Function set
  eightBitMode: boolean;      // true=8-bit, false=4-bit
  twoLineMode: boolean;       // true=2 lines, false=1 line
  largeFont: boolean;         // true=5x10, false=5x8

  // Display shift offset
  shiftOffset: number;

  // Internal
  busyFlag: boolean;
}

Key insight: The HD44780 is fundamentally a state machine. Every command modifies internal state (cursor position, display flags, RAM addresses). A virtual HD44780 is just this state machine plus a renderer that reads DDRAM to produce pixels.

2. Instruction Decoding

The HD44780 uses a priority-encoded instruction format β€” the highest set bit determines the command:

enum HD44780Command {
  // Bit patterns (RS=0, RW=0 for all writes)
  CLEAR_DISPLAY       = 0x01, // 00000001
  RETURN_HOME         = 0x02, // 0000001x
  ENTRY_MODE_SET      = 0x04, // 000001xx β€” bits: I/D, S
  DISPLAY_CONTROL     = 0x08, // 00001xxx β€” bits: D, C, B
  CURSOR_SHIFT        = 0x10, // 0001xxxx β€” bits: S/C, R/L
  FUNCTION_SET        = 0x20, // 001xxxxx β€” bits: DL, N, F
  SET_CGRAM_ADDR      = 0x40, // 01xxxxxx β€” 6-bit address
  SET_DDRAM_ADDR      = 0x80, // 1xxxxxxx β€” 7-bit address
}

// Decode an instruction byte
function decodeInstruction(byte: number): {
  command: string;
  params: Record<string, boolean | number>;
} {
  if (byte & 0x80) {
    return {
      command: 'SET_DDRAM_ADDR',
      params: { address: byte & 0x7F }
    };
  }
  if (byte & 0x40) {
    return {
      command: 'SET_CGRAM_ADDR',
      params: { address: byte & 0x3F }
    };
  }
  if (byte & 0x20) {
    return {
      command: 'FUNCTION_SET',
      params: {
        eightBit: !!(byte & 0x10),  // DL
        twoLine:  !!(byte & 0x08),  // N
        largeFont: !!(byte & 0x04), // F
      }
    };
  }
  if (byte & 0x10) {
    return {
      command: 'CURSOR_SHIFT',
      params: {
        shiftDisplay: !!(byte & 0x08), // S/C: 1=display, 0=cursor
        shiftRight:   !!(byte & 0x04), // R/L: 1=right, 0=left
      }
    };
  }
  if (byte & 0x08) {
    return {
      command: 'DISPLAY_CONTROL',
      params: {
        displayOn:   !!(byte & 0x04), // D
        cursorOn:    !!(byte & 0x02), // C
        cursorBlink: !!(byte & 0x01), // B
      }
    };
  }
  if (byte & 0x04) {
    return {
      command: 'ENTRY_MODE_SET',
      params: {
        increment:    !!(byte & 0x02), // I/D: 1=increment, 0=decrement
        displayShift: !!(byte & 0x01), // S: 1=shift display on write
      }
    };
  }
  if (byte & 0x02) {
    return { command: 'RETURN_HOME', params: {} };
  }
  if (byte & 0x01) {
    return { command: 'CLEAR_DISPLAY', params: {} };
  }
  return { command: 'NOP', params: {} };
}

3. DDRAM Address Mapping

The HD44780’s DDRAM layout is notoriously non-contiguous. For a 2-line display, row 2 doesn’t start at address 40 β€” it starts at address 64 (0x40):

// DDRAM layout for common display sizes
const DDRAM_ROW_OFFSETS: Record<string, number[]> = {
  '16x2': [0x00, 0x40],
  '20x4': [0x00, 0x40, 0x14, 0x54],  // Row 3 wraps after row 1!
  '40x2': [0x00, 0x40],
  '20x2': [0x00, 0x40],
  '16x1': [0x00],                      // Some 16x1s are actually 8x2
  '8x2':  [0x00, 0x40],
};

// Convert DDRAM address to row/col
function ddramToPosition(
  address: number,
  cols: number,
  rows: number
): { row: number; col: number } | null {
  const offsets = rows === 4
    ? [0x00, 0x40, 0x00 + cols, 0x40 + cols]
    : [0x00, 0x40];

  for (let row = 0; row < offsets.length; row++) {
    const start = offsets[row];
    if (address >= start && address < start + cols) {
      return { row, col: address - start };
    }
  }
  return null;
}

// Convert row/col to DDRAM address
function positionToDdram(
  row: number,
  col: number,
  cols: number
): number {
  const rowOffsets = [0x00, 0x40, cols, 0x40 + cols];
  return rowOffsets[row] + col;
}

Key insight: The 20x4 display layout is especially tricky β€” row 3 (index 2) continues from the end of row 1’s DDRAM space, and row 4 (index 3) continues from row 2. This means scrolling a 20x4 display doesn’t work like you’d expect.

4. Color Formats

Moving beyond monochrome, displays use different color encodings. The three you need to know:

// RGB565: 16-bit color (5 red, 6 green, 5 blue)
// Used by: ST7735, ILI9341, Adafruit GFX, most TFT displays
type RGB565 = number; // uint16

// RGB888: 24-bit true color (8 bits per channel)
// Used by: Canvas, CSS, most software rendering
type RGB888 = { r: number; g: number; b: number };

// HSV: Hue-Saturation-Value
// Used by: FastLED, WLED, color animations
type HSV = { h: number; s: number; v: number }; // h: 0-360, s/v: 0-100

function rgb888ToRgb565(r: number, g: number, b: number): RGB565 {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

function rgb565ToRgb888(color: RGB565): RGB888 {
  const r = ((color >> 11) & 0x1F) << 3;
  const g = ((color >> 5) & 0x3F) << 2;
  const b = (color & 0x1F) << 3;
  // Fill lower bits for full range
  return {
    r: r | (r >> 5),
    g: g | (g >> 6),
    b: b | (b >> 5),
  };
}

function rgb888ToHsv(r: number, g: number, b: number): HSV {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const d = max - min;

  let h = 0;
  const s = max === 0 ? 0 : d / max;
  const v = max;

  if (d !== 0) {
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
      case g: h = ((b - r) / d + 2) / 6; break;
      case b: h = ((r - g) / d + 4) / 6; break;
    }
  }

  return { h: h * 360, s: s * 100, v: v * 100 };
}

function hsvToRgb888(h: number, s: number, v: number): RGB888 {
  h /= 360; s /= 100; v /= 100;
  const i = Math.floor(h * 6);
  const f = h * 6 - i;
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);

  let r: number, g: number, b: number;
  switch (i % 6) {
    case 0: r = v; g = t; b = p; break;
    case 1: r = q; g = v; b = p; break;
    case 2: r = p; g = v; b = t; break;
    case 3: r = p; g = q; b = v; break;
    case 4: r = t; g = p; b = v; break;
    case 5: r = v; g = p; b = q; break;
    default: r = 0; g = 0; b = 0;
  }

  return {
    r: Math.round(r * 255),
    g: Math.round(g * 255),
    b: Math.round(b * 255),
  };
}

// Named color constants (Adafruit GFX convention, RGB565)
const COLOR = {
  BLACK:   0x0000,
  WHITE:   0xFFFF,
  RED:     0xF800,
  GREEN:   0x07E0,
  BLUE:    0x001F,
  CYAN:    0x07FF,
  MAGENTA: 0xF81F,
  YELLOW:  0xFFE0,
  ORANGE:  0xFD20,
} as const;

Key insight: RGB565 gives green one extra bit because the human eye is most sensitive to green wavelengths. This isn’t arbitrary β€” it’s perceptual optimization in 16 bits. When converting RGB565 back to RGB888, you must fill the lower bits (not just shift) or you’ll never reach full white (255,255,255).

5. The Graphics Primitive Interface

The Adafruit GFX library established the standard API for drawing on small displays. Every display library in the Arduino ecosystem derives from it. Here’s the interface in TypeScript:

interface GFXDisplay {
  readonly width: number;
  readonly height: number;

  // The one function every display MUST implement
  drawPixel(x: number, y: number, color: number): void;

  // Everything else is built on drawPixel
  drawLine(x0: number, y0: number, x1: number, y1: number, color: number): void;
  drawFastHLine(x: number, y: number, w: number, color: number): void;
  drawFastVLine(x: number, y: number, h: number, color: number): void;

  drawRect(x: number, y: number, w: number, h: number, color: number): void;
  fillRect(x: number, y: number, w: number, h: number, color: number): void;

  drawCircle(x0: number, y0: number, r: number, color: number): void;
  fillCircle(x0: number, y0: number, r: number, color: number): void;

  drawTriangle(
    x0: number, y0: number,
    x1: number, y1: number,
    x2: number, y2: number,
    color: number
  ): void;
  fillTriangle(
    x0: number, y0: number,
    x1: number, y1: number,
    x2: number, y2: number,
    color: number
  ): void;

  drawRoundRect(x: number, y: number, w: number, h: number, radius: number, color: number): void;
  fillRoundRect(x: number, y: number, w: number, h: number, radius: number, color: number): void;

  drawBitmap(x: number, y: number, bitmap: Uint8Array, w: number, h: number, color: number): void;
  drawChar(x: number, y: number, c: string, color: number, bg: number, size: number): void;

  fillScreen(color: number): void;
  setRotation(r: number): void; // 0-3 for 0/90/180/270 degrees

  // Text cursor
  setCursor(x: number, y: number): void;
  setTextColor(fg: number, bg?: number): void;
  setTextSize(s: number): void;
  setTextWrap(wrap: boolean): void;
  print(text: string): void;
  println(text: string): void;

  // Color helper
  color565(r: number, g: number, b: number): number;
}

Pattern 1: Virtual HD44780 Controller

A complete virtual HD44780 that accepts the same byte commands as the real chip. This is the core state machine β€” pair it with any renderer (Canvas, SwiftUI, terminal).

type HD44780EventType =
  | 'clear'
  | 'home'
  | 'cursor-move'
  | 'display-update'
  | 'display-control'
  | 'shift'
  | 'cgram-update'
  | 'function-set';

type HD44780Event = {
  type: HD44780EventType;
  state: Readonly<VirtualHD44780>;
};

type HD44780Listener = (event: HD44780Event) => void;

class VirtualHD44780 {
  // Memory
  readonly ddram = new Uint8Array(128).fill(0x20);  // Space-filled
  readonly cgram = new Uint8Array(64).fill(0x00);

  // Configuration
  cols: number;
  rows: number;

  // State
  ddramAddress = 0x00;
  cgramAddress = 0x00;
  addressingCgram = false;

  displayOn = true;
  cursorOn = false;
  cursorBlink = false;

  incrementMode = true;
  displayShift = false;

  eightBitMode = true;
  twoLineMode = true;
  largeFont = false;

  shiftOffset = 0;

  private listeners: HD44780Listener[] = [];
  private nibbleBuffer: number | null = null;

  constructor(cols = 16, rows = 2) {
    this.cols = cols;
    this.rows = rows;
  }

  onEvent(listener: HD44780Listener): () => void {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }

  private emit(type: HD44780EventType): void {
    const event: HD44780Event = { type, state: this };
    for (const listener of this.listeners) {
      listener(event);
    }
  }

  // RS=0: Write instruction
  writeInstruction(byte: number): void {
    if (!this.eightBitMode) {
      // 4-bit mode: accumulate two nibbles
      if (this.nibbleBuffer === null) {
        this.nibbleBuffer = (byte & 0xF0);
        return;
      }
      byte = this.nibbleBuffer | ((byte >> 4) & 0x0F);
      this.nibbleBuffer = null;
    }

    this.executeInstruction(byte);
  }

  // RS=1: Write data
  writeData(byte: number): void {
    if (!this.eightBitMode) {
      if (this.nibbleBuffer === null) {
        this.nibbleBuffer = (byte & 0xF0);
        return;
      }
      byte = this.nibbleBuffer | ((byte >> 4) & 0x0F);
      this.nibbleBuffer = null;
    }

    if (this.addressingCgram) {
      this.cgram[this.cgramAddress & 0x3F] = byte;
      this.cgramAddress = (this.cgramAddress + 1) & 0x3F;
      this.emit('cgram-update');
    } else {
      this.ddram[this.ddramAddress & 0x7F] = byte;
      this.advanceCursor();
      this.emit('display-update');
    }
  }

  private executeInstruction(byte: number): void {
    if (byte & 0x80) {
      // Set DDRAM Address
      this.ddramAddress = byte & 0x7F;
      this.addressingCgram = false;
      this.emit('cursor-move');
      return;
    }

    if (byte & 0x40) {
      // Set CGRAM Address
      this.cgramAddress = byte & 0x3F;
      this.addressingCgram = true;
      this.emit('cursor-move');
      return;
    }

    if (byte & 0x20) {
      // Function Set
      this.eightBitMode = !!(byte & 0x10);
      this.twoLineMode = !!(byte & 0x08);
      this.largeFont = !!(byte & 0x04);
      this.emit('function-set');
      return;
    }

    if (byte & 0x10) {
      // Cursor or Display Shift
      const shiftDisplay = !!(byte & 0x08);
      const shiftRight = !!(byte & 0x04);

      if (shiftDisplay) {
        this.shiftOffset += shiftRight ? 1 : -1;
      } else {
        if (shiftRight) {
          this.ddramAddress = (this.ddramAddress + 1) & 0x7F;
        } else {
          this.ddramAddress = (this.ddramAddress - 1) & 0x7F;
        }
      }
      this.emit('shift');
      return;
    }

    if (byte & 0x08) {
      // Display On/Off Control
      this.displayOn = !!(byte & 0x04);
      this.cursorOn = !!(byte & 0x02);
      this.cursorBlink = !!(byte & 0x01);
      this.emit('display-control');
      return;
    }

    if (byte & 0x04) {
      // Entry Mode Set
      this.incrementMode = !!(byte & 0x02);
      this.displayShift = !!(byte & 0x01);
      this.emit('display-control');
      return;
    }

    if (byte & 0x02) {
      // Return Home
      this.ddramAddress = 0x00;
      this.shiftOffset = 0;
      this.emit('home');
      return;
    }

    if (byte & 0x01) {
      // Clear Display
      this.ddram.fill(0x20);
      this.ddramAddress = 0x00;
      this.shiftOffset = 0;
      this.incrementMode = true;
      this.emit('clear');
      return;
    }
  }

  private advanceCursor(): void {
    if (this.incrementMode) {
      this.ddramAddress++;
    } else {
      this.ddramAddress--;
    }
    this.ddramAddress &= 0x7F;

    if (this.displayShift) {
      this.shiftOffset += this.incrementMode ? 1 : -1;
    }
  }

  // Convenience: get the visible characters for each row
  getVisibleText(): string[] {
    const result: string[] = [];
    const rowOffsets = this.rows === 4
      ? [0x00, 0x40, this.cols, 0x40 + this.cols]
      : this.rows === 1
        ? [0x00]
        : [0x00, 0x40];

    for (let row = 0; row < this.rows; row++) {
      let text = '';
      for (let col = 0; col < this.cols; col++) {
        const addr = (rowOffsets[row] + col + this.shiftOffset) & 0x7F;
        const charCode = this.ddram[addr];
        // Characters 0-7 are CGRAM custom characters
        if (charCode < 8) {
          text += `\x00`; // Placeholder β€” renderer handles CGRAM
        } else {
          text += String.fromCharCode(charCode);
        }
      }
      result.push(text);
    }
    return result;
  }

  // Get CGRAM character bitmap (8 rows of 5 bits each)
  getCustomCharacter(index: number): Uint8Array {
    const offset = (index & 0x07) * 8;
    return this.cgram.slice(offset, offset + 8);
  }

  // Get cursor position as row/col
  getCursorPosition(): { row: number; col: number } {
    const rowOffsets = this.rows === 4
      ? [0x00, 0x40, this.cols, 0x40 + this.cols]
      : [0x00, 0x40];

    for (let row = 0; row < rowOffsets.length; row++) {
      const start = rowOffsets[row];
      if (this.ddramAddress >= start && this.ddramAddress < start + this.cols) {
        return { row, col: this.ddramAddress - start };
      }
    }
    return { row: 0, col: this.ddramAddress };
  }

  // Helper: write a string at the current position
  printString(text: string): void {
    for (const char of text) {
      this.writeData(char.charCodeAt(0));
    }
  }

  // Helper: set cursor to row/col
  setCursor(row: number, col: number): void {
    const rowOffsets = [0x00, 0x40, this.cols, 0x40 + this.cols];
    this.writeInstruction(0x80 | (rowOffsets[row] + col));
  }

  // Helper: define a custom character (index 0-7)
  defineCharacter(index: number, bitmap: number[]): void {
    this.writeInstruction(0x40 | ((index & 0x07) << 3));
    for (const row of bitmap) {
      this.writeData(row & 0x1F);
    }
    // Restore DDRAM addressing
    this.addressingCgram = false;
  }
}

Usage β€” driving the virtual HD44780 like hardware code would:

const lcd = new VirtualHD44780(16, 2);

// Standard initialization sequence (matches real hardware)
lcd.writeInstruction(0x38); // Function Set: 8-bit, 2-line, 5x8 font
lcd.writeInstruction(0x0C); // Display On, Cursor Off, Blink Off
lcd.writeInstruction(0x06); // Entry Mode: Increment, No Shift
lcd.writeInstruction(0x01); // Clear Display

// Write "Hello, World!" on line 1
lcd.printString('Hello, World!');

// Move to line 2, column 0
lcd.setCursor(1, 0);
lcd.printString('HD44780 Virtual');

// Define a custom heart character at index 0
lcd.defineCharacter(0, [
  0b00000,
  0b01010,
  0b11111,
  0b11111,
  0b11111,
  0b01110,
  0b00100,
  0b00000,
]);

// Write the custom character
lcd.setCursor(0, 15);
lcd.writeData(0x00); // Character 0 = our heart

Pattern 2: Canvas-Based Dot Matrix Renderer

A renderer that takes a virtual HD44780 (or any pixel buffer) and draws it on an HTML Canvas with authentic LCD aesthetics β€” pixel gaps, glow effects, and configurable colors.

interface DotMatrixTheme {
  backgroundColor: string;
  pixelOnColor: string;
  pixelOffColor: string;
  pixelGap: number;         // Gap between dots in pixels
  pixelRadius: number;      // 0=square, >0=rounded
  glowRadius: number;       // 0=no glow, >0=blur radius
  glowColor: string;
  dotShape: 'square' | 'circle';
}

const THEMES: Record<string, DotMatrixTheme> = {
  classicGreen: {
    backgroundColor: '#0a1a0a',
    pixelOnColor: '#33ff33',
    pixelOffColor: '#0a2a0a',
    pixelGap: 1,
    pixelRadius: 0,
    glowRadius: 2,
    glowColor: 'rgba(51, 255, 51, 0.3)',
    dotShape: 'square',
  },
  amberLCD: {
    backgroundColor: '#1a0f00',
    pixelOnColor: '#ffaa00',
    pixelOffColor: '#1a1200',
    pixelGap: 1,
    pixelRadius: 0,
    glowRadius: 2,
    glowColor: 'rgba(255, 170, 0, 0.3)',
    dotShape: 'square',
  },
  blueLED: {
    backgroundColor: '#000a1a',
    pixelOnColor: '#3399ff',
    pixelOffColor: '#001022',
    pixelGap: 1,
    pixelRadius: 1,
    glowRadius: 3,
    glowColor: 'rgba(51, 153, 255, 0.4)',
    dotShape: 'circle',
  },
  rgbMatrix: {
    backgroundColor: '#111111',
    pixelOnColor: '#ffffff',  // Overridden per-pixel for RGB
    pixelOffColor: '#1a1a1a',
    pixelGap: 1,
    pixelRadius: 1,
    glowRadius: 1,
    glowColor: 'rgba(255, 255, 255, 0.2)',
    dotShape: 'circle',
  },
  whiteOnBlue: {
    backgroundColor: '#0000aa',
    pixelOnColor: '#ffffff',
    pixelOffColor: '#0000cc',
    pixelGap: 1,
    pixelRadius: 0,
    glowRadius: 0,
    glowColor: 'transparent',
    dotShape: 'square',
  },
};

class DotMatrixRenderer {
  private ctx: CanvasRenderingContext2D;
  private pixelSize: number;
  private theme: DotMatrixTheme;

  constructor(
    private canvas: HTMLCanvasElement,
    private gridWidth: number,   // Total pixel columns
    private gridHeight: number,  // Total pixel rows
    theme: DotMatrixTheme = THEMES.classicGreen,
  ) {
    this.ctx = canvas.getContext('2d')!;
    this.theme = theme;

    // Calculate pixel size to fill canvas
    const cellWidth = canvas.width / gridWidth;
    const cellHeight = canvas.height / gridHeight;
    this.pixelSize = Math.min(cellWidth, cellHeight);
  }

  setTheme(theme: DotMatrixTheme): void {
    this.theme = theme;
  }

  // Render a monochrome pixel buffer (1 = on, 0 = off)
  renderMono(pixels: Uint8Array): void {
    const { ctx, theme, pixelSize, gridWidth, gridHeight } = this;
    const gap = theme.pixelGap;
    const dotSize = pixelSize - gap;

    ctx.fillStyle = theme.backgroundColor;
    ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    // Apply glow effect
    if (theme.glowRadius > 0) {
      ctx.save();
      ctx.shadowBlur = theme.glowRadius;
      ctx.shadowColor = theme.glowColor;
    }

    for (let y = 0; y < gridHeight; y++) {
      for (let x = 0; x < gridWidth; x++) {
        const on = pixels[y * gridWidth + x] !== 0;
        ctx.fillStyle = on ? theme.pixelOnColor : theme.pixelOffColor;

        const px = x * pixelSize + gap / 2;
        const py = y * pixelSize + gap / 2;

        if (theme.dotShape === 'circle') {
          ctx.beginPath();
          ctx.arc(px + dotSize / 2, py + dotSize / 2, dotSize / 2, 0, Math.PI * 2);
          ctx.fill();
        } else if (theme.pixelRadius > 0) {
          this.roundRect(px, py, dotSize, dotSize, theme.pixelRadius);
        } else {
          ctx.fillRect(px, py, dotSize, dotSize);
        }
      }
    }

    if (theme.glowRadius > 0) {
      ctx.restore();
    }
  }

  // Render an RGB pixel buffer (3 bytes per pixel: R, G, B)
  renderRGB(pixels: Uint8Array): void {
    const { ctx, theme, pixelSize, gridWidth, gridHeight } = this;
    const gap = theme.pixelGap;
    const dotSize = pixelSize - gap;

    ctx.fillStyle = theme.backgroundColor;
    ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    for (let y = 0; y < gridHeight; y++) {
      for (let x = 0; x < gridWidth; x++) {
        const i = (y * gridWidth + x) * 3;
        const r = pixels[i];
        const g = pixels[i + 1];
        const b = pixels[i + 2];

        const isOff = r === 0 && g === 0 && b === 0;

        if (isOff) {
          ctx.fillStyle = theme.pixelOffColor;
        } else {
          ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
          if (theme.glowRadius > 0) {
            ctx.shadowBlur = theme.glowRadius;
            ctx.shadowColor = `rgba(${r}, ${g}, ${b}, 0.4)`;
          }
        }

        const px = x * pixelSize + gap / 2;
        const py = y * pixelSize + gap / 2;

        if (theme.dotShape === 'circle') {
          ctx.beginPath();
          ctx.arc(px + dotSize / 2, py + dotSize / 2, dotSize / 2, 0, Math.PI * 2);
          ctx.fill();
        } else {
          ctx.fillRect(px, py, dotSize, dotSize);
        }

        ctx.shadowBlur = 0;
      }
    }
  }

  // Render an RGB565 pixel buffer (2 bytes per pixel, big-endian)
  renderRGB565(pixels: Uint16Array): void {
    const rgb = new Uint8Array(pixels.length * 3);
    for (let i = 0; i < pixels.length; i++) {
      const c = pixels[i];
      const { r, g, b } = rgb565ToRgb888(c);
      rgb[i * 3] = r;
      rgb[i * 3 + 1] = g;
      rgb[i * 3 + 2] = b;
    }
    this.renderRGB(rgb);
  }

  private roundRect(
    x: number, y: number, w: number, h: number, r: number
  ): void {
    const ctx = this.ctx;
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
    ctx.lineTo(x + w, y + h - r);
    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    ctx.lineTo(x + r, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
    ctx.lineTo(x, y + r);
    ctx.quadraticCurveTo(x, y, x + r, y);
    ctx.fill();
  }
}

Pattern 3: GFX Graphics Primitives on a Pixel Buffer

A TypeScript port of the Adafruit GFX drawing primitives. This operates on an abstract pixel buffer β€” connect it to a Canvas renderer or any other output.

// Standard 5x7 font (ASCII 32-126), packed as 5 bytes per character
// Each byte is a column, LSB is top row
const FONT_5X7: Uint8Array = new Uint8Array([
  // Space (0x20)
  0x00, 0x00, 0x00, 0x00, 0x00,
  // ! (0x21)
  0x00, 0x00, 0x5F, 0x00, 0x00,
  // " (0x22)
  0x00, 0x07, 0x00, 0x07, 0x00,
  // # (0x23)
  0x14, 0x7F, 0x14, 0x7F, 0x14,
  // $ (0x24)
  0x24, 0x2A, 0x7F, 0x2A, 0x12,
  // % (0x25)
  0x23, 0x13, 0x08, 0x64, 0x62,
  // & (0x26)
  0x36, 0x49, 0x55, 0x22, 0x50,
  // ' (0x27)
  0x00, 0x05, 0x03, 0x00, 0x00,
  // ( (0x28)
  0x00, 0x1C, 0x22, 0x41, 0x00,
  // ) (0x29)
  0x00, 0x41, 0x22, 0x1C, 0x00,
  // * (0x2A)
  0x08, 0x2A, 0x1C, 0x2A, 0x08,
  // + (0x2B)
  0x08, 0x08, 0x3E, 0x08, 0x08,
  // , (0x2C)
  0x00, 0x50, 0x30, 0x00, 0x00,
  // - (0x2D)
  0x08, 0x08, 0x08, 0x08, 0x08,
  // . (0x2E)
  0x00, 0x60, 0x60, 0x00, 0x00,
  // / (0x2F)
  0x20, 0x10, 0x08, 0x04, 0x02,
  // 0 (0x30)
  0x3E, 0x51, 0x49, 0x45, 0x3E,
  // 1 (0x31)
  0x00, 0x42, 0x7F, 0x40, 0x00,
  // 2 (0x32)
  0x42, 0x61, 0x51, 0x49, 0x46,
  // 3 (0x33)
  0x21, 0x41, 0x45, 0x4B, 0x31,
  // 4 (0x34)
  0x18, 0x14, 0x12, 0x7F, 0x10,
  // 5 (0x35)
  0x27, 0x45, 0x45, 0x45, 0x39,
  // 6 (0x36)
  0x3C, 0x4A, 0x49, 0x49, 0x30,
  // 7 (0x37)
  0x01, 0x71, 0x09, 0x05, 0x03,
  // 8 (0x38)
  0x36, 0x49, 0x49, 0x49, 0x36,
  // 9 (0x39)
  0x06, 0x49, 0x49, 0x29, 0x1E,
  // : (0x3A)
  0x00, 0x36, 0x36, 0x00, 0x00,
  // ; (0x3B)
  0x00, 0x56, 0x36, 0x00, 0x00,
  // < (0x3C)
  0x00, 0x08, 0x14, 0x22, 0x41,
  // = (0x3D)
  0x14, 0x14, 0x14, 0x14, 0x14,
  // > (0x3E)
  0x41, 0x22, 0x14, 0x08, 0x00,
  // ? (0x3F)
  0x02, 0x01, 0x51, 0x09, 0x06,
  // @ (0x40)
  0x32, 0x49, 0x79, 0x41, 0x3E,
  // A (0x41)
  0x7E, 0x11, 0x11, 0x11, 0x7E,
  // B (0x42)
  0x7F, 0x49, 0x49, 0x49, 0x36,
  // C (0x43)
  0x3E, 0x41, 0x41, 0x41, 0x22,
  // D (0x44)
  0x7F, 0x41, 0x41, 0x22, 0x1C,
  // E (0x45)
  0x7F, 0x49, 0x49, 0x49, 0x41,
  // F (0x46)
  0x7F, 0x09, 0x09, 0x01, 0x01,
  // G (0x47)
  0x3E, 0x41, 0x41, 0x51, 0x32,
  // H (0x48)
  0x7F, 0x08, 0x08, 0x08, 0x7F,
  // I (0x49)
  0x00, 0x41, 0x7F, 0x41, 0x00,
  // J (0x4A)
  0x20, 0x40, 0x41, 0x3F, 0x01,
  // K (0x4B)
  0x7F, 0x08, 0x14, 0x22, 0x41,
  // L (0x4C)
  0x7F, 0x40, 0x40, 0x40, 0x40,
  // M (0x4D)
  0x7F, 0x02, 0x04, 0x02, 0x7F,
  // N (0x4E)
  0x7F, 0x04, 0x08, 0x10, 0x7F,
  // O (0x4F)
  0x3E, 0x41, 0x41, 0x41, 0x3E,
  // P (0x50)
  0x7F, 0x09, 0x09, 0x09, 0x06,
  // Q (0x51)
  0x3E, 0x41, 0x51, 0x21, 0x5E,
  // R (0x52)
  0x7F, 0x09, 0x19, 0x29, 0x46,
  // S (0x53)
  0x46, 0x49, 0x49, 0x49, 0x31,
  // T (0x54)
  0x01, 0x01, 0x7F, 0x01, 0x01,
  // U (0x55)
  0x3F, 0x40, 0x40, 0x40, 0x3F,
  // V (0x56)
  0x1F, 0x20, 0x40, 0x20, 0x1F,
  // W (0x57)
  0x7F, 0x20, 0x18, 0x20, 0x7F,
  // X (0x58)
  0x63, 0x14, 0x08, 0x14, 0x63,
  // Y (0x59)
  0x03, 0x04, 0x78, 0x04, 0x03,
  // Z (0x5A)
  0x61, 0x51, 0x49, 0x45, 0x43,
]);

class PixelBuffer implements GFXDisplay {
  readonly buffer: Uint16Array; // RGB565 color per pixel
  private cursorX = 0;
  private cursorY = 0;
  private textColor: number = 0xFFFF;
  private textBg: number = 0x0000;
  private textSize = 1;
  private wrap = true;
  private rotation = 0;
  private _width: number;
  private _height: number;

  constructor(
    public readonly width: number,
    public readonly height: number,
  ) {
    this._width = width;
    this._height = height;
    this.buffer = new Uint16Array(width * height);
  }

  drawPixel(x: number, y: number, color: number): void {
    // Apply rotation
    [x, y] = this.applyRotation(x, y);
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    this.buffer[y * this.width + x] = color;
  }

  private applyRotation(x: number, y: number): [number, number] {
    switch (this.rotation) {
      case 1: return [this.width - 1 - y, x];
      case 2: return [this.width - 1 - x, this.height - 1 - y];
      case 3: return [y, this.height - 1 - x];
      default: return [x, y];
    }
  }

  drawLine(x0: number, y0: number, x1: number, y1: number, color: number): void {
    // Bresenham's line algorithm
    const steep = Math.abs(y1 - y0) > Math.abs(x1 - x0);
    if (steep) {
      [x0, y0] = [y0, x0];
      [x1, y1] = [y1, x1];
    }
    if (x0 > x1) {
      [x0, x1] = [x1, x0];
      [y0, y1] = [y1, y0];
    }

    const dx = x1 - x0;
    const dy = Math.abs(y1 - y0);
    let err = dx / 2;
    const ystep = y0 < y1 ? 1 : -1;
    let y = y0;

    for (let x = x0; x <= x1; x++) {
      if (steep) {
        this.drawPixel(y, x, color);
      } else {
        this.drawPixel(x, y, color);
      }
      err -= dy;
      if (err < 0) {
        y += ystep;
        err += dx;
      }
    }
  }

  drawFastHLine(x: number, y: number, w: number, color: number): void {
    for (let i = 0; i < w; i++) this.drawPixel(x + i, y, color);
  }

  drawFastVLine(x: number, y: number, h: number, color: number): void {
    for (let i = 0; i < h; i++) this.drawPixel(x, y + i, color);
  }

  drawRect(x: number, y: number, w: number, h: number, color: number): void {
    this.drawFastHLine(x, y, w, color);
    this.drawFastHLine(x, y + h - 1, w, color);
    this.drawFastVLine(x, y, h, color);
    this.drawFastVLine(x + w - 1, y, h, color);
  }

  fillRect(x: number, y: number, w: number, h: number, color: number): void {
    for (let j = 0; j < h; j++) {
      this.drawFastHLine(x, y + j, w, color);
    }
  }

  drawCircle(x0: number, y0: number, r: number, color: number): void {
    // Midpoint circle algorithm
    let f = 1 - r;
    let ddF_x = 1;
    let ddF_y = -2 * r;
    let x = 0;
    let y = r;

    this.drawPixel(x0, y0 + r, color);
    this.drawPixel(x0, y0 - r, color);
    this.drawPixel(x0 + r, y0, color);
    this.drawPixel(x0 - r, y0, color);

    while (x < y) {
      if (f >= 0) { y--; ddF_y += 2; f += ddF_y; }
      x++; ddF_x += 2; f += ddF_x;

      this.drawPixel(x0 + x, y0 + y, color);
      this.drawPixel(x0 - x, y0 + y, color);
      this.drawPixel(x0 + x, y0 - y, color);
      this.drawPixel(x0 - x, y0 - y, color);
      this.drawPixel(x0 + y, y0 + x, color);
      this.drawPixel(x0 - y, y0 + x, color);
      this.drawPixel(x0 + y, y0 - x, color);
      this.drawPixel(x0 - y, y0 - x, color);
    }
  }

  fillCircle(x0: number, y0: number, r: number, color: number): void {
    this.drawFastVLine(x0, y0 - r, 2 * r + 1, color);
    this.fillCircleHelper(x0, y0, r, 3, 0, color);
  }

  private fillCircleHelper(
    x0: number, y0: number, r: number,
    corners: number, delta: number, color: number
  ): void {
    let f = 1 - r;
    let ddF_x = 1;
    let ddF_y = -2 * r;
    let x = 0;
    let y = r;

    while (x < y) {
      if (f >= 0) { y--; ddF_y += 2; f += ddF_y; }
      x++; ddF_x += 2; f += ddF_x;

      if (corners & 0x1) {
        this.drawFastVLine(x0 + x, y0 - y, 2 * y + 1 + delta, color);
        this.drawFastVLine(x0 + y, y0 - x, 2 * x + 1 + delta, color);
      }
      if (corners & 0x2) {
        this.drawFastVLine(x0 - x, y0 - y, 2 * y + 1 + delta, color);
        this.drawFastVLine(x0 - y, y0 - x, 2 * x + 1 + delta, color);
      }
    }
  }

  drawTriangle(
    x0: number, y0: number,
    x1: number, y1: number,
    x2: number, y2: number,
    color: number
  ): void {
    this.drawLine(x0, y0, x1, y1, color);
    this.drawLine(x1, y1, x2, y2, color);
    this.drawLine(x2, y2, x0, y0, color);
  }

  fillTriangle(
    x0: number, y0: number,
    x1: number, y1: number,
    x2: number, y2: number,
    color: number
  ): void {
    // Sort vertices by Y
    if (y0 > y1) { [x0, x1] = [x1, x0]; [y0, y1] = [y1, y0]; }
    if (y1 > y2) { [x1, x2] = [x2, x1]; [y1, y2] = [y2, y1]; }
    if (y0 > y1) { [x0, x1] = [x1, x0]; [y0, y1] = [y1, y0]; }

    if (y0 === y2) {
      let a = Math.min(x0, x1, x2);
      let b = Math.max(x0, x1, x2);
      this.drawFastHLine(a, y0, b - a + 1, color);
      return;
    }

    const dx01 = x1 - x0, dy01 = y1 - y0;
    const dx02 = x2 - x0, dy02 = y2 - y0;
    const dx12 = x2 - x1, dy12 = y2 - y1;

    let sa = 0, sb = 0;
    let last = y1 === y2 ? y1 : y1 - 1;

    for (let y = y0; y <= last; y++) {
      let a = x0 + Math.floor(sa / dy01);
      let b = x0 + Math.floor(sb / dy02);
      sa += dx01; sb += dx02;
      if (a > b) [a, b] = [b, a];
      this.drawFastHLine(a, y, b - a + 1, color);
    }

    sa = dx12 * (last + 1 - y1);
    sb = dx02 * (last + 1 - y0);
    for (let y = last + 1; y <= y2; y++) {
      let a = x1 + Math.floor(sa / dy12);
      let b = x0 + Math.floor(sb / dy02);
      sa += dx12; sb += dx02;
      if (a > b) [a, b] = [b, a];
      this.drawFastHLine(a, y, b - a + 1, color);
    }
  }

  drawRoundRect(
    x: number, y: number, w: number, h: number,
    radius: number, color: number
  ): void {
    this.drawFastHLine(x + radius, y, w - 2 * radius, color);
    this.drawFastHLine(x + radius, y + h - 1, w - 2 * radius, color);
    this.drawFastVLine(x, y + radius, h - 2 * radius, color);
    this.drawFastVLine(x + w - 1, y + radius, h - 2 * radius, color);
    // Corners via circle quadrants
    this.drawCircleQuadrant(x + radius, y + radius, radius, 1, color);
    this.drawCircleQuadrant(x + w - radius - 1, y + radius, radius, 2, color);
    this.drawCircleQuadrant(x + w - radius - 1, y + h - radius - 1, radius, 4, color);
    this.drawCircleQuadrant(x + radius, y + h - radius - 1, radius, 8, color);
  }

  fillRoundRect(
    x: number, y: number, w: number, h: number,
    radius: number, color: number
  ): void {
    this.fillRect(x + radius, y, w - 2 * radius, h, color);
    this.fillCircleHelper(x + w - radius - 1, y + radius, radius, 1, h - 2 * radius - 1, color);
    this.fillCircleHelper(x + radius, y + radius, radius, 2, h - 2 * radius - 1, color);
  }

  private drawCircleQuadrant(
    x0: number, y0: number, r: number, quadrant: number, color: number
  ): void {
    let f = 1 - r;
    let ddF_x = 1;
    let ddF_y = -2 * r;
    let x = 0;
    let y = r;

    while (x < y) {
      if (f >= 0) { y--; ddF_y += 2; f += ddF_y; }
      x++; ddF_x += 2; f += ddF_x;

      if (quadrant & 0x1) { this.drawPixel(x0 + x, y0 - y, color); this.drawPixel(x0 + y, y0 - x, color); }
      if (quadrant & 0x2) { this.drawPixel(x0 - y, y0 - x, color); this.drawPixel(x0 - x, y0 - y, color); }
      if (quadrant & 0x4) { this.drawPixel(x0 + x, y0 + y, color); this.drawPixel(x0 + y, y0 + x, color); }
      if (quadrant & 0x8) { this.drawPixel(x0 - y, y0 + x, color); this.drawPixel(x0 - x, y0 + y, color); }
    }
  }

  drawBitmap(
    x: number, y: number, bitmap: Uint8Array,
    w: number, h: number, color: number
  ): void {
    let byteIndex = 0;
    let bit = 0;

    for (let j = 0; j < h; j++) {
      for (let i = 0; i < w; i++) {
        if (i % 8 === 0 && i > 0) byteIndex++;
        if (bitmap[byteIndex] & (0x80 >> (i % 8))) {
          this.drawPixel(x + i, y + j, color);
        }
        bit++;
      }
      byteIndex++;
    }
  }

  drawChar(
    x: number, y: number, c: string,
    color: number, bg: number, size: number
  ): void {
    const charCode = c.charCodeAt(0);
    if (charCode < 0x20 || charCode > 0x7E) return;

    const fontIndex = (charCode - 0x20) * 5;

    for (let col = 0; col < 5; col++) {
      let line = FONT_5X7[fontIndex + col] ?? 0;
      for (let row = 0; row < 8; row++) {
        if (line & 0x01) {
          if (size === 1) {
            this.drawPixel(x + col, y + row, color);
          } else {
            this.fillRect(x + col * size, y + row * size, size, size, color);
          }
        } else if (bg !== color) {
          if (size === 1) {
            this.drawPixel(x + col, y + row, bg);
          } else {
            this.fillRect(x + col * size, y + row * size, size, size, bg);
          }
        }
        line >>= 1;
      }
    }

    // Column gap
    if (bg !== color) {
      if (size === 1) {
        this.drawFastVLine(x + 5, y, 8, bg);
      } else {
        this.fillRect(x + 5 * size, y, size, 8 * size, bg);
      }
    }
  }

  fillScreen(color: number): void {
    this.buffer.fill(color);
  }

  setRotation(r: number): void {
    this.rotation = r & 3;
    if (this.rotation & 1) {
      this._width = this.height;
      this._height = this.width;
    } else {
      this._width = this.width;
      this._height = this.height;
    }
  }

  setCursor(x: number, y: number): void {
    this.cursorX = x;
    this.cursorY = y;
  }

  setTextColor(fg: number, bg?: number): void {
    this.textColor = fg;
    this.textBg = bg ?? fg;
  }

  setTextSize(s: number): void {
    this.textSize = Math.max(1, s);
  }

  setTextWrap(wrap: boolean): void {
    this.wrap = wrap;
  }

  print(text: string): void {
    for (const c of text) {
      if (c === '\n') {
        this.cursorX = 0;
        this.cursorY += this.textSize * 8;
        continue;
      }
      if (c === '\r') {
        this.cursorX = 0;
        continue;
      }

      if (this.wrap && (this.cursorX + this.textSize * 6 > this._width)) {
        this.cursorX = 0;
        this.cursorY += this.textSize * 8;
      }

      this.drawChar(
        this.cursorX, this.cursorY, c,
        this.textColor, this.textBg, this.textSize
      );
      this.cursorX += this.textSize * 6; // 5 pixels + 1 gap
    }
  }

  println(text: string): void {
    this.print(text + '\n');
  }

  color565(r: number, g: number, b: number): number {
    return rgb888ToRgb565(r, g, b);
  }
}

Pattern 4: Color Display Controller Emulator (SPI Command Protocol)

Color TFT and OLED displays (ILI9341, ST7735, SSD1331) use an SPI-like command protocol with a Data/Command (DC) pin. Here’s a virtual color display controller that accepts the same command bytes:

// Common display controller commands shared across SSD1331/ST7735/ILI9341
enum DisplayCmd {
  // System
  NOP          = 0x00,
  SWRESET      = 0x01,  // Software reset
  SLPIN        = 0x10,  // Sleep in
  SLPOUT       = 0x11,  // Sleep out
  NORON        = 0x13,  // Normal display mode on
  INVOFF       = 0x20,  // Display inversion off
  INVON        = 0x21,  // Display inversion on
  DISPOFF      = 0x28,  // Display off
  DISPON       = 0x29,  // Display on

  // Addressing
  CASET        = 0x2A,  // Column address set (x start/end)
  RASET        = 0x2B,  // Row address set (y start/end)
  RAMWR        = 0x2C,  // Memory write (pixel data follows)
  RAMRD        = 0x2E,  // Memory read

  // Display control
  MADCTL       = 0x36,  // Memory Access Data Control (rotation/mirror)
  COLMOD       = 0x3A,  // Color mode (12/16/18 bit)

  // Scrolling
  VSCRDEF      = 0x33,  // Vertical scrolling definition
  VSCRSADD     = 0x37,  // Vertical scrolling start address
}

// MADCTL bit flags (same across ST7735/ILI9341/SSD1331)
const MADCTL_MY  = 0x80; // Row address order (mirror Y)
const MADCTL_MX  = 0x40; // Column address order (mirror X)
const MADCTL_MV  = 0x20; // Row/column exchange (rotate 90)
const MADCTL_ML  = 0x10; // Vertical refresh order
const MADCTL_BGR = 0x08; // BGR color order (vs RGB)
const MADCTL_MH  = 0x04; // Horizontal refresh order

interface DisplayControllerConfig {
  width: number;
  height: number;
  colorDepth: 12 | 16 | 18;  // Bits per pixel
  name: string;               // 'SSD1331' | 'ST7735' | 'ILI9341'
}

const DISPLAY_CONFIGS: Record<string, DisplayControllerConfig> = {
  SSD1331: { width: 96, height: 64, colorDepth: 16, name: 'SSD1331' },
  ST7735:  { width: 128, height: 160, colorDepth: 16, name: 'ST7735' },
  ILI9341: { width: 240, height: 320, colorDepth: 16, name: 'ILI9341' },
  SSD1306: { width: 128, height: 64, colorDepth: 1 as any, name: 'SSD1306' },
};

class VirtualDisplayController {
  private framebuffer: Uint16Array;
  private config: DisplayControllerConfig;

  // Address window for pixel writes
  private colStart = 0;
  private colEnd: number;
  private rowStart = 0;
  private rowEnd: number;
  private writeCol = 0;
  private writeRow = 0;

  // State
  private displayOn = true;
  private inverted = false;
  private sleeping = false;
  private madctl = 0x00;
  private colorMode = 16;

  // Command parsing state
  private currentCommand: number | null = null;
  private paramBuffer: number[] = [];
  private expectedParams = 0;

  // Scroll
  private scrollTopFixed = 0;
  private scrollArea = 0;
  private scrollOffset = 0;

  private listeners: ((fb: Uint16Array) => void)[] = [];

  constructor(configName: string = 'ST7735') {
    this.config = DISPLAY_CONFIGS[configName] ?? DISPLAY_CONFIGS.ST7735;
    this.framebuffer = new Uint16Array(this.config.width * this.config.height);
    this.colEnd = this.config.width - 1;
    this.rowEnd = this.config.height - 1;
  }

  onFrame(listener: (fb: Uint16Array) => void): void {
    this.listeners.push(listener);
  }

  // DC=0: Command byte
  writeCommand(cmd: number): void {
    this.currentCommand = cmd;
    this.paramBuffer = [];

    switch (cmd) {
      case DisplayCmd.SWRESET:
        this.reset();
        break;
      case DisplayCmd.SLPOUT:
        this.sleeping = false;
        break;
      case DisplayCmd.SLPIN:
        this.sleeping = true;
        break;
      case DisplayCmd.DISPON:
        this.displayOn = true;
        this.emitFrame();
        break;
      case DisplayCmd.DISPOFF:
        this.displayOn = false;
        break;
      case DisplayCmd.INVON:
        this.inverted = true;
        this.emitFrame();
        break;
      case DisplayCmd.INVOFF:
        this.inverted = false;
        this.emitFrame();
        break;
      case DisplayCmd.CASET:
        this.expectedParams = 4;
        break;
      case DisplayCmd.RASET:
        this.expectedParams = 4;
        break;
      case DisplayCmd.MADCTL:
        this.expectedParams = 1;
        break;
      case DisplayCmd.COLMOD:
        this.expectedParams = 1;
        break;
      case DisplayCmd.RAMWR:
        this.writeCol = this.colStart;
        this.writeRow = this.rowStart;
        this.expectedParams = Infinity;
        break;
      case DisplayCmd.VSCRDEF:
        this.expectedParams = 6;
        break;
      case DisplayCmd.VSCRSADD:
        this.expectedParams = 2;
        break;
    }
  }

  // DC=1: Data byte
  writeData(byte: number): void {
    this.paramBuffer.push(byte);

    if (this.currentCommand === DisplayCmd.RAMWR) {
      this.handlePixelData();
      return;
    }

    if (this.paramBuffer.length >= this.expectedParams) {
      this.executeCommand();
    }
  }

  // Bulk data write (for efficiency)
  writeDataBulk(data: Uint8Array | number[]): void {
    for (const byte of data) {
      this.writeData(byte);
    }
  }

  private handlePixelData(): void {
    if (this.paramBuffer.length < 2) return;

    // RGB565: 2 bytes per pixel, big-endian
    const hi = this.paramBuffer[this.paramBuffer.length - 2];
    const lo = this.paramBuffer[this.paramBuffer.length - 1];

    if (this.paramBuffer.length % 2 !== 0) return;

    const color = (hi << 8) | lo;
    const idx = this.writeRow * this.config.width + this.writeCol;

    if (idx < this.framebuffer.length) {
      this.framebuffer[idx] = this.inverted ? ~color & 0xFFFF : color;
    }

    // Advance write position within window
    this.writeCol++;
    if (this.writeCol > this.colEnd) {
      this.writeCol = this.colStart;
      this.writeRow++;
      if (this.writeRow > this.rowEnd) {
        this.writeRow = this.rowStart;
        this.emitFrame();
      }
    }

    // Keep only the last incomplete pixel
    if (this.paramBuffer.length >= 2) {
      this.paramBuffer = [];
    }
  }

  private executeCommand(): void {
    const p = this.paramBuffer;

    switch (this.currentCommand) {
      case DisplayCmd.CASET:
        this.colStart = (p[0] << 8) | p[1];
        this.colEnd = (p[2] << 8) | p[3];
        break;

      case DisplayCmd.RASET:
        this.rowStart = (p[0] << 8) | p[1];
        this.rowEnd = (p[2] << 8) | p[3];
        break;

      case DisplayCmd.MADCTL:
        this.madctl = p[0];
        break;

      case DisplayCmd.COLMOD:
        this.colorMode = p[0] & 0x07;
        break;

      case DisplayCmd.VSCRDEF:
        this.scrollTopFixed = (p[0] << 8) | p[1];
        this.scrollArea = (p[2] << 8) | p[3];
        break;

      case DisplayCmd.VSCRSADD:
        this.scrollOffset = (p[0] << 8) | p[1];
        this.emitFrame();
        break;
    }

    this.currentCommand = null;
    this.paramBuffer = [];
  }

  private reset(): void {
    this.framebuffer.fill(0x0000);
    this.displayOn = true;
    this.inverted = false;
    this.sleeping = false;
    this.madctl = 0x00;
    this.colStart = 0;
    this.colEnd = this.config.width - 1;
    this.rowStart = 0;
    this.rowEnd = this.config.height - 1;
    this.writeCol = 0;
    this.writeRow = 0;
    this.scrollOffset = 0;
  }

  private emitFrame(): void {
    for (const listener of this.listeners) {
      listener(this.framebuffer);
    }
  }

  getFramebuffer(): Uint16Array {
    return this.framebuffer;
  }

  getConfig(): DisplayControllerConfig {
    return this.config;
  }
}

Pattern 5: WebSocket Protocol Adapter

Accept display commands over WebSocket so remote processes can drive your virtual display. This bridges the gap between embedded LCD libraries and a macOS Canvas renderer.

interface DisplayProtocolMessage {
  // Instruction messages (RS=0)
  type: 'instruction' | 'data' | 'data-bulk' | 'text' | 'clear'
      | 'cursor' | 'gfx' | 'theme' | 'spi-cmd' | 'spi-data';

  // For instruction/data: the byte value
  byte?: number;

  // For data-bulk: array of bytes (efficient pixel writes)
  bytes?: number[];

  // For text: string to display
  text?: string;
  row?: number;
  col?: number;

  // For cursor: position
  x?: number;
  y?: number;

  // For gfx: drawing primitives
  op?: 'drawPixel' | 'drawLine' | 'drawRect' | 'fillRect'
     | 'drawCircle' | 'fillCircle' | 'drawBitmap' | 'fillScreen'
     | 'print' | 'println' | 'drawChar';
  args?: number[];
  color?: number;

  // For theme: change display appearance
  theme?: string;
}

// LCDproc-compatible text protocol (ASCII over TCP/WebSocket)
// Based on the LCDproc client-server protocol on port 13666
class LCDProcProtocolAdapter {
  private lcd: VirtualHD44780;
  private clientName = 'unknown';

  constructor(lcd: VirtualHD44780) {
    this.lcd = lcd;
  }

  // Parse and execute an LCDproc protocol line
  handleLine(line: string): string {
    const parts = line.trim().split(/\s+/);
    const cmd = parts[0];

    switch (cmd) {
      case 'hello':
        return `connect LCDproc 0.5.9 protocol 0.3 lcd wid ${this.lcd.cols} hgt ${this.lcd.rows} cellwid 5 cellhgt 8`;

      case 'client_set':
        if (parts[1] === '-name') {
          this.clientName = parts[2] ?? 'unknown';
        }
        return 'success';

      case 'screen_add':
        return 'success';

      case 'screen_set':
        return 'success';

      case 'widget_add': {
        const _screenId = parts[1];
        const _widgetId = parts[2];
        const _widgetType = parts[3]; // string, hbar, vbar, title, scroller
        return 'success';
      }

      case 'widget_set': {
        const _screenId = parts[1];
        const widgetId = parts[2];
        // widget_set screen widget x y "text"
        if (parts.length >= 5) {
          const x = parseInt(parts[3]) - 1; // LCDproc is 1-indexed
          const y = parseInt(parts[4]) - 1;
          // Extract quoted text
          const textMatch = line.match(/"([^"]*)"/);
          if (textMatch) {
            this.lcd.setCursor(y, x);
            this.lcd.printString(textMatch[1]);
          }
        }
        return 'success';
      }

      case 'backlight':
        // on/off/toggle/blink
        if (parts[1] === 'on') {
          this.lcd.writeInstruction(0x0C); // Display on
        } else if (parts[1] === 'off') {
          this.lcd.writeInstruction(0x08); // Display off
        }
        return 'success';

      default:
        return `huh? unknown command: ${cmd}`;
    }
  }
}

// WebSocket server that accepts display protocol messages
function createDisplayServer(
  lcd: VirtualHD44780,
  gfx: PixelBuffer,
  colorDisplay: VirtualDisplayController,
  port = 13666
): void {
  // In a real implementation, use 'ws' package or Deno.serve
  // This shows the message handling logic

  const lcdproc = new LCDProcProtocolAdapter(lcd);

  function handleMessage(raw: string | ArrayBuffer): string | void {
    // Text messages: try LCDproc protocol first, then JSON
    if (typeof raw === 'string') {
      // Try JSON parse
      try {
        const msg: DisplayProtocolMessage = JSON.parse(raw);
        return handleJsonMessage(msg);
      } catch {
        // Fall back to LCDproc text protocol
        return lcdproc.handleLine(raw);
      }
    }

    // Binary messages: treat as raw SPI data stream
    if (raw instanceof ArrayBuffer) {
      const data = new Uint8Array(raw);
      for (const byte of data) {
        colorDisplay.writeData(byte);
      }
    }
  }

  function handleJsonMessage(msg: DisplayProtocolMessage): string {
    switch (msg.type) {
      case 'instruction':
        lcd.writeInstruction(msg.byte!);
        return 'ok';

      case 'data':
        lcd.writeData(msg.byte!);
        return 'ok';

      case 'data-bulk':
        for (const byte of msg.bytes ?? []) {
          lcd.writeData(byte);
        }
        return 'ok';

      case 'text':
        if (msg.row !== undefined && msg.col !== undefined) {
          lcd.setCursor(msg.row, msg.col);
        }
        lcd.printString(msg.text ?? '');
        return 'ok';

      case 'clear':
        lcd.writeInstruction(0x01);
        return 'ok';

      case 'cursor':
        lcd.setCursor(msg.row ?? 0, msg.col ?? 0);
        return 'ok';

      case 'gfx':
        return handleGfxMessage(msg);

      case 'spi-cmd':
        colorDisplay.writeCommand(msg.byte!);
        return 'ok';

      case 'spi-data':
        if (msg.bytes) {
          colorDisplay.writeDataBulk(msg.bytes);
        } else if (msg.byte !== undefined) {
          colorDisplay.writeData(msg.byte);
        }
        return 'ok';

      default:
        return 'error: unknown message type';
    }
  }

  function handleGfxMessage(msg: DisplayProtocolMessage): string {
    const a = msg.args ?? [];
    const c = msg.color ?? 0xFFFF;

    switch (msg.op) {
      case 'drawPixel':   gfx.drawPixel(a[0], a[1], c); break;
      case 'drawLine':    gfx.drawLine(a[0], a[1], a[2], a[3], c); break;
      case 'drawRect':    gfx.drawRect(a[0], a[1], a[2], a[3], c); break;
      case 'fillRect':    gfx.fillRect(a[0], a[1], a[2], a[3], c); break;
      case 'drawCircle':  gfx.drawCircle(a[0], a[1], a[2], c); break;
      case 'fillCircle':  gfx.fillCircle(a[0], a[1], a[2], c); break;
      case 'fillScreen':  gfx.fillScreen(c); break;
      case 'print':       gfx.print(msg.text ?? ''); break;
      case 'println':     gfx.println(msg.text ?? ''); break;
      case 'drawChar':    gfx.drawChar(a[0], a[1], msg.text?.[0] ?? ' ', c, a[2] ?? 0, a[3] ?? 1); break;
      default: return 'error: unknown gfx op';
    }
    return 'ok';
  }
}

Pattern 6: HD44780-to-Canvas Bridge

Connect the virtual HD44780 to the Canvas renderer. The HD44780 stores character codes in DDRAM; this bridge looks up the 5x7 font and renders each character as dots.

class HD44780CanvasBridge {
  private lcd: VirtualHD44780;
  private renderer: DotMatrixRenderer;
  private canvas: HTMLCanvasElement;

  // Character cell dimensions
  private charWidth = 5;   // pixels per character width
  private charHeight = 8;  // pixels per character height (7 + 1 gap)
  private charGapX = 1;    // gap between characters
  private charGapY = 1;    // gap between rows

  // Total pixel grid
  private gridWidth: number;
  private gridHeight: number;
  private pixels: Uint8Array;

  // Cursor blink state
  private blinkVisible = true;
  private blinkTimer: ReturnType<typeof setInterval> | null = null;

  constructor(
    lcd: VirtualHD44780,
    canvas: HTMLCanvasElement,
    theme: DotMatrixTheme = THEMES.classicGreen
  ) {
    this.lcd = lcd;
    this.canvas = canvas;

    this.gridWidth = lcd.cols * (this.charWidth + this.charGapX) - this.charGapX;
    this.gridHeight = lcd.rows * (this.charHeight + this.charGapY) - this.charGapY;
    this.pixels = new Uint8Array(this.gridWidth * this.gridHeight);

    this.renderer = new DotMatrixRenderer(canvas, this.gridWidth, this.gridHeight, theme);

    // Listen for LCD state changes
    lcd.onEvent(() => this.render());

    // Start cursor blink timer
    this.blinkTimer = setInterval(() => {
      this.blinkVisible = !this.blinkVisible;
      if (lcd.cursorBlink || lcd.cursorOn) {
        this.render();
      }
    }, 530); // HD44780 blink rate is approximately 1.9 Hz
  }

  render(): void {
    this.pixels.fill(0);

    if (!this.lcd.displayOn) {
      this.renderer.renderMono(this.pixels);
      return;
    }

    const rowOffsets = this.lcd.rows === 4
      ? [0x00, 0x40, this.lcd.cols, 0x40 + this.lcd.cols]
      : [0x00, 0x40];

    for (let row = 0; row < this.lcd.rows; row++) {
      for (let col = 0; col < this.lcd.cols; col++) {
        const addr = (rowOffsets[row] + col + this.lcd.shiftOffset) & 0x7F;
        const charCode = this.lcd.ddram[addr];

        this.renderCharacter(charCode, row, col);
      }
    }

    // Render cursor
    if (this.lcd.cursorOn || this.lcd.cursorBlink) {
      const pos = this.lcd.getCursorPosition();
      this.renderCursor(pos.row, pos.col);
    }

    this.renderer.renderMono(this.pixels);
  }

  private renderCharacter(charCode: number, row: number, col: number): void {
    const startX = col * (this.charWidth + this.charGapX);
    const startY = row * (this.charHeight + this.charGapY);

    if (charCode < 8) {
      // CGRAM custom character
      const bitmap = this.lcd.getCustomCharacter(charCode);
      for (let py = 0; py < 8; py++) {
        for (let px = 0; px < 5; px++) {
          if (bitmap[py] & (0x10 >> px)) {
            const idx = (startY + py) * this.gridWidth + (startX + px);
            if (idx < this.pixels.length) {
              this.pixels[idx] = 1;
            }
          }
        }
      }
    } else if (charCode >= 0x20 && charCode <= 0x7E) {
      // Standard ASCII from 5x7 font
      const fontIndex = (charCode - 0x20) * 5;
      for (let px = 0; px < 5; px++) {
        const column = FONT_5X7[fontIndex + px] ?? 0;
        for (let py = 0; py < 7; py++) {
          if (column & (1 << py)) {
            const idx = (startY + py) * this.gridWidth + (startX + px);
            if (idx < this.pixels.length) {
              this.pixels[idx] = 1;
            }
          }
        }
      }
    }
  }

  private renderCursor(row: number, col: number): void {
    const startX = col * (this.charWidth + this.charGapX);
    const startY = row * (this.charHeight + this.charGapY);

    if (this.lcd.cursorBlink && !this.blinkVisible) {
      return; // Blink off phase
    }

    if (this.lcd.cursorBlink) {
      // Block cursor (fill entire character cell)
      for (let py = 0; py < this.charHeight; py++) {
        for (let px = 0; px < this.charWidth; px++) {
          const idx = (startY + py) * this.gridWidth + (startX + px);
          if (idx < this.pixels.length) {
            this.pixels[idx] = 1;
          }
        }
      }
    } else if (this.lcd.cursorOn) {
      // Underline cursor (bottom row only)
      const py = this.charHeight - 1;
      for (let px = 0; px < this.charWidth; px++) {
        const idx = (startY + py) * this.gridWidth + (startX + px);
        if (idx < this.pixels.length) {
          this.pixels[idx] = 1;
        }
      }
    }
  }

  destroy(): void {
    if (this.blinkTimer) {
      clearInterval(this.blinkTimer);
      this.blinkTimer = null;
    }
  }
}

Pattern 7: Scrolling Text Engine

Horizontal and vertical scrolling for LED matrix displays. This is one of the most common use cases β€” scrolling text that’s wider than the display.

interface ScrollOptions {
  direction: 'left' | 'right' | 'up' | 'down';
  speed: number;        // Pixels per frame
  gap: number;          // Pixels of gap before text repeats
  loop: boolean;        // Whether to loop continuously
  color: number;        // RGB565 color
  bgColor?: number;     // Background color (default: black)
  font?: 'small' | 'medium' | 'large'; // Text size multiplier
}

class ScrollingTextEngine {
  private gfx: PixelBuffer;
  private animFrame: number | null = null;

  // Active scroll regions
  private scrollers: Map<string, ScrollState> = new Map();

  constructor(gfx: PixelBuffer) {
    this.gfx = gfx;
  }

  addScroller(
    id: string,
    text: string,
    y: number,
    height: number,
    options: Partial<ScrollOptions> = {}
  ): void {
    const opts: ScrollOptions = {
      direction: 'left',
      speed: 1,
      gap: 20,
      loop: true,
      color: 0xFFFF,
      bgColor: 0x0000,
      font: 'small',
      ...options,
    };

    const sizeMultiplier = opts.font === 'large' ? 3 : opts.font === 'medium' ? 2 : 1;
    const charWidth = 6 * sizeMultiplier; // 5px + 1px gap
    const textWidth = text.length * charWidth;

    this.scrollers.set(id, {
      text,
      y,
      height,
      options: opts,
      offset: 0,
      textWidth,
      sizeMultiplier,
    });

    if (!this.animFrame) {
      this.startAnimation();
    }
  }

  removeScroller(id: string): void {
    this.scrollers.delete(id);
    if (this.scrollers.size === 0 && this.animFrame) {
      cancelAnimationFrame(this.animFrame);
      this.animFrame = null;
    }
  }

  updateText(id: string, text: string): void {
    const state = this.scrollers.get(id);
    if (state) {
      state.text = text;
      state.textWidth = text.length * 6 * state.sizeMultiplier;
    }
  }

  private startAnimation(): void {
    const tick = () => {
      for (const [, state] of this.scrollers) {
        this.renderScroller(state);
        state.offset += state.options.speed;

        const totalWidth = state.textWidth + state.options.gap;
        if (state.offset >= totalWidth) {
          if (state.options.loop) {
            state.offset = 0;
          } else {
            state.offset = totalWidth;
          }
        }
      }
      this.animFrame = requestAnimationFrame(tick);
    };
    this.animFrame = requestAnimationFrame(tick);
  }

  private renderScroller(state: ScrollState): void {
    const { text, y, height, options, offset, sizeMultiplier } = state;
    const bg = options.bgColor ?? 0x0000;

    // Clear scroll region
    this.gfx.fillRect(0, y, this.gfx.width, height, bg);

    // Save and set text properties
    this.gfx.setTextSize(sizeMultiplier);
    this.gfx.setTextColor(options.color, bg);
    this.gfx.setTextWrap(false);

    const textY = y + Math.floor((height - 8 * sizeMultiplier) / 2);

    if (options.direction === 'left') {
      // Draw text at offset position
      this.gfx.setCursor(-offset, textY);
      this.gfx.print(text);

      // Draw repeated copy for seamless loop
      if (options.loop) {
        const repeatX = state.textWidth + options.gap - offset;
        this.gfx.setCursor(repeatX, textY);
        this.gfx.print(text);
      }
    } else if (options.direction === 'right') {
      this.gfx.setCursor(offset - state.textWidth, textY);
      this.gfx.print(text);
    }
  }

  stop(): void {
    if (this.animFrame) {
      cancelAnimationFrame(this.animFrame);
      this.animFrame = null;
    }
  }
}

interface ScrollState {
  text: string;
  y: number;
  height: number;
  options: ScrollOptions;
  offset: number;
  textWidth: number;
  sizeMultiplier: number;
}

Example 1: Custom Character Definitions (HD44780 CGRAM)

// The HD44780 supports 8 custom characters (indices 0-7)
// Each character is 5 pixels wide and 8 rows tall
// Only the lower 5 bits of each row are used

const CUSTOM_CHARS = {
  heart: [
    0b00000,
    0b01010,
    0b11111,
    0b11111,
    0b11111,
    0b01110,
    0b00100,
    0b00000,
  ],
  smiley: [
    0b00000,
    0b01010,
    0b00000,
    0b00000,
    0b10001,
    0b01110,
    0b00000,
    0b00000,
  ],
  battery_full: [
    0b01110,
    0b11111,
    0b11111,
    0b11111,
    0b11111,
    0b11111,
    0b11111,
    0b11111,
  ],
  battery_empty: [
    0b01110,
    0b10001,
    0b10001,
    0b10001,
    0b10001,
    0b10001,
    0b10001,
    0b11111,
  ],
  arrow_right: [
    0b01000,
    0b01100,
    0b01110,
    0b01111,
    0b01110,
    0b01100,
    0b01000,
    0b00000,
  ],
  thermometer: [
    0b00100,
    0b01010,
    0b01010,
    0b01010,
    0b01010,
    0b10001,
    0b10001,
    0b01110,
  ],
  wifi: [
    0b00000,
    0b01110,
    0b10001,
    0b00100,
    0b01010,
    0b00000,
    0b00100,
    0b00000,
  ],
  lock: [
    0b01110,
    0b10001,
    0b10001,
    0b11111,
    0b11011,
    0b11011,
    0b11111,
    0b00000,
  ],
};

// Load all custom characters
const lcd = new VirtualHD44780(20, 4);
Object.values(CUSTOM_CHARS).forEach((bitmap, i) => {
  if (i < 8) lcd.defineCharacter(i, bitmap);
});

// Display: "Status: [heart] OK [battery] [wifi]"
lcd.setCursor(0, 0);
lcd.printString('Status: ');
lcd.writeData(0); // heart
lcd.printString(' OK ');
lcd.writeData(2); // battery_full
lcd.writeData(6); // wifi

Example 2: Mini Bar Chart on RGB Display

function drawBarChart(
  gfx: PixelBuffer,
  x: number, y: number,
  width: number, height: number,
  values: number[],        // 0.0 to 1.0
  colors: number[],        // RGB565 color per bar
  bgColor: number = 0x0000,
  barGap: number = 1,
): void {
  const barWidth = Math.floor(
    (width - barGap * (values.length - 1)) / values.length
  );

  gfx.fillRect(x, y, width, height, bgColor);

  for (let i = 0; i < values.length; i++) {
    const barHeight = Math.round(values[i] * height);
    const barX = x + i * (barWidth + barGap);
    const barY = y + height - barHeight;
    const color = colors[i % colors.length];

    gfx.fillRect(barX, barY, barWidth, barHeight, color);
  }
}

// Usage: CPU usage per core on a 64x32 matrix
const display = new PixelBuffer(64, 32);
const cpuValues = [0.85, 0.42, 0.67, 0.93, 0.31, 0.55, 0.78, 0.22];
const cpuColors = cpuValues.map(v =>
  v > 0.8 ? 0xF800 :  // Red for >80%
  v > 0.5 ? 0xFFE0 :  // Yellow for >50%
            0x07E0     // Green for <50%
);

drawBarChart(display, 0, 8, 64, 24, cpuValues, cpuColors);
display.setTextSize(1);
display.setTextColor(0xFFFF);
display.setCursor(0, 0);
display.print('CPU');

Example 3: Status Icon Sprites

// 8x8 sprites stored as bitmaps (1 bit per pixel, row-major)
const SPRITES_8X8: Record<string, Uint8Array> = {
  check: new Uint8Array([
    0b00000000,
    0b00000001,
    0b00000010,
    0b10000100,
    0b01001000,
    0b00110000,
    0b00000000,
    0b00000000,
  ]),
  cross: new Uint8Array([
    0b00000000,
    0b01000010,
    0b00100100,
    0b00011000,
    0b00011000,
    0b00100100,
    0b01000010,
    0b00000000,
  ]),
  warning: new Uint8Array([
    0b00011000,
    0b00011000,
    0b00100100,
    0b00100100,
    0b01000010,
    0b01011010,
    0b10000001,
    0b11111111,
  ]),
  clock: new Uint8Array([
    0b00111100,
    0b01000010,
    0b10010001,
    0b10010001,
    0b10001111,
    0b10000001,
    0b01000010,
    0b00111100,
  ]),
};

function drawSprite(
  gfx: PixelBuffer,
  sprite: Uint8Array,
  x: number, y: number,
  color: number,
  scale: number = 1
): void {
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      if (sprite[row] & (0x80 >> col)) {
        if (scale === 1) {
          gfx.drawPixel(x + col, y + row, color);
        } else {
          gfx.fillRect(x + col * scale, y + row * scale, scale, scale, color);
        }
      }
    }
  }
}

// Draw status row: [check] OK  [warning] 3
const display = new PixelBuffer(64, 32);
drawSprite(display, SPRITES_8X8.check, 0, 0, 0x07E0);  // Green check
display.setCursor(10, 0);
display.setTextColor(0x07E0);
display.print('OK');

drawSprite(display, SPRITES_8X8.warning, 32, 0, 0xFFE0); // Yellow warning
display.setCursor(42, 0);
display.setTextColor(0xFFE0);
display.print('3');

Example 4: Color Temperature Display

function drawTemperatureGauge(
  gfx: PixelBuffer,
  temp: number,          // Current temperature
  min: number,           // Min of range
  max: number,           // Max of range
  x: number, y: number,
  width: number, height: number,
): void {
  const normalized = Math.max(0, Math.min(1, (temp - min) / (max - min)));

  // Color gradient: blue (cold) -> green (normal) -> red (hot)
  let color: number;
  if (normalized < 0.5) {
    // Blue to green
    const t = normalized * 2;
    const r = 0;
    const g = Math.round(t * 255);
    const b = Math.round((1 - t) * 255);
    color = rgb888ToRgb565(r, g, b);
  } else {
    // Green to red
    const t = (normalized - 0.5) * 2;
    const r = Math.round(t * 255);
    const g = Math.round((1 - t) * 255);
    const b = 0;
    color = rgb888ToRgb565(r, g, b);
  }

  // Draw gauge background
  gfx.drawRect(x, y, width, height, 0x4208); // Dark gray border
  const barWidth = Math.round(normalized * (width - 2));
  gfx.fillRect(x + 1, y + 1, barWidth, height - 2, color);

  // Draw temperature text
  const text = `${Math.round(temp)}C`;
  gfx.setTextColor(0xFFFF);
  gfx.setTextSize(1);
  gfx.setCursor(x + 2, y + Math.floor((height - 8) / 2));
  gfx.print(text);
}

// Usage
const display = new PixelBuffer(128, 64);
display.setTextColor(0xFFFF);
display.setCursor(0, 0);
display.print('System Temp');
drawTemperatureGauge(display, 67, 20, 100, 0, 12, 128, 14);

display.setCursor(0, 30);
display.print('GPU Temp');
drawTemperatureGauge(display, 82, 20, 100, 0, 42, 128, 14);

Example 5: WLED-Compatible JSON Controller

// Accept WLED JSON API messages and apply to a pixel buffer
// Based on the WLED JSON API specification at kno.wled.ge

interface WLEDState {
  on: boolean;
  bri: number;        // 0-255 brightness
  transition: number; // Transition time in 100ms units
  seg: WLEDSegment[];
}

interface WLEDSegment {
  id: number;
  start: number;
  stop: number;
  col: [number, number, number][]; // Up to 3 colors, each [R, G, B]
  fx: number;      // Effect ID
  sx: number;      // Effect speed
  ix: number;      // Effect intensity
  pal: number;     // Palette ID
}

class WLEDAdapter {
  private state: WLEDState;
  private pixels: Uint8Array; // RGB888, 3 bytes per pixel
  private numPixels: number;

  constructor(numPixels: number) {
    this.numPixels = numPixels;
    this.pixels = new Uint8Array(numPixels * 3);
    this.state = {
      on: true,
      bri: 128,
      transition: 7,
      seg: [{
        id: 0,
        start: 0,
        stop: numPixels,
        col: [[255, 160, 0], [0, 0, 0], [0, 0, 0]],
        fx: 0,
        sx: 128,
        ix: 128,
        pal: 0,
      }],
    };
  }

  // Apply a partial state update (WLED JSON API format)
  applyState(update: Partial<WLEDState>): void {
    if (update.on !== undefined) this.state.on = update.on;
    if (update.bri !== undefined) this.state.bri = update.bri;
    if (update.seg) {
      for (const segUpdate of update.seg) {
        const existing = this.state.seg.find(s => s.id === segUpdate.id);
        if (existing) {
          Object.assign(existing, segUpdate);
        }
      }
    }
    this.render();
  }

  // Render current state to pixel buffer
  private render(): void {
    if (!this.state.on) {
      this.pixels.fill(0);
      return;
    }

    const brightness = this.state.bri / 255;

    for (const seg of this.state.seg) {
      const [r, g, b] = seg.col[0];
      for (let i = seg.start; i < seg.stop && i < this.numPixels; i++) {
        this.pixels[i * 3] = Math.round(r * brightness);
        this.pixels[i * 3 + 1] = Math.round(g * brightness);
        this.pixels[i * 3 + 2] = Math.round(b * brightness);
      }
    }
  }

  getPixels(): Uint8Array {
    return this.pixels;
  }

  // Return current state (for WebSocket GET)
  getState(): WLEDState {
    return { ...this.state };
  }
}

Example 6: Animated Progress Spinner

function drawSpinner(
  gfx: PixelBuffer,
  cx: number, cy: number,
  radius: number,
  frame: number,        // 0-7 for 8-frame animation
  color: number,
  bgColor: number = 0x0000,
): void {
  // 8-segment spinner, each segment is an arc
  const segments = 8;
  const segmentAngle = (2 * Math.PI) / segments;

  for (let i = 0; i < segments; i++) {
    const angle = i * segmentAngle - Math.PI / 2;
    const intensity = ((i + frame) % segments) / segments;
    const x = Math.round(cx + Math.cos(angle) * radius);
    const y = Math.round(cy + Math.sin(angle) * radius);

    // Fade from dim to bright based on position relative to frame
    if (intensity > 0.3) {
      gfx.drawPixel(x, y, color);
    } else {
      gfx.drawPixel(x, y, bgColor);
    }
  }
}

// Animate on a 32x32 display
const display = new PixelBuffer(32, 32);
let frame = 0;
setInterval(() => {
  display.fillScreen(0x0000);
  drawSpinner(display, 16, 16, 8, frame, 0x07FF);  // Cyan spinner

  display.setTextColor(0xFFFF);
  display.setTextSize(1);
  display.setCursor(4, 26);
  display.print('LOAD');

  frame = (frame + 1) % 8;
}, 100);

Example 7: Stock Ticker with Red/Green

interface TickerItem {
  symbol: string;
  price: number;
  change: number; // Percentage, positive or negative
}

function drawStockTicker(
  gfx: PixelBuffer,
  items: TickerItem[],
  y: number,
  height: number,
): void {
  gfx.fillRect(0, y, gfx.width, height, 0x0000);

  let x = 2;
  gfx.setTextSize(1);

  for (const item of items) {
    const isUp = item.change >= 0;
    const symbolColor = 0xFFFF; // White
    const priceColor = isUp ? 0x07E0 : 0xF800; // Green or Red
    const arrow = isUp ? '\x1E' : '\x1F'; // Up/down arrows
    const changeStr = `${isUp ? '+' : ''}${item.change.toFixed(1)}%`;

    // Symbol
    gfx.setTextColor(symbolColor);
    gfx.setCursor(x, y + 1);
    gfx.print(item.symbol);
    x += item.symbol.length * 6 + 2;

    // Price + change
    gfx.setTextColor(priceColor);
    gfx.setCursor(x, y + 1);
    gfx.print(`${item.price.toFixed(0)} ${changeStr}`);
    x += (changeStr.length + item.price.toFixed(0).length + 1) * 6 + 8;
  }
}

// Usage on 128x32 matrix
const display = new PixelBuffer(128, 32);
drawStockTicker(display, [
  { symbol: 'AAPL', price: 178.2, change: 1.3 },
  { symbol: 'GOOGL', price: 142.5, change: -0.8 },
], 0, 10);

Example 8: Multi-Zone Dashboard Layout

interface DashboardZone {
  x: number;
  y: number;
  width: number;
  height: number;
  type: 'text' | 'bar' | 'icon' | 'sparkline';
  label: string;
  value: string | number | number[];
  color: number;
}

function renderDashboard(gfx: PixelBuffer, zones: DashboardZone[]): void {
  gfx.fillScreen(0x0000);

  for (const zone of zones) {
    // Draw zone border
    gfx.drawRect(zone.x, zone.y, zone.width, zone.height, 0x2104);

    switch (zone.type) {
      case 'text': {
        gfx.setTextColor(0x8410); // Gray label
        gfx.setTextSize(1);
        gfx.setCursor(zone.x + 2, zone.y + 1);
        gfx.print(zone.label);

        gfx.setTextColor(zone.color);
        gfx.setCursor(zone.x + 2, zone.y + 10);
        gfx.print(String(zone.value));
        break;
      }
      case 'bar': {
        const val = typeof zone.value === 'number' ? zone.value : 0;
        const barW = Math.round(val * (zone.width - 4));
        gfx.fillRect(zone.x + 2, zone.y + 2, barW, zone.height - 4, zone.color);

        gfx.setTextColor(0xFFFF);
        gfx.setTextSize(1);
        gfx.setCursor(zone.x + 2, zone.y + Math.floor(zone.height / 2) - 3);
        gfx.print(`${zone.label} ${Math.round(val * 100)}%`);
        break;
      }
      case 'sparkline': {
        const values = zone.value as number[];
        if (values.length < 2) break;
        const max = Math.max(...values);
        const min = Math.min(...values);
        const range = max - min || 1;

        const stepX = (zone.width - 4) / (values.length - 1);
        for (let i = 1; i < values.length; i++) {
          const x0 = zone.x + 2 + (i - 1) * stepX;
          const y0 = zone.y + zone.height - 2 -
            ((values[i - 1] - min) / range) * (zone.height - 4);
          const x1 = zone.x + 2 + i * stepX;
          const y1 = zone.y + zone.height - 2 -
            ((values[i] - min) / range) * (zone.height - 4);
          gfx.drawLine(
            Math.round(x0), Math.round(y0),
            Math.round(x1), Math.round(y1),
            zone.color
          );
        }
        break;
      }
    }
  }
}

// Usage: system monitor dashboard on 128x64 display
const display = new PixelBuffer(128, 64);
renderDashboard(display, [
  { x: 0, y: 0, width: 64, height: 20, type: 'text',
    label: 'CPU', value: '67%', color: 0xFFE0 },
  { x: 64, y: 0, width: 64, height: 20, type: 'text',
    label: 'MEM', value: '4.2G', color: 0x07E0 },
  { x: 0, y: 20, width: 128, height: 12, type: 'bar',
    label: 'DISK', value: 0.73, color: 0xFD20 },
  { x: 0, y: 32, width: 128, height: 32, type: 'sparkline',
    label: 'NET', value: [10, 25, 15, 40, 35, 50, 45, 30, 55, 60, 42, 38],
    color: 0x07FF },
]);

Example 9: SPI Command Sequence for ST7735 Init

// Typical ST7735 initialization sequence β€” same commands a real display needs
// This can drive the VirtualDisplayController directly

function initST7735(display: VirtualDisplayController): void {
  // Software reset
  display.writeCommand(0x01);

  // Sleep out
  display.writeCommand(0x11);

  // Frame rate control (normal mode)
  display.writeCommand(0xB1);
  display.writeData(0x01); // RTNA
  display.writeData(0x2C); // Front porch
  display.writeData(0x2D); // Back porch

  // Display inversion control
  display.writeCommand(0xB4);
  display.writeData(0x07);

  // Power control 1
  display.writeCommand(0xC0);
  display.writeData(0xA2);
  display.writeData(0x02);
  display.writeData(0x84);

  // Power control 2
  display.writeCommand(0xC1);
  display.writeData(0xC5);

  // VCOM control
  display.writeCommand(0xC5);
  display.writeData(0x0A);
  display.writeData(0x00);

  // Memory access control (rotation)
  display.writeCommand(0x36);
  display.writeData(0xC8); // Row/Col exchange, RGB order

  // Color mode: 16-bit (RGB565)
  display.writeCommand(0x3A);
  display.writeData(0x05); // 16 bits per pixel

  // Normal display mode
  display.writeCommand(0x13);

  // Display on
  display.writeCommand(0x29);

  // Set column address (0 to 127)
  display.writeCommand(0x2A);
  display.writeData(0x00);
  display.writeData(0x00);
  display.writeData(0x00);
  display.writeData(0x7F);

  // Set row address (0 to 159)
  display.writeCommand(0x2B);
  display.writeData(0x00);
  display.writeData(0x00);
  display.writeData(0x00);
  display.writeData(0x9F);

  // Ready for pixel data
  display.writeCommand(0x2C);
}

// Fill screen with a color gradient
function fillGradient(display: VirtualDisplayController): void {
  display.writeCommand(0x2A); // CASET
  display.writeData(0x00); display.writeData(0x00);
  display.writeData(0x00); display.writeData(0x7F);

  display.writeCommand(0x2B); // RASET
  display.writeData(0x00); display.writeData(0x00);
  display.writeData(0x00); display.writeData(0x9F);

  display.writeCommand(0x2C); // RAMWR

  for (let y = 0; y < 160; y++) {
    for (let x = 0; x < 128; x++) {
      const r = Math.round((x / 128) * 31);
      const g = Math.round((y / 160) * 63);
      const b = 15;
      const color = (r << 11) | (g << 5) | b;
      display.writeData(color >> 8);   // High byte
      display.writeData(color & 0xFF); // Low byte
    }
  }
}

Example 10: Monochrome SSD1306 Page-Mode Emulator

// The SSD1306 uses a unique "page" addressing mode
// The display is divided into 8 pages of 128 columns
// Each byte represents 8 vertical pixels in a page

class VirtualSSD1306 {
  private buffer: Uint8Array; // 128 * 8 pages = 1024 bytes
  private displayOn = false;
  private contrast = 0xCF;
  private invertDisplay = false;
  private startLine = 0;
  private pageAddress = 0;
  private columnAddress = 0;
  private pageStart = 0;
  private pageEnd = 7;
  private colStart = 0;
  private colEnd = 127;

  private currentCommand: number | null = null;
  private paramIndex = 0;
  private params: number[] = [];

  readonly width = 128;
  readonly height = 64;

  private listeners: ((buffer: Uint8Array) => void)[] = [];

  constructor() {
    this.buffer = new Uint8Array(1024); // 128 * 8
  }

  onUpdate(listener: (buffer: Uint8Array) => void): void {
    this.listeners.push(listener);
  }

  writeCommand(cmd: number): void {
    // Single-byte commands
    if (cmd >= 0xB0 && cmd <= 0xB7) {
      this.pageAddress = cmd & 0x07;
      return;
    }

    if ((cmd & 0xF0) === 0x00) {
      this.columnAddress = (this.columnAddress & 0xF0) | (cmd & 0x0F);
      return;
    }

    if ((cmd & 0xF0) === 0x10) {
      this.columnAddress = (this.columnAddress & 0x0F) | ((cmd & 0x0F) << 4);
      return;
    }

    switch (cmd) {
      case 0xAE: this.displayOn = false; break;
      case 0xAF: this.displayOn = true; this.emit(); break;
      case 0xA6: this.invertDisplay = false; this.emit(); break;
      case 0xA7: this.invertDisplay = true; this.emit(); break;
      case 0xE3: break; // NOP

      // Multi-byte commands
      case 0x81: // Set contrast
      case 0xD5: // Set display clock
      case 0xA8: // Set multiplex ratio
      case 0xD3: // Set display offset
      case 0xDA: // Set COM pins config
      case 0x8D: // Charge pump setting
      case 0x20: // Set memory addressing mode
        this.currentCommand = cmd;
        this.paramIndex = 0;
        this.params = [];
        break;

      case 0x21: // Set column address (2 params)
        this.currentCommand = cmd;
        this.paramIndex = 0;
        this.params = [];
        break;

      case 0x22: // Set page address (2 params)
        this.currentCommand = cmd;
        this.paramIndex = 0;
        this.params = [];
        break;
    }
  }

  writeData(data: number): void {
    if (this.currentCommand !== null) {
      this.params.push(data);

      switch (this.currentCommand) {
        case 0x81:
          this.contrast = data;
          this.currentCommand = null;
          break;
        case 0x21:
          if (this.params.length === 2) {
            this.colStart = this.params[0];
            this.colEnd = this.params[1];
            this.columnAddress = this.colStart;
            this.currentCommand = null;
          }
          break;
        case 0x22:
          if (this.params.length === 2) {
            this.pageStart = this.params[0];
            this.pageEnd = this.params[1];
            this.pageAddress = this.pageStart;
            this.currentCommand = null;
          }
          break;
        default:
          this.currentCommand = null;
          break;
      }
      return;
    }

    // Write to display buffer
    const idx = this.pageAddress * 128 + this.columnAddress;
    if (idx < this.buffer.length) {
      this.buffer[idx] = data;
    }

    this.columnAddress++;
    if (this.columnAddress > this.colEnd) {
      this.columnAddress = this.colStart;
      this.pageAddress++;
      if (this.pageAddress > this.pageEnd) {
        this.pageAddress = this.pageStart;
        this.emit();
      }
    }
  }

  // Convert page-mode buffer to pixel array
  toPixelArray(): Uint8Array {
    const pixels = new Uint8Array(this.width * this.height);
    for (let page = 0; page < 8; page++) {
      for (let col = 0; col < 128; col++) {
        const byte = this.buffer[page * 128 + col];
        for (let bit = 0; bit < 8; bit++) {
          const y = page * 8 + bit;
          const on = !!(byte & (1 << bit));
          pixels[y * this.width + col] = (on !== this.invertDisplay) ? 1 : 0;
        }
      }
    }
    return pixels;
  }

  private emit(): void {
    for (const listener of this.listeners) {
      listener(this.buffer);
    }
  }
}

Display Controller Comparison

ControllerTypeResolutionColorsInterfaceCommand ProtocolTypical Use
HD44780Character LCD16x2 to 40x4 charsMono (backlight color)Parallel 4/8-bitInstruction register + Data registerStatus displays, appliances, DIY
SSD1306Monochrome OLED128x641-bit (white/blue/yellow)I2C / SPIPage-mode addressing, column/row commandsWearables, small status displays
SSD1331Color OLED96x6465K (RGB565)SPICASET/RASET/RAMWR window addressingSmall color indicators, IoT
ST7735Color TFT128x16065K (RGB565)SPICASET/RASET/RAMWR (same as ILI9341)Hobby projects, portable devices
ILI9341Color TFT240x320262K (RGB666)SPI / ParallelCASET/RASET/RAMWR + MADCTL rotationDashboard displays, touchscreen UIs
WS2812BAddressable LEDAny matrix16.7M (RGB888)Single-wire timing protocolTimed pulse sequences (no register model)LED art, signage, decorative
HUB75LED Panel32x16 to 128x64Varies (BCM depth)Parallel shift-registerRow scanning + shift register clockingLarge LED signs, stadium displays

Graphics Library Comparison

LibraryLanguageDisplay SupportGraphics APISimulator?ProtocolBest For
Adafruit GFXC++ (Arduino)50+ display driversdrawPixel + derived primitivesNo (hardware only)Direct GPIO/SPI/I2CArduino projects with any display
FastLEDC++ (Arduino)WS2812, APA102, etc.CRGB array, HSV nativeYes (via Wokwi)Single-wire / SPILED strips, NeoPixel matrices
SmartMatrixC++ (Teensy/ESP32)HUB75 panelsLayers + GFX-compatibleNoHUB75 shift-registerLarge RGB LED panels
LVGLC (WASM port)Any display driverWidgets, layouts, animationsYes (browser via WASM)Flush callbackRich embedded UIs
WLEDC++ (ESP)WS2812, SK6812, etc.JSON API, 180+ effectsYes (browser preview)HTTP/WebSocket/MQTTWiFi-controlled LED installations
PixelblazeCustom (JS-like)WS2812, APA102, etc.Per-pixel shader functionsYes (built-in web editor)WebSocket JSONCreative LED art, live coding
led-matrix (npm)TypeScriptCanvas simulationMatrix array inputYes (is a simulator)JavaScript APIBrowser-based LED visualization
Pixlet (Tidbyt)Starlark (Python-like)64x32 RGB matrixWidget-based (Text, Image, Plot)Yes (browser + CLI)WebP pushTidbyt pixel display apps
RGBMatrixEmulatorPythonrpi-rgb-led-matrix compatSame as rpi-rgb-led-matrixYes (is an emulator)Drop-in Python moduleDeveloping Raspberry Pi LED projects on desktop

LED Matrix Simulator Comparison

SimulatorPlatformInput FormatDisplay TypeMulti-colorOpen SourceURL
WokwiBrowserArduino/ESP32 codeFull circuit simulationYesNo (free to use)wokwi.com
RGBMatrixEmulatorPython (browser display)rpi-rgb-led-matrix APILED dot matrixYesYes (MIT)GitHub
PixSimBrowserCustom assembly languageLED matrixMonoYeshenryhale.dev
led-matrix-simulatorBrowser (HTML5 Canvas)Pixel matrix arrayLED dot matrixMonoYes (MIT)GitHub
PixeliqueBrowserFastLED patternsLED matrixYesPartialpixelique.fun
LED Matrix EditorBrowserClick to toggle8x8 bitmap editorMonoYesGitHub
LVGL SimulatorBrowser (WASM)C code compiled to WASMAny display sizeYesYessim.lvgl.io

Protocol Adapter Comparison

ProtocolTransportFormatBidirectionalLatencyComplexityBest For
HD44780 directParallel GPIO (virtual)Binary bytesYes (read busy flag)~37 us (virtual: instant)LowCharacter LCD emulation
SPI command/dataSPI bus (virtual)Binary with DC pinHalf-duplex~ns (virtual: instant)MediumColor display emulation
LCDprocTCP (port 13666)ASCII text commandsYes~msLowSystem monitoring displays
WLED JSONHTTP / WebSocketJSONYes (WebSocket)~10-50msMediumRemote LED control
Serial/UARTSerial portBinary (with 0xFE prefix)Half-duplexBaud-dependentLowSparkFun SerLCD, serial backpacks
Custom WebSocketWebSocketJSON (GFX primitives)Yes~1-5ms localMediumBrowser-to-virtual display
Pixelblaze WSWebSocketJSONYes~10msLowLive pattern control

Don’tDo InsteadWhy
Store display content as stringsUse DDRAM byte array like the real HD44780Strings can’t represent custom CGRAM characters (codes 0-7), and you lose the address gap between rows
Implement cursor position as a simple (x, y)Track as DDRAM address and derive row/colThe HD44780 address space is non-contiguous (row 2 starts at 0x40, not at column count). Direct x/y breaks when you implement display shift
Use RGB888 throughout for color displaysUse RGB565 internally, convert to RGB888 only at the Canvas render stepRGB565 matches what real displays use. Your virtual display should speak the same color format so protocol adapters work without conversion at every pixel
Re-render the entire display on every changeDirty-rectangle tracking: only re-render cells that changedA 64x32 RGB matrix at 60fps is fine. But a 240x320 ILI9341 at 60fps means 4.6M pixel operations per second. Track which address windows were written
Hardcode 16x2 display dimensionsMake dimensions configurable (16x2, 20x4, 40x2)The HD44780 supports many sizes, and the DDRAM row offsets change based on column count. A 20x4 display has rows at offsets [0x00, 0x40, 0x14, 0x54]
Skip the initialization sequenceRequire (or auto-run) the standard init sequence: Function Set, Display Control, Entry Mode, ClearReal code sends init commands. If your virtual display works without them, protocol compatibility breaks β€” you’ll accept malformed command streams without noticing
Use setTimeout for cursor blinkUse a state variable toggled by a single interval timerMultiple setTimeout chains drift and create visual jitter. The HD44780 blinks at ~1.9 Hz (530ms period) β€” one interval is cleaner
Ignore the Entry Mode β€œshift” flagImplement display shift on character writeWhen Entry Mode S=1, writing a character shifts the entire display. This is how scrolling marquee text works on real HD44780s. Skipping it breaks a common usage pattern
Convert RGB565 by just shifting bitsFill the lower bits: r = (r5 << 3) | (r5 >> 2)Simple shifting gives max red of 248, not 255. You need to replicate the upper bits into the lower positions for full range
Build one monolithic display classSeparate state machine (HD44780), pixel buffer (GFX), and renderer (Canvas)You want to swap renderers (Canvas, SwiftUI, terminal), connect different state machines (HD44780, SSD1306), and add protocol adapters (WebSocket, LCDproc) independently

For reference, here is the complete HD44780 instruction set in one table. This is the specification your virtual HD44780 must implement.

InstructionRSRWDB7DB6DB5DB4DB3DB2DB1DB0Execution TimeDescription
Clear Display00000000011.52 msClear all DDRAM, set address to 0, reset shift
Return Home000000001x1.52 msSet DDRAM address to 0, reset display shift
Entry Mode Set00000001I/DS37 usI/D=1: increment, S=1: shift display on write
Display Control0000001DCB37 usD=display, C=cursor, B=blink
Cursor/Display Shift000001S/CR/Lxx37 usS/C=1: display shift, R/L=1: right
Function Set00001DLNFxx37 usDL=8-bit, N=2-line, F=5x10 font
Set CGRAM Addr0001A5A4A3A2A1A037 usSet CGRAM address (6-bit)
Set DDRAM Addr001A6A5A4A3A2A1A037 usSet DDRAM address (7-bit)
Read Busy Flag01BFAC6AC5AC4AC3AC2AC1AC00BF=1: busy, AC=address counter
Write Data10D7D6D5D4D3D2D1D037 usWrite to DDRAM or CGRAM
Read Data11D7D6D5D4D3D2D1D037 usRead from DDRAM or CGRAM

DDRAM Address Map (common configurations):

16x2:   Row 0: 0x00-0x0F    Row 1: 0x40-0x4F
20x2:   Row 0: 0x00-0x13    Row 1: 0x40-0x53
20x4:   Row 0: 0x00-0x13    Row 1: 0x40-0x53    Row 2: 0x14-0x27    Row 3: 0x54-0x67
40x2:   Row 0: 0x00-0x27    Row 1: 0x40-0x67

CGRAM Map:

Character 0: addresses 0x00-0x07  (8 rows, 5 bits each)
Character 1: addresses 0x08-0x0F
Character 2: addresses 0x10-0x17
...
Character 7: addresses 0x38-0x3F

Based on what people actually display on small LED panels and color LCDs, here are the most useful patterns for a macOS notch HUD context:

What Works at Notch Scale (~200x30 pixels visible)

PatternDescriptionColors NeededComplexity
Scrolling status text”Building… 45% complete” scrolling left2-3 (status color + bg)Low
Progress barThin horizontal bar with percentage2-3 (bar color varies by progress)Low
System stats rowCPU 45% MEM 67% DISK 73% with color coding3-4 (green/yellow/red + white text)Medium
Agent status iconsRow of 8x8 sprites: check, spinner, warning3 (green/cyan/yellow)Low
Mini sparkline30-pixel-wide sparkline of last N values2 (line color + bg)Low
Clock + weather14:32 72F with weather icon2-3Medium
Build pipelineColored dots representing CI stages4+ (per-stage colors)Low
Notification toastBrief colored text that fades out2 (notification color + bg)Medium
Stock tickerScrolling symbols with green/red changes3 (white/green/red)Medium
Terminal-styleFixed-width character grid like a real LCD2 (green on black)Low

Color Recommendations for Dark Backgrounds

ColorRGB565RGB888Use Case
LCD Green0x07E0#00FF00Classic status text, β€œall OK”
Amber0xFE00#FF7F00Warnings, retro LCD look
Cyan0x07FF#00FFFFActive/working state
Soft White0xC618#C0C0C0Labels, secondary text
Red0xF800#FF0000Errors, critical alerts
Yellow0xFFE0#FFFF00Warnings, attention needed
Blue0x001F#0000FFLinks, info indicators
Dim Green0x0320#006400Inactive/idle state

Here’s how all the pieces fit together for a macOS notch HUD:

                    +-----------------------+
                    |   macOS Canvas/SwiftUI |  <-- DotMatrixRenderer
                    |   (pixel rendering)   |      renders pixel buffer
                    +-----------+-----------+      to screen
                                |
                    +-----------+-----------+
                    |     PixelBuffer       |  <-- Stores RGB565 pixels
                    |   (graphics state)    |      implements GFX primitives
                    +-----------+-----------+
                                |
              +-----------------+------------------+
              |                 |                  |
   +----------+---+   +--------+------+   +-------+--------+
   | VirtualHD44780|  |VirtualDisplay |   | ScrollingText  |
   | (char LCD    |   |Controller     |   | Engine         |
   |  state)      |   |(color SPI)    |   | (animation)    |
   +-----------+--+   +--------+------+   +-------+--------+
               |               |                   |
   +-----------+--+   +--------+------+   +--------+-------+
   |HD44780Canvas |   |SPI Command    |   | Direct GFX     |
   |Bridge        |   |Adapter        |   | API calls      |
   +-----------+--+   +--------+------+   +--------+-------+
               |               |                   |
   +-----------+---------------+-------------------+
   |              WebSocket Protocol Server         |
   |    (accepts HD44780 / SPI / GFX / LCDproc)    |
   +-----------------------------------------------+
               |
     Remote processes (CLI tools, monitoring agents,
     build systems, AI agents) send display commands

The key architectural insight: separate the state machine (what data is in the display), the pixel buffer (what pixels are lit), and the renderer (how pixels become visible). Each layer can be swapped independently:


For integration with a macOS notch app like Notchy, the virtual display stack maps to Swift as follows:

TypeScript PixelBuffer  -->  Swift class with [UInt16] buffer
TypeScript DotMatrixRenderer  -->  NSView.draw() or SwiftUI Canvas
TypeScript VirtualHD44780  -->  Swift class (same state machine logic)
TypeScript WebSocket server  -->  Network.framework NWListener

The rendering path in Swift would use CGContext or SwiftUI Canvas:

// Swift equivalent of DotMatrixRenderer (conceptual)
struct DotMatrixView: View {
    let pixels: [UInt16]  // RGB565 buffer
    let gridWidth: Int
    let gridHeight: Int
    let dotSize: CGFloat = 3.0
    let gap: CGFloat = 1.0

    var body: some View {
        Canvas { context, size in
            for y in 0..<gridHeight {
                for x in 0..<gridWidth {
                    let color = pixels[y * gridWidth + x]
                    let r = Double((color >> 11) & 0x1F) / 31.0
                    let g = Double((color >> 5) & 0x3F) / 63.0
                    let b = Double(color & 0x1F) / 31.0

                    let rect = CGRect(
                        x: CGFloat(x) * (dotSize + gap),
                        y: CGFloat(y) * (dotSize + gap),
                        width: dotSize,
                        height: dotSize
                    )

                    context.fill(
                        Path(ellipseIn: rect),
                        with: .color(Color(
                            red: r, green: g, blue: b
                        ))
                    )
                }
            }
        }
    }
}

For network communication, use Network.framework to accept WebSocket connections on a local port, enabling CLI tools and background processes to push content to the notch display:

// Conceptual WebSocket server for display commands
import Network

let listener = try NWListener(using: .tcp, on: 13666)
listener.newConnectionHandler = { connection in
    // Upgrade to WebSocket, parse JSON messages,
    // route to VirtualHD44780 or PixelBuffer
}
listener.start(queue: .main)

Official Documentation & Datasheets

Graphics & Display Libraries

LED Control Protocols & Ecosystems

LED Matrix Simulators

Display Controllers & Hardware

Color Formats & Conversion

Virtual Display Daemons

JavaScript/TypeScript Display Libraries

Blog Posts & Tutorials


Edit page
Share this post on:

Previous Post
Funnel Design for Free Tool Sites
Next Post
Multi-Agent AI Processing Pipelines on Cloudflare Workers