detached a72295f

feat(palette): add responsive command rail

commit
a72295f72577e870c82dabc7598d060a51d35b9e
author
brice · · 2026-07-09 23:30 UTC
parent
916eeb2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +38 -9
M internal/usecase/daemon/palette.go +17 -1
@@ -9,8 +9,23 @@ import (
99 "github.com/bnema/vev/pkg/renderer"
1010 )
1111
12+const (
13+ paletteRailBreakpoint = 96
14+ paletteRailWidth = 64
15+)
16+
1217 var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}
1318
19+func paletteModalFor(size domain.Size) ui.Modal {
20+ modal := paletteModal
21+ if size.Cols >= paletteRailBreakpoint {
22+ modal.FixedWidth = paletteRailWidth
23+ modal.HorizontalAnchor = ui.HorizontalAnchorRight
24+ modal.RightMargin = 1
25+ }
26+ return modal
27+}
28+
1429 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
1530 d.closePalette(ac)
1631 ac.overlays.paletteMu.Lock()
@@ -277,7 +292,8 @@ func (e paletteExec) OpenSessionPicker() error {
277292
278293 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
279294 styleSet := resolveThemeStyles(styles)
280- return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
295+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})
296+ return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
281297 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
282298 })
283299 }
@@ -9,8 +9,23 @@ import (
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10 )10 )
11 11
12+const (
13+ paletteRailBreakpoint = 96
14+ paletteRailWidth = 64
15+)
16+
12 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: ui.AnchorBottom, BottomMargin: 1}
13 18
19+func paletteModalFor(size domain.Size) ui.Modal {
20+ modal := paletteModal
21+ if size.Cols >= paletteRailBreakpoint {
22+ modal.FixedWidth = paletteRailWidth
23+ modal.HorizontalAnchor = ui.HorizontalAnchorRight
24+ modal.RightMargin = 1
25+ }
26+ return modal
27+}
28+
14 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {29 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
15 d.closePalette(ac)30 d.closePalette(ac)
16 ac.overlays.paletteMu.Lock()31 ac.overlays.paletteMu.Lock()
@@ -277,7 +292,8 @@ func (e paletteExec) OpenSessionPicker() error {
277 292
278 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {293 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
279 styleSet := resolveThemeStyles(styles)294 styleSet := resolveThemeStyles(styles)
280- return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {295+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})
296+ return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
281 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})297 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
282 })298 })
283 }299 }
M internal/usecase/daemon/palette_test.go +21 -8
@@ -217,14 +217,27 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
217217 }
218218
219219 func TestPaletteModalGeometry(t *testing.T) {
220- require.Equal(t, 100, paletteModal.WidthPct)
221- require.Equal(t, 11, paletteModal.FixedHeight)
222- require.Equal(t, ui.AnchorBottom, paletteModal.Anchor)
223- require.Equal(t, 1, paletteModal.BottomMargin)
224- require.Equal(t, 32, paletteModal.MinWidth)
225-
226- bounds := paletteModal.Bounds(domain.Size{Cols: 120, Rows: 40})
227- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 120, Height: 11}, bounds)
220+ tests := []struct {
221+ name string
222+ size domain.Size
223+ want domain.Rect
224+ }{
225+ {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
226+ {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
227+ {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
228+ {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ modal := paletteModalFor(tt.size)
234+ require.Equal(t, tt.want, modal.Bounds(tt.size))
235+ require.Equal(t, " Commands ", modal.Title)
236+ require.Equal(t, 11, modal.FixedHeight)
237+ require.Equal(t, ui.AnchorBottom, modal.Anchor)
238+ require.Equal(t, 1, modal.BottomMargin)
239+ })
240+ }
228241 }
229242
230243 func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
@@ -217,14 +217,27 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
217 }217 }
218 218
219 func TestPaletteModalGeometry(t *testing.T) {219 func TestPaletteModalGeometry(t *testing.T) {
220- require.Equal(t, 100, paletteModal.WidthPct)220+ tests := []struct {
221- require.Equal(t, 11, paletteModal.FixedHeight)221+ name string
222- require.Equal(t, ui.AnchorBottom, paletteModal.Anchor)222+ size domain.Size
223- require.Equal(t, 1, paletteModal.BottomMargin)223+ want domain.Rect
224- require.Equal(t, 32, paletteModal.MinWidth)224+ }{
225-225+ {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
226- bounds := paletteModal.Bounds(domain.Size{Cols: 120, Rows: 40})226+ {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
227- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 120, Height: 11}, bounds)227+ {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
228+ {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ modal := paletteModalFor(tt.size)
234+ require.Equal(t, tt.want, modal.Bounds(tt.size))
235+ require.Equal(t, " Commands ", modal.Title)
236+ require.Equal(t, 11, modal.FixedHeight)
237+ require.Equal(t, ui.AnchorBottom, modal.Anchor)
238+ require.Equal(t, 1, modal.BottomMargin)
239+ })
240+ }
228 }241 }
229 242
230 func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {243 func TestPaletteUTF8PendingCompletesFilter(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