detached 309d975

feat(palette): configure overlay anchoring

commit
309d9756287836ff10f0177e5299b35bac7469c0
author
brice · · 2026-07-11 06:43 UTC
parent
7fe026f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +121 -18
M internal/usecase/daemon/config.go +9 -0
@@ -29,6 +29,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2929 d.restoreProcessAllowlist.Store(&allowlist)
3030 floating := cfg.Floating
3131 d.floatingConfig.Store(&floating)
32+ palette := cfg.Palette
33+ d.paletteConfig.Store(&palette)
3234 d.themeMode.Store(uint32(cfg.Theme))
3335 if d.barScripts != nil {
3436 d.barScripts.mu.Lock()
@@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
178180 d.log.Warn("config warning", "msg", w.Msg)
179181 }
180182
183+func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
184+ if cfg := d.paletteConfig.Load(); cfg != nil {
185+ return *cfg
186+ }
187+ return domain.Defaults().Palette
188+}
189+
181190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
182191 if cfg := d.floatingConfig.Load(); cfg != nil {
183192 return *cfg
@@ -29,6 +29,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
29 d.restoreProcessAllowlist.Store(&allowlist)29 d.restoreProcessAllowlist.Store(&allowlist)
30 floating := cfg.Floating30 floating := cfg.Floating
31 d.floatingConfig.Store(&floating)31 d.floatingConfig.Store(&floating)
32+ palette := cfg.Palette
33+ d.paletteConfig.Store(&palette)
32 d.themeMode.Store(uint32(cfg.Theme))34 d.themeMode.Store(uint32(cfg.Theme))
33 if d.barScripts != nil {35 if d.barScripts != nil {
34 d.barScripts.mu.Lock()36 d.barScripts.mu.Lock()
@@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
178 d.log.Warn("config warning", "msg", w.Msg)180 d.log.Warn("config warning", "msg", w.Msg)
179 }181 }
180 182
183+func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
184+ if cfg := d.paletteConfig.Load(); cfg != nil {
185+ return *cfg
186+ }
187+ return domain.Defaults().Palette
188+}
189+
181 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
182 if cfg := d.floatingConfig.Load(); cfg != nil {191 if cfg := d.floatingConfig.Load(); cfg != nil {
183 return *cfg192 return *cfg
M internal/usecase/daemon/config_test.go +52 -0
@@ -6,6 +6,7 @@ import (
66 "github.com/stretchr/testify/require"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/internal/usecase/keys"
1011 )
1112
@@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4950 require.Equal(t, "NT", cmd.Code)
5051 }
5152
53+func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
54+ d := newTestDaemon(t, nil, stubClock{})
55+ require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
56+
57+ firstConfig := domain.Defaults()
58+ firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
59+ d.ApplyConfig(firstConfig)
60+ first := d.currentPaletteConfig()
61+
62+ secondConfig := domain.Defaults()
63+ secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
64+ d.ApplyConfig(secondConfig)
65+ second := d.currentPaletteConfig()
66+
67+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
68+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
69+}
70+
71+func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
72+ p, release := newBlockingPTY(t)
73+ defer release()
74+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
75+ d.enterPalette(sess, ac)
76+ awaitFrame(t, sends, ports.MsgOutput)
77+ d.handlePaletteInput(ac, []byte("new"))
78+ awaitFrame(t, sends, ports.MsgOutput)
79+ d.handlePaletteInput(ac, []byte{0x0e})
80+ awaitFrame(t, sends, ports.MsgOutput)
81+
82+ ac.overlays.paletteMu.Lock()
83+ model := ac.overlays.palette
84+ query := model.Query()
85+ selected, ok := model.Selected()
86+ ac.overlays.paletteMu.Unlock()
87+ require.True(t, ok)
88+
89+ cfg := domain.Defaults()
90+ cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
91+ d.ApplyConfig(cfg)
92+ awaitFrame(t, sends, ports.MsgOutput)
93+
94+ ac.overlays.paletteMu.Lock()
95+ defer ac.overlays.paletteMu.Unlock()
96+ require.Same(t, model, ac.overlays.palette)
97+ require.Equal(t, query, ac.overlays.palette.Query())
98+ selectedAfter, ok := ac.overlays.palette.Selected()
99+ require.True(t, ok)
100+ require.Equal(t, selected.Slug, selectedAfter.Slug)
101+ require.Equal(t, selected.Code, selectedAfter.Code)
102+}
103+
52104 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
53105 d := newTestDaemon(t, nil, stubClock{})
54106 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
@@ -6,6 +6,7 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
10 )11 )
11 12
@@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
49 require.Equal(t, "NT", cmd.Code)50 require.Equal(t, "NT", cmd.Code)
50 }51 }
51 52
53+func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
54+ d := newTestDaemon(t, nil, stubClock{})
55+ require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
56+
57+ firstConfig := domain.Defaults()
58+ firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
59+ d.ApplyConfig(firstConfig)
60+ first := d.currentPaletteConfig()
61+
62+ secondConfig := domain.Defaults()
63+ secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
64+ d.ApplyConfig(secondConfig)
65+ second := d.currentPaletteConfig()
66+
67+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
68+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
69+}
70+
71+func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
72+ p, release := newBlockingPTY(t)
73+ defer release()
74+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
75+ d.enterPalette(sess, ac)
76+ awaitFrame(t, sends, ports.MsgOutput)
77+ d.handlePaletteInput(ac, []byte("new"))
78+ awaitFrame(t, sends, ports.MsgOutput)
79+ d.handlePaletteInput(ac, []byte{0x0e})
80+ awaitFrame(t, sends, ports.MsgOutput)
81+
82+ ac.overlays.paletteMu.Lock()
83+ model := ac.overlays.palette
84+ query := model.Query()
85+ selected, ok := model.Selected()
86+ ac.overlays.paletteMu.Unlock()
87+ require.True(t, ok)
88+
89+ cfg := domain.Defaults()
90+ cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
91+ d.ApplyConfig(cfg)
92+ awaitFrame(t, sends, ports.MsgOutput)
93+
94+ ac.overlays.paletteMu.Lock()
95+ defer ac.overlays.paletteMu.Unlock()
96+ require.Same(t, model, ac.overlays.palette)
97+ require.Equal(t, query, ac.overlays.palette.Query())
98+ selectedAfter, ok := ac.overlays.palette.Selected()
99+ require.True(t, ok)
100+ require.Equal(t, selected.Slug, selectedAfter.Slug)
101+ require.Equal(t, selected.Code, selectedAfter.Code)
102+}
103+
52 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {104 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
53 d := newTestDaemon(t, nil, stubClock{})105 d := newTestDaemon(t, nil, stubClock{})
54 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())106 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
M internal/usecase/daemon/daemon.go +5 -1
@@ -136,6 +136,7 @@ type Daemon struct {
136136 codeOverrides atomic.Pointer[map[string]string]
137137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139+ paletteConfig atomic.Pointer[domain.PaletteConfig]
139140 themeMode atomic.Uint32
140141 barScripts *barScriptState
141142 resumeParkGrace time.Duration
@@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
316317 running: make(map[domain.SessionID]bool),
317318 },
318319 }
319- defaultFloating := domain.Defaults().Floating
320+ defaults := domain.Defaults()
321+ defaultFloating := defaults.Floating
320322 d.floatingConfig.Store(&defaultFloating)
323+ defaultPalette := defaults.Palette
324+ d.paletteConfig.Store(&defaultPalette)
321325 for _, o := range opts {
322326 o(d)
323327 }
@@ -136,6 +136,7 @@ type Daemon struct {
136 codeOverrides atomic.Pointer[map[string]string]136 codeOverrides atomic.Pointer[map[string]string]
137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138 floatingConfig atomic.Pointer[domain.FloatingConfig]138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139+ paletteConfig atomic.Pointer[domain.PaletteConfig]
139 themeMode atomic.Uint32140 themeMode atomic.Uint32
140 barScripts *barScriptState141 barScripts *barScriptState
141 resumeParkGrace time.Duration142 resumeParkGrace time.Duration
@@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
316 running: make(map[domain.SessionID]bool),317 running: make(map[domain.SessionID]bool),
317 },318 },
318 }319 }
319- defaultFloating := domain.Defaults().Floating320+ defaults := domain.Defaults()
321+ defaultFloating := defaults.Floating
320 d.floatingConfig.Store(&defaultFloating)322 d.floatingConfig.Store(&defaultFloating)
323+ defaultPalette := defaults.Palette
324+ d.paletteConfig.Store(&defaultPalette)
321 for _, o := range opts {325 for _, o := range opts {
322 o(d)326 o(d)
323 }327 }
M internal/usecase/daemon/palette.go +7 -4
@@ -16,12 +16,15 @@ const (
1616
1717 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
19-func paletteModalFor(size domain.Size) ui.Modal {
19+func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
2020 modal := paletteModal
2121 if size.Cols >= paletteRailBreakpoint {
2222 modal.FixedWidth = paletteRailWidth
23+ }
24+ if cfg.AnchorSet {
25+ modal.Anchor = cfg.Anchor
26+ } else if size.Cols >= paletteRailBreakpoint {
2327 modal.Anchor = domain.AnchorBottomRight
24- modal.Margins.Right = 1
2528 }
2629 return modal
2730 }
@@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error {
298301 return nil
299302 }
300303
301-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
304+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
302305 styleSet := resolveThemeStyles(styles)
303- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})
306+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
304307 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
305308 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
306309 })
@@ -16,12 +16,15 @@ const (
16 16
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}}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, cfg domain.PaletteConfig) 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+ }
24+ if cfg.AnchorSet {
25+ modal.Anchor = cfg.Anchor
26+ } else if size.Cols >= paletteRailBreakpoint {
23 modal.Anchor = domain.AnchorBottomRight27 modal.Anchor = domain.AnchorBottomRight
24- modal.Margins.Right = 1
25 }28 }
26 return modal29 return modal
27 }30 }
@@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error {
298 return nil301 return nil
299 }302 }
300 303
301-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {304+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
302 styleSet := resolveThemeStyles(styles)305 styleSet := resolveThemeStyles(styles)
303- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})306+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
304 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {307 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
305 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})308 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
306 })309 })
M internal/usecase/daemon/palette_test.go +46 -12
@@ -11,7 +11,9 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/palette"
1415 "github.com/bnema/vev/internal/usecase/ui"
16+ "github.com/bnema/vev/pkg/renderer"
1517 )
1618
1719 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
@@ -241,30 +243,62 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
241243 }
242244
243245 func TestPaletteModalGeometry(t *testing.T) {
246+ auto := domain.PaletteConfig{}
244247 tests := []struct {
245248 name string
246249 size domain.Size
250+ cfg domain.PaletteConfig
247251 want domain.Rect
248252 }{
249- {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
250- {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
251- {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
252- {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
253+ {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
254+ {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
255+ {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
256+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
257+ }
258+ for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
259+ wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
260+ wantY := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 1, domain.AnchorTopRight: 1, domain.AnchorLeft: 14, domain.AnchorCenter: 14, domain.AnchorRight: 14, domain.AnchorBottomLeft: 28, domain.AnchorBottom: 28, domain.AnchorBottomRight: 28}[anchor]
261+ tests = append(tests, struct {
262+ name string
263+ size domain.Size
264+ cfg domain.PaletteConfig
265+ want domain.Rect
266+ }{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
267+ }
268+ for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
269+ tests = append(tests, struct {
270+ name string
271+ size domain.Size
272+ cfg domain.PaletteConfig
273+ want domain.Rect
274+ }{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})
253275 }
254276
255277 for _, tt := range tests {
256278 t.Run(tt.name, func(t *testing.T) {
257- modal := paletteModalFor(tt.size)
279+ modal := paletteModalFor(tt.size, tt.cfg)
258280 require.Equal(t, tt.want, modal.Bounds(tt.size))
259281 require.Equal(t, " Commands ", modal.Title)
260282 require.Equal(t, 11, modal.FixedHeight)
261- wantAnchor := domain.AnchorBottom
262- wantMargins := ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}
263- if tt.size.Cols >= paletteRailBreakpoint {
264- wantAnchor = domain.AnchorBottomRight
265- }
266- require.Equal(t, wantAnchor, modal.Anchor)
267- require.Equal(t, wantMargins, modal.Margins)
283+ require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
284+ })
285+ }
286+}
287+
288+func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
289+ model := palette.New(nil)
290+ base := renderer.NewFrame(120, 40)
291+ for _, tt := range []struct {
292+ name string
293+ cfg domain.PaletteConfig
294+ at domain.Rect
295+ }{
296+ {name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
297+ {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
298+ } {
299+ t.Run(tt.name, func(t *testing.T) {
300+ frame, _ := composePaletteClientFrame(model, base, tt.cfg)
301+ require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
268302 })
269303 }
270304 }
@@ -11,7 +11,9 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
16+ "github.com/bnema/vev/pkg/renderer"
15 )17 )
16 18
17 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {19 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
@@ -241,30 +243,62 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
241 }243 }
242 244
243 func TestPaletteModalGeometry(t *testing.T) {245 func TestPaletteModalGeometry(t *testing.T) {
246+ auto := domain.PaletteConfig{}
244 tests := []struct {247 tests := []struct {
245 name string248 name string
246 size domain.Size249 size domain.Size
250+ cfg domain.PaletteConfig
247 want domain.Rect251 want domain.Rect
248 }{252 }{
249- {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},253+ {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
250- {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},254+ {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
251- {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},255+ {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
252- {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},256+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
257+ }
258+ for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
259+ wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
260+ wantY := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 1, domain.AnchorTopRight: 1, domain.AnchorLeft: 14, domain.AnchorCenter: 14, domain.AnchorRight: 14, domain.AnchorBottomLeft: 28, domain.AnchorBottom: 28, domain.AnchorBottomRight: 28}[anchor]
261+ tests = append(tests, struct {
262+ name string
263+ size domain.Size
264+ cfg domain.PaletteConfig
265+ want domain.Rect
266+ }{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
267+ }
268+ for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
269+ tests = append(tests, struct {
270+ name string
271+ size domain.Size
272+ cfg domain.PaletteConfig
273+ want domain.Rect
274+ }{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})
253 }275 }
254 276
255 for _, tt := range tests {277 for _, tt := range tests {
256 t.Run(tt.name, func(t *testing.T) {278 t.Run(tt.name, func(t *testing.T) {
257- modal := paletteModalFor(tt.size)279+ modal := paletteModalFor(tt.size, tt.cfg)
258 require.Equal(t, tt.want, modal.Bounds(tt.size))280 require.Equal(t, tt.want, modal.Bounds(tt.size))
259 require.Equal(t, " Commands ", modal.Title)281 require.Equal(t, " Commands ", modal.Title)
260 require.Equal(t, 11, modal.FixedHeight)282 require.Equal(t, 11, modal.FixedHeight)
261- wantAnchor := domain.AnchorBottom283+ require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
262- wantMargins := ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}284+ })
263- if tt.size.Cols >= paletteRailBreakpoint {285+ }
264- wantAnchor = domain.AnchorBottomRight286+}
265- }287+
266- require.Equal(t, wantAnchor, modal.Anchor)288+func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
267- require.Equal(t, wantMargins, modal.Margins)289+ model := palette.New(nil)
290+ base := renderer.NewFrame(120, 40)
291+ for _, tt := range []struct {
292+ name string
293+ cfg domain.PaletteConfig
294+ at domain.Rect
295+ }{
296+ {name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
297+ {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
298+ } {
299+ t.Run(tt.name, func(t *testing.T) {
300+ frame, _ := composePaletteClientFrame(model, base, tt.cfg)
301+ require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
268 })302 })
269 }303 }
270 }304 }
M internal/usecase/daemon/render.go +2 -1
@@ -350,6 +350,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
350350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
351351
352352 styles := newThemeStyles(ac.getTheme())
353+ paletteCfg := d.currentPaletteConfig()
353354 tb.mu.Lock()
354355 layoutSnap := solveTabLayoutLocked(tb)
355356 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
@@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
429430 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
430431 }
431432 if overlays.paletteActive {
432- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)
433+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)
433434 }
434435 if overlays.promptActive {
435436 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
@@ -350,6 +350,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
351 351
352 styles := newThemeStyles(ac.getTheme())352 styles := newThemeStyles(ac.getTheme())
353+ paletteCfg := d.currentPaletteConfig()
353 tb.mu.Lock()354 tb.mu.Lock()
354 layoutSnap := solveTabLayoutLocked(tb)355 layoutSnap := solveTabLayoutLocked(tb)
355 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)356 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
@@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
429 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)430 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
430 }431 }
431 if overlays.paletteActive {432 if overlays.paletteActive {
432- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)433+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)
433 }434 }
434 if overlays.promptActive {435 if overlays.promptActive {
435 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)436 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)

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