Building a Mouse-Aware Terminal UI: What erd-cli Taught Me About Focus, Input, and Layout
Building erd-cli, a terminal-based ERD editor in Java with Lanterna, and the non-obvious problems that come with making a TUI mouse-aware.
Why Build an ERD Editor for the Terminal
I wanted a way to sketch entity-relationship diagrams without leaving the terminal — no browser tab, no desktop app, just a schema file and a live diagram next to it. That's erd-cli: a split-screen tool where you write a small schema DSL on one side and watch the ERD render on the other, built in Java on top of Lanterna.
The interesting part wasn't the diagram rendering itself. It was everything underneath it: a terminal UI framework assumes you're mostly dealing with keyboard input and a single focused component. The moment you add mouse support — click a pane to focus it, click an entity to select it, click it again to collapse it — you run into problems that don't exist in a typical TUI, and that don't show up until you actually try to click something.
The Focus Routing Problem
Lanterna routes input to whatever component currently has focus, not whatever is under the mouse cursor. That's fine for keyboard-only apps, but it means clicking on the diagram while the editor has focus does nothing by default — the click event just goes to the editor, which has no idea a diagram exists.
To fix this, the main window intercepts every mouse event before it reaches any component, hit-tests the click position against each pane's bounds, and manually forwards the event to whichever one it landed on:
private boolean handleMouse(MouseAction mouse) {
TerminalPosition pos = mouse.getPosition();
if (isInside(diagramComponent, pos)) {
setFocusedInteractable(diagramComponent);
diagramComponent.handleInput(mouse);
return true;
}
if (isInside(editorComponent, pos)) {
setFocusedInteractable(editorComponent);
editorComponent.handleInput(mouse);
return true;
}
// ...
}
The isInside check just compares the click position against each component's global position and size. It's a small amount of code, but it's the difference between "the mouse technically works" and "clicking things does what you'd expect."
Keeping the Editor From Losing Itself
Once mouse routing worked, a second problem showed up: keyboard shortcuts. erd-cli uses F1/F2/F3 to jump between the editor, diagram, and command bar. But the editor is a full text component — it needs to consume nearly every keystroke, including function keys other apps might treat as shortcuts, without accidentally swallowing the ones that are supposed to move focus away from it.
The editor component solves this by being deliberately narrow about what it claims to handle. Every printable character, arrow key, Enter, Backspace, Tab — all of it gets consumed internally. But anything it doesn't explicitly recognize falls through as UNHANDLED:
default:
// Anything not handled above (e.g. F1-F4, Escape) is left
// UNHANDLED so the window-level global shortcuts can catch it.
return Interactable.Result.UNHANDLED;
That single default case is what makes F1/F2/F3 safe to press mid-keystroke, without any risk of a stray keypress being misinterpreted as text or, worse, a shortcut leaking through and yanking focus away while you're in the middle of typing a schema. Getting this wrong wouldn't crash anything — it would just be quietly annoying in a way that's hard to notice until you've lost focus for the third time mid-sentence.
Making Relationship Lines Readable
The other problem that only shows up with real schemas: what happens when three relationships touch the same entity, or two relationships connect the same pair of entities? The naive approach draws every line from the center of one box to the center of another, which means anything with more than one or two relationships turns into an unreadable knot right where the lines meet the box.
The renderer instead tracks, per box and per edge, how many relationships are competing for that edge, and spreads their start/end points evenly along it:
double fromFraction = (fromIdx + 1.0) / (fromTotal + 1.0);
double toFraction = (toIdx + 1.0) / (toTotal + 1.0);
It does the same thing for relationships that connect the same pair of entities — grouping them and offsetting each one into its own "lane" so they don't draw directly on top of each other:
int lane = pairTotal <= 1 ? 0 : pairIdx - (pairTotal - 1) / 2;
None of this changes what the diagram means — it's the same information either way. But it's the difference between a diagram you can actually trace with your eyes and one where every line touching a busy entity looks like it comes from the same point.
Where the Design Breaks Down
I'll be upfront about this one instead of finding it out the hard way: the layout engine arranges entities in a simple square-ish grid, sized by column/row count. It works well for a handful of entities. Past a dozen or so, boxes just get bigger and the grid gets wider — there's no pagination, no zoom, no smarter packing that tries to keep related entities near each other.
This wasn't an oversight so much as a sequencing decision. Getting focus routing, input handling, and relationship rendering right first felt more valuable than a sophisticated layout algorithm for a tool that, right now, is mostly useful for sketching schemas while you're heads-down in a terminal — not for laying out a 50-table production database. That's explicitly next, not solved.
What This Taught Me
A few things that don't show up when you're just using a TUI framework:
- "Focused" and "under the cursor" are two completely different concepts in most terminal UI frameworks, and mouse support means bridging that gap yourself — it isn't handled for you.
- Being explicit about what a component doesn't handle is as important as what it does. The editor's safety around global shortcuts comes entirely from a deliberate
default: UNHANDLED, not from any special-casing of F1–F4. - Untangling visual clutter is usually a bookkeeping problem, not a rendering problem. The relationship-line fix wasn't about drawing better lines — it was about counting how many things wanted the same pixel and spacing them out before drawing anything.
erd-cli is still early — the grid layout is the most obvious next thing to improve, and there's no undo/redo yet either. But the goal from the start was never to out-feature a GUI ERD tool. It was to get a small, fast, keyboard-and-mouse-friendly diagramming loop that never makes me leave the terminal — and figuring out focus routing, input safety, and line untangling along the way turned out to be the actual interesting part of building it.