Building a node editor on a grid of terminal cells: everything the browser does for you
It started as one question: what would a node editor feel like if the only primitive I had was a terminal cell? I quickly realized every capability the browser hands you for free is one you build by hand. Here's each one.
cargo run --example overviewrataflow is a library to build node-based UIs in the terminal, from a static diagram to a fully interactive editor: a node graph where you can select, drag, and connect nodes, pan and zoom the canvas, with handles and nested nodes, all rendered on ratatui.
Here it is whole. A graph from a list of edges, laid out and ready to drag, pan, zoom, and connect:
use rataflow::{Flow, Sugiyama};
let mut flow: Flow = Flow::from_edges(
&[("Start", "Process"), ("Process", "End")],
Sugiyama::vertical(),
)?;Nodes come from the unique names, positions from the layout, handles from its direction. When you want to say more than that, build the graph yourself and the defaults come apart into their pieces:
use crossterm::event::{self, Event as CrosstermEvent};
use rataflow::{Background, Edge, Flow, FlowEvent, Handle, HandlePosition, Node, StepEdge};
let nodes = vec![
Node::from_text("a", (0.0, 0.0), "source")
.with_handles(vec![Handle::source(HandlePosition::Right)]),
Node::from_text("b", (30.0, 8.0), "target")
.with_handles(vec![Handle::target(HandlePosition::Left)]),
];
let edges = vec![Edge::new("e1", "a", "b").with_content(StepEdge::default())];
let mut flow = Flow::with_graph(nodes, edges)?;
flow.request_fit_view();
loop {
terminal.draw(|frame| {
frame.render_widget(Background::new(&flow), frame.area());
frame.render_widget(&mut flow, frame.area());
})?;
match event::read()? {
CrosstermEvent::Mouse(mouse) => {
// Dragging, panning, zooming and hit testing are all in here. The
// one thing the library won't decide for you is what a completed
// connection becomes, so that comes back as an event.
for event in flow.handle_mouse_event(mouse).into_events() {
if let FlowEvent::ConnectionCompleted(conn) = event {
flow.add_edge_from_connection(conn, StepEdge::default());
}
}
}
CrosstermEvent::Key(key) => { flow.handle_key_event(key); }
_ => {}
}
}That's the surface. The rest of this post is what sits behind it.
I wanted it for one thing in particular: watching a coding agent session while it ran, the main agent, the subagents it spawns, the tools each one runs, in the terminal the session already lives in. That data is a graph, and it had nowhere to be drawn. The app that draws it is zoetrope, and rataflow is what had to exist first.
I've built node-based UIs before, on the web, in the world xyflow comes from. What rebuilding one on a grid made obvious is how much of it was never my code. It was the browser. The DOM lays out your boxes. The compositor clips and stacks them. The event system tells you which element you clicked. You write a <div> and thousands of lines of someone else's C++ make it real.
A terminal gives you two things: a 2D grid of character cells, and a stream of key and mouse events. That is the entire platform. Everything the browser was quietly doing, every default you never had to think about, becomes code you write. Each section below is one capability the browser hands you for free, and what it takes to rebuild it by hand.
1. Positioning and clipping
The browser: a CSS transform positions your node in sub-pixel floating point, overflow clips whatever crosses the viewport, and an element at translateX(-40px) just works.
The terminal: you own the transform down to the cell, and the framebuffer is indexed by u16, which cannot go negative. A node panned off the left edge sits at a negative column the buffer has no name for. rataflow runs every coordinate through three representations: f64 world, i32 logical terminal, u16 buffer. The signed middle stage exists so "off-screen" stays a value you can compute with, instead of a cast that already lost the answer.
This one is deep enough to be its own post: Negative pixels don't exist covers negative coordinates, the clip-in-i32-cast-in-u16 rule, the floor/half-cell rounding, and why hit testing runs the pipeline backwards.
2. Compositing the node
The browser: you hand the compositor a <div> and it renders wherever you put it, even half off-screen, with no thought from you.
The terminal: the same u16 wall as the last section, one level up. There the problem was a coordinate that can't go negative. Here it's that you can't construct anything at a negative origin: Rect is u16 too, so a rectangle can't start at (-4, 2). Instead, each node renders into its own scratch buffer at local (0, 0) with its full dimensions, and only the visible sub-rectangle gets composited onto the screen. That solves a second problem for free: the content renderer always receives the node's complete area, so a node clipped at the edge lays out correctly instead of re-wrapping into a smaller box.
And that content renderer is yours. A node is a NodeContent impl handed a buffer and a local (0, 0) rect, plus whether it is selected or being dragged, so anything ratatui can draw is a node: a paragraph, a table, a sparkline, a widget you wrote. A bordered text node ships with the library for the common case, written against that same trait rather than beside it. The scratch buffer is what keeps that true at the viewport edge, where your layout still sees the whole box even though half of it will never be composited.
It sounds wasteful: a heap buffer per node, every frame. It would be, if it were per node. It's per visible node. A coarse cull drops everything outside the viewport before a buffer is considered, so what allocates is bounded by the terminal, not the graph. The per-node buffer work in a 37k-node graph measures the same as in a small one.
What grows with the graph is the edges, not the node buffers. A node can be rejected by its rectangle before any work happens. An edge has no extent until its path is routed, so every edge gets routed every frame and only the drawing is culled.
3. Stacking
The browser: z-index and stacking contexts decide who draws on top, and a child element renders above its parent automatically.
The terminal: you sort, yourself. rataflow computes an effective_z per node, keeps children above parents (parent_z + 1), and elevates a selected node above the rest, which you can switch off to drive the order yourself with per-node z-indexes. Rendering walks that order back-to-front; hit testing walks the same list front-to-back. The sort sits behind a dirty flag. It re-runs only when order can actually change: a node added or reparented, a selection toggled, a z-index set.
4. Drawing the edges
The browser: an <svg> path draws a bezier between two points and the renderer antialiases the curve.
The terminal: there are no curves. There are box-drawing characters on a grid. An edge is a polyline laid down cell by cell. Where two cross, you can't just overwrite one with the other: a ─ meeting a │ should become ┼, and two edges branching from one point should fuse into ┤. rataflow renders edges into their own buffer, merges box-drawing symbols at every intersection, then composites the result. Off-screen segments get Cohen–Sutherland clipping in the same signed i32 space as the coordinate pipeline, for the same reason.
None of that is a fixed look. Three concerns stay separate, and you can pick each one:
- Route — how the edge gets there. Step edges elbow with rounded corners; straight ones go point to point.
- Attachment — where it meets a node. The middle of the facing side, or the exact point where the line between the two nodes' centers crosses its outline.
- Stroke — what it's drawn with. Box-drawing characters you pick yourself, dotted, or braille, whose 2x4 dots per cell buy the sub-cell resolution a clean diagonal needs and cost the ability to merge with
─and│.
The built-ins cover the usual shapes, stepped, straight and floating, and they're EdgeContent implementations like any other, with no access you don't have. When none of them fit, yours takes over the routing, and you can hand the result back to the merging renderer or paint into the buffer yourself.
The alphabets, the crossings, and the clipping all get the full treatment in Rounded turns, sharp crossings.
5. Hit testing
The browser: you click, and the event's target is the element. The DOM already did the hit test. You just read the answer.
The terminal: a mouse event is a (column, row) cell and nothing else. Figuring out what's under it is on you. rataflow converts the cell back to world space once, then tests everything in world coordinates: node rectangles, handle radii, edge proximity. Two things follow. The query never touches the render pipeline's integer stages, and because world units don't rescale with zoom, one tolerance holds at every zoom level. The defaults for handles and edges already feel right, and if you want them more forgiving, that's one number to change, not one per zoom level. Rendering narrows world → i32 → u16; hit testing widens u16 → world and stops.
6. Dragging and connecting
The browser: pointer events, drag capture, dragstart / dragover / drop. The platform tracks the gesture and hands you lifecycle callbacks.
The terminal: you get mouse-down, mouse-move, and mouse-up, and you build the state machine yourself. rataflow tracks an explicit drag state. Is this moving a node, resizing one, panning the canvas, drawing a new connection, reconnecting an existing edge, dragging a selection box, or still waiting to see if a press becomes a drag? A distance threshold separates a click from a drag, so a twitchy mouse-down selects a node rather than yanking it a pixel.
Drawing an edge is its own mode: press on a handle and a live preview trails the cursor, validated in real time against every other handle. A source can't land on a source, and a strict-vs-loose setting decides how picky the match is. The rule past that is yours: install a validator closure and it judges every candidate, while connectable flags on a node or on a single handle take targets out of play entirely. Every part of that gesture model is hand-rolled; there is no draggable attribute to lean on.
The full state machine is its own deep-dive: Three mouse bytes and a state machine covers click-vs-drag, the fiddly question of when a click selects, validated connection dragging, and the auto-pan that carries the view when you drag past the edge.
What the browser was actually doing
Same pattern in all six. A browser is a pile of defaults you never notice until you leave it. Positioning and clipping, compositing, stacking, drawing, hit testing, gesture tracking. None of it is hard on its own. There's just a lot of it, and it all normally happens below the line you get to look at.
A browser does all of this and never mentions it. A grid of cells never promised to, which turns out to be the fastest way to find out what the browser was doing for you.
And then you put the whole thing back in a browser
Everything above is about leaving the browser. Here is the part I did not plan for: the same code compiles to WebAssembly and runs inside one.
ratzilla gives ratatui a WebGL2 backend, a terminal canvas painted as textured quads instead of DOM nodes. rataflow's ratzilla feature switches its event conversion over, and the graph that draws to your terminal draws to a browser tab with nothing else changed. Every live demo linked from these posts is that build, and so is zoetrope, the same session graph in a tab instead of a terminal.
Which sets up a comparison I could not resist, because rataflow takes heavy inspiration from xyflow and now the two run in the same window. 625 nodes, 624 edges, same browser, a 200x60 cell viewport, one 20-frame drag sample: rataflow delivers all 20 frames. xyflow delivers 11 to 14, with individual frames ranging from 5ms to 30ms. The averages are close, about 8ms against about 11ms.
The delivered count is the number worth looking at. A flat cell buffer has no layout pass, no style recalculation, and no per-node DOM to invalidate, so its cost per frame is close to constant. The DOM's is not, and the spread is where that shows.
It buys headroom rather than speed. rataflow draws 10,000 nodes at about 8ms a frame, which is the frame time xyflow needs for 625. Sixteen to one.
The stress test starts at a 25x25 grid and takes a ?size= to go further, if you want to find the edge yourself.
Two architectures, two sets of trades. A DOM node hands you sub-pixel layout, text selection, accessibility, and any styling you can write. A cell grid hands you a per-frame cost that barely moves as the graph grows, and an interface that runs wherever a terminal does: over ssh, on a server with no display, next to the tools it drives. You pick the medium your problem already lives in.
The native build is faster still: about 1ms at 625 nodes against the WASM build's 8. That eightfold gap is the WebGL2 pipeline and the browser's frame scheduling, which is to say it is the browser, one last time, doing a great deal for you whether or not you asked.
All of that is internals, the work a simpler medium leaves to you, and three of the walls go deeper on their own pages: the coordinate pipeline, the edge renderer, the drag-and-connect state machine. Wrapping any of it in a library other people build on is a different problem entirely: an API that guides users to correctness and can't be broken by accident. That one has its own post too: Locked, open, honest: the three contracts of a Rust widget API.
rataflow is open source and on GitHub.
