detached 8e4df5c

perf(renderer): reuse cell styles and support downward scroll deltas (#29)

* perf(renderer): reuse cell styles and support downward scroll deltas

* fix(renderer): validate newly exposed scroll cells
commit
8e4df5c081447325640e4df6ee7558d6b7a6fd99
author
bnema · · 2026-09-05 12:36 UTC
committer
GitHub · · 2026-09-05 12:36 UTC
parent
31affde
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.5.1
10 files · +284 -15
M ansi/damage.go +1 -0
@@ -9,6 +9,7 @@ const (
99 DamageText = core.DamageText
1010 DamageClear = core.DamageClear
1111 DamageScrollUp = core.DamageScrollUp
12+ DamageScrollDown = core.DamageScrollDown
1213 DamageFullRedraw = core.DamageFullRedraw
1314 )
1415
@@ -9,6 +9,7 @@ const (
9 DamageText = core.DamageText9 DamageText = core.DamageText
10 DamageClear = core.DamageClear10 DamageClear = core.DamageClear
11 DamageScrollUp = core.DamageScrollUp11 DamageScrollUp = core.DamageScrollUp
12+ DamageScrollDown = core.DamageScrollDown
12 DamageFullRedraw = core.DamageFullRedraw13 DamageFullRedraw = core.DamageFullRedraw
13 )14 )
14 15
M ansi/delta_plan.go +8 -3
@@ -7,11 +7,12 @@ type Span struct {
77 Width int
88 }
99
10-// Scroll identifies a safe full-width upward scroll.
10+// Scroll identifies a safe full-width scroll.
1111 type Scroll struct {
1212 Y int
1313 Height int
1414 Count int
15+ Down bool
1516 }
1617
1718 // DeltaPlan describes how to advance a committed frame to the candidate frame.
@@ -66,7 +67,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
6667 var plan DeltaPlan
6768 var skip *Damage
6869 if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {
69- plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count}
70+ plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}
7071 skip = &scroll
7172 } else if hasScrollDamage(damage) {
7273 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
@@ -202,7 +203,11 @@ func (c DeltaCandidate) Commit(dst *Frame) {
202203 }
203204 if c.Plan.Scroll.Height != 0 {
204205 scroll := c.Plan.Scroll
205- dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
206+ if scroll.Down {
207+ dst.ScrollDown(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
208+ } else {
209+ dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
210+ }
206211 }
207212 for _, span := range c.Plan.Spans {
208213 for x := span.X; x < span.X+span.Width; x++ {
@@ -7,11 +7,12 @@ type Span struct {
7 Width int7 Width int
8 }8 }
9 9
10-// Scroll identifies a safe full-width upward scroll.10+// Scroll identifies a safe full-width scroll.
11 type Scroll struct {11 type Scroll struct {
12 Y int12 Y int
13 Height int13 Height int
14 Count int14 Count int
15+ Down bool
15 }16 }
16 17
17 // DeltaPlan describes how to advance a committed frame to the candidate frame.18 // DeltaPlan describes how to advance a committed frame to the candidate frame.
@@ -66,7 +67,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
66 var plan DeltaPlan67 var plan DeltaPlan
67 var skip *Damage68 var skip *Damage
68 if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {69 if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {
69- plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count}70+ plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}
70 skip = &scroll71 skip = &scroll
71 } else if hasScrollDamage(damage) {72 } else if hasScrollDamage(damage) {
72 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil73 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
@@ -202,7 +203,11 @@ func (c DeltaCandidate) Commit(dst *Frame) {
202 }203 }
203 if c.Plan.Scroll.Height != 0 {204 if c.Plan.Scroll.Height != 0 {
204 scroll := c.Plan.Scroll205 scroll := c.Plan.Scroll
205- dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)206+ if scroll.Down {
207+ dst.ScrollDown(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
208+ } else {
209+ dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
210+ }
206 }211 }
207 for _, span := range c.Plan.Spans {212 for _, span := range c.Plan.Spans {
208 for x := span.X; x < span.X+span.Width; x++ {213 for x := span.X; x < span.X+span.Width; x++ {
M ansi/renderer.go +6 -2
@@ -122,7 +122,11 @@ func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (Prepa
122122 } else {
123123 if plan.Scroll.Height != 0 {
124124 scroll := plan.Scroll
125- emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
125+ kind := DamageScrollUp
126+ if scroll.Down {
127+ kind = DamageScrollDown
128+ }
129+ emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
126130 }
127131 for _, span := range plan.Spans {
128132 r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
@@ -186,7 +190,7 @@ func needsFull(damage []Damage) bool {
186190
187191 func hasScrollDamage(damage []Damage) bool {
188192 for _, d := range damage {
189- if d.Kind == DamageScrollUp {
193+ if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown {
190194 return true
191195 }
192196 }
@@ -122,7 +122,11 @@ func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (Prepa
122 } else {122 } else {
123 if plan.Scroll.Height != 0 {123 if plan.Scroll.Height != 0 {
124 scroll := plan.Scroll124 scroll := plan.Scroll
125- emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})125+ kind := DamageScrollUp
126+ if scroll.Down {
127+ kind = DamageScrollDown
128+ }
129+ emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
126 }130 }
127 for _, span := range plan.Spans {131 for _, span := range plan.Spans {
128 r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)132 r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
@@ -186,7 +190,7 @@ func needsFull(damage []Damage) bool {
186 190
187 func hasScrollDamage(damage []Damage) bool {191 func hasScrollDamage(damage []Damage) bool {
188 for _, d := range damage {192 for _, d := range damage {
189- if d.Kind == DamageScrollUp {193+ if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown {
190 return true194 return true
191 }195 }
192 }196 }
M ansi/scroll.go +39 -6
@@ -7,7 +7,7 @@ import (
77
88 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
99 for _, d := range damage {
10- if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {
10+ if (d.Kind == DamageScrollUp || d.Kind == DamageScrollDown) && isSafeScroll(frame, d) {
1111 return d, true
1212 }
1313 }
@@ -18,7 +18,7 @@ func isSafeScroll(frame CellSource, d Damage) bool {
1818 return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height
1919 }
2020
21-func emitScrollUp(out *bytes.Buffer, d Damage) {
21+func emitScroll(out *bytes.Buffer, d Damage) {
2222 out.WriteString("\x1b[0m")
2323 // \x1b[%d;%dr — set scroll region
2424 out.WriteString("\x1b[")
@@ -29,6 +29,15 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
2929 n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10)
3030 out.Write(n)
3131 out.WriteByte('r')
32+ // SD scrolls down without moving content outside the chosen region.
33+ if d.Kind == DamageScrollDown {
34+ out.WriteString("\x1b[")
35+ n = strconv.AppendInt(b[:0], int64(d.Count), 10)
36+ out.Write(n)
37+ out.WriteByte('T')
38+ out.WriteString("\x1b[r")
39+ return
40+ }
3241 // Position cursor at bottom of scroll region.
3342 writeCursor(out, d.Y+d.Height-1, 0)
3443 if d.Count == 1 {
@@ -43,16 +52,32 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
4352 }
4453
4554 func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
55+ // Scrolling fills exposed rows with default-style blanks. Every target
56+ // cell that differs from that fill must be repainted, in either direction.
57+ exposed := scroll.Y + scroll.Height - scroll.Count
58+ if scroll.Kind == DamageScrollDown {
59+ exposed = scroll.Y
60+ }
61+ blank := BlankCell()
62+ for y := exposed; y < exposed+scroll.Count; y++ {
63+ for x := range scroll.Width {
64+ column := scroll.X + x
65+ if !damageCoversCell(damage, column, y) && !frame.Cell(column, y).Equal(blank) {
66+ return false
67+ }
68+ }
69+ }
4670 switch frame := frame.(type) {
4771 case Frame:
4872 return canApplyDenseScrollAgainst(frame, scroll, damage, committed)
4973 case *Frame:
5074 return canApplyDenseScrollAgainst(*frame, scroll, damage, committed)
5175 }
52- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
76+ start, end, offset := scrollRetainedRows(scroll)
77+ for y := start; y < end; y++ {
5378 for x := range scroll.Width {
5479 column := scroll.X + x
55- committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y)
80+ committedCell, frameCell := committed.Cell(column, y+offset), frame.Cell(column, y)
5681 if committedCell == frameCell || committedCell.Equal(frameCell) {
5782 continue
5883 }
@@ -65,10 +90,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
6590 }
6691
6792 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
68- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
93+ start, end, offset := scrollRetainedRows(scroll)
94+ for y := start; y < end; y++ {
6995 for x := range scroll.Width {
7096 column := scroll.X + x
71- committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y)
97+ committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)
7298 if committedCell == frameCell || committedCell.Equal(frameCell) {
7399 continue
74100 }
@@ -80,6 +106,13 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com
80106 return true
81107 }
82108
109+func scrollRetainedRows(scroll Damage) (start, end, offset int) {
110+ if scroll.Kind == DamageScrollDown {
111+ return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count
112+ }
113+ return scroll.Y, scroll.Y + scroll.Height - scroll.Count, scroll.Count
114+}
115+
83116 func damageCoversCell(damage []Damage, x, y int) bool {
84117 for _, d := range damage {
85118 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {
@@ -7,7 +7,7 @@ import (
7 7
8 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {8 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
9 for _, d := range damage {9 for _, d := range damage {
10- if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {10+ if (d.Kind == DamageScrollUp || d.Kind == DamageScrollDown) && isSafeScroll(frame, d) {
11 return d, true11 return d, true
12 }12 }
13 }13 }
@@ -18,7 +18,7 @@ func isSafeScroll(frame CellSource, d Damage) bool {
18 return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height18 return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height
19 }19 }
20 20
21-func emitScrollUp(out *bytes.Buffer, d Damage) {21+func emitScroll(out *bytes.Buffer, d Damage) {
22 out.WriteString("\x1b[0m")22 out.WriteString("\x1b[0m")
23 // \x1b[%d;%dr — set scroll region23 // \x1b[%d;%dr — set scroll region
24 out.WriteString("\x1b[")24 out.WriteString("\x1b[")
@@ -29,6 +29,15 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
29 n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10)29 n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10)
30 out.Write(n)30 out.Write(n)
31 out.WriteByte('r')31 out.WriteByte('r')
32+ // SD scrolls down without moving content outside the chosen region.
33+ if d.Kind == DamageScrollDown {
34+ out.WriteString("\x1b[")
35+ n = strconv.AppendInt(b[:0], int64(d.Count), 10)
36+ out.Write(n)
37+ out.WriteByte('T')
38+ out.WriteString("\x1b[r")
39+ return
40+ }
32 // Position cursor at bottom of scroll region.41 // Position cursor at bottom of scroll region.
33 writeCursor(out, d.Y+d.Height-1, 0)42 writeCursor(out, d.Y+d.Height-1, 0)
34 if d.Count == 1 {43 if d.Count == 1 {
@@ -43,16 +52,32 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
43 }52 }
44 53
45 func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {54 func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
55+ // Scrolling fills exposed rows with default-style blanks. Every target
56+ // cell that differs from that fill must be repainted, in either direction.
57+ exposed := scroll.Y + scroll.Height - scroll.Count
58+ if scroll.Kind == DamageScrollDown {
59+ exposed = scroll.Y
60+ }
61+ blank := BlankCell()
62+ for y := exposed; y < exposed+scroll.Count; y++ {
63+ for x := range scroll.Width {
64+ column := scroll.X + x
65+ if !damageCoversCell(damage, column, y) && !frame.Cell(column, y).Equal(blank) {
66+ return false
67+ }
68+ }
69+ }
46 switch frame := frame.(type) {70 switch frame := frame.(type) {
47 case Frame:71 case Frame:
48 return canApplyDenseScrollAgainst(frame, scroll, damage, committed)72 return canApplyDenseScrollAgainst(frame, scroll, damage, committed)
49 case *Frame:73 case *Frame:
50 return canApplyDenseScrollAgainst(*frame, scroll, damage, committed)74 return canApplyDenseScrollAgainst(*frame, scroll, damage, committed)
51 }75 }
52- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {76+ start, end, offset := scrollRetainedRows(scroll)
77+ for y := start; y < end; y++ {
53 for x := range scroll.Width {78 for x := range scroll.Width {
54 column := scroll.X + x79 column := scroll.X + x
55- committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y)80+ committedCell, frameCell := committed.Cell(column, y+offset), frame.Cell(column, y)
56 if committedCell == frameCell || committedCell.Equal(frameCell) {81 if committedCell == frameCell || committedCell.Equal(frameCell) {
57 continue82 continue
58 }83 }
@@ -65,10 +90,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
65 }90 }
66 91
67 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {92 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
68- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {93+ start, end, offset := scrollRetainedRows(scroll)
94+ for y := start; y < end; y++ {
69 for x := range scroll.Width {95 for x := range scroll.Width {
70 column := scroll.X + x96 column := scroll.X + x
71- committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y)97+ committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)
72 if committedCell == frameCell || committedCell.Equal(frameCell) {98 if committedCell == frameCell || committedCell.Equal(frameCell) {
73 continue99 continue
74 }100 }
@@ -80,6 +106,13 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com
80 return true106 return true
81 }107 }
82 108
109+func scrollRetainedRows(scroll Damage) (start, end, offset int) {
110+ if scroll.Kind == DamageScrollDown {
111+ return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count
112+ }
113+ return scroll.Y, scroll.Y + scroll.Height - scroll.Count, scroll.Count
114+}
115+
83 func damageCoversCell(damage []Damage, x, y int) bool {116 func damageCoversCell(damage []Damage, x, y int) bool {
84 for _, d := range damage {117 for _, d := range damage {
85 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {118 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {
A ansi/scroll_down_test.go +79 -0
@@ -0,0 +1,79 @@
1+package ansi_test
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+)
10+
11+func TestBidirectionalScrollReplay(t *testing.T) {
12+ for _, down := range []bool{false, true} {
13+ for _, count := range []int{1, 3} {
14+ frame := vt.NewFrame(12, 10)
15+ for y := range frame.Height {
16+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.Style{Foreground: y, Background: -1}})
17+ }
18+ r := ansi.New(ansi.Capabilities{})
19+ terminal := vt.NewScreen(frame.Width, frame.Height)
20+ initial, err := r.Draw(frame, nil)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ terminal.Write(initial)
25+ kind, exposed := vt.DamageScrollUp, 8-count
26+ if down {
27+ kind, exposed = vt.DamageScrollDown, 2
28+ frame.ScrollDown(2, 7, count)
29+ } else {
30+ frame.ScrollUp(2, 7, count)
31+ }
32+ for y := exposed; y < exposed+count; y++ {
33+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()})
34+ }
35+ damage := []vt.Damage{
36+ {Kind: kind, Y: 2, Width: 12, Height: 6, Count: count},
37+ {Kind: vt.DamageText, Y: exposed, Width: 12, Height: count},
38+ }
39+ prepared, err := r.Prepare(frame, damage, false)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if !bytes.Contains(prepared.Bytes(), []byte("\x1b[3;8r")) {
44+ t.Fatalf("missing scroll region: %q", prepared.Bytes())
45+ }
46+ terminal.Write(prepared.Bytes())
47+ prepared.Commit()
48+ for y := range frame.Height {
49+ for x := range frame.Width {
50+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
51+ t.Fatalf("down=%v count=%d cell %d,%d mismatch", down, count, x, y)
52+ }
53+ }
54+ }
55+ if err := frame.CheckInvariants(); err != nil {
56+ t.Fatal(err)
57+ }
58+ unchanged, err := r.Draw(frame, nil)
59+ if err != nil || len(unchanged) != 0 {
60+ t.Fatalf("committed shadow differs: %q, %v", unchanged, err)
61+ }
62+ }
63+ }
64+}
65+
66+func TestDownwardScrollMismatchFallsBack(t *testing.T) {
67+ committed := vt.NewFrame(10, 8)
68+ frame := committed.Clone()
69+ frame.Set(0, 4, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()})
70+ for _, width := range []int{9, 10} {
71+ candidate, err := ansi.PlanDelta(frame, []vt.Damage{{Kind: vt.DamageScrollDown, Y: 1, Width: width, Height: 6, Count: 1}}, committed, false)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if !candidate.Plan.Snapshot || candidate.Plan.Scroll.Height != 0 {
76+ t.Fatalf("unsafe scroll accepted: %+v", candidate.Plan)
77+ }
78+ }
79+}
@@ -0,0 +1,79 @@
1+package ansi_test
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+)
10+
11+func TestBidirectionalScrollReplay(t *testing.T) {
12+ for _, down := range []bool{false, true} {
13+ for _, count := range []int{1, 3} {
14+ frame := vt.NewFrame(12, 10)
15+ for y := range frame.Height {
16+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.Style{Foreground: y, Background: -1}})
17+ }
18+ r := ansi.New(ansi.Capabilities{})
19+ terminal := vt.NewScreen(frame.Width, frame.Height)
20+ initial, err := r.Draw(frame, nil)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ terminal.Write(initial)
25+ kind, exposed := vt.DamageScrollUp, 8-count
26+ if down {
27+ kind, exposed = vt.DamageScrollDown, 2
28+ frame.ScrollDown(2, 7, count)
29+ } else {
30+ frame.ScrollUp(2, 7, count)
31+ }
32+ for y := exposed; y < exposed+count; y++ {
33+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()})
34+ }
35+ damage := []vt.Damage{
36+ {Kind: kind, Y: 2, Width: 12, Height: 6, Count: count},
37+ {Kind: vt.DamageText, Y: exposed, Width: 12, Height: count},
38+ }
39+ prepared, err := r.Prepare(frame, damage, false)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if !bytes.Contains(prepared.Bytes(), []byte("\x1b[3;8r")) {
44+ t.Fatalf("missing scroll region: %q", prepared.Bytes())
45+ }
46+ terminal.Write(prepared.Bytes())
47+ prepared.Commit()
48+ for y := range frame.Height {
49+ for x := range frame.Width {
50+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
51+ t.Fatalf("down=%v count=%d cell %d,%d mismatch", down, count, x, y)
52+ }
53+ }
54+ }
55+ if err := frame.CheckInvariants(); err != nil {
56+ t.Fatal(err)
57+ }
58+ unchanged, err := r.Draw(frame, nil)
59+ if err != nil || len(unchanged) != 0 {
60+ t.Fatalf("committed shadow differs: %q, %v", unchanged, err)
61+ }
62+ }
63+ }
64+}
65+
66+func TestDownwardScrollMismatchFallsBack(t *testing.T) {
67+ committed := vt.NewFrame(10, 8)
68+ frame := committed.Clone()
69+ frame.Set(0, 4, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()})
70+ for _, width := range []int{9, 10} {
71+ candidate, err := ansi.PlanDelta(frame, []vt.Damage{{Kind: vt.DamageScrollDown, Y: 1, Width: width, Height: 6, Count: 1}}, committed, false)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if !candidate.Plan.Snapshot || candidate.Plan.Scroll.Height != 0 {
76+ t.Fatalf("unsafe scroll accepted: %+v", candidate.Plan)
77+ }
78+ }
79+}
A ansi/scroll_exposed_test.go +92 -0
@@ -0,0 +1,92 @@
1+package ansi_test
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+)
10+
11+// An embedded source exercises the generic path rather than Frame dispatch.
12+type scrollCellSource struct{ vt.CellSource }
13+
14+func TestScrollExposedCellsRequireBlankOrRepaint(t *testing.T) {
15+ for _, down := range []bool{false, true} {
16+ for _, sourceKind := range []string{"frame", "pointer", "generic"} {
17+ for _, tc := range []struct {
18+ name string
19+ cell vt.Cell
20+ coverage int
21+ kind vt.DamageKind
22+ fallback bool
23+ }{
24+ {"blank", vt.BlankCell(), 0, vt.DamageText, false},
25+ {"uncovered text", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 0, vt.DamageText, true},
26+ {"uncovered styled blank", vt.Cell{Rune: ' ', Style: vt.Style{Bold: true}}, 0, vt.DamageText, true},
27+ {"partial coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 1, vt.DamageText, true},
28+ {"text coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageText, false},
29+ {"clear coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageClear, false},
30+ } {
31+ t.Run(fmt.Sprintf("down=%v/%s/%s", down, sourceKind, tc.name), func(t *testing.T) {
32+ committed := vt.NewFrame(12, 10)
33+ for y := range committed.Height {
34+ committed.FillRow(y, 0, committed.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
35+ }
36+ frame := committed.Clone()
37+ kind, exposed := vt.DamageScrollUp, 6
38+ if down {
39+ kind, exposed = vt.DamageScrollDown, 2
40+ frame.ScrollDown(2, 7, 2)
41+ } else {
42+ frame.ScrollUp(2, 7, 2)
43+ }
44+ for y := exposed; y < exposed+2; y++ {
45+ frame.Set(0, y, tc.cell)
46+ }
47+ damage := []vt.Damage{{Kind: kind, Y: 2, Width: 12, Height: 6, Count: 2}}
48+ if tc.coverage > 0 {
49+ damage = append(damage, vt.Damage{Kind: tc.kind, Y: exposed, Width: 12, Height: tc.coverage})
50+ }
51+ var source vt.CellSource = frame
52+ switch sourceKind {
53+ case "pointer":
54+ source = &frame
55+ case "generic":
56+ source = scrollCellSource{frame}
57+ }
58+ candidate, err := ansi.PlanDelta(source, damage, committed, false)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if candidate.Plan.Snapshot != tc.fallback {
63+ t.Fatalf("plan = %+v, want fallback %v", candidate.Plan, tc.fallback)
64+ }
65+ r := ansi.New(ansi.Capabilities{})
66+ terminal := vt.NewScreen(12, 10)
67+ initial, err := r.Draw(committed, nil)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ terminal.Write(initial)
72+ data, err := r.Draw(source, damage)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ terminal.Write(data)
77+ for y := range frame.Height {
78+ for x := range frame.Width {
79+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
80+ t.Fatalf("replay differs at %d,%d", x, y)
81+ }
82+ }
83+ }
84+ data, err = r.Draw(source, nil)
85+ if err != nil || len(data) != 0 {
86+ t.Fatalf("committed shadow differs: %q, %v", data, err)
87+ }
88+ })
89+ }
90+ }
91+ }
92+}
@@ -0,0 +1,92 @@
1+package ansi_test
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+)
10+
11+// An embedded source exercises the generic path rather than Frame dispatch.
12+type scrollCellSource struct{ vt.CellSource }
13+
14+func TestScrollExposedCellsRequireBlankOrRepaint(t *testing.T) {
15+ for _, down := range []bool{false, true} {
16+ for _, sourceKind := range []string{"frame", "pointer", "generic"} {
17+ for _, tc := range []struct {
18+ name string
19+ cell vt.Cell
20+ coverage int
21+ kind vt.DamageKind
22+ fallback bool
23+ }{
24+ {"blank", vt.BlankCell(), 0, vt.DamageText, false},
25+ {"uncovered text", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 0, vt.DamageText, true},
26+ {"uncovered styled blank", vt.Cell{Rune: ' ', Style: vt.Style{Bold: true}}, 0, vt.DamageText, true},
27+ {"partial coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 1, vt.DamageText, true},
28+ {"text coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageText, false},
29+ {"clear coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageClear, false},
30+ } {
31+ t.Run(fmt.Sprintf("down=%v/%s/%s", down, sourceKind, tc.name), func(t *testing.T) {
32+ committed := vt.NewFrame(12, 10)
33+ for y := range committed.Height {
34+ committed.FillRow(y, 0, committed.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
35+ }
36+ frame := committed.Clone()
37+ kind, exposed := vt.DamageScrollUp, 6
38+ if down {
39+ kind, exposed = vt.DamageScrollDown, 2
40+ frame.ScrollDown(2, 7, 2)
41+ } else {
42+ frame.ScrollUp(2, 7, 2)
43+ }
44+ for y := exposed; y < exposed+2; y++ {
45+ frame.Set(0, y, tc.cell)
46+ }
47+ damage := []vt.Damage{{Kind: kind, Y: 2, Width: 12, Height: 6, Count: 2}}
48+ if tc.coverage > 0 {
49+ damage = append(damage, vt.Damage{Kind: tc.kind, Y: exposed, Width: 12, Height: tc.coverage})
50+ }
51+ var source vt.CellSource = frame
52+ switch sourceKind {
53+ case "pointer":
54+ source = &frame
55+ case "generic":
56+ source = scrollCellSource{frame}
57+ }
58+ candidate, err := ansi.PlanDelta(source, damage, committed, false)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if candidate.Plan.Snapshot != tc.fallback {
63+ t.Fatalf("plan = %+v, want fallback %v", candidate.Plan, tc.fallback)
64+ }
65+ r := ansi.New(ansi.Capabilities{})
66+ terminal := vt.NewScreen(12, 10)
67+ initial, err := r.Draw(committed, nil)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ terminal.Write(initial)
72+ data, err := r.Draw(source, damage)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ terminal.Write(data)
77+ for y := range frame.Height {
78+ for x := range frame.Width {
79+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
80+ t.Fatalf("replay differs at %d,%d", x, y)
81+ }
82+ }
83+ }
84+ data, err = r.Draw(source, nil)
85+ if err != nil || len(data) != 0 {
86+ t.Fatalf("committed shadow differs: %q, %v", data, err)
87+ }
88+ })
89+ }
90+ }
91+ }
92+}
M core/damage.go +1 -0
@@ -7,6 +7,7 @@ const (
77 DamageClear
88 DamageScrollUp
99 DamageFullRedraw
10+ DamageScrollDown
1011 )
1112
1213 type Damage struct {
@@ -7,6 +7,7 @@ const (
7 DamageClear7 DamageClear
8 DamageScrollUp8 DamageScrollUp
9 DamageFullRedraw9 DamageFullRedraw
10+ DamageScrollDown
10 )11 )
11 12
12 type Damage struct {13 type Damage struct {
M core/frame.go +5 -4
@@ -261,10 +261,11 @@ func (f Frame) Set(x, y int, cell Cell) {
261261 payloadID := f.internPayload(cell.Payload)
262262 f.releasePayload(f.page.cells[index].payloadID)
263263 oldID := f.page.cells[index].styleID
264- styleID := f.internStyle(cell.Style)
265- if oldID == styleID {
266- f.releaseStyle(styleID) // cancel internStyle's temporary reference
267- } else {
264+ styleID := oldID
265+ // Repainting a cell usually keeps its style. Compare the canonical value
266+ // before hashing it and changing references in the page-local dictionary.
267+ if f.page.styles[oldID].style != cell.Style.Canonical() {
268+ styleID = f.internStyle(cell.Style)
268269 f.releaseStyle(oldID)
269270 }
270271 flags := uint8(0)
@@ -261,10 +261,11 @@ func (f Frame) Set(x, y int, cell Cell) {
261 payloadID := f.internPayload(cell.Payload)261 payloadID := f.internPayload(cell.Payload)
262 f.releasePayload(f.page.cells[index].payloadID)262 f.releasePayload(f.page.cells[index].payloadID)
263 oldID := f.page.cells[index].styleID263 oldID := f.page.cells[index].styleID
264- styleID := f.internStyle(cell.Style)264+ styleID := oldID
265- if oldID == styleID {265+ // Repainting a cell usually keeps its style. Compare the canonical value
266- f.releaseStyle(styleID) // cancel internStyle's temporary reference266+ // before hashing it and changing references in the page-local dictionary.
267- } else {267+ if f.page.styles[oldID].style != cell.Style.Canonical() {
268+ styleID = f.internStyle(cell.Style)
268 f.releaseStyle(oldID)269 f.releaseStyle(oldID)
269 }270 }
270 flags := uint8(0)271 flags := uint8(0)
A core/frame_style_reuse_test.go +52 -0
@@ -0,0 +1,52 @@
1+package core
2+
3+import "testing"
4+
5+func TestSetStyleReuse(t *testing.T) {
6+ for _, rotated := range []bool{false, true} {
7+ f := NewFrame(3, 2)
8+ if rotated {
9+ f.ScrollUp(0, 1, 1)
10+ }
11+ styles := []Style{DefaultStyle(), {Bold: true}, {Italic: true}, {Bold: true}, DefaultStyle()}
12+ for _, style := range styles {
13+ for repeat := range 3 {
14+ for x := range f.Width {
15+ cell := Cell{Rune: rune('a' + repeat), Style: style}
16+ // Inactive RGB fields differ without changing canonical style.
17+ cell.Style.ForegroundRGB = RGB{R: uint8(repeat + 1)}
18+ index := f.offset(x, 0)
19+ oldID := f.page.cells[index].styleID
20+ sameStyle := f.page.styles[oldID].style == style.Canonical()
21+ f.Set(x, 0, cell)
22+ if got := f.page.cells[index].styleID; sameStyle && got != oldID {
23+ t.Fatalf("equivalent style changed ID from %d to %d", oldID, got)
24+ }
25+ if got := f.Cell(x, 0); !got.Equal(cell) {
26+ t.Fatalf("cell = %+v, want %+v", got, cell)
27+ }
28+ if err := f.CheckInvariants(); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ }
33+ }
34+ }
35+}
36+
37+func BenchmarkFrameRewriteSameStyle(b *testing.B) {
38+ f := NewFrame(182, 53)
39+ row := make([]Cell, f.Width)
40+ for x := range row {
41+ row[x] = Cell{Rune: 'x', Style: Style{Bold: true}}
42+ }
43+ for y := range f.Height {
44+ f.WriteRow(y, 0, row)
45+ }
46+ b.ReportAllocs()
47+ for b.Loop() {
48+ for y := range f.Height {
49+ f.WriteRow(y, 0, row)
50+ }
51+ }
52+}
@@ -0,0 +1,52 @@
1+package core
2+
3+import "testing"
4+
5+func TestSetStyleReuse(t *testing.T) {
6+ for _, rotated := range []bool{false, true} {
7+ f := NewFrame(3, 2)
8+ if rotated {
9+ f.ScrollUp(0, 1, 1)
10+ }
11+ styles := []Style{DefaultStyle(), {Bold: true}, {Italic: true}, {Bold: true}, DefaultStyle()}
12+ for _, style := range styles {
13+ for repeat := range 3 {
14+ for x := range f.Width {
15+ cell := Cell{Rune: rune('a' + repeat), Style: style}
16+ // Inactive RGB fields differ without changing canonical style.
17+ cell.Style.ForegroundRGB = RGB{R: uint8(repeat + 1)}
18+ index := f.offset(x, 0)
19+ oldID := f.page.cells[index].styleID
20+ sameStyle := f.page.styles[oldID].style == style.Canonical()
21+ f.Set(x, 0, cell)
22+ if got := f.page.cells[index].styleID; sameStyle && got != oldID {
23+ t.Fatalf("equivalent style changed ID from %d to %d", oldID, got)
24+ }
25+ if got := f.Cell(x, 0); !got.Equal(cell) {
26+ t.Fatalf("cell = %+v, want %+v", got, cell)
27+ }
28+ if err := f.CheckInvariants(); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ }
33+ }
34+ }
35+}
36+
37+func BenchmarkFrameRewriteSameStyle(b *testing.B) {
38+ f := NewFrame(182, 53)
39+ row := make([]Cell, f.Width)
40+ for x := range row {
41+ row[x] = Cell{Rune: 'x', Style: Style{Bold: true}}
42+ }
43+ for y := range f.Height {
44+ f.WriteRow(y, 0, row)
45+ }
46+ b.ReportAllocs()
47+ for b.Loop() {
48+ for y := range f.Height {
49+ f.WriteRow(y, 0, row)
50+ }
51+ }
52+}
M core_model.go +1 -0
@@ -33,6 +33,7 @@ const (
3333 DamageText = core.DamageText
3434 DamageClear = core.DamageClear
3535 DamageScrollUp = core.DamageScrollUp
36+ DamageScrollDown = core.DamageScrollDown
3637 DamageFullRedraw = core.DamageFullRedraw
3738 )
3839
@@ -33,6 +33,7 @@ const (
33 DamageText = core.DamageText33 DamageText = core.DamageText
34 DamageClear = core.DamageClear34 DamageClear = core.DamageClear
35 DamageScrollUp = core.DamageScrollUp35 DamageScrollUp = core.DamageScrollUp
36+ DamageScrollDown = core.DamageScrollDown
36 DamageFullRedraw = core.DamageFullRedraw37 DamageFullRedraw = core.DamageFullRedraw
37 )38 )
38 39

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