detached 2f1b8ab

refactor(ui): add reusable anchor placement

commit
2f1b8abea35ca8c6b7277ad4a288777b0e5548aa
author
brice · · 2026-07-11 06:23 UTC
parent
4bfcb44
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +213 -85
M internal/usecase/daemon/copymode.go +1 -1
@@ -37,7 +37,7 @@ import (
3737 "github.com/bnema/vev/pkg/renderer"
3838 )
3939
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}}
4141
4242 func copyTargetPane(rt *overlayRuntime) *pane {
4343 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 (
1414 paletteRailWidth = 64
1515 )
1616
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}}
1818
1919 func paletteModalFor(size domain.Size) ui.Modal {
2020 modal := paletteModal
2121 if size.Cols >= paletteRailBreakpoint {
2222 modal.FixedWidth = paletteRailWidth
23- modal.HorizontalAnchor = ui.HorizontalAnchorRight
24- modal.RightMargin = 1
23+ modal.Anchor = domain.AnchorBottomRight
24+ modal.Margins.Right = 1
2525 }
2626 return modal
2727 }
@@ -14,14 +14,14 @@ const (
14 paletteRailWidth = 6414 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 := paletteModal20 modal := paletteModal
21 if size.Cols >= paletteRailBreakpoint {21 if size.Cols >= paletteRailBreakpoint {
22 modal.FixedWidth = paletteRailWidth22 modal.FixedWidth = paletteRailWidth
23- modal.HorizontalAnchor = ui.HorizontalAnchorRight23+ modal.Anchor = domain.AnchorBottomRight
24- modal.RightMargin = 124+ modal.Margins.Right = 1
25 }25 }
26 return modal26 return modal
27 }27 }
M internal/usecase/daemon/palette_test.go +8 -2
@@ -258,8 +258,14 @@ func TestPaletteModalGeometry(t *testing.T) {
258258 require.Equal(t, tt.want, modal.Bounds(tt.size))
259259 require.Equal(t, " Commands ", modal.Title)
260260 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)
263269 })
264270 }
265271 }
@@ -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
33 import (
44 "strings"
55
6+ "github.com/bnema/vev/internal/domain"
67 promptui "github.com/bnema/vev/internal/usecase/prompt"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
910 )
1011
1112 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}}
1314 }
1415
1516 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 (
55 "github.com/bnema/vev/pkg/renderer"
66 )
77
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-
288 // Modal describes a rectangular overlay.
299 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
4119 }
4220
4321 // Bounds returns the modal rectangle positioned within base and clamped to base.
4422 func (m Modal) Bounds(base domain.Size) domain.Rect {
45- if base.Cols <= 0 || base.Rows <= 0 {
46- return domain.Rect{}
47- }
48-
4923 width := percentOf(base.Cols, m.WidthPct)
5024 height := percentOf(base.Rows, m.HeightPct)
5125 if m.FixedWidth > 0 {
@@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
6034 if height < m.MinHeight {
6135 height = m.MinHeight
6236 }
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)
8138 }
8239
8340 // Inner returns the modal content rectangle after removing a one-cell border.
@@ -193,16 +150,6 @@ func percentOf(n, pct int) int {
193150 return n * pct / 100
194151 }
195152
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-
206153 func textWidth(text string) int {
207154 width := 0
208155 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 int10+ WidthPct int
31- HeightPct int11+ HeightPct int
32- MinWidth int12+ MinWidth int
33- MinHeight int13+ MinHeight int
34- Title string14+ Title string
35- Anchor Anchor15+ Anchor domain.Anchor
36- HorizontalAnchor HorizontalAnchor16+ Margins Margins
37- FixedWidth int17+ FixedWidth int
38- FixedHeight int18+ 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.MinHeight35 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 / 100150 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 := 0154 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) {
4141 {
4242 name: "bottom anchor honors bottom margin",
4343 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}},
4545 want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
4646 },
4747 {
@@ -54,10 +54,10 @@ func TestModalBounds(t *testing.T) {
5454 name: "right anchor honors right margin",
5555 base: domain.Size{Cols: 100, Rows: 40},
5656 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},
6161 },
6262 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
6363 },
@@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) {
6565 name: "right margin clamps to screen",
6666 base: domain.Size{Cols: 20, Rows: 10},
6767 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},
7272 },
7373 want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},
7474 },
@@ -76,23 +76,23 @@ func TestModalBounds(t *testing.T) {
7676 name: "right anchored fixed dimensions clamp on small terminal",
7777 base: domain.Size{Cols: 10, Rows: 4},
7878 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},
8383 },
8484 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
8585 },
8686 {
8787 name: "fixed dimensions clamp on small terminal",
8888 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}},
9090 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
9191 },
9292 {
9393 name: "bottom margin clamps to screen",
9494 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}},
9696 want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
9797 },
9898 }
@@ -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+}

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