fix(ui): harden anchored frame clipping
- commit
- c415453bd20eebd593303f5414a64e1c5d9069a0
- author
- brice · · 2026-07-14 11:05 UTC
- parent
- d4e061d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 21 | 21 | Width, Height int | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | type frameSpan struct { | |
| 25 | + | source, destination, length int | |
| 26 | + | } | |
| 27 | + | ||
| 24 | 28 | // BlitFrame copies the valid intersection of src and rect into dst. Uncovered | |
| 25 | 29 | // destination cells are left untouched. Invalid dimensions, views, rectangles, | |
| 26 | 30 | // or anchors are ignored. | |
| @@ -39,30 +43,81 @@ func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor Verti | |||
| 39 | 43 | 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 | 50 | return | |
| 44 | 51 | } | |
| 45 | 52 | ||
| 46 | - | srcY, dstY := 0, 0 | |
| 53 | + | height := min(src.Height, rect.Height) | |
| 54 | + | sourceY, destinationOffset := 0, 0 | |
| 47 | 55 | if anchor == VerticalAnchorBottom { | |
| 48 | 56 | if src.Height > rect.Height { | |
| 49 | - | srcY = src.Height - rect.Height | |
| 57 | + | sourceY = src.Height - rect.Height | |
| 50 | 58 | } else { | |
| 51 | - | dstY = rect.Height - src.Height | |
| 59 | + | destinationOffset = rect.Height - src.Height | |
| 52 | 60 | } | |
| 53 | 61 | } | |
| 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]) | |
| 63 | 86 | 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 | } | |
| @@ -21,6 +21,10 @@ type FrameView struct { | |||||
| 21 | Width, Height int | 21 | 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. Uncovered | 28 | // 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 | return | 43 | 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 | return | 50 | return | ||
| 44 | } | 51 | } | ||
| 45 | 52 | ||||
| 46 | - | srcY, dstY := 0, 0 | 53 | + | 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.Height | 57 | + | sourceY = src.Height - rect.Height |
| 50 | } else { | 58 | } else { | ||
| 51 | - | dstY = rect.Height - src.Height | 59 | + | 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 + dy | 63 | + | if !ok { |
| 56 | - | y := rect.Y + dstY + dy | 64 | + | return |
| 57 | - | if y < 0 || y >= dst.Height { | 65 | + | } |
| 58 | - | continue | 66 | + | 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 + dx | 69 | + | } |
| 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 | continue | 86 | 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) { | |||
| 30 | 30 | for y := range src.Rows { | |
| 31 | 31 | src.Rows[y] = make([]renderer.Cell, tt.srcW) | |
| 32 | 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 | 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) { | |
| 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) { | |
| 53 | 117 | 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 { | |
| 56 | 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)}}} | |
| 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]}) | |
| 64 | 156 | } | |
| 65 | 157 | ||
| 66 | 158 | func TestBlitFrameInvalidNoOp(t *testing.T) { | |
| @@ -71,19 +163,36 @@ func TestBlitFrameInvalidNoOp(t *testing.T) { | |||
| 71 | 163 | s FrameView | |
| 72 | 164 | 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 | 168 | {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)}, | |
| 76 | 169 | } | |
| 77 | 170 | for _, tc := range cases { | |
| 78 | 171 | BlitFrame(dst, tc.r, tc.s, tc.a) | |
| 79 | 172 | } | |
| 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) | |
| 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 | + | } | |
| @@ -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 FrameView | 163 | s FrameView | ||
| 72 | a VerticalAnchor | 164 | 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) { | |||
| 42 | 42 | func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |
| 43 | 43 | style := renderer.DefaultStyle() | |
| 44 | 44 | 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 | + | } | |
| 47 | 49 | } | |
| 48 | 50 | DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style) | |
| 49 | 51 | 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++ { | ||