A caret cannot be a glyph you insert
The Insert-mode caret in outl's TUI was a ▏ spliced between two characters of the block's text. A terminal is a cell grid and a glyph costs a cell, so everything right of the cursor sat one column over and walked back and forth by one as the cursor moved, and a block near the pane edge wrapped a character earlier while it was being edited than it did the moment Esc was pressed. The fix is to make the caret a property of a character that is already there, which is cheap, and the consequences are not: a foreground colour paints nothing on a space, so the underline became load-bearing; and the moment the caret was the cell, view::wrap's rule that a space is a separator it may discard silently ate the cursor at columns 9 and 19 of a 43-cell block in a 16-cell pane. The blanket fix could not ship, because the spaces inside bold, code and page refs really are separators.
In Insert mode, the rest of the line sat one column too far right.
Not permanently. It walked. One cell right while the cursor was behind it, back where it belonged the moment the cursor passed over it, so typing a sentence made the tail of the line shuffle sideways under my eyes. And a block near the right edge of the pane wrapped a character earlier while I was editing it than it did the second I pressed Esc.
The Normal-mode block cursor, on the same block, in the same renderer, never did any of it.
That is issue #320. Like the status probe that broke the sync it was measuring, the thing drawn to tell me where I was had moved the thing it was describing.
a glyph costs a cell, and the caret was a glyph
emit_row_with_cursor in crates/outl-tui/src/view/outline.rs splits a row into three pieces: everything left of the cursor, the cursor, everything right of it. The caret used to be a literal ▏ span pushed into the middle of that sandwich.
A terminal is a grid of fixed cells, and ▏ is a character like any other: putting it on the row costs a column. So the caret did not sit between two characters, because there is no between. It took a cell of its own, and every cell to its right took the next column along.
That is the whole bug, and it has three faces. The tail of the line is one cell right of where it really is. It walks back and forth by one as the cursor moves through the text, because the extra cell moves with the cursor. And the row measures one cell wider than its text, which the word wrapper reads:
let avail = (text_width as usize).saturating_sub(prefix_w);
let content_w = spans_width(&content);
if avail == 0 || content_w <= avail {
One extra cell in content is one cell closer to avail, so a block sitting right at the pane width broke to a second row while the caret was in it and collapsed back to one row when the caret left. The wrap was not wrong. It was correctly wrapping a row that was lying about its width.
The Normal-mode cursor was exempt for the reason that turned out to be the fix: it inverts the character it lands on instead of adding one. The two cursors live in the same function as two arms of one match, and they held two different theories of what a cursor is. One of the two was already right.
So the caret stopped being a thing you insert and became a property of a character that is already there:
let (on_char, past_end) = match style {
CursorStyle::Caret => (theme.cursor_caret_on_char(), theme.cursor_caret),
CursorStyle::Block => (theme.cursor_block, theme.cursor_block),
};
let mut right_chars = right.chars();
match right_chars.next() {
Some(ch) => {
spans.push(Span::styled(ch.to_string(), on_char));
let rest: String = right_chars.collect();
spans.extend(highlight_inline(&rest, theme));
}
None => {
spans.push(Span::styled("▏", past_end));
return past_end;
}
}
Past the end of the line there is no character to mark, so the ▏ stays. It is appended after the last cell and has nothing to its right to shift, which costs nothing. The same reasoning is why the overlay inputs kept theirs without a second thought: the command palette, the search box and the key:: value editor are append-only. PropertyEdit carries a field, a key and a value and no cursor column at all, so its caret is always past the last character by construction.
the underline is the cursor, because a colour paints nothing on a space
cursor_caret is a foreground colour plus BOLD. Apply a foreground colour to a space and you have painted nothing, and a caret in prose spends a great deal of its time on a space: every gap between two words is a position the arrow keys stop at.
So the style the caret paints with is not the palette entry:
pub fn cursor_caret_on_char(&self) -> Style {
self.cursor_caret.add_modifier(Modifier::UNDERLINED)
}
Two lines, and the underline in them is the entire cursor in more cases than I expected. Four shipped presets set cursor_caret_fg equal to fg: light, dracula, nord and monokai. Nord is the clearest, where snow1 is both the body text colour and the caret colour, so the hue swap is a no-op and the underline is all there is. The two ANSI presets inherit the terminal’s own foreground, so on a terminal already using white on black, same result.
The style also lives in crates/outl-tui/src/theme.rs rather than next to the renderer that needs it, because that file declares itself the owner of the modifier formula. A modifier decided in a view module is a second owner of that formula, and the whole point of the file is that there is one. Getting this right also made a line in DESIGN.md false. It said UNDERLINED belongs to the three link roles, the only clickable things in pretty-render mode, where the underline is the affordance. There is a fourth role now, and the specification says so, including the reason the two never read as the same thing: a caret on a link character also swaps the hue and adds BOLD.
the caret became a cell, and the wrapper had opinions about cells
This is the half that cost the time, and it is the half I did not see coming.
outl’s outline wraps its own text rather than using ratatui’s Paragraph::wrap, because that expands one logical line into N visual lines after layout and desyncs the scroll index the viewport depends on. So crates/outl-tui/src/view/wrap.rs emits the wrapped lines up front, working on already-styled spans so a break can never land inside a **bold** token and turn it back into literal asterisks.
It has a rule about spaces. A space between two words is a separator, and a separator is discardable in two places: absorbed when it is the thing that overflows the row, and trimmed off the end of a row that just pushed a word down, so the next row does not lead with a blank.
That rule was correct for as long as it had existed, and it stopped being correct the hour the caret became a text cell. A caret parked on a space near a wrap boundary was a separator as far as the wrapper could tell, so it was thrown away, and the user saw no cursor on the screen at all.
This is the same shape as a complement turning every lenient filter into a liability. The rule did not change. What changed was what else was standing on it.
The failing positions were columns 9 and 19 of a 43-cell block in a 16-cell pane. Both are spaces, which is the entire story, and which column lands on a space is a function of the wrap arithmetic rather than of anything a future reader would think to preserve. So the test walks all of them:
#[test]
fn the_caret_survives_a_wrap_break_at_every_column() {
It loops both cursor styles over every character of the block and asserts exactly one cursor cell is on the screen. The Normal-mode block cursor, which I had just finished calling the one that was already right, failed it too. It paints a space in place as well, so it had been losing itself at wrap boundaries before this change as well, with nothing in the report to say so.
”a styled space is never a separator” was the wrong shape
The first fix I reached for is the one-liner: if a space carries a style, it is load-bearing, so keep it.
That is wrong, and highlight_inline is where you can see why:
InlineTok::Bold { inner } => {
out.push(Span::styled("**".to_string(), dim));
out.push(Span::styled(inline_to_source(&inner), theme.bold));
out.push(Span::styled("**".to_string(), dim));
}
The inner text of a **bold** token is one styled span, spaces included. So are the spaces inside `some code` and inside [[a page ref]]. Every one of them is a styled space, and every one of them is a genuine separator that the wrapper should be free to drop at a break. The blanket rule would have fixed the caret by refusing to wrap any emphasised phrase properly.
The question is not whether the space carries a style. It is whether it carries this style. push_wrapped takes the style the caller painted the cursor with:
pub(crate) fn push_wrapped(
guides: Vec<Span<'static>>,
head: Vec<Span<'static>>,
content: Vec<Span<'static>>,
text_width: u16,
cursor: Option<Style>,
out: &mut Vec<Line<'static>>,
)
and both discard sites ask one question before discarding:
fn carries_cursor(cell: (char, Style), cursor: Option<Style>) -> bool {
cursor.is_some_and(|style| cell.1 == style)
}
The trim stops when it reaches the cursor cell. The overflow case does something slightly better than stopping: it breaks the row and opens the next one with that space, because a leading blank on the continuation row is exactly where the insertion point is.
The protection is keyed on the style, not on the position, and that is a real blind spot rather than a tidy one. The caret style is cursor_caret_fg plus BOLD plus UNDERLINED. Nothing else in the theme is both bold and underlined today: the three link roles are underlined without bold, and bold is bold without underline. So the comparison is exact, by accident of the modifier formula rather than by construction, and no test pins it. If a future role or a preset landed on that same Style, every space painted with it would become undiscardable at a wrap boundary, and the regression tests would not catch it, because they count cursor cells by style too.
⚡ measures two cells and the pad reserved one
The same claim one pane over, which is issue #319 and the reason this post covers two changes.
A bullet row spends four cells between the indent guides and the block’s text: two for the fold slot, which keeps its width whether a marker is visible or not so a leaf lines up with its parent, and two for the - bullet. Property rows padded two, so a priority:: high landed under the fold marker instead of under the text of the block it belongs to.
The issue pointed at the line that does it. There were three copies of that measurement and they had drifted in three directions, so the fix is a module rather than a patched line. crates/outl-tui/src/view/row_chrome.rs owns the fold slot, the auto-run marker, the pad and the whole key:: value row now, and the outline and the backlinks mini-outline both call into it:
pub(crate) fn push_body_indent(
spans: &mut Vec<Span<'static>>,
has_auto_run: bool,
icons: &IconSet,
) {
spans.push(Span::raw(" "));
if has_auto_run {
spans.push(Span::raw(auto_run_pad(icons)));
}
}
Writing the test for that turned up the second one. The pad standing in for the auto-run:: marker on rows that do not draw it used to be a literal single space. ⚡ measures two terminal cells. The Nerd Font glyph in the alternative icon set measures one. No literal is right in both modes, and TuiIconStyle::Emoji is the default, so every continuation row of an auto-run:: block was a column short for anyone who had not opted into a Nerd Font. A width that depends on a configuration value is a measurement:
fn auto_run_pad(icons: &IconSet) -> String {
" ".repeat(icons.bolt.width())
}
and the_auto_run_pad_matches_the_glyph loops both icon styles asserting the pad and the glyph agree.
The third one is the one with no symptom you could report. The backlinks pane had its own copy of the property row and it never drew the property_glyph at all, so the same remind:: showed a ⏰ in the outline and nothing in the backlinks pane, two panes apart on the same screen, with no test that could notice. Now there is one, and it asserts the two renders are the same bytes rather than asserting a glyph it would have to keep in step by hand:
push_property_row(0, "remind", "9am", false, &app, &mut expected, 0);
assert_eq!(row_text(&out[1]), row_text(&expected[0]));
That is the same move as turning client parity into an exhaustive match: make the suite ask the question, because I will not remember to.
A property row also wraps now. It was pushed as a bare Line, and the outline’s Paragraph deliberately has no .wrap(), so a long template:: ran off the right edge and was clipped with nothing on screen to say it had been. It goes through the same push_wrapped every block row uses, with the glyph in the head so a wrapped value re-indents under the key rather than under the glyph.
Both changes came apart on the way, because outline.rs crossed its file-size baseline mid-fix twice. The !((blk-X)) embed expansion moved out to view/embed.rs, the outline’s tests moved out to view/outline/tests.rs, and the row for that file is gone from .github/file-size-baseline.txt rather than merely smaller.
what is still wrong on purpose
Three things, named so they are recorded rather than discovered.
The caret styles a single char, not a grapheme cluster. Parked on a zero-width continuation code point, a combining accent or a ZWJ inside an emoji sequence, it paints a zero-width cell and disappears. That position is reachable: EditBuffer holds a Vec<char> and move_right is self.cursor += 1, so the arrow keys stop there. The recorded reason for leaving it is that grapheme segmentation is not a dependency of this workspace, and that is true of the manifests, where unicode-width is the only unicode crate any outl crate names, though ratatui already drags unicode-segmentation into the lock file. The harder reason is that the caret’s unit and the cursor’s unit have to be the same unit. A grapheme-aware caret over a char-stepping cursor would paint the same cluster for several distinct cursor positions, which is a worse lie than painting nothing. The old ▏ was visible there, at the price of splitting the cluster it was drawn inside.
The caret is still painted into the cell grid rather than handed to the terminal. A real terminal cursor would blink and take the shape the user configured in their emulator. Getting there needs the caret’s screen coordinates after wrapping and after scrolling, and an arbiter for the single terminal cursor, because view/overlays.rs draws a caret of its own in six places and only one of them can win.
And from the property-row half: the glyph sits inline before the key. A block carrying both remind:: and priority:: has its two keys in different columns, because one of them gets a three-cell ⏰ and the other gets nothing. Moving the glyph into the fold slot means reserving a slot wide enough for ⏰ on every property row of every block, which is three dead cells everywhere to straighten out the blocks that carry two properties where exactly one has a glyph. I would rather keep the misalignment and write it down.
The lesson isn’t that terminals are awkward. It’s that a cell grid has no decorations in it. A cursor, a fold marker, a bolt before a bullet: each one is a width, and a width is either measured or assumed, and every one of these bugs is an assumption standing where a measurement belonged. The expensive half is never the first assumption, either. It’s the rule the first one licensed: view::wrap had been correctly throwing spaces away for as long as it had existed, and it became a bug the hour the caret stopped being a glyph, without a line of it changing.
The caret is in crates/outl-tui/src/view/outline.rs, its style in crates/outl-tui/src/theme.rs, the wrapper’s two space rules in crates/outl-tui/src/view/wrap.rs, and the column every row starts in is crates/outl-tui/src/view/row_chrome.rs, all in the outl repository.