Designing a TUI Framework in Rust - Part 1
Part 1 of 2 in Designing a TUI Framework in Rust.
Next: Part 2: What the Real World Taught Us
Telex is a component-based TUI framework for Rust. This post describes the foundational design decisions made in its first version: the problems each one solves, the alternatives considered, and the trade-offs accepted.
The problem
Component-based UI frameworks follow a simple idea: describe what the screen should look like given the current state, and let the framework figure out how to update the terminal. The component is a function from state to view tree. State changes, the function re-runs, and the framework diffs the result against the previous frame.
In a garbage-collected language this is straightforward. Closures capture whatever they need, the GC tracks references, and everyone goes home. Rust doesn't work that way. Three tensions define the design space:
- Closures and lifetimes. An event handler on a button needs to outlive
the render call that created it. But a closure that captures
&mut stateborrows from the enclosing scope and can't be stored beyond it. - Shared mutable access. Multiple handlers need to mutate the same
state - an increment button, a decrement button, a reset button all touching
the same counter. Rust's ownership rules forbid multiple
&mutreferences. - No garbage collector. Whatever solution we pick for the above, we can't rely on a runtime to clean up after us. Lifetimes and ownership must be explicit.
Every design decision in Telex flows from navigating these constraints while keeping the API ergonomic. The goal is an interface where the ownership machinery is invisible to the person writing a component.
State: Rc<RefCell<T>>
The core problem is this: we want developers to write count.update(|n| *n += 1)
inside a closure that gets stored in a button, called later when the button is pressed, and
possibly called multiple times across re-renders. A closure that captures
&mut i32 can't be 'static. It borrows from the stack frame
that created it, and that frame is gone by the time the button fires.
The solution is State<T>, a cheap-to-clone handle to shared, mutable data:
pub struct State<T> {
inner: Rc<StateInner<T>>,
}
struct StateInner<T> {
value: RefCell<T>,
dirty: RefCell<bool>,
}
Rc provides shared ownership - multiple closures can hold handles to the
same state, and cloning a State<T> just increments a reference count.
RefCell provides interior mutability - we can mutate through a shared
reference, with borrow checking deferred to runtime. The dirty flag tracks
whether the value has changed since the last render, which becomes important later for
skipping unnecessary work.
This means a button handler looks like:
let count = state!(cx, || 0);
View::button()
.label("+")
.on_press(with!(count => move || count.update(|n| *n += 1)))
.build()
The with! macro clones count into the closure. The clone is
one pointer copy - an Rc::clone. Inside the closure, update
borrows the RefCell mutably, applies the function, and sets the dirty flag.
Why Rc, not Arc?
Telex is single-threaded. The entire render loop - component functions, event handlers,
state mutations - runs on one thread. Arc pays for atomic reference
counting on every clone and drop. Rc doesn't. For a framework where state
handles are cloned into every closure, that overhead adds up for no benefit. The thread
boundary is elsewhere (more on that in a future post).
Why not Copy?
We considered impl Copy for State<T>, but Rc isn't
Copy. The cost of .clone() is minimal - just an
Rc::clone - and being explicit about cloning makes ownership
visible at the call site. When three closures each need their own handle, you see
three clones (or a with! macro that does it for you).
The widget tree: enum, not trait objects
A component function returns a View - a tree describing what should be
on screen. The obvious Rust approach would be Box<dyn Widget>, a trait
object. Telex uses an enum instead:
pub enum View {
Text(TextNode),
VStack(VStackNode),
HStack(HStackNode),
Button(ButtonNode),
TextInput(TextInputNode),
Modal(ModalNode),
// ... 20+ variants
Empty,
}
The advantages are practical:
- No vtable overhead. Pattern matching is a jump table, not an indirect call through a fat pointer.
- Cloneable. The view tree needs to be cloned for diffing.
Cloneon an enum is derived;CloneonBox<dyn Widget>requiresdyn CloneWidgetgymnastics. - Debuggable.
Debugis derived. You can print the entire view tree and see exactly what's in it. - Exhaustive matching. The compiler ensures every renderer handles every
widget type. Add a new variant, and every
matchstatement that doesn't cover it becomes a compile error.
The trade-off is that adding a new built-in widget means modifying the enum, which means modifying the framework. For a framework we control, this is acceptable - the set of built-in widgets changes slowly and deliberately. User-defined widgets use composition (nesting built-in widgets), not new variants.
(The escape hatch for truly custom rendering came later. More on that in the next post.)
Callbacks as Rc<dyn Fn()>
Event handlers are stored as Rc<dyn Fn()>:
pub type Callback = Rc<dyn Fn()>;
pub struct ButtonNode {
pub label: String,
pub on_press: Option<Callback>,
}
Rc because callbacks live inside the view tree, and the view tree gets cloned.
Box<dyn Fn()> isn't Clone; Rc<dyn Fn()>
is - cloning shares the closure rather than copying it.
Fn, not FnMut, because a callback may be called multiple times
across re-renders, and mutation goes through State (which uses interior
mutability). The closure itself captures shared handles, not mutable references.
Hook storage: HashMap<TypeId, Rc<dyn Any>>
State needs to persist across re-renders. When a component function runs for the second time,
state!(cx, || 0) should return the same State<i32> it created
on the first call, not a fresh one. This means the framework needs a place to store hooks
between renders.
The storage is a HashMap keyed by TypeId:
pub struct StateStorage {
keyed_states: RefCell<HashMap<TypeId, Rc<dyn Any>>>,
}
Each call to state! generates an anonymous struct type at the call site via
a proc macro. That type's TypeId becomes the key. Same call site, same key,
same state. Different call sites, different keys, different state.
This makes hooks order-independent. Unlike React's rules of hooks (no conditionals, no loops, always call in the same order), Telex hooks can appear anywhere:
fn counter(cx: Scope) -> View {
// Safe in conditionals
if show_counter {
let count = state!(cx, || 0);
// ...
}
// Safe in loops
for i in 0..3 {
let item = state!(cx, || String::new());
// each iteration is a different call site = different state
}
}
Why dyn Any? Each hook can store a different type -
State<i32>, State<String>,
ChannelHandle<MidiMessage>. Type erasure via Any lets them
coexist in a single map, with downcast_ref recovering the concrete type on
retrieval. The cast is checked at runtime, but it always succeeds because the key and the
value type are generated together by the same macro invocation.
Rendering: double buffer and diff
Terminals are slow output devices. Writing every cell on every frame - even if most cells haven't changed - causes visible flicker and measurable lag. The standard solution is double buffering: maintain two cell grids, render the new frame into one, diff it against the previous frame, and only emit escape sequences for cells that changed.
Telex's buffer is a flat array of Cell structs, each holding a character,
foreground color, background color, and a wide_continuation flag. That flag
handles wide characters (emoji, CJK ideographs) which occupy two terminal columns: the
character is stored in the first cell, and the second cell is marked as a continuation so
the renderer skips it. When a wide character would overflow the right edge of a line, a
space is written instead - you can't split an emoji across lines.
The diff pass walks both buffers in lockstep. For each cell that differs, it moves the cursor and writes the new content. In practice, most frames change a small fraction of cells (a counter incrementing, a cursor blinking, a list item highlighting), so the diff reduces terminal I/O by orders of magnitude.
Trade-offs
| Choice | Benefit | Cost |
|---|---|---|
| Enum for View | Fast, cloneable, exhaustive matching | Can't extend without modifying the framework |
| Rc for State | Cheap cloning, no lifetime parameters | Runtime borrow checking, single-threaded only |
| TypeId-keyed hooks | Order-independent, safe in conditionals | HashMap lookup vs. Vec index |
| Rebuild view every frame | Simple, always correct | Rebuilds unchanged subtrees |
| Full buffer diff | Simple implementation | O(width × height) per frame |
These are reasonable for a terminal application. The view tree is dozens of widgets, not thousands. The buffer is 80×24 to maybe 200×50 cells. Updates happen at human speed - keypresses, not animation frames. Simplicity aids correctness, and correctness is hard enough when Unicode, terminal escape codes, and focus management are all in play.
The first version of Telex shipped with these decisions and they held up well for self-contained applications. But real applications need to talk to the outside world - network sockets, MIDI devices, file watchers - and that's where the story gets more interesting.
Telex