Merge pull request #94 from bnema/fix/canonical-renderer-damage
fix(renderer): canonicalize incremental damage emission
6 files · +557 -27
M pkg/renderer/benchmark_test.go +45 -0
| @@ -31,6 +31,51 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) { | |||
| 31 | 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 | 79 | func BenchmarkRendererScrollFastPath(b *testing.B) { | |
| 35 | 80 | frame := NewFrame(120, 40) | |
| 36 | 81 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 5 | 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 | 161 | func TestNativeClearGolden(t *testing.T) { | |
| 75 | 162 | tests := []struct { | |
| 76 | 163 | 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 | + | } | |
| @@ -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 string | 163 | 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) { | |||
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 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 | 94 | emitScrollUp(buf, scroll) | |
| 85 | 95 | // emitScrollUp resets the SGR pen to default (matching st's initial | |
| 86 | 96 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | |
| 87 | 97 | // terminal-dependent, so cursor tracking stays invalidated. | |
| 88 | 98 | r.applyScroll(scroll) | |
| 89 | - | r.writeDamage(buf, frame, damage, &scroll, &st) | |
| 99 | + | r.emitDamageSpans(buf, frame, spans, &st) | |
| 90 | 100 | r.advanceDamage(frame, damage, &scroll) | |
| 91 | 101 | if r.caps.SynchronizedOutput { | |
| 92 | 102 | buf.WriteString(SyncEndCSI) | |
| @@ -106,8 +116,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 106 | 116 | return copyBytes(buf), nil | |
| 107 | 117 | } | |
| 108 | 118 | ||
| 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 | + | } | |
| 111 | 124 | if r.caps.SynchronizedOutput { | |
| 112 | 125 | buf.WriteString(SyncEndCSI) | |
| 113 | 126 | } | |
| @@ -167,7 +180,9 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) { | |||
| 167 | 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 | 186 | if len(damage) == 0 { | |
| 172 | 187 | for y := range frame.Height { | |
| 173 | 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 | 192 | out.WriteString("\x1b[0m") | |
| 178 | - | return | |
| 193 | + | return false | |
| 179 | 194 | } | |
| 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) | |
| 194 | 208 | } | |
| 195 | 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 | 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 | 281 | return 0, 0, 0, 0, false | |
| 266 | 282 | } | |
| 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 | |
| 273 | 308 | } | |
| 274 | - | return x0, y0, x1 - x0, y1 - y0, true | |
| 309 | + | return pos, size, true | |
| 275 | 310 | } | |
| 276 | 311 | ||
| 277 | 312 | // 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 initial | 95 | // 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), nil | 116 | 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 | - | return | 193 | + | 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 | - | continue | 197 | + | 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 | - | continue | 203 | + | } |
| 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, false | 281 | 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, false | 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 | |||
| 273 | } | 308 | } | ||
| 274 | - | return x0, y0, x1 - x0, y1 - y0, true | 309 | + | return pos, size, true |
| 275 | } | 310 | } | ||
| 276 | 311 | ||||
| 277 | // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf | 312 | // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf | ||
M pkg/vt/screen_integration_test.go +39 -0
| @@ -6,6 +6,45 @@ import ( | |||
| 6 | 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 | 48 | func TestIntegrationScenarios(t *testing.T) { | |
| 10 | 49 | tests := []struct { | |
| 11 | 50 | 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 string | 50 | name string | ||