fix(renderer): plan scroll damage before emission
- commit
- e74381d02c8545c2185e2a4afe3ba5972a8fabff
- author
- brice · · 2026-07-15 08:19 UTC
- committer
- brice · · 2026-07-15 08:19 UTC
- parent
- 616526c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +136 -81
M pkg/renderer/damage_plan.go +8 -5
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package renderer | |
| 2 | 2 | ||
| 3 | - | import "sort" | |
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "slices" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | const maxPlannedDamageSpans = 4096 | |
| 6 | 9 | ||
| @@ -32,11 +35,11 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, | |||
| 32 | 35 | } | |
| 33 | 36 | } | |
| 34 | 37 | ||
| 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 | + | slices.SortFunc(spans, func(a, b damageSpan) int { | |
| 39 | + | if c := cmp.Compare(a.y, b.y); c != 0 { | |
| 40 | + | return c | |
| 38 | 41 | } | |
| 39 | - | return spans[i].x < spans[j].x | |
| 42 | + | return cmp.Compare(a.x, b.x) | |
| 40 | 43 | }) | |
| 41 | 44 | ||
| 42 | 45 | return mergeDamageSpans(spans), false | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package renderer | 1 | package renderer | ||
| 2 | 2 | ||||
| 3 | - | import "sort" | 3 | + | import ( |
| 4 | + | "cmp" | |||
| 5 | + | "slices" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | const maxPlannedDamageSpans = 4096 | 8 | const maxPlannedDamageSpans = 4096 | ||
| 6 | 9 | ||||
| @@ -32,11 +35,11 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, | |||||
| 32 | } | 35 | } | ||
| 33 | } | 36 | } | ||
| 34 | 37 | ||||
| 35 | - | sort.Slice(spans, func(i, j int) bool { | 38 | + | slices.SortFunc(spans, func(a, b damageSpan) int { |
| 36 | - | if spans[i].y != spans[j].y { | 39 | + | if c := cmp.Compare(a.y, b.y); c != 0 { |
| 37 | - | return spans[i].y < spans[j].y | 40 | + | return c |
| 38 | } | 41 | } | ||
| 39 | - | return spans[i].x < spans[j].x | 42 | + | return cmp.Compare(a.x, b.x) |
| 40 | }) | 43 | }) | ||
| 41 | 44 | ||||
| 42 | return mergeDamageSpans(spans), false | 45 | return mergeDamageSpans(spans), false | ||
M pkg/renderer/damage_plan_test.go +77 -70
| @@ -6,57 +6,85 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ) | |
| 8 | 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) | |
| 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 | + | }, | |
| 18 | 75 | } | |
| 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 | 76 | ||
| 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) | |
| 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 | + | }) | |
| 60 | 88 | } | |
| 61 | 89 | } | |
| 62 | 90 | ||
| @@ -70,27 +98,6 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) { | |||
| 70 | 98 | } | |
| 71 | 99 | } | |
| 72 | 100 | ||
| 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 | 101 | func TestSyncRectRejectsOverflowingBounds(t *testing.T) { | |
| 95 | 102 | r := New(Capabilities{}) | |
| 96 | 103 | frame := NewFrame(2, 1) | |
| @@ -6,57 +6,85 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestBuildDamagePlanMergesReverseOverlaps(t *testing.T) { | 9 | + | func TestBuildDamagePlan(t *testing.T) { |
| 10 | - | frame := NewFrame(10, 2) | 10 | + | tests := []struct { |
| 11 | - | got, full := buildDamagePlan(frame, []Damage{ | 11 | + | name string |
| 12 | - | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1}, | 12 | + | frame Frame |
| 13 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1}, | 13 | + | damage []Damage |
| 14 | - | }, nil) | 14 | + | want []damageSpan |
| 15 | - | want := []damageSpan{{y: 0, x: 0, width: 9}} | 15 | + | full bool |
| 16 | - | if full || !reflect.DeepEqual(got, want) { | 16 | + | }{ |
| 17 | - | t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want) | 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 | + | }, | |||
| 18 | } | 75 | } | ||
| 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 | 76 | ||||
| 51 | - | func TestBuildDamagePlanClampsRectangles(t *testing.T) { | 77 | + | for _, tt := range tests { |
| 52 | - | frame := NewFrame(10, 3) | 78 | + | t.Run(tt.name, func(t *testing.T) { |
| 53 | - | got, full := buildDamagePlan(frame, []Damage{ | 79 | + | original := append([]Damage(nil), tt.damage...) |
| 54 | - | {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3}, | 80 | + | got, full := buildDamagePlan(tt.frame, tt.damage, nil) |
| 55 | - | {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3}, | 81 | + | if full != tt.full || !reflect.DeepEqual(got, tt.want) { |
| 56 | - | }, nil) | 82 | + | t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full) |
| 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}} | 83 | + | } |
| 58 | - | if full || !reflect.DeepEqual(got, want) { | 84 | + | if !reflect.DeepEqual(tt.damage, original) { |
| 59 | - | t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, false", got, full, want) | 85 | + | t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original) |
| 86 | + | } | |||
| 87 | + | }) | |||
| 60 | } | 88 | } | ||
| 61 | } | 89 | } | ||
| 62 | 90 | ||||
| @@ -70,27 +98,6 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) { | |||||
| 70 | } | 98 | } | ||
| 71 | } | 99 | } | ||
| 72 | 100 | ||||
| 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) { | 101 | func TestSyncRectRejectsOverflowingBounds(t *testing.T) { | ||
| 95 | r := New(Capabilities{}) | 102 | r := New(Capabilities{}) | ||
| 96 | frame := NewFrame(2, 1) | 103 | frame := NewFrame(2, 1) | ||
M pkg/renderer/output_min_test.go +34 -0
| @@ -2,6 +2,7 @@ package renderer | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ) | |
| @@ -429,6 +430,39 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||
| 429 | 430 | } | |
| 430 | 431 | } | |
| 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 | + | ||
| 432 | 466 | func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) { | |
| 433 | 467 | r := New(Capabilities{}) | |
| 434 | 468 | frame := NewFrame(1, maxPlannedDamageSpans+1) | |
| @@ -2,6 +2,7 @@ package renderer | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "strconv" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | ) | 8 | ) | ||
| @@ -429,6 +430,39 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||||
| 429 | } | 430 | } | ||
| 430 | } | 431 | } | ||
| 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 | + | ||||
| 432 | func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) { | 466 | func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) { | ||
| 433 | r := New(Capabilities{}) | 467 | r := New(Capabilities{}) | ||
| 434 | frame := NewFrame(1, maxPlannedDamageSpans+1) | 468 | frame := NewFrame(1, maxPlannedDamageSpans+1) | ||
M pkg/renderer/renderer.go +17 -6
| @@ -81,16 +81,23 @@ 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 | - | if r.writeDamage(buf, frame, damage, &scroll, &st) { | |
| 90 | - | r.advanceShadow(frame) | |
| 91 | - | } else { | |
| 92 | - | r.advanceDamage(frame, damage, &scroll) | |
| 93 | - | } | |
| 99 | + | r.emitDamageSpans(buf, frame, spans, &st) | |
| 100 | + | r.advanceDamage(frame, damage, &scroll) | |
| 94 | 101 | if r.caps.SynchronizedOutput { | |
| 95 | 102 | buf.WriteString(SyncEndCSI) | |
| 96 | 103 | } | |
| @@ -191,11 +198,15 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, | |||
| 191 | 198 | r.writeFull(out, frame, st) | |
| 192 | 199 | return true | |
| 193 | 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) { | |
| 194 | 206 | for _, span := range spans { | |
| 195 | 207 | r.emitSpan(out, frame, span.y, span.x, span.width, st) | |
| 196 | 208 | } | |
| 197 | 209 | out.WriteString("\x1b[0m") | |
| 198 | - | return false | |
| 199 | 210 | } | |
| 200 | 211 | ||
| 201 | 212 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | |
| @@ -81,16 +81,23 @@ 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 | - | if r.writeDamage(buf, frame, damage, &scroll, &st) { | 99 | + | r.emitDamageSpans(buf, frame, spans, &st) |
| 90 | - | r.advanceShadow(frame) | 100 | + | r.advanceDamage(frame, damage, &scroll) |
| 91 | - | } else { | |||
| 92 | - | r.advanceDamage(frame, damage, &scroll) | |||
| 93 | - | } | |||
| 94 | if r.caps.SynchronizedOutput { | 101 | if r.caps.SynchronizedOutput { | ||
| 95 | buf.WriteString(SyncEndCSI) | 102 | buf.WriteString(SyncEndCSI) | ||
| 96 | } | 103 | } | ||
| @@ -191,11 +198,15 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, | |||||
| 191 | r.writeFull(out, frame, st) | 198 | r.writeFull(out, frame, st) | ||
| 192 | return true | 199 | return true | ||
| 193 | } | 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) { | |||
| 194 | for _, span := range spans { | 206 | for _, span := range spans { | ||
| 195 | r.emitSpan(out, frame, span.y, span.x, span.width, st) | 207 | r.emitSpan(out, frame, span.y, span.x, span.width, st) | ||
| 196 | } | 208 | } | ||
| 197 | out.WriteString("\x1b[0m") | 209 | out.WriteString("\x1b[0m") | ||
| 198 | - | return false | |||
| 199 | } | 210 | } | ||
| 200 | 211 | ||||
| 201 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | 212 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | ||