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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 18 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 21 | 22 | "github.com/bnema/vev/pkg/renderer" | |
| 22 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 23 | 24 | ) | |
| @@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||
| 96 | 97 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 97 | 98 | // 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 | 108 | func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |
| 100 | 109 | p, release := newBlockingPTY(t) | |
| 101 | 110 | 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 it | 97 | // 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 by | 98 | // 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 ( | |||
| 14 | 14 | 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 | 19 | func paletteModalFor(size domain.Size) ui.Modal { | |
| 20 | 20 | modal := paletteModal | |
| @@ -14,7 +14,7 @@ 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: 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 := paletteModal | 20 | modal := paletteModal | ||
M internal/usecase/daemon/palette_test.go +1 -2
| @@ -259,10 +259,9 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 259 | 259 | require.Equal(t, " Commands ", modal.Title) | |
| 260 | 260 | require.Equal(t, 11, modal.FixedHeight) | |
| 261 | 261 | wantAnchor := domain.AnchorBottom | |
| 262 | - | wantMargins := ui.Margins{Bottom: 1} | |
| 262 | + | wantMargins := ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1} | |
| 263 | 263 | if tt.size.Cols >= paletteRailBreakpoint { | |
| 264 | 264 | wantAnchor = domain.AnchorBottomRight | |
| 265 | - | wantMargins.Right = 1 | |
| 266 | 265 | } | |
| 267 | 266 | require.Equal(t, wantAnchor, modal.Anchor) | |
| 268 | 267 | 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.AnchorBottom | 261 | 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.AnchorBottomRight | 264 | 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 ( | |||
| 37 | 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 | 42 | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 43 | 43 | 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) { | |||
| 516 | 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 | 526 | func TestPickerResizeRecomposesModal(t *testing.T) { | |
| 520 | 527 | p, releasePTY := newBlockingPTY(t) | |
| 521 | 528 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 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 | 18 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | |
| 12 | 19 | p, release := newBlockingPTY(t) | |
| 13 | 20 | 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) { | |||
| 95 | 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 | + | { | |
| 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 | 110 | for _, tt := range tests { | |
| 100 | 111 | 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) { | ||