Rounded turns, sharp crossings: drawing flow-graph edges in a terminal
The browser gives you an SVG bezier. A terminal gives you 128 box-drawing characters and a grid. Where two edges cross, you can't just draw a line.
cargo run --example custom_edgesThis is a deep dive from Building a node editor on a grid of terminal cells: the edge-rendering wall, in full. It shares the coordinate machinery from Negative pixels don't exist.
In a browser, an edge between two nodes is an <svg> <path>. You hand the renderer two endpoints and a bezier control scheme, and it draws a smooth, antialiased curve at whatever sub-pixel resolution the screen has. In a terminal there are no curves, no antialiasing, no sub-pixel anything. There is a grid of cells, and in each cell you may place exactly one character. The vocabulary for drawing a line is the box-drawing block of Unicode: about 128 glyphs of horizontals, verticals, corners, and junctions.
So "draw an edge" becomes a different problem. Turn a route into a sequence of cells, and pick, for each cell, the one character that makes the strokes line up, including where this edge meets another one.
A route is a polyline, in world space
Before anything gets drawn, an edge is computed as a Path, a list of points in world coordinates joined into a polyline. World space, not terminal space, for the same reason hit testing lives there: world coordinates don't move when you zoom, so one path serves rendering, label placement, and click detection with no recomputation.
Routing decides where the points go. A straight edge is trivial: two points, source to target. A step edge is orthogonal. It leaves each handle in the handle's facing direction for a short stem (default one cell), then turns. When two handles face each other, a right-facing source into a left-facing target, it routes a Z through the midpoint; when they don't, it makes a single L-shaped corner. The output is a short list of points where every segment is purely horizontal or vertical, and dedup() drops any point identical to the one before it, so no segment has zero length. Axis-aligned edges aren't only prettier. Every segment being horizontal or vertical is what makes a box-drawing alphabet enough to draw one: two line characters and four corners cover every case, and no diagonal ever has to be faked.
Drag either handle, source or target; tap a facing button to rotate it. The route leaves each handle for one stem cell, then turns.
cargo run --example edge_routingThe alphabet, and picking characters
However it routes, an edge arrives as a polyline, and a polyline is still just points. Nothing has been drawn yet. Turning it into something visible means walking the cells it passes through and deciding, for each one, which single character to put there so that it lines up with the cell before it and the cell after it. That decision needs a vocabulary, and the one a terminal offers is small enough to print in full:
horizontal ─ corners ╭ ╮
vertical │ ╰ ╯
(An ASCII style swaps in -, |, +; a dotted style uses ·.) Rendering a segment is a loop: for a horizontal run, walk the columns writing ─; for a vertical run, walk the rows writing │. The corners are the interesting cells. At each interior point of the polyline, the renderer reads the direction coming in and the direction going out, via corner_kind_at(a, b, c), and picks the glyph that connects them. Coming from the left and heading down is ╮; coming from below and heading right is ╭; and so on for the eight in/out combinations.
One case escapes this alphabet. A straight edge between misaligned handles crosses cells at an angle, and the block's two diagonals, ╱ and ╲, are fixed at exactly 45°, so an arbitrary slope can't be assembled from them. Ask for these characters on a straight edge anyway and the renderer falls back to Bresenham, approximating the slope with ─s and │s: a staircase. Which is why this alphabet isn't what the built-in straight edge reaches for by default.
That gets you one clean edge. The trouble starts when a second one crosses it.
The crossing problem, and why you can't just overwrite
Put a horizontal edge and a vertical edge across the same cell. Whichever draws second wins the cell, so you get a ─ with a │ stamped over it, or the reverse, and the crossing looks broken. What you want is ┼, the glyph whose strokes are the union of both.
The naive fix, special-casing "if there's already a line here, upgrade to a junction," spirals immediately. A ─ on a │ is ┼; a ─ on a ╮ is ┬; a corner on another corner is some ┤/├/┬/┴; and you'd enumerate every pair. ratatui solves it generally with symbol merging. Instead of set_char, edge cells are written with merge_symbol(glyph, MergeStrategy::Fuzzy), which unions the strokes of the character already in the cell with the one you're adding. You never name the junction. You declare both strokes and the merge computes it.
Markers are the one exception. An arrowhead is written with set_char, so it overwrites the cell instead of fusing with the line beneath it into some junction glyph.
| Draw… | onto… | and the cell becomes |
|---|---|---|
│ | ─ | ┼ |
╯ | ╮ | ┤ |
─ | ╮ | ┬ |
(Those three come straight from the renderer's own tests.) The base corners are rounded (╭╮╰╯) but a merged junction comes out sharp (┼┤┬┴├): Fuzzy merge treats rounded and square as one stroke family and emits the sharp variant. So the graph turns with rounded corners and crosses with sharp ones, which is exactly how a schematic reads.
One horizontal, one vertical, in the same cell. Overwrite breaks one; merge unions them into ┼.
The twist: an edge that merges with itself
Merging is the right tool, and it immediately creates a subtle bug. Walk a single L-shaped edge cell by cell: the horizontal run draws a ─ into the corner cell, then the vertical run draws a │ into the same corner cell, and if both go through merge_symbol they fuse into ┼. The edge has crossed itself at its own corner, and instead of a tidy ╮ you get a four-way junction pointing at nothing.
The fix is two passes, and it's my favorite detail in the whole renderer:
// condensed. Two passes, so an edge never merges with its own corner.
// Pass 1: find every corner cell, and *exclude* it from segment drawing.
let mut corner_positions: HashSet<(i32, i32)> = HashSet::new();
for window in clipped_points.windows(3) {
let (a, b, c) = (window[0], window[1], window[2]);
if corner_kind_at(a, b, c).is_some() {
corner_positions.insert(b);
}
}
for window in clipped_points.windows(2) {
render_segment_excluding(window[0], window[1], style, &corner_positions, /* … */ buf);
}
// Pass 2: draw the corner glyphs — merged, so corners from *different* edges still fuse.
for window in clipped_points.windows(3) {
let (a, b, c) = (window[0], window[1], window[2]);
if let Some(kind) = corner_kind_at(a, b, c) {
buf[(b.0 as u16, b.1 as u16)]
.merge_symbol(&style.corner_char(kind).to_string(), MergeStrategy::Fuzzy)
.set_style(/* … */);
}
}Pass one records every corner cell and tells the segment loop to skip it. Pass two lays down the corner glyph. The corner is still written with merge_symbol, because two different edges branching from the same point should fuse (╯ + ╮ → ┤, the same merge as the table above). It's only the edge's own segments that are held back. An edge may merge with its neighbors, never with itself. You wouldn't predict needing that until you watch your first L-bend render a ┼.
The second alphabet
The built-in straight edge draws in braille.
A braille cell carries a 2x4 grid of dots, so a stroke lands twice as finely across a cell and four times as finely down it. Nothing gets picked from a table here. The glyph is whichever dots the line passed through:
// condensed. A braille glyph is a dot mask offset from the block's base.
for ((x, y), mask) in cells {
let cell = &mut buf[(x as u16, y as u16)];
let existing = cell.symbol().chars().next().unwrap_or(' ');
let merged = if is_braille(existing) {
(existing as u32 - BRAILLE_BASE) as u8 | mask // union: OR the dots
} else {
mask // not braille: replace
};
cell.set_char(char::from_u32(BRAILLE_BASE + merged as u32).unwrap());
}(The bottom row's bits are 0x40 and 0x80 instead of continuing the sequence, because braille was extended from six dots to eight after the block was laid out.)
That single | is why merging comes free inside this alphabet. Box-drawing has to look a junction up in a merge table; braille just ORs the dots.
The else branch is the limit. No character means "a │ plus three dots", so where a braille stroke meets a box-drawing one, one of them wins.
That's the trade, and it's why the choice sits on the edge rather than on the library. Rounded corners and real junctions in one alphabet, real slopes in the other. Stepped edges are axis-aligned already and gain nothing from braille, so they keep the characters. Straight edges take the dots, because a staircase reads worse than a lost crossing.
Where an edge meets a node, and why the alphabet decides
Choosing an alphabet turns out to settle something else too: where an edge is allowed to meet a node.
Step and straight edges both start from the handles you placed. A floating edge doesn't use them at all. It looks at where the two nodes are, picks the side of each one that faces the other, and attaches there. Move either node and the sides are picked again, so an edge never has to wrap around a node to reach a handle stranded on the far side.
Where on that side it lands is a second choice. Snapping to the middle gives four attachment points per node, so the endpoint holds still and then jumps as the facing side flips. That is what the built-in step edge does, because box-drawing characters move a whole cell at a time. Attaching to the perimeter, at the point where the line joining the two nodes' centers crosses the outline, gives a continuous one, so the endpoint slides. That is what the built-in straight edge does, because braille has the sub-cell dots to show it.
Neither is tied to a route. Each is a setting you can override, and a path function of your own can ignore both and attach wherever it likes.
cargo run --example floating_edgesThat is the shape of every choice in here. rataflow picks the default that reads best under the constraint at hand, rounded corners for a turn, braille for a slope, box-drawing for anything that has to meet another edge, and leaves each of them a setting for the cases that want otherwise.
One buffer for all edges
Merging only resolves a crossing if both strokes land in the same buffer, which means every edge has to draw onto the same surface. So edges don't render straight to the screen. They render into a dedicated buffer of their own, canvas-sized, and get composited onto the main buffer only after:
- Allocate an empty edge buffer.
- Render every edge (and the in-progress connection preview) into it, each segment clipped to the canvas first, merging freely at every crossing.
- Composite it onto the main buffer, skipping empty cells so the edges don't erase the background between strokes.
- Render the nodes and handles on top.
Step three is a trick that exists only because of the medium. A terminal cell carries both a foreground glyph and a background color, and edge glyphs only own the foreground. When compositing, a source cell whose background is Reset leaves the destination's background intact, so an edge character laid over the canvas doesn't stamp a colored rectangle around itself. Step four is why edges have no z-index: the entire edge buffer goes down first, then every node paints over it, so edges are always a single background layer beneath the graph. (Nodes are opaque by default, so they cleanly occlude any edge behind them. A parent can switch that off and let its children's edges show through, the same skip-the-empty-cells trick one level up. Tricks like these come out of wrong renders, not out of planning.)
What the SVG was doing
A browser hands you one primitive, <path d="M… C…">, and behind that one attribute is routing, rasterization, antialiasing, and z-ordering you never think about. Draw the same edge on a grid of cells and each of those becomes code you write: route the polyline, choose a glyph per cell, resolve crossings by unioning strokes, keep an edge from colliding with itself, clip every segment to the canvas, composite the lot as a background layer. None of it is deep on its own. The sum turns two small alphabets into something that reads like a wiring diagram: rounded where it turns, sharp where it crosses, sloped where neither will do.
All of that draws a graph that sits still. Making it interactive, so three bare mouse bytes become "you grabbed this node" and "you're drawing an edge from that handle," is the next deep-dive.
rataflow is open source and on GitHub.
