Negative pixels don't exist: three coordinate systems behind a terminal flow graph
A node scrolled off the left edge is at column -4. The terminal buffer is indexed by u16. That gap is the whole post.
cargo run --example basicThis is a deep dive from Building a node editor on a grid of terminal cells: the coordinate-pipeline wall, in full.
Any canvas you can pan and zoom needs a coordinate pipeline. Content lives in world space (the fixed plane your nodes and edges sit on, wherever the camera is) and gets drawn on screen, with a pan/zoom transform in between. In a browser that pipeline is mostly invisible. A node is a <div> at translateX(-40px); the compositor lays it out in sub-pixel floating point, clips whatever crosses the viewport, and you never see the seam. Strip that layer away and one thing dominates: the framebuffer is indexed by u16, and u16 cannot be negative. A ratatui buffer is a grid of cells addressed from (0, 0). There is no cell to the left of column zero, and no way to name one.
Half the things you want to draw are off-screen, at negative positions. Pan a little to the right and a node that was in view now has its left edge four columns past the border, at column -4. It's still partly visible; you still have to draw the part that's on screen. But its origin is a coordinate the buffer can't represent, and the moment you force it into u16, the fact that it's off-screen is gone.
So the real question isn't how do I convert world coordinates to screen coordinates. That's arithmetic. It's where does "off-screen" get handled, given that the type you draw into can't express it. rataflow's answer is a three-stage pipeline: f64 world, i32 logical terminal, u16 buffer. The middle stage exists for one reason: to be a space where a node at column -4 is a value you can still compute with, instead of a cast that already threw the answer away.
Why one conversion isn't enough
Start with the version that looks sufficient, because it's the one you'd reach for. World is f64, the buffer is u16, so rendering is one cast and a bounds check:
// the tempting shortcut — transform straight to buffer coordinates, then guard the write
let (x, y) = world_to_buffer(node.position); // f64 → u16
if x < width && y < height {
buf[(x, y)].set_symbol(…);
}The bounds check looks like it handles off-screen nodes. Watch what actually reaches it.
In Rust an as cast from float to integer saturates: -4.0_f64 as u16 is 0. Not a wrap, not an error, nothing to catch. So the off-left node never fails x < width. It arrives already claiming x = 0, and the check passes it. You draw the whole node from the border: nothing cut, the entire box sitting four columns right of where it belongs, its left edge welded to column zero.
The instinct is to clamp before the cast: subtract with a floor at zero so nothing ever goes negative. That fixes the first bug and quietly replaces it with a subtler one: the floor only exists on one side.
Measure how far a node sits past the left border and every answer collapses to the same one. Four columns past reads as zero. One column past reads as zero. Measure past the right border and there's no floor to hit, so four columns past reads as four. The same overshoot, on opposite edges, comes back as different numbers.
Anything you derive from those distances inherits the bias, and it won't look like a coordinate bug when it surfaces. It'll look like one feature being mysteriously worse on two sides of the screen, with nothing wrong in that feature's own math. The mistake is upstream, in a subtraction that lost its sign.
Both attempts fail for one reason: u16 has no way to say "left of the canvas." The cast erases that fact, the clamp flattens it, and neither can decide something it can't state. So the fix isn't a smarter clamp. It's to make the decision somewhere else: compare in a signed space that can hold a -4, and narrow to u16 only once "is this visible" already has an answer. That signed space is the middle stage of the pipeline.
The pipeline: three representations, one job each
Here's the whole trip a coordinate makes, in order:
world → (viewport: pan/zoom) → canvas → (offset) → terminalThat's four names but three representations you hold and reason in. Canvas is an intermediate value, not a space you work in: the viewport transform produces it, the canvas offset consumes it on the next line, and nothing stores it or makes a decision in it. The three that matter each answer a different question, in a different type:
f64world. Where nodes and edges live. A position here is a fact about the graph, independent of zoom, pan, or terminal size: nodeais at(120.0, 40.0)whether you're zoomed to the whole graph or one corner. Continuous, signed, unbounded. It's also the only space hit testing touches (more below).i32logical terminal. The world point after the full transform: zoom and pan applied, canvas offset added. Fully resolved onto the grid, ready to index a buffer, except it can be negative or past the right edge. This is the space that holds our-4.u16buffer. The ratatui cell grid itself. Every address in it names a cell that exists, which is exactly why a-4can't survive the trip.
Which gives the rule the renderer is organized around:
Every clip, reject, and bounds decision happens in
i32. The cast tou16runs only once the answer is already "visible."
f64 → i32 is where the transform lives. i32 → u16 is a bounds-confirmed truncation, nothing more.
How each element flows through it
The rule holds for every element. Only the clipping primitive changes with the shape.
Points are the base case. world_to_terminal composes the two halves and stops at i32. It never returns u16:
// the composite point transform. Returns i32, NOT u16.
pub fn world_to_terminal(&self, viewport: &Viewport, world_pos: Position) -> (i32, i32) {
let canvas_pos = viewport.world_to_canvas(world_pos); // f64: canvas = world*zoom + pan
self.canvas_to_terminal(canvas_pos) // + canvas offset, floor → i32
}The viewport half is a plain affine map, canvas = world * zoom + pan. canvas_to_terminal adds the canvas offset and floors to the grid. The result can be negative when the point is off-screen, which is exactly what the i32 stage needs. Everything below is built on this one function.
Nodes are the case the shortcut got wrong, done right. A node's world rectangle becomes four i32 edges, each clipped against the canvas with a signed max/min, and the u16 cast happens inside the visibility test, never before it:
// decide in i32, cast in u16
let (left, top, right, bottom) = render_ctx.world_to_terminal_rect(viewport, node_world_rect);
let vis_left = left.max(canvas_area.x as i32); // clip in i32 — these can be negative
let vis_top = top.max(canvas_area.y as i32);
let vis_right = right.min(canvas_area.x as i32 + canvas_area.width as i32);
let vis_bottom = bottom.min(canvas_area.y as i32 + canvas_area.height as i32);
if vis_left < vis_right && vis_top < vis_bottom { // the gate: is any of it on-screen?
let visible_area = Rect::new(
vis_left as u16, // only now, past the gate, → u16
vis_top as u16,
(vis_right - vis_left) as u16,
(vis_bottom - vis_top) as u16,
);
// …render into visible_area…
}The off-left node arrives with left = -4. vis_left clamps it to the canvas's left edge, a comparison against a signed value, so the four hidden columns come off the width instead of being drawn back on from the border: vis_right - vis_left is four smaller than the node's full width, which is exactly the part that's still on screen. The if is the whole off-screen decision, made while the negative is still negative. A node fully to the left fails it (vis_left >= vis_right) and never reaches a cast.
Edges decide in i32 too, with a line clipper instead of a rect one: each point transforms to i32, the segments run through Cohen–Sutherland clipping against the canvas, and only the survivors become u16. Handles are the smallest case: transform, bounds check, cast. Same shape, no special path, from a polyline down to a single glyph.
Off-screen origins and the scratch buffer
The i32 stage decides whether a node is visible. It leaves a second problem: how to render a node whose origin is off the top or left. You still have to run the content renderer, and that renderer draws into a buffer, and ratatui's Buffer and Rect are u16. You can't construct a buffer at (-4, 2). The type that couldn't hold the coordinate for clipping can't hold it for rendering.
The obvious escape is to hand the renderer just the visible sub-rectangle, starting at the border, and it quietly breaks. The content doesn't know it's clipped; NodeContent::render() gets an area and fills it. Give it the shrunken box and it lays out as if that box were the whole node: text re-wraps, the border draws its left edge at the boundary, centered content re-centers. You don't get the left four columns hidden. You get a differently-shaped node.
So rataflow renders every node into its own scratch buffer at local (0, 0), full dimensions, then composites only the visible sub-rectangle onto the main buffer. The renderer always sees the complete area; the clipping happens after, as a copy of the on-screen cells. The negative origin never reaches a u16; it's only ever an offset into the scratch buffer during that copy, plain i32 arithmetic.
The obvious worry is allocation: a fresh buffer per node, every frame. It's per visible node, not per node. A coarse cull drops everything outside the viewport before a buffer is considered, and the fine vis_left < vis_right gate drops anything that clips to nothing.
So what allocates is bounded by the terminal, not the graph. A 40-row terminal holds a few dozen nodes at readable zoom, whether the graph has fifty or fifty thousand, and each buffer is node-sized, roughly 10×3 cells. Frame time stops tracking graph size as a result: dragging a node in a 37k-node graph natively, or 28k in WASM, measures the same as dragging one in a small graph, and memory doesn't move either. The node count went up and the number of buffers didn't. What frame time there is goes to the canvas-sized edge buffer and edge path computation, not to node buffers.
Floor forward, half-cell back
Two more transforms round out the pipeline, and they have to agree on a question the naive version never asks: where in a cell does a coordinate point?
canvas_to_terminal floors. Not rounds. Floors, and it matters. A handle centered on an odd-height node sits at a fractional row. Take a node three cells tall, occupying rows 2, 3, and 4:
center = 3.5
floor(3.5) = 3 row 3 is the middle cell
round(3.5) = 4 row 4 is one cell lowRound puts the handle below center on every odd-height node in the graph. Floor puts it on the middle cell, because floor is the right discretization for "which cell contains this point."
Floor isn't symmetric, so its inverse has to compensate. terminal_to_canvas, the first step of a click's trip back, adds 0.5 so the result lands on the center of the clicked cell instead of its top-left, and terminal_to_world divides out the zoom from there. Skip it and hit testing drifts: floor already biased rendered positions toward each cell's top-left, so a naive inverse reads every click a fraction low and right, and the error grows as you zoom out and each cell covers more world distance. The +0.5 cancels the bias. Floor down, half-cell back, chosen together so a round trip lands where it started.
The general rule: a forward transform and its inverse have to agree on where in a cell a coordinate points, or every round trip leaks a fraction of a cell. Pick one reference (here, the cell center) and make both directions honor it.
Sizes: derive extents, and pay the cell it costs
Size has a matching subtlety, and this one has no free side. A node has a world position and world dimensions, and both have to reach the grid. There are two ways to get them there, and they fail in opposite directions.
Snap them separately: floor the position, floor the width, place a box that wide there. The width comes out rock stable. A node one and a half cells wide is one cell at every pan offset, forever. What it loses is agreement. floor(pos) + floor(dim) isn't floor(pos + dim), so the node's right edge lands in a different cell than anything else derived from the same world coordinate: an edge terminating at that border, the left edge of an abutting node. You get a one-cell gap where two nodes should touch, or an edge that stops a column short of the port it connects to.
world_to_terminal_rect never transforms a dimension. It floors the two corners, top-left and bottom-right, and derives width and height from the differences (right - left, bottom - top). Every consumer of that world point now agrees, because it's the same computation.
The cost moves into the size. Take that node one and a half cells wide again, at pan offset p:
pan p | left = floor(p) | right = floor(p + 1.5) | width |
|---|---|---|---|
0.0 | 0 | 1 | 1 cell |
0.6 | 0 | 2 | 2 cells |
1.0 | 1 | 2 | 1 cell |
One cell wide at some pan offsets, two at others. Drag the canvas and it breathes by a column.
That's the pixel-snapping tradeoff in its usual shape, and neither side is free: stable sizes with edges that disagree, or agreeing edges with a size that varies by a cell. Rendering picks agreement. A seam between two nodes that should touch reads as a bug; a column of breathing reads as the grid.
"Why does hit testing skip all this?"
If rendering needs three coordinate spaces, symmetry says hit testing (click a cell, find the node) should need them in reverse. It doesn't. Hit testing stays in f64 world space start to finish, and the asymmetry is the point.
A click arrives as a (u16, u16) cell. terminal_to_world converts it once, back to a world position (with the +0.5 cell-center compensation), and every comparison after that is in world coordinates: is this point inside a node's rectangle, within a handle's world radius, close to an edge's path. The buffer is never indexed, no clip is performed, so the i32 stage has nothing to do. Hit testing isn't clipping. It's a query.
Staying in world space is what makes hit testing zoom-independent. Node bounds and handle radii are defined in world units, so comparing a world-space click against them gives the same answer at any zoom, no tolerance to rescale. Push the query into terminal space and every threshold would need re-deriving per zoom level.
So the whole pipeline fits in a line. Rendering flows world → i32 → u16 and narrows, each stage dropping what the next doesn't need until only on-screen cells remain. Hit testing flows u16 → world once and widens, a single cell opening back into the continuous space where the geometry lives. Same three spaces, opposite directions, different places to stop.
What the compositor was doing
There's no cell at column -4, and no amount of care at the u16 boundary will invent one. The fix is to keep a space one stage earlier where -4 is an ordinary number you can compare, clip, and subtract, and to cast to u16 only after every such decision is made, never to make one.
It's not really about terminals. Any pannable canvas has content that runs off the edge, and every framebuffer has a type that can't go negative. Settle visibility in a signed, unclamped space before you touch that type. The browser did this for you; the terminal just makes you write it down.
All of that decides which cell a thing lands in. What character goes in the cell, once a horizontal edge and a vertical one both want it, is the next deep-dive.
rataflow is open source and on GitHub.
