Project 2: Reflow on Resize
2–3 weeks · ●●●●● · touches the screen model at its hardest point
The smallest diff in this portfolio and by far the hardest. Every major terminal has open bugs about this. If you finish it correctly, you have solved something the ecosystem has not.
1. The Problem
Your terminal truncates on resize. Narrow the window and text is lost; widen it and lines stay short with ragged whitespace where they used to wrap.
Before (width 40): After widening to 60 — TRUNCATE:
the quick brown fox jumps over the │ the quick brown fox jumps over the
lazy dog and keeps going for a whi │ lazy dog and keeps going for a whi
le │ le
After widening to 60 — REFLOW:
│ the quick brown fox jumps over the lazy dog
│ and keeps going for a while
Users expect reflow. It is the single most-requested terminal feature, and every implementation of it has bugs.
2. Why It Is Hard
Reflow looks like string re-wrapping. It is not; it is a simultaneous re-derivation of six pieces of state that must stay consistent.
| Problem | Why it is genuinely hard |
|---|---|
| Where does the cursor go? | It was on a specific character. After reflow that character is at a different (row, col) — or was on trailing padding that no longer exists. |
| What about scrollback? | Reflowing 100,000 lines on every resize-drag frame is unusable. Not reflowing makes history look wrong. |
| Wide characters at the new boundary | A CJK character that no longer fits must move whole, and the cell before it becomes blank. |
| Are trailing spaces content? | echo "hi " produced real spaces. Padding to cols did not. The grid cannot tell them apart unless you track it. |
| What is a "logical line"? | A chain of wrapped: true lines. But a program may have cursor-addressed into the middle of one. |
| The alternate screen | Never reflows. Universally. |
| Idempotency | 80 → 60 → 80 must return to the original. It usually does not. |
| Performance | A resize drag fires dozens of events per second. |
Warning: The trailing-space problem is the one that makes this genuinely unsolved rather than merely fiddly. When a program writes
"hi"into an 80-column grid, cells 2–79 contain spaces. Are they content? If you strip them you corruptecho "a b" | column. If you keep them, every reflowed line is padded to the old width. Real terminals track a per-line "logical length" — and even then, a program that cursor-addressed and wrote a space defeats it.
3. The Design
The data you need
#![allow(unused)] fn main() { pub struct Line { cells: Vec<Cell>, /// Ended by WRAPPING, not by an explicit newline. You already have this /// from Lab 7 — this is where it earns its keep. pub wrapped: bool, /// How many cells were ever WRITTEN, as opposed to being padding. /// Without this you cannot distinguish `echo "hi "` from `echo "hi"`. pub logical_len: usize, } }
The algorithm
1. GATHER: walk scrollback + grid, joining chains of wrapped lines into
LOGICAL LINES. Record, for the cursor, which logical line and which
character offset within it.
2. MARK: before rewrapping, note the cursor's (logical_line_index,
char_offset_within_logical_line). This is the ONLY reliable cursor anchor.
3. REWRAP: for each logical line, emit physical lines of the new width,
never splitting a wide character. Set `wrapped` on every emitted line
except the last.
4. RESTORE: find the cursor's character by (logical_line, offset) and
convert back to (row, col).
5. SPLIT: the last `rows` physical lines are the grid; the rest is
scrollback.
#![allow(unused)] fn main() { fn reflow(&mut self, new_cols: usize, new_rows: usize) { // 1-2. Gather logical lines and anchor the cursor to a CHARACTER. let (logical, cursor_anchor) = self.gather_logical_lines(); // 3. Rewrap. let mut physical = Vec::with_capacity(logical.len() * 2); let mut cursor_pos = None; for (li, lline) in logical.iter().enumerate() { let start = physical.len(); rewrap_one(lline, new_cols, &mut physical); if let Some(a) = &cursor_anchor { if a.logical_line == li { // 4. Convert the character offset back to (row, col). cursor_pos = Some(offset_to_position(&physical[start..], a.offset, new_cols)); } } } // 5. Split into scrollback + grid. let grid_start = physical.len().saturating_sub(new_rows); self.screen.scrollback = physical[..grid_start].iter().cloned().collect(); self.screen.primary = Grid::from_lines(&physical[grid_start..], new_rows, new_cols); self.cursor.set_from(cursor_pos, new_rows, new_cols); self.cursor.pending_wrap = false; } /// Rewrap ONE logical line. The wide-character rule is the subtle part. fn rewrap_one(line: &LogicalLine, cols: usize, out: &mut Vec<Line>) { let mut cur = Line::blank(cols); let mut col = 0; for cell in line.cells_up_to_logical_len() { let w = cell.width() as usize; // A wide character must not be split across the margin. If it does not // fit, leave the last cell BLANK and wrap — matching what the terminal // does when printing. if col + w > cols { cur.wrapped = true; out.push(std::mem::replace(&mut cur, Line::blank(cols))); col = 0; } cur.set(col, *cell); col += w; } cur.logical_len = col; cur.wrapped = false; out.push(cur); } }
The three decisions you must make and document
| Decision | Options | Recommendation |
|---|---|---|
| Scrollback | Reflow all / reflow lazily on scroll / never reflow | Lazily. Reflow the visible grid immediately; reflow scrollback pages on demand. This is what makes it fast enough. |
| Trailing spaces | Strip / keep / track logical_len | Track logical_len. It is one usize per line and it is the only approach that is ever right. |
| Alt screen | Never reflow | Never. Universal. The program redraws on SIGWINCH. |
4. Milestones
| # | Goal | Demonstrable by |
|---|---|---|
| 1 | logical_len tracked, with tests | echo "hi " and echo "hi" produce different logical_len |
| 2 | Grid-only reflow, cursor ignored | Widening rejoins wrapped lines; content is correct |
| 3 | Cursor tracking | The cursor stays on the same character across 80→60→80 |
| 4 | Scrollback, lazily | 100k lines of scrollback; resize stays under 16 ms |
| 5 | Wide characters and the round-trip property | The property test passes |
Milestone 2 alone is shippable and already better than truncation for most users. Ship it, then decide about 3–5.
5. The Tests
This project is defined by its property tests. Write these first.
#![allow(unused)] fn main() { #[test] fn resize_round_trip_is_idempotent() { // THE test. 80 → 60 → 80 must return to the original screen. // Almost every implementation fails this on the first attempt. for case in golden_cases() { let mut a = replay(&case, 24, 80); let original = a.snapshot_debug(); a.resize(24, 60); a.resize(24, 80); assert_eq!(a.snapshot_debug(), original, "round trip failed: {}", case.name); } } #[test] fn cursor_stays_on_the_same_character() { let mut t = Terminal::new(10, 40, cfg()); t.advance(b"the quick brown fox jumps over the lazy dog"); t.advance(b"\x1b[1;15H"); // land on a known character let ch = t.cell_at_cursor().grapheme().to_string(); t.resize(10, 60); assert_eq!(t.cell_at_cursor().grapheme(), ch, "the cursor lost its character"); } #[test] fn wide_characters_are_never_split() { let mut t = Terminal::new(10, 41, cfg()); t.advance("日".repeat(40).as_bytes()); for cols in (10..=80).rev() { // every width, one at a time t.resize(10, cols); assert_spacer_invariant(&t); } } #[test] fn trailing_spaces_are_preserved_when_written_and_dropped_when_padding() { let mut a = Terminal::new(5, 40, cfg()); a.advance(b"hi \n"); // three REAL spaces let mut b = Terminal::new(5, 40, cfg()); b.advance(b"hi\n"); // padding only a.resize(5, 20); b.resize(5, 20); assert_eq!(a.screen().line(0).logical_len, 5); assert_eq!(b.screen().line(0).logical_len, 2); } #[test] fn alternate_screen_is_never_reflowed() { let mut t = Terminal::new(10, 40, cfg()); t.advance(b"\x1b[?1049h"); t.advance(&"x".repeat(100).into_bytes()); let before = t.snapshot_text(); t.resize(10, 60); assert_ne!(t.snapshot_text(), rewrapped(&before), "the alt screen must be truncated, not reflowed"); } #[test] fn reflow_of_large_scrollback_is_fast_enough_for_a_drag() { let mut t = Terminal::new(24, 80, TerminalConfig { scrollback_limit: 100_000, ..cfg() }); for i in 0..100_000 { t.advance(format!("line {i} with some text\n").as_bytes()); } let start = Instant::now(); t.resize(24, 79); assert!(start.elapsed() < Duration::from_millis(16), "resize took {:?} — a drag fires dozens of these per second", start.elapsed()); } #[test] fn reflow_preserves_styles_and_hyperlinks() { let mut t = Terminal::new(5, 20, cfg()); t.advance(b"\x1b[31m"); t.advance(&"x".repeat(50).into_bytes()); t.resize(5, 40); for c in 0..40 { assert_eq!(t.screen().line(0).cell(c).style().fg, Color::Indexed(1)); } } }
6. The Measurement
| Metric | Target | Method |
|---|---|---|
| Grid-only reflow (24×80) | < 0.5 ms | criterion |
| Reflow with 100k scrollback | < 16 ms | The same |
Memory overhead of logical_len | 8 bytes/line — state it | size_of × line count |
| Round-trip fidelity | 100% of the corpus | The property test |
| Resize events per second during a drag | Measure, then debounce | Count SIGWINCH |
Then compare against reality:
# Do the same drag in each and note what breaks:
for term in alacritty kitty wezterm foot xterm; do echo "$term"; done
# Cat a long file, then narrow and widen the window.
# Which preserve content? Which move the cursor? Which are fast?
That comparison table is publishable. Nobody maintains a current one.
7. Known Traps
| Trap | Detail |
|---|---|
| Tracking the cursor by (row, col) | It must be tracked by character identity: (logical line, offset). |
| Reflowing all scrollback eagerly | 100k lines × 60 resize events per drag = unusable. |
| Stripping trailing spaces | Breaks column, paste, and anything alignment-sensitive. |
| Keeping trailing padding | Every reflowed line is padded to the old width. |
| Splitting a wide character | Produces an orphan spacer and corrupts every subsequent column. |
| Forgetting cursor-addressed content | A program that wrote at (5,10) without wrapping has a "logical line" that is mostly blank. Decide what that means. |
| Reflowing the alt screen | vim will redraw anyway, and your reflow fights it. |
| Losing styles | The cells move; their Style must move with them. |
| Not testing the round trip | The bug that survives everything else. |
Tip: Write
resize_round_trip_is_idempotentbefore you write any reflow code. Watch it fail, then make it pass. It is the single most effective test in this book, because every subtle reflow bug shows up as a round-trip failure and almost none of them show up any other way.
Deliverables
-
logical_lenper line, with the write path maintaining it correctly. - Grid reflow with cursor tracking by character identity.
- Lazy scrollback reflow, under 16 ms with 100k lines.
- Wide characters never split; the spacer invariant holds at every width.
- The alt screen never reflowed.
- All seven tests passing, especially the round trip over the whole corpus.
- The cross-terminal comparison table.
- A written note on the trailing-space decision and what it costs.