detached cc10813

Merge pull request #171 from bnema/fix/viewport-sync-after-delete

fix(vt): clear erased characters during viewport sync
commit
cc1081385397f18e0a5bdb1acb674bf01e4a0205
author
bnema · · 2026-08-03 07:54 UTC
committer
GitHub · · 2026-08-03 07:54 UTC
merge of
090a62a + bc06f3f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +87 -0
M pkg/vt/screen_csi.go +17 -0
@@ -99,6 +99,8 @@ func (s *Screen) applyCSI(params string, cmd byte) {
9999 mode = parts[0]
100100 }
101101 s.clearLineMode(mode)
102+ case 'X':
103+ s.eraseChars(firstPositive(parts, 1))
102104 case 'S':
103105 s.scrollUpBy(firstPositive(parts, 1))
104106 case 'T':
@@ -439,6 +441,21 @@ func (s *Screen) clearLineMode(mode int) {
439441 }
440442 }
441443
444+func (s *Screen) eraseChars(n int) {
445+ if n <= 0 {
446+ return
447+ }
448+ s.clampCursor()
449+ if remaining := s.Frame.Width - s.Col; n > remaining {
450+ n = remaining
451+ }
452+ end := s.Col + n
453+ start, width := s.clearRow(s.Row, s.Col, end)
454+ if width > 0 {
455+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
456+ }
457+}
458+
442459 func (s *Screen) clampCursor() {
443460 s.Row = clamp(s.Row, 0, s.Frame.Height-1)
444461 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
@@ -99,6 +99,8 @@ func (s *Screen) applyCSI(params string, cmd byte) {
99 mode = parts[0]99 mode = parts[0]
100 }100 }
101 s.clearLineMode(mode)101 s.clearLineMode(mode)
102+ case 'X':
103+ s.eraseChars(firstPositive(parts, 1))
102 case 'S':104 case 'S':
103 s.scrollUpBy(firstPositive(parts, 1))105 s.scrollUpBy(firstPositive(parts, 1))
104 case 'T':106 case 'T':
@@ -439,6 +441,21 @@ func (s *Screen) clearLineMode(mode int) {
439 }441 }
440 }442 }
441 443
444+func (s *Screen) eraseChars(n int) {
445+ if n <= 0 {
446+ return
447+ }
448+ s.clampCursor()
449+ if remaining := s.Frame.Width - s.Col; n > remaining {
450+ n = remaining
451+ }
452+ end := s.Col + n
453+ start, width := s.clearRow(s.Row, s.Col, end)
454+ if width > 0 {
455+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
456+ }
457+}
458+
442 func (s *Screen) clampCursor() {459 func (s *Screen) clampCursor() {
443 s.Row = clamp(s.Row, 0, s.Frame.Height-1)460 s.Row = clamp(s.Row, 0, s.Frame.Height-1)
444 s.Col = clamp(s.Col, 0, s.Frame.Width-1)461 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
M pkg/vt/screen_csi_test.go +70 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "strconv"
45 "testing"
56
67 "github.com/bnema/vev/pkg/renderer"
@@ -608,6 +609,75 @@ func TestClearAndErase(t *testing.T) {
608609 }
609610 }
610611
612+func TestEraseCharacters(t *testing.T) {
613+ maxInt := strconv.Itoa(int(^uint(0) >> 1))
614+ tests := []struct {
615+ name string
616+ width int
617+ row int
618+ initial string
619+ seq string
620+ wantRow string
621+ wantCol int
622+ wantDamage []renderer.Damage
623+ }{
624+ {
625+ name: "explicit count",
626+ width: 50,
627+ row: 1,
628+ initial: ` // scroll-method "on-button-down"`,
629+ seq: "\x1b[19Gbutton 273\x1b[13X",
630+ wantRow: " // scroll-button 273",
631+ wantCol: 28,
632+ wantDamage: []renderer.Damage{
633+ {Kind: renderer.DamageText, X: 18, Y: 1, Width: 10, Height: 1, Count: 1},
634+ {Kind: renderer.DamageClear, X: 28, Y: 1, Width: 13, Height: 1, Count: 1},
635+ },
636+ },
637+ {
638+ name: "omitted count erases exactly one cell",
639+ width: 6,
640+ row: 1,
641+ initial: "abcdef",
642+ seq: "\x1b[3G\x1b[X",
643+ wantRow: "ab def",
644+ wantCol: 2,
645+ wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 2, Y: 1, Width: 1, Height: 1, Count: 1}},
646+ },
647+ {
648+ name: "maximum count clips to screen edge",
649+ width: 6,
650+ row: 1,
651+ initial: "abcdef",
652+ seq: "\x1b[4G\x1b[" + maxInt + "X",
653+ wantRow: "abc",
654+ wantCol: 3,
655+ wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 3, Y: 1, Width: 3, Height: 1, Count: 1}},
656+ },
657+ }
658+
659+ for _, tt := range tests {
660+ t.Run(tt.name, func(t *testing.T) {
661+ s := NewScreen(tt.width, 2)
662+ s.Write([]byte("\x1b[2;1H"))
663+ s.Write([]byte(tt.initial))
664+ s.ClearDamage()
665+ s.Write([]byte(tt.seq))
666+
667+ for x := range tt.width {
668+ want := ' '
669+ if x < len(tt.wantRow) {
670+ want = rune(tt.wantRow[x])
671+ }
672+ require.Equal(t, want, cellAt(s, x, tt.row).Rune, "cell(%d,%d)", x, tt.row)
673+ }
674+ require.Equal(t, tt.row, s.Row)
675+ require.Equal(t, tt.wantCol, s.Col)
676+ require.Equal(t, tt.wantDamage, s.Damage())
677+ })
678+ }
679+}
680+
611681 func TestBackgroundColorErase(t *testing.T) {
612682 tests := []struct {
613683 name string
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "strconv"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
@@ -608,6 +609,75 @@ func TestClearAndErase(t *testing.T) {
608 }609 }
609 }610 }
610 611
612+func TestEraseCharacters(t *testing.T) {
613+ maxInt := strconv.Itoa(int(^uint(0) >> 1))
614+ tests := []struct {
615+ name string
616+ width int
617+ row int
618+ initial string
619+ seq string
620+ wantRow string
621+ wantCol int
622+ wantDamage []renderer.Damage
623+ }{
624+ {
625+ name: "explicit count",
626+ width: 50,
627+ row: 1,
628+ initial: ` // scroll-method "on-button-down"`,
629+ seq: "\x1b[19Gbutton 273\x1b[13X",
630+ wantRow: " // scroll-button 273",
631+ wantCol: 28,
632+ wantDamage: []renderer.Damage{
633+ {Kind: renderer.DamageText, X: 18, Y: 1, Width: 10, Height: 1, Count: 1},
634+ {Kind: renderer.DamageClear, X: 28, Y: 1, Width: 13, Height: 1, Count: 1},
635+ },
636+ },
637+ {
638+ name: "omitted count erases exactly one cell",
639+ width: 6,
640+ row: 1,
641+ initial: "abcdef",
642+ seq: "\x1b[3G\x1b[X",
643+ wantRow: "ab def",
644+ wantCol: 2,
645+ wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 2, Y: 1, Width: 1, Height: 1, Count: 1}},
646+ },
647+ {
648+ name: "maximum count clips to screen edge",
649+ width: 6,
650+ row: 1,
651+ initial: "abcdef",
652+ seq: "\x1b[4G\x1b[" + maxInt + "X",
653+ wantRow: "abc",
654+ wantCol: 3,
655+ wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 3, Y: 1, Width: 3, Height: 1, Count: 1}},
656+ },
657+ }
658+
659+ for _, tt := range tests {
660+ t.Run(tt.name, func(t *testing.T) {
661+ s := NewScreen(tt.width, 2)
662+ s.Write([]byte("\x1b[2;1H"))
663+ s.Write([]byte(tt.initial))
664+ s.ClearDamage()
665+ s.Write([]byte(tt.seq))
666+
667+ for x := range tt.width {
668+ want := ' '
669+ if x < len(tt.wantRow) {
670+ want = rune(tt.wantRow[x])
671+ }
672+ require.Equal(t, want, cellAt(s, x, tt.row).Rune, "cell(%d,%d)", x, tt.row)
673+ }
674+ require.Equal(t, tt.row, s.Row)
675+ require.Equal(t, tt.wantCol, s.Col)
676+ require.Equal(t, tt.wantDamage, s.Damage())
677+ })
678+ }
679+}
680+
611 func TestBackgroundColorErase(t *testing.T) {681 func TestBackgroundColorErase(t *testing.T) {
612 tests := []struct {682 tests := []struct {
613 name string683 name string

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 2

Tags 13

↑↓ moveenter openesc close