feat(ui): support right-anchored modals
- commit
- 31971aef6ea106ecdca74e4fa9851e0b520608b4
- author
- brice · · 2026-07-09 23:26 UTC
- committer
- brice · · 2026-07-09 23:28 UTC
- parent
- 7e5b90a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +57 -9
M internal/usecase/ui/modal.go +24 -9
| @@ -15,17 +15,29 @@ const ( | |||
| 15 | 15 | AnchorBottom | |
| 16 | 16 | ) | |
| 17 | 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 | + | ||
| 18 | 28 | // Modal describes a rectangular overlay. | |
| 19 | 29 | type Modal struct { | |
| 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 | |
| 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 | |
| 29 | 41 | } | |
| 30 | 42 | ||
| 31 | 43 | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| @@ -52,6 +64,9 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 52 | 64 | height = clamp(height, 0, base.Rows) | |
| 53 | 65 | ||
| 54 | 66 | x := (base.Cols - width) / 2 | |
| 67 | + | if m.HorizontalAnchor == HorizontalAnchorRight { | |
| 68 | + | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |
| 69 | + | } | |
| 55 | 70 | y := (base.Rows - height) / 2 | |
| 56 | 71 | if m.Anchor == AnchorBottom { | |
| 57 | 72 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |
| @@ -15,17 +15,29 @@ const ( | |||||
| 15 | AnchorBottom | 15 | AnchorBottom | ||
| 16 | ) | 16 | ) | ||
| 17 | 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 | + | ||||
| 18 | // Modal describes a rectangular overlay. | 28 | // Modal describes a rectangular overlay. | ||
| 19 | type Modal struct { | 29 | type Modal struct { | ||
| 20 | - | WidthPct int | 30 | + | WidthPct int |
| 21 | - | HeightPct int | 31 | + | HeightPct int |
| 22 | - | MinWidth int | 32 | + | MinWidth int |
| 23 | - | MinHeight int | 33 | + | MinHeight int |
| 24 | - | Title string | 34 | + | Title string |
| 25 | - | Anchor Anchor | 35 | + | Anchor Anchor |
| 26 | - | FixedWidth int | 36 | + | HorizontalAnchor HorizontalAnchor |
| 27 | - | FixedHeight int | 37 | + | FixedWidth int |
| 28 | - | BottomMargin int | 38 | + | FixedHeight int |
| 39 | + | BottomMargin int | |||
| 40 | + | RightMargin int | |||
| 29 | } | 41 | } | ||
| 30 | 42 | ||||
| 31 | // Bounds returns the modal rectangle positioned within base and clamped to base. | 43 | // Bounds returns the modal rectangle positioned within base and clamped to base. | ||
| @@ -52,6 +64,9 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 52 | height = clamp(height, 0, base.Rows) | 64 | height = clamp(height, 0, base.Rows) | ||
| 53 | 65 | ||||
| 54 | x := (base.Cols - width) / 2 | 66 | x := (base.Cols - width) / 2 | ||
| 67 | + | if m.HorizontalAnchor == HorizontalAnchorRight { | |||
| 68 | + | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |||
| 69 | + | } | |||
| 55 | y := (base.Rows - height) / 2 | 70 | y := (base.Rows - height) / 2 | ||
| 56 | if m.Anchor == AnchorBottom { | 71 | if m.Anchor == AnchorBottom { | ||
| 57 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | 72 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | ||
M internal/usecase/ui/modal_test.go +33 -0
| @@ -50,6 +50,39 @@ func TestModalBounds(t *testing.T) { | |||
| 50 | 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | |
| 51 | 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | |
| 52 | 52 | }, | |
| 53 | + | { | |
| 54 | + | name: "right anchor honors right margin", | |
| 55 | + | base: domain.Size{Cols: 100, Rows: 40}, | |
| 56 | + | m: Modal{ | |
| 57 | + | FixedWidth: 30, | |
| 58 | + | FixedHeight: 12, | |
| 59 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 60 | + | RightMargin: 3, | |
| 61 | + | }, | |
| 62 | + | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "right margin clamps to screen", | |
| 66 | + | base: domain.Size{Cols: 20, Rows: 10}, | |
| 67 | + | m: Modal{ | |
| 68 | + | FixedWidth: 8, | |
| 69 | + | FixedHeight: 4, | |
| 70 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 71 | + | RightMargin: 20, | |
| 72 | + | }, | |
| 73 | + | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "right anchored fixed dimensions clamp on small terminal", | |
| 77 | + | base: domain.Size{Cols: 10, Rows: 4}, | |
| 78 | + | m: Modal{ | |
| 79 | + | FixedWidth: 30, | |
| 80 | + | FixedHeight: 12, | |
| 81 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 82 | + | RightMargin: 3, | |
| 83 | + | }, | |
| 84 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 85 | + | }, | |
| 53 | 86 | { | |
| 54 | 87 | name: "fixed dimensions clamp on small terminal", | |
| 55 | 88 | base: domain.Size{Cols: 10, Rows: 4}, | |
| @@ -50,6 +50,39 @@ func TestModalBounds(t *testing.T) { | |||||
| 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | ||
| 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | ||
| 52 | }, | 52 | }, | ||
| 53 | + | { | |||
| 54 | + | name: "right anchor honors right margin", | |||
| 55 | + | base: domain.Size{Cols: 100, Rows: 40}, | |||
| 56 | + | m: Modal{ | |||
| 57 | + | FixedWidth: 30, | |||
| 58 | + | FixedHeight: 12, | |||
| 59 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 60 | + | RightMargin: 3, | |||
| 61 | + | }, | |||
| 62 | + | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "right margin clamps to screen", | |||
| 66 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 67 | + | m: Modal{ | |||
| 68 | + | FixedWidth: 8, | |||
| 69 | + | FixedHeight: 4, | |||
| 70 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 71 | + | RightMargin: 20, | |||
| 72 | + | }, | |||
| 73 | + | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "right anchored fixed dimensions clamp on small terminal", | |||
| 77 | + | base: domain.Size{Cols: 10, Rows: 4}, | |||
| 78 | + | m: Modal{ | |||
| 79 | + | FixedWidth: 30, | |||
| 80 | + | FixedHeight: 12, | |||
| 81 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 82 | + | RightMargin: 3, | |||
| 83 | + | }, | |||
| 84 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |||
| 85 | + | }, | |||
| 53 | { | 86 | { | ||
| 54 | name: "fixed dimensions clamp on small terminal", | 87 | name: "fixed dimensions clamp on small terminal", | ||
| 55 | base: domain.Size{Cols: 10, Rows: 4}, | 88 | base: domain.Size{Cols: 10, Rows: 4}, | ||