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.

ProblemWhy 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 boundaryA 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 screenNever reflows. Universally.
Idempotency80 → 60 → 80 must return to the original. It usually does not.
PerformanceA 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 corrupt echo "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

DecisionOptionsRecommendation
ScrollbackReflow all / reflow lazily on scroll / never reflowLazily. Reflow the visible grid immediately; reflow scrollback pages on demand. This is what makes it fast enough.
Trailing spacesStrip / keep / track logical_lenTrack logical_len. It is one usize per line and it is the only approach that is ever right.
Alt screenNever reflowNever. Universal. The program redraws on SIGWINCH.

4. Milestones

#GoalDemonstrable by
1logical_len tracked, with testsecho "hi " and echo "hi" produce different logical_len
2Grid-only reflow, cursor ignoredWidening rejoins wrapped lines; content is correct
3Cursor trackingThe cursor stays on the same character across 80→60→80
4Scrollback, lazily100k lines of scrollback; resize stays under 16 ms
5Wide characters and the round-trip propertyThe 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

MetricTargetMethod
Grid-only reflow (24×80)< 0.5 mscriterion
Reflow with 100k scrollback< 16 msThe same
Memory overhead of logical_len8 bytes/line — state itsize_of × line count
Round-trip fidelity100% of the corpusThe property test
Resize events per second during a dragMeasure, then debounceCount 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

TrapDetail
Tracking the cursor by (row, col)It must be tracked by character identity: (logical line, offset).
Reflowing all scrollback eagerly100k lines × 60 resize events per drag = unusable.
Stripping trailing spacesBreaks column, paste, and anything alignment-sensitive.
Keeping trailing paddingEvery reflowed line is padded to the old width.
Splitting a wide characterProduces an orphan spacer and corrupts every subsequent column.
Forgetting cursor-addressed contentA program that wrote at (5,10) without wrapping has a "logical line" that is mostly blank. Decide what that means.
Reflowing the alt screenvim will redraw anyway, and your reflow fights it.
Losing stylesThe cells move; their Style must move with them.
Not testing the round tripThe bug that survives everything else.

Tip: Write resize_round_trip_is_idempotent before 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_len per 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.

Next: Project 3 — A GPU Renderer