refactor(ui): add reusable anchor placement
- commit
- 2f1b8abea35ca8c6b7277ad4a288777b0e5548aa
- author
- brice · · 2026-07-11 06:23 UTC
- parent
- 4bfcb44
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +213 -85
M internal/usecase/daemon/copymode.go +1 -1
| @@ -37,7 +37,7 @@ import ( | |||
| 37 | 37 | "github.com/bnema/vev/pkg/renderer" | |
| 38 | 38 | ) | |
| 39 | 39 | ||
| 40 | - | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1} | |
| 40 | + | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 41 | 41 | ||
| 42 | 42 | func copyTargetPane(rt *overlayRuntime) *pane { | |
| 43 | 43 | if rt == nil { | |
| @@ -37,7 +37,7 @@ import ( | |||||
| 37 | "github.com/bnema/vev/pkg/renderer" | 37 | "github.com/bnema/vev/pkg/renderer" | ||
| 38 | ) | 38 | ) | ||
| 39 | 39 | ||||
| 40 | - | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1} | 40 | + | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} |
| 41 | 41 | ||||
| 42 | func copyTargetPane(rt *overlayRuntime) *pane { | 42 | func copyTargetPane(rt *overlayRuntime) *pane { | ||
| 43 | if rt == nil { | 43 | if rt == nil { | ||
M internal/usecase/daemon/palette.go +3 -3
| @@ -14,14 +14,14 @@ const ( | |||
| 14 | 14 | paletteRailWidth = 64 | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | |
| 17 | + | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 18 | 18 | ||
| 19 | 19 | func paletteModalFor(size domain.Size) ui.Modal { | |
| 20 | 20 | modal := paletteModal | |
| 21 | 21 | if size.Cols >= paletteRailBreakpoint { | |
| 22 | 22 | modal.FixedWidth = paletteRailWidth | |
| 23 | - | modal.HorizontalAnchor = ui.HorizontalAnchorRight | |
| 24 | - | modal.RightMargin = 1 | |
| 23 | + | modal.Anchor = domain.AnchorBottomRight | |
| 24 | + | modal.Margins.Right = 1 | |
| 25 | 25 | } | |
| 26 | 26 | return modal | |
| 27 | 27 | } | |
| @@ -14,14 +14,14 @@ const ( | |||||
| 14 | paletteRailWidth = 64 | 14 | paletteRailWidth = 64 | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | 17 | + | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} |
| 18 | 18 | ||||
| 19 | func paletteModalFor(size domain.Size) ui.Modal { | 19 | func paletteModalFor(size domain.Size) ui.Modal { | ||
| 20 | modal := paletteModal | 20 | modal := paletteModal | ||
| 21 | if size.Cols >= paletteRailBreakpoint { | 21 | if size.Cols >= paletteRailBreakpoint { | ||
| 22 | modal.FixedWidth = paletteRailWidth | 22 | modal.FixedWidth = paletteRailWidth | ||
| 23 | - | modal.HorizontalAnchor = ui.HorizontalAnchorRight | 23 | + | modal.Anchor = domain.AnchorBottomRight |
| 24 | - | modal.RightMargin = 1 | 24 | + | modal.Margins.Right = 1 |
| 25 | } | 25 | } | ||
| 26 | return modal | 26 | return modal | ||
| 27 | } | 27 | } | ||
M internal/usecase/daemon/palette_test.go +8 -2
| @@ -258,8 +258,14 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 258 | 258 | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |
| 259 | 259 | require.Equal(t, " Commands ", modal.Title) | |
| 260 | 260 | require.Equal(t, 11, modal.FixedHeight) | |
| 261 | - | require.Equal(t, ui.AnchorBottom, modal.Anchor) | |
| 262 | - | require.Equal(t, 1, modal.BottomMargin) | |
| 261 | + | wantAnchor := domain.AnchorBottom | |
| 262 | + | wantMargins := ui.Margins{Bottom: 1} | |
| 263 | + | if tt.size.Cols >= paletteRailBreakpoint { | |
| 264 | + | wantAnchor = domain.AnchorBottomRight | |
| 265 | + | wantMargins.Right = 1 | |
| 266 | + | } | |
| 267 | + | require.Equal(t, wantAnchor, modal.Anchor) | |
| 268 | + | require.Equal(t, wantMargins, modal.Margins) | |
| 263 | 269 | }) | |
| 264 | 270 | } | |
| 265 | 271 | } | |
| @@ -258,8 +258,14 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 258 | require.Equal(t, tt.want, modal.Bounds(tt.size)) | 258 | require.Equal(t, tt.want, modal.Bounds(tt.size)) | ||
| 259 | require.Equal(t, " Commands ", modal.Title) | 259 | require.Equal(t, " Commands ", modal.Title) | ||
| 260 | require.Equal(t, 11, modal.FixedHeight) | 260 | require.Equal(t, 11, modal.FixedHeight) | ||
| 261 | - | require.Equal(t, ui.AnchorBottom, modal.Anchor) | 261 | + | wantAnchor := domain.AnchorBottom |
| 262 | - | require.Equal(t, 1, modal.BottomMargin) | 262 | + | wantMargins := ui.Margins{Bottom: 1} |
| 263 | + | if tt.size.Cols >= paletteRailBreakpoint { | |||
| 264 | + | wantAnchor = domain.AnchorBottomRight | |||
| 265 | + | wantMargins.Right = 1 | |||
| 266 | + | } | |||
| 267 | + | require.Equal(t, wantAnchor, modal.Anchor) | |||
| 268 | + | require.Equal(t, wantMargins, modal.Margins) | |||
| 263 | }) | 269 | }) | ||
| 264 | } | 270 | } | ||
| 265 | } | 271 | } | ||
M internal/usecase/daemon/prompt.go +2 -1
| @@ -3,13 +3,14 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | func promptModalFor(title string) ui.Modal { | |
| 12 | - | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1} | |
| 13 | + | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) { | |
| @@ -3,13 +3,14 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 7 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 8 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | func promptModalFor(title string) ui.Modal { | 12 | func promptModalFor(title string) ui.Modal { | ||
| 12 | - | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1} | 13 | + | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} |
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| 15 | func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) { | 16 | func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) { | ||
M internal/usecase/ui/modal.go +10 -63
| @@ -5,47 +5,21 @@ import ( | |||
| 5 | 5 | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 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 | - | // HorizontalAnchor describes a modal's horizontal position within its base rectangle. | |
| 19 | - | type HorizontalAnchor int | |
| 20 | - | ||
| 21 | - | const ( | |
| 22 | - | // HorizontalAnchorCenter centers the modal within the base rectangle. | |
| 23 | - | HorizontalAnchorCenter HorizontalAnchor = iota | |
| 24 | - | // HorizontalAnchorRight positions the modal left of RightMargin columns. | |
| 25 | - | HorizontalAnchorRight | |
| 26 | - | ) | |
| 27 | - | ||
| 28 | 8 | // Modal describes a rectangular overlay. | |
| 29 | 9 | type Modal struct { | |
| 30 | - | WidthPct int | |
| 31 | - | HeightPct int | |
| 32 | - | MinWidth int | |
| 33 | - | MinHeight int | |
| 34 | - | Title string | |
| 35 | - | Anchor Anchor | |
| 36 | - | HorizontalAnchor HorizontalAnchor | |
| 37 | - | FixedWidth int | |
| 38 | - | FixedHeight int | |
| 39 | - | BottomMargin int | |
| 40 | - | RightMargin int | |
| 10 | + | WidthPct int | |
| 11 | + | HeightPct int | |
| 12 | + | MinWidth int | |
| 13 | + | MinHeight int | |
| 14 | + | Title string | |
| 15 | + | Anchor domain.Anchor | |
| 16 | + | Margins Margins | |
| 17 | + | FixedWidth int | |
| 18 | + | FixedHeight int | |
| 41 | 19 | } | |
| 42 | 20 | ||
| 43 | 21 | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| 44 | 22 | func (m Modal) Bounds(base domain.Size) domain.Rect { | |
| 45 | - | if base.Cols <= 0 || base.Rows <= 0 { | |
| 46 | - | return domain.Rect{} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | 23 | width := percentOf(base.Cols, m.WidthPct) | |
| 50 | 24 | height := percentOf(base.Rows, m.HeightPct) | |
| 51 | 25 | if m.FixedWidth > 0 { | |
| @@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 60 | 34 | if height < m.MinHeight { | |
| 61 | 35 | height = m.MinHeight | |
| 62 | 36 | } | |
| 63 | - | width = clamp(width, 0, base.Cols) | |
| 64 | - | height = clamp(height, 0, base.Rows) | |
| 65 | - | ||
| 66 | - | x := (base.Cols - width) / 2 | |
| 67 | - | if m.HorizontalAnchor == HorizontalAnchorRight { | |
| 68 | - | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |
| 69 | - | } | |
| 70 | - | y := (base.Rows - height) / 2 | |
| 71 | - | if m.Anchor == AnchorBottom { | |
| 72 | - | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | return domain.Rect{ | |
| 76 | - | X: x, | |
| 77 | - | Y: y, | |
| 78 | - | Width: width, | |
| 79 | - | Height: height, | |
| 80 | - | } | |
| 37 | + | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | |
| 81 | 38 | } | |
| 82 | 39 | ||
| 83 | 40 | // Inner returns the modal content rectangle after removing a one-cell border. | |
| @@ -193,16 +150,6 @@ func percentOf(n, pct int) int { | |||
| 193 | 150 | return n * pct / 100 | |
| 194 | 151 | } | |
| 195 | 152 | ||
| 196 | - | func clamp(n, low, high int) int { | |
| 197 | - | if n < low { | |
| 198 | - | return low | |
| 199 | - | } | |
| 200 | - | if n > high { | |
| 201 | - | return high | |
| 202 | - | } | |
| 203 | - | return n | |
| 204 | - | } | |
| 205 | - | ||
| 206 | 153 | func textWidth(text string) int { | |
| 207 | 154 | width := 0 | |
| 208 | 155 | for _, r := range text { | |
| @@ -5,47 +5,21 @@ import ( | |||||
| 5 | "github.com/bnema/vev/pkg/renderer" | 5 | "github.com/bnema/vev/pkg/renderer" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 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 | - | // HorizontalAnchor describes a modal's horizontal position within its base rectangle. | |||
| 19 | - | type HorizontalAnchor int | |||
| 20 | - | ||||
| 21 | - | const ( | |||
| 22 | - | // HorizontalAnchorCenter centers the modal within the base rectangle. | |||
| 23 | - | HorizontalAnchorCenter HorizontalAnchor = iota | |||
| 24 | - | // HorizontalAnchorRight positions the modal left of RightMargin columns. | |||
| 25 | - | HorizontalAnchorRight | |||
| 26 | - | ) | |||
| 27 | - | ||||
| 28 | // Modal describes a rectangular overlay. | 8 | // Modal describes a rectangular overlay. | ||
| 29 | type Modal struct { | 9 | type Modal struct { | ||
| 30 | - | WidthPct int | 10 | + | WidthPct int |
| 31 | - | HeightPct int | 11 | + | HeightPct int |
| 32 | - | MinWidth int | 12 | + | MinWidth int |
| 33 | - | MinHeight int | 13 | + | MinHeight int |
| 34 | - | Title string | 14 | + | Title string |
| 35 | - | Anchor Anchor | 15 | + | Anchor domain.Anchor |
| 36 | - | HorizontalAnchor HorizontalAnchor | 16 | + | Margins Margins |
| 37 | - | FixedWidth int | 17 | + | FixedWidth int |
| 38 | - | FixedHeight int | 18 | + | FixedHeight int |
| 39 | - | BottomMargin int | |||
| 40 | - | RightMargin int | |||
| 41 | } | 19 | } | ||
| 42 | 20 | ||||
| 43 | // Bounds returns the modal rectangle positioned within base and clamped to base. | 21 | // Bounds returns the modal rectangle positioned within base and clamped to base. | ||
| 44 | func (m Modal) Bounds(base domain.Size) domain.Rect { | 22 | func (m Modal) Bounds(base domain.Size) domain.Rect { | ||
| 45 | - | if base.Cols <= 0 || base.Rows <= 0 { | |||
| 46 | - | return domain.Rect{} | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | width := percentOf(base.Cols, m.WidthPct) | 23 | width := percentOf(base.Cols, m.WidthPct) | ||
| 50 | height := percentOf(base.Rows, m.HeightPct) | 24 | height := percentOf(base.Rows, m.HeightPct) | ||
| 51 | if m.FixedWidth > 0 { | 25 | if m.FixedWidth > 0 { | ||
| @@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 60 | if height < m.MinHeight { | 34 | if height < m.MinHeight { | ||
| 61 | height = m.MinHeight | 35 | height = m.MinHeight | ||
| 62 | } | 36 | } | ||
| 63 | - | width = clamp(width, 0, base.Cols) | 37 | + | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) |
| 64 | - | height = clamp(height, 0, base.Rows) | |||
| 65 | - | ||||
| 66 | - | x := (base.Cols - width) / 2 | |||
| 67 | - | if m.HorizontalAnchor == HorizontalAnchorRight { | |||
| 68 | - | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |||
| 69 | - | } | |||
| 70 | - | y := (base.Rows - height) / 2 | |||
| 71 | - | if m.Anchor == AnchorBottom { | |||
| 72 | - | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | return domain.Rect{ | |||
| 76 | - | X: x, | |||
| 77 | - | Y: y, | |||
| 78 | - | Width: width, | |||
| 79 | - | Height: height, | |||
| 80 | - | } | |||
| 81 | } | 38 | } | ||
| 82 | 39 | ||||
| 83 | // Inner returns the modal content rectangle after removing a one-cell border. | 40 | // Inner returns the modal content rectangle after removing a one-cell border. | ||
| @@ -193,16 +150,6 @@ func percentOf(n, pct int) int { | |||||
| 193 | return n * pct / 100 | 150 | return n * pct / 100 | ||
| 194 | } | 151 | } | ||
| 195 | 152 | ||||
| 196 | - | func clamp(n, low, high int) int { | |||
| 197 | - | if n < low { | |||
| 198 | - | return low | |||
| 199 | - | } | |||
| 200 | - | if n > high { | |||
| 201 | - | return high | |||
| 202 | - | } | |||
| 203 | - | return n | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | func textWidth(text string) int { | 153 | func textWidth(text string) int { | ||
| 207 | width := 0 | 154 | width := 0 | ||
| 208 | for _, r := range text { | 155 | for _, r := range text { | ||
M internal/usecase/ui/modal_test.go +15 -15
| @@ -41,7 +41,7 @@ func TestModalBounds(t *testing.T) { | |||
| 41 | 41 | { | |
| 42 | 42 | name: "bottom anchor honors bottom margin", | |
| 43 | 43 | base: domain.Size{Cols: 100, Rows: 40}, | |
| 44 | - | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3}, | |
| 44 | + | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}}, | |
| 45 | 45 | want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10}, | |
| 46 | 46 | }, | |
| 47 | 47 | { | |
| @@ -54,10 +54,10 @@ func TestModalBounds(t *testing.T) { | |||
| 54 | 54 | name: "right anchor honors right margin", | |
| 55 | 55 | base: domain.Size{Cols: 100, Rows: 40}, | |
| 56 | 56 | m: Modal{ | |
| 57 | - | FixedWidth: 30, | |
| 58 | - | FixedHeight: 12, | |
| 59 | - | HorizontalAnchor: HorizontalAnchorRight, | |
| 60 | - | RightMargin: 3, | |
| 57 | + | FixedWidth: 30, | |
| 58 | + | FixedHeight: 12, | |
| 59 | + | Anchor: domain.AnchorRight, | |
| 60 | + | Margins: Margins{Right: 3}, | |
| 61 | 61 | }, | |
| 62 | 62 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |
| 63 | 63 | }, | |
| @@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) { | |||
| 65 | 65 | name: "right margin clamps to screen", | |
| 66 | 66 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 67 | 67 | m: Modal{ | |
| 68 | - | FixedWidth: 8, | |
| 69 | - | FixedHeight: 4, | |
| 70 | - | HorizontalAnchor: HorizontalAnchorRight, | |
| 71 | - | RightMargin: 20, | |
| 68 | + | FixedWidth: 8, | |
| 69 | + | FixedHeight: 4, | |
| 70 | + | Anchor: domain.AnchorRight, | |
| 71 | + | Margins: Margins{Right: 20}, | |
| 72 | 72 | }, | |
| 73 | 73 | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |
| 74 | 74 | }, | |
| @@ -76,23 +76,23 @@ func TestModalBounds(t *testing.T) { | |||
| 76 | 76 | name: "right anchored fixed dimensions clamp on small terminal", | |
| 77 | 77 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 78 | 78 | m: Modal{ | |
| 79 | - | FixedWidth: 30, | |
| 80 | - | FixedHeight: 12, | |
| 81 | - | HorizontalAnchor: HorizontalAnchorRight, | |
| 82 | - | RightMargin: 3, | |
| 79 | + | FixedWidth: 30, | |
| 80 | + | FixedHeight: 12, | |
| 81 | + | Anchor: domain.AnchorRight, | |
| 82 | + | Margins: Margins{Right: 3}, | |
| 83 | 83 | }, | |
| 84 | 84 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 85 | 85 | }, | |
| 86 | 86 | { | |
| 87 | 87 | name: "fixed dimensions clamp on small terminal", | |
| 88 | 88 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 89 | - | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2}, | |
| 89 | + | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | |
| 90 | 90 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 91 | 91 | }, | |
| 92 | 92 | { | |
| 93 | 93 | name: "bottom margin clamps to screen", | |
| 94 | 94 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 95 | - | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20}, | |
| 95 | + | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | |
| 96 | 96 | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | |
| 97 | 97 | }, | |
| 98 | 98 | } | |
| @@ -41,7 +41,7 @@ func TestModalBounds(t *testing.T) { | |||||
| 41 | { | 41 | { | ||
| 42 | name: "bottom anchor honors bottom margin", | 42 | name: "bottom anchor honors bottom margin", | ||
| 43 | base: domain.Size{Cols: 100, Rows: 40}, | 43 | base: domain.Size{Cols: 100, Rows: 40}, | ||
| 44 | - | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3}, | 44 | + | m: Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}}, |
| 45 | want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10}, | 45 | want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10}, | ||
| 46 | }, | 46 | }, | ||
| 47 | { | 47 | { | ||
| @@ -54,10 +54,10 @@ func TestModalBounds(t *testing.T) { | |||||
| 54 | name: "right anchor honors right margin", | 54 | name: "right anchor honors right margin", | ||
| 55 | base: domain.Size{Cols: 100, Rows: 40}, | 55 | base: domain.Size{Cols: 100, Rows: 40}, | ||
| 56 | m: Modal{ | 56 | m: Modal{ | ||
| 57 | - | FixedWidth: 30, | 57 | + | FixedWidth: 30, |
| 58 | - | FixedHeight: 12, | 58 | + | FixedHeight: 12, |
| 59 | - | HorizontalAnchor: HorizontalAnchorRight, | 59 | + | Anchor: domain.AnchorRight, |
| 60 | - | RightMargin: 3, | 60 | + | Margins: Margins{Right: 3}, |
| 61 | }, | 61 | }, | ||
| 62 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | 62 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | ||
| 63 | }, | 63 | }, | ||
| @@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) { | |||||
| 65 | name: "right margin clamps to screen", | 65 | name: "right margin clamps to screen", | ||
| 66 | base: domain.Size{Cols: 20, Rows: 10}, | 66 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 67 | m: Modal{ | 67 | m: Modal{ | ||
| 68 | - | FixedWidth: 8, | 68 | + | FixedWidth: 8, |
| 69 | - | FixedHeight: 4, | 69 | + | FixedHeight: 4, |
| 70 | - | HorizontalAnchor: HorizontalAnchorRight, | 70 | + | Anchor: domain.AnchorRight, |
| 71 | - | RightMargin: 20, | 71 | + | Margins: Margins{Right: 20}, |
| 72 | }, | 72 | }, | ||
| 73 | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | 73 | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | ||
| 74 | }, | 74 | }, | ||
| @@ -76,23 +76,23 @@ func TestModalBounds(t *testing.T) { | |||||
| 76 | name: "right anchored fixed dimensions clamp on small terminal", | 76 | name: "right anchored fixed dimensions clamp on small terminal", | ||
| 77 | base: domain.Size{Cols: 10, Rows: 4}, | 77 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 78 | m: Modal{ | 78 | m: Modal{ | ||
| 79 | - | FixedWidth: 30, | 79 | + | FixedWidth: 30, |
| 80 | - | FixedHeight: 12, | 80 | + | FixedHeight: 12, |
| 81 | - | HorizontalAnchor: HorizontalAnchorRight, | 81 | + | Anchor: domain.AnchorRight, |
| 82 | - | RightMargin: 3, | 82 | + | Margins: Margins{Right: 3}, |
| 83 | }, | 83 | }, | ||
| 84 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 84 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | ||
| 85 | }, | 85 | }, | ||
| 86 | { | 86 | { | ||
| 87 | name: "fixed dimensions clamp on small terminal", | 87 | name: "fixed dimensions clamp on small terminal", | ||
| 88 | base: domain.Size{Cols: 10, Rows: 4}, | 88 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 89 | - | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2}, | 89 | + | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, |
| 90 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 90 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | ||
| 91 | }, | 91 | }, | ||
| 92 | { | 92 | { | ||
| 93 | name: "bottom margin clamps to screen", | 93 | name: "bottom margin clamps to screen", | ||
| 94 | base: domain.Size{Cols: 20, Rows: 10}, | 94 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 95 | - | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20}, | 95 | + | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, |
| 96 | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | 96 | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | ||
| 97 | }, | 97 | }, | ||
| 98 | } | 98 | } | ||
A internal/usecase/ui/place.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // Margins specifies the distance between an anchored rectangle and each base edge. | |
| 6 | + | type Margins struct { | |
| 7 | + | Top, Right, Bottom, Left int | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // Place positions content in base according to anchor. Negative margins and | |
| 11 | + | // content dimensions are normalized to zero. The returned rectangle is always | |
| 12 | + | // contained within a positive base; a nonpositive base returns an empty rect. | |
| 13 | + | func Place(base, content domain.Size, anchor domain.Anchor, margins Margins) domain.Rect { | |
| 14 | + | if !base.Valid() { | |
| 15 | + | return domain.Rect{} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | width := clamp(content.Cols, 0, base.Cols) | |
| 19 | + | height := clamp(content.Rows, 0, base.Rows) | |
| 20 | + | margins.Top = max(0, margins.Top) | |
| 21 | + | margins.Right = max(0, margins.Right) | |
| 22 | + | margins.Bottom = max(0, margins.Bottom) | |
| 23 | + | margins.Left = max(0, margins.Left) | |
| 24 | + | ||
| 25 | + | x := (base.Cols - width) / 2 | |
| 26 | + | y := (base.Rows - height) / 2 | |
| 27 | + | switch anchor { | |
| 28 | + | case domain.AnchorTopLeft: | |
| 29 | + | x, y = margins.Left, margins.Top | |
| 30 | + | case domain.AnchorTop: | |
| 31 | + | y = margins.Top | |
| 32 | + | case domain.AnchorTopRight: | |
| 33 | + | x, y = base.Cols-margins.Right-width, margins.Top | |
| 34 | + | case domain.AnchorLeft: | |
| 35 | + | x = margins.Left | |
| 36 | + | case domain.AnchorRight: | |
| 37 | + | x = base.Cols - margins.Right - width | |
| 38 | + | case domain.AnchorBottomLeft: | |
| 39 | + | x, y = margins.Left, base.Rows-margins.Bottom-height | |
| 40 | + | case domain.AnchorBottom: | |
| 41 | + | y = base.Rows - margins.Bottom - height | |
| 42 | + | case domain.AnchorBottomRight: | |
| 43 | + | x, y = base.Cols-margins.Right-width, base.Rows-margins.Bottom-height | |
| 44 | + | case domain.AnchorCenter: | |
| 45 | + | // Center is the zero value and intentionally ignores all margins. | |
| 46 | + | default: | |
| 47 | + | // Invalid anchors fall back to center. | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | return domain.Rect{ | |
| 51 | + | X: clamp(x, 0, base.Cols-width), | |
| 52 | + | Y: clamp(y, 0, base.Rows-height), | |
| 53 | + | Width: width, | |
| 54 | + | Height: height, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func clamp(n, low, high int) int { | |
| 59 | + | if n < low { | |
| 60 | + | return low | |
| 61 | + | } | |
| 62 | + | if n > high { | |
| 63 | + | return high | |
| 64 | + | } | |
| 65 | + | return n | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // Margins specifies the distance between an anchored rectangle and each base edge. | |||
| 6 | + | type Margins struct { | |||
| 7 | + | Top, Right, Bottom, Left int | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // Place positions content in base according to anchor. Negative margins and | |||
| 11 | + | // content dimensions are normalized to zero. The returned rectangle is always | |||
| 12 | + | // contained within a positive base; a nonpositive base returns an empty rect. | |||
| 13 | + | func Place(base, content domain.Size, anchor domain.Anchor, margins Margins) domain.Rect { | |||
| 14 | + | if !base.Valid() { | |||
| 15 | + | return domain.Rect{} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | width := clamp(content.Cols, 0, base.Cols) | |||
| 19 | + | height := clamp(content.Rows, 0, base.Rows) | |||
| 20 | + | margins.Top = max(0, margins.Top) | |||
| 21 | + | margins.Right = max(0, margins.Right) | |||
| 22 | + | margins.Bottom = max(0, margins.Bottom) | |||
| 23 | + | margins.Left = max(0, margins.Left) | |||
| 24 | + | ||||
| 25 | + | x := (base.Cols - width) / 2 | |||
| 26 | + | y := (base.Rows - height) / 2 | |||
| 27 | + | switch anchor { | |||
| 28 | + | case domain.AnchorTopLeft: | |||
| 29 | + | x, y = margins.Left, margins.Top | |||
| 30 | + | case domain.AnchorTop: | |||
| 31 | + | y = margins.Top | |||
| 32 | + | case domain.AnchorTopRight: | |||
| 33 | + | x, y = base.Cols-margins.Right-width, margins.Top | |||
| 34 | + | case domain.AnchorLeft: | |||
| 35 | + | x = margins.Left | |||
| 36 | + | case domain.AnchorRight: | |||
| 37 | + | x = base.Cols - margins.Right - width | |||
| 38 | + | case domain.AnchorBottomLeft: | |||
| 39 | + | x, y = margins.Left, base.Rows-margins.Bottom-height | |||
| 40 | + | case domain.AnchorBottom: | |||
| 41 | + | y = base.Rows - margins.Bottom - height | |||
| 42 | + | case domain.AnchorBottomRight: | |||
| 43 | + | x, y = base.Cols-margins.Right-width, base.Rows-margins.Bottom-height | |||
| 44 | + | case domain.AnchorCenter: | |||
| 45 | + | // Center is the zero value and intentionally ignores all margins. | |||
| 46 | + | default: | |||
| 47 | + | // Invalid anchors fall back to center. | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | return domain.Rect{ | |||
| 51 | + | X: clamp(x, 0, base.Cols-width), | |||
| 52 | + | Y: clamp(y, 0, base.Rows-height), | |||
| 53 | + | Width: width, | |||
| 54 | + | Height: height, | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func clamp(n, low, high int) int { | |||
| 59 | + | if n < low { | |||
| 60 | + | return low | |||
| 61 | + | } | |||
| 62 | + | if n > high { | |||
| 63 | + | return high | |||
| 64 | + | } | |||
| 65 | + | return n | |||
| 66 | + | } | |||
A internal/usecase/ui/place_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPlaceAnchors(t *testing.T) { | |
| 10 | + | base := domain.Size{Cols: 31, Rows: 19} | |
| 11 | + | content := domain.Size{Cols: 8, Rows: 6} | |
| 12 | + | margins := Margins{Top: 2, Right: 3, Bottom: 4, Left: 5} | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | anchor domain.Anchor | |
| 16 | + | want domain.Rect | |
| 17 | + | }{ | |
| 18 | + | {domain.AnchorTopLeft, domain.Rect{X: 5, Y: 2, Width: 8, Height: 6}}, | |
| 19 | + | {domain.AnchorTop, domain.Rect{X: 11, Y: 2, Width: 8, Height: 6}}, | |
| 20 | + | {domain.AnchorTopRight, domain.Rect{X: 20, Y: 2, Width: 8, Height: 6}}, | |
| 21 | + | {domain.AnchorLeft, domain.Rect{X: 5, Y: 6, Width: 8, Height: 6}}, | |
| 22 | + | {domain.AnchorCenter, domain.Rect{X: 11, Y: 6, Width: 8, Height: 6}}, | |
| 23 | + | {domain.AnchorRight, domain.Rect{X: 20, Y: 6, Width: 8, Height: 6}}, | |
| 24 | + | {domain.AnchorBottomLeft, domain.Rect{X: 5, Y: 9, Width: 8, Height: 6}}, | |
| 25 | + | {domain.AnchorBottom, domain.Rect{X: 11, Y: 9, Width: 8, Height: 6}}, | |
| 26 | + | {domain.AnchorBottomRight, domain.Rect{X: 20, Y: 9, Width: 8, Height: 6}}, | |
| 27 | + | } | |
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.anchor.String(), func(t *testing.T) { | |
| 30 | + | if got := Place(base, content, tt.anchor, margins); got != tt.want { | |
| 31 | + | t.Fatalf("Place() = %+v, want %+v", got, tt.want) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPlaceNormalizesAndClamps(t *testing.T) { | |
| 38 | + | tests := []struct { | |
| 39 | + | name string | |
| 40 | + | base domain.Size | |
| 41 | + | content domain.Size | |
| 42 | + | anchor domain.Anchor | |
| 43 | + | margins Margins | |
| 44 | + | want domain.Rect | |
| 45 | + | }{ | |
| 46 | + | { | |
| 47 | + | name: "center ignores margins and rounds down", | |
| 48 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 49 | + | content: domain.Size{Cols: 5, Rows: 3}, | |
| 50 | + | anchor: domain.AnchorCenter, | |
| 51 | + | margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100}, | |
| 52 | + | want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3}, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "invalid anchor falls back to center", | |
| 56 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 57 | + | content: domain.Size{Cols: 5, Rows: 3}, | |
| 58 | + | anchor: domain.Anchor(99), | |
| 59 | + | margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100}, | |
| 60 | + | want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3}, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "nonpositive base is empty", | |
| 64 | + | base: domain.Size{Cols: 0, Rows: 8}, | |
| 65 | + | content: domain.Size{Cols: 5, Rows: 3}, | |
| 66 | + | anchor: domain.AnchorTopLeft, | |
| 67 | + | want: domain.Rect{}, | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | name: "negative content clamps to zero", | |
| 71 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 72 | + | content: domain.Size{Cols: -5, Rows: -3}, | |
| 73 | + | anchor: domain.AnchorBottomRight, | |
| 74 | + | want: domain.Rect{X: 10, Y: 8}, | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: "oversized content clamps to base", | |
| 78 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 79 | + | content: domain.Size{Cols: 50, Rows: 30}, | |
| 80 | + | anchor: domain.AnchorTopLeft, | |
| 81 | + | margins: Margins{Top: 2, Left: 3}, | |
| 82 | + | want: domain.Rect{Width: 10, Height: 8}, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "negative margins normalize to zero", | |
| 86 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 87 | + | content: domain.Size{Cols: 5, Rows: 3}, | |
| 88 | + | anchor: domain.AnchorTopLeft, | |
| 89 | + | margins: Margins{Top: -2, Left: -3}, | |
| 90 | + | want: domain.Rect{Width: 5, Height: 3}, | |
| 91 | + | }, | |
| 92 | + | { | |
| 93 | + | name: "excessive relevant margins clamp coordinates", | |
| 94 | + | base: domain.Size{Cols: 10, Rows: 8}, | |
| 95 | + | content: domain.Size{Cols: 5, Rows: 3}, | |
| 96 | + | anchor: domain.AnchorBottomRight, | |
| 97 | + | margins: Margins{Bottom: 20, Right: 20}, | |
| 98 | + | want: domain.Rect{Width: 5, Height: 3}, | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | if got := Place(tt.base, tt.content, tt.anchor, tt.margins); got != tt.want { | |
| 104 | + | t.Fatalf("Place() = %+v, want %+v", got, tt.want) | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPlaceAnchors(t *testing.T) { | |||
| 10 | + | base := domain.Size{Cols: 31, Rows: 19} | |||
| 11 | + | content := domain.Size{Cols: 8, Rows: 6} | |||
| 12 | + | margins := Margins{Top: 2, Right: 3, Bottom: 4, Left: 5} | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | anchor domain.Anchor | |||
| 16 | + | want domain.Rect | |||
| 17 | + | }{ | |||
| 18 | + | {domain.AnchorTopLeft, domain.Rect{X: 5, Y: 2, Width: 8, Height: 6}}, | |||
| 19 | + | {domain.AnchorTop, domain.Rect{X: 11, Y: 2, Width: 8, Height: 6}}, | |||
| 20 | + | {domain.AnchorTopRight, domain.Rect{X: 20, Y: 2, Width: 8, Height: 6}}, | |||
| 21 | + | {domain.AnchorLeft, domain.Rect{X: 5, Y: 6, Width: 8, Height: 6}}, | |||
| 22 | + | {domain.AnchorCenter, domain.Rect{X: 11, Y: 6, Width: 8, Height: 6}}, | |||
| 23 | + | {domain.AnchorRight, domain.Rect{X: 20, Y: 6, Width: 8, Height: 6}}, | |||
| 24 | + | {domain.AnchorBottomLeft, domain.Rect{X: 5, Y: 9, Width: 8, Height: 6}}, | |||
| 25 | + | {domain.AnchorBottom, domain.Rect{X: 11, Y: 9, Width: 8, Height: 6}}, | |||
| 26 | + | {domain.AnchorBottomRight, domain.Rect{X: 20, Y: 9, Width: 8, Height: 6}}, | |||
| 27 | + | } | |||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.anchor.String(), func(t *testing.T) { | |||
| 30 | + | if got := Place(base, content, tt.anchor, margins); got != tt.want { | |||
| 31 | + | t.Fatalf("Place() = %+v, want %+v", got, tt.want) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPlaceNormalizesAndClamps(t *testing.T) { | |||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | base domain.Size | |||
| 41 | + | content domain.Size | |||
| 42 | + | anchor domain.Anchor | |||
| 43 | + | margins Margins | |||
| 44 | + | want domain.Rect | |||
| 45 | + | }{ | |||
| 46 | + | { | |||
| 47 | + | name: "center ignores margins and rounds down", | |||
| 48 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 49 | + | content: domain.Size{Cols: 5, Rows: 3}, | |||
| 50 | + | anchor: domain.AnchorCenter, | |||
| 51 | + | margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100}, | |||
| 52 | + | want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3}, | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "invalid anchor falls back to center", | |||
| 56 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 57 | + | content: domain.Size{Cols: 5, Rows: 3}, | |||
| 58 | + | anchor: domain.Anchor(99), | |||
| 59 | + | margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100}, | |||
| 60 | + | want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3}, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "nonpositive base is empty", | |||
| 64 | + | base: domain.Size{Cols: 0, Rows: 8}, | |||
| 65 | + | content: domain.Size{Cols: 5, Rows: 3}, | |||
| 66 | + | anchor: domain.AnchorTopLeft, | |||
| 67 | + | want: domain.Rect{}, | |||
| 68 | + | }, | |||
| 69 | + | { | |||
| 70 | + | name: "negative content clamps to zero", | |||
| 71 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 72 | + | content: domain.Size{Cols: -5, Rows: -3}, | |||
| 73 | + | anchor: domain.AnchorBottomRight, | |||
| 74 | + | want: domain.Rect{X: 10, Y: 8}, | |||
| 75 | + | }, | |||
| 76 | + | { | |||
| 77 | + | name: "oversized content clamps to base", | |||
| 78 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 79 | + | content: domain.Size{Cols: 50, Rows: 30}, | |||
| 80 | + | anchor: domain.AnchorTopLeft, | |||
| 81 | + | margins: Margins{Top: 2, Left: 3}, | |||
| 82 | + | want: domain.Rect{Width: 10, Height: 8}, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "negative margins normalize to zero", | |||
| 86 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 87 | + | content: domain.Size{Cols: 5, Rows: 3}, | |||
| 88 | + | anchor: domain.AnchorTopLeft, | |||
| 89 | + | margins: Margins{Top: -2, Left: -3}, | |||
| 90 | + | want: domain.Rect{Width: 5, Height: 3}, | |||
| 91 | + | }, | |||
| 92 | + | { | |||
| 93 | + | name: "excessive relevant margins clamp coordinates", | |||
| 94 | + | base: domain.Size{Cols: 10, Rows: 8}, | |||
| 95 | + | content: domain.Size{Cols: 5, Rows: 3}, | |||
| 96 | + | anchor: domain.AnchorBottomRight, | |||
| 97 | + | margins: Margins{Bottom: 20, Right: 20}, | |||
| 98 | + | want: domain.Rect{Width: 5, Height: 3}, | |||
| 99 | + | }, | |||
| 100 | + | } | |||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | if got := Place(tt.base, tt.content, tt.anchor, tt.margins); got != tt.want { | |||
| 104 | + | t.Fatalf("Place() = %+v, want %+v", got, tt.want) | |||
| 105 | + | } | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | } | |||