runlocally

runlocally engineering notes

Edit ASCII Diagram

How Edit ASCII Diagram is built

By Geppetto · · Open Edit ASCII Diagram →

Edit ASCII Diagram takes a pasted Unicode box-drawing diagram — the kind of nested-rectangle text art used for UI wireframes and architecture sketches — and lets you edit it with clicks instead of hand-adjusting ┌│└ characters. This post is about the bug in a whole category of similar tools that shaped this one’s core design, and the box-detection algorithm underneath the click-to-edit interface.

Tech used

A real width bug, found by reading the source

Before writing a parser, it’s worth checking whether the problem is already solved. Several open-source browser tools already do “draw boxes in a GUI, get Unicode box-drawing text out” well — Asciiflow among them, MIT-licensed and mature. Reading its source turned up a specific, verifiable bug: its text-import routine (textToLayer in text_utils.ts) walks a pasted line with line.charAt(i), one UTF-16 code unit at a time, and places one code unit per grid column. That’s correct for plain ASCII, where a code unit and a display column are the same thing. It’s wrong for a Japanese full-width character, which is one code unit but occupies two display columns in a monospace font — importing a line containing one shifts everything after it left by one column, and the box borders on every following line stop lining up. An emoji made of a surrogate pair fares worse: two code units become two separate (and separately meaningless) grid cells instead of one.

This matters here specifically because the motivating input for this tool is exactly that case: a UI wireframe with Japanese labels and emoji icons. So the core design decision this tool makes differently is addressing that gap directly, not the click-to-edit interface itself (which prior art already does well).

Grid cells addressed by display column, not code unit

The grid model is still a sparse Map from a coordinate to a glyph, the same general shape as the tools referenced above — but cells are indexed by display column, and a character that occupies two display columns (CJK full-width characters, most emoji) is written into two consecutive cells: the glyph itself in the first, a reserved continuation marker in the second. Anything that reads the grid — box-boundary detection, click hit-testing, re-serialization — sees a stable two-cell-wide slot for a double-width character rather than a single cell that’s secretly wider than it claims to be.

Getting “is this character double-width” right is its own small problem — plain string length doesn’t answer it, and neither does code-unit counting, as the bug above demonstrates. This tool uses string-width (MIT), which resolves it via Intl.Segmenter for grapheme-cluster boundaries (so a ZWJ emoji sequence like a family emoji — several code points that render as one glyph — is measured as the one visual unit it is) plus Unicode East Asian Width data for CJK characters, rather than hand-rolling either lookup.

Detecting boxes in an imported grid

Click-to-select, drag-to-move, and drag-to-resize all need the tool to know where a “box” is, not just where individual // characters sit. Detection starts from a candidate top-left corner (any cell that connects right and down) and traces its four edges to the corners that close them — the same corner-tracing approach a reStructuredText grid-table parser uses, and what lets nested and adjacent boxes both fall out of one scan.

Why the detector tolerates ragged input, and why it can’t do so by position

The interesting part is that real pasted diagrams are almost never drawn perfectly. A diagram copied out of a design doc, hand-edited in a comment, or pasted through a chat tool drifts: the vertical side borders wander several columns from row to row because getting the padding right on every interior content line is fiddly, while the top and bottom border rows — a single deliberate run of dashes — stay aligned. A detector that demands a border character at exactly the right column on every row finds nothing.

The tempting fix is to snap: cluster the near-vertical separators and pull each to a common column. It cannot work here, and a real pasted wireframe shows why concretely — it had two genuine box edges only two columns apart (columns 88 and 90) while per-row drift reached nine columns. To absorb nine columns of drift the snap tolerance has to be at least nine; but a tolerance of nine merges the real edges at 88 and 90 into one. No single tolerance both keeps nearby edges distinct and absorbs drift larger than the gap between them — that is a structural dead end for any position-based clustering, not a tuning problem.

What survives is matching by order rather than position — the same principle that lets a Markdown pipe table be parsed purely by the order of its | characters, ignoring alignment entirely. Each edge is traced along a fixed row or column, and every cell it crosses is scored into one of four roles: rail (a real border cell — keep going), corner (the edge closes here), slack (the border is absent but tolerably so — a blank to heal, or off-axis label text — step over it), or break (real structure of the wrong shape — stop, this would cross into a different box). Because a trace never leaves its row or column, two edges two columns apart stay distinct no matter how large the drift, and the whole thing reduces to one loop with a single knob: how many consecutive slack cells are allowed. The continuous top/bottom border allows one (a lone paste nick); the drifting sides allow unlimited, since the column is genuinely empty for long runs. The three special-case tolerances an earlier version of this code had — gap healing, blank skipping, and a separate orphan-character check — all turned out to be the same order-based idea wearing three hats, and collapsed into that one model.

One subtlety the four-role scoring has to get right: a stray, disconnected sitting alone in blank padding has the exact connectivity signature of a real bottom-right corner. If it happens to land on a box’s edge column, a naive trace closes the edge there instead of at the real corner further along. The fix is an attachment check — a corner only counts if real content leads into it from at least one of the sides its shape claims; an orphan surrounded by blanks is scored slack and stepped over. (An earlier attempt to catch this by requiring the intervening rows to line up is exactly the positional requirement that made ragged diagrams undetectable in the first place, so the check stays strictly local.)

Copying a before/after pair for an AI

Like Edit Flowchart, this tool’s motivating use case includes asking an AI assistant to change a UI whose current structure is expressed as a diagram — here, boxes as UI regions instead of a flowchart’s nodes and subgraphs. The same “copy for AI” pattern applies: a clipboard payload with the diagram’s text before your edits and after, so the change instruction is exactly the edit you made.

Implementation & operational notes

The round-trip guarantee here is structural, not byte-identical. Edit Flowchart can promise a byte-for-byte-identical output for untouched input, because it only ever rewrites the specific line it edited. This tool can’t make that promise — the whole point of a grid-based canvas is that the output is always the entire grid, regenerated from its current state. What it does guarantee instead, and what a fixture-based test suite checks directly: importing a diagram and exporting it again with no edits reproduces the same box count, positions, and text content as the original, verified by re-parsing the exported text and diffing the two structural models rather than the two strings.

The editor takes over the whole viewport once a diagram is loaded. A wide wireframe plus a side inspector needs real room, so opening a diagram switches from the centered landing column into a full-screen fixed overlay (the same pattern the CSV viewer in this catalog uses), with Escape and a close button to leave. This also sidesteps a subtle trap: the shared page shell caps its width when the tool is installed as a standalone app, and a fixed, viewport-relative overlay escapes that cap where a plain wide container would have stayed narrow.

Text editing goes through a field, not the canvas directly. A selected box’s label is edited in an inspector panel next to the canvas, rather than by clicking straight into the grid and typing. That’s a deliberate simplification — it sidesteps IME composition and cursor-placement complexity inside a character grid, and it means keyboard-only users get the exact same editing path as a mouse, rather than a separate, easily-neglected fallback.

Freehand line and arrow drawing didn’t make it into this version. Editing here is scoped to existing rectangular boxes — add, move, resize, relabel, delete — not drawing arbitrary new lines and connectors between them, which is a substantially larger interaction surface left for a later pass if it’s needed.

Try it / source

Edit ASCII Diagram

Open the tool → All posts →