feat(ui): support bottom-anchored modals
- commit
- 0d2917c80c1e4f27ef396cf7f0e3af51b2c27a7b
- author
- brice · · 2026-07-03 05:18 UTC
- committer
- brice · · 2026-07-03 07:33 UTC
- parent
- af68b90
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +64 -8
M internal/usecase/ui/modal.go +34 -8
| @@ -5,13 +5,27 @@ import ( | |||
| 5 | 5 | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | // Modal describes a centered rectangular overlay. | |
| 8 | + | // Anchor describes where a modal is positioned within its base rectangle. | |
| 9 | + | type Anchor int | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | // AnchorCenter centers the modal within the base rectangle. | |
| 13 | + | AnchorCenter Anchor = iota | |
| 14 | + | // AnchorBottom positions the modal at the bottom above BottomMargin rows. | |
| 15 | + | AnchorBottom | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Modal describes a rectangular overlay. | |
| 9 | 19 | type Modal struct { | |
| 10 | - | WidthPct int | |
| 11 | - | HeightPct int | |
| 12 | - | MinWidth int | |
| 13 | - | MinHeight int | |
| 14 | - | Title string | |
| 20 | + | WidthPct int | |
| 21 | + | HeightPct int | |
| 22 | + | MinWidth int | |
| 23 | + | MinHeight int | |
| 24 | + | Title string | |
| 25 | + | Anchor Anchor | |
| 26 | + | FixedWidth int | |
| 27 | + | FixedHeight int | |
| 28 | + | BottomMargin int | |
| 15 | 29 | } | |
| 16 | 30 | ||
| 17 | 31 | // Bounds returns the modal rectangle centered within base and clamped to base. | |
| @@ -22,6 +36,12 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 22 | 36 | ||
| 23 | 37 | width := percentOf(base.Cols, m.WidthPct) | |
| 24 | 38 | height := percentOf(base.Rows, m.HeightPct) | |
| 39 | + | if m.FixedWidth > 0 { | |
| 40 | + | width = m.FixedWidth | |
| 41 | + | } | |
| 42 | + | if m.FixedHeight > 0 { | |
| 43 | + | height = m.FixedHeight | |
| 44 | + | } | |
| 25 | 45 | if width < m.MinWidth { | |
| 26 | 46 | width = m.MinWidth | |
| 27 | 47 | } | |
| @@ -31,9 +51,15 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 31 | 51 | width = clamp(width, 0, base.Cols) | |
| 32 | 52 | height = clamp(height, 0, base.Rows) | |
| 33 | 53 | ||
| 54 | + | x := (base.Cols - width) / 2 | |
| 55 | + | y := (base.Rows - height) / 2 | |
| 56 | + | if m.Anchor == AnchorBottom { | |
| 57 | + | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |
| 58 | + | } | |
| 59 | + | ||
| 34 | 60 | return domain.Rect{ | |
| 35 | - | X: (base.Cols - width) / 2, | |
| 36 | - | Y: (base.Rows - height) / 2, | |
| 61 | + | X: x, | |
| 62 | + | Y: y, | |
| 37 | 63 | Width: width, | |
| 38 | 64 | Height: height, | |
| 39 | 65 | } | |
| @@ -5,13 +5,27 @@ import ( | |||||
| 5 | "github.com/bnema/vev/pkg/renderer" | 5 | "github.com/bnema/vev/pkg/renderer" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | // Modal describes a centered rectangular overlay. | 8 | + | // Anchor describes where a modal is positioned within its base rectangle. |
| 9 | + | type Anchor int | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | // AnchorCenter centers the modal within the base rectangle. | |||
| 13 | + | AnchorCenter Anchor = iota | |||
| 14 | + | // AnchorBottom positions the modal at the bottom above BottomMargin rows. | |||
| 15 | + | AnchorBottom | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Modal describes a rectangular overlay. | |||
| 9 | type Modal struct { | 19 | type Modal struct { | ||
| 10 | - | WidthPct int | 20 | + | WidthPct int |
| 11 | - | HeightPct int | 21 | + | HeightPct int |
| 12 | - | MinWidth int | 22 | + | MinWidth int |
| 13 | - | MinHeight int | 23 | + | MinHeight int |
| 14 | - | Title string | 24 | + | Title string |
| 25 | + | Anchor Anchor | |||
| 26 | + | FixedWidth int | |||
| 27 | + | FixedHeight int | |||
| 28 | + | BottomMargin int | |||
| 15 | } | 29 | } | ||
| 16 | 30 | ||||
| 17 | // Bounds returns the modal rectangle centered within base and clamped to base. | 31 | // Bounds returns the modal rectangle centered within base and clamped to base. | ||
| @@ -22,6 +36,12 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 22 | 36 | ||||
| 23 | width := percentOf(base.Cols, m.WidthPct) | 37 | width := percentOf(base.Cols, m.WidthPct) | ||
| 24 | height := percentOf(base.Rows, m.HeightPct) | 38 | height := percentOf(base.Rows, m.HeightPct) | ||
| 39 | + | if m.FixedWidth > 0 { | |||
| 40 | + | width = m.FixedWidth | |||
| 41 | + | } | |||
| 42 | + | if m.FixedHeight > 0 { | |||
| 43 | + | height = m.FixedHeight | |||
| 44 | + | } | |||
| 25 | if width < m.MinWidth { | 45 | if width < m.MinWidth { | ||
| 26 | width = m.MinWidth | 46 | width = m.MinWidth | ||
| 27 | } | 47 | } | ||
| @@ -31,9 +51,15 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 31 | width = clamp(width, 0, base.Cols) | 51 | width = clamp(width, 0, base.Cols) | ||
| 32 | height = clamp(height, 0, base.Rows) | 52 | height = clamp(height, 0, base.Rows) | ||
| 33 | 53 | ||||
| 54 | + | x := (base.Cols - width) / 2 | |||
| 55 | + | y := (base.Rows - height) / 2 | |||
| 56 | + | if m.Anchor == AnchorBottom { | |||
| 57 | + | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 34 | return domain.Rect{ | 60 | return domain.Rect{ | ||
| 35 | - | X: (base.Cols - width) / 2, | 61 | + | X: x, |
| 36 | - | Y: (base.Rows - height) / 2, | 62 | + | Y: y, |
| 37 | Width: width, | 63 | Width: width, | ||
| 38 | Height: height, | 64 | Height: height, | ||
| 39 | } | 65 | } | ||
M internal/usecase/ui/modal_test.go +30 -0
| @@ -32,6 +32,36 @@ func TestModalBounds(t *testing.T) { | |||
| 32 | 32 | m: Modal{WidthPct: 50, HeightPct: 50}, | |
| 33 | 33 | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | |
| 34 | 34 | }, | |
| 35 | + | { | |
| 36 | + | name: "zero value fills base centered", | |
| 37 | + | base: domain.Size{Cols: 12, Rows: 8}, | |
| 38 | + | m: Modal{}, | |
| 39 | + | want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8}, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "bottom anchor honors bottom margin", | |
| 43 | + | base: domain.Size{Cols: 100, Rows: 40}, | |
| 44 | + | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3}, | |
| 45 | + | want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10}, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "fixed dimensions override percentages", | |
| 49 | + | base: domain.Size{Cols: 100, Rows: 40}, | |
| 50 | + | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | |
| 51 | + | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "fixed dimensions clamp on small terminal", | |
| 55 | + | base: domain.Size{Cols: 10, Rows: 4}, | |
| 56 | + | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2}, | |
| 57 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "bottom margin clamps to screen", | |
| 61 | + | base: domain.Size{Cols: 20, Rows: 10}, | |
| 62 | + | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20}, | |
| 63 | + | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | |
| 64 | + | }, | |
| 35 | 65 | } | |
| 36 | 66 | for _, tt := range tests { | |
| 37 | 67 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -32,6 +32,36 @@ func TestModalBounds(t *testing.T) { | |||||
| 32 | m: Modal{WidthPct: 50, HeightPct: 50}, | 32 | m: Modal{WidthPct: 50, HeightPct: 50}, | ||
| 33 | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | 33 | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | ||
| 34 | }, | 34 | }, | ||
| 35 | + | { | |||
| 36 | + | name: "zero value fills base centered", | |||
| 37 | + | base: domain.Size{Cols: 12, Rows: 8}, | |||
| 38 | + | m: Modal{}, | |||
| 39 | + | want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8}, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "bottom anchor honors bottom margin", | |||
| 43 | + | base: domain.Size{Cols: 100, Rows: 40}, | |||
| 44 | + | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3}, | |||
| 45 | + | want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10}, | |||
| 46 | + | }, | |||
| 47 | + | { | |||
| 48 | + | name: "fixed dimensions override percentages", | |||
| 49 | + | base: domain.Size{Cols: 100, Rows: 40}, | |||
| 50 | + | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | |||
| 51 | + | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "fixed dimensions clamp on small terminal", | |||
| 55 | + | base: domain.Size{Cols: 10, Rows: 4}, | |||
| 56 | + | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2}, | |||
| 57 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |||
| 58 | + | }, | |||
| 59 | + | { | |||
| 60 | + | name: "bottom margin clamps to screen", | |||
| 61 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 62 | + | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20}, | |||
| 63 | + | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | |||
| 64 | + | }, | |||
| 35 | } | 65 | } | ||
| 36 | for _, tt := range tests { | 66 | for _, tt := range tests { | ||
| 37 | t.Run(tt.name, func(t *testing.T) { | 67 | t.Run(tt.name, func(t *testing.T) { | ||