detached 0214273

Merge pull request #102 from bnema/fix/reflow-stale-soft-boundary

fix(vt): sever stale soft wrap boundaries on erase
commit
0214273e2149937dc721c69de427d6b50f118f03
author
bnema · · 2026-07-17 06:22 UTC
committer
GitHub · · 2026-07-17 06:22 UTC
merge of
239bcd7 + 83a3e26 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +36 -3
M pkg/vt/buffer.go +7 -1
@@ -76,8 +76,14 @@ func (b *buffer) clear(y, x0, x1 int) {
7676 return
7777 }
7878 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+ }
7985 if x0 == 0 && x1 >= b.frame.Width {
80- b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft}
86+ b.boundaries[y] = lineBoundary{}
8187 }
8288 }
8389
@@ -76,8 +76,14 @@ func (b *buffer) clear(y, x0, x1 int) {
76 return76 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) {
121121 },
122122 },
123123 {
124- name: "erase preserves a soft wrap boundary",
124+ name: "erase severs a soft wrap boundary",
125125 run: func(t *testing.T) {
126126 s := NewScreen(4, 2)
127127 s.Write([]byte("abcdef"))
128128 s.Write([]byte("\x1b[1;1H\x1b[2K"))
129129
130+ // Erasing through the right edge ends the logical line: the
131+ // blank head must not pull "ef" up a row on reflow.
130132 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 " {
132134 t.Fatalf("erased soft line reflow = %q", got)
133135 }
134136 },
135137 },
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+ },
136163 {
137164 name: "wide edge padding does not become logical content",
138165 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close