Skip to main content

max / shop

Rewrap scrollback on resize instead of clipping it History rows already record where a line ran off the right edge, so the logical lines are recoverable from the materialized rows: join maximal runs of `wrapped` rows, re-split at the new width. History stays a deque of exactly-`cols` rows, so `row()` and everything above it — text, selection, word boundaries, copy — is untouched. Rewrap runs before the history_limit trim so no line is cut in half and its tail rewrapped alone, and `view_offset` is carried across as a position within its logical line so the row under the reader's eye stays there. The live screen keeps its old treatment: clipped, wrap flags dropped. It is whatever an application last painted and is about to repaint. One consequence is that a history line continuing onto the live screen keeps its join only while it still fills the new width; below that the flag would be a lie about where the text leaves the edge, and would emit padding as content on a copy.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 23:00 UTC
Signed with PGP, not checked
Commit: eeddd7871bf730effb07ae5733c92d849491a0e8
Parent: e9687b6
2 files changed, +293 insertions, -44 deletions
M README.md +14 -7
@@ -18,8 +18,7 @@
18 18 through `shop-xkb`, an I-beam pointer over the grid, and scrollback with the
19 19 wheel and Shift+Page Up / Shift+Page Down.
20 20
21 - Absent: scrollback search, reflow (a resize clips scrollback to the new width
22 - rather than rewrapping it), sixel (Kitty graphics covers the same ground), and
21 + Absent: scrollback search, sixel (Kitty graphics covers the same ground), and
23 22 hyperlinks.
24 23
25 24 `-e PROGRAM [ARGS...]` runs a program instead of the login shell, as every
@@ -106,11 +105,19 @@
106 105 turn inside vim or htop does nothing for now — translating it into arrow keys
107 106 is a separate question.
108 107
109 - A resize does not reflow. Scrollback is clipped or padded to the new width, so
110 - narrowing the window truncates old wide lines for good. Rewrapping history is
111 - the right answer for text someone is reading and it is also the classic source
112 - of terminal bugs, so it is deliberate future work rather than a side effect of
113 - having scrollback at all.
108 + A resize rewraps scrollback. Each history row records whether it ran off the
109 + right edge, so a run of those rows plus the one ending it is a logical line the
110 + rewrap can rebuild and re-split at the new width: narrowing a window and
111 + widening it again gives the original lines back. History stays a buffer of
112 + exactly-width rows, so nothing reading it needs to know this happened, and the
113 + viewport is carried across the rewrap by position within its line rather than
114 + by row number.
115 +
116 + The live screen is clipped instead, and its wrap flags are dropped. It is
117 + whatever an application last painted and is about to be told the new size and
118 + repaint it, so there is nothing there worth rewrapping. One consequence: a
119 + history line that continued onto the live screen keeps its join only when it
120 + still fills the new width.
114 121
115 122 ## Emit
116 123
@@ -279,13 +279,16 @@
279 279
280 280 /// One row that has scrolled off the top of the main screen.
281 281 ///
282 - /// Held at the width it was authored at, which `resize` then normalizes to the
283 - /// grid's width — see [`Grid::history`].
282 + /// Always exactly the grid's current width: `resize` rewraps the whole buffer
283 + /// to the new one — see [`Grid::history`].
284 284 #[derive(Clone, Debug)]
285 285 struct HistoryRow {
286 286 cells: Vec<Cell>,
287 287 /// Whether it ran off the right edge and continued on the row below, so a
288 288 /// copy spanning the two joins them without a newline.
289 + ///
290 + /// Also the wrap-point record [`Grid::rewrap_history`] reads: a maximal run
291 + /// of these plus the row that ends it is one logical line.
289 292 wrapped: bool,
290 293 }
291 294
@@ -322,10 +325,10 @@
322 325 /// a row leaving its top is overdraw rather than history, and every
323 326 /// terminal that keeps scrollback keeps none for it.
324 327 ///
325 - /// Every row here is exactly `cols` wide. `resize` clips or pads the whole
326 - /// buffer to the new width rather than reflowing it, so the invariant every
327 - /// reader depends on — `row()` yields `cols` cells — holds for history rows
328 - /// as much as for live ones.
328 + /// Every row here is exactly `cols` wide. `resize` rewraps the whole buffer
329 + /// to the new width and re-materializes it at that width, so the invariant
330 + /// every reader depends on — `row()` yields `cols` cells — holds for history
331 + /// rows as much as for live ones.
329 332 history: VecDeque<HistoryRow>,
330 333 /// How many rows `history` keeps before dropping its oldest.
331 334 history_limit: usize,
@@ -897,14 +900,20 @@
897 900 self.set_row_wrapped(r, false);
898 901 }
899 902
900 - /// Resize the grid, preserving as much of the top-left as fits. Truncates
901 - /// content outside the new bounds; does not reflow.
903 + /// Resize the grid, preserving as much of the top-left of the live screen
904 + /// as fits and rewrapping scrollback to the new width.
905 + ///
906 + /// The live screen is truncated, not reflowed: it is whatever an
907 + /// application last painted, and it is about to be told the new size and
908 + /// repaint. History has no one to repaint it, so it is rewrapped — see
909 + /// [`Grid::rewrap_history`].
902 910 pub fn resize(&mut self, cols: u16, rows: u16) {
903 911 let cols = cols.max(1);
904 912 let rows = rows.max(1);
905 913 if cols == self.cols && rows == self.rows {
906 914 return;
907 915 }
916 + let old_cols = self.cols;
908 917 self.main = resize_buf(
909 918 &self.main,
910 919 self.main_origin,
@@ -914,27 +923,12 @@
914 923 rows,
915 924 );
916 925 self.alt = resize_buf(&self.alt, self.alt_origin, self.cols, self.rows, cols, rows);
917 - // Resize does not reflow, so every recorded wrap point is now a lie
918 - // about where the text actually runs off the edge. Drop them all
919 - // rather than carry wrong ones into a copy.
926 + // The live screen is clipped rather than reflowed, so every recorded
927 + // wrap point on it is now a lie about where the text runs off the edge.
928 + // Drop them all rather than carry wrong ones into a copy. History keeps
929 + // its flags: the rewrap is what makes them true again.
920 930 self.main_wrapped = vec![false; rows as usize];
921 931 self.alt_wrapped = vec![false; rows as usize];
922 - // Scrollback is clipped or padded to the new width, never reflowed.
923 - // Reflow is the right answer for a history someone is reading and it is
924 - // also the classic source of terminal bugs, so it is a separate,
925 - // deliberate piece of work rather than a side effect of this one.
926 - //
927 - // Normalizing here rather than at render keeps the invariant every
928 - // reader leans on — a row is `cols` cells — in one place. The cost is
929 - // that narrowing the window truncates old wide lines for good.
930 - if cols != self.cols {
931 - for row in &mut self.history {
932 - row.cells.resize(cols as usize, Cell::default());
933 - // A wrap point recorded at the old width no longer says where
934 - // the text runs off the edge, same argument as the live rows.
935 - row.wrapped = false;
936 - }
937 - }
938 932 // The viewport survives a height change, but it cannot point further
939 933 // back than history goes.
940 934 self.view_offset = self.view_offset.min(self.history_len_u16());
@@ -943,6 +937,9 @@
943 937 self.region_origin = 0;
944 938 self.cols = cols;
945 939 self.rows = rows;
940 + if cols != old_cols {
941 + self.rewrap_history(old_cols);
942 + }
946 943 self.scroll_top = 0;
947 944 self.scroll_bottom = rows - 1;
948 945 self.cursor.row = self.cursor.row.min(rows - 1);
@@ -955,6 +952,141 @@
955 952 self.invalidate_cur_row();
956 953 }
957 954
955 + /// Rewrap scrollback from `old_cols` to the width already stored in
956 + /// `self.cols`.
957 + ///
958 + /// The logical lines are recoverable from the materialized rows, so this
959 + /// needs no second representation: a maximal run of `wrapped` rows plus the
960 + /// row that ends it is one line a program printed, and `wrapped` is correct
961 + /// at the moment a row is pushed. Join those runs, re-split at the new
962 + /// width, and history is still a deque of exactly-`cols` rows — `row()` and
963 + /// every reader above it (selection, word boundaries, copy) is untouched.
964 + ///
965 + /// The alternative, storing history as logical lines and materializing rows
966 + /// on read, moves the cost to every frame and puts variable-width rows in
967 + /// front of every reader to buy nothing this does not.
968 + ///
969 + /// Costs one pass and a transient second copy of the buffer, at resize
970 + /// only. That is ~24 MB at the default limit and 200 columns, held for the
971 + /// length of a window drag.
972 + fn rewrap_history(&mut self, old_cols: u16) {
973 + if self.history.is_empty() || old_cols == 0 {
974 + return;
975 + }
976 + let cols = self.cols as usize;
977 + // Absolute index of the row the viewport's top sits on, if it is back
978 + // in history at all. Carried through as (logical line, cells into it)
979 + // so the text under the user's eye stays under it.
980 + let anchor_row = self.history.len().saturating_sub(self.view_offset as usize);
981 + // The newest line may run onto the live screen. Recorded before the
982 + // walk, because re-splitting otherwise decides the final row's flag
983 + // from the line's length and would break that join.
984 + let tail_continues = self.history.back().is_some_and(|r| r.wrapped);
985 +
986 + let mut lines: Vec<Vec<Cell>> = Vec::new();
987 + let mut anchor: Option<(usize, usize)> = None;
988 + // Whether the row being visited continues the line already open.
989 + let mut open = false;
990 + for (i, row) in self.history.iter().enumerate() {
991 + if !open {
992 + lines.push(Vec::new());
993 + }
994 + let li = lines.len() - 1;
995 + let line = lines.last_mut().expect("a line is open by here");
996 + if i == anchor_row {
997 + anchor = Some((li, line.len()));
998 + }
999 + if row.wrapped {
1000 + // An interior row ran off the right edge, so it is full of
1001 + // content by construction — nothing on it is padding.
1002 + line.extend_from_slice(&row.cells);
1003 + } else {
1004 + // The last row of a line: its tail is padding, not content.
1005 + // Only never-written cells count as padding. A space someone
1006 + // typed is a cell like any other and keeps its background.
1007 + let end = row
1008 + .cells
1009 + .iter()
1010 + .rposition(|c| *c != Cell::default())
1011 + .map_or(0, |i| i + 1);
1012 + line.extend_from_slice(&row.cells[..end]);
1013 + }
1014 + open = row.wrapped;
1015 + }
1016 +
1017 + let last_line = lines.len() - 1;
1018 + let mut out: VecDeque<HistoryRow> = VecDeque::with_capacity(self.history.len());
1019 + let mut new_anchor: Option<usize> = None;
1020 + for (li, line) in lines.into_iter().enumerate() {
1021 + let first = out.len();
1022 + // Only the newest line can be unterminated, and only if it was
1023 + // running onto the live screen before the resize.
1024 + let unterminated = li == last_line && tail_continues;
1025 + if line.is_empty() {
1026 + // A blank line is content: someone's output had a gap in it.
1027 + // Never wrapped — an empty row holds nothing that could have
1028 + // run off the edge.
1029 + out.push_back(HistoryRow {
1030 + cells: vec![Cell::default(); cols],
1031 + wrapped: false,
1032 + });
1033 + } else {
1034 + // A line exactly `cols` long is one row, not a row plus an
1035 + // empty continuation — `div_ceil` of a multiple is the multiple.
1036 + let n = line.len().div_ceil(cols);
1037 + for (ci, chunk) in line.chunks(cols).enumerate() {
1038 + // A row is wrapped when it is full AND something follows.
1039 + // Interior rows are full by construction. The final row of
1040 + // an unterminated line only keeps the flag if it fills the
1041 + // new width too: the live screen it ran onto is clipped
1042 + // rather than reflowed, so a flag on a half-full row would
1043 + // be the same lie about where the text leaves the edge that
1044 + // the live rows drop theirs for, and would emit its padding
1045 + // as content on a copy.
1046 + let full = chunk.len() == cols;
1047 + let mut cells = chunk.to_vec();
1048 + cells.resize(cols, Cell::default());
1049 + out.push_back(HistoryRow {
1050 + cells,
1051 + wrapped: if ci + 1 < n {
1052 + true
1053 + } else {
1054 + unterminated && full
1055 + },
1056 + });
1057 + }
1058 + }
1059 + if let Some((al, off)) = anchor
1060 + && al == li
1061 + {
1062 + // Widening can put the anchor past the line's new end, in which
1063 + // case that line's last row is the closest thing to it.
1064 + new_anchor = Some((first + off / cols).min(out.len() - 1));
1065 + }
1066 + }
1067 + self.history = out;
1068 +
1069 + // Narrowing turns n rows into more than n, which can cross the limit.
1070 + // Trim after the rewrap and not before, so the trim never cuts a
1071 + // logical line in half and leaves its tail to be rewrapped alone.
1072 + let over = self.history.len().saturating_sub(self.history_limit);
1073 + self.history.drain(..over);
1074 +
1075 + let before = self.view_offset;
1076 + self.view_offset = match new_anchor {
1077 + // The anchored row itself can fall to the trim, and then the oldest
1078 + // surviving row is the closest the viewport can get to it.
1079 + Some(a) => {
1080 + let a = a.saturating_sub(over);
1081 + (self.history.len() - a).min(u16::MAX as usize) as u16
1082 + }
1083 + None => 0,
1084 + };
1085 + if self.view_offset != before {
1086 + self.view_dirty = true;
1087 + }
1088 + }
1089 +
958 1090 #[inline]
959 1091 fn invalidate_cur_row(&mut self) {
960 1092 self.cur_row_start = CUR_ROW_INVALID;
@@ -2491,30 +2623,140 @@
2491 2623 assert_eq!(g.history_len(), 0);
2492 2624 }
2493 2625
2626 + /// Everything in scrollback, as text. The round-trip property is stated
2627 + /// over this rather than over rows, because the rows are exactly what a
2628 + /// rewrap is allowed to change.
2629 + fn history_text(g: &Grid) -> String {
2630 + g.text_range(0, g.history_len())
2631 + }
2632 +
2494 2633 #[test]
2495 - fn history_is_clipped_to_the_new_width_rather_than_reflowed() {
2634 + fn narrowing_wraps_a_long_history_row_instead_of_clipping_it() {
2496 2635 let mut g = Grid::new(8, 2);
2497 2636 feed(&mut g, b"abcdefgh\r\nsecond\r\nthird");
2498 2637 g.scroll_view_up(1);
2499 2638 assert_eq!(row_str(&g, 0), "abcdefgh");
2500 2639 g.scroll_view_down(1);
2501 2640 g.resize(4, 2);
2502 - g.scroll_view_up(1);
2503 - // Narrowed, so the tail is gone for good. That is the cost of not
2504 - // reflowing, and it is the stated tradeoff rather than a bug.
2505 - assert_eq!(row_str(&g, 0), "abcd");
2641 + // The tail moved to a continuation row rather than being destroyed.
2506 2642 assert_eq!(g.row(0).len(), 4, "history rows must be `cols` wide");
2643 + assert!(history_text(&g).contains("abcdefgh"));
2507 2644 }
2508 2645
2509 2646 #[test]
2510 - fn widening_pads_history_so_every_row_is_cols_wide() {
2511 - let mut g = Grid::new(4, 2);
2512 - // Exactly one row off the top, so offset 1 is unambiguously "abcd".
2513 - feed(&mut g, b"abcd\r\nxy\r\nz");
2647 + fn narrowing_then_widening_gives_the_logical_lines_back() {
2648 + let mut g = Grid::new(8, 2);
2649 + feed(&mut g, b"abcdefgh\r\nsecond\r\nthird");
2650 + let before = history_text(&g);
2651 + g.resize(4, 2);
2514 2652 g.resize(8, 2);
2653 + // The property the clipping code could not satisfy at any width.
2654 + assert_eq!(history_text(&g), before);
2655 + }
2656 +
2657 + #[test]
2658 + fn a_line_exactly_cols_wide_gains_no_empty_continuation_row() {
2659 + let mut g = Grid::new(4, 2);
2660 + feed(&mut g, b"abcd\r\nxy\r\nz");
2661 + let rows = g.history_len();
2662 + g.resize(8, 2);
2663 + g.resize(4, 2);
2664 + assert_eq!(g.history_len(), rows, "a full row grew a continuation");
2665 + }
2666 +
2667 + #[test]
2668 + fn widening_rejoins_what_narrowing_split() {
2669 + let mut g = Grid::new(4, 2);
2670 + // "abcdefgh" wraps into two history rows at width 4.
2671 + feed(&mut g, b"abcdefgh\r\nxy\r\nz");
2672 + assert_eq!(g.history_len(), 2);
2673 + g.resize(8, 2);
2674 + assert_eq!(g.history_len(), 1, "the two halves did not rejoin");
2515 2675 g.scroll_view_up(1);
2516 2676 assert_eq!(g.row(0).len(), 8);
2517 - assert_eq!(row_str(&g, 0), "abcd");
2677 + assert_eq!(row_str(&g, 0), "abcdefgh");
2678 + }
2679 +
2680 + #[test]
2681 + fn a_blank_history_line_survives_a_rewrap() {
2682 + let mut g = Grid::new(8, 2);
2683 + feed(&mut g, b"one\r\n\r\ntwo\r\nthree");
2684 + let before = history_text(&g);
2685 + g.resize(4, 2);
2686 + g.resize(8, 2);
2687 + assert_eq!(history_text(&g), before, "the gap in the output closed");
2688 + }
2689 +
2690 + #[test]
2691 + fn narrowing_trims_to_the_limit_after_rewrapping_not_before() {
2692 + let mut g = Grid::new(8, 2);
2693 + g.set_history_limit(3);
2694 + feed(&mut g, b"abcdefgh\r\nijklmnop\r\nqrst\r\nuvwx\r\nlast");
2695 + g.resize(4, 2);
2696 + assert_eq!(g.history_len(), 3, "the limit did not hold across a rewrap");
2697 + // The newest rows survive and they are whole: the trim came after the
2698 + // rewrap, so no line was cut in half and its tail rewrapped alone.
2699 + assert!(
2700 + history_text(&g).ends_with("mnop\nqrst\n"),
2701 + "{:?}",
2702 + history_text(&g)
2703 + );
2704 + }
2705 +
2706 + #[test]
2707 + fn the_viewport_keeps_the_row_it_was_reading_across_a_rewrap() {
2708 + let mut g = Grid::new(8, 2);
2709 + feed(&mut g, b"aaaaaaaa\r\nbbbb\r\ncccc\r\ndddd\r\nlive");
2710 + g.scroll_view_up(2);
2711 + let reading = row_str(&g, 0);
2712 + assert_eq!(reading, "bbbb");
2713 + g.resize(4, 2);
2714 + assert_eq!(row_str(&g, 0), reading, "narrowing moved the text");
2715 + g.resize(8, 2);
2716 + assert_eq!(row_str(&g, 0), reading, "widening moved the text");
2717 + }
2718 +
2719 + #[test]
2720 + fn a_history_row_running_onto_the_live_screen_still_joins_after_a_rewrap() {
2721 + let mut g = Grid::new(8, 2);
2722 + // The oldest line runs off the edge and continues onto the live
2723 + // screen, so its wrap flag has to survive the rewrap.
2724 + feed(&mut g, b"abcdefghijklmnopqrst");
2725 + assert_eq!(g.history_len(), 1);
2726 + g.resize(4, 2);
2727 + let all = g.text_range(0, g.abs_rows());
2728 + assert!(
2729 + all.starts_with("abcdefghijkl"),
2730 + "the join to the live screen broke: {all:?}"
2731 + );
2732 + }
2733 +
2734 + #[test]
2735 + fn a_half_full_tail_row_drops_its_wrap_flag_rather_than_emit_padding() {
2736 + let mut g = Grid::new(4, 2);
2737 + // "abcd" is a full history row continuing onto the live screen. At
2738 + // width 8 it no longer reaches the edge, and the live screen it ran
2739 + // onto is clipped rather than reflowed, so the flag would be a lie —
2740 + // and would emit four cells of padding as content on a copy.
2741 + feed(&mut g, b"abcdefghijkl");
2742 + g.resize(8, 2);
2743 + assert!(
2744 + !history_text(&g).starts_with("abcd "),
2745 + "padding emitted as content: {:?}",
2746 + history_text(&g)
2747 + );
2748 + }
2749 +
2750 + #[test]
2751 + fn a_typed_space_at_the_end_of_a_line_is_content_not_padding() {
2752 + let mut g = Grid::new(8, 2);
2753 + // A trailing space inside a wrapped line is a real cell the text ran
2754 + // through; only never-written cells are padding.
2755 + feed(&mut g, b"ab cd ef gh\r\nxx\r\nyy");
2756 + let before = history_text(&g);
2757 + g.resize(4, 2);
2758 + g.resize(8, 2);
2759 + assert_eq!(history_text(&g), before);
2518 2760 }
2519 2761
2520 2762 #[test]