demo.mp4
Simple TUI library written using Rust and TypeScript
Core dependencies:
TODO:
Goal: Stop sending all text on every render. Register text once → get u8 ID → pass only ID (1 byte) across FFI.
Rust side (lib.rs):
- Add
TextRegistrystruct:slots: Vec<Option<String>>(256 max),free: Vec<u8>(freelist) - FFI functions:
text_register(ptr, len) -> u8— alloc slot, return ID (0 = failure/empty)text_update(id, ptr, len) -> i32— replace text at existing IDtext_free(id) -> i32— return slot to freelisttext_clear() -> i32— reset all (on quit)
- In
paint(): lock registry once at start, pass&TextRegistrydown - Change node parsing: read
text_idfield (u8 stored as f32), resolve viareg.get(id)
TypeScript side (ffi.ts, runtime.ts):
- Add FFI symbols for
text_register,text_update,text_free,text_clear - Create
TextIdRegistryclass:byNodeId: Map<number, { id: number; last: string }>getOrCreate(nodeId, text): register if new, update if changed, return IDfreeNode(nodeId): reclaim ID when node unmounts
- In serialization: replace
textLengthfield withtextId, removetextDataconcat - Track
prevNodeIdsvscurrentNodeIdseach frame → free disappeared nodes
Key details:
u8IDs are exactly representable inf32(no Float32Array change needed)- ID 0 = empty/missing text (reserve slot 0)
- Must free IDs on node removal (255 usable slots max)
- Lock registry once per
paint(), not per-node (perf)
Expected result: FFI traffic O(changed texts) instead of O(all texts every frame)
- Add
overflow: "hidden"style prop → triggers clipping during paint - Add
scrollX/scrollYsignals per scrollable node - Pass scissor rect to Rust paint — skip cells outside bounds
- Horizontal scrolling first, then vertical
-
TextChunktype:{ text: string; fg?: number; bg?: number; bold?: boolean } - Update
Textcomponent to acceptTextChunk[]or plain string - Serialize chunks to Rust for rendering
- Single-line input improvements (cursor position, selection)
- Multi-line text editor (builds on scrollable + input)
- Tree-sitter integration (Rust bindings → FFI)
- TextMate-compatible theme loading (like OpenTUI's
SyntaxStyle)
-
Move paint to Rust (currently 81% of frame time @ 1.7ms avg)
- Why: Eliminates JS per-cell loops, staging buffer, and BigInt conversions
- New FFI function:
paint(node_data, text_data, focused_id, pressed_id, colors...) - Rust side:
- Reuse parsed node tree from
calculate_layout(already has frames) - Add
PaintNodestruct with: frame, bg, fg, border_color, border_style, text range, node_type - Implement
draw_background(),draw_border(),draw_text(),draw_cursor()writing directly toCURRENT_BUFFER - Recursive
paint_node()traversal matching JS logic
- Reuse parsed node tree from
- TS side:
- Add
isFocusedandisPressedfields to serialization (FIELDS_PER_NODE: 13 → 15) - Replace JS
paint()call withapi.paint(...)FFI call - Keep
registerHit()in JS for mouse hit testing (cheap traversal) - Remove
stagingBufferandflushStagingBuffer()(no longer needed)
- Add
- Expected result: Paint phase from 1.7ms → ~0.1-0.2ms
-
Add scrollable containers
-
How to handle serialization of walls of text: https://ampcode.com/threads/T-019bdac3-ba03-745f-a3d3-c9d53bfa0648
-
Add render caching - skip serialize/layout if signals unchanged (pi-mono pattern)
-
Incremental tree updates - don't rebuild entire Taffy tree each frame, cache structure and update only changed nodes
-
Visibility culling - skip
paint()for off-screen nodes (OpenTUI's_getVisibleChildrenpattern) -
Neovim as text input
-
Will SIMD work if I wanna implement caching for serialization. For example, when comparing trees I used SIMD (idk what i'm talking about)
-
Refactor flush function with BatchWriter pattern to reduce nesting
- BatchWriter struct holds stdout ref, char_seq, batch_start_x/y, prev_fg/bg
new()initializes with sentinel colors (u64::MAX) to force first color emitpush(x, y, ch, fg, bg)handles gap detection, color changes, and accumulates charsflush_pending()emits MoveTo + Print for accumulated batch- Encapsulates all batching logic, main loop just calls push() for changed cells
-
Add performance stats overlay that update independently from rest of the app (can i use a separate thread?)
-
Add
flexGrowsupport for dynamic width components (e.g., progress bars)- TypeScript side:
- Add
flexGrow?: numbertoStylePropsintypes.ts - Update
createStyleSignals()incomponents.tsto includeflexGrow: $(input.flexGrow) - Update serialization in
runtime.tsto passflexGrowvalue to Rust (add toFIELDS_PER_NODE)
- Add
- Rust side:
- Increment
FIELDS_PER_NODEfrom 12 to 13 inlib.rs - Add
flex_grow: f32field toNodestruct - Parse
flex_growinparse_node()function - Apply
style.flex_grow = node.flex_growinget_styles()for all node types
- Increment
- Progress bar update:
- Remove fixed
widthprop fromProgressBar - Instead of
" ".repeat(n), use a single space" "for text - Set
flexGrow: progress / 100on filled node,flexGrow: (100 - progress) / 100on unfilled node - Layout engine distributes space proportionally - bar auto-sizes to container
- Remove fixed
- Benefits: No fixed width needed, bar fills available space, cleaner API
- TypeScript side:
- push your changes
- update versions in
Cargo.tomlandpackage.json git tag v0.0.1- tag a commitgit push origin v0.0.1- push the tag- release action will build and deploy it as package