
LeTUI
TUI library built from scratch with TypeScript and a Rust core, featuring a component-based API, signal-based state management, CSS-like styling API, a Rust-powered layout and rendering engine, and cell-based diffing.


TUI library built from scratch with TypeScript and a Rust core, featuring a component-based API, signal-based state management, CSS-like styling API, a Rust-powered layout and rendering engine, and cell-based diffing.
original
anime4kIn-browser MKV video streamer and player built from scratch, featuring real-time 4K anime upscaling powered by WGSL (WebGPU) shaders ported from Anime4K.

Web app and CLI for exporting X threads, articles, posts, and replies as Markdown or JSON, built around resumable jobs.
Paid music collaborations and song-cover campaigns for artists worldwide. Led frontend and backend development across both products, built the Next.js frontend from scratch, and owned payments, payouts, and mobile audio features. Cut data processing for 1M+ artists from nine days to seven hours and roughly halved Soundcharts costs.
AI mapping platform for satellite and aerial imagery. Built high-performance React interfaces for large imagery datasets, simplified component state and data flow, and helped move users to centralized sign-in with zero downtime.
Social content and analytics platform with shoppable feeds for brands. Led React adoption within an Angular application and improved embedded app performance for e-commerce sites across Europe. Built a custom state manager that cut the JavaScript bundle by 20% and automated package publishing.
Online clothing brand. Fully automated stock updates with a tested pipeline that transformed Airtable inventory data and stored it in the website's database.
Online sports insurance company for individuals, tournaments, etc. Built payment and subscription workflows and internal support tools, redesigned the PostgreSQL schema, and consolidated two backends into one codebase.
Paint in Rust, rethinking interaction state, stabilization, release pain, and text/style diff sync
Part 4 ended with paint taking 81% of frame time. Obvious fix: move paint to Rust.
In TypeScript, I was calling setCell() for every character — updating a typed array through Bun's FFI. Each call had overhead:
// Before - setCell per character
for (let x = frame.x; x < frame.x + frame.width; x++) {
for (let y = frame.y; y < frame.y + frame.height; y++) {
setCell(x, y, char, fg, bg);
}
}In Rust, direct buffer writes. No FFI boundary per cell:
let idx = (w * y + x) as usize * 3;
buf[idx] = char as u64;
buf[idx + 1] = fg; // raw hex, no conversion
buf[idx + 2] = bg;hex_to_color() conversion only happens at flush time when crossterm needs it. During paint, just integer writes.
Here's where it got interesting. My components had isFocused and isPressed props for styling:
const bg = isFocused ? COLORS.default.bg_highlight : COLORS.default.bg;If paint moves to Rust, who owns these flags?
Option 1: Keep state in TS, pass as node props every frame. But then TS needs layout rects for hit-testing, or Rust sends them back. FFI round-trip. Latency. Duplication.
Option 2: Move interaction state to Rust. I went with option 2.
The insight: isFocused and isPressed are view state, not app state. They belong with the renderer.
Rust now maintains:
struct InteractionState {
focused: Option<NodeId>,
pressed: Option<NodeId>,
}When a click comes in:
InteractionStateTypeScript doesn't need to know a button "looks pressed". It only cares when the click completes and onClick fires.
The split:
Rust batches events and returns them to TS:
// Rust queues these during input handling
events.push(Event::Focus(node_id));
events.push(Event::Click(node_id));
// TS receives batch after frameNo synchronous callbacks across FFI. TS updates app state, rebuilds tree, sends to Rust. Rust renders at 120fps without waiting.
Components declare style variants in the node data:
{
id: buttonId,
focusable: true,
styles: {
base: { bg: 0x16181a, fg: 0xffffff },
focused: { bg: 0x3c4048, fg: 0xffffff },
pressed: { bg: 0x5ea1ff, fg: 0x16181a },
}
}Rust picks the right variant based on InteractionState.
Originally two FFI calls: calculate_layout() then paint(). Both built the same Taffy tree. Wasteful.
Now there's just paint(). It parses node data, builds the Taffy tree once, computes layout, stores frames for TS to read, then paints. One tree, one pass.
TS just calls api.paint() then reads frames back:
api.paint(ptr(nodeData), nodeData.length, ptr(textData), textData.length);
updateNodeFrames(root); // reads FRAMES array from RustTaffy tree stores everything needed for both layout and paint via NodeContext:
enum NodeContext {
Text { content: String, fg: u32, bg: u32 },
Button { label: String, fg: u32, bg: u32, border_color: u32, border_style: BorderStyle },
Input { content: String, fg: u32, bg: u32, border_color: u32, border_style: BorderStyle },
Row { fg: u32, bg: u32, border_color: u32, border_style: BorderStyle },
Column { fg: u32, bg: u32, border_color: u32, border_style: BorderStyle },
}paint_taffy_node() walks the tree recursively, calling taffy.layout(node_id) for position and taffy.get_node_context(node_id) for paint data.
Text was rendering wrong: [ ld hello wor ] instead of [ hello world ]. Two issues:
Issue 1: Using layout.location instead of content box. Location is the outer box — includes border and padding. Fix:
let content_x = abs_x + layout.content_box_x();
let content_y = abs_y + layout.content_box_y();
draw_text_at(content_x, content_y, content, fg, bg, tw, th);Issue 2: Character count vs byte count. TypeScript sent [...text].length (character count), Rust used it to slice UTF-8 bytes. For ASCII they match, but offset accumulation was wrong. Fix:
const textLength = new TextEncoder().encode(textContent).length;Still measuring, but early results look promising. Paint in Rust eliminates thousands of FFI calls per frame. Next up: proper text input handling and controlled vs uncontrolled inputs.
Part 5 ended with paint/layout in Rust. Performance looked great. Then I hit the less fun part: stability and release workflow.
Responsiveness degraded after resize/reinit. Root cause: freed buffers but forgot to reset first-diff state.
pub extern "C" fn free_buffer() -> c_int {
*CURRENT_BUFFER.lock().unwrap() = None;
*LAST_BUFFER.lock().unwrap() = None;
*FIRST_DIFF.lock().unwrap() = true;
// ...
}One boolean, noticeable UX impact.
Removed unnecessary buffer locks in Rust paint. Before, each helper locked the buffer internally. After, paint() takes one lock and passes &mut [u64] down.
// before: each draw_* locked CURRENT_BUFFER internally
fn draw_text_at(x: f32, y: f32, text: &str, ...)
// after: paint() takes one lock, helpers just write
fn draw_text_at(buf: &mut [u64], x: f32, y: f32, text: &str, ...)Less lock churn in hot path.
Fixed benchmark wrappers for OpenTUI and pi-mono — I was measuring at the wrong point for their render scheduling. Switched to measuring after scheduled completion (setTimeout(0) in adapters).
After this pass, sample metrics: 0.5ms avg, ~`1900fps` in that scene.
Once paint was stable, text path became the obvious target. Wrote TEXT_SERIALIZATION_OPTIMIZATIONS.md to map waste before coding changes.
Main findings:
Also added a small stress script for raw terminal writes as calibration.
Big structure cleanup before release:
src/examples/index.tslegacy/Shipped v0.0.10, immediately hit publish/package issues. Fixed those, then hit another: logger could crash in some environments. Safe fallback:
function createLogWriter() {
try {
return Bun.file("dump/logs.txt").writer();
} catch {
return { write() {}, flush() {} };
}
}That became v0.0.11.
Part 5 was raw speed. Part 6 was making it usable and shippable:
Part 7 is where text/style sync architecture changes for real.
After v0.0.11, one focus: stop sending all text every frame. This part is mostly that migration plus style sync changes.
Quick cleanup first:
disable_raw_mode + leave alt screen)Also fixed p99 calculation in metrics — interpolation instead of rough indexing:
function percentile(sorted: number[], p: number): number {
const rank = p * (sorted.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return sorted[lower]!;
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (rank - lower);
}Made comparison across runs more trustworthy.
Old flow: TS serialized full textData, paint() received full text payload every frame.
New flow:
TEXT_REGISTRY: HashMap<u32, String> keyed by node idpaint() reads text from registryFirst implementation used per-node FFI calls (upsert_text/delete_text). Worked, but too many calls. Switched to batched ops — one sync_text_ops call per frame:
op (u8), node_id (u32 LE), text_len (u32 LE), text bytes for upsertsRust applies all ops under one registry lock.
match op {
TEXT_OP_UPSERT => registry.insert(node_id, text),
TEXT_OP_DELETE => registry.remove(&node_id),
_ => return 0,
}Main result: text transfer scales with changed nodes, not total text.
Some snapshots got worse before better (~0.9ms avg vs older ~0.5ms). Expected — extra JS work to build ops payload plus several architectural changes landing in the same window. Later snapshots: ~0.7ms avg. Not a straight line, but direction was correct.
Added a Unix socket test driver (ping, sleep, key, mouse, focused, snapshot, quit) to script interactions. Useful for quick black-box checks, but later removed during cleanup.
Next bottleneck: style payload duplication. Before, each node carried full style payload inline in serialized node data. After:
nodeType, childCount, nodeId, styleId)STYLE_REGISTRYsync_style_opsThis introduced explicit schema files (style_schema.rs, src/style-schema.ts) and unlocked broader style support:
margin, rowGap, columnGapflexShrink, flexBasisjustifyContent, alignItems, alignSelfmin/max width/heightoverflow, overflowX, overflowYFixed text wrapping and clipping in Rust. Problems: naive text measurement for wrapping/newlines, draw path not clipping correctly in nested overflow scenarios.
Key changes:
measure_wrapped_text)Improved: long text behavior, input cursor placement in wrapped content, nested container clipping correctness.
Built more demos and proper docs:
ai-agent, mission-control, visualizerGood validation pass — these examples touched many edge cases at once.
By end of this phase:
Next: scroll containers, richer text primitives, and tail-latency cleanup on heavier scenes.