detached c415453

fix(ui): harden anchored frame clipping

commit
c415453bd20eebd593303f5414a64e1c5d9069a0
author
brice · · 2026-07-14 11:05 UTC
parent
d4e061d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +199 -33
M internal/usecase/ui/frame.go +69 -14
@@ -21,6 +21,10 @@ type FrameView struct {
2121 Width, Height int
2222 }
2323
24+type frameSpan struct {
25+ source, destination, length int
26+}
27+
2428 // BlitFrame copies the valid intersection of src and rect into dst. Uncovered
2529 // destination cells are left untouched. Invalid dimensions, views, rectangles,
2630 // or anchors are ignored.
@@ -39,30 +43,81 @@ func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor Verti
3943 return
4044 }
4145 }
42- if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height {
46+
47+ width := min(src.Width, rect.Width)
48+ x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width)
49+ if !ok {
4350 return
4451 }
4552
46- srcY, dstY := 0, 0
53+ height := min(src.Height, rect.Height)
54+ sourceY, destinationOffset := 0, 0
4755 if anchor == VerticalAnchorBottom {
4856 if src.Height > rect.Height {
49- srcY = src.Height - rect.Height
57+ sourceY = src.Height - rect.Height
5058 } else {
51- dstY = rect.Height - src.Height
59+ destinationOffset = rect.Height - src.Height
5260 }
5361 }
54- for dy := 0; dy < src.Height && dy+dstY < rect.Height; dy++ {
55- sy := srcY + dy
56- y := rect.Y + dstY + dy
57- if y < 0 || y >= dst.Height {
58- continue
59- }
60- for dx := 0; dx < rect.Width; dx++ {
61- x := rect.X + dx
62- if x < 0 || x >= dst.Width || dx >= src.Width {
62+ destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height)
63+ if !ok {
64+ return
65+ }
66+ y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height)
67+ if !ok {
68+ return
69+ }
70+
71+ for dy := range y.length {
72+ row := src.Rows[y.source+dy]
73+ for dx := range x.length {
74+ sourceX := x.source + dx
75+ current := row[sourceX]
76+ if current.Continuation {
77+ continue
78+ }
79+ if renderer.RuneWidth(current.Rune) == 2 {
80+ next := sourceX + 1
81+ if dx+1 >= x.length || !row[next].Continuation {
82+ continue
83+ }
84+ dst.Set(x.destination+dx, y.destination+dy, current)
85+ dst.Set(x.destination+dx+1, y.destination+dy, row[next])
6386 continue
6487 }
65- dst.Set(x, y, src.Rows[sy][dx])
88+ dst.Set(x.destination+dx, y.destination+dy, current)
89+ }
90+ }
91+}
92+
93+// frameDestinationOrigin adds an in-rectangle offset only after proving that
94+// a non-negative origin remains in the destination's bounded coordinate range.
95+func frameDestinationOrigin(origin, offset, limit int) (int, bool) {
96+ if origin >= 0 {
97+ if origin >= limit || offset >= limit-origin {
98+ return 0, false
6699 }
100+ return origin + offset, true
101+ }
102+ // A negative integer plus a non-negative offset cannot overflow.
103+ return origin + offset, true
104+}
105+
106+// clippedFrameSpan intersects a source span placed at origin with [0, limit).
107+// It avoids forming an unbounded endpoint before both operands are bounded.
108+func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) {
109+ if origin < 0 {
110+ if origin <= -length {
111+ return frameSpan{}, false
112+ }
113+ trim := -origin
114+ source += trim
115+ length -= trim
116+ origin = 0
117+ }
118+ if origin >= limit {
119+ return frameSpan{}, false
67120 }
121+ length = min(length, limit-origin)
122+ return frameSpan{source: source, destination: origin, length: length}, length > 0
68123 }
@@ -21,6 +21,10 @@ type FrameView struct {
21 Width, Height int21 Width, Height int
22 }22 }
23 23
24+type frameSpan struct {
25+ source, destination, length int
26+}
27+
24 // BlitFrame copies the valid intersection of src and rect into dst. Uncovered28 // BlitFrame copies the valid intersection of src and rect into dst. Uncovered
25 // destination cells are left untouched. Invalid dimensions, views, rectangles,29 // destination cells are left untouched. Invalid dimensions, views, rectangles,
26 // or anchors are ignored.30 // or anchors are ignored.
@@ -39,30 +43,81 @@ func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor Verti
39 return43 return
40 }44 }
41 }45 }
42- if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height {46+
47+ width := min(src.Width, rect.Width)
48+ x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width)
49+ if !ok {
43 return50 return
44 }51 }
45 52
46- srcY, dstY := 0, 053+ height := min(src.Height, rect.Height)
54+ sourceY, destinationOffset := 0, 0
47 if anchor == VerticalAnchorBottom {55 if anchor == VerticalAnchorBottom {
48 if src.Height > rect.Height {56 if src.Height > rect.Height {
49- srcY = src.Height - rect.Height57+ sourceY = src.Height - rect.Height
50 } else {58 } else {
51- dstY = rect.Height - src.Height59+ destinationOffset = rect.Height - src.Height
52 }60 }
53 }61 }
54- for dy := 0; dy < src.Height && dy+dstY < rect.Height; dy++ {62+ destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height)
55- sy := srcY + dy63+ if !ok {
56- y := rect.Y + dstY + dy64+ return
57- if y < 0 || y >= dst.Height {65+ }
58- continue66+ y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height)
59- }67+ if !ok {
60- for dx := 0; dx < rect.Width; dx++ {68+ return
61- x := rect.X + dx69+ }
62- if x < 0 || x >= dst.Width || dx >= src.Width {70+
71+ for dy := range y.length {
72+ row := src.Rows[y.source+dy]
73+ for dx := range x.length {
74+ sourceX := x.source + dx
75+ current := row[sourceX]
76+ if current.Continuation {
77+ continue
78+ }
79+ if renderer.RuneWidth(current.Rune) == 2 {
80+ next := sourceX + 1
81+ if dx+1 >= x.length || !row[next].Continuation {
82+ continue
83+ }
84+ dst.Set(x.destination+dx, y.destination+dy, current)
85+ dst.Set(x.destination+dx+1, y.destination+dy, row[next])
63 continue86 continue
64 }87 }
65- dst.Set(x, y, src.Rows[sy][dx])88+ dst.Set(x.destination+dx, y.destination+dy, current)
89+ }
90+ }
91+}
92+
93+// frameDestinationOrigin adds an in-rectangle offset only after proving that
94+// a non-negative origin remains in the destination's bounded coordinate range.
95+func frameDestinationOrigin(origin, offset, limit int) (int, bool) {
96+ if origin >= 0 {
97+ if origin >= limit || offset >= limit-origin {
98+ return 0, false
66 }99 }
100+ return origin + offset, true
101+ }
102+ // A negative integer plus a non-negative offset cannot overflow.
103+ return origin + offset, true
104+}
105+
106+// clippedFrameSpan intersects a source span placed at origin with [0, limit).
107+// It avoids forming an unbounded endpoint before both operands are bounded.
108+func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) {
109+ if origin < 0 {
110+ if origin <= -length {
111+ return frameSpan{}, false
112+ }
113+ trim := -origin
114+ source += trim
115+ length -= trim
116+ origin = 0
117+ }
118+ if origin >= limit {
119+ return frameSpan{}, false
67 }120 }
121+ length = min(length, limit-origin)
122+ return frameSpan{source: source, destination: origin, length: length}, length > 0
68 }123 }
M internal/usecase/ui/frame_test.go +126 -17
@@ -30,7 +30,7 @@ func TestBlitFrameAnchorsAndClips(t *testing.T) {
3030 for y := range src.Rows {
3131 src.Rows[y] = make([]renderer.Cell, tt.srcW)
3232 for x := range src.Rows[y] {
33- src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1)
33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false)
3434 }
3535 }
3636 dst := renderer.NewFrame(5, 5)
@@ -48,19 +48,111 @@ func TestBlitFrameAnchorsAndClips(t *testing.T) {
4848 }
4949 }
5050
51-func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) {
52- dst := renderer.NewFrame(3, 3)
51+func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) {
52+ style := renderer.Style{Foreground: 7, Background: 8, Bold: true}
53+ wide := []renderer.Cell{
54+ cell('界', style, false),
55+ cell(0, style, true),
56+ cell('x', renderer.DefaultStyle(), false),
57+ }
58+ mark := cell('!', renderer.Style{Foreground: 3}, false)
59+ tests := []struct {
60+ name string
61+ rect domain.Rect
62+ dst renderer.Frame
63+ want []renderer.Cell
64+ }{
65+ {
66+ name: "left clipped continuation remains untouched",
67+ rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1},
68+ dst: markedFrame(3, 1, mark),
69+ want: []renderer.Cell{mark, wide[2], mark},
70+ },
71+ {
72+ name: "right clipped leader remains untouched",
73+ rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1},
74+ dst: markedFrame(1, 1, mark),
75+ want: []renderer.Cell{mark},
76+ },
77+ {
78+ name: "complete pair retains exact cells",
79+ rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1},
80+ dst: markedFrame(4, 1, mark),
81+ want: []renderer.Cell{mark, wide[0], wide[1], wide[2]},
82+ },
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop)
87+ assertRowEqual(t, tt.dst, 0, tt.want)
88+ })
89+ }
90+}
91+
92+func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) {
93+ maxInt := int(^uint(0) >> 1)
94+ minInt := -maxInt - 1
95+ style := renderer.Style{Foreground: 9}
96+ src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1}
97+ mark := cell('!', renderer.Style{Background: 4}, false)
98+ tests := []struct {
99+ name string
100+ rect domain.Rect
101+ want []renderer.Cell
102+ }{
103+ {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}},
104+ {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
105+ {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ dst := markedFrame(2, 1, mark)
110+ BlitFrame(dst, tt.rect, src, VerticalAnchorTop)
111+ assertRowEqual(t, dst, 0, tt.want)
112+ })
113+ }
114+}
115+
116+func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) {
53117 mark := cell('!', renderer.Style{Foreground: 3}, false)
54- for y := 0; y < 3; y++ {
55- for x := 0; x < 3; x++ {
118+ dst := renderer.NewFrame(3, 3)
119+ dst.ScrollUp(0, 2, 1)
120+ for y := range dst.Height {
121+ for x := range dst.Width {
56122 dst.Set(x, y, mark)
57123 }
58124 }
59- src := FrameView{Width: 2, Height: 2, Rows: [][]renderer.Cell{{cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), true)}, {cell('c', renderer.DefaultStyle(), false), cell('d', renderer.DefaultStyle(), true)}}}
60- BlitFrame(dst, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
61- if dst.At(0, 0).Rune != 'd' || dst.At(2, 2).Rune != '!' {
62- t.Fatalf("unexpected clipping/sentinel")
63- }
125+ src := FrameView{Rows: [][]renderer.Cell{
126+ {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)},
127+ {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)},
128+ }, Width: 2, Height: 2}
129+
130+ BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop)
131+
132+ assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark})
133+ assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark})
134+ assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark})
135+}
136+
137+func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) {
138+ mark := cell('!', renderer.Style{Foreground: 3}, false)
139+ src := FrameView{Rows: [][]renderer.Cell{
140+ {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)},
141+ {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)},
142+ {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)},
143+ }, Width: 3, Height: 3}
144+
145+ leftTop := markedFrame(3, 3, mark)
146+ BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
147+ assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark})
148+ assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark})
149+ assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark})
150+
151+ rightBottom := markedFrame(3, 3, mark)
152+ BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop)
153+ assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark})
154+ assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark})
155+ assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]})
64156 }
65157
66158 func TestBlitFrameInvalidNoOp(t *testing.T) {
@@ -71,19 +163,36 @@ func TestBlitFrameInvalidNoOp(t *testing.T) {
71163 s FrameView
72164 a VerticalAnchor
73165 }{
74- {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
166+ {domain.Rect{}, FrameView{}, VerticalAnchorTop},
167+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
75168 {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
76169 }
77170 for _, tc := range cases {
78171 BlitFrame(dst, tc.r, tc.s, tc.a)
79172 }
80- for y := 0; y < 2; y++ {
81- for x := 0; x < 2; x++ {
82- if !dst.At(x, y).Equal(before.At(x, y)) {
83- t.Fatal("invalid blit changed destination")
84- }
173+ if !framesEqual(dst, before) {
174+ t.Fatal("invalid blit changed destination")
175+ }
176+}
177+
178+func markedFrame(width, height int, mark renderer.Cell) renderer.Frame {
179+ frame := renderer.NewFrame(width, height)
180+ for y := range height {
181+ for x := range width {
182+ frame.Set(x, y, mark)
85183 }
86184 }
185+ return frame
87186 }
88187
89-func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {}
188+func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) {
189+ t.Helper()
190+ if len(want) != frame.Width {
191+ t.Fatalf("want row length %d, frame width %d", len(want), frame.Width)
192+ }
193+ for x := range want {
194+ if got := frame.At(x, y); !got.Equal(want[x]) {
195+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x])
196+ }
197+ }
198+}
@@ -30,7 +30,7 @@ func TestBlitFrameAnchorsAndClips(t *testing.T) {
30 for y := range src.Rows {30 for y := range src.Rows {
31 src.Rows[y] = make([]renderer.Cell, tt.srcW)31 src.Rows[y] = make([]renderer.Cell, tt.srcW)
32 for x := range src.Rows[y] {32 for x := range src.Rows[y] {
33- src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1)33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false)
34 }34 }
35 }35 }
36 dst := renderer.NewFrame(5, 5)36 dst := renderer.NewFrame(5, 5)
@@ -48,19 +48,111 @@ func TestBlitFrameAnchorsAndClips(t *testing.T) {
48 }48 }
49 }49 }
50 50
51-func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) {51+func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) {
52- dst := renderer.NewFrame(3, 3)52+ style := renderer.Style{Foreground: 7, Background: 8, Bold: true}
53+ wide := []renderer.Cell{
54+ cell('界', style, false),
55+ cell(0, style, true),
56+ cell('x', renderer.DefaultStyle(), false),
57+ }
58+ mark := cell('!', renderer.Style{Foreground: 3}, false)
59+ tests := []struct {
60+ name string
61+ rect domain.Rect
62+ dst renderer.Frame
63+ want []renderer.Cell
64+ }{
65+ {
66+ name: "left clipped continuation remains untouched",
67+ rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1},
68+ dst: markedFrame(3, 1, mark),
69+ want: []renderer.Cell{mark, wide[2], mark},
70+ },
71+ {
72+ name: "right clipped leader remains untouched",
73+ rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1},
74+ dst: markedFrame(1, 1, mark),
75+ want: []renderer.Cell{mark},
76+ },
77+ {
78+ name: "complete pair retains exact cells",
79+ rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1},
80+ dst: markedFrame(4, 1, mark),
81+ want: []renderer.Cell{mark, wide[0], wide[1], wide[2]},
82+ },
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop)
87+ assertRowEqual(t, tt.dst, 0, tt.want)
88+ })
89+ }
90+}
91+
92+func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) {
93+ maxInt := int(^uint(0) >> 1)
94+ minInt := -maxInt - 1
95+ style := renderer.Style{Foreground: 9}
96+ src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1}
97+ mark := cell('!', renderer.Style{Background: 4}, false)
98+ tests := []struct {
99+ name string
100+ rect domain.Rect
101+ want []renderer.Cell
102+ }{
103+ {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}},
104+ {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
105+ {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ dst := markedFrame(2, 1, mark)
110+ BlitFrame(dst, tt.rect, src, VerticalAnchorTop)
111+ assertRowEqual(t, dst, 0, tt.want)
112+ })
113+ }
114+}
115+
116+func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) {
53 mark := cell('!', renderer.Style{Foreground: 3}, false)117 mark := cell('!', renderer.Style{Foreground: 3}, false)
54- for y := 0; y < 3; y++ {118+ dst := renderer.NewFrame(3, 3)
55- for x := 0; x < 3; x++ {119+ dst.ScrollUp(0, 2, 1)
120+ for y := range dst.Height {
121+ for x := range dst.Width {
56 dst.Set(x, y, mark)122 dst.Set(x, y, mark)
57 }123 }
58 }124 }
59- src := FrameView{Width: 2, Height: 2, Rows: [][]renderer.Cell{{cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), true)}, {cell('c', renderer.DefaultStyle(), false), cell('d', renderer.DefaultStyle(), true)}}}125+ src := FrameView{Rows: [][]renderer.Cell{
60- BlitFrame(dst, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)126+ {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)},
61- if dst.At(0, 0).Rune != 'd' || dst.At(2, 2).Rune != '!' {127+ {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)},
62- t.Fatalf("unexpected clipping/sentinel")128+ }, Width: 2, Height: 2}
63- }129+
130+ BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop)
131+
132+ assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark})
133+ assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark})
134+ assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark})
135+}
136+
137+func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) {
138+ mark := cell('!', renderer.Style{Foreground: 3}, false)
139+ src := FrameView{Rows: [][]renderer.Cell{
140+ {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)},
141+ {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)},
142+ {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)},
143+ }, Width: 3, Height: 3}
144+
145+ leftTop := markedFrame(3, 3, mark)
146+ BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
147+ assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark})
148+ assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark})
149+ assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark})
150+
151+ rightBottom := markedFrame(3, 3, mark)
152+ BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop)
153+ assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark})
154+ assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark})
155+ assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]})
64 }156 }
65 157
66 func TestBlitFrameInvalidNoOp(t *testing.T) {158 func TestBlitFrameInvalidNoOp(t *testing.T) {
@@ -71,19 +163,36 @@ func TestBlitFrameInvalidNoOp(t *testing.T) {
71 s FrameView163 s FrameView
72 a VerticalAnchor164 a VerticalAnchor
73 }{165 }{
74- {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},166+ {domain.Rect{}, FrameView{}, VerticalAnchorTop},
167+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
75 {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},168 {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
76 }169 }
77 for _, tc := range cases {170 for _, tc := range cases {
78 BlitFrame(dst, tc.r, tc.s, tc.a)171 BlitFrame(dst, tc.r, tc.s, tc.a)
79 }172 }
80- for y := 0; y < 2; y++ {173+ if !framesEqual(dst, before) {
81- for x := 0; x < 2; x++ {174+ t.Fatal("invalid blit changed destination")
82- if !dst.At(x, y).Equal(before.At(x, y)) {175+ }
83- t.Fatal("invalid blit changed destination")176+}
84- }177+
178+func markedFrame(width, height int, mark renderer.Cell) renderer.Frame {
179+ frame := renderer.NewFrame(width, height)
180+ for y := range height {
181+ for x := range width {
182+ frame.Set(x, y, mark)
85 }183 }
86 }184 }
185+ return frame
87 }186 }
88 187
89-func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {}188+func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) {
189+ t.Helper()
190+ if len(want) != frame.Width {
191+ t.Fatalf("want row length %d, frame width %d", len(want), frame.Width)
192+ }
193+ for x := range want {
194+ if got := frame.At(x, y); !got.Equal(want[x]) {
195+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x])
196+ }
197+ }
198+}
M internal/usecase/ui/separator_test.go +4 -2
@@ -42,8 +42,10 @@ func TestDrawSeparator(t *testing.T) {
4242 func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
4343 style := renderer.DefaultStyle()
4444 f := renderer.NewFrame(3, 3)
45- for i := range f.Cells {
46- f.Cells[i] = renderer.Cell{Rune: 'x', Style: style}
45+ for y := range f.Height {
46+ for x := range f.Width {
47+ f.Set(x, y, renderer.Cell{Rune: 'x', Style: style})
48+ }
4749 }
4850 DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
4951 for x := 0; x < 3; x++ {
@@ -42,8 +42,10 @@ func TestDrawSeparator(t *testing.T) {
42 func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {42 func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
43 style := renderer.DefaultStyle()43 style := renderer.DefaultStyle()
44 f := renderer.NewFrame(3, 3)44 f := renderer.NewFrame(3, 3)
45- for i := range f.Cells {45+ for y := range f.Height {
46- f.Cells[i] = renderer.Cell{Rune: 'x', Style: style}46+ for x := range f.Width {
47+ f.Set(x, y, renderer.Cell{Rune: 'x', Style: style})
48+ }
47 }49 }
48 DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)50 DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
49 for x := 0; x < 3; x++ {51 for x := 0; x < 3; x++ {

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