Wrapping text without breaking its colors
May 11, 2026 · 11 min read
Take the simplest styled string a terminal UI ever renders ("\x1b[31mhello world\x1b[39m", eleven visible characters painted red) and wrap it to five columns with the obvious algorithm: walk the characters, emit a newline every five. Here's what actually comes out, with escapes made visible:
\x1b[31m
hello
worl
d\x1b[39
m
Three things went wrong. The opening escape ate an entire row of the budget, because five invisible characters are still five characters to a char counter. The visible text drifted off-grid as a result. And the reset sequence got sheared down the middle: the terminal receives ESC [ 3 9, then a newline, then a stray m — whether the reset ever lands now depends on how charitably your terminal treats a control character parked in the middle of an escape sequence. If it doesn't land, everything painted after this string — borders, labels, other components — is red.
You don't even need to cut an escape in half to get the bleed. Any wrapper or truncater that keeps the opener but drops the closer — say, "take the first five characters" — leaves \x1b[31m open with no \x1b[39m ever coming. In a full-screen TUI that repaints components independently, one leaked SGR code turns into a UI-wide plague: the style escapes the string it belonged to.
Enter the wrap-text crate in inkrs. It's the Rust port of Ink's wrap-text.ts, which in turn leans on the npm packages wrap-ansi and cli-truncate, and its job is stated in its module docs: word-wrapping, hard-wrapping, and truncation of strings while preserving ANSI SGR escape sequences and respecting Unicode display widths. The italicized part is where all the actual work lives.
A styled string is a token stream
The naive wrapper fails because it treats the string as a sequence of characters when it's really a sequence of two different things interleaved: instructions and content. Before you can wrap anything, you have to separate them. That's the tokenizer's job — the same ansi-tokenizer crate we used when measuring strings — and its output for the red string is exactly three tokens:
Csi { value: "\x1b[31m" } // instruction: set foreground red
Text { value: "hello world" } // content: eleven cells
Csi { value: "\x1b[39m" } // instruction: reset foreground
The AnsiToken enum covers the full grammar — Text, Csi, OSC/DCS control strings, C1 controls — but for styling, the pattern that matters is this alternation: SGR escapes and visible runs, taking turns. Call each maximal stretch of visible text under one active style an SGR run. The red string is one run ("hello world", style: red). A string like "plain \x1b[1mbold\x1b[22m plain" is three runs. The inkrs README describes wrap-text as preserving exactly this: wrap / hard / truncate-* while preserving SGR runs.
Once you see styled text as runs, the wrapping problem restates itself cleanly: wrap the visible characters as if the escapes weren't there, then make sure every output line reconstructs the style state its runs need. The first half is bookkeeping. The second half is the interesting part.
Width, not length
The bookkeeping half starts with measuring, and as we saw when measuring strings, the only count a terminal cares about is cells — not bytes, not chars. wrap-text doesn't reimplement any of that; it delegates every measurement to measure-text:
/// Length in display columns of `text`, ignoring ANSI codes — same as
/// `string-width`.
/// Returns the display width of each whitespace-separated word.
string_width tokenizes internally and sums cell widths over the Text tokens only, so a word carrying an escape sequence weighs exactly its visible cells. And because the unit is cells, CJK behaves correctly for free: the test suite wraps "你好世界" (four ideographs, eight cells) at width 4 and asserts that no row exceeds four cells and no character is dropped. What comes out is "你好\n世界", two rows of two ideographs. A wrapper counting chars would have put all four on one row and blown the border off the box.
Five ways to fit
The crate's public surface is one function and one enum:
These are the five modes the framework exposes (wrap, hard, truncate-end/start/middle in the README's terms), and they split into two families: the first two produce multiple lines, the last three produce one.
Wrap is what you'd call word wrap, with one asterisk: it mirrors wrap-ansi with hard: true, so a single word wider than the whole column budget still breaks mid-word rather than overflowing. Hard skips word boundaries entirely and packs cells. The difference in one example, at width 6:
wrap_text("hi there", 6, WrapMode::Wrap) → "hi \nthere"
wrap_text("hi there", 6, WrapMode::Hard) → "hi the\nre"
Both outputs came from running the crate, as did every other before/after in this essay. One more real case, because it looks like a bug and isn't: the port runs with trim: false (Ink's setting), so separator spaces are never discarded — and a test pins the consequence that wrap_text("hello world", 5, WrapMode::Wrap) is "hello\n \nworld". The space between the words gets a row of its own, because the row "hello" was already full and the space must go somewhere. The accompanying assertion is the one that matters: every row, trailing spaces included, measures within the column budget — an over-wide row would paint one cell outside the box.
Close before the break, reopen after
Now the interesting half. The wrapping pass produces rows by splitting visible text, with escapes riding along inside whichever row their neighboring characters landed in (the mid-word breaker tracks an is_inside_escape flag so escape bytes never count toward the column budget). That gives you rows that are the right width, but a row whose style was opened two rows earlier is still a landmine. So the wrapper runs a second pass over the joined rows, and this is the mechanism the whole essay has been building toward:
/// Walks `text` and re-emits ANSI SGR + hyperlink state around `\n` so
/// that each wrapped row is independently styled.
So the answer to "how do open styles survive a line break?" is: both of the strategies you might guess, composed. The function keeps state — which SGR code is currently open, which hyperlink is active — and uses that state to close and reopen around every newline. Immediately before each \n it emits the close code for whatever is open; immediately after, it re-emits the opener. The close codes come from a small table transcribed from the ansi-styles package: bold/dim (1/2) close with 22, italic (3) with 23, underline (4) with 24, all foreground colors (30–37, 90–97) with 39, backgrounds with 49.
Run the red string through Wrap at width 5 and you can watch it work:
\x1b[31mhello\x1b[39m
\x1b[31m \x1b[39m
\x1b[31mworld\x1b[39m
Three rows, each one opening red, painting its run, and resetting — including the middle row, whose entire content is one styled space. Feed any row to a terminal in isolation, in any order, and it renders correctly and leaks nothing. This is the property the test suite pins for both Wrap and Hard: the output must contain \x1b[39m immediately before a newline and the original opener immediately after one.
Two honest limits, both inherited deliberately from upstream wrap-ansi. The state is a single Option<u32>, not a stack — a run that is bold and red only gets its most recent single-parameter code carried across breaks. And parse_csi_sgr_at only parses plain ESC [ digits m, so 256-color and truecolor sequences (38;5;n, 38;2;r;g;b) get no continuity at all: wrap red as \x1b[38;2;255;0;0m… and the continuation rows come back bare, leaning on the terminal's own state. The doc comment on close_code_for says so out loud, and claims the same is true of wrap-ansi: no continuity. For the styles Ink actually emits per-run, the tracked set is the working set.
Truncation: where the ellipsis goes
The three Truncate* modes port cli-truncate, and their skeleton is arithmetic over one primitive. After the edge cases — width 0 returns an empty string, width 1 returns just "…", text that already fits is returned untouched — each mode is a slice-and-glue:
End =>
Start mirrors it ("…" plus a suffix), and Middle keeps columns / 2 cells of prefix, the ellipsis, and enough suffix to fill the rest. The primitive doing the work is slice_ansi, a display-column slicer over the token stream — and here the tokenizer is the loop itself, not a helper behind a measuring function:
/// Display-width-aware slice that preserves ANSI escape tokens between
/// (and at the boundaries of) the kept visible columns.
Two details in there separate this from a freehand attempt:
- Escapes before the slice are buffered, not dropped. Every escape token seen before the first kept column accumulates in
pending_escapesand is replayed the moment the slice starts. That's why start-truncating the red string keeps it red: the\x1b[31mopener lives in the discarded prefix, but the slicer re-emits it in front of the kept suffix. - A wide character straddling a boundary is dropped, not split. There is no such thing as the left half of
你. Truncating"你好世界天地玄黄"(16 cells) to 5 yields"你好…"— four cells of ideographs plus the ellipsis, and the third ideograph, which would have occupied columns 4 and 5, simply doesn't appear. A test asserts the result never overflows.
Here are all three modes on the red string, real output, width 8 (middle at 7):
End → \x1b[31mhello w…
Start → …\x1b[31mo world\x1b[39m
Middle → \x1b[31mhel…\x1b[31mrld\x1b[39m
Read the styling of the ellipsis itself. In End and Middle it sits inside red — after an unclosed opener — so it renders red, which is what you want: the … stands for red text. In Start it sits before the replayed opener, so it renders unstyled. And note what End does with open styles: the input's own \x1b[39m reset lived in the tail that was cut off, and the port doesn't synthesize a replacement — the row ends open, and closing it is the renderer's problem. The doc comment states the scope plainly: it mirrors the slice-ansi npm package "for the subset we need." Truncation produces a single line for a single box cell; the framework layer above owns the reset. Wrapping, which produces many lines that travel independently, is where self-containment is enforced — and there, it's unconditional for every style the framework emits.
Every line must stand alone
Why insist on that property? Because of what happens to these lines next. A terminal UI doesn't print its output top to bottom like a report; it paints rows into a grid, diffs them against the previous frame, repaints the three that changed, and skips the rest. Rows from different components interleave. A row that depends on an escape sequence emitted three rows earlier — in a row that maybe didn't get repainted this frame — renders differently depending on paint order, which is a bug you'll first observe as a flickering color somewhere unrelated.
The contract that prevents the whole class of bugs is the one rebuild_with_escape_continuity enforces mechanically: state is tracked so it can be closed at every break and reopened after it, and no row ever borrows style from another. Wrap on cells, carry the escapes, and make every line stand alone.
Thoughts? Find me on LinkedIn.