{"slug": "terminal-borders-that-don-t-cost-a-cell", "title": "Terminal borders that don't cost a cell", "summary": "Ratatui maintainer Joshka has published a terminal protocol that draws thin lines on the edges of cells rather than inside them, implemented as a patch to the Ghostty terminal and previously in Rio, with demo apps built in Ratatui. The approach lets panes, tabs, text fields, buttons and chart gridlines render without consuming a row or column, whereas the Unicode Box Drawing block's 128 characters each occupy a full cell; on a roughly 200x50 terminal, nested panels can spend 6 or 8 of 50 rows on lines alone. Joshka is seeking feedback from terminal and TUI authors on the protocol.", "body_md": "# [Terminal borders that don't cost a cell](https://www.joshka.net/cell-borders/)\n\n*This post (and the code behind it) was written with a lot of help from AI. I'm not going to\napologize for that. If that bothers you, feel free to close the tab.*\n\nI maintain [Ratatui](https://ratatui.rs), so I see a lot of the TUIs people build. Many of them end up looking fairly\nsame-ish: plain boxes on a boring background, missing a bunch of the things that good design in\nother mediums brings us.\n\nBorders are part of the problem. They have to take up space, so they become part of the panels\npeople want to build. One option is to drop them and just use color, but borders are really nice\nfor delineating and surrounding things. There are lots of ways to sort of get borders in a\nterminal, but none that are 100% good. I've drawn enough `┌──┐` by hand to have opinions about\nthis.\n\nThe usual way is the Unicode Box Drawing block, which has 128 characters for lines, corners and joins. Somehow that's still not enough:\n\nSo I've been working on a terminal protocol that draws thin lines on the edges of cells instead.\nI've got it working as [a patch to Ghostty](https://github.com/joshka/ghostty/tree/joshka/cell-borders) (and [an earlier version in\nRio](https://github.com/raphamorim/rio/pull/1947) before that), and\nI've built demo apps for it with Ratatui. I'd like feedback from\nterminal and TUI authors.\n\nEvery line in that screenshot is a cell border. None of them use up a row or column. The tab labels sit on the top edge of each pane, the text fields are underlined right under the text, and the buttons are one row tall and rounded.\n\nHere's the same app in a terminal without cell borders, falling back to box-drawing characters:\n\nEach pane loses a row and a column on every side, so the log pane shows two fewer lines and the notes wrap differently. The tab labels move inside the frame. The text fields use the underline attribute instead, and the checkbox and buttons turn into brackets.\n\n## What I'd use it for\n\n- Borders, obviously. Panes and panels that don't eat a row and column on every side.\n- Menus and popups that sit on top of other content and still look like they're on top.\n- Panel style apps, where the layout is a set of regions that each want an edge.\n- Widgets like text fields, buttons and checkboxes, which want a line right up against their text.\n- Making things look neater in general. A border on the outer edge of a panel's background looks a lot cleaner, and text can run right up to it.\n\n### Up close\n\nThe settings form, with cell borders and then with the box-drawing fallback:\n\nThe tabs are the same story. With cell borders they sit on the pane's top edge. With box characters they need their own row inside the frame:\n\n### Charts\n\nLines between cells also work for charts. btop-style braille graphs fill every cell with dots, so there's nowhere to put a gridline or an average line without giving up a row. With cell borders they sit on the cell edges between the dots. Here's a monitor in that style, built with Ratatui:\n\nAnd the fallback, which has to drop the gridlines:\n\n### In motion\n\nHere's the tiling panes demo moving panes around, opening menus and the about dialog:\n\nPartway through, it turns on a checkerboard of the cells, which shows how everything lines up with the grid. Toggle it here:\n\n## What box characters can't do\n\nThe root of it is that box-drawing characters are text. Each line is a glyph that takes up a whole cell, and pretty much everything annoying about them falls out of that.\n\nSpace is the obvious one. On my 16\" laptop with a font I can actually read, the terminal is something like 200x50 (your prefs will move that around a bit). Columns are mostly fine at that size. Rows aren't. A layout with a couple of levels of nested panels can easily spend 6 or 8 of those 50 rows on nothing but lines. That's a lot of screen to give up for decoration.\n\nYou also can't put a line between two cells that both have something in them. So there's no way to underline a text field right under its text, or to outline a button, without adding padding around it. Rounded corners sort of work, except the corner glyph has one background color, and when the panel's background is different from what's around it you get a little square of the wrong color in each corner. And the line itself is whatever the font decides it is, weight and position included.\n\nThen there's copy and paste. Select some text inside a box and you get the box too. Everyone who's\npasted from a TUI into Slack knows the little `│` cleanup dance. Sometimes you do want the box\n(pasting a table into a chat, say), but mostly it's junk. Cell borders aren't text, so they never\nget copied, which cuts both ways.\n\n## How it works\n\nA cell border is a cell attribute, like a background color. Each cell can carry up to twelve line segments: the two halves of each of its four edges, and four arms from its center. Each segment has its own color, width, placement and style, and the terminal draws them over the cell grid. Borders are stored in the cells, so they scroll, erase, insert and reflow with the text. What happens on erase or scroll already has an answer, the same one as for colors.\n\nThe edges are split into halves so borders line up with quadrant block characters (`▖▗▘▝` and\nfriends). That makes it easy to draw a shape that's a half cell in from the grid and still put a\nneat border around it. Yes, that still uses a character for the fill, and there might be a better\nway to do that part. One problem at a time.\n\nThe same idea could stretch to sextants and octants, or to quarter and eighth widths, if designs need them.\n\n### Styles and joints\n\nLines can be solid, double, dashed or dotted. Each corner and center of a cell can be square or rounded, with a configurable radius and anti-aliased arcs. A rounded corner can name an outside color, so the area outside the arc is painted correctly when the panel's background differs from what's behind it.\n\nWhen two neighboring cells both put a line on their shared edge, they merge into one line. The\nstroke with the higher `priority` wins, then the wider one, then the one to the right or below.\nThat way a focused pane can raise its priority and keep its whole outline, whatever order its\nneighbors were drawn in. My first try was \"right/bottom always wins\", which lasted until the\nfocused pane lost half its outline to its neighbor.\n\n## The wire format\n\nEverything is one DCS string with a namespace:\n\n```\nESC P 1 + b cell-border ; OP ; FIELDS... ESC \\\n```\n\nThe common path is a pen, held in the cursor like the current background color. Set it, print some text, and the printed cells get the borders:\n\n```\nESC P 1 + b cell-border;pen;48;32;7aa2f7;0;0 ESC \\     bottom edge, blue: an underline\nhello\nESC [ 0 m                                              SGR 0 clears the pen\n```\n\nThere are also rectangle commands that set or clear borders on existing cells without touching\nthe pen, and a capability query. An app sends the query followed by DA1. If the DA1 reply arrives\nwithout a `cell-border` reply first, the terminal doesn't support it, so the app falls back to box\ncharacters and reserves the space they need. Terminals that don't understand the DCS ignore it,\nthe same as any unknown DCS.\n\nThe minimum a terminal needs is the pen, the rectangle commands and solid strokes. I've tried to keep that first cut small, because there's always one more \"... and this\" to add, and that's a slippery slope.\n\nLine styles, rounded joints, priority and outside colors are extension fields listed in the capability reply, and unknown fields are ignored, so they can be added later. I'd rather the early decisions be two way doors where possible.\n\nThe spec calls itself version 1 (it's the `1` in `DCS 1 + b`). That's v1 in the old sense, before\nwe all got bogged down in semver. It doesn't mean done or stable, it means it's the first one I was willing to write\ndown. Read it as v0.1, or a draft, or whatever you prefer.\n\n## Prior art\n\nParts of this have been done before:\n\n- **DEC ruled lines** in DECterm (extension 43 in the DA1 reply) draw lines around rectangles.\nThey scroll with the text, but they're a separate layer: clearing the screen leaves them behind,\nand they're always one pixel wide in the normal text color.\n- **SGR 51 to 65** : framed, encircled, overline and the ideogram side lines. Some terminals draw\nborders with them, but they're on/off attributes with no color, width or joins, and there's no\nattribute for the bottom edge.\n- The **Win32 console** has per-cell grid flags for the top, left and right edges.**PDCurses** and**X/Open curses** (`WA_TOP` ,`WA_LEFT` and friends) have similar attributes. These are the\nclosest to this protocol, but they have no bottom edge (Win32), color, width or halves.\n- **IBM 3270** terminals can outline whole input fields.\n\nWhat none of these give you is a line with its own color and width on each edge. The\n[rationale document](https://github.com/joshka/ghostty/blob/joshka/cell-borders/specs/cell-border-rationale.md) covers the prior art in more detail and explains each choice in\nthe protocol.\n\nMy own first prototype was in Rio ([raphamorim/rio#1947](https://github.com/raphamorim/rio/pull/1947)), with an earlier\nversion of the spec that used APC instead of DCS. Feedback pointed out that APC isn't safe to\nreuse: Kermit 95 has run APC payloads as Kermit commands since the 90s. DCS is where structured\nterminal commands already live, and terminals ignore DCS strings they don't recognize, so I\nswitched to that.\n\n## The awkward (edge) cases\n\nWhen I first showed this around, most of the questions were about these. The spec goes into all of them properly, but here's roughly where I landed.\n\nMultiplexers were the first thing people asked about. tmux and friends redraw panes from their own idea of the cell grid, so passing these sequences through would just put borders in the wrong place. A multiplexer that doesn't know about the protocol should drop them and answer DA1 itself, and then apps running inside it fall back to box characters like they would anywhere else. One that does know about it can store borders like any other attribute and redraw them.\n\nConflicting lines on a shared edge are settled when the terminal renders, using the priority rule above, so it doesn't matter what order the app wrote the cells in.\n\nErase, insert and delete all work the way they do for background colors. Erased cells pick up the current pen, and lines that get inserted or deleted take their borders along. Scrollback, the alternate screen and reflow mostly come for free, because the border lives in the cell.\n\nOn cost: the Ghostty change is about 1,650 lines, and a fair chunk of that is tests. Each cell gets a 16-bit border ID, which fits in padding that was already there, so cells don't get any bigger and a screen with no borders costs nothing extra. The IDs point into a table of distinct border sets (about 100 bytes each) that's shared by the whole terminal. A typical TUI only uses a handful of distinct sets, but the table is never freed, so in the worst case (all 65,535 IDs in use) it's somewhere around 10-15 MB.\n\nRendering only does extra work for cells that have borders. Each one looks at its four neighbors to resolve shared edges, so changing a row also rebuilds the rows either side of it. Straight lines are a few solid quads per cell. Rounded corners cost more, because the anti-aliased arc is drawn as rows of small quads, roughly one per edge pixel. I haven't benchmarked any of this properly yet, and it's the part I'd most like another set of eyes on.\n\n## Try it\n\nThe [spec](https://github.com/joshka/ghostty/blob/joshka/cell-borders/specs/cell-border-protocol.md) and [rationale](https://github.com/joshka/ghostty/blob/joshka/cell-borders/specs/cell-border-rationale.md) are on my Ghostty fork. The [Ghostty\nbranch](https://github.com/joshka/ghostty/tree/joshka/cell-borders) implements all of version 1. I've only built and run it on macOS so far, so the\nGTK and OpenGL builds are untested. To build and run it (you'll need Zig 0.17 and Xcode):\n\n```\ngit clone -b joshka/cell-borders https://github.com/joshka/ghostty.git\ncd ghostty\nzig build -Doptimize=ReleaseFast -Dxcframework-target=native\nopen zig-out/Ghostty.app\n```\n\nInside that Ghostty, the gallery script on the same branch draws the style gallery above. It detects support first, so it's also a quick check for another terminal's implementation:\n\n```\npython3 scripts/cell-borders.py            # the default view\npython3 scripts/cell-borders.py --styles   # line styles, joints and outside fill\n```\n\nOnce it's running you can flip between views with keys: `p` placement, `c` colors, `h` blocks, `j`\njunctions, `e` halves, `s` styles and `l` live. `w` cycles the stroke width, `b` turns borders off\nso you can compare, and `q` quits. Each view also has a matching flag (`--placement`, `--styles`\nand so on) if you want to start on it.\n\nThe tiling panes demo is on the [Ratatui branch](https://github.com/joshka/ratatui/tree/joshka/cell-borders). It falls back to box-drawing\ncharacters when the terminal doesn't support cell borders, which is how I made the comparison\nshots above:\n\n```\ngit clone -b joshka/cell-borders https://github.com/joshka/ratatui.git\ncd ratatui\ncargo run -p tiling-panes\n```\n\nDrag panes around with the mouse to retile them. `n` and `v` split, `x` closes, and right click\nopens a pane menu. Press `b` to switch between cell borders and box drawing, or `g` to shade\nalternate cells and see where the edges are.\n\nThe btop-style monitor from the chart screenshots is in the same branch. Given how much of it an AI wrote, I did briefly consider calling it bslop:\n\nTo run it:\n\n```\ncargo run -p cell-border-top\n```\n\nIt's a real system monitor (it reads your actual CPU, memory, network and processes), so it's a\nbetter test of how this looks on a busy screen than the panes demo. `1` to `4` show and hide the\nboxes, `b` and `g` work the same as in the panes demo, and `h` or `?` lists the rest of the keys.\nIt needs a newer Rust (1.95) than the rest of Ratatui because of the `sysinfo` dependency.\n\n## What I'd like to hear\n\nI'm not attached to this being the exact solution, but I think it's a problem worth solving, and\nI'd like it to be more than one terminal's feature (and more than one weekend's worth of yak\nshaving). If you work on a terminal, would you implement this, and which parts look expensive? If you work on a TUI library or app, which of your widgets\nwould use it, and what would they need that isn't here? Comments are welcome on the\n[Ghostty discussion](https://github.com/ghostty-org/ghostty/discussions/14324).", "url": "https://wpnews.pro/news/terminal-borders-that-don-t-cost-a-cell", "canonical_source": "https://www.joshka.net/cell-borders/", "published_at": "2026-11-13 12:51:37+00:00", "updated_at": "2026-11-13 13:22:11.353331+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Ratatui", "Ghostty", "Rio", "Joshka", "Unicode Box Drawing"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/terminal-borders-that-don-t-cost-a-cell", "markdown": "https://wpnews.pro/news/terminal-borders-that-don-t-cost-a-cell.md", "text": "https://wpnews.pro/news/terminal-borders-that-don-t-cost-a-cell.txt", "jsonld": "https://wpnews.pro/news/terminal-borders-that-don-t-cost-a-cell.jsonld"}}