fix(ansi): widen damage spans that split a wide rune
Text damage recorded before a scroll could start on the right half of a wide rune moved under it; the renderer then wrote over that half and erased the rune on the client. Planned spans now cover whole wide pairs, which also keeps cursor tracking exact. The oracle replays ANSI-256 output too, and a seeded scroll workload test replays real VT damage for every CellSource shape.
- commit
- f8f97be4f2c62bf11d30f9acf99c36dfd39ee3ae
- author
- brice · · 2026-10-02 12:26 UTC
- parent
- 86aa468
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +188 -29
M ansi/damage_plan.go +17 -2
| @@ -38,7 +38,7 @@ func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, b | |||
| 38 | 38 | if len(spans) == maxPlannedDamageSpans { | |
| 39 | 39 | return nil, true | |
| 40 | 40 | } | |
| 41 | - | spans = append(spans, Span{Y: row, X: x, Width: width}) | |
| 41 | + | spans = append(spans, wideAlignedSpan(frame, row, x, width)) | |
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| @@ -62,11 +62,26 @@ func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) { | |||
| 62 | 62 | } | |
| 63 | 63 | spans := make([]Span, height) | |
| 64 | 64 | for row := range height { | |
| 65 | - | spans[row] = Span{Y: y + row, X: x, Width: width} | |
| 65 | + | spans[row] = wideAlignedSpan(frame, y+row, x, width) | |
| 66 | 66 | } | |
| 67 | 67 | return spans, false | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | // wideAlignedSpan widens a clamped span so it never splits a wide rune. | |
| 71 | + | // Damage can name only one half of a pair, for example when text damage was | |
| 72 | + | // recorded before a scroll moved a wide rune under it. Writing from the right | |
| 73 | + | // half would erase the rune on the client, and the shadow must hold whole pairs. | |
| 74 | + | func wideAlignedSpan(frame CellSource, y, x, width int) Span { | |
| 75 | + | if x > 0 && frame.Cell(x, y).Continuation { | |
| 76 | + | x-- | |
| 77 | + | width++ | |
| 78 | + | } | |
| 79 | + | if end := x + width; end < frame.Columns() && frame.Cell(end, y).Continuation { | |
| 80 | + | width++ | |
| 81 | + | } | |
| 82 | + | return Span{Y: y, X: x, Width: width} | |
| 83 | + | } | |
| 84 | + | ||
| 70 | 85 | func mergeDamageSpans(spans []Span) []Span { | |
| 71 | 86 | if len(spans) < 2 { | |
| 72 | 87 | return spans | |
| @@ -38,7 +38,7 @@ func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, b | |||||
| 38 | if len(spans) == maxPlannedDamageSpans { | 38 | if len(spans) == maxPlannedDamageSpans { | ||
| 39 | return nil, true | 39 | return nil, true | ||
| 40 | } | 40 | } | ||
| 41 | - | spans = append(spans, Span{Y: row, X: x, Width: width}) | 41 | + | spans = append(spans, wideAlignedSpan(frame, row, x, width)) |
| 42 | } | 42 | } | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| @@ -62,11 +62,26 @@ func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) { | |||||
| 62 | } | 62 | } | ||
| 63 | spans := make([]Span, height) | 63 | spans := make([]Span, height) | ||
| 64 | for row := range height { | 64 | for row := range height { | ||
| 65 | - | spans[row] = Span{Y: y + row, X: x, Width: width} | 65 | + | spans[row] = wideAlignedSpan(frame, y+row, x, width) |
| 66 | } | 66 | } | ||
| 67 | return spans, false | 67 | return spans, false | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | + | // wideAlignedSpan widens a clamped span so it never splits a wide rune. | |||
| 71 | + | // Damage can name only one half of a pair, for example when text damage was | |||
| 72 | + | // recorded before a scroll moved a wide rune under it. Writing from the right | |||
| 73 | + | // half would erase the rune on the client, and the shadow must hold whole pairs. | |||
| 74 | + | func wideAlignedSpan(frame CellSource, y, x, width int) Span { | |||
| 75 | + | if x > 0 && frame.Cell(x, y).Continuation { | |||
| 76 | + | x-- | |||
| 77 | + | width++ | |||
| 78 | + | } | |||
| 79 | + | if end := x + width; end < frame.Columns() && frame.Cell(end, y).Continuation { | |||
| 80 | + | width++ | |||
| 81 | + | } | |||
| 82 | + | return Span{Y: y, X: x, Width: width} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 70 | func mergeDamageSpans(spans []Span) []Span { | 85 | func mergeDamageSpans(spans []Span) []Span { | ||
| 71 | if len(spans) < 2 { | 86 | if len(spans) < 2 { | ||
| 72 | return spans | 87 | return spans | ||
M ansi/output_min_test.go +6 -8
| @@ -293,11 +293,10 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 293 | 293 | } | |
| 294 | 294 | }) | |
| 295 | 295 | ||
| 296 | - | t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) { | |
| 296 | + | t.Run("rect splitting a wide pair is widened to the whole pair", func(t *testing.T) { | |
| 297 | 297 | // A damage rect that ends on a wide head whose continuation lies | |
| 298 | - | // outside the rect makes the terminal advance one column further than | |
| 299 | - | // the rect width accounts for. Tracking must be dropped: the next | |
| 300 | - | // rect must be positioned with absolute CUP, not a relative move. | |
| 298 | + | // outside the rect is widened to cover the pair, so the terminal | |
| 299 | + | // advance matches the span and tracking stays exact. | |
| 301 | 300 | r := New(Capabilities{}) | |
| 302 | 301 | base := NewFrame(8, 1) | |
| 303 | 302 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| @@ -314,9 +313,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 314 | 313 | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | |
| 315 | 314 | } | |
| 316 | 315 | got := drawGolden(t, r, frame, damage) | |
| 317 | - | // After A你 the real cursor is at col 3, not col 2: a relative CUF | |
| 318 | - | // would be off by one, so the second rect must use absolute CUP. | |
| 319 | - | want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m" | |
| 316 | + | // After A你 the cursor is known at col 3, so one CUF reaches col 4. | |
| 317 | + | want := "\x1b[1;1HA你\x1b[CB\x1b[0m" | |
| 320 | 318 | if got != want { | |
| 321 | 319 | t.Fatalf("output = %q, want %q", got, want) | |
| 322 | 320 | } | |
| @@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||
| 425 | 423 | if err != nil { | |
| 426 | 424 | t.Fatal(err) | |
| 427 | 425 | } | |
| 428 | - | if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want { | |
| 426 | + | if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want { | |
| 429 | 427 | t.Fatalf("output = %q, want %q", got, want) | |
| 430 | 428 | } | |
| 431 | 429 | } | |
| @@ -293,11 +293,10 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||||
| 293 | } | 293 | } | ||
| 294 | }) | 294 | }) | ||
| 295 | 295 | ||||
| 296 | - | t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) { | 296 | + | t.Run("rect splitting a wide pair is widened to the whole pair", func(t *testing.T) { |
| 297 | // A damage rect that ends on a wide head whose continuation lies | 297 | // A damage rect that ends on a wide head whose continuation lies | ||
| 298 | - | // outside the rect makes the terminal advance one column further than | 298 | + | // outside the rect is widened to cover the pair, so the terminal |
| 299 | - | // the rect width accounts for. Tracking must be dropped: the next | 299 | + | // advance matches the span and tracking stays exact. |
| 300 | - | // rect must be positioned with absolute CUP, not a relative move. | |||
| 301 | r := New(Capabilities{}) | 300 | r := New(Capabilities{}) | ||
| 302 | base := NewFrame(8, 1) | 301 | base := NewFrame(8, 1) | ||
| 303 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | 302 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | ||
| @@ -314,9 +313,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||||
| 314 | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | 313 | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | ||
| 315 | } | 314 | } | ||
| 316 | got := drawGolden(t, r, frame, damage) | 315 | got := drawGolden(t, r, frame, damage) | ||
| 317 | - | // After A你 the real cursor is at col 3, not col 2: a relative CUF | 316 | + | // After A你 the cursor is known at col 3, so one CUF reaches col 4. |
| 318 | - | // would be off by one, so the second rect must use absolute CUP. | 317 | + | want := "\x1b[1;1HA你\x1b[CB\x1b[0m" |
| 319 | - | want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m" | |||
| 320 | if got != want { | 318 | if got != want { | ||
| 321 | t.Fatalf("output = %q, want %q", got, want) | 319 | t.Fatalf("output = %q, want %q", got, want) | ||
| 322 | } | 320 | } | ||
| @@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||||
| 425 | if err != nil { | 423 | if err != nil { | ||
| 426 | t.Fatal(err) | 424 | t.Fatal(err) | ||
| 427 | } | 425 | } | ||
| 428 | - | if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want { | 426 | + | if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want { |
| 429 | t.Fatalf("output = %q, want %q", got, want) | 427 | t.Fatalf("output = %q, want %q", got, want) | ||
| 430 | } | 428 | } | ||
| 431 | } | 429 | } | ||
A ansi/replay_property_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package ansi_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "math/rand/v2" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | vt "github.com/bnema/vev-vt" | |
| 10 | + | "github.com/bnema/vev-vt/ansi" | |
| 11 | + | core "github.com/bnema/vev-vt/core" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type hiddenCellSource struct{ core.CellSource } | |
| 15 | + | ||
| 16 | + | // Random VT scroll workloads rendered with real screen damage must replay to | |
| 17 | + | // the exact source grid for every CellSource shape the renderer dispatches on. | |
| 18 | + | func TestRandomScrollWorkloadsReplayExactly(t *testing.T) { | |
| 19 | + | seeds := 400 | |
| 20 | + | if testing.Short() { | |
| 21 | + | seeds = 60 | |
| 22 | + | } | |
| 23 | + | for _, kind := range []string{"screen", "frame", "pointer", "hidden"} { | |
| 24 | + | t.Run(kind, func(t *testing.T) { | |
| 25 | + | for seed := range seeds { | |
| 26 | + | runReplayWorkload(t, kind, uint64(seed)) | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func runReplayWorkload(t *testing.T, kind string, seed uint64) { | |
| 33 | + | t.Helper() | |
| 34 | + | r := rand.New(rand.NewPCG(seed, 0x5c4011)) | |
| 35 | + | cols, rows := 4+r.IntN(14), 3+r.IntN(10) | |
| 36 | + | s := vt.NewScreen(cols, rows) | |
| 37 | + | renderer := ansi.New(ansi.Capabilities{}) | |
| 38 | + | replay := vt.NewScreen(cols, rows) | |
| 39 | + | mirror := core.NewFrame(cols, rows) | |
| 40 | + | for step := range 30 { | |
| 41 | + | input := randomScrollInput(r, cols, rows) | |
| 42 | + | s.Write(input) | |
| 43 | + | var source core.CellSource = s | |
| 44 | + | if kind != "screen" { | |
| 45 | + | for y := range rows { | |
| 46 | + | for x := range cols { | |
| 47 | + | mirror.Set(x, y, s.Cell(x, y)) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | switch kind { | |
| 51 | + | case "frame": | |
| 52 | + | source = mirror | |
| 53 | + | case "pointer": | |
| 54 | + | source = &mirror | |
| 55 | + | default: | |
| 56 | + | source = hiddenCellSource{mirror} | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | prepared, err := renderer.Prepare(source, s.Damage(), false) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | prepared.Commit() | |
| 64 | + | replay.Write(prepared.Bytes()) | |
| 65 | + | s.ClearDamage() | |
| 66 | + | for y := range rows { | |
| 67 | + | for x := range cols { | |
| 68 | + | want, got := s.Cell(x, y), replay.Cell(x, y) | |
| 69 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | |
| 70 | + | t.Fatalf("seed %d step %d input %q: cell (%d,%d) = %+v, want %+v", seed, step, input, x, y, got, want) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func randomScrollInput(r *rand.Rand, cols, rows int) []byte { | |
| 78 | + | var b strings.Builder | |
| 79 | + | for range 1 + r.IntN(12) { | |
| 80 | + | switch r.IntN(14) { | |
| 81 | + | case 0: | |
| 82 | + | top := 1 + r.IntN(rows) | |
| 83 | + | fmt.Fprintf(&b, "\x1b[%d;%dr", top, top+r.IntN(rows-top+1)) | |
| 84 | + | case 1: | |
| 85 | + | b.WriteString("\x1b[r") | |
| 86 | + | case 2: | |
| 87 | + | b.WriteString("\x1bD") | |
| 88 | + | case 3: | |
| 89 | + | b.WriteString("\x1bM") | |
| 90 | + | case 4: | |
| 91 | + | fmt.Fprintf(&b, "\x1b[%dS", 1+r.IntN(3)) | |
| 92 | + | case 5: | |
| 93 | + | fmt.Fprintf(&b, "\x1b[%dT", 1+r.IntN(3)) | |
| 94 | + | case 6: | |
| 95 | + | fmt.Fprintf(&b, "\x1b[%dL", 1+r.IntN(3)) | |
| 96 | + | case 7: | |
| 97 | + | fmt.Fprintf(&b, "\x1b[%dM", 1+r.IntN(3)) | |
| 98 | + | case 8: | |
| 99 | + | b.WriteString("\r\n") | |
| 100 | + | case 9: | |
| 101 | + | b.WriteString([]string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J"}[r.IntN(4)]) | |
| 102 | + | case 10: | |
| 103 | + | fmt.Fprintf(&b, "\x1b[%dm", []int{0, 1, 7, 31, 42}[r.IntN(5)]) | |
| 104 | + | case 11: | |
| 105 | + | fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(rows), 1+r.IntN(cols)) | |
| 106 | + | case 12: | |
| 107 | + | b.WriteString("界") | |
| 108 | + | default: | |
| 109 | + | for range 1 + r.IntN(cols) { | |
| 110 | + | b.WriteByte(byte('a' + r.IntN(26))) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return []byte(b.String()) | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package ansi_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "math/rand/v2" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | + | "github.com/bnema/vev-vt/ansi" | |||
| 11 | + | core "github.com/bnema/vev-vt/core" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type hiddenCellSource struct{ core.CellSource } | |||
| 15 | + | ||||
| 16 | + | // Random VT scroll workloads rendered with real screen damage must replay to | |||
| 17 | + | // the exact source grid for every CellSource shape the renderer dispatches on. | |||
| 18 | + | func TestRandomScrollWorkloadsReplayExactly(t *testing.T) { | |||
| 19 | + | seeds := 400 | |||
| 20 | + | if testing.Short() { | |||
| 21 | + | seeds = 60 | |||
| 22 | + | } | |||
| 23 | + | for _, kind := range []string{"screen", "frame", "pointer", "hidden"} { | |||
| 24 | + | t.Run(kind, func(t *testing.T) { | |||
| 25 | + | for seed := range seeds { | |||
| 26 | + | runReplayWorkload(t, kind, uint64(seed)) | |||
| 27 | + | } | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func runReplayWorkload(t *testing.T, kind string, seed uint64) { | |||
| 33 | + | t.Helper() | |||
| 34 | + | r := rand.New(rand.NewPCG(seed, 0x5c4011)) | |||
| 35 | + | cols, rows := 4+r.IntN(14), 3+r.IntN(10) | |||
| 36 | + | s := vt.NewScreen(cols, rows) | |||
| 37 | + | renderer := ansi.New(ansi.Capabilities{}) | |||
| 38 | + | replay := vt.NewScreen(cols, rows) | |||
| 39 | + | mirror := core.NewFrame(cols, rows) | |||
| 40 | + | for step := range 30 { | |||
| 41 | + | input := randomScrollInput(r, cols, rows) | |||
| 42 | + | s.Write(input) | |||
| 43 | + | var source core.CellSource = s | |||
| 44 | + | if kind != "screen" { | |||
| 45 | + | for y := range rows { | |||
| 46 | + | for x := range cols { | |||
| 47 | + | mirror.Set(x, y, s.Cell(x, y)) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | switch kind { | |||
| 51 | + | case "frame": | |||
| 52 | + | source = mirror | |||
| 53 | + | case "pointer": | |||
| 54 | + | source = &mirror | |||
| 55 | + | default: | |||
| 56 | + | source = hiddenCellSource{mirror} | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | prepared, err := renderer.Prepare(source, s.Damage(), false) | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | prepared.Commit() | |||
| 64 | + | replay.Write(prepared.Bytes()) | |||
| 65 | + | s.ClearDamage() | |||
| 66 | + | for y := range rows { | |||
| 67 | + | for x := range cols { | |||
| 68 | + | want, got := s.Cell(x, y), replay.Cell(x, y) | |||
| 69 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | |||
| 70 | + | t.Fatalf("seed %d step %d input %q: cell (%d,%d) = %+v, want %+v", seed, step, input, x, y, got, want) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func randomScrollInput(r *rand.Rand, cols, rows int) []byte { | |||
| 78 | + | var b strings.Builder | |||
| 79 | + | for range 1 + r.IntN(12) { | |||
| 80 | + | switch r.IntN(14) { | |||
| 81 | + | case 0: | |||
| 82 | + | top := 1 + r.IntN(rows) | |||
| 83 | + | fmt.Fprintf(&b, "\x1b[%d;%dr", top, top+r.IntN(rows-top+1)) | |||
| 84 | + | case 1: | |||
| 85 | + | b.WriteString("\x1b[r") | |||
| 86 | + | case 2: | |||
| 87 | + | b.WriteString("\x1bD") | |||
| 88 | + | case 3: | |||
| 89 | + | b.WriteString("\x1bM") | |||
| 90 | + | case 4: | |||
| 91 | + | fmt.Fprintf(&b, "\x1b[%dS", 1+r.IntN(3)) | |||
| 92 | + | case 5: | |||
| 93 | + | fmt.Fprintf(&b, "\x1b[%dT", 1+r.IntN(3)) | |||
| 94 | + | case 6: | |||
| 95 | + | fmt.Fprintf(&b, "\x1b[%dL", 1+r.IntN(3)) | |||
| 96 | + | case 7: | |||
| 97 | + | fmt.Fprintf(&b, "\x1b[%dM", 1+r.IntN(3)) | |||
| 98 | + | case 8: | |||
| 99 | + | b.WriteString("\r\n") | |||
| 100 | + | case 9: | |||
| 101 | + | b.WriteString([]string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J"}[r.IntN(4)]) | |||
| 102 | + | case 10: | |||
| 103 | + | fmt.Fprintf(&b, "\x1b[%dm", []int{0, 1, 7, 31, 42}[r.IntN(5)]) | |||
| 104 | + | case 11: | |||
| 105 | + | fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(rows), 1+r.IntN(cols)) | |||
| 106 | + | case 12: | |||
| 107 | + | b.WriteString("界") | |||
| 108 | + | default: | |||
| 109 | + | for range 1 + r.IntN(cols) { | |||
| 110 | + | b.WriteByte(byte('a' + r.IntN(26))) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return []byte(b.String()) | |||
| 115 | + | } | |||
M ansi/scroll.go +1 -4
| @@ -121,10 +121,7 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com | |||
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | |
| 124 | - | dense, isDense := frame.(Frame) | |
| 125 | - | if pointer, ok := frame.(*Frame); ok { | |
| 126 | - | dense, isDense = *pointer, true | |
| 127 | - | } | |
| 124 | + | dense, isDense := asFrame(frame) | |
| 128 | 125 | columns := frame.Columns() | |
| 129 | 126 | for y := range frame.Rows() { | |
| 130 | 127 | if y >= scroll.Y && y < scroll.Y+scroll.Height { | |
| @@ -121,10 +121,7 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com | |||||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | 123 | func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | ||
| 124 | - | dense, isDense := frame.(Frame) | 124 | + | dense, isDense := asFrame(frame) |
| 125 | - | if pointer, ok := frame.(*Frame); ok { | |||
| 126 | - | dense, isDense = *pointer, true | |||
| 127 | - | } | |||
| 128 | columns := frame.Columns() | 125 | columns := frame.Columns() | ||
| 129 | for y := range frame.Rows() { | 126 | for y := range frame.Rows() { | ||
| 130 | if y >= scroll.Y && y < scroll.Y+scroll.Height { | 127 | if y >= scroll.Y && y < scroll.Y+scroll.Height { | ||
M ansi/scroll_multi_region_test.go +36 -7
| @@ -42,13 +42,42 @@ func TestTwoScrollRegionsInOneBatchReplayExactly(t *testing.T) { | |||
| 42 | 42 | draw() | |
| 43 | 43 | s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r")) | |
| 44 | 44 | draw() | |
| 45 | - | for y := range 8 { | |
| 46 | - | for x := range 10 { | |
| 47 | - | if want, got := s.Cell(x, y), replay.Cell(x, y); want.Rune != got.Rune { | |
| 48 | - | t.Fatalf("cell (%d,%d) = %q, want %q", x, y, got.Rune, want.Rune) | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | } | |
| 45 | + | requireReplayMatches(t, s, replay) | |
| 52 | 46 | }) | |
| 53 | 47 | } | |
| 54 | 48 | } | |
| 49 | + | ||
| 50 | + | // Text damage recorded before a scroll can start on the right half of a wide | |
| 51 | + | // rune that the scroll moved there; the renderer must repaint the rune. | |
| 52 | + | func TestSpanStartingOnWideContinuationRepaintsHead(t *testing.T) { | |
| 53 | + | s := vt.NewScreen(6, 3) | |
| 54 | + | r := ansi.New(ansi.Capabilities{}) | |
| 55 | + | replay := vt.NewScreen(6, 3) | |
| 56 | + | draw := func() { | |
| 57 | + | prepared, err := r.Prepare(s, s.Damage(), false) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | prepared.Commit() | |
| 62 | + | replay.Write(prepared.Bytes()) | |
| 63 | + | s.ClearDamage() | |
| 64 | + | } | |
| 65 | + | draw() | |
| 66 | + | s.Write([]byte("\x1b[2;1H界")) | |
| 67 | + | draw() | |
| 68 | + | s.Write([]byte("\x1b[1;2Hab\x1b[1;2r\x1b[2;1H\n\x1b[r")) | |
| 69 | + | draw() | |
| 70 | + | requireReplayMatches(t, s, replay) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func requireReplayMatches(t *testing.T, source, replay *vt.Screen) { | |
| 74 | + | t.Helper() | |
| 75 | + | for y := range source.Rows() { | |
| 76 | + | for x := range source.Columns() { | |
| 77 | + | want, got := source.Cell(x, y), replay.Cell(x, y) | |
| 78 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | |
| 79 | + | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| @@ -42,13 +42,42 @@ func TestTwoScrollRegionsInOneBatchReplayExactly(t *testing.T) { | |||||
| 42 | draw() | 42 | draw() | ||
| 43 | s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r")) | 43 | s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r")) | ||
| 44 | draw() | 44 | draw() | ||
| 45 | - | for y := range 8 { | 45 | + | requireReplayMatches(t, s, replay) |
| 46 | - | for x := range 10 { | |||
| 47 | - | if want, got := s.Cell(x, y), replay.Cell(x, y); want.Rune != got.Rune { | |||
| 48 | - | t.Fatalf("cell (%d,%d) = %q, want %q", x, y, got.Rune, want.Rune) | |||
| 49 | - | } | |||
| 50 | - | } | |||
| 51 | - | } | |||
| 52 | }) | 46 | }) | ||
| 53 | } | 47 | } | ||
| 54 | } | 48 | } | ||
| 49 | + | ||||
| 50 | + | // Text damage recorded before a scroll can start on the right half of a wide | |||
| 51 | + | // rune that the scroll moved there; the renderer must repaint the rune. | |||
| 52 | + | func TestSpanStartingOnWideContinuationRepaintsHead(t *testing.T) { | |||
| 53 | + | s := vt.NewScreen(6, 3) | |||
| 54 | + | r := ansi.New(ansi.Capabilities{}) | |||
| 55 | + | replay := vt.NewScreen(6, 3) | |||
| 56 | + | draw := func() { | |||
| 57 | + | prepared, err := r.Prepare(s, s.Damage(), false) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | prepared.Commit() | |||
| 62 | + | replay.Write(prepared.Bytes()) | |||
| 63 | + | s.ClearDamage() | |||
| 64 | + | } | |||
| 65 | + | draw() | |||
| 66 | + | s.Write([]byte("\x1b[2;1H界")) | |||
| 67 | + | draw() | |||
| 68 | + | s.Write([]byte("\x1b[1;2Hab\x1b[1;2r\x1b[2;1H\n\x1b[r")) | |||
| 69 | + | draw() | |||
| 70 | + | requireReplayMatches(t, s, replay) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func requireReplayMatches(t *testing.T, source, replay *vt.Screen) { | |||
| 74 | + | t.Helper() | |||
| 75 | + | for y := range source.Rows() { | |||
| 76 | + | for x := range source.Columns() { | |||
| 77 | + | want, got := source.Cell(x, y), replay.Cell(x, y) | |||
| 78 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | |||
| 79 | + | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
M oracle_test.go +13 -8
| @@ -72,6 +72,9 @@ type oracleRun struct { | |||
| 72 | 72 | ansi256 *ansi.Renderer | |
| 73 | 73 | htmlR *html.Renderer | |
| 74 | 74 | replay *vt.Screen | |
| 75 | + | // replay256 receives the ANSI-256 renderer output. Colors are projected, | |
| 76 | + | // so only text and wide-cell structure are compared. | |
| 77 | + | replay256 *vt.Screen | |
| 75 | 78 | // mirror reproduces the vev daemon consumer: one core.Frame reused and | |
| 76 | 79 | // mutated in place between draws, so renderer shadows must not alias it. | |
| 77 | 80 | mirror core.Frame | |
| @@ -432,7 +435,7 @@ func (o *oracleRun) render(step int) { | |||
| 432 | 435 | o.emit("ansi discard") | |
| 433 | 436 | } else { | |
| 434 | 437 | prepared.Commit() | |
| 435 | - | o.applyReplay(out, step) | |
| 438 | + | o.replay = o.applyReplay(o.replay, out, step, true) | |
| 436 | 439 | if !s.AcknowledgeDamage(capture.Generation) { | |
| 437 | 440 | o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed) | |
| 438 | 441 | } | |
| @@ -442,6 +445,7 @@ func (o *oracleRun) render(step int) { | |||
| 442 | 445 | o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err) | |
| 443 | 446 | } | |
| 444 | 447 | o.emit("ansi256 %q", other) | |
| 448 | + | o.replay256 = o.applyReplay(o.replay256, other, step, false) | |
| 445 | 449 | ||
| 446 | 450 | snap := s.Snapshot() | |
| 447 | 451 | cur := snap.Cursor() | |
| @@ -468,20 +472,21 @@ func (o *oracleRun) render(step int) { | |||
| 468 | 472 | // applyReplay feeds committed ANSI output to an independent VT and requires | |
| 469 | 473 | // the visible grid to match the source. This semantic check does not depend on | |
| 470 | 474 | // the golden file. | |
| 471 | - | func (o *oracleRun) applyReplay(out []byte, step int) { | |
| 475 | + | func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen { | |
| 472 | 476 | s := o.screen | |
| 473 | - | if o.replay == nil || o.replay.Columns() != s.Columns() || o.replay.Rows() != s.Rows() { | |
| 474 | - | o.replay = vt.NewScreen(s.Columns(), s.Rows()) | |
| 477 | + | if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() { | |
| 478 | + | replay = vt.NewScreen(s.Columns(), s.Rows()) | |
| 475 | 479 | } | |
| 476 | - | o.replay.Write(out) | |
| 480 | + | replay.Write(out) | |
| 477 | 481 | for y := range s.Rows() { | |
| 478 | 482 | for x := range s.Columns() { | |
| 479 | - | want, got := s.Cell(x, y), o.replay.Cell(x, y) | |
| 480 | - | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | |
| 481 | - | o.t.Fatalf("seed %d step %d: ANSI replay mismatch at (%d,%d): got %s want %s", o.sc.seed, step, x, y, formatCell(got), formatCell(want)) | |
| 483 | + | want, got := s.Cell(x, y), replay.Cell(x, y) | |
| 484 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) { | |
| 485 | + | o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want)) | |
| 482 | 486 | } | |
| 483 | 487 | } | |
| 484 | 488 | } | |
| 489 | + | return replay | |
| 485 | 490 | } | |
| 486 | 491 | ||
| 487 | 492 | func runOracleScenario(t *testing.T, idx int) string { | |
| @@ -72,6 +72,9 @@ type oracleRun struct { | |||||
| 72 | ansi256 *ansi.Renderer | 72 | ansi256 *ansi.Renderer | ||
| 73 | htmlR *html.Renderer | 73 | htmlR *html.Renderer | ||
| 74 | replay *vt.Screen | 74 | replay *vt.Screen | ||
| 75 | + | // replay256 receives the ANSI-256 renderer output. Colors are projected, | |||
| 76 | + | // so only text and wide-cell structure are compared. | |||
| 77 | + | replay256 *vt.Screen | |||
| 75 | // mirror reproduces the vev daemon consumer: one core.Frame reused and | 78 | // mirror reproduces the vev daemon consumer: one core.Frame reused and | ||
| 76 | // mutated in place between draws, so renderer shadows must not alias it. | 79 | // mutated in place between draws, so renderer shadows must not alias it. | ||
| 77 | mirror core.Frame | 80 | mirror core.Frame | ||
| @@ -432,7 +435,7 @@ func (o *oracleRun) render(step int) { | |||||
| 432 | o.emit("ansi discard") | 435 | o.emit("ansi discard") | ||
| 433 | } else { | 436 | } else { | ||
| 434 | prepared.Commit() | 437 | prepared.Commit() | ||
| 435 | - | o.applyReplay(out, step) | 438 | + | o.replay = o.applyReplay(o.replay, out, step, true) |
| 436 | if !s.AcknowledgeDamage(capture.Generation) { | 439 | if !s.AcknowledgeDamage(capture.Generation) { | ||
| 437 | o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed) | 440 | o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed) | ||
| 438 | } | 441 | } | ||
| @@ -442,6 +445,7 @@ func (o *oracleRun) render(step int) { | |||||
| 442 | o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err) | 445 | o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err) | ||
| 443 | } | 446 | } | ||
| 444 | o.emit("ansi256 %q", other) | 447 | o.emit("ansi256 %q", other) | ||
| 448 | + | o.replay256 = o.applyReplay(o.replay256, other, step, false) | |||
| 445 | 449 | ||||
| 446 | snap := s.Snapshot() | 450 | snap := s.Snapshot() | ||
| 447 | cur := snap.Cursor() | 451 | cur := snap.Cursor() | ||
| @@ -468,20 +472,21 @@ func (o *oracleRun) render(step int) { | |||||
| 468 | // applyReplay feeds committed ANSI output to an independent VT and requires | 472 | // applyReplay feeds committed ANSI output to an independent VT and requires | ||
| 469 | // the visible grid to match the source. This semantic check does not depend on | 473 | // the visible grid to match the source. This semantic check does not depend on | ||
| 470 | // the golden file. | 474 | // the golden file. | ||
| 471 | - | func (o *oracleRun) applyReplay(out []byte, step int) { | 475 | + | func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen { |
| 472 | s := o.screen | 476 | s := o.screen | ||
| 473 | - | if o.replay == nil || o.replay.Columns() != s.Columns() || o.replay.Rows() != s.Rows() { | 477 | + | if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() { |
| 474 | - | o.replay = vt.NewScreen(s.Columns(), s.Rows()) | 478 | + | replay = vt.NewScreen(s.Columns(), s.Rows()) |
| 475 | } | 479 | } | ||
| 476 | - | o.replay.Write(out) | 480 | + | replay.Write(out) |
| 477 | for y := range s.Rows() { | 481 | for y := range s.Rows() { | ||
| 478 | for x := range s.Columns() { | 482 | for x := range s.Columns() { | ||
| 479 | - | want, got := s.Cell(x, y), o.replay.Cell(x, y) | 483 | + | want, got := s.Cell(x, y), replay.Cell(x, y) |
| 480 | - | if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) { | 484 | + | if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) { |
| 481 | - | o.t.Fatalf("seed %d step %d: ANSI replay mismatch at (%d,%d): got %s want %s", o.sc.seed, step, x, y, formatCell(got), formatCell(want)) | 485 | + | o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want)) |
| 482 | } | 486 | } | ||
| 483 | } | 487 | } | ||
| 484 | } | 488 | } | ||
| 489 | + | return replay | |||
| 485 | } | 490 | } | ||
| 486 | 491 | ||||
| 487 | func runOracleScenario(t *testing.T, idx int) string { | 492 | func runOracleScenario(t *testing.T, idx int) string { | ||