rust · tui

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`.
#[inline]
fn visible_width(text: &str) -> usize {
    string_width(text)
}

/// Returns the display width of each whitespace-separated word.
fn word_widths(line: &str) -> Vec<usize> {
    line.split(' ').map(visible_width).collect()
}

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:

pub enum WrapMode {
    /// Word-wrap (break on whitespace), but break mid-word when no
    /// whitespace fits (`hard` mode in wrap-ansi).
    Wrap,
    /// Hard-wrap: break mid-word with no word-aware breaking.
    Hard,
    /// Truncate to fit, ellipsis at the end.
    TruncateEnd,
    /// Truncate to fit, ellipsis at the start.
    TruncateStart,
    /// Truncate to fit, ellipsis in the middle.
    TruncateMiddle,
}

pub fn wrap_text(text: &str, max_width: usize, mode: WrapMode) -> String {

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.
fn rebuild_with_escape_continuity(text: &str) -> String {
    // Active SGR code (the *opening* code, e.g. 31 for red).
    let mut escape_code: Option<u32> = None;
    // Active hyperlink URL.
    let mut escape_url: Option<String> = None;

    // ... for each character, copied through to the output:
    if is_escape_introducer(character) {
        if let Some(code) = parse_csi_sgr_at(&chars, i) {
            escape_code = if code == END_CODE { None } else { Some(code) };
        } else if let Some(uri) = parse_hyperlink_at(&chars, i) {
            escape_url = if uri.is_empty() { None } else { Some(uri) };
        }
    }
    let close = escape_code.and_then(close_code_for);

    if next == Some('\n') {
        // Right before the `\n`, close the active style/link.
        if let Some(close_str) = close { out.push_str(&close_str); }
    } else if character == '\n' {
        // Just after the `\n`, re-open the active style/link.
        if let (Some(code), Some(_)) = (escape_code, &close) {
            out.push_str(&csi_sgr_seq(code));
        }
    }
    // ...
}

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.

wrapping a styled run keeps every line self-contained input — one red SGR run, 11 visible cells \x1b[31m hello world \x1b[39m wrap · width 5 column width = 5 \x1b[31m h e l l o \x1b[39m \x1b[31m w o r l d \x1b[39m close 39, then reopen 31 each row closes its SGR before the newline and reopens it after — nothing leaks
A red run of eleven cells wrapped to width 5: each output row reopens the red at its start and closes it before the newline, so the style is carried across the column boundary and no SGR code ever leaks into the next line.

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:

TruncatePosition::End => {
    // Keep prefix of width (columns - 1), append "…".
    let prefix = slice_ansi(text, 0, columns - ellipsis_width);
    format!("{prefix}{ELLIPSIS}")
}

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.
fn slice_ansi(text: &str, start: usize, end: usize) -> String {
    let mut visible: usize = 0;
    // Collect ANSI escape tokens we encounter *before* we hit the first
    // kept column, so we can replay them once we cross `start`.
    let mut pending_escapes = String::new();
    let mut started = false;

    for token in tokenize_ansi(text) {
        match token {
            AnsiToken::Text { value } => {
                for ch in value.chars() {
                    // ... count cells; skip until `start`, stop at `end`;
                    // a wide char straddling `start` is dropped whole ...
                    if !started {
                        out.push_str(&pending_escapes);
                        started = true;
                    }
                    out.push(ch);
                }
            }
            other => {
                // ANSI escape — zero visible width. Either emit it (if we
                // already started the kept slice) or buffer it for the start.
                let value = other.value().to_string();
                if started { out.push_str(&value); }
                else { pending_escapes.push_str(&value); }
            }
        }
    }
    out
}

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_escapes and is replayed the moment the slice starts. That's why start-truncating the red string keeps it red: the \x1b[31m opener 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.