feat(palette): configure overlay anchoring
- commit
- 309d9756287836ff10f0177e5299b35bac7469c0
- author
- brice · · 2026-07-11 06:43 UTC
- parent
- 7fe026f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 29 | 29 | d.restoreProcessAllowlist.Store(&allowlist) | |
| 30 | 30 | floating := cfg.Floating | |
| 31 | 31 | d.floatingConfig.Store(&floating) | |
| 32 | + | palette := cfg.Palette | |
| 33 | + | d.paletteConfig.Store(&palette) | |
| 32 | 34 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 33 | 35 | if d.barScripts != nil { | |
| 34 | 36 | d.barScripts.mu.Lock() | |
| @@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) { | |||
| 178 | 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 | 190 | func (d *Daemon) currentFloatingConfig() domain.FloatingConfig { | |
| 182 | 191 | if cfg := d.floatingConfig.Load(); cfg != nil { | |
| 183 | 192 | 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.Floating | 30 | 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 *cfg | 192 | return *cfg | ||
M internal/usecase/daemon/config_test.go +52 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 49 | 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 | 104 | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |
| 53 | 105 | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | 106 | 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 { | |||
| 136 | 136 | codeOverrides atomic.Pointer[map[string]string] | |
| 137 | 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 138 | 138 | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 139 | + | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 139 | 140 | themeMode atomic.Uint32 | |
| 140 | 141 | barScripts *barScriptState | |
| 141 | 142 | resumeParkGrace time.Duration | |
| @@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 316 | 317 | running: make(map[domain.SessionID]bool), | |
| 317 | 318 | }, | |
| 318 | 319 | } | |
| 319 | - | defaultFloating := domain.Defaults().Floating | |
| 320 | + | defaults := domain.Defaults() | |
| 321 | + | defaultFloating := defaults.Floating | |
| 320 | 322 | d.floatingConfig.Store(&defaultFloating) | |
| 323 | + | defaultPalette := defaults.Palette | |
| 324 | + | d.paletteConfig.Store(&defaultPalette) | |
| 321 | 325 | for _, o := range opts { | |
| 322 | 326 | o(d) | |
| 323 | 327 | } | |
| @@ -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.Uint32 | 140 | themeMode atomic.Uint32 | ||
| 140 | barScripts *barScriptState | 141 | barScripts *barScriptState | ||
| 141 | resumeParkGrace time.Duration | 142 | 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().Floating | 320 | + | 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 ( | |||
| 16 | 16 | ||
| 17 | 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 | 20 | modal := paletteModal | |
| 21 | 21 | if size.Cols >= paletteRailBreakpoint { | |
| 22 | 22 | modal.FixedWidth = paletteRailWidth | |
| 23 | + | } | |
| 24 | + | if cfg.AnchorSet { | |
| 25 | + | modal.Anchor = cfg.Anchor | |
| 26 | + | } else if size.Cols >= paletteRailBreakpoint { | |
| 23 | 27 | modal.Anchor = domain.AnchorBottomRight | |
| 24 | - | modal.Margins.Right = 1 | |
| 25 | 28 | } | |
| 26 | 29 | return modal | |
| 27 | 30 | } | |
| @@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 298 | 301 | 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 | 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 | 307 | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 305 | 308 | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 306 | 309 | }) | |
| @@ -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 := paletteModal | 20 | modal := paletteModal | ||
| 21 | if size.Cols >= paletteRailBreakpoint { | 21 | if size.Cols >= paletteRailBreakpoint { | ||
| 22 | modal.FixedWidth = paletteRailWidth | 22 | modal.FixedWidth = paletteRailWidth | ||
| 23 | + | } | |||
| 24 | + | if cfg.AnchorSet { | |||
| 25 | + | modal.Anchor = cfg.Anchor | |||
| 26 | + | } else if size.Cols >= paletteRailBreakpoint { | |||
| 23 | modal.Anchor = domain.AnchorBottomRight | 27 | modal.Anchor = domain.AnchorBottomRight | ||
| 24 | - | modal.Margins.Right = 1 | |||
| 25 | } | 28 | } | ||
| 26 | return modal | 29 | return modal | ||
| 27 | } | 30 | } | ||
| @@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 298 | return nil | 301 | 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 ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| 17 | 19 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |
| @@ -241,30 +243,62 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 241 | 243 | } | |
| 242 | 244 | ||
| 243 | 245 | func TestPaletteModalGeometry(t *testing.T) { | |
| 246 | + | auto := domain.PaletteConfig{} | |
| 244 | 247 | tests := []struct { | |
| 245 | 248 | name string | |
| 246 | 249 | size domain.Size | |
| 250 | + | cfg domain.PaletteConfig | |
| 247 | 251 | 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}}, | |
| 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}}) | |
| 253 | 275 | } | |
| 254 | 276 | ||
| 255 | 277 | for _, tt := range tests { | |
| 256 | 278 | t.Run(tt.name, func(t *testing.T) { | |
| 257 | - | modal := paletteModalFor(tt.size) | |
| 279 | + | modal := paletteModalFor(tt.size, tt.cfg) | |
| 258 | 280 | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |
| 259 | 281 | require.Equal(t, " Commands ", modal.Title) | |
| 260 | 282 | 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) | |
| 268 | 302 | }) | |
| 269 | 303 | } | |
| 270 | 304 | } | |
| @@ -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 string | 248 | name string | ||
| 246 | size domain.Size | 249 | size domain.Size | ||
| 250 | + | cfg domain.PaletteConfig | |||
| 247 | want domain.Rect | 251 | 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.AnchorBottom | 283 | + | 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.AnchorBottomRight | 286 | + | } |
| 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) { | |||
| 350 | 350 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 351 | 351 | ||
| 352 | 352 | styles := newThemeStyles(ac.getTheme()) | |
| 353 | + | paletteCfg := d.currentPaletteConfig() | |
| 353 | 354 | tb.mu.Lock() | |
| 354 | 355 | layoutSnap := solveTabLayoutLocked(tb) | |
| 355 | 356 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | |
| @@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 429 | 430 | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |
| 430 | 431 | } | |
| 431 | 432 | if overlays.paletteActive { | |
| 432 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles) | |
| 433 | + | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles) | |
| 433 | 434 | } | |
| 434 | 435 | if overlays.promptActive { | |
| 435 | 436 | 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) | ||