feat(ui): add oriented separators
- commit
- d4e061d1c03d6414be29b0f2df8f9afd7a9cb359
- author
- brice · · 2026-07-14 10:58 UTC
- parent
- 1a915d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +112 -0
A internal/usecase/ui/separator.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // SeparatorOrientation selects the axis of a separator slice. | |
| 9 | + | type SeparatorOrientation uint8 | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | SeparatorHorizontal SeparatorOrientation = iota | |
| 13 | + | SeparatorVertical | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // DrawSeparator draws a styled, clipped separator slice. It contains no layout | |
| 17 | + | // or theme policy; callers provide both the rectangle and style. | |
| 18 | + | func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) { | |
| 19 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | var cell renderer.Cell | |
| 23 | + | switch orientation { | |
| 24 | + | case SeparatorHorizontal: | |
| 25 | + | cell = renderer.Cell{Rune: '─', Style: style} | |
| 26 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell) | |
| 27 | + | case SeparatorVertical: | |
| 28 | + | cell = renderer.Cell{Rune: '│', Style: style} | |
| 29 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell) | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // SeparatorOrientation selects the axis of a separator slice. | |||
| 9 | + | type SeparatorOrientation uint8 | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | SeparatorHorizontal SeparatorOrientation = iota | |||
| 13 | + | SeparatorVertical | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // DrawSeparator draws a styled, clipped separator slice. It contains no layout | |||
| 17 | + | // or theme policy; callers provide both the rectangle and style. | |||
| 18 | + | func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) { | |||
| 19 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 { | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | var cell renderer.Cell | |||
| 23 | + | switch orientation { | |||
| 24 | + | case SeparatorHorizontal: | |||
| 25 | + | cell = renderer.Cell{Rune: '─', Style: style} | |||
| 26 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell) | |||
| 27 | + | case SeparatorVertical: | |||
| 28 | + | cell = renderer.Cell{Rune: '│', Style: style} | |||
| 29 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell) | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/usecase/ui/separator_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 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 TestDrawSeparator(t *testing.T) { | |
| 11 | + | style := renderer.DefaultStyle() | |
| 12 | + | style.Attrs = renderer.AttrDim | |
| 13 | + | cases := []struct { | |
| 14 | + | name string | |
| 15 | + | orientation SeparatorOrientation | |
| 16 | + | rune rune | |
| 17 | + | rect domain.Rect | |
| 18 | + | }{ | |
| 19 | + | {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}}, | |
| 20 | + | {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}}, | |
| 21 | + | } | |
| 22 | + | for _, tc := range cases { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |
| 24 | + | f := renderer.NewFrame(6, 6) | |
| 25 | + | DrawSeparator(f, tc.rect, tc.orientation, style) | |
| 26 | + | for y := 0; y < 6; y++ { | |
| 27 | + | for x := 0; x < 6; x++ { | |
| 28 | + | want := renderer.BlankCell() | |
| 29 | + | if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) || | |
| 30 | + | (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) { | |
| 31 | + | want = renderer.Cell{Rune: tc.rune, Style: style} | |
| 32 | + | } | |
| 33 | + | if !f.At(x, y).Equal(want) { | |
| 34 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |
| 43 | + | style := renderer.DefaultStyle() | |
| 44 | + | f := renderer.NewFrame(3, 3) | |
| 45 | + | for i := range f.Cells { | |
| 46 | + | f.Cells[i] = renderer.Cell{Rune: 'x', Style: style} | |
| 47 | + | } | |
| 48 | + | DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style) | |
| 49 | + | for x := 0; x < 3; x++ { | |
| 50 | + | if f.At(x, 1).Rune != '─' { | |
| 51 | + | t.Fatalf("clipping failed at %d", x) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | before := f.Clone() | |
| 55 | + | for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} { | |
| 56 | + | DrawSeparator(f, rect, SeparatorHorizontal, style) | |
| 57 | + | } | |
| 58 | + | DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style) | |
| 59 | + | if !framesEqual(f, before) { | |
| 60 | + | t.Fatal("invalid rectangles/orientation changed frame") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestDrawSeparatorInvalidFrame(t *testing.T) { | |
| 65 | + | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | |
| 66 | + | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func framesEqual(a, b renderer.Frame) bool { | |
| 70 | + | if a.Width != b.Width || a.Height != b.Height { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | for y := 0; y < a.Height; y++ { | |
| 74 | + | for x := 0; x < a.Width; x++ { | |
| 75 | + | if !a.At(x, y).Equal(b.At(x, y)) { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return true | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 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 TestDrawSeparator(t *testing.T) { | |||
| 11 | + | style := renderer.DefaultStyle() | |||
| 12 | + | style.Attrs = renderer.AttrDim | |||
| 13 | + | cases := []struct { | |||
| 14 | + | name string | |||
| 15 | + | orientation SeparatorOrientation | |||
| 16 | + | rune rune | |||
| 17 | + | rect domain.Rect | |||
| 18 | + | }{ | |||
| 19 | + | {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}}, | |||
| 20 | + | {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}}, | |||
| 21 | + | } | |||
| 22 | + | for _, tc := range cases { | |||
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 24 | + | f := renderer.NewFrame(6, 6) | |||
| 25 | + | DrawSeparator(f, tc.rect, tc.orientation, style) | |||
| 26 | + | for y := 0; y < 6; y++ { | |||
| 27 | + | for x := 0; x < 6; x++ { | |||
| 28 | + | want := renderer.BlankCell() | |||
| 29 | + | if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) || | |||
| 30 | + | (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) { | |||
| 31 | + | want = renderer.Cell{Rune: tc.rune, Style: style} | |||
| 32 | + | } | |||
| 33 | + | if !f.At(x, y).Equal(want) { | |||
| 34 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |||
| 43 | + | style := renderer.DefaultStyle() | |||
| 44 | + | f := renderer.NewFrame(3, 3) | |||
| 45 | + | for i := range f.Cells { | |||
| 46 | + | f.Cells[i] = renderer.Cell{Rune: 'x', Style: style} | |||
| 47 | + | } | |||
| 48 | + | DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style) | |||
| 49 | + | for x := 0; x < 3; x++ { | |||
| 50 | + | if f.At(x, 1).Rune != '─' { | |||
| 51 | + | t.Fatalf("clipping failed at %d", x) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | before := f.Clone() | |||
| 55 | + | for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} { | |||
| 56 | + | DrawSeparator(f, rect, SeparatorHorizontal, style) | |||
| 57 | + | } | |||
| 58 | + | DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style) | |||
| 59 | + | if !framesEqual(f, before) { | |||
| 60 | + | t.Fatal("invalid rectangles/orientation changed frame") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestDrawSeparatorInvalidFrame(t *testing.T) { | |||
| 65 | + | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | |||
| 66 | + | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func framesEqual(a, b renderer.Frame) bool { | |||
| 70 | + | if a.Width != b.Width || a.Height != b.Height { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | for y := 0; y < a.Height; y++ { | |||
| 74 | + | for x := 0; x < a.Width; x++ { | |||
| 75 | + | if !a.At(x, y).Equal(b.At(x, y)) { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | return true | |||
| 81 | + | } | |||