background image
reader
Skip to contentposts
reader
Node-based UIs in the terminal · 5 of 5Mon, Aug 10, 2026

Locked, open, honest: the three contracts of a Rust widget API

three contracts · one boundary
your code
rataflow
fn node(&self, id: &str) -> Option<&Node> fn node_content_mut(&mut self, id: &str) -> Option<&mut N>
read every field, mutate your contentno &mut Node, ever

a node holds the library's own bookkeeping: the ids and parent links its lookups and hierarchy are keyed on. Write one directly and every index built on the old value goes quietly wrong. The content inside is yours, only ever drawn, so that reference comes back for you to change as you like.

The rest of what I've written about rataflow is about internals: the coordinate pipeline, the edge renderer, the drag machine, everything the browser does for you that a terminal doesn't. This one is about the other half of building a library. Not what it does, but what it lets you do. A library is a contract with people who will build things you never imagined, and designing that contract is a different problem from making the thing work.

Here's the surprise that gets at it. rataflow will hand you a &Node. It will never hand you a &mut Node. You can read every field on a node; you cannot write one. That isn't an oversight. It's the decision the whole API is built on, and one face of a single rule that runs through all of it:

The library never acts behind your back, and never lets you act behind its.

A widget library exposes three surfaces to the people who use it: what they can change, what they can render, and how it tells them what happened. Each wants a different stance: locked, open, honest. This post is those three, plus the thing that falls out of them: where failure goes. None of it is terminal-specific; the same decisions apply to a browser library. (The one exception is the event system, which is shaped by what ratatui does and doesn't do; more on that at the end.)

Contract 1 — mutation: locked

Why withhold &mut Node? Because a node's identity (its id, its parent_id, the source/target of an edge, its handles) isn't just data. Those fields key the internal lookups (node_lookup: HashMap<String, usize>, the edge index) and drive the parent/child hierarchy: nodes can nest, and a child's position is stored relative to its parent, so its on-screen coordinates are derived rather than stored. Let a user write node.id = "…" directly and every lookup keyed on the old id silently points at the wrong node, or nothing. The corruption is invisible until something much later behaves impossibly.

So the accessors only ever hand out shared references:

src/state/graph.rs
// read all you want; mutate nothing
pub fn node(&self, id: &str) -> Option<&Node<N>>;
pub fn edge(&self, id: &str) -> Option<&Edge<E>>;
pub fn nodes(&self) -> impl Iterator<Item = &Node<N>>;

Mutation goes through operations instead, and each one does the bookkeeping a raw field write would skip:

src/state/graph.rs
// setting a position isn't an assignment; it's an operation with consequences
pub fn set_node_position(&mut self, id: &str, position: impl Into<Position>) {
    if let Some(&idx) = self.node_lookup.get(id) {
        self.nodes[idx].node.position = position.into();
        self.resolve_hierarchy();   // ← children reposition, absolute coords recompute
    }
}

Every other mutating operation has a version of that last line. set_node_z_index invalidates the z-order cache; the handle-style setters rebuild handle bounds; add_edge validates both endpoints exist. The recomputation is the point, and you can't forget to trigger it, because you can't get the &mut you'd need to bypass it.

The tempting alternative was a with_node_mut(id, |node| { … }) callback, one ergonomic escape valve for "just let me change a field." I didn't add it, and the reason is exactly the trap above: a closure taking &mut Node re-exposes the identity fields, and the compiler can't tell "user nudged hidden" from "user rewrote id." The restrictive API is the one that can't be misused, so that's the one that ships.

A sharper question is why the ids are strings at all. Key the graph on an arena instead, the way slotmap does, and node.id = "…" stops being expressible: the node has no id to write. That removes the failure this section opened with, and not the contract itself. &mut Node still can't be handed out: nesting a node inside another (parent_id), or bringing one to the front (z_index), are ordinary things to want, and both still have to go through a setter, so the graph is never left inconsistent, not even for a frame. What settles it is that these ids aren't only the library's. Connection::edge_id() builds "source:handle<>target:handle", snapshots carry ids that survive a reload, and an app usually has its own ids for whatever its nodes represent. Strings travel, and this contract is what that costs.

That leaves an obvious question: Node<N> is generic over your data, so how do you ever change it? One app's node holds the text of a note; another's holds a label and a color. Those change constantly, and none of them are the library's business.

So content is the one place the &mut comes back:

src/state/graph.rs
// the only mutable reference the API hands out
pub fn node_content_mut(&mut self, id: &str) -> Option<&mut N>;
pub fn edge_content_mut(&mut self, id: &str) -> Option<&mut E>;

That isn't a hole in the rule, it's the rule stated precisely. The library's entire relationship with your content is drawing it: it calls render while painting a frame, and for an edge compute_path to route one. It keys nothing on your data and caches nothing it produces, so a write can't leave anything stale. The principle was never "mutation is dangerous," it's that mutation which invalidates derived state has to go through the code that recomputes it. Content has no derived state, so it gets the direct reference. Identity has state no recomputation can rescue, so it gets nothing. Geometry sits between and gets a setter.

You can watch the seam in practice. When a note's text changes, its box needs to grow, so app code mutates content and then asks the library to redo the geometry:

// you write the content; the size of the box goes through the library
if let Some(content) = flow.node_content_mut(id) {
    content.text = new_text;
}
if let Some((w, h)) = flow.node(id).map(|n| n.content.measure_text()) {
    flow.set_node_dimensions(id, w, h);   // ← library side; children and absolute coords recompute
}

I should be honest about what this costs, because it's the strongest argument the other side has. Since nodes() only hands out shared references, you can't walk the graph and change it as you go. With a &mut Node you would edit each node in the loop you already had. Without one, changing many nodes at once is something the library has to offer explicitly.

For content it offers the loop itself. You already own it, so handing out every reference is no more dangerous than handing out one, and content is the thing most likely to change across every node at once:

// push app-derived state onto every node before rendering
for (id, content) in flow.nodes_content_mut() {
    content.editing = editing_id.as_deref() == Some(id);
}

Positions can't be offered that way. A mutable position would let a write slip past the recomputation that has to follow it, so you hand the whole set over instead and the library does the writing. set_node_positions sets them all, then resolves the hierarchy once. The same work in a loop resolves it again after every node.

The node properties stay singular, which is usually what you wanted anyway. Hiding a node is a thing you do to a node. When you do want forty at once, you write the loop, and that's the whole of it.

Which is the part worth noticing. The shapes follow the work rather than the constraint. Content gets resynced across every node before a frame, so it gets a loop. Positions arrive from a layout as a finished set, so they go in as a set. Flags flip one node at a time, so they get a setter. The API that keeps the library's promises and the API you would have reached for are the same one.

The same split decides field visibility. Types the library hands back (Node, Edge, Handle) have pub fields, because a getter for every field would be noise when the whole point is to inspect them. Types you only ever hand in, the style structs (EdgeStyle, HandleStyle) and companion widgets (Controls, MiniMap), have private fields and builders. That means I can add a field to EdgeStyle next release without breaking your code, because you never named them positionally. Read → public; configure → builder.

Contract 2 — rendering: open

If mutation is locked because the library owns the invariants, rendering is wide open because it doesn't own the pixels. What a node looks like is entirely yours. You implement a trait:

src/content.rs
// the whole extension point for a custom node
pub trait NodeContent: Debug + Sized {
    fn render(&self, ctx: &NodeRenderContext, buf: &mut Buffer);
}

ctx hands you the area to draw into plus everything you might branch on (selected, dragging, the theme, the animation phase), and you draw into buf with ordinary ratatui widgets. There is deliberately no library-level "node style" primitive to configure; the answer to "how do I make my node look like X" is "render X."

Edges work the same way with a little more help, and handles get none. That asymmetry is on purpose:

ElementYou implementThe library gives you
NodeNodeContent::render, everythingnothing; use ratatui directly
EdgeEdgeContent::compute_path + renderEdgeStyle + a render_path that transforms, clips, draws markers, animates
Handlenothingfully library-rendered from HandleStyle

You write the most code where the freedom is worth the most (a node body can be anything), and none where it isn't (a handle is a glyph on a border). The library scales its involvement to how much you actually want to vary.

The edge row is the interesting one, because that trait's split isn't stylistic. compute_path returns the route as geometry, and the library uses it twice: once to draw the edge, and once to decide whether a click landed on it. EdgeContent::hit_test ships a default implementation that runs your compute_path and measures against the result.

src/content.rs
// hit-testing you never write: the route you computed is the route we click
fn hit_test(&self, point: Position, ctx: &EdgePathContext, threshold: f64) -> bool {
    let path = self.compute_path(ctx);
    path.hit_test(point, threshold)
}

So route an edge however you like and it becomes clickable along that same curve, with nothing extra to implement and nothing to keep in sync. Fold routing into render instead and the path would only ever exist as characters already stamped into a buffer, leaving the library to guess where the edge went.

It also explains the EdgeStyle sitting in that row, and the reason is geometry rather than generosity. An edge is a stroke, and a stroke through a grid of cells has a finite vocabulary: box-drawing characters, their ASCII fallbacks, braille sub-cells for a smoother diagonal, an arrow on the end. The library had to pick from that vocabulary to draw an edge at all, so naming it in a struct costs nothing and hands you the same choices it made. A node is an area, and an area has no vocabulary. Anything that fits in a rectangle of cells is a legal node body, so the only honest thing to give you is the rectangle. Each API is shaped by the thing it draws, not by how much help I felt like offering.

For the cases the trait system can't anticipate, there are explicit escape hatches. Say you want to float a badge or a tooltip next to a node, something the library has no concept of. It gives you its own coordinate math:

src/state/viewport.rs
// the same transform the renderer uses, handed to app code
pub fn world_to_terminal(&self, pos: Position) -> (i32, i32);
pub fn node_terminal_rect(&self, id: &str) -> Option<(i32, i32, i32, i32)>;
pub fn is_in_bounds(&self, x: i32, y: i32) -> bool;

The point isn't the three functions, it's that going off-script shouldn't mean going it alone. A badge has to stay glued to its node through every pan and zoom, and the only way it does is by being placed with the same math the renderer uses. Rebuild that math yourself and it drifts out of sync the first time the transform changes, so the library lends you its own instead. The details follow from the use case: node_terminal_rect is unclipped, because a badge hanging off a node's edge is exactly why you reached for it, and is_in_bounds exists because writing outside the buffer panics, and an escape hatch that can crash the app is not much of a hatch. Before the first render the canvas is zero-sized, so it answers false and an overlay drawn too early quietly does nothing.

Persistence draws the same line, and the reason is worth more than the rule. With the serde feature a FlowSnapshot carries the graph data (nodes, edges, viewport) and skips presentation: a handle's style and the style structs are #[serde(skip)]. That isn't about file size. A save file that remembers its colors is frozen in the look the app had the day it was written, so a redesign reaches new flows and leaves every old one behind. Skipping styles means they come back as Default and the running app paints its current ones over the top. Structure is portable and belongs in the file; how it looks belongs to whatever version is drawing it. Locked data, open presentation, one more time.

Contract 3 — events: honest

The third surface is how the library talks back. Most ratatui widgets are built fresh each frame and have little to report; this one keeps a graph you drag, connect, and select, so it has plenty.

ratatui is immediate-mode, and it ships no input handling at all. It draws widgets to a buffer each frame from your state, and that's the entire job. Reading the keyboard and mouse is left to you and your backend (crossterm, usually), typically in one big match in your event loop. Widgets draw; they do not emit. There's no built-in notion of "this widget was clicked."

rataflow layers a semantic action/event model on top of that, much closer to how a retained-mode or React-style toolkit talks to you than to how a ratatui widget usually does. It's two enums with opposite jobs:

src/actions.rs
// an INTENT you dispatch (Copy, tiny)
pub enum FlowAction {
    SelectUp, SelectDown, SelectLeft, SelectRight,
    SelectNext, SelectPrev, ClearSelection, ToggleMultiSelect,
    PanLeft, PanRight, PanUp, PanDown, Pan { dx: f64, dy: f64 },
    Delete, CancelConnection, CenterOnSelected,
}
 
// an OUTCOME you react to (carries owned data)
pub enum FlowEvent {
    NodeClicked { node_id: String },
    ConnectionCompleted(Connection),
    SelectionChanged { node_ids: Vec<String>, edge_ids: Vec<String> },
    ViewportChanged { x: f64, y: f64, zoom: f64 },
    /* NodeDragStarted/Dragged/Ended, Reconnection*, Deleted, … */
}

An action separates what from how: you bind your own keys, map them to a FlowAction, and dispatch it. An event is a thing that happened that an app would plausibly run code in response to. The pipe between them is one method and one return type:

src/state/event_handlers.rssrc/actions.rs
pub fn apply(&mut self, action: FlowAction) -> EventResponse;
pub fn handle_mouse_event(&mut self, mouse: impl Into<MouseEvent>) -> EventResponse;
 
pub enum EventResponse {
    NotHandled,            // input not consumed — fall through to your next handler
    Handled,               // consumed, nothing worth reporting
    Event(Vec<FlowEvent>), // consumed, and here's what happened
}

Events come back synchronously. There is no event queue anywhere in the library. A handler builds a Vec inline and returns it, and into_events() moves it out with no copy (and allocates nothing for the empty cases). The library reports and returns; it doesn't buffer a stream you have to drain.

Two more choices make the model honest rather than merely present.

It's backend-agnostic. rataflow defines its own KeyEvent, MouseEvent, KeyCode, and Modifiers; the conversions from crossterm, termion, termwiz, and the wasm backend are feature-gated From impls. The core has zero backend dependency, which is why the same flow logic runs in a real terminal and, via WASM, in a browser. Binding straight to crossterm is simpler, and costs nothing right up until you want to run somewhere crossterm can't.

And it never mutates your graph behind your back. This is the whole philosophy in one rule. When you drag a connection and release on a valid handle, the library emits ConnectionCompleted(connection) and stops. It does not add the edge. The event is a description of a gesture; you decide what it means and call add_edge_from_connection yourself (or don't). Same for reconnection. The library's job is to tell you a thing happened, not to decide what your graph should become.

Here is what that looks like on the consumer side. The two arms where you decide are the point of all three contracts:

match event::read()? {
    // inbound: your keys, mapped to intents
    CrosstermEvent::Key(key) => {
        if let Some(action) = my_bindings(&key.into()) {
            flow.apply(action);
        } else {
            flow.handle_key_event(key);       // or fall through to defaults
        }
    }
    // outbound: gestures come back, decisions stay yours
    CrosstermEvent::Mouse(mouse) => {
        for event in flow.handle_mouse_event(mouse).into_events() {
            match event {
                FlowEvent::ConnectionCompleted(conn) => {
                    // the edge exists because this line runs, not because you dragged
                    flow.add_edge_from_connection(conn, StepEdge::default());
                }
                FlowEvent::ReconnectionCompleted { edge_id, new_connection, .. } => {
                    flow.reconnect_edge(&edge_id, new_connection);
                }
                _ => {}
            }
        }
    }
    _ => {}
}

Delete the ConnectionCompleted arm and dragging between handles draws a preview that snaps away on release. That's not a bug; it's an app that has decided connections need confirmation first, or a schema check, or a modal asking what kind of edge this is.

There is exactly one exception to that never-behind-your-back rule, and it's called out explicitly: selection. Clicking a node selects it before the event returns, because a click that visibly did nothing (no highlight, just an event you're expected to act on) would be baffling. So selection is the single implicit side effect, and everything else waits for you. Even that is careful: SelectionChanged fires only when the selection actually changed, never on a node that was already selected.

The naming follows from "an app would run code in response to this." Gesture events are named for the interaction (NodeClicked, NodeDragStarted); state-change events are named for the outcome (SelectionChanged, ViewportChanged) because they aggregate several inputs: keyboard and mouse can change the selection, and you want to handle "the selection changed" in one place regardless of how. And if the only thing an app would do in response is render differently, there's no event at all. Dragging a connection is the case: ConnectionStarted fires at the press and ConnectionCompleted at the release, but the preview line following your cursor in between is reported nowhere, because the library is already drawing it.

Corollary — where failure goes

Three stances, and then a question that cuts across all of them: when something doesn't work, who hears about it? The same rule answers it. Reporting is only useful to someone who can act on the report, so errors come in tiers matched to who can fix them:

  • Developer boundaries fail loudly. add_node, add_edge, with_graph return Result<_, Error> and validate graph integrity up front: duplicate ids, dangling references, self-loops, ambiguous handles (ten variants via thiserror: DuplicateNodeId, InvalidEdgeReference, SelfReferentialEdge, AmbiguousHandles, …). These are programmer mistakes, caught where a programmer is looking.
  • User operations are infallible no-ops. select_next_node, pan, remove_selected_nodes return () or the removed items, never Result. A user pressing a key over an empty canvas hasn't made an error; there's just nothing to do.
  • Render time is defensive. An edge whose node was removed is silently skipped: no else, no panic. Drawing a frame is the one place you must never blow up.
try to break the graph
flow.node("a").unwrap().id = "z";
error[E0594]: cannot assign to `id`, behind a `&` reference

the type wallyou only ever get &Node, so the identity fields that key the lookups can't be written.

graph: 2 nodes · 1 edge · valid unchanged, whatever you try
The bad writes don't compile; the bad ops error or no-op. The graph stays valid no matter what you try.

The obvious objection is the one the figure invites you to try. Call set_node_position with an id that doesn't exist and nothing happens. But a developer wrote that call, so isn't a silent no-op exactly the invisible corruption Contract 1 exists to prevent?

The difference is what kind of code the call sits in. add_node and with_graph run while you are building a graph out of data from somewhere else: a save file, a parser, a generator. Those ids can be wrong in ways only you can fix, and a wrong one poisons the graph from the start, so they're validated. The setters run while someone is using the graph, in response to a keypress or a gesture that just happened, on ids the library handed you moments earlier: out of first_selected_node_id(), off an event payload, out of an iteration you just collected:

if let Some(id) = flow.first_selected_node_id() {
    flow.move_node(&id, Position::new(5.0, 0.0));
}

For a miss to happen there, the node has to have disappeared between the read and the write, which usually means your app held an id across frames and the person using it deleted that node in the meantime. Nothing was typed wrong and nothing is broken. The node is gone, and the correct response to "the node you were about to move is gone" is to not move it. Returning a Result would put an error branch in every one of those call sites to handle a case whose only sane arm is to do nothing. So the tiers hold, with a sharper line underneath: what decides the tier isn't just who made the call, it's whether you're building the graph or someone is using it.

Those two aren't always cleanly separated in real apps, and the split doesn't pretend they are. Add a node when the user presses a key and you're building while someone is using, and you get a Result there, which is still the right answer: a duplicate id is your bug whoever triggered it. The tiers are a convention chosen for how these calls are usually made, not a claim about how they always are.

The library never acts behind your back

Three surfaces, three stances, one rule.

Mutation is locked because a silent write corrupts invariants the user can't see, so the API won't hand you the &mut to do it and routes every change through an operation that keeps its bookkeeping straight, except for your own content, which is yours to change because drawing it is all the library ever does with it. And failure follows the same logic one level down: loud while you're building the graph, quiet while someone is using it. Rendering is open because the pixels are yours and the library has no business guessing, so it gives you a trait and, when that's not enough, its own coordinate math. Events are honest because the library's job is to report, not decide, so it hands you a completed gesture and lets you choose what it does to your graph. Control stays with the application throughout.

Same idea as the coordinate pipeline, one layer up. There the fix was a type with no room for the bad value: a u16 that can't hold an off-screen -4. Here it's an API that can't express the bad operation: no &mut Node to corrupt the graph with, so the mistake never compiles. Put correctness in the types and a whole class of bug stops being something you watch for and becomes something you can't write. Same move for a public API as for a render buffer.

And that closes the series: the node editor itself, then the coordinate pipeline, the edge renderer, the gesture machine, and the API wrapped around them.


rataflow is open source and on GitHub.

glyph. these blocks are excerpts. tap ▽ scry on one and i'll show you where it's called from and what its tests promise.

▽ hey ▽