detached 31971ae

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +57 -9
M internal/usecase/ui/modal.go +24 -9
@@ -15,17 +15,29 @@ const (
1515 AnchorBottom
1616 )
1717
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+
1828 // Modal describes a rectangular overlay.
1929 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
2941 }
3042
3143 // 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 {
5264 height = clamp(height, 0, base.Rows)
5365
5466 x := (base.Cols - width) / 2
67+ if m.HorizontalAnchor == HorizontalAnchorRight {
68+ x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width)
69+ }
5570 y := (base.Rows - height) / 2
5671 if m.Anchor == AnchorBottom {
5772 y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height)
@@ -15,17 +15,29 @@ const (
15 AnchorBottom15 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 int30+ WidthPct int
21- HeightPct int31+ HeightPct int
22- MinWidth int32+ MinWidth int
23- MinHeight int33+ MinHeight int
24- Title string34+ Title string
25- Anchor Anchor35+ Anchor Anchor
26- FixedWidth int36+ HorizontalAnchor HorizontalAnchor
27- FixedHeight int37+ FixedWidth int
28- BottomMargin int38+ 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) / 266 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) / 270 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) {
5050 m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12},
5151 want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12},
5252 },
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+ },
5386 {
5487 name: "fixed dimensions clamp on small terminal",
5588 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},

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