detached 292e666

refactor(ui): unify modal anchoring

commit
292e6668f082076089065fb4a0956bc70aaa3283
author
brice · · 2026-07-11 06:28 UTC
committer
brice · · 2026-07-11 06:31 UTC
parent
2f1b8ab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +37 -4
M internal/usecase/daemon/copymode_test.go +9 -0
@@ -18,6 +18,7 @@ import (
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
2020 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/ui"
2122 "github.com/bnema/vev/pkg/renderer"
2223 "github.com/bnema/vev/pkg/vt"
2324 )
@@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
9697 // stubClock returns timers whose channel never fires, so a scheduler under it
9798 // blocks in its debounce loop until the session context is cancelled. Used by
9899
100+func TestCopySearchModalGeometry(t *testing.T) {
101+ base := domain.Size{Cols: 100, Rows: 40}
102+
103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))
104+ require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105+ require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106+}
107+
99108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
100109 p, release := newBlockingPTY(t)
101110 defer release()
@@ -18,6 +18,7 @@ import (
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/ui"
21 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
22 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
23 )24 )
@@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
96 // stubClock returns timers whose channel never fires, so a scheduler under it97 // stubClock returns timers whose channel never fires, so a scheduler under it
97 // blocks in its debounce loop until the session context is cancelled. Used by98 // blocks in its debounce loop until the session context is cancelled. Used by
98 99
100+func TestCopySearchModalGeometry(t *testing.T) {
101+ base := domain.Size{Cols: 100, Rows: 40}
102+
103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))
104+ require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105+ require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106+}
107+
99 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
100 p, release := newBlockingPTY(t)109 p, release := newBlockingPTY(t)
101 defer release()110 defer release()
M internal/usecase/daemon/palette.go +1 -1
@@ -14,7 +14,7 @@ const (
1414 paletteRailWidth = 64
1515 )
1616
17-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
17+var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
1818
1919 func paletteModalFor(size domain.Size) ui.Modal {
2020 modal := paletteModal
@@ -14,7 +14,7 @@ const (
14 paletteRailWidth = 6414 paletteRailWidth = 64
15 )15 )
16 16
17-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}17+var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
18 18
19 func paletteModalFor(size domain.Size) ui.Modal {19 func paletteModalFor(size domain.Size) ui.Modal {
20 modal := paletteModal20 modal := paletteModal
M internal/usecase/daemon/palette_test.go +1 -2
@@ -259,10 +259,9 @@ func TestPaletteModalGeometry(t *testing.T) {
259259 require.Equal(t, " Commands ", modal.Title)
260260 require.Equal(t, 11, modal.FixedHeight)
261261 wantAnchor := domain.AnchorBottom
262- wantMargins := ui.Margins{Bottom: 1}
262+ wantMargins := ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}
263263 if tt.size.Cols >= paletteRailBreakpoint {
264264 wantAnchor = domain.AnchorBottomRight
265- wantMargins.Right = 1
266265 }
267266 require.Equal(t, wantAnchor, modal.Anchor)
268267 require.Equal(t, wantMargins, modal.Margins)
@@ -259,10 +259,9 @@ func TestPaletteModalGeometry(t *testing.T) {
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 wantAnchor := domain.AnchorBottom261 wantAnchor := domain.AnchorBottom
262- wantMargins := ui.Margins{Bottom: 1}262+ wantMargins := ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}
263 if tt.size.Cols >= paletteRailBreakpoint {263 if tt.size.Cols >= paletteRailBreakpoint {
264 wantAnchor = domain.AnchorBottomRight264 wantAnchor = domain.AnchorBottomRight
265- wantMargins.Right = 1
266 }265 }
267 require.Equal(t, wantAnchor, modal.Anchor)266 require.Equal(t, wantAnchor, modal.Anchor)
268 require.Equal(t, wantMargins, modal.Margins)267 require.Equal(t, wantMargins, modal.Margins)
M internal/usecase/daemon/picker.go +1 -1
@@ -37,7 +37,7 @@ import (
3737 "github.com/bnema/vev/pkg/renderer"
3838 )
3939
40-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
4141
4242 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
4343 views, curTab := d.pickerViews(sess)
@@ -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 pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
41 41
42 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {42 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
43 views, curTab := d.pickerViews(sess)43 views, curTab := d.pickerViews(sess)
M internal/usecase/daemon/picker_test.go +7 -0
@@ -516,6 +516,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
516516 drain.Wait()
517517 }
518518
519+func TestPickerModalGeometry(t *testing.T) {
520+ base := domain.Size{Cols: 100, Rows: 40}
521+
522+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
523+ require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
524+}
525+
519526 func TestPickerResizeRecomposesModal(t *testing.T) {
520527 p, releasePTY := newBlockingPTY(t)
521528 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -516,6 +516,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
516 drain.Wait()516 drain.Wait()
517 }517 }
518 518
519+func TestPickerModalGeometry(t *testing.T) {
520+ base := domain.Size{Cols: 100, Rows: 40}
521+
522+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
523+ require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
524+}
525+
519 func TestPickerResizeRecomposesModal(t *testing.T) {526 func TestPickerResizeRecomposesModal(t *testing.T) {
520 p, releasePTY := newBlockingPTY(t)527 p, releasePTY := newBlockingPTY(t)
521 d, sess, ac, sends := newManualSessionWithPTYs(t, p)528 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/prompt_test.go +7 -0
@@ -4,10 +4,17 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/stretchr/testify/require"
910 )
1011
12+func TestPromptModalGeometry(t *testing.T) {
13+ base := domain.Size{Cols: 100, Rows: 40}
14+
15+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))
16+}
17+
1118 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
1219 p, release := newBlockingPTY(t)
1320 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -4,10 +4,17 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
12+func TestPromptModalGeometry(t *testing.T) {
13+ base := domain.Size{Cols: 100, Rows: 40}
14+
15+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))
16+}
17+
11 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {18 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
12 p, release := newBlockingPTY(t)19 p, release := newBlockingPTY(t)
13 d, sess, ac, sends := newManualSessionWithPTYs(t, p)20 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/ui/modal_test.go +11 -0
@@ -95,6 +95,17 @@ func TestModalBounds(t *testing.T) {
9595 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
9696 want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
9797 },
98+ {
99+ name: "bottom right anchor honors combined margins",
100+ base: domain.Size{Cols: 100, Rows: 40},
101+ m: Modal{
102+ FixedWidth: 30,
103+ FixedHeight: 12,
104+ Anchor: domain.AnchorBottomRight,
105+ Margins: Margins{Bottom: 3, Right: 5},
106+ },
107+ want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
108+ },
98109 }
99110 for _, tt := range tests {
100111 t.Run(tt.name, func(t *testing.T) {
@@ -95,6 +95,17 @@ func TestModalBounds(t *testing.T) {
95 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 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+ {
99+ name: "bottom right anchor honors combined margins",
100+ base: domain.Size{Cols: 100, Rows: 40},
101+ m: Modal{
102+ FixedWidth: 30,
103+ FixedHeight: 12,
104+ Anchor: domain.AnchorBottomRight,
105+ Margins: Margins{Bottom: 3, Right: 5},
106+ },
107+ want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
108+ },
98 }109 }
99 for _, tt := range tests {110 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {111 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