detached ce6cefd

fix(renderer): canonicalize incremental damage emission

commit
ce6cefd1f10fc0cbaa829417a7a8f74eabd7ff84
author
brice · · 2026-07-15 07:11 UTC
parent
3328c17
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +334 -28
A pkg/renderer/damage_plan.go +70 -0
@@ -0,0 +1,70 @@
1+package renderer
2+
3+import "sort"
4+
5+const maxPlannedDamageSpans = 4096
6+
7+type damageSpan struct {
8+ y, x, width int
9+}
10+
11+// buildDamagePlan creates the bounded, canonical view of text and clear damage
12+// used for terminal emission. The source damage remains untouched because the
13+// scroll and shadow paths need its original structural information.
14+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {
15+ spans := make([]damageSpan, 0)
16+ for _, d := range damage {
17+ if skip != nil && sameDamage(d, *skip) {
18+ continue
19+ }
20+ if d.Kind != DamageText && d.Kind != DamageClear {
21+ continue
22+ }
23+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
24+ if !ok {
25+ continue
26+ }
27+ for row := y; row < y+height; row++ {
28+ if len(spans) == maxPlannedDamageSpans {
29+ return nil, true
30+ }
31+ spans = append(spans, damageSpan{y: row, x: x, width: width})
32+ }
33+ }
34+
35+ sort.Slice(spans, func(i, j int) bool {
36+ if spans[i].y != spans[j].y {
37+ return spans[i].y < spans[j].y
38+ }
39+ return spans[i].x < spans[j].x
40+ })
41+
42+ return mergeDamageSpans(spans), false
43+}
44+
45+func mergeDamageSpans(spans []damageSpan) []damageSpan {
46+ if len(spans) < 2 {
47+ return spans
48+ }
49+
50+ out := spans[:0]
51+ for _, span := range spans {
52+ if len(out) == 0 {
53+ out = append(out, span)
54+ continue
55+ }
56+ last := &out[len(out)-1]
57+ // Both endpoints are bounded by frame.Width after clampRect, so these
58+ // additions cannot overflow. The comparison includes adjacency.
59+ lastEnd := last.x + last.width
60+ spanEnd := span.x + span.width
61+ if span.y != last.y || span.x > lastEnd {
62+ out = append(out, span)
63+ continue
64+ }
65+ if spanEnd > lastEnd {
66+ last.width = spanEnd - last.x
67+ }
68+ }
69+ return out
70+}
@@ -0,0 +1,70 @@
1+package renderer
2+
3+import "sort"
4+
5+const maxPlannedDamageSpans = 4096
6+
7+type damageSpan struct {
8+ y, x, width int
9+}
10+
11+// buildDamagePlan creates the bounded, canonical view of text and clear damage
12+// used for terminal emission. The source damage remains untouched because the
13+// scroll and shadow paths need its original structural information.
14+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {
15+ spans := make([]damageSpan, 0)
16+ for _, d := range damage {
17+ if skip != nil && sameDamage(d, *skip) {
18+ continue
19+ }
20+ if d.Kind != DamageText && d.Kind != DamageClear {
21+ continue
22+ }
23+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
24+ if !ok {
25+ continue
26+ }
27+ for row := y; row < y+height; row++ {
28+ if len(spans) == maxPlannedDamageSpans {
29+ return nil, true
30+ }
31+ spans = append(spans, damageSpan{y: row, x: x, width: width})
32+ }
33+ }
34+
35+ sort.Slice(spans, func(i, j int) bool {
36+ if spans[i].y != spans[j].y {
37+ return spans[i].y < spans[j].y
38+ }
39+ return spans[i].x < spans[j].x
40+ })
41+
42+ return mergeDamageSpans(spans), false
43+}
44+
45+func mergeDamageSpans(spans []damageSpan) []damageSpan {
46+ if len(spans) < 2 {
47+ return spans
48+ }
49+
50+ out := spans[:0]
51+ for _, span := range spans {
52+ if len(out) == 0 {
53+ out = append(out, span)
54+ continue
55+ }
56+ last := &out[len(out)-1]
57+ // Both endpoints are bounded by frame.Width after clampRect, so these
58+ // additions cannot overflow. The comparison includes adjacency.
59+ lastEnd := last.x + last.width
60+ spanEnd := span.x + span.width
61+ if span.y != last.y || span.x > lastEnd {
62+ out = append(out, span)
63+ continue
64+ }
65+ if spanEnd > lastEnd {
66+ last.width = spanEnd - last.x
67+ }
68+ }
69+ return out
70+}
A pkg/renderer/damage_plan_test.go +103 -0
@@ -0,0 +1,103 @@
1+package renderer
2+
3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestBuildDamagePlanMergesReverseOverlaps(t *testing.T) {
10+ frame := NewFrame(10, 2)
11+ got, full := buildDamagePlan(frame, []Damage{
12+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
13+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
14+ }, nil)
15+ want := []damageSpan{{y: 0, x: 0, width: 9}}
16+ if full || !reflect.DeepEqual(got, want) {
17+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
18+ }
19+}
20+
21+func TestBuildDamagePlanMergesTextAndClear(t *testing.T) {
22+ frame := NewFrame(10, 1)
23+ got, full := buildDamagePlan(frame, []Damage{
24+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
25+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
26+ }, nil)
27+ want := []damageSpan{{y: 0, x: 0, width: 9}}
28+ if full || !reflect.DeepEqual(got, want) {
29+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
30+ }
31+}
32+
33+func TestBuildDamagePlanOrdersMultiRowSpans(t *testing.T) {
34+ frame := NewFrame(10, 3)
35+ got, full := buildDamagePlan(frame, []Damage{
36+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
37+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
38+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
39+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
40+ }, nil)
41+ want := []damageSpan{
42+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
43+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
44+ {y: 2, x: 5, width: 2},
45+ }
46+ if full || !reflect.DeepEqual(got, want) {
47+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
48+ }
49+}
50+
51+func TestBuildDamagePlanClampsRectangles(t *testing.T) {
52+ frame := NewFrame(10, 3)
53+ got, full := buildDamagePlan(frame, []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ }, nil)
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+ if full || !reflect.DeepEqual(got, want) {
59+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
60+ }
61+}
62+
63+func TestClampRectRejectsOverflowingBounds(t *testing.T) {
64+ frame := NewFrame(10, 10)
65+ if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok {
66+ t.Fatal("clampRect accepted overflowing x bound")
67+ }
68+ if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok {
69+ t.Fatal("clampRect accepted overflowing y bound")
70+ }
71+}
72+
73+func TestBuildDamagePlanDoesNotMutateInput(t *testing.T) {
74+ frame := NewFrame(10, 2)
75+ damage := []Damage{
76+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
77+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
78+ }
79+ original := append([]Damage(nil), damage...)
80+ _, _ = buildDamagePlan(frame, damage, nil)
81+ if !reflect.DeepEqual(damage, original) {
82+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", damage, original)
83+ }
84+}
85+
86+func TestBuildDamagePlanExceedingBudgetRequestsFullRedraw(t *testing.T) {
87+ frame := NewFrame(1, maxPlannedDamageSpans+1)
88+ got, full := buildDamagePlan(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}}, nil)
89+ if !full || got != nil {
90+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want nil, true", got, full)
91+ }
92+}
93+
94+func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
95+ r := New(Capabilities{})
96+ frame := NewFrame(2, 1)
97+ r.replaceShadow(frame)
98+ before := append([]Cell(nil), r.shadow...)
99+ r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
100+ if !reflect.DeepEqual(r.shadow, before) {
101+ t.Fatal("syncRect changed shadow for overflowing bounds")
102+ }
103+}
@@ -0,0 +1,103 @@
1+package renderer
2+
3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
8+
9+func TestBuildDamagePlanMergesReverseOverlaps(t *testing.T) {
10+ frame := NewFrame(10, 2)
11+ got, full := buildDamagePlan(frame, []Damage{
12+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
13+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
14+ }, nil)
15+ want := []damageSpan{{y: 0, x: 0, width: 9}}
16+ if full || !reflect.DeepEqual(got, want) {
17+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
18+ }
19+}
20+
21+func TestBuildDamagePlanMergesTextAndClear(t *testing.T) {
22+ frame := NewFrame(10, 1)
23+ got, full := buildDamagePlan(frame, []Damage{
24+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
25+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
26+ }, nil)
27+ want := []damageSpan{{y: 0, x: 0, width: 9}}
28+ if full || !reflect.DeepEqual(got, want) {
29+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
30+ }
31+}
32+
33+func TestBuildDamagePlanOrdersMultiRowSpans(t *testing.T) {
34+ frame := NewFrame(10, 3)
35+ got, full := buildDamagePlan(frame, []Damage{
36+ {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
37+ {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
38+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
39+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
40+ }, nil)
41+ want := []damageSpan{
42+ {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
43+ {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
44+ {y: 2, x: 5, width: 2},
45+ }
46+ if full || !reflect.DeepEqual(got, want) {
47+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
48+ }
49+}
50+
51+func TestBuildDamagePlanClampsRectangles(t *testing.T) {
52+ frame := NewFrame(10, 3)
53+ got, full := buildDamagePlan(frame, []Damage{
54+ {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55+ {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56+ }, nil)
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+ if full || !reflect.DeepEqual(got, want) {
59+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want)
60+ }
61+}
62+
63+func TestClampRectRejectsOverflowingBounds(t *testing.T) {
64+ frame := NewFrame(10, 10)
65+ if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok {
66+ t.Fatal("clampRect accepted overflowing x bound")
67+ }
68+ if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok {
69+ t.Fatal("clampRect accepted overflowing y bound")
70+ }
71+}
72+
73+func TestBuildDamagePlanDoesNotMutateInput(t *testing.T) {
74+ frame := NewFrame(10, 2)
75+ damage := []Damage{
76+ {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
77+ {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
78+ }
79+ original := append([]Damage(nil), damage...)
80+ _, _ = buildDamagePlan(frame, damage, nil)
81+ if !reflect.DeepEqual(damage, original) {
82+ t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", damage, original)
83+ }
84+}
85+
86+func TestBuildDamagePlanExceedingBudgetRequestsFullRedraw(t *testing.T) {
87+ frame := NewFrame(1, maxPlannedDamageSpans+1)
88+ got, full := buildDamagePlan(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}}, nil)
89+ if !full || got != nil {
90+ t.Fatalf("buildDamagePlan() = %#v, full = %v; want nil, true", got, full)
91+ }
92+}
93+
94+func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
95+ r := New(Capabilities{})
96+ frame := NewFrame(2, 1)
97+ r.replaceShadow(frame)
98+ before := append([]Cell(nil), r.shadow...)
99+ r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
100+ if !reflect.DeepEqual(r.shadow, before) {
101+ t.Fatal("syncRect changed shadow for overflowing bounds")
102+ }
103+}
M pkg/renderer/output_min_test.go +109 -0
@@ -2,6 +2,7 @@ package renderer
22
33 import (
44 "bytes"
5+ "strings"
56 "testing"
67 )
78
@@ -342,3 +343,111 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
342343 }
343344 })
344345 }
346+
347+func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
348+ render := func(damage []Damage) string {
349+ t.Helper()
350+ r := New(Capabilities{})
351+ base := NewFrame(10, 3)
352+ setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
353+ setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
354+ setRow(base, 2, "cccccccccc", DefaultStyle())
355+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
356+ t.Fatal(err)
357+ }
358+
359+ frame := NewFrame(10, 3)
360+ setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
361+ setRow(frame, 1, "cccccccccc", DefaultStyle())
362+ setRow(frame, 2, "abcdefghij", DefaultStyle())
363+ out, err := r.Draw(frame, damage)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ return string(out)
368+ }
369+
370+ scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
371+ forward := render([]Damage{
372+ scroll,
373+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
374+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
375+ })
376+ reverse := render([]Damage{
377+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
378+ scroll,
379+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
380+ })
381+ if forward != reverse {
382+ t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
383+ }
384+ if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
385+ t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
386+ }
387+}
388+
389+func TestCanonicalDamageMergesClearAndText(t *testing.T) {
390+ r := New(Capabilities{})
391+ base := NewFrame(10, 1)
392+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
393+ t.Fatal(err)
394+ }
395+ frame := NewFrame(10, 1)
396+ setRow(frame, 0, "abcdefghi", DefaultStyle())
397+ out, err := r.Draw(frame, []Damage{
398+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
399+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
400+ })
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
405+ t.Fatalf("output = %q, want %q", got, want)
406+ }
407+}
408+
409+func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
410+ r := New(Capabilities{})
411+ base := NewFrame(8, 1)
412+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
413+ t.Fatal(err)
414+ }
415+ frame := NewFrame(8, 1)
416+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
417+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
418+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
419+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
420+ out, err := r.Draw(frame, []Damage{
421+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
422+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
423+ })
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
428+ t.Fatalf("output = %q, want %q", got, want)
429+ }
430+}
431+
432+func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
433+ r := New(Capabilities{})
434+ frame := NewFrame(1, maxPlannedDamageSpans+1)
435+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
436+ t.Fatal(err)
437+ }
438+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
439+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
440+ if err != nil {
441+ t.Fatal(err)
442+ }
443+ if !strings.Contains(string(out), "X") {
444+ t.Fatalf("fallback output did not redraw the frame: %q", string(out))
445+ }
446+ out, err = r.Draw(frame, nil)
447+ if err != nil {
448+ t.Fatal(err)
449+ }
450+ if len(out) != 0 {
451+ t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
452+ }
453+}
@@ -2,6 +2,7 @@ package renderer
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "strings"
5 "testing"6 "testing"
6 )7 )
7 8
@@ -342,3 +343,111 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
342 }343 }
343 })344 })
344 }345 }
346+
347+func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
348+ render := func(damage []Damage) string {
349+ t.Helper()
350+ r := New(Capabilities{})
351+ base := NewFrame(10, 3)
352+ setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
353+ setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
354+ setRow(base, 2, "cccccccccc", DefaultStyle())
355+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
356+ t.Fatal(err)
357+ }
358+
359+ frame := NewFrame(10, 3)
360+ setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
361+ setRow(frame, 1, "cccccccccc", DefaultStyle())
362+ setRow(frame, 2, "abcdefghij", DefaultStyle())
363+ out, err := r.Draw(frame, damage)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ return string(out)
368+ }
369+
370+ scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
371+ forward := render([]Damage{
372+ scroll,
373+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
374+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
375+ })
376+ reverse := render([]Damage{
377+ {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
378+ scroll,
379+ {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
380+ })
381+ if forward != reverse {
382+ t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
383+ }
384+ if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
385+ t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
386+ }
387+}
388+
389+func TestCanonicalDamageMergesClearAndText(t *testing.T) {
390+ r := New(Capabilities{})
391+ base := NewFrame(10, 1)
392+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
393+ t.Fatal(err)
394+ }
395+ frame := NewFrame(10, 1)
396+ setRow(frame, 0, "abcdefghi", DefaultStyle())
397+ out, err := r.Draw(frame, []Damage{
398+ {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
399+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
400+ })
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
405+ t.Fatalf("output = %q, want %q", got, want)
406+ }
407+}
408+
409+func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
410+ r := New(Capabilities{})
411+ base := NewFrame(8, 1)
412+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
413+ t.Fatal(err)
414+ }
415+ frame := NewFrame(8, 1)
416+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
417+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
418+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
419+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
420+ out, err := r.Draw(frame, []Damage{
421+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
422+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
423+ })
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
428+ t.Fatalf("output = %q, want %q", got, want)
429+ }
430+}
431+
432+func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
433+ r := New(Capabilities{})
434+ frame := NewFrame(1, maxPlannedDamageSpans+1)
435+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
436+ t.Fatal(err)
437+ }
438+ frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
439+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
440+ if err != nil {
441+ t.Fatal(err)
442+ }
443+ if !strings.Contains(string(out), "X") {
444+ t.Fatalf("fallback output did not redraw the frame: %q", string(out))
445+ }
446+ out, err = r.Draw(frame, nil)
447+ if err != nil {
448+ t.Fatal(err)
449+ }
450+ if len(out) != 0 {
451+ t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
452+ }
453+}
M pkg/renderer/renderer.go +52 -28
@@ -86,8 +86,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
8686 // pen) but leaves the cursor wherever the DECSTBM restore put it —
8787 // terminal-dependent, so cursor tracking stays invalidated.
8888 r.applyScroll(scroll)
89- r.writeDamage(buf, frame, damage, &scroll, &st)
90- r.advanceDamage(frame, damage, &scroll)
89+ if r.writeDamage(buf, frame, damage, &scroll, &st) {
90+ r.advanceShadow(frame)
91+ } else {
92+ r.advanceDamage(frame, damage, &scroll)
93+ }
9194 if r.caps.SynchronizedOutput {
9295 buf.WriteString(SyncEndCSI)
9396 }
@@ -106,8 +109,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
106109 return copyBytes(buf), nil
107110 }
108111
109- r.writeDamage(buf, frame, damage, nil, &st)
110- r.advanceDamage(frame, damage, nil)
112+ if r.writeDamage(buf, frame, damage, nil, &st) {
113+ r.advanceShadow(frame)
114+ } else {
115+ r.advanceDamage(frame, damage, nil)
116+ }
111117 if r.caps.SynchronizedOutput {
112118 buf.WriteString(SyncEndCSI)
113119 }
@@ -167,7 +173,9 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
167173 out.WriteString("\x1b[0m")
168174 }
169175
170-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {
176+// writeDamage emits a canonical, bounded view of text and clear damage. It
177+// reports whether the planner exceeded its budget and emitted a full redraw.
178+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
171179 if len(damage) == 0 {
172180 for y := range frame.Height {
173181 if r.lineDirty(frame, y) {
@@ -175,24 +183,19 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
175183 }
176184 }
177185 out.WriteString("\x1b[0m")
178- return
186+ return false
179187 }
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- }
188+
189+ spans, full := buildDamagePlan(frame, damage, skip)
190+ if full {
191+ r.writeFull(out, frame, st)
192+ return true
193+ }
194+ for _, span := range spans {
195+ r.emitSpan(out, frame, span.y, span.x, span.width, st)
194196 }
195197 out.WriteString("\x1b[0m")
198+ return false
196199 }
197200
198201 func (r *Renderer) lineDirty(frame Frame, y int) bool {
@@ -261,17 +264,38 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
261264 }
262265
263266 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
264- if width <= 0 || height <= 0 {
267+ x, width, okX := clampRange(x, width, frame.Width)
268+ y, height, okY := clampRange(y, height, frame.Height)
269+ if !okX || !okY {
265270 return 0, 0, 0, 0, false
266271 }
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
272+ return x, y, width, height, true
273+}
274+
275+// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
276+// overflowing endpoint from untrusted damage coordinates.
277+func clampRange(pos, size, limit int) (int, int, bool) {
278+ if size <= 0 || limit <= 0 || pos >= limit {
279+ return 0, 0, false
280+ }
281+ if pos < 0 {
282+ // -size is safe because size is positive. If pos is at or before that
283+ // point, the rectangle ends at or before zero.
284+ if pos <= -size {
285+ return 0, 0, false
286+ }
287+ end := pos + size // pos > -size proves this addition cannot overflow.
288+ if end > limit {
289+ end = limit
290+ }
291+ return 0, end, true
292+ }
293+
294+ available := limit - pos
295+ if size > available {
296+ size = available
273297 }
274- return x0, y0, x1 - x0, y1 - y0, true
298+ return pos, size, true
275299 }
276300
277301 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
@@ -86,8 +86,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
86 // pen) but leaves the cursor wherever the DECSTBM restore put it —86 // pen) but leaves the cursor wherever the DECSTBM restore put it —
87 // terminal-dependent, so cursor tracking stays invalidated.87 // terminal-dependent, so cursor tracking stays invalidated.
88 r.applyScroll(scroll)88 r.applyScroll(scroll)
89- r.writeDamage(buf, frame, damage, &scroll, &st)89+ if r.writeDamage(buf, frame, damage, &scroll, &st) {
90- r.advanceDamage(frame, damage, &scroll)90+ r.advanceShadow(frame)
91+ } else {
92+ r.advanceDamage(frame, damage, &scroll)
93+ }
91 if r.caps.SynchronizedOutput {94 if r.caps.SynchronizedOutput {
92 buf.WriteString(SyncEndCSI)95 buf.WriteString(SyncEndCSI)
93 }96 }
@@ -106,8 +109,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
106 return copyBytes(buf), nil109 return copyBytes(buf), nil
107 }110 }
108 111
109- r.writeDamage(buf, frame, damage, nil, &st)112+ if r.writeDamage(buf, frame, damage, nil, &st) {
110- r.advanceDamage(frame, damage, nil)113+ r.advanceShadow(frame)
114+ } else {
115+ r.advanceDamage(frame, damage, nil)
116+ }
111 if r.caps.SynchronizedOutput {117 if r.caps.SynchronizedOutput {
112 buf.WriteString(SyncEndCSI)118 buf.WriteString(SyncEndCSI)
113 }119 }
@@ -167,7 +173,9 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
167 out.WriteString("\x1b[0m")173 out.WriteString("\x1b[0m")
168 }174 }
169 175
170-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {176+// writeDamage emits a canonical, bounded view of text and clear damage. It
177+// reports whether the planner exceeded its budget and emitted a full redraw.
178+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
171 if len(damage) == 0 {179 if len(damage) == 0 {
172 for y := range frame.Height {180 for y := range frame.Height {
173 if r.lineDirty(frame, y) {181 if r.lineDirty(frame, y) {
@@ -175,24 +183,19 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
175 }183 }
176 }184 }
177 out.WriteString("\x1b[0m")185 out.WriteString("\x1b[0m")
178- return186+ return false
179 }187 }
180- for _, d := range damage {188+
181- if skip != nil && sameDamage(d, *skip) {189+ spans, full := buildDamagePlan(frame, damage, skip)
182- continue190+ if full {
183- }191+ r.writeFull(out, frame, st)
184- switch d.Kind {192+ return true
185- case DamageText, DamageClear:193+ }
186- x, y, w, h, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)194+ for _, span := range spans {
187- if !ok {195+ r.emitSpan(out, frame, span.y, span.x, span.width, st)
188- continue
189- }
190- for row := y; row < y+h; row++ {
191- r.emitSpan(out, frame, row, x, w, st)
192- }
193- }
194 }196 }
195 out.WriteString("\x1b[0m")197 out.WriteString("\x1b[0m")
198+ return false
196 }199 }
197 200
198 func (r *Renderer) lineDirty(frame Frame, y int) bool {201 func (r *Renderer) lineDirty(frame Frame, y int) bool {
@@ -261,17 +264,38 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
261 }264 }
262 265
263 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {266 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
264- if width <= 0 || height <= 0 {267+ x, width, okX := clampRange(x, width, frame.Width)
268+ y, height, okY := clampRange(y, height, frame.Height)
269+ if !okX || !okY {
265 return 0, 0, 0, 0, false270 return 0, 0, 0, 0, false
266 }271 }
267- x0 := max(0, x)272+ return x, y, width, height, true
268- y0 := max(0, y)273+}
269- x1 := min(frame.Width, x+width)274+
270- y1 := min(frame.Height, y+height)275+// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
271- if x1 <= x0 || y1 <= y0 {276+// overflowing endpoint from untrusted damage coordinates.
272- return 0, 0, 0, 0, false277+func clampRange(pos, size, limit int) (int, int, bool) {
278+ if size <= 0 || limit <= 0 || pos >= limit {
279+ return 0, 0, false
280+ }
281+ if pos < 0 {
282+ // -size is safe because size is positive. If pos is at or before that
283+ // point, the rectangle ends at or before zero.
284+ if pos <= -size {
285+ return 0, 0, false
286+ }
287+ end := pos + size // pos > -size proves this addition cannot overflow.
288+ if end > limit {
289+ end = limit
290+ }
291+ return 0, end, true
292+ }
293+
294+ available := limit - pos
295+ if size > available {
296+ size = available
273 }297 }
274- return x0, y0, x1 - x0, y1 - y0, true298+ return pos, size, true
275 }299 }
276 300
277 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf301 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf

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