detached 7a972e7

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +374 -39
M pkg/renderer/frame.go +92 -3
@@ -2,10 +2,19 @@ package renderer
22
33 import "fmt"
44
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.
511 type Frame struct {
612 Width int
713 Height int
814 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
918 }
1019
1120 func NewFrame(width, height int) Frame {
@@ -13,7 +22,11 @@ func NewFrame(width, height int) Frame {
1322 for i := range cells {
1423 cells[i] = BlankCell()
1524 }
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}
1730 }
1831
1932 func (f Frame) Validate() error {
@@ -23,13 +36,89 @@ func (f Frame) Validate() error {
2336 if len(f.Cells) != f.Width*f.Height {
2437 return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height)
2538 }
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+ }
2670 return nil
2771 }
2872
2973 func (f Frame) At(x, y int) Cell {
30- return f.Cells[y*f.Width+x]
74+ return f.Cells[f.lineOffset[y]+x]
3175 }
3276
3377 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+ }
35124 }
@@ -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 int12 Width int
7 Height int13 Height int
8 Cells []Cell14 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 nil70 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] = cell78+ 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
198198 }
199199
200200 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.
201203 start := y * frame.Width
204+ row := frame.Row(y)
202205 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]) {
204207 return true
205208 }
206209 }
@@ -210,7 +213,17 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
210213 func (r *Renderer) replaceShadow(frame Frame) {
211214 r.width = frame.Width
212215 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+ }
214227 }
215228
216229 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) {
236249 }
237250 for row := y; row < y+height; row++ {
238251 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])
240254 }
241255 }
242256
@@ -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.Width203 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 true207 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.Width214 r.width = frame.Width
212 r.height = frame.Height215 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 + x251 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) {
4444
4545 func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool {
4646 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
47- frameStart := y*frame.Width + scroll.X
47+ frameRow := frame.Row(y)
4848 shadowStart := (y+scroll.Count)*r.width + scroll.X
4949 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]) {
5151 continue
5252 }
5353 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.X47+ frameRow := frame.Row(y)
48 shadowStart := (y+scroll.Count)*r.width + scroll.X48 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 continue51 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) {
460460 if n > height {
461461 n = height
462462 }
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)
474467 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
475468 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
476469 }
@@ -488,17 +481,8 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
488481 if n > height {
489482 n = height
490483 }
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)
502486 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
503487 }
504488
@@ -887,9 +871,14 @@ func (s *Screen) exitAlternateScreen() {
887871 s.fullRedraw()
888872 }
889873
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.
890877 func cloneFrame(frame renderer.Frame) renderer.Frame {
891878 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+ }
893882 return out
894883 }
895884
@@ -901,15 +890,15 @@ func (s *Screen) insertChars(n int) {
901890 if n > w-s.Col {
902891 n = w - s.Col
903892 }
904- rowStart := s.Row * w
893+ row := s.Frame.Row(s.Row)
905894 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
906895 // 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
908897 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]
910899 }
911900 for x := s.Col; x < s.Col+n; x++ {
912- s.Frame.Cells[rowStart+x] = renderer.BlankCell()
901+ row[x] = renderer.BlankCell()
913902 }
914903 s.repairRow(s.Row)
915904 dmgX := s.Col
@@ -927,15 +916,15 @@ func (s *Screen) deleteChars(n int) {
927916 if n > w-s.Col {
928917 n = w - s.Col
929918 }
930- rowStart := s.Row * w
919+ row := s.Frame.Row(s.Row)
931920 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
932921 // 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
934923 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]
936925 }
937926 for x := w - n; x < w; x++ {
938- s.Frame.Cells[rowStart+x] = renderer.BlankCell()
927+ row[x] = renderer.BlankCell()
939928 }
940929 s.repairRow(s.Row)
941930 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 = height461 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 * w464+ // frame's line offsets (recycling and blanking the evicted rows in place)
465- src := (y + n) * w465+ // 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 = height482 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 * w485+ 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 out882 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.Col891 n = w - s.Col
903 }892 }
904- rowStart := s.Row * w893+ row := s.Frame.Row(s.Row)
905 // A wide left half at Col-1 whose continuation sits at Col will be orphaned894 // 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).Continuation896+ 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.Col904 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.Col917 n = w - s.Col
929 }918 }
930- rowStart := s.Row * w919+ row := s.Frame.Row(s.Row)
931 // A wide left half at Col-1 whose continuation sits at Col will be orphaned920 // 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).Continuation922+ 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.Col930 dmgX := s.Col
M pkg/vt/screen_test.go +78 -0
@@ -2102,3 +2102,81 @@ func TestC1Controls(t *testing.T) {
21022102 t.Run(tt.name, tt.run)
21032103 }
21042104 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close