detached a3000d8

Merge pull request #94 from bnema/fix/canonical-renderer-damage

fix(renderer): canonicalize incremental damage emission
commit
a3000d839a513588de426d9a479390cb23a9dbef
author
bnema · · 2026-07-15 08:23 UTC
committer
GitHub · · 2026-07-15 08:23 UTC
merge of
464c9e0 + e74381d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +557 -27
M pkg/renderer/benchmark_test.go +45 -0
@@ -31,6 +31,51 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
3131 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
3232 }
3333
34+func BenchmarkRendererFragmentedDamage(b *testing.B) {
35+ const (
36+ width = 120
37+ height = 40
38+ )
39+
40+ baseline := NewFrame(width, height)
41+ markBenchmarkFrame(&baseline)
42+ frame := NewFrame(width, height)
43+ markBenchmarkFrame(&frame)
44+
45+ // Create 256 overlapping or vertically adjacent text rectangles in reverse
46+ // row/column order. Canonical damage planning must sort and merge these into
47+ // stable per-row spans before emission.
48+ damage := make([]Damage, 0, 256)
49+ for row := 15; row >= 0; row-- {
50+ for col := 15; col >= 0; col-- {
51+ x, y := col*7, row*2
52+ damage = append(damage, Damage{Kind: DamageText, X: x, Y: y, Width: 8, Height: 2})
53+ for dy := range 2 {
54+ for dx := range 8 {
55+ frame.Set(x+dx, y+dy, Cell{Rune: 'z', Style: DefaultStyle()})
56+ }
57+ }
58+ }
59+ }
60+
61+ r := New(Capabilities{})
62+ b.ReportAllocs()
63+ var outBytes int64
64+ for b.Loop() {
65+ // Each iteration starts with the identical pre-update terminal shadow.
66+ r.replaceShadow(baseline)
67+ out, err := r.Draw(frame, damage)
68+ if err != nil {
69+ b.Fatal(err)
70+ }
71+ if len(out) == 0 {
72+ b.Fatal("expected renderer output")
73+ }
74+ outBytes += int64(len(out))
75+ }
76+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
77+}
78+
3479 func BenchmarkRendererScrollFastPath(b *testing.B) {
3580 frame := NewFrame(120, 40)
3681 markBenchmarkFrame(&frame)
@@ -31,6 +31,51 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
31 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")31 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
32 }32 }
33 33
34+func BenchmarkRendererFragmentedDamage(b *testing.B) {
35+ const (
36+ width = 120
37+ height = 40
38+ )
39+
40+ baseline := NewFrame(width, height)
41+ markBenchmarkFrame(&baseline)
42+ frame := NewFrame(width, height)
43+ markBenchmarkFrame(&frame)
44+
45+ // Create 256 overlapping or vertically adjacent text rectangles in reverse
46+ // row/column order. Canonical damage planning must sort and merge these into
47+ // stable per-row spans before emission.
48+ damage := make([]Damage, 0, 256)
49+ for row := 15; row >= 0; row-- {
50+ for col := 15; col >= 0; col-- {
51+ x, y := col*7, row*2
52+ damage = append(damage, Damage{Kind: DamageText, X: x, Y: y, Width: 8, Height: 2})
53+ for dy := range 2 {
54+ for dx := range 8 {
55+ frame.Set(x+dx, y+dy, Cell{Rune: 'z', Style: DefaultStyle()})
56+ }
57+ }
58+ }
59+ }
60+
61+ r := New(Capabilities{})
62+ b.ReportAllocs()
63+ var outBytes int64
64+ for b.Loop() {
65+ // Each iteration starts with the identical pre-update terminal shadow.
66+ r.replaceShadow(baseline)
67+ out, err := r.Draw(frame, damage)
68+ if err != nil {
69+ b.Fatal(err)
70+ }
71+ if len(out) == 0 {
72+ b.Fatal("expected renderer output")
73+ }
74+ outBytes += int64(len(out))
75+ }
76+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
77+}
78+
34 func BenchmarkRendererScrollFastPath(b *testing.B) {79 func BenchmarkRendererScrollFastPath(b *testing.B) {
35 frame := NewFrame(120, 40)80 frame := NewFrame(120, 40)
36 markBenchmarkFrame(&frame)81 markBenchmarkFrame(&frame)
A pkg/renderer/damage_plan.go +73 -0
@@ -0,0 +1,73 @@
1+package renderer
2+
3+import (
4+ "cmp"
5+ "slices"
6+)
7+
8+const maxPlannedDamageSpans = 4096
9+
10+type damageSpan struct {
11+ y, x, width int
12+}
13+
14+// buildDamagePlan creates the bounded, canonical view of text and clear damage
15+// used for terminal emission. The source damage remains untouched because the
16+// scroll and shadow paths need its original structural information.
17+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {
18+ spans := make([]damageSpan, 0)
19+ for _, d := range damage {
20+ if skip != nil && sameDamage(d, *skip) {
21+ continue
22+ }
23+ if d.Kind != DamageText && d.Kind != DamageClear {
24+ continue
25+ }
26+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
27+ if !ok {
28+ continue
29+ }
30+ for row := y; row < y+height; row++ {
31+ if len(spans) == maxPlannedDamageSpans {
32+ return nil, true
33+ }
34+ spans = append(spans, damageSpan{y: row, x: x, width: width})
35+ }
36+ }
37+
38+ slices.SortFunc(spans, func(a, b damageSpan) int {
39+ if c := cmp.Compare(a.y, b.y); c != 0 {
40+ return c
41+ }
42+ return cmp.Compare(a.x, b.x)
43+ })
44+
45+ return mergeDamageSpans(spans), false
46+}
47+
48+func mergeDamageSpans(spans []damageSpan) []damageSpan {
49+ if len(spans) < 2 {
50+ return spans
51+ }
52+
53+ out := spans[:0]
54+ for _, span := range spans {
55+ if len(out) == 0 {
56+ out = append(out, span)
57+ continue
58+ }
59+ last := &out[len(out)-1]
60+ // Both endpoints are bounded by frame.Width after clampRect, so these
61+ // additions cannot overflow. The comparison includes adjacency.
62+ lastEnd := last.x + last.width
63+ spanEnd := span.x + span.width
64+ if span.y != last.y || span.x > lastEnd {
65+ out = append(out, span)
66+ continue
67+ }
68+ if spanEnd > lastEnd {
69+ last.width = spanEnd - last.x
70+ }
71+ }
72+ return out
73+}
@@ -0,0 +1,73 @@
1+package renderer
2+
3+import (
4+ "cmp"
5+ "slices"
6+)
7+
8+const maxPlannedDamageSpans = 4096
9+
10+type damageSpan struct {
11+ y, x, width int
12+}
13+
14+// buildDamagePlan creates the bounded, canonical view of text and clear damage
15+// used for terminal emission. The source damage remains untouched because the
16+// scroll and shadow paths need its original structural information.
17+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {
18+ spans := make([]damageSpan, 0)
19+ for _, d := range damage {
20+ if skip != nil && sameDamage(d, *skip) {
21+ continue
22+ }
23+ if d.Kind != DamageText && d.Kind != DamageClear {
24+ continue
25+ }
26+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
27+ if !ok {
28+ continue
29+ }
30+ for row := y; row < y+height; row++ {
31+ if len(spans) == maxPlannedDamageSpans {
32+ return nil, true
33+ }
34+ spans = append(spans, damageSpan{y: row, x: x, width: width})
35+ }
36+ }
37+
38+ slices.SortFunc(spans, func(a, b damageSpan) int {
39+ if c := cmp.Compare(a.y, b.y); c != 0 {
40+ return c
41+ }
42+ return cmp.Compare(a.x, b.x)
43+ })
44+
45+ return mergeDamageSpans(spans), false
46+}
47+
48+func mergeDamageSpans(spans []damageSpan) []damageSpan {
49+ if len(spans) < 2 {
50+ return spans
51+ }
52+
53+ out := spans[:0]
54+ for _, span := range spans {
55+ if len(out) == 0 {
56+ out = append(out, span)
57+ continue
58+ }
59+ last := &out[len(out)-1]
60+ // Both endpoints are bounded by frame.Width after clampRect, so these
61+ // additions cannot overflow. The comparison includes adjacency.
62+ lastEnd := last.x + last.width
63+ spanEnd := span.x + span.width
64+ if span.y != last.y || span.x > lastEnd {
65+ out = append(out, span)
66+ continue
67+ }
68+ if spanEnd > lastEnd {
69+ last.width = spanEnd - last.x
70+ }
71+ }
72+ return out
73+}
A pkg/renderer/damage_plan_test.go +110 -0
@@ -0,0 +1,110 @@
1+package renderer
2+
3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestBuildDamagePlan(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ frame Frame
13+ damage []Damage
14+ want []damageSpan
15+ full bool
16+ }{
17+ {
18+ name: "merges reverse overlaps",
19+ frame: NewFrame(10, 2),
20+ damage: []Damage{
21+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23+ },
24+ want: []damageSpan{{y: 0, x: 0, width: 9}},
25+ },
26+ {
27+ name: "merges text and clear",
28+ frame: NewFrame(10, 1),
29+ damage: []Damage{
30+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32+ },
33+ want: []damageSpan{{y: 0, x: 0, width: 9}},
34+ },
35+ {
36+ name: "orders multi-row spans",
37+ frame: NewFrame(10, 3),
38+ damage: []Damage{
39+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
40+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
41+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43+ },
44+ want: []damageSpan{
45+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
46+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
47+ {y: 2, x: 5, width: 2},
48+ },
49+ },
50+ {
51+ name: "clamps rectangles",
52+ frame: NewFrame(10, 3),
53+ damage: []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ },
57+ want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},
58+ },
59+ {
60+ name: "does not mutate input",
61+ frame: NewFrame(10, 2),
62+ damage: []Damage{
63+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65+ },
66+ want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},
67+ },
68+ {
69+ name: "exceeding budget requests full redraw",
70+ frame: NewFrame(1, maxPlannedDamageSpans+1),
71+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
72+ want: nil,
73+ full: true,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ original := append([]Damage(nil), tt.damage...)
80+ got, full := buildDamagePlan(tt.frame, tt.damage, nil)
81+ if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83+ }
84+ if !reflect.DeepEqual(tt.damage, original) {
85+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original)
86+ }
87+ })
88+ }
89+}
90+
91+func TestClampRectRejectsOverflowingBounds(t *testing.T) {
92+ frame := NewFrame(10, 10)
93+ if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok {
94+ t.Fatal("clampRect accepted overflowing x bound")
95+ }
96+ if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok {
97+ t.Fatal("clampRect accepted overflowing y bound")
98+ }
99+}
100+
101+func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
102+ r := New(Capabilities{})
103+ frame := NewFrame(2, 1)
104+ r.replaceShadow(frame)
105+ before := append([]Cell(nil), r.shadow...)
106+ r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
107+ if !reflect.DeepEqual(r.shadow, before) {
108+ t.Fatal("syncRect changed shadow for overflowing bounds")
109+ }
110+}
@@ -0,0 +1,110 @@
1+package renderer
2+
3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestBuildDamagePlan(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ frame Frame
13+ damage []Damage
14+ want []damageSpan
15+ full bool
16+ }{
17+ {
18+ name: "merges reverse overlaps",
19+ frame: NewFrame(10, 2),
20+ damage: []Damage{
21+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23+ },
24+ want: []damageSpan{{y: 0, x: 0, width: 9}},
25+ },
26+ {
27+ name: "merges text and clear",
28+ frame: NewFrame(10, 1),
29+ damage: []Damage{
30+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32+ },
33+ want: []damageSpan{{y: 0, x: 0, width: 9}},
34+ },
35+ {
36+ name: "orders multi-row spans",
37+ frame: NewFrame(10, 3),
38+ damage: []Damage{
39+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
40+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
41+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43+ },
44+ want: []damageSpan{
45+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
46+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
47+ {y: 2, x: 5, width: 2},
48+ },
49+ },
50+ {
51+ name: "clamps rectangles",
52+ frame: NewFrame(10, 3),
53+ damage: []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ },
57+ want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},
58+ },
59+ {
60+ name: "does not mutate input",
61+ frame: NewFrame(10, 2),
62+ damage: []Damage{
63+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65+ },
66+ want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},
67+ },
68+ {
69+ name: "exceeding budget requests full redraw",
70+ frame: NewFrame(1, maxPlannedDamageSpans+1),
71+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
72+ want: nil,
73+ full: true,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ original := append([]Damage(nil), tt.damage...)
80+ got, full := buildDamagePlan(tt.frame, tt.damage, nil)
81+ if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83+ }
84+ if !reflect.DeepEqual(tt.damage, original) {
85+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original)
86+ }
87+ })
88+ }
89+}
90+
91+func TestClampRectRejectsOverflowingBounds(t *testing.T) {
92+ frame := NewFrame(10, 10)
93+ if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok {
94+ t.Fatal("clampRect accepted overflowing x bound")
95+ }
96+ if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok {
97+ t.Fatal("clampRect accepted overflowing y bound")
98+ }
99+}
100+
101+func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
102+ r := New(Capabilities{})
103+ frame := NewFrame(2, 1)
104+ r.replaceShadow(frame)
105+ before := append([]Cell(nil), r.shadow...)
106+ r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
107+ if !reflect.DeepEqual(r.shadow, before) {
108+ t.Fatal("syncRect changed shadow for overflowing bounds")
109+ }
110+}
M pkg/renderer/output_min_test.go +228 -0
@@ -2,6 +2,8 @@ package renderer
22
33 import (
44 "bytes"
5+ "strconv"
6+ "strings"
57 "testing"
68 )
79
@@ -71,6 +73,91 @@ func setRow(f Frame, y int, s string, st Style) {
7173 }
7274 }
7375
76+func hasCUB(out string) bool {
77+ for i := 0; i+2 < len(out); i++ {
78+ if out[i] != '\x1b' || out[i+1] != '[' {
79+ continue
80+ }
81+ j := i + 2
82+ for j < len(out) && out[j] >= '0' && out[j] <= '9' {
83+ j++
84+ }
85+ if j < len(out) && out[j] == 'D' {
86+ return true
87+ }
88+ }
89+ return false
90+}
91+
92+func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
93+ r := New(Capabilities{})
94+ base := NewFrame(10, 1)
95+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ frame := NewFrame(10, 1)
100+ setRow(frame, 0, "abcdefghi", DefaultStyle())
101+ got := drawGolden(t, r, frame, []Damage{
102+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
103+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
104+ })
105+ want := "\x1b[1;1Habcdefghi\x1b[0m"
106+ if got != want {
107+ t.Fatalf("output = %q, want %q", got, want)
108+ }
109+}
110+
111+func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ spans []Damage
115+ }{
116+ {
117+ name: "overlapping spans",
118+ spans: []Damage{
119+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
120+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
121+ },
122+ },
123+ {
124+ name: "adjacent spans",
125+ spans: []Damage{
126+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
127+ {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
128+ },
129+ },
130+ }
131+
132+ for _, tt := range tests {
133+ t.Run(tt.name, func(t *testing.T) {
134+ render := func(spans []Damage) string {
135+ t.Helper()
136+ r := New(Capabilities{})
137+ if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
138+ t.Fatal(err)
139+ }
140+ frame := NewFrame(10, 1)
141+ setRow(frame, 0, "abcdefghi", DefaultStyle())
142+ return drawGolden(t, r, frame, spans)
143+ }
144+
145+ forward := render(tt.spans)
146+ reverse := render([]Damage{tt.spans[1], tt.spans[0]})
147+ if forward != reverse {
148+ t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
149+ }
150+ if hasCUB(forward) {
151+ t.Fatalf("canonical output contains CUB: %q", forward)
152+ }
153+ want := "\x1b[1;1Habcdefghi\x1b[0m"
154+ if forward != want {
155+ t.Fatalf("output = %q, want canonical output %q", forward, want)
156+ }
157+ })
158+ }
159+}
160+
74161 func TestNativeClearGolden(t *testing.T) {
75162 tests := []struct {
76163 name string
@@ -257,3 +344,144 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
257344 }
258345 })
259346 }
347+
348+func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
349+ render := func(damage []Damage) string {
350+ t.Helper()
351+ r := New(Capabilities{})
352+ base := NewFrame(10, 3)
353+ setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
354+ setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
355+ setRow(base, 2, "cccccccccc", DefaultStyle())
356+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
357+ t.Fatal(err)
358+ }
359+
360+ frame := NewFrame(10, 3)
361+ setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
362+ setRow(frame, 1, "cccccccccc", DefaultStyle())
363+ setRow(frame, 2, "abcdefghij", DefaultStyle())
364+ out, err := r.Draw(frame, damage)
365+ if err != nil {
366+ t.Fatal(err)
367+ }
368+ return string(out)
369+ }
370+
371+ scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
372+ forward := render([]Damage{
373+ scroll,
374+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
375+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
376+ })
377+ reverse := render([]Damage{
378+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
379+ scroll,
380+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
381+ })
382+ if forward != reverse {
383+ t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
384+ }
385+ if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
386+ t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
387+ }
388+}
389+
390+func TestCanonicalDamageMergesClearAndText(t *testing.T) {
391+ r := New(Capabilities{})
392+ base := NewFrame(10, 1)
393+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
394+ t.Fatal(err)
395+ }
396+ frame := NewFrame(10, 1)
397+ setRow(frame, 0, "abcdefghi", DefaultStyle())
398+ out, err := r.Draw(frame, []Damage{
399+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
400+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
401+ })
402+ if err != nil {
403+ t.Fatal(err)
404+ }
405+ if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
406+ t.Fatalf("output = %q, want %q", got, want)
407+ }
408+}
409+
410+func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
411+ r := New(Capabilities{})
412+ base := NewFrame(8, 1)
413+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
414+ t.Fatal(err)
415+ }
416+ frame := NewFrame(8, 1)
417+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
418+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
419+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
420+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
421+ out, err := r.Draw(frame, []Damage{
422+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
423+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
424+ })
425+ if err != nil {
426+ t.Fatal(err)
427+ }
428+ if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
429+ t.Fatalf("output = %q, want %q", got, want)
430+ }
431+}
432+
433+func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434+ r := New(Capabilities{})
435+ base := NewFrame(1, maxPlannedDamageSpans+1)
436+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437+ t.Fatal(err)
438+ }
439+
440+ frame := base.Clone()
441+ frame.ScrollUp(0, frame.Height-1, 1)
442+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443+ damage := []Damage{
444+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445+ {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446+ }
447+ out, err := r.Draw(frame, damage)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452+ t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453+ }
454+ if !strings.Contains(string(out), "X") {
455+ t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456+ }
457+ out, err = r.Draw(frame, nil)
458+ if err != nil {
459+ t.Fatal(err)
460+ }
461+ if len(out) != 0 {
462+ t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463+ }
464+}
465+
466+func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
467+ r := New(Capabilities{})
468+ frame := NewFrame(1, maxPlannedDamageSpans+1)
469+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
470+ t.Fatal(err)
471+ }
472+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
473+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
474+ if err != nil {
475+ t.Fatal(err)
476+ }
477+ if !strings.Contains(string(out), "X") {
478+ t.Fatalf("fallback output did not redraw the frame: %q", string(out))
479+ }
480+ out, err = r.Draw(frame, nil)
481+ if err != nil {
482+ t.Fatal(err)
483+ }
484+ if len(out) != 0 {
485+ t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
486+ }
487+}
@@ -2,6 +2,8 @@ package renderer
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "strconv"
6+ "strings"
5 "testing"7 "testing"
6 )8 )
7 9
@@ -71,6 +73,91 @@ func setRow(f Frame, y int, s string, st Style) {
71 }73 }
72 }74 }
73 75
76+func hasCUB(out string) bool {
77+ for i := 0; i+2 < len(out); i++ {
78+ if out[i] != '\x1b' || out[i+1] != '[' {
79+ continue
80+ }
81+ j := i + 2
82+ for j < len(out) && out[j] >= '0' && out[j] <= '9' {
83+ j++
84+ }
85+ if j < len(out) && out[j] == 'D' {
86+ return true
87+ }
88+ }
89+ return false
90+}
91+
92+func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
93+ r := New(Capabilities{})
94+ base := NewFrame(10, 1)
95+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ frame := NewFrame(10, 1)
100+ setRow(frame, 0, "abcdefghi", DefaultStyle())
101+ got := drawGolden(t, r, frame, []Damage{
102+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
103+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
104+ })
105+ want := "\x1b[1;1Habcdefghi\x1b[0m"
106+ if got != want {
107+ t.Fatalf("output = %q, want %q", got, want)
108+ }
109+}
110+
111+func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ spans []Damage
115+ }{
116+ {
117+ name: "overlapping spans",
118+ spans: []Damage{
119+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
120+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
121+ },
122+ },
123+ {
124+ name: "adjacent spans",
125+ spans: []Damage{
126+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
127+ {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
128+ },
129+ },
130+ }
131+
132+ for _, tt := range tests {
133+ t.Run(tt.name, func(t *testing.T) {
134+ render := func(spans []Damage) string {
135+ t.Helper()
136+ r := New(Capabilities{})
137+ if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
138+ t.Fatal(err)
139+ }
140+ frame := NewFrame(10, 1)
141+ setRow(frame, 0, "abcdefghi", DefaultStyle())
142+ return drawGolden(t, r, frame, spans)
143+ }
144+
145+ forward := render(tt.spans)
146+ reverse := render([]Damage{tt.spans[1], tt.spans[0]})
147+ if forward != reverse {
148+ t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
149+ }
150+ if hasCUB(forward) {
151+ t.Fatalf("canonical output contains CUB: %q", forward)
152+ }
153+ want := "\x1b[1;1Habcdefghi\x1b[0m"
154+ if forward != want {
155+ t.Fatalf("output = %q, want canonical output %q", forward, want)
156+ }
157+ })
158+ }
159+}
160+
74 func TestNativeClearGolden(t *testing.T) {161 func TestNativeClearGolden(t *testing.T) {
75 tests := []struct {162 tests := []struct {
76 name string163 name string
@@ -257,3 +344,144 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
257 }344 }
258 })345 })
259 }346 }
347+
348+func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
349+ render := func(damage []Damage) string {
350+ t.Helper()
351+ r := New(Capabilities{})
352+ base := NewFrame(10, 3)
353+ setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
354+ setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
355+ setRow(base, 2, "cccccccccc", DefaultStyle())
356+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
357+ t.Fatal(err)
358+ }
359+
360+ frame := NewFrame(10, 3)
361+ setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
362+ setRow(frame, 1, "cccccccccc", DefaultStyle())
363+ setRow(frame, 2, "abcdefghij", DefaultStyle())
364+ out, err := r.Draw(frame, damage)
365+ if err != nil {
366+ t.Fatal(err)
367+ }
368+ return string(out)
369+ }
370+
371+ scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
372+ forward := render([]Damage{
373+ scroll,
374+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
375+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
376+ })
377+ reverse := render([]Damage{
378+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
379+ scroll,
380+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
381+ })
382+ if forward != reverse {
383+ t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
384+ }
385+ if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
386+ t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
387+ }
388+}
389+
390+func TestCanonicalDamageMergesClearAndText(t *testing.T) {
391+ r := New(Capabilities{})
392+ base := NewFrame(10, 1)
393+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
394+ t.Fatal(err)
395+ }
396+ frame := NewFrame(10, 1)
397+ setRow(frame, 0, "abcdefghi", DefaultStyle())
398+ out, err := r.Draw(frame, []Damage{
399+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
400+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
401+ })
402+ if err != nil {
403+ t.Fatal(err)
404+ }
405+ if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
406+ t.Fatalf("output = %q, want %q", got, want)
407+ }
408+}
409+
410+func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
411+ r := New(Capabilities{})
412+ base := NewFrame(8, 1)
413+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
414+ t.Fatal(err)
415+ }
416+ frame := NewFrame(8, 1)
417+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
418+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
419+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
420+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
421+ out, err := r.Draw(frame, []Damage{
422+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
423+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
424+ })
425+ if err != nil {
426+ t.Fatal(err)
427+ }
428+ if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
429+ t.Fatalf("output = %q, want %q", got, want)
430+ }
431+}
432+
433+func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434+ r := New(Capabilities{})
435+ base := NewFrame(1, maxPlannedDamageSpans+1)
436+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437+ t.Fatal(err)
438+ }
439+
440+ frame := base.Clone()
441+ frame.ScrollUp(0, frame.Height-1, 1)
442+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443+ damage := []Damage{
444+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445+ {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446+ }
447+ out, err := r.Draw(frame, damage)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452+ t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453+ }
454+ if !strings.Contains(string(out), "X") {
455+ t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456+ }
457+ out, err = r.Draw(frame, nil)
458+ if err != nil {
459+ t.Fatal(err)
460+ }
461+ if len(out) != 0 {
462+ t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463+ }
464+}
465+
466+func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
467+ r := New(Capabilities{})
468+ frame := NewFrame(1, maxPlannedDamageSpans+1)
469+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
470+ t.Fatal(err)
471+ }
472+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
473+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
474+ if err != nil {
475+ t.Fatal(err)
476+ }
477+ if !strings.Contains(string(out), "X") {
478+ t.Fatalf("fallback output did not redraw the frame: %q", string(out))
479+ }
480+ out, err = r.Draw(frame, nil)
481+ if err != nil {
482+ t.Fatal(err)
483+ }
484+ if len(out) != 0 {
485+ t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
486+ }
487+}
M pkg/renderer/renderer.go +62 -27
@@ -81,12 +81,22 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
8181 }
8282
8383 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84+ spans, full := buildDamagePlan(frame, damage, &scroll)
85+ if full {
86+ r.writeFull(buf, frame, &st)
87+ r.advanceShadow(frame)
88+ if r.caps.SynchronizedOutput {
89+ buf.WriteString(SyncEndCSI)
90+ }
91+ return copyBytes(buf), nil
92+ }
93+
8494 emitScrollUp(buf, scroll)
8595 // emitScrollUp resets the SGR pen to default (matching st's initial
8696 // pen) but leaves the cursor wherever the DECSTBM restore put it —
8797 // terminal-dependent, so cursor tracking stays invalidated.
8898 r.applyScroll(scroll)
89- r.writeDamage(buf, frame, damage, &scroll, &st)
99+ r.emitDamageSpans(buf, frame, spans, &st)
90100 r.advanceDamage(frame, damage, &scroll)
91101 if r.caps.SynchronizedOutput {
92102 buf.WriteString(SyncEndCSI)
@@ -106,8 +116,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
106116 return copyBytes(buf), nil
107117 }
108118
109- r.writeDamage(buf, frame, damage, nil, &st)
110- r.advanceDamage(frame, damage, nil)
119+ if r.writeDamage(buf, frame, damage, nil, &st) {
120+ r.advanceShadow(frame)
121+ } else {
122+ r.advanceDamage(frame, damage, nil)
123+ }
111124 if r.caps.SynchronizedOutput {
112125 buf.WriteString(SyncEndCSI)
113126 }
@@ -167,7 +180,9 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
167180 out.WriteString("\x1b[0m")
168181 }
169182
170-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {
183+// writeDamage emits a canonical, bounded view of text and clear damage. It
184+// reports whether the planner exceeded its budget and emitted a full redraw.
185+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
171186 if len(damage) == 0 {
172187 for y := range frame.Height {
173188 if r.lineDirty(frame, y) {
@@ -175,22 +190,21 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
175190 }
176191 }
177192 out.WriteString("\x1b[0m")
178- return
193+ return false
179194 }
180- for _, d := range damage {
181- if skip != nil && sameDamage(d, *skip) {
182- continue
183- }
184- switch d.Kind {
185- case DamageText, DamageClear:
186- x, y, w, h, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
187- if !ok {
188- continue
189- }
190- for row := y; row < y+h; row++ {
191- r.emitSpan(out, frame, row, x, w, st)
192- }
193- }
195+
196+ spans, full := buildDamagePlan(frame, damage, skip)
197+ if full {
198+ r.writeFull(out, frame, st)
199+ return true
200+ }
201+ r.emitDamageSpans(out, frame, spans, st)
202+ return false
203+}
204+
205+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
206+ for _, span := range spans {
207+ r.emitSpan(out, frame, span.y, span.x, span.width, st)
194208 }
195209 out.WriteString("\x1b[0m")
196210 }
@@ -261,17 +275,38 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
261275 }
262276
263277 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
264- if width <= 0 || height <= 0 {
278+ x, width, okX := clampRange(x, width, frame.Width)
279+ y, height, okY := clampRange(y, height, frame.Height)
280+ if !okX || !okY {
265281 return 0, 0, 0, 0, false
266282 }
267- x0 := max(0, x)
268- y0 := max(0, y)
269- x1 := min(frame.Width, x+width)
270- y1 := min(frame.Height, y+height)
271- if x1 <= x0 || y1 <= y0 {
272- return 0, 0, 0, 0, false
283+ return x, y, width, height, true
284+}
285+
286+// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
287+// overflowing endpoint from untrusted damage coordinates.
288+func clampRange(pos, size, limit int) (int, int, bool) {
289+ if size <= 0 || limit <= 0 || pos >= limit {
290+ return 0, 0, false
291+ }
292+ if pos < 0 {
293+ // -size is safe because size is positive. If pos is at or before that
294+ // point, the rectangle ends at or before zero.
295+ if pos <= -size {
296+ return 0, 0, false
297+ }
298+ end := pos + size // pos > -size proves this addition cannot overflow.
299+ if end > limit {
300+ end = limit
301+ }
302+ return 0, end, true
303+ }
304+
305+ available := limit - pos
306+ if size > available {
307+ size = available
273308 }
274- return x0, y0, x1 - x0, y1 - y0, true
309+ return pos, size, true
275310 }
276311
277312 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
@@ -81,12 +81,22 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
81 }81 }
82 82
83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84+ spans, full := buildDamagePlan(frame, damage, &scroll)
85+ if full {
86+ r.writeFull(buf, frame, &st)
87+ r.advanceShadow(frame)
88+ if r.caps.SynchronizedOutput {
89+ buf.WriteString(SyncEndCSI)
90+ }
91+ return copyBytes(buf), nil
92+ }
93+
84 emitScrollUp(buf, scroll)94 emitScrollUp(buf, scroll)
85 // emitScrollUp resets the SGR pen to default (matching st's initial95 // emitScrollUp resets the SGR pen to default (matching st's initial
86 // pen) but leaves the cursor wherever the DECSTBM restore put it —96 // pen) but leaves the cursor wherever the DECSTBM restore put it —
87 // terminal-dependent, so cursor tracking stays invalidated.97 // terminal-dependent, so cursor tracking stays invalidated.
88 r.applyScroll(scroll)98 r.applyScroll(scroll)
89- r.writeDamage(buf, frame, damage, &scroll, &st)99+ r.emitDamageSpans(buf, frame, spans, &st)
90 r.advanceDamage(frame, damage, &scroll)100 r.advanceDamage(frame, damage, &scroll)
91 if r.caps.SynchronizedOutput {101 if r.caps.SynchronizedOutput {
92 buf.WriteString(SyncEndCSI)102 buf.WriteString(SyncEndCSI)
@@ -106,8 +116,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
106 return copyBytes(buf), nil116 return copyBytes(buf), nil
107 }117 }
108 118
109- r.writeDamage(buf, frame, damage, nil, &st)119+ if r.writeDamage(buf, frame, damage, nil, &st) {
110- r.advanceDamage(frame, damage, nil)120+ r.advanceShadow(frame)
121+ } else {
122+ r.advanceDamage(frame, damage, nil)
123+ }
111 if r.caps.SynchronizedOutput {124 if r.caps.SynchronizedOutput {
112 buf.WriteString(SyncEndCSI)125 buf.WriteString(SyncEndCSI)
113 }126 }
@@ -167,7 +180,9 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
167 out.WriteString("\x1b[0m")180 out.WriteString("\x1b[0m")
168 }181 }
169 182
170-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {183+// writeDamage emits a canonical, bounded view of text and clear damage. It
184+// reports whether the planner exceeded its budget and emitted a full redraw.
185+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
171 if len(damage) == 0 {186 if len(damage) == 0 {
172 for y := range frame.Height {187 for y := range frame.Height {
173 if r.lineDirty(frame, y) {188 if r.lineDirty(frame, y) {
@@ -175,22 +190,21 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
175 }190 }
176 }191 }
177 out.WriteString("\x1b[0m")192 out.WriteString("\x1b[0m")
178- return193+ return false
179 }194 }
180- for _, d := range damage {195+
181- if skip != nil && sameDamage(d, *skip) {196+ spans, full := buildDamagePlan(frame, damage, skip)
182- continue197+ if full {
183- }198+ r.writeFull(out, frame, st)
184- switch d.Kind {199+ return true
185- case DamageText, DamageClear:200+ }
186- x, y, w, h, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)201+ r.emitDamageSpans(out, frame, spans, st)
187- if !ok {202+ return false
188- continue203+}
189- }204+
190- for row := y; row < y+h; row++ {205+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
191- r.emitSpan(out, frame, row, x, w, st)206+ for _, span := range spans {
192- }207+ r.emitSpan(out, frame, span.y, span.x, span.width, st)
193- }
194 }208 }
195 out.WriteString("\x1b[0m")209 out.WriteString("\x1b[0m")
196 }210 }
@@ -261,17 +275,38 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
261 }275 }
262 276
263 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {277 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
264- if width <= 0 || height <= 0 {278+ x, width, okX := clampRange(x, width, frame.Width)
279+ y, height, okY := clampRange(y, height, frame.Height)
280+ if !okX || !okY {
265 return 0, 0, 0, 0, false281 return 0, 0, 0, 0, false
266 }282 }
267- x0 := max(0, x)283+ return x, y, width, height, true
268- y0 := max(0, y)284+}
269- x1 := min(frame.Width, x+width)285+
270- y1 := min(frame.Height, y+height)286+// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
271- if x1 <= x0 || y1 <= y0 {287+// overflowing endpoint from untrusted damage coordinates.
272- return 0, 0, 0, 0, false288+func clampRange(pos, size, limit int) (int, int, bool) {
289+ if size <= 0 || limit <= 0 || pos >= limit {
290+ return 0, 0, false
291+ }
292+ if pos < 0 {
293+ // -size is safe because size is positive. If pos is at or before that
294+ // point, the rectangle ends at or before zero.
295+ if pos <= -size {
296+ return 0, 0, false
297+ }
298+ end := pos + size // pos > -size proves this addition cannot overflow.
299+ if end > limit {
300+ end = limit
301+ }
302+ return 0, end, true
303+ }
304+
305+ available := limit - pos
306+ if size > available {
307+ size = available
273 }308 }
274- return x0, y0, x1 - x0, y1 - y0, true309+ return pos, size, true
275 }310 }
276 311
277 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf312 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
M pkg/vt/screen_integration_test.go +39 -0
@@ -6,6 +6,45 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9+func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
10+ s := NewScreen(20, 2)
11+ r := renderer.New(renderer.Capabilities{})
12+
13+ s.Write([]byte("❯ "))
14+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
15+ t.Fatal(err)
16+ }
17+ s.ClearDamage()
18+
19+ s.Write([]byte("h"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[31mh\x1b[39m"))
26+ s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27+
28+ for y := range s.Frame.Height {
29+ for x := range s.Frame.Width {
30+ r := s.Frame.At(x, y).Rune
31+ if r == '\r' || r == '\n' {
32+ t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33+ }
34+ }
35+ }
36+
37+ out, err := r.Draw(s.Frame, s.Damage())
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, b := range out {
42+ if b == '\r' || b == '\n' {
43+ t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out))
44+ }
45+ }
46+}
47+
948 func TestIntegrationScenarios(t *testing.T) {
1049 tests := []struct {
1150 name string
@@ -6,6 +6,45 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9+func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
10+ s := NewScreen(20, 2)
11+ r := renderer.New(renderer.Capabilities{})
12+
13+ s.Write([]byte("❯ "))
14+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
15+ t.Fatal(err)
16+ }
17+ s.ClearDamage()
18+
19+ s.Write([]byte("h"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[31mh\x1b[39m"))
26+ s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27+
28+ for y := range s.Frame.Height {
29+ for x := range s.Frame.Width {
30+ r := s.Frame.At(x, y).Rune
31+ if r == '\r' || r == '\n' {
32+ t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33+ }
34+ }
35+ }
36+
37+ out, err := r.Draw(s.Frame, s.Damage())
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, b := range out {
42+ if b == '\r' || b == '\n' {
43+ t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out))
44+ }
45+ }
46+}
47+
9 func TestIntegrationScenarios(t *testing.T) {48 func TestIntegrationScenarios(t *testing.T) {
10 tests := []struct {49 tests := []struct {
11 name string50 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 12

Tags 11

↑↓ moveenter openesc close