Three mouse bytes and a state machine: drag and connect in a terminal
The browser hands you pointer events, drag capture, and an event target. A terminal hands you three: down, move, up, each a bare (column, row). The gesture model is yours to build.
cargo run --example validationThis is a deep dive from Building a node editor on a grid of terminal cells: the interaction wall, in full. The hit testing it relies on is covered in Negative pixels don't exist.
In a browser, a drag is a vocabulary. pointerdown, pointermove, pointerup; setPointerCapture so the element keeps receiving events after the cursor leaves it; dragstart / dragover / drop if you want the higher-level machinery. And every one of those events arrives already knowing what it hit: event.target is the element, because the DOM did the hit test for you. Is this a click or a drag, which node am I over, did I release on a valid drop target. The platform answers all of it.
A terminal gives you three events: mouse pressed, mouse moved, mouse released. Each is a bare (column, row) cell and a button. No target, no capture, no distinction between a click and a drag, no notion of a drop. If you want "grab this node and move it" or "draw an edge from that handle to this one," you build the whole gesture out of those three signals. That build is a small state machine, and the interesting parts are the ones the browser never made you think about.
The state machine
Everything hangs off one enum, the current drag operation. This is what it looks like internally, and an app never has to touch it:
pub(crate) enum DragState {
None,
AwaitingNodeClick { node_id: String }, // clicked, waiting for mouse-up to fire NodeClicked
CreatingConnection, // dragging a new edge from a handle
MovingNode {
node_id: String,
offset: Position,
start_pos: Position, // for the click-vs-drag threshold
drag_started: bool, // has the threshold been crossed?
selected: bool, // did selection already happen this gesture?
parent_absolute: Option<Position>,
},
ReconnectingEdge { edge_id: String }, // dragging one end of an existing edge
AwaitingContextMenu {
event: FlowEvent, // the menu event this hit chose, held until release
anchor: Position,
},
SelectingBox { anchor: Position, current: Position }, // rubber-band rectangle
ResizingNode {
node_id: String,
initial: Rect, // the size the drag started from
},
Panning { anchor_canvas: Position, initial_viewport: Position },
}The three mouse events map cleanly onto it.
- Mouse-down hit-tests the cell and chooses a state: a handle starts
CreatingConnection(orReconnectingEdge); the corner grip of a resizable node startsResizingNode; a draggable node body startsMovingNode; empty space startsPanning; anything else parks inAwaitingNodeClick. - Mouse-move drives whichever state is active: moving the node, extending the connection preview, stretching the node, or panning the viewport.
- Mouse-up resolves it, and resolution is where the meaningful events fire. A
MovingNodethat never crossed the threshold becomes aNodeClicked, aCreatingConnectionbecomes aConnectionCompletedorConnectionCancelled, and the state resets toNone.
The right button runs the same shape with one twist. A right-press hit-tests too, but instead of acting it holds the event that hit chose (NodeContextMenu, EdgeContextMenu, or PaneContextMenu) in AwaitingContextMenu. Release without moving and that held event fires. Move past the drag threshold first and the state promotes to SelectingBox, the held menu is dropped, and release commits a rubber-band selection instead. It's the click-versus-drag question asked of the other button: one threshold decides whether you clicked a node or dragged it, and the same threshold decides whether you wanted a menu or a box.
A nice bit of Rust in the middle of this: the handler takes the state out with mem::take, so it can match the old value by value and still mutate the flow. None is the default that gets left behind, which means mouse-up resets the machine by simply not writing back.
Click versus drag
The browser distinguishes a click from a drag. Here you measure it yourself. A press on a draggable node doesn't move anything. It enters MovingNode with drag_started: false and remembers start_pos. Nothing happens until a later mouse-move travels more than the drag threshold (two cells, by default) from that start. The first crossing flips drag_started and emits NodeDragStarted; if the button is released before any crossing, it was a click, and mouse-up emits NodeClicked. A twitchy hand that jitters one cell during a press still registers a clean click, not a one-cell move.
That threshold is also why AwaitingNodeClick exists. A non-draggable node never enters MovingNode, so there's nothing to measure. But its NodeClicked should still fire on release, not on press, so it feels identical to a draggable node's click. So it parks in AwaitingNodeClick { node_id } on the way down and emits the click on the way up. Whether or not a node can be dragged, clicking it feels the same.
Selection timing — the fiddly part
That leaves a question the three events don't answer on their own: when, exactly, does clicking or dragging a node select it?
There isn't one answer, because different apps want different things. It's governed by two flags (select_nodes_on_drag, deselect_on_drag) and the drag threshold, and it resolves into three cases:
| drag threshold | select_nodes_on_drag | selection fires on |
|---|---|---|
| zero | on | mouse-down |
| non-zero (default) | on | the threshold crossing — or mouse-up, if released first |
| any | off | mouse-up only |
The default is the middle row, and it's the one worth reading twice: the press selects nothing yet. You don't select a node by beginning to touch it; you select it once you've committed to a click or a drag. The bottom row is for apps where dragging is a manipulation rather than a way of picking something, so only a completed click selects.
Read the table down the last column and the subtlety appears: a single gesture can reach a "select this node" branch at three different moments depending on config, and it must never take two of them. That's the lone-looking selected: bool on MovingNode: it records "selection already happened this gesture," so the mouse-up click path checks it and declines to re-select. One boolean, carried through the whole drag, exists purely to make three mutually-exclusive timing paths compose without stepping on each other.
Two more flags sit in the same area:
- Draggable but not selectable. The hit test admits a node body on
selectable || draggable, so you can drag something without it ever getting selected (a slider-like handle, say). deselect_on_dragoff. Dragging an unselected node normally clears whatever else was selected. Turning this off keeps the existing selection alive across the drag, for an app with a detail panel that shouldn't lose its inspected node because you nudged a different one.
One gesture sidesteps the matrix entirely. A right-drag on the canvas drags out a box, and a left-drag does the same when selection_on_drag is on and the press landed on empty pane. Release, and every visible node that box touched becomes the selection, replacing whatever was selected before. No click to interpret, so no timing question to answer.
None of this is hard once it's written down. "Clicking selects" sounds like one rule until you ask when it fires, and then it's a small matrix. Writing the matrix down is the whole job.
Drawing a connection
Dragging an edge into existence is its own mode. Press on a handle, and if it's connectable the machine enters CreatingConnection and emits ConnectionStarted. The state is a unit variant with no data; everything about the in-progress edge lives in a separate EdgePreview value, which is what the edge renderer draws as a line trailing the cursor.
Every mouse-move while connecting does real work. It updates the preview's endpoint and re-answers "is this a valid connection right now." It scans nearby handles, finds the closest compatible one within a small radius, and sets a three-valued verdict (valid, invalid, or no-target-yet) that the preview uses to color itself. What counts as "compatible" is the connection mode:
- Strict (the default). A connection goes source → target and nothing else. You can only start a drag from a source handle, and only a target handle is a valid drop.
- Loose. Any handle can connect to any other, in either direction. Dragging from a target is allowed, and if you do, the connection quietly normalizes its direction so "source" still means the source end.
A node can never connect to itself; the target search skips the origin node outright. A duplicate edge (same endpoints, same handles) is rejected, as is anything an optional user-supplied validator turns down.
That's why a refusal has two looks, and the demo at the top shows both. Drag onto a handle the validator turns down and the search did find a target, so the verdict is invalid and the preview goes red. Drag toward a handle that takes no connections at all and the search never returns it, so the verdict stays no-target-yet and the preview holds its neutral color. Both refuse you. Only one of them has something to refuse.
Then you release, and here is the decision that matters most: the library does not add the edge. Mouse-up on a valid connection emits ConnectionCompleted(connection) and stops. The connection is a description (source node, source handle, target node, target handle), not a mutation. It's the app's job to call add_edge_from_connection with whatever content the new edge should carry. The mouse path moves nodes, resizes them, pans the viewport and changes selection. What it never does is change the shape of the graph: every structural change (adding an edge, applying a reconnection, deleting) is surfaced as an event and left to the app.
A flow-graph library can't know what an edge means in your domain: what data it carries, whether your rules even allow it. So it reports the gesture and hands you the decision. xyflow draws the line in the same place, and this part of the API is modelled on it: onConnect gives you a connection and you call addEdge yourself. Reconnection works the same way: grab the end of a selected edge, drag it to a new handle (the far end stays anchored, the preview reuses the same validation path), release, and you get a ReconnectionCompleted carrying the old and new endpoints, again for you to apply or ignore.
Resizing from one corner
Resizing is the quietest gesture here. A resizable node draws a grip at its bottom-right corner; pressing it enters ResizingNode, holding the rect the drag started from. Mouse-move recomputes width and height from the cursor, floored at a minimum size, and reports NodeResized as it goes.
Only that one corner resizes, and the reason is aim. The other three would have to move the node's position as well as its size, so you'd be chasing a corner that moves as you drag it. Selection isn't required either: the grip is drawn on every resizable node, so it works wherever you can see it, with no select-then-resize dance in between.
Mouse-up emits NodeResizeEnded and re-runs hierarchy resolution, because a child that just grew can push out a parent that's set to expand around its children.
Panning at the edge
Drag a node toward the edge of the canvas and the view should follow it. The browser won't do this one for you either. Native autoscroll fires for text selection and HTML5 drag-and-drop, not for a node you're moving with pointer events inside a transformed canvas, which is why xyflow ships its own implementation in @xyflow/system: a requestAnimationFrame loop that ramps a velocity from the cursor's distance to the edge and pans the viewport each frame.
rataflow builds the same shape. Within five cells of the edge a velocity ramps linearly from zero to full, and the camera keeps moving for as long as the drag is live, even if the mouse has stopped. The dragged node, or the connection preview, is adjusted as the viewport moves so it stays under the cursor.
That ramp is only even-handed because the distance feeding it comes out of a signed space. Measure the same distance in unsigned terminal coordinates and it reads zero past the left and top edges no matter how far past you go, so the canvas pans smoothly right and down and barely moves in the other two.
The difference is the clock. You own the event loop here, so you know the elapsed time. The app calls tick_auto_pan(elapsed) and the pan is velocity × speed × elapsed, which is the same real-world speed at 30fps as at 120. A requestAnimationFrame loop has no elapsed term to apply: it moves a fixed step per frame, so a faster machine pans faster.
The speed is a setting, 110 cells a second by default, as is whether auto-pan runs while you drag a node, while you drag a connection, or at all. Owning the clock made the ramp frame-rate independent for free, and it's the one place in the whole rebuild where the terminal handed something back instead of taking it away.
What the event system was doing
Three bare events, and look what they carried. Some of it the browser decided for you and never showed, like how far a press travels before it counts as a drag. Most of it it never decided at all: when a click selects, what "compatible" means, who gets to change the graph.
Either way what's left is the same work, and it isn't typing. Nine states. One threshold doing double duty across two buttons. Three separate moments where a selection can legitimately happen, and a bool carried through the drag so it happens once. A validity verdict recomputed on every move. None of that is difficult code. It's that every piece has to agree with every other one, and when a piece doesn't, nothing crashes. The interaction just feels wrong to use.
That's the interaction model done, and with it the internals. Wrapping them in a library other people build on is a different problem: an API that guides its users to correctness and can't be broken by accident. That's the last deep-dive.
rataflow is open source and on GitHub.
