perf(renderer,vt): line-offset indirection — rotate instead of copy on full-width scroll
- commit
- 7a972e7555dea95e335c27aa7ec074012e1aaaa2
- author
- brice · · 2026-07-02 09:55 UTC
- parent
- 4ac84f4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +374 -39
M pkg/renderer/frame.go +92 -3
| @@ -2,10 +2,19 @@ package renderer | |||
| 2 | 2 | ||
| 3 | 3 | import "fmt" | |
| 4 | 4 | ||
| 5 | + | // Frame is a fixed-size grid of cells. Logical rows are decoupled from their | |
| 6 | + | // physical storage through lineOffset: logical row y lives at | |
| 7 | + | // Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width | |
| 8 | + | // scrolls rotate offsets instead of copying cell memory (NeoVim's grid | |
| 9 | + | // technique; cf. nvim grid.c line_offset[]). All cell access must go through | |
| 10 | + | // the accessors (At/Set/Row) so callers never assume a physical layout. | |
| 5 | 11 | type Frame struct { | |
| 6 | 12 | Width int | |
| 7 | 13 | Height int | |
| 8 | 14 | Cells []Cell | |
| 15 | + | // lineOffset maps a logical row index to its physical base index into | |
| 16 | + | // Cells. It is always a permutation of the canonical offsets y*Width. | |
| 17 | + | lineOffset []int | |
| 9 | 18 | } | |
| 10 | 19 | ||
| 11 | 20 | func NewFrame(width, height int) Frame { | |
| @@ -13,7 +22,11 @@ func NewFrame(width, height int) Frame { | |||
| 13 | 22 | for i := range cells { | |
| 14 | 23 | cells[i] = BlankCell() | |
| 15 | 24 | } | |
| 16 | - | return Frame{Width: width, Height: height, Cells: cells} | |
| 25 | + | lineOffset := make([]int, height) | |
| 26 | + | for y := range lineOffset { | |
| 27 | + | lineOffset[y] = y * width | |
| 28 | + | } | |
| 29 | + | return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset} | |
| 17 | 30 | } | |
| 18 | 31 | ||
| 19 | 32 | func (f Frame) Validate() error { | |
| @@ -23,13 +36,89 @@ func (f Frame) Validate() error { | |||
| 23 | 36 | if len(f.Cells) != f.Width*f.Height { | |
| 24 | 37 | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |
| 25 | 38 | } | |
| 39 | + | if len(f.lineOffset) != f.Height { | |
| 40 | + | return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height) | |
| 41 | + | } | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // CheckInvariants verifies that lineOffset is a permutation of the canonical | |
| 46 | + | // row offsets: every entry is a non-negative multiple of Width within bounds, | |
| 47 | + | // and every logical row maps to a distinct physical row. It is used by tests | |
| 48 | + | // and assertions; the hot path uses the lighter Validate. | |
| 49 | + | func (f Frame) CheckInvariants() error { | |
| 50 | + | if f.Width <= 0 { | |
| 51 | + | return fmt.Errorf("invalid width %d", f.Width) | |
| 52 | + | } | |
| 53 | + | if len(f.lineOffset) != f.Height { | |
| 54 | + | return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height) | |
| 55 | + | } | |
| 56 | + | seen := make([]bool, f.Height) | |
| 57 | + | for y, off := range f.lineOffset { | |
| 58 | + | if off < 0 || off%f.Width != 0 { | |
| 59 | + | return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width) | |
| 60 | + | } | |
| 61 | + | phys := off / f.Width | |
| 62 | + | if phys >= f.Height { | |
| 63 | + | return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height) | |
| 64 | + | } | |
| 65 | + | if seen[phys] { | |
| 66 | + | return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys) | |
| 67 | + | } | |
| 68 | + | seen[phys] = true | |
| 69 | + | } | |
| 26 | 70 | return nil | |
| 27 | 71 | } | |
| 28 | 72 | ||
| 29 | 73 | func (f Frame) At(x, y int) Cell { | |
| 30 | - | return f.Cells[y*f.Width+x] | |
| 74 | + | return f.Cells[f.lineOffset[y]+x] | |
| 31 | 75 | } | |
| 32 | 76 | ||
| 33 | 77 | func (f Frame) Set(x, y int, cell Cell) { | |
| 34 | - | f.Cells[y*f.Width+x] = cell | |
| 78 | + | f.Cells[f.lineOffset[y]+x] = cell | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Row returns the backing slice for logical row y. Mutating the returned slice | |
| 82 | + | // mutates the frame in place. The slice is valid until the next scroll or | |
| 83 | + | // resize that reassigns offsets. | |
| 84 | + | func (f Frame) Row(y int) []Cell { | |
| 85 | + | base := f.lineOffset[y] | |
| 86 | + | return f.Cells[base : base+f.Width] | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating | |
| 90 | + | // their physical offsets: no per-cell copying. Rows scrolled off the top are | |
| 91 | + | // recycled to the bottom and blanked in place. It assumes the full frame width | |
| 92 | + | // and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller | |
| 93 | + | // clamps). The receiver is a value, but lineOffset and Cells alias the caller's | |
| 94 | + | // backing arrays, so in-place mutations are visible to the caller. | |
| 95 | + | func (f Frame) ScrollUp(top, bottom, n int) { | |
| 96 | + | for ; n > 0; n-- { | |
| 97 | + | recycled := f.lineOffset[top] | |
| 98 | + | for y := top; y < bottom; y++ { | |
| 99 | + | f.lineOffset[y] = f.lineOffset[y+1] | |
| 100 | + | } | |
| 101 | + | f.lineOffset[bottom] = recycled | |
| 102 | + | blankRow(f.Cells[recycled : recycled+f.Width]) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by | |
| 107 | + | // rotating their physical offsets. Rows scrolled off the bottom are recycled to | |
| 108 | + | // the top and blanked in place. See ScrollUp for preconditions. | |
| 109 | + | func (f Frame) ScrollDown(top, bottom, n int) { | |
| 110 | + | for ; n > 0; n-- { | |
| 111 | + | recycled := f.lineOffset[bottom] | |
| 112 | + | for y := bottom; y > top; y-- { | |
| 113 | + | f.lineOffset[y] = f.lineOffset[y-1] | |
| 114 | + | } | |
| 115 | + | f.lineOffset[top] = recycled | |
| 116 | + | blankRow(f.Cells[recycled : recycled+f.Width]) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func blankRow(row []Cell) { | |
| 121 | + | for i := range row { | |
| 122 | + | row[i] = BlankCell() | |
| 123 | + | } | |
| 35 | 124 | } | |
| @@ -2,10 +2,19 @@ package renderer | |||||
| 2 | 2 | ||||
| 3 | import "fmt" | 3 | import "fmt" | ||
| 4 | 4 | ||||
| 5 | + | // Frame is a fixed-size grid of cells. Logical rows are decoupled from their | |||
| 6 | + | // physical storage through lineOffset: logical row y lives at | |||
| 7 | + | // Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width | |||
| 8 | + | // scrolls rotate offsets instead of copying cell memory (NeoVim's grid | |||
| 9 | + | // technique; cf. nvim grid.c line_offset[]). All cell access must go through | |||
| 10 | + | // the accessors (At/Set/Row) so callers never assume a physical layout. | |||
| 5 | type Frame struct { | 11 | type Frame struct { | ||
| 6 | Width int | 12 | Width int | ||
| 7 | Height int | 13 | Height int | ||
| 8 | Cells []Cell | 14 | Cells []Cell | ||
| 15 | + | // lineOffset maps a logical row index to its physical base index into | |||
| 16 | + | // Cells. It is always a permutation of the canonical offsets y*Width. | |||
| 17 | + | lineOffset []int | |||
| 9 | } | 18 | } | ||
| 10 | 19 | ||||
| 11 | func NewFrame(width, height int) Frame { | 20 | func NewFrame(width, height int) Frame { | ||
| @@ -13,7 +22,11 @@ func NewFrame(width, height int) Frame { | |||||
| 13 | for i := range cells { | 22 | for i := range cells { | ||
| 14 | cells[i] = BlankCell() | 23 | cells[i] = BlankCell() | ||
| 15 | } | 24 | } | ||
| 16 | - | return Frame{Width: width, Height: height, Cells: cells} | 25 | + | lineOffset := make([]int, height) |
| 26 | + | for y := range lineOffset { | |||
| 27 | + | lineOffset[y] = y * width | |||
| 28 | + | } | |||
| 29 | + | return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset} | |||
| 17 | } | 30 | } | ||
| 18 | 31 | ||||
| 19 | func (f Frame) Validate() error { | 32 | func (f Frame) Validate() error { | ||
| @@ -23,13 +36,89 @@ func (f Frame) Validate() error { | |||||
| 23 | if len(f.Cells) != f.Width*f.Height { | 36 | if len(f.Cells) != f.Width*f.Height { | ||
| 24 | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | 37 | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | ||
| 25 | } | 38 | } | ||
| 39 | + | if len(f.lineOffset) != f.Height { | |||
| 40 | + | return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height) | |||
| 41 | + | } | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // CheckInvariants verifies that lineOffset is a permutation of the canonical | |||
| 46 | + | // row offsets: every entry is a non-negative multiple of Width within bounds, | |||
| 47 | + | // and every logical row maps to a distinct physical row. It is used by tests | |||
| 48 | + | // and assertions; the hot path uses the lighter Validate. | |||
| 49 | + | func (f Frame) CheckInvariants() error { | |||
| 50 | + | if f.Width <= 0 { | |||
| 51 | + | return fmt.Errorf("invalid width %d", f.Width) | |||
| 52 | + | } | |||
| 53 | + | if len(f.lineOffset) != f.Height { | |||
| 54 | + | return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height) | |||
| 55 | + | } | |||
| 56 | + | seen := make([]bool, f.Height) | |||
| 57 | + | for y, off := range f.lineOffset { | |||
| 58 | + | if off < 0 || off%f.Width != 0 { | |||
| 59 | + | return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width) | |||
| 60 | + | } | |||
| 61 | + | phys := off / f.Width | |||
| 62 | + | if phys >= f.Height { | |||
| 63 | + | return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height) | |||
| 64 | + | } | |||
| 65 | + | if seen[phys] { | |||
| 66 | + | return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys) | |||
| 67 | + | } | |||
| 68 | + | seen[phys] = true | |||
| 69 | + | } | |||
| 26 | return nil | 70 | return nil | ||
| 27 | } | 71 | } | ||
| 28 | 72 | ||||
| 29 | func (f Frame) At(x, y int) Cell { | 73 | func (f Frame) At(x, y int) Cell { | ||
| 30 | - | return f.Cells[y*f.Width+x] | 74 | + | return f.Cells[f.lineOffset[y]+x] |
| 31 | } | 75 | } | ||
| 32 | 76 | ||||
| 33 | func (f Frame) Set(x, y int, cell Cell) { | 77 | func (f Frame) Set(x, y int, cell Cell) { | ||
| 34 | - | f.Cells[y*f.Width+x] = cell | 78 | + | f.Cells[f.lineOffset[y]+x] = cell |
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Row returns the backing slice for logical row y. Mutating the returned slice | |||
| 82 | + | // mutates the frame in place. The slice is valid until the next scroll or | |||
| 83 | + | // resize that reassigns offsets. | |||
| 84 | + | func (f Frame) Row(y int) []Cell { | |||
| 85 | + | base := f.lineOffset[y] | |||
| 86 | + | return f.Cells[base : base+f.Width] | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating | |||
| 90 | + | // their physical offsets: no per-cell copying. Rows scrolled off the top are | |||
| 91 | + | // recycled to the bottom and blanked in place. It assumes the full frame width | |||
| 92 | + | // and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller | |||
| 93 | + | // clamps). The receiver is a value, but lineOffset and Cells alias the caller's | |||
| 94 | + | // backing arrays, so in-place mutations are visible to the caller. | |||
| 95 | + | func (f Frame) ScrollUp(top, bottom, n int) { | |||
| 96 | + | for ; n > 0; n-- { | |||
| 97 | + | recycled := f.lineOffset[top] | |||
| 98 | + | for y := top; y < bottom; y++ { | |||
| 99 | + | f.lineOffset[y] = f.lineOffset[y+1] | |||
| 100 | + | } | |||
| 101 | + | f.lineOffset[bottom] = recycled | |||
| 102 | + | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by | |||
| 107 | + | // rotating their physical offsets. Rows scrolled off the bottom are recycled to | |||
| 108 | + | // the top and blanked in place. See ScrollUp for preconditions. | |||
| 109 | + | func (f Frame) ScrollDown(top, bottom, n int) { | |||
| 110 | + | for ; n > 0; n-- { | |||
| 111 | + | recycled := f.lineOffset[bottom] | |||
| 112 | + | for y := bottom; y > top; y-- { | |||
| 113 | + | f.lineOffset[y] = f.lineOffset[y-1] | |||
| 114 | + | } | |||
| 115 | + | f.lineOffset[top] = recycled | |||
| 116 | + | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func blankRow(row []Cell) { | |||
| 121 | + | for i := range row { | |||
| 122 | + | row[i] = BlankCell() | |||
| 123 | + | } | |||
| 35 | } | 124 | } | ||
A pkg/renderer/frame_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // rowRunes renders a logical row to a string for readable assertions. | |
| 6 | + | func rowRunes(f Frame, y int) string { | |
| 7 | + | out := make([]rune, f.Width) | |
| 8 | + | for x := range f.Width { | |
| 9 | + | out[x] = f.At(x, y).Rune | |
| 10 | + | } | |
| 11 | + | return string(out) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // fillFrame writes one distinct rune per logical row so rotation is observable. | |
| 15 | + | func fillFrame(f Frame, rows []string) { | |
| 16 | + | for y, s := range rows { | |
| 17 | + | for x, r := range s { | |
| 18 | + | f.Set(x, y, Cell{Rune: r, Style: DefaultStyle()}) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestNewFrameCanonicalOffsets(t *testing.T) { | |
| 24 | + | f := NewFrame(4, 3) | |
| 25 | + | for y := 0; y < f.Height; y++ { | |
| 26 | + | if f.lineOffset[y] != y*f.Width { | |
| 27 | + | t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if err := f.CheckInvariants(); err != nil { | |
| 31 | + | t.Fatalf("fresh frame invariants: %v", err) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // TestCheckInvariantsDetectsBrokenRotation is the RED guard: a rotation that | |
| 36 | + | // duplicates a physical offset (the classic off-by-one bug) must be rejected. | |
| 37 | + | func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) { | |
| 38 | + | f := NewFrame(4, 3) | |
| 39 | + | // Corrupt: two logical rows map to the same physical row. | |
| 40 | + | f.lineOffset[1] = f.lineOffset[0] | |
| 41 | + | if err := f.CheckInvariants(); err == nil { | |
| 42 | + | t.Fatal("expected invariant violation for duplicate physical row, got nil") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | f = NewFrame(4, 3) | |
| 46 | + | f.lineOffset[0] = 1 // not a multiple of width | |
| 47 | + | if err := f.CheckInvariants(); err == nil { | |
| 48 | + | t.Fatal("expected invariant violation for non-multiple offset, got nil") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | f = NewFrame(4, 3) | |
| 52 | + | f.lineOffset[2] = 99 // out of range | |
| 53 | + | if err := f.CheckInvariants(); err == nil { | |
| 54 | + | t.Fatal("expected invariant violation for out-of-range offset, got nil") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestScrollUpEquivalence(t *testing.T) { | |
| 59 | + | tests := []struct { | |
| 60 | + | name string | |
| 61 | + | width, height int | |
| 62 | + | rows []string | |
| 63 | + | top, bottom int | |
| 64 | + | n int | |
| 65 | + | want []string | |
| 66 | + | }{ | |
| 67 | + | { | |
| 68 | + | name: "scroll up 1 full screen", width: 3, height: 4, | |
| 69 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |
| 70 | + | top: 0, bottom: 3, n: 1, | |
| 71 | + | want: []string{"bbb", "ccc", "ddd", " "}, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "scroll up 2 full screen", width: 3, height: 4, | |
| 75 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |
| 76 | + | top: 0, bottom: 3, n: 2, | |
| 77 | + | want: []string{"ccc", "ddd", " ", " "}, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "scroll up region subset", width: 3, height: 5, | |
| 81 | + | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |
| 82 | + | top: 1, bottom: 3, n: 1, | |
| 83 | + | want: []string{"aaa", "ccc", "ddd", " ", "eee"}, | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | for _, tt := range tests { | |
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |
| 88 | + | f := NewFrame(tt.width, tt.height) | |
| 89 | + | fillFrame(f, tt.rows) | |
| 90 | + | f.ScrollUp(tt.top, tt.bottom, tt.n) | |
| 91 | + | for y := 0; y < f.Height; y++ { | |
| 92 | + | if got := rowRunes(f, y); got != tt.want[y] { | |
| 93 | + | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if err := f.CheckInvariants(); err != nil { | |
| 97 | + | t.Errorf("invariants after scroll up: %v", err) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestScrollDownEquivalence(t *testing.T) { | |
| 104 | + | tests := []struct { | |
| 105 | + | name string | |
| 106 | + | width, height int | |
| 107 | + | rows []string | |
| 108 | + | top, bottom int | |
| 109 | + | n int | |
| 110 | + | want []string | |
| 111 | + | }{ | |
| 112 | + | { | |
| 113 | + | name: "scroll down 1 full screen", width: 3, height: 4, | |
| 114 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |
| 115 | + | top: 0, bottom: 3, n: 1, | |
| 116 | + | want: []string{" ", "aaa", "bbb", "ccc"}, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "scroll down 2 region subset", width: 3, height: 5, | |
| 120 | + | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |
| 121 | + | top: 1, bottom: 4, n: 2, | |
| 122 | + | want: []string{"aaa", " ", " ", "bbb", "ccc"}, | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | for _, tt := range tests { | |
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |
| 127 | + | f := NewFrame(tt.width, tt.height) | |
| 128 | + | fillFrame(f, tt.rows) | |
| 129 | + | f.ScrollDown(tt.top, tt.bottom, tt.n) | |
| 130 | + | for y := 0; y < f.Height; y++ { | |
| 131 | + | if got := rowRunes(f, y); got != tt.want[y] { | |
| 132 | + | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if err := f.CheckInvariants(); err != nil { | |
| 136 | + | t.Errorf("invariants after scroll down: %v", err) | |
| 137 | + | } | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // TestRotationIsPermutationUnderRandomOps drives an arbitrary sequence of | |
| 143 | + | // scrolls and asserts lineOffset stays a valid permutation throughout. | |
| 144 | + | func TestRotationIsPermutationUnderRandomOps(t *testing.T) { | |
| 145 | + | f := NewFrame(5, 6) | |
| 146 | + | fillFrame(f, []string{"aaaaa", "bbbbb", "ccccc", "ddddd", "eeeee", "fffff"}) | |
| 147 | + | ops := []struct { | |
| 148 | + | up bool | |
| 149 | + | top, bottom int | |
| 150 | + | n int | |
| 151 | + | }{ | |
| 152 | + | {true, 0, 5, 1}, {false, 1, 4, 2}, {true, 2, 5, 3}, | |
| 153 | + | {false, 0, 5, 1}, {true, 0, 2, 2}, {false, 3, 5, 1}, | |
| 154 | + | } | |
| 155 | + | for i, op := range ops { | |
| 156 | + | if op.up { | |
| 157 | + | f.ScrollUp(op.top, op.bottom, op.n) | |
| 158 | + | } else { | |
| 159 | + | f.ScrollDown(op.top, op.bottom, op.n) | |
| 160 | + | } | |
| 161 | + | if err := f.CheckInvariants(); err != nil { | |
| 162 | + | t.Fatalf("op %d (%+v): invariants broken: %v", i, op, err) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // rowRunes renders a logical row to a string for readable assertions. | |||
| 6 | + | func rowRunes(f Frame, y int) string { | |||
| 7 | + | out := make([]rune, f.Width) | |||
| 8 | + | for x := range f.Width { | |||
| 9 | + | out[x] = f.At(x, y).Rune | |||
| 10 | + | } | |||
| 11 | + | return string(out) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // fillFrame writes one distinct rune per logical row so rotation is observable. | |||
| 15 | + | func fillFrame(f Frame, rows []string) { | |||
| 16 | + | for y, s := range rows { | |||
| 17 | + | for x, r := range s { | |||
| 18 | + | f.Set(x, y, Cell{Rune: r, Style: DefaultStyle()}) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestNewFrameCanonicalOffsets(t *testing.T) { | |||
| 24 | + | f := NewFrame(4, 3) | |||
| 25 | + | for y := 0; y < f.Height; y++ { | |||
| 26 | + | if f.lineOffset[y] != y*f.Width { | |||
| 27 | + | t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | if err := f.CheckInvariants(); err != nil { | |||
| 31 | + | t.Fatalf("fresh frame invariants: %v", err) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // TestCheckInvariantsDetectsBrokenRotation is the RED guard: a rotation that | |||
| 36 | + | // duplicates a physical offset (the classic off-by-one bug) must be rejected. | |||
| 37 | + | func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) { | |||
| 38 | + | f := NewFrame(4, 3) | |||
| 39 | + | // Corrupt: two logical rows map to the same physical row. | |||
| 40 | + | f.lineOffset[1] = f.lineOffset[0] | |||
| 41 | + | if err := f.CheckInvariants(); err == nil { | |||
| 42 | + | t.Fatal("expected invariant violation for duplicate physical row, got nil") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | f = NewFrame(4, 3) | |||
| 46 | + | f.lineOffset[0] = 1 // not a multiple of width | |||
| 47 | + | if err := f.CheckInvariants(); err == nil { | |||
| 48 | + | t.Fatal("expected invariant violation for non-multiple offset, got nil") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | f = NewFrame(4, 3) | |||
| 52 | + | f.lineOffset[2] = 99 // out of range | |||
| 53 | + | if err := f.CheckInvariants(); err == nil { | |||
| 54 | + | t.Fatal("expected invariant violation for out-of-range offset, got nil") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestScrollUpEquivalence(t *testing.T) { | |||
| 59 | + | tests := []struct { | |||
| 60 | + | name string | |||
| 61 | + | width, height int | |||
| 62 | + | rows []string | |||
| 63 | + | top, bottom int | |||
| 64 | + | n int | |||
| 65 | + | want []string | |||
| 66 | + | }{ | |||
| 67 | + | { | |||
| 68 | + | name: "scroll up 1 full screen", width: 3, height: 4, | |||
| 69 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 70 | + | top: 0, bottom: 3, n: 1, | |||
| 71 | + | want: []string{"bbb", "ccc", "ddd", " "}, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "scroll up 2 full screen", width: 3, height: 4, | |||
| 75 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 76 | + | top: 0, bottom: 3, n: 2, | |||
| 77 | + | want: []string{"ccc", "ddd", " ", " "}, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "scroll up region subset", width: 3, height: 5, | |||
| 81 | + | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |||
| 82 | + | top: 1, bottom: 3, n: 1, | |||
| 83 | + | want: []string{"aaa", "ccc", "ddd", " ", "eee"}, | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | for _, tt := range tests { | |||
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 88 | + | f := NewFrame(tt.width, tt.height) | |||
| 89 | + | fillFrame(f, tt.rows) | |||
| 90 | + | f.ScrollUp(tt.top, tt.bottom, tt.n) | |||
| 91 | + | for y := 0; y < f.Height; y++ { | |||
| 92 | + | if got := rowRunes(f, y); got != tt.want[y] { | |||
| 93 | + | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if err := f.CheckInvariants(); err != nil { | |||
| 97 | + | t.Errorf("invariants after scroll up: %v", err) | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestScrollDownEquivalence(t *testing.T) { | |||
| 104 | + | tests := []struct { | |||
| 105 | + | name string | |||
| 106 | + | width, height int | |||
| 107 | + | rows []string | |||
| 108 | + | top, bottom int | |||
| 109 | + | n int | |||
| 110 | + | want []string | |||
| 111 | + | }{ | |||
| 112 | + | { | |||
| 113 | + | name: "scroll down 1 full screen", width: 3, height: 4, | |||
| 114 | + | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 115 | + | top: 0, bottom: 3, n: 1, | |||
| 116 | + | want: []string{" ", "aaa", "bbb", "ccc"}, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "scroll down 2 region subset", width: 3, height: 5, | |||
| 120 | + | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |||
| 121 | + | top: 1, bottom: 4, n: 2, | |||
| 122 | + | want: []string{"aaa", " ", " ", "bbb", "ccc"}, | |||
| 123 | + | }, | |||
| 124 | + | } | |||
| 125 | + | for _, tt := range tests { | |||
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 127 | + | f := NewFrame(tt.width, tt.height) | |||
| 128 | + | fillFrame(f, tt.rows) | |||
| 129 | + | f.ScrollDown(tt.top, tt.bottom, tt.n) | |||
| 130 | + | for y := 0; y < f.Height; y++ { | |||
| 131 | + | if got := rowRunes(f, y); got != tt.want[y] { | |||
| 132 | + | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | if err := f.CheckInvariants(); err != nil { | |||
| 136 | + | t.Errorf("invariants after scroll down: %v", err) | |||
| 137 | + | } | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // TestRotationIsPermutationUnderRandomOps drives an arbitrary sequence of | |||
| 143 | + | // scrolls and asserts lineOffset stays a valid permutation throughout. | |||
| 144 | + | func TestRotationIsPermutationUnderRandomOps(t *testing.T) { | |||
| 145 | + | f := NewFrame(5, 6) | |||
| 146 | + | fillFrame(f, []string{"aaaaa", "bbbbb", "ccccc", "ddddd", "eeeee", "fffff"}) | |||
| 147 | + | ops := []struct { | |||
| 148 | + | up bool | |||
| 149 | + | top, bottom int | |||
| 150 | + | n int | |||
| 151 | + | }{ | |||
| 152 | + | {true, 0, 5, 1}, {false, 1, 4, 2}, {true, 2, 5, 3}, | |||
| 153 | + | {false, 0, 5, 1}, {true, 0, 2, 2}, {false, 3, 5, 1}, | |||
| 154 | + | } | |||
| 155 | + | for i, op := range ops { | |||
| 156 | + | if op.up { | |||
| 157 | + | f.ScrollUp(op.top, op.bottom, op.n) | |||
| 158 | + | } else { | |||
| 159 | + | f.ScrollDown(op.top, op.bottom, op.n) | |||
| 160 | + | } | |||
| 161 | + | if err := f.CheckInvariants(); err != nil { | |||
| 162 | + | t.Fatalf("op %d (%+v): invariants broken: %v", i, op, err) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | } | |||
M pkg/renderer/renderer.go +17 -3
| @@ -198,9 +198,12 @@ func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, sty | |||
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 200 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | |
| 201 | + | // The shadow is stored in canonical (logical) row order; the frame is read | |
| 202 | + | // through its logical accessor so the frame's physical layout is invisible. | |
| 201 | 203 | start := y * frame.Width | |
| 204 | + | row := frame.Row(y) | |
| 202 | 205 | for i := 0; i < frame.Width; i++ { | |
| 203 | - | if !r.shadow[start+i].Equal(frame.Cells[start+i]) { | |
| 206 | + | if !r.shadow[start+i].Equal(row[i]) { | |
| 204 | 207 | return true | |
| 205 | 208 | } | |
| 206 | 209 | } | |
| @@ -210,7 +213,17 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||
| 210 | 213 | func (r *Renderer) replaceShadow(frame Frame) { | |
| 211 | 214 | r.width = frame.Width | |
| 212 | 215 | r.height = frame.Height | |
| 213 | - | r.shadow = append(r.shadow[:0], frame.Cells...) | |
| 216 | + | n := len(frame.Cells) | |
| 217 | + | if cap(r.shadow) >= n { | |
| 218 | + | r.shadow = r.shadow[:n] | |
| 219 | + | } else { | |
| 220 | + | r.shadow = make([]Cell, n) | |
| 221 | + | } | |
| 222 | + | // Copy logical rows into canonical shadow order so the frame's line-offset | |
| 223 | + | // rotation never leaks into the renderer's own buffer. | |
| 224 | + | for y := 0; y < frame.Height; y++ { | |
| 225 | + | copy(r.shadow[y*frame.Width:], frame.Row(y)) | |
| 226 | + | } | |
| 214 | 227 | } | |
| 215 | 228 | ||
| 216 | 229 | func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) { | |
| @@ -236,7 +249,8 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) { | |||
| 236 | 249 | } | |
| 237 | 250 | for row := y; row < y+height; row++ { | |
| 238 | 251 | start := row*frame.Width + x | |
| 239 | - | copy(r.shadow[start:start+width], frame.Cells[start:start+width]) | |
| 252 | + | frameRow := frame.Row(row) | |
| 253 | + | copy(r.shadow[start:start+width], frameRow[x:x+width]) | |
| 240 | 254 | } | |
| 241 | 255 | } | |
| 242 | 256 | ||
| @@ -198,9 +198,12 @@ func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, sty | |||||
| 198 | } | 198 | } | ||
| 199 | 199 | ||||
| 200 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | 200 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | ||
| 201 | + | // The shadow is stored in canonical (logical) row order; the frame is read | |||
| 202 | + | // through its logical accessor so the frame's physical layout is invisible. | |||
| 201 | start := y * frame.Width | 203 | start := y * frame.Width | ||
| 204 | + | row := frame.Row(y) | |||
| 202 | for i := 0; i < frame.Width; i++ { | 205 | for i := 0; i < frame.Width; i++ { | ||
| 203 | - | if !r.shadow[start+i].Equal(frame.Cells[start+i]) { | 206 | + | if !r.shadow[start+i].Equal(row[i]) { |
| 204 | return true | 207 | return true | ||
| 205 | } | 208 | } | ||
| 206 | } | 209 | } | ||
| @@ -210,7 +213,17 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||||
| 210 | func (r *Renderer) replaceShadow(frame Frame) { | 213 | func (r *Renderer) replaceShadow(frame Frame) { | ||
| 211 | r.width = frame.Width | 214 | r.width = frame.Width | ||
| 212 | r.height = frame.Height | 215 | r.height = frame.Height | ||
| 213 | - | r.shadow = append(r.shadow[:0], frame.Cells...) | 216 | + | n := len(frame.Cells) |
| 217 | + | if cap(r.shadow) >= n { | |||
| 218 | + | r.shadow = r.shadow[:n] | |||
| 219 | + | } else { | |||
| 220 | + | r.shadow = make([]Cell, n) | |||
| 221 | + | } | |||
| 222 | + | // Copy logical rows into canonical shadow order so the frame's line-offset | |||
| 223 | + | // rotation never leaks into the renderer's own buffer. | |||
| 224 | + | for y := 0; y < frame.Height; y++ { | |||
| 225 | + | copy(r.shadow[y*frame.Width:], frame.Row(y)) | |||
| 226 | + | } | |||
| 214 | } | 227 | } | ||
| 215 | 228 | ||||
| 216 | func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) { | 229 | func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) { | ||
| @@ -236,7 +249,8 @@ func (r *Renderer) syncRect(frame Frame, x, y, width, height int) { | |||||
| 236 | } | 249 | } | ||
| 237 | for row := y; row < y+height; row++ { | 250 | for row := y; row < y+height; row++ { | ||
| 238 | start := row*frame.Width + x | 251 | start := row*frame.Width + x | ||
| 239 | - | copy(r.shadow[start:start+width], frame.Cells[start:start+width]) | 252 | + | frameRow := frame.Row(row) |
| 253 | + | copy(r.shadow[start:start+width], frameRow[x:x+width]) | |||
| 240 | } | 254 | } | ||
| 241 | } | 255 | } | ||
| 242 | 256 | ||||
M pkg/renderer/scroll.go +2 -2
| @@ -44,10 +44,10 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 44 | 44 | ||
| 45 | 45 | func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool { | |
| 46 | 46 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 47 | - | frameStart := y*frame.Width + scroll.X | |
| 47 | + | frameRow := frame.Row(y) | |
| 48 | 48 | shadowStart := (y+scroll.Count)*r.width + scroll.X | |
| 49 | 49 | for x := 0; x < scroll.Width; x++ { | |
| 50 | - | if r.shadow[shadowStart+x].Equal(frame.Cells[frameStart+x]) { | |
| 50 | + | if r.shadow[shadowStart+x].Equal(frameRow[scroll.X+x]) { | |
| 51 | 51 | continue | |
| 52 | 52 | } | |
| 53 | 53 | if !damageCoversCell(damage, scroll.X+x, y) { | |
| @@ -44,10 +44,10 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||||
| 44 | 44 | ||||
| 45 | func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool { | 45 | func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool { | ||
| 46 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | 46 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | ||
| 47 | - | frameStart := y*frame.Width + scroll.X | 47 | + | frameRow := frame.Row(y) |
| 48 | shadowStart := (y+scroll.Count)*r.width + scroll.X | 48 | shadowStart := (y+scroll.Count)*r.width + scroll.X | ||
| 49 | for x := 0; x < scroll.Width; x++ { | 49 | for x := 0; x < scroll.Width; x++ { | ||
| 50 | - | if r.shadow[shadowStart+x].Equal(frame.Cells[frameStart+x]) { | 50 | + | if r.shadow[shadowStart+x].Equal(frameRow[scroll.X+x]) { |
| 51 | continue | 51 | continue | ||
| 52 | } | 52 | } | ||
| 53 | if !damageCoversCell(damage, scroll.X+x, y) { | 53 | if !damageCoversCell(damage, scroll.X+x, y) { | ||
M pkg/vt/screen.go +20 -31
| @@ -460,17 +460,10 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 460 | 460 | if n > height { | |
| 461 | 461 | n = height | |
| 462 | 462 | } | |
| 463 | - | for y := top; y <= bottom-n; y++ { | |
| 464 | - | dst := y * w | |
| 465 | - | src := (y + n) * w | |
| 466 | - | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |
| 467 | - | } | |
| 468 | - | for y := bottom - n + 1; y <= bottom; y++ { | |
| 469 | - | start := y * w | |
| 470 | - | for i := start; i < start+w; i++ { | |
| 471 | - | s.Frame.Cells[i] = renderer.BlankCell() | |
| 472 | - | } | |
| 473 | - | } | |
| 463 | + | // VT scroll regions always span the full frame width, so we rotate the | |
| 464 | + | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 465 | + | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 466 | + | s.Frame.ScrollUp(top, bottom, n) | |
| 474 | 467 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 475 | 468 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 476 | 469 | } | |
| @@ -488,17 +481,8 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 488 | 481 | if n > height { | |
| 489 | 482 | n = height | |
| 490 | 483 | } | |
| 491 | - | for y := bottom; y >= top+n; y-- { | |
| 492 | - | dst := y * w | |
| 493 | - | src := (y - n) * w | |
| 494 | - | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |
| 495 | - | } | |
| 496 | - | for y := top; y < top+n; y++ { | |
| 497 | - | start := y * w | |
| 498 | - | for i := start; i < start+w; i++ { | |
| 499 | - | s.Frame.Cells[i] = renderer.BlankCell() | |
| 500 | - | } | |
| 501 | - | } | |
| 484 | + | // Full-width region: rotate line offsets instead of copying cells. | |
| 485 | + | s.Frame.ScrollDown(top, bottom, n) | |
| 502 | 486 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 503 | 487 | } | |
| 504 | 488 | ||
| @@ -887,9 +871,14 @@ func (s *Screen) exitAlternateScreen() { | |||
| 887 | 871 | s.fullRedraw() | |
| 888 | 872 | } | |
| 889 | 873 | ||
| 874 | + | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 875 | + | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 876 | + | // canonical, so a rotated source is normalized in the clone. | |
| 890 | 877 | func cloneFrame(frame renderer.Frame) renderer.Frame { | |
| 891 | 878 | out := renderer.NewFrame(frame.Width, frame.Height) | |
| 892 | - | copy(out.Cells, frame.Cells) | |
| 879 | + | for y := 0; y < frame.Height; y++ { | |
| 880 | + | copy(out.Row(y), frame.Row(y)) | |
| 881 | + | } | |
| 893 | 882 | return out | |
| 894 | 883 | } | |
| 895 | 884 | ||
| @@ -901,15 +890,15 @@ func (s *Screen) insertChars(n int) { | |||
| 901 | 890 | if n > w-s.Col { | |
| 902 | 891 | n = w - s.Col | |
| 903 | 892 | } | |
| 904 | - | rowStart := s.Row * w | |
| 893 | + | row := s.Frame.Row(s.Row) | |
| 905 | 894 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |
| 906 | 895 | // by the shift; its repair falls outside the default damage rect. | |
| 907 | - | leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation | |
| 896 | + | leftSplit := s.Col > 0 && row[s.Col].Continuation | |
| 908 | 897 | for x := w - 1; x >= s.Col+n; x-- { | |
| 909 | - | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n] | |
| 898 | + | row[x] = row[x-n] | |
| 910 | 899 | } | |
| 911 | 900 | for x := s.Col; x < s.Col+n; x++ { | |
| 912 | - | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |
| 901 | + | row[x] = renderer.BlankCell() | |
| 913 | 902 | } | |
| 914 | 903 | s.repairRow(s.Row) | |
| 915 | 904 | dmgX := s.Col | |
| @@ -927,15 +916,15 @@ func (s *Screen) deleteChars(n int) { | |||
| 927 | 916 | if n > w-s.Col { | |
| 928 | 917 | n = w - s.Col | |
| 929 | 918 | } | |
| 930 | - | rowStart := s.Row * w | |
| 919 | + | row := s.Frame.Row(s.Row) | |
| 931 | 920 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |
| 932 | 921 | // by the shift; its repair falls outside the default damage rect. | |
| 933 | - | leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation | |
| 922 | + | leftSplit := s.Col > 0 && row[s.Col].Continuation | |
| 934 | 923 | for x := s.Col; x < w-n; x++ { | |
| 935 | - | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n] | |
| 924 | + | row[x] = row[x+n] | |
| 936 | 925 | } | |
| 937 | 926 | for x := w - n; x < w; x++ { | |
| 938 | - | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |
| 927 | + | row[x] = renderer.BlankCell() | |
| 939 | 928 | } | |
| 940 | 929 | s.repairRow(s.Row) | |
| 941 | 930 | dmgX := s.Col | |
| @@ -460,17 +460,10 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||||
| 460 | if n > height { | 460 | if n > height { | ||
| 461 | n = height | 461 | n = height | ||
| 462 | } | 462 | } | ||
| 463 | - | for y := top; y <= bottom-n; y++ { | 463 | + | // VT scroll regions always span the full frame width, so we rotate the |
| 464 | - | dst := y * w | 464 | + | // frame's line offsets (recycling and blanking the evicted rows in place) |
| 465 | - | src := (y + n) * w | 465 | + | // instead of copying cells. See renderer.Frame.ScrollUp. |
| 466 | - | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | 466 | + | s.Frame.ScrollUp(top, bottom, n) |
| 467 | - | } | |||
| 468 | - | for y := bottom - n + 1; y <= bottom; y++ { | |||
| 469 | - | start := y * w | |||
| 470 | - | for i := start; i < start+w; i++ { | |||
| 471 | - | s.Frame.Cells[i] = renderer.BlankCell() | |||
| 472 | - | } | |||
| 473 | - | } | |||
| 474 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | 467 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | ||
| 475 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | 468 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | ||
| 476 | } | 469 | } | ||
| @@ -488,17 +481,8 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||||
| 488 | if n > height { | 481 | if n > height { | ||
| 489 | n = height | 482 | n = height | ||
| 490 | } | 483 | } | ||
| 491 | - | for y := bottom; y >= top+n; y-- { | 484 | + | // Full-width region: rotate line offsets instead of copying cells. |
| 492 | - | dst := y * w | 485 | + | s.Frame.ScrollDown(top, bottom, n) |
| 493 | - | src := (y - n) * w | |||
| 494 | - | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |||
| 495 | - | } | |||
| 496 | - | for y := top; y < top+n; y++ { | |||
| 497 | - | start := y * w | |||
| 498 | - | for i := start; i < start+w; i++ { | |||
| 499 | - | s.Frame.Cells[i] = renderer.BlankCell() | |||
| 500 | - | } | |||
| 501 | - | } | |||
| 502 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | 486 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | ||
| 503 | } | 487 | } | ||
| 504 | 488 | ||||
| @@ -887,9 +871,14 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 887 | s.fullRedraw() | 871 | s.fullRedraw() | ||
| 888 | } | 872 | } | ||
| 889 | 873 | ||||
| 874 | + | // cloneFrame produces an independent copy in canonical layout: it copies the | |||
| 875 | + | // source's logical rows (via Row) into a fresh frame whose offsets are already | |||
| 876 | + | // canonical, so a rotated source is normalized in the clone. | |||
| 890 | func cloneFrame(frame renderer.Frame) renderer.Frame { | 877 | func cloneFrame(frame renderer.Frame) renderer.Frame { | ||
| 891 | out := renderer.NewFrame(frame.Width, frame.Height) | 878 | out := renderer.NewFrame(frame.Width, frame.Height) | ||
| 892 | - | copy(out.Cells, frame.Cells) | 879 | + | for y := 0; y < frame.Height; y++ { |
| 880 | + | copy(out.Row(y), frame.Row(y)) | |||
| 881 | + | } | |||
| 893 | return out | 882 | return out | ||
| 894 | } | 883 | } | ||
| 895 | 884 | ||||
| @@ -901,15 +890,15 @@ func (s *Screen) insertChars(n int) { | |||||
| 901 | if n > w-s.Col { | 890 | if n > w-s.Col { | ||
| 902 | n = w - s.Col | 891 | n = w - s.Col | ||
| 903 | } | 892 | } | ||
| 904 | - | rowStart := s.Row * w | 893 | + | row := s.Frame.Row(s.Row) |
| 905 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | 894 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | ||
| 906 | // by the shift; its repair falls outside the default damage rect. | 895 | // by the shift; its repair falls outside the default damage rect. | ||
| 907 | - | leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation | 896 | + | leftSplit := s.Col > 0 && row[s.Col].Continuation |
| 908 | for x := w - 1; x >= s.Col+n; x-- { | 897 | for x := w - 1; x >= s.Col+n; x-- { | ||
| 909 | - | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n] | 898 | + | row[x] = row[x-n] |
| 910 | } | 899 | } | ||
| 911 | for x := s.Col; x < s.Col+n; x++ { | 900 | for x := s.Col; x < s.Col+n; x++ { | ||
| 912 | - | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | 901 | + | row[x] = renderer.BlankCell() |
| 913 | } | 902 | } | ||
| 914 | s.repairRow(s.Row) | 903 | s.repairRow(s.Row) | ||
| 915 | dmgX := s.Col | 904 | dmgX := s.Col | ||
| @@ -927,15 +916,15 @@ func (s *Screen) deleteChars(n int) { | |||||
| 927 | if n > w-s.Col { | 916 | if n > w-s.Col { | ||
| 928 | n = w - s.Col | 917 | n = w - s.Col | ||
| 929 | } | 918 | } | ||
| 930 | - | rowStart := s.Row * w | 919 | + | row := s.Frame.Row(s.Row) |
| 931 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | 920 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | ||
| 932 | // by the shift; its repair falls outside the default damage rect. | 921 | // by the shift; its repair falls outside the default damage rect. | ||
| 933 | - | leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation | 922 | + | leftSplit := s.Col > 0 && row[s.Col].Continuation |
| 934 | for x := s.Col; x < w-n; x++ { | 923 | for x := s.Col; x < w-n; x++ { | ||
| 935 | - | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n] | 924 | + | row[x] = row[x+n] |
| 936 | } | 925 | } | ||
| 937 | for x := w - n; x < w; x++ { | 926 | for x := w - n; x < w; x++ { | ||
| 938 | - | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | 927 | + | row[x] = renderer.BlankCell() |
| 939 | } | 928 | } | ||
| 940 | s.repairRow(s.Row) | 929 | s.repairRow(s.Row) | ||
| 941 | dmgX := s.Col | 930 | dmgX := s.Col | ||
M pkg/vt/screen_test.go +78 -0
| @@ -2102,3 +2102,81 @@ func TestC1Controls(t *testing.T) { | |||
| 2102 | 2102 | t.Run(tt.name, tt.run) | |
| 2103 | 2103 | } | |
| 2104 | 2104 | } | |
| 2105 | + | ||
| 2106 | + | // --------------------------------------------------------------------------- | |
| 2107 | + | // Line-offset rotation scroll (M1c) | |
| 2108 | + | // --------------------------------------------------------------------------- | |
| 2109 | + | ||
| 2110 | + | // TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact | |
| 2111 | + | // with its line when a full-width scroll rotates line offsets rather than | |
| 2112 | + | // copying cells. | |
| 2113 | + | func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |
| 2114 | + | tests := []struct { | |
| 2115 | + | name string | |
| 2116 | + | run func(t *testing.T) | |
| 2117 | + | }{ | |
| 2118 | + | { | |
| 2119 | + | name: "wide pair moves up one row on newline scroll", | |
| 2120 | + | run: func(t *testing.T) { | |
| 2121 | + | s := NewScreen(4, 3) | |
| 2122 | + | // Place a wide rune (世, width 2) on the bottom row, then scroll up. | |
| 2123 | + | s.Write([]byte("\x1b[3;1H世")) | |
| 2124 | + | assertCell(t, s, 0, 2, '世') | |
| 2125 | + | assertContinuation(t, s, 1, 2) | |
| 2126 | + | // Newline at the bottom row scrolls the region up by one. | |
| 2127 | + | s.Write([]byte("\n")) | |
| 2128 | + | // The wide pair is now on row 1, intact; bottom row blank. | |
| 2129 | + | assertCell(t, s, 0, 1, '世') | |
| 2130 | + | assertContinuation(t, s, 1, 1) | |
| 2131 | + | assertBlank(t, s, 0, 2) | |
| 2132 | + | assertBlank(t, s, 1, 2) | |
| 2133 | + | if err := s.Frame.CheckInvariants(); err != nil { | |
| 2134 | + | t.Fatalf("invariants: %v", err) | |
| 2135 | + | } | |
| 2136 | + | }, | |
| 2137 | + | }, | |
| 2138 | + | { | |
| 2139 | + | name: "wide pair moves down on reverse index", | |
| 2140 | + | run: func(t *testing.T) { | |
| 2141 | + | s := NewScreen(4, 3) | |
| 2142 | + | s.Write([]byte("\x1b[1;1H世")) // top row | |
| 2143 | + | assertCell(t, s, 0, 0, '世') | |
| 2144 | + | assertContinuation(t, s, 1, 0) | |
| 2145 | + | s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down | |
| 2146 | + | assertCell(t, s, 0, 1, '世') | |
| 2147 | + | assertContinuation(t, s, 1, 1) | |
| 2148 | + | assertBlank(t, s, 0, 0) | |
| 2149 | + | if err := s.Frame.CheckInvariants(); err != nil { | |
| 2150 | + | t.Fatalf("invariants: %v", err) | |
| 2151 | + | } | |
| 2152 | + | }, | |
| 2153 | + | }, | |
| 2154 | + | } | |
| 2155 | + | for _, tt := range tests { | |
| 2156 | + | t.Run(tt.name, tt.run) | |
| 2157 | + | } | |
| 2158 | + | } | |
| 2159 | + | ||
| 2160 | + | // TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls | |
| 2161 | + | // through the VT layer and asserts the frame's line-offset stays a valid | |
| 2162 | + | // permutation. | |
| 2163 | + | func TestLineOffsetInvariantAfterVTSequences(t *testing.T) { | |
| 2164 | + | s := NewScreen(10, 6) | |
| 2165 | + | seqs := [][]byte{ | |
| 2166 | + | []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"), | |
| 2167 | + | []byte("\x1b[2;5r"), // scroll region rows 2..5 | |
| 2168 | + | []byte("\x1b[5;1Hmore\n"), // scroll within region | |
| 2169 | + | []byte("\x1b[1;1H\x1bM"), // reverse index at top | |
| 2170 | + | []byte("\x1b[3S"), // scroll up 3 | |
| 2171 | + | []byte("\x1b[2T"), // scroll down 2 | |
| 2172 | + | []byte("\x1b[r"), // reset region | |
| 2173 | + | []byte("\x1b[6;1Hbottom\n"), | |
| 2174 | + | } | |
| 2175 | + | for i, seq := range seqs { | |
| 2176 | + | s.Write(seq) | |
| 2177 | + | s.ClearDamage() | |
| 2178 | + | if err := s.Frame.CheckInvariants(); err != nil { | |
| 2179 | + | t.Fatalf("after seq %d: invariants broken: %v", i, err) | |
| 2180 | + | } | |
| 2181 | + | } | |
| 2182 | + | } | |
| @@ -2102,3 +2102,81 @@ func TestC1Controls(t *testing.T) { | |||||
| 2102 | t.Run(tt.name, tt.run) | 2102 | t.Run(tt.name, tt.run) | ||
| 2103 | } | 2103 | } | ||
| 2104 | } | 2104 | } | ||
| 2105 | + | ||||
| 2106 | + | // --------------------------------------------------------------------------- | |||
| 2107 | + | // Line-offset rotation scroll (M1c) | |||
| 2108 | + | // --------------------------------------------------------------------------- | |||
| 2109 | + | ||||
| 2110 | + | // TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact | |||
| 2111 | + | // with its line when a full-width scroll rotates line offsets rather than | |||
| 2112 | + | // copying cells. | |||
| 2113 | + | func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |||
| 2114 | + | tests := []struct { | |||
| 2115 | + | name string | |||
| 2116 | + | run func(t *testing.T) | |||
| 2117 | + | }{ | |||
| 2118 | + | { | |||
| 2119 | + | name: "wide pair moves up one row on newline scroll", | |||
| 2120 | + | run: func(t *testing.T) { | |||
| 2121 | + | s := NewScreen(4, 3) | |||
| 2122 | + | // Place a wide rune (世, width 2) on the bottom row, then scroll up. | |||
| 2123 | + | s.Write([]byte("\x1b[3;1H世")) | |||
| 2124 | + | assertCell(t, s, 0, 2, '世') | |||
| 2125 | + | assertContinuation(t, s, 1, 2) | |||
| 2126 | + | // Newline at the bottom row scrolls the region up by one. | |||
| 2127 | + | s.Write([]byte("\n")) | |||
| 2128 | + | // The wide pair is now on row 1, intact; bottom row blank. | |||
| 2129 | + | assertCell(t, s, 0, 1, '世') | |||
| 2130 | + | assertContinuation(t, s, 1, 1) | |||
| 2131 | + | assertBlank(t, s, 0, 2) | |||
| 2132 | + | assertBlank(t, s, 1, 2) | |||
| 2133 | + | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 2134 | + | t.Fatalf("invariants: %v", err) | |||
| 2135 | + | } | |||
| 2136 | + | }, | |||
| 2137 | + | }, | |||
| 2138 | + | { | |||
| 2139 | + | name: "wide pair moves down on reverse index", | |||
| 2140 | + | run: func(t *testing.T) { | |||
| 2141 | + | s := NewScreen(4, 3) | |||
| 2142 | + | s.Write([]byte("\x1b[1;1H世")) // top row | |||
| 2143 | + | assertCell(t, s, 0, 0, '世') | |||
| 2144 | + | assertContinuation(t, s, 1, 0) | |||
| 2145 | + | s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down | |||
| 2146 | + | assertCell(t, s, 0, 1, '世') | |||
| 2147 | + | assertContinuation(t, s, 1, 1) | |||
| 2148 | + | assertBlank(t, s, 0, 0) | |||
| 2149 | + | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 2150 | + | t.Fatalf("invariants: %v", err) | |||
| 2151 | + | } | |||
| 2152 | + | }, | |||
| 2153 | + | }, | |||
| 2154 | + | } | |||
| 2155 | + | for _, tt := range tests { | |||
| 2156 | + | t.Run(tt.name, tt.run) | |||
| 2157 | + | } | |||
| 2158 | + | } | |||
| 2159 | + | ||||
| 2160 | + | // TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls | |||
| 2161 | + | // through the VT layer and asserts the frame's line-offset stays a valid | |||
| 2162 | + | // permutation. | |||
| 2163 | + | func TestLineOffsetInvariantAfterVTSequences(t *testing.T) { | |||
| 2164 | + | s := NewScreen(10, 6) | |||
| 2165 | + | seqs := [][]byte{ | |||
| 2166 | + | []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"), | |||
| 2167 | + | []byte("\x1b[2;5r"), // scroll region rows 2..5 | |||
| 2168 | + | []byte("\x1b[5;1Hmore\n"), // scroll within region | |||
| 2169 | + | []byte("\x1b[1;1H\x1bM"), // reverse index at top | |||
| 2170 | + | []byte("\x1b[3S"), // scroll up 3 | |||
| 2171 | + | []byte("\x1b[2T"), // scroll down 2 | |||
| 2172 | + | []byte("\x1b[r"), // reset region | |||
| 2173 | + | []byte("\x1b[6;1Hbottom\n"), | |||
| 2174 | + | } | |||
| 2175 | + | for i, seq := range seqs { | |||
| 2176 | + | s.Write(seq) | |||
| 2177 | + | s.ClearDamage() | |||
| 2178 | + | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 2179 | + | t.Fatalf("after seq %d: invariants broken: %v", i, err) | |||
| 2180 | + | } | |||
| 2181 | + | } | |||
| 2182 | + | } | |||