fix(vt): sever soft wrap boundary on erase to right edge
buffer.clear kept a stale soft wrap flag when a row was erased through the right edge (CUP + text + EL repaints, as done by docker buildx progress and shell prompt redraws). On the next resize, reflow merged the repainted row with the unrelated row below it, and a subsequent width shrink truncated the merged line, permanently deleting cells. Sever the soft continuation when an erase reaches the right edge, matching xterm/tmux semantics. Regression introduced by #98.
- commit
- 83a3e26ffd687ec7b90e1c5564fe78defc5a431e
- author
- brice · · 2026-07-17 06:19 UTC
- parent
- 239bcd7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +36 -3
M pkg/vt/buffer.go +7 -1
| @@ -76,8 +76,14 @@ func (b *buffer) clear(y, x0, x1 int) { | |||
| 76 | 76 | return | |
| 77 | 77 | } | |
| 78 | 78 | b.content(y, x1) | |
| 79 | + | if x1 >= b.frame.Width { | |
| 80 | + | // Erasing through the right edge leaves nothing flowing onto the next | |
| 81 | + | // row: the logical line ends here. Keeping a stale soft link would let | |
| 82 | + | // reflow merge a repainted row with the unrelated row below it. | |
| 83 | + | b.boundaries[y].soft = false | |
| 84 | + | } | |
| 79 | 85 | if x0 == 0 && x1 >= b.frame.Width { | |
| 80 | - | b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft} | |
| 86 | + | b.boundaries[y] = lineBoundary{} | |
| 81 | 87 | } | |
| 82 | 88 | } | |
| 83 | 89 | ||
| @@ -76,8 +76,14 @@ func (b *buffer) clear(y, x0, x1 int) { | |||||
| 76 | return | 76 | return | ||
| 77 | } | 77 | } | ||
| 78 | b.content(y, x1) | 78 | b.content(y, x1) | ||
| 79 | + | if x1 >= b.frame.Width { | |||
| 80 | + | // Erasing through the right edge leaves nothing flowing onto the next | |||
| 81 | + | // row: the logical line ends here. Keeping a stale soft link would let | |||
| 82 | + | // reflow merge a repainted row with the unrelated row below it. | |||
| 83 | + | b.boundaries[y].soft = false | |||
| 84 | + | } | |||
| 79 | if x0 == 0 && x1 >= b.frame.Width { | 85 | if x0 == 0 && x1 >= b.frame.Width { | ||
| 80 | - | b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft} | 86 | + | b.boundaries[y] = lineBoundary{} |
| 81 | } | 87 | } | ||
| 82 | } | 88 | } | ||
| 83 | 89 | ||||
M pkg/vt/screen_resize_test.go +29 -2
| @@ -121,18 +121,45 @@ func TestResize(t *testing.T) { | |||
| 121 | 121 | }, | |
| 122 | 122 | }, | |
| 123 | 123 | { | |
| 124 | - | name: "erase preserves a soft wrap boundary", | |
| 124 | + | name: "erase severs a soft wrap boundary", | |
| 125 | 125 | run: func(t *testing.T) { | |
| 126 | 126 | s := NewScreen(4, 2) | |
| 127 | 127 | s.Write([]byte("abcdef")) | |
| 128 | 128 | s.Write([]byte("\x1b[1;1H\x1b[2K")) | |
| 129 | 129 | ||
| 130 | + | // Erasing through the right edge ends the logical line: the | |
| 131 | + | // blank head must not pull "ef" up a row on reflow. | |
| 130 | 132 | s.Resize(3, 2) | |
| 131 | - | if got := rowString(s, 0); got != "ef " { | |
| 133 | + | if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != " ,ef " { | |
| 132 | 134 | t.Fatalf("erased soft line reflow = %q", got) | |
| 133 | 135 | } | |
| 134 | 136 | }, | |
| 135 | 137 | }, | |
| 138 | + | { | |
| 139 | + | name: "in-place repaint does not merge with the row below", | |
| 140 | + | run: func(t *testing.T) { | |
| 141 | + | // Status UIs (docker buildx, shell prompt redraws) repaint a | |
| 142 | + | // previously soft-wrapped row with CUP + text + EL and no | |
| 143 | + | // newline. The stale soft link must not glue the repainted row | |
| 144 | + | // to the unrelated row below: reflow would merge them, and the | |
| 145 | + | // next shrink would truncate the merged line, losing cells. | |
| 146 | + | s := NewScreen(40, 8) | |
| 147 | + | s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n")) | |
| 148 | + | s.Write([]byte("tail-line\r\n")) | |
| 149 | + | s.Write([]byte("\x1b[1;1HSHORT\x1b[K")) | |
| 150 | + | s.Write([]byte("\x1b[4;1H")) | |
| 151 | + | ||
| 152 | + | s.Resize(60, 8) | |
| 153 | + | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |
| 154 | + | t.Fatalf("grow rows = %q", got) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | s.Resize(40, 8) | |
| 158 | + | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |
| 159 | + | t.Fatalf("round-trip rows = %q", got) | |
| 160 | + | } | |
| 161 | + | }, | |
| 162 | + | }, | |
| 136 | 163 | { | |
| 137 | 164 | name: "wide edge padding does not become logical content", | |
| 138 | 165 | run: func(t *testing.T) { | |
| @@ -121,18 +121,45 @@ func TestResize(t *testing.T) { | |||||
| 121 | }, | 121 | }, | ||
| 122 | }, | 122 | }, | ||
| 123 | { | 123 | { | ||
| 124 | - | name: "erase preserves a soft wrap boundary", | 124 | + | name: "erase severs a soft wrap boundary", |
| 125 | run: func(t *testing.T) { | 125 | run: func(t *testing.T) { | ||
| 126 | s := NewScreen(4, 2) | 126 | s := NewScreen(4, 2) | ||
| 127 | s.Write([]byte("abcdef")) | 127 | s.Write([]byte("abcdef")) | ||
| 128 | s.Write([]byte("\x1b[1;1H\x1b[2K")) | 128 | s.Write([]byte("\x1b[1;1H\x1b[2K")) | ||
| 129 | 129 | ||||
| 130 | + | // Erasing through the right edge ends the logical line: the | |||
| 131 | + | // blank head must not pull "ef" up a row on reflow. | |||
| 130 | s.Resize(3, 2) | 132 | s.Resize(3, 2) | ||
| 131 | - | if got := rowString(s, 0); got != "ef " { | 133 | + | if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != " ,ef " { |
| 132 | t.Fatalf("erased soft line reflow = %q", got) | 134 | t.Fatalf("erased soft line reflow = %q", got) | ||
| 133 | } | 135 | } | ||
| 134 | }, | 136 | }, | ||
| 135 | }, | 137 | }, | ||
| 138 | + | { | |||
| 139 | + | name: "in-place repaint does not merge with the row below", | |||
| 140 | + | run: func(t *testing.T) { | |||
| 141 | + | // Status UIs (docker buildx, shell prompt redraws) repaint a | |||
| 142 | + | // previously soft-wrapped row with CUP + text + EL and no | |||
| 143 | + | // newline. The stale soft link must not glue the repainted row | |||
| 144 | + | // to the unrelated row below: reflow would merge them, and the | |||
| 145 | + | // next shrink would truncate the merged line, losing cells. | |||
| 146 | + | s := NewScreen(40, 8) | |||
| 147 | + | s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n")) | |||
| 148 | + | s.Write([]byte("tail-line\r\n")) | |||
| 149 | + | s.Write([]byte("\x1b[1;1HSHORT\x1b[K")) | |||
| 150 | + | s.Write([]byte("\x1b[4;1H")) | |||
| 151 | + | ||||
| 152 | + | s.Resize(60, 8) | |||
| 153 | + | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |||
| 154 | + | t.Fatalf("grow rows = %q", got) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | s.Resize(40, 8) | |||
| 158 | + | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |||
| 159 | + | t.Fatalf("round-trip rows = %q", got) | |||
| 160 | + | } | |||
| 161 | + | }, | |||
| 162 | + | }, | |||
| 136 | { | 163 | { | ||
| 137 | name: "wide edge padding does not become logical content", | 164 | name: "wide edge padding does not become logical content", | ||
| 138 | run: func(t *testing.T) { | 165 | run: func(t *testing.T) { | ||