detached 0d2917c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +64 -8
M internal/usecase/ui/modal.go +34 -8
@@ -5,13 +5,27 @@ import (
55 "github.com/bnema/vev/pkg/renderer"
66 )
77
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.
919 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
1529 }
1630
1731 // 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 {
2236
2337 width := percentOf(base.Cols, m.WidthPct)
2438 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+ }
2545 if width < m.MinWidth {
2646 width = m.MinWidth
2747 }
@@ -31,9 +51,15 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
3151 width = clamp(width, 0, base.Cols)
3252 height = clamp(height, 0, base.Rows)
3353
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+
3460 return domain.Rect{
35- X: (base.Cols - width) / 2,
36- Y: (base.Rows - height) / 2,
61+ X: x,
62+ Y: y,
3763 Width: width,
3864 Height: height,
3965 }
@@ -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 int20+ WidthPct int
11- HeightPct int21+ HeightPct int
12- MinWidth int22+ MinWidth int
13- MinHeight int23+ MinHeight int
14- Title string24+ 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.MinWidth46 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) {
3232 m: Modal{WidthPct: 50, HeightPct: 50},
3333 want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},
3434 },
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+ },
3565 }
3666 for _, tt := range tests {
3767 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close