feat(ui): add anchored frame blitting
- commit
- 1a915d855aa06a1787b5d5dd1f19981bdf8c8282
- author
- brice · · 2026-07-14 10:57 UTC
- parent
- 0adb096
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +157 -0
A internal/usecase/ui/frame.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // VerticalAnchor controls which end of a source is retained when its height | |
| 9 | + | // differs from the destination rectangle. Source and destination aliasing is | |
| 10 | + | // unsupported. | |
| 11 | + | type VerticalAnchor uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | VerticalAnchorTop VerticalAnchor = iota | |
| 15 | + | VerticalAnchorBottom | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // FrameView is a bounded, row-oriented view of cells. | |
| 19 | + | type FrameView struct { | |
| 20 | + | Rows [][]renderer.Cell | |
| 21 | + | Width, Height int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // BlitFrame copies the valid intersection of src and rect into dst. Uncovered | |
| 25 | + | // destination cells are left untouched. Invalid dimensions, views, rectangles, | |
| 26 | + | // or anchors are ignored. | |
| 27 | + | func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) { | |
| 28 | + | if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | if len(src.Rows) != src.Height { | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | for _, row := range src.Rows { | |
| 38 | + | if len(row) != src.Width { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | srcY, dstY := 0, 0 | |
| 47 | + | if anchor == VerticalAnchorBottom { | |
| 48 | + | if src.Height > rect.Height { | |
| 49 | + | srcY = src.Height - rect.Height | |
| 50 | + | } else { | |
| 51 | + | dstY = rect.Height - src.Height | |
| 52 | + | } | |
| 53 | + | } | |
| 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 { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | dst.Set(x, y, src.Rows[sy][dx]) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // VerticalAnchor controls which end of a source is retained when its height | |||
| 9 | + | // differs from the destination rectangle. Source and destination aliasing is | |||
| 10 | + | // unsupported. | |||
| 11 | + | type VerticalAnchor uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | VerticalAnchorTop VerticalAnchor = iota | |||
| 15 | + | VerticalAnchorBottom | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // FrameView is a bounded, row-oriented view of cells. | |||
| 19 | + | type FrameView struct { | |||
| 20 | + | Rows [][]renderer.Cell | |||
| 21 | + | Width, Height int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // BlitFrame copies the valid intersection of src and rect into dst. Uncovered | |||
| 25 | + | // destination cells are left untouched. Invalid dimensions, views, rectangles, | |||
| 26 | + | // or anchors are ignored. | |||
| 27 | + | func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) { | |||
| 28 | + | if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom { | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | if len(src.Rows) != src.Height { | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | for _, row := range src.Rows { | |||
| 38 | + | if len(row) != src.Width { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | srcY, dstY := 0, 0 | |||
| 47 | + | if anchor == VerticalAnchorBottom { | |||
| 48 | + | if src.Height > rect.Height { | |||
| 49 | + | srcY = src.Height - rect.Height | |||
| 50 | + | } else { | |||
| 51 | + | dstY = rect.Height - src.Height | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 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 { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | dst.Set(x, y, src.Rows[sy][dx]) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
A internal/usecase/ui/frame_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | |
| 11 | + | return renderer.Cell{Rune: r, Style: style, Continuation: continuation} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestBlitFrameAnchorsAndClips(t *testing.T) { | |
| 15 | + | style := renderer.Style{Foreground: 1, Background: 2} | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | anchor VerticalAnchor | |
| 19 | + | srcH, srcW, rectX, rectY, rectW, rectH int | |
| 20 | + | want string | |
| 21 | + | }{ | |
| 22 | + | {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"}, | |
| 23 | + | {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"}, | |
| 24 | + | {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "}, | |
| 25 | + | {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"}, | |
| 26 | + | } | |
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)} | |
| 30 | + | for y := range src.Rows { | |
| 31 | + | src.Rows[y] = make([]renderer.Cell, tt.srcW) | |
| 32 | + | for x := range src.Rows[y] { | |
| 33 | + | src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | dst := renderer.NewFrame(5, 5) | |
| 37 | + | BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor) | |
| 38 | + | got := "" | |
| 39 | + | for y := 1; y < 3; y++ { | |
| 40 | + | for x := 1; x < 3; x++ { | |
| 41 | + | got += string(dst.At(x, y).Rune) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if got != tt.want { | |
| 45 | + | t.Fatalf("got %q want %q", got, tt.want) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) { | |
| 52 | + | dst := renderer.NewFrame(3, 3) | |
| 53 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |
| 54 | + | for y := 0; y < 3; y++ { | |
| 55 | + | for x := 0; x < 3; x++ { | |
| 56 | + | dst.Set(x, y, mark) | |
| 57 | + | } | |
| 58 | + | } | |
| 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 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestBlitFrameInvalidNoOp(t *testing.T) { | |
| 67 | + | dst := renderer.NewFrame(2, 2) | |
| 68 | + | before := dst.Clone() | |
| 69 | + | cases := []struct { | |
| 70 | + | r domain.Rect | |
| 71 | + | s FrameView | |
| 72 | + | a VerticalAnchor | |
| 73 | + | }{ | |
| 74 | + | {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {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)}, | |
| 76 | + | } | |
| 77 | + | for _, tc := range cases { | |
| 78 | + | BlitFrame(dst, tc.r, tc.s, tc.a) | |
| 79 | + | } | |
| 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 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {} | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | |||
| 11 | + | return renderer.Cell{Rune: r, Style: style, Continuation: continuation} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestBlitFrameAnchorsAndClips(t *testing.T) { | |||
| 15 | + | style := renderer.Style{Foreground: 1, Background: 2} | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | anchor VerticalAnchor | |||
| 19 | + | srcH, srcW, rectX, rectY, rectW, rectH int | |||
| 20 | + | want string | |||
| 21 | + | }{ | |||
| 22 | + | {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"}, | |||
| 23 | + | {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"}, | |||
| 24 | + | {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "}, | |||
| 25 | + | {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)} | |||
| 30 | + | for y := range src.Rows { | |||
| 31 | + | src.Rows[y] = make([]renderer.Cell, tt.srcW) | |||
| 32 | + | for x := range src.Rows[y] { | |||
| 33 | + | src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | dst := renderer.NewFrame(5, 5) | |||
| 37 | + | BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor) | |||
| 38 | + | got := "" | |||
| 39 | + | for y := 1; y < 3; y++ { | |||
| 40 | + | for x := 1; x < 3; x++ { | |||
| 41 | + | got += string(dst.At(x, y).Rune) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if got != tt.want { | |||
| 45 | + | t.Fatalf("got %q want %q", got, tt.want) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) { | |||
| 52 | + | dst := renderer.NewFrame(3, 3) | |||
| 53 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |||
| 54 | + | for y := 0; y < 3; y++ { | |||
| 55 | + | for x := 0; x < 3; x++ { | |||
| 56 | + | dst.Set(x, y, mark) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 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 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestBlitFrameInvalidNoOp(t *testing.T) { | |||
| 67 | + | dst := renderer.NewFrame(2, 2) | |||
| 68 | + | before := dst.Clone() | |||
| 69 | + | cases := []struct { | |||
| 70 | + | r domain.Rect | |||
| 71 | + | s FrameView | |||
| 72 | + | a VerticalAnchor | |||
| 73 | + | }{ | |||
| 74 | + | {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {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)}, | |||
| 76 | + | } | |||
| 77 | + | for _, tc := range cases { | |||
| 78 | + | BlitFrame(dst, tc.r, tc.s, tc.a) | |||
| 79 | + | } | |||
| 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 | + | } | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {} | |||