detached 10e6267

Merge pull request #64 from bnema/feat/command-palette-ui

feat(palette): improve command palette UI
commit
10e62677aedc7f3e1671fc93b437ac779d62756f
author
bnema · · 2026-07-10 05:34 UTC
committer
GitHub · · 2026-07-10 05:34 UTC
merge of
7e5b90a + d5c777e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +377 -30
A internal/usecase/daemon/overlay_backdrop.go +50 -0
@@ -0,0 +1,50 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// overlayBackdrop controls optional dimming of pane content behind an overlay.
10+// Chrome (bars, dividers, and pane titles) is deliberately not part of the
11+// backdrop.
12+type overlayBackdrop struct {
13+ DimPaneContents bool
14+}
15+
16+// apply dims the content rectangles from a tab-content-relative layout
17+// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18+// dimming the complete content area.
19+func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20+ if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21+ return
22+ }
23+ if !layoutSnap.ok {
24+ dimFrameRect(frame, contentArea, theme)
25+ return
26+ }
27+ for _, placement := range layoutSnap.placements {
28+ if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29+ continue
30+ }
31+ r := placement.Content
32+ r.X += contentArea.X
33+ r.Y += contentArea.Y
34+ dimFrameRect(frame, r, theme)
35+ }
36+}
37+
38+func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ x0 := max(rect.X, 0)
40+ y0 := max(rect.Y, 0)
41+ x1 := min(rect.X+rect.Width, frame.Width)
42+ y1 := min(rect.Y+rect.Height, frame.Height)
43+ for y := y0; y < y1; y++ {
44+ for x := x0; x < x1; x++ {
45+ cell := frame.At(x, y)
46+ cell.Style = themeui.DimStyle(cell.Style, theme)
47+ frame.Set(x, y, cell)
48+ }
49+ }
50+}
@@ -0,0 +1,50 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// overlayBackdrop controls optional dimming of pane content behind an overlay.
10+// Chrome (bars, dividers, and pane titles) is deliberately not part of the
11+// backdrop.
12+type overlayBackdrop struct {
13+ DimPaneContents bool
14+}
15+
16+// apply dims the content rectangles from a tab-content-relative layout
17+// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18+// dimming the complete content area.
19+func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20+ if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21+ return
22+ }
23+ if !layoutSnap.ok {
24+ dimFrameRect(frame, contentArea, theme)
25+ return
26+ }
27+ for _, placement := range layoutSnap.placements {
28+ if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29+ continue
30+ }
31+ r := placement.Content
32+ r.X += contentArea.X
33+ r.Y += contentArea.Y
34+ dimFrameRect(frame, r, theme)
35+ }
36+}
37+
38+func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ x0 := max(rect.X, 0)
40+ y0 := max(rect.Y, 0)
41+ x1 := min(rect.X+rect.Width, frame.Width)
42+ y1 := min(rect.Y+rect.Height, frame.Height)
43+ for y := y0; y < y1; y++ {
44+ for x := x0; x < x1; x++ {
45+ cell := frame.At(x, y)
46+ cell.Style = themeui.DimStyle(cell.Style, theme)
47+ frame.Set(x, y, cell)
48+ }
49+ }
50+}
A internal/usecase/daemon/overlay_backdrop_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func backdropTheme() themeui.Theme {
15+ return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16+}
17+
18+func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {
19+ frame := renderer.NewFrame(8, 6)
20+ original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21+ frame.Set(2, 2, original)
22+ frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
23+ frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title
24+ frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar
25+ frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26+ frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27+
28+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29+ {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30+ {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31+ }}
32+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33+
34+ require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35+ require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36+ require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37+ require.Zero(t, frame.At(3, 2).Rune)
38+ require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40+ require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41+ require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42+}
43+
44+func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ content domain.Rect
48+ affected [][2]int
49+ unaffected [][2]int
50+ }{
51+ {
52+ name: "negative origin",
53+ content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54+ affected: [][2]int{{0, 0}, {1, 1}},
55+ unaffected: [][2]int{{2, 2}, {3, 3}},
56+ },
57+ {
58+ name: "right and bottom overflow",
59+ content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60+ affected: [][2]int{{2, 2}, {3, 3}},
61+ unaffected: [][2]int{{0, 0}, {1, 1}},
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ frame := renderer.NewFrame(4, 4)
68+ original := renderer.DefaultStyle()
69+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}
70+
71+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72+
73+ for _, point := range tt.affected {
74+ require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
75+ }
76+ for _, point := range tt.unaffected {
77+ require.Equal(t, original, frame.At(point[0], point[1]).Style)
78+ }
79+ })
80+ }
81+}
82+
83+func TestOverlayBackdropDisabledAndFallback(t *testing.T) {
84+ frame := renderer.NewFrame(4, 4)
85+ frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86+ before := frame.Clone()
87+ area := domain.Rect{Y: 1, Width: 4, Height: 2}
88+ (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
89+ require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90+
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
92+ require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")
93+ require.True(t, frame.At(3, 2).Style.HasForegroundRGB)
94+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB)
95+ require.False(t, frame.At(0, 3).Style.HasForegroundRGB)
96+}
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func backdropTheme() themeui.Theme {
15+ return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16+}
17+
18+func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {
19+ frame := renderer.NewFrame(8, 6)
20+ original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21+ frame.Set(2, 2, original)
22+ frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
23+ frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title
24+ frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar
25+ frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26+ frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27+
28+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29+ {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30+ {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31+ }}
32+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33+
34+ require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35+ require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36+ require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37+ require.Zero(t, frame.At(3, 2).Rune)
38+ require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40+ require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41+ require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42+}
43+
44+func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ content domain.Rect
48+ affected [][2]int
49+ unaffected [][2]int
50+ }{
51+ {
52+ name: "negative origin",
53+ content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54+ affected: [][2]int{{0, 0}, {1, 1}},
55+ unaffected: [][2]int{{2, 2}, {3, 3}},
56+ },
57+ {
58+ name: "right and bottom overflow",
59+ content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60+ affected: [][2]int{{2, 2}, {3, 3}},
61+ unaffected: [][2]int{{0, 0}, {1, 1}},
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ frame := renderer.NewFrame(4, 4)
68+ original := renderer.DefaultStyle()
69+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}
70+
71+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72+
73+ for _, point := range tt.affected {
74+ require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
75+ }
76+ for _, point := range tt.unaffected {
77+ require.Equal(t, original, frame.At(point[0], point[1]).Style)
78+ }
79+ })
80+ }
81+}
82+
83+func TestOverlayBackdropDisabledAndFallback(t *testing.T) {
84+ frame := renderer.NewFrame(4, 4)
85+ frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86+ before := frame.Clone()
87+ area := domain.Rect{Y: 1, Width: 4, Height: 2}
88+ (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
89+ require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90+
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
92+ require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")
93+ require.True(t, frame.At(3, 2).Style.HasForegroundRGB)
94+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB)
95+ require.False(t, frame.At(0, 3).Style.HasForegroundRGB)
96+}
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) {
M internal/usecase/daemon/render.go +4 -0
@@ -346,6 +346,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
346346 if overlays.copyActive {
347347 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
348348 }
349+ if overlays.paletteActive {
350+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
351+ (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
352+ }
349353 if overlays.copySearchModel != nil {
350354 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
351355 }
@@ -346,6 +346,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
346 if overlays.copyActive {346 if overlays.copyActive {
347 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)347 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
348 }348 }
349+ if overlays.paletteActive {
350+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
351+ (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
352+ }
349 if overlays.copySearchModel != nil {353 if overlays.copySearchModel != nil {
350 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)354 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
351 }355 }
M internal/usecase/daemon/render_test.go +81 -0
@@ -22,8 +22,89 @@ import (
2222 "github.com/bnema/vev/internal/usecase/layout"
2323 themeui "github.com/bnema/vev/internal/usecase/theme"
2424 "github.com/bnema/vev/pkg/renderer"
25+ "github.com/bnema/vev/pkg/vt"
2526 )
2627
28+func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
29+ p, release := newBlockingPTY(t)
30+ defer release()
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ theme := backdropTheme()
33+ ac.setTheme(theme)
34+ client := vt.NewScreen(80, 25)
35+ pane := sess.tabs[0].focusedPane()
36+ pane.screen.Write([]byte("\x1b[38;2;180;90;30mX"))
37+
38+ d.enterCopyMode(sess, ac)
39+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
40+ undimmed := client.Frame.At(0, 1)
41+ require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")
42+ copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...)
43+ require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")
44+
45+ d.enterPalette(sess, ac)
46+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
47+ dimmed := client.Frame.At(0, 1)
48+ require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
49+ require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame")
50+ require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")
51+ paletteVisible := false
52+ for y := range client.Frame.Height {
53+ paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
54+ }
55+ require.True(t, paletteVisible, "palette must remain composed above copy mode")
56+}
57+
58+func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
59+ p, release := newBlockingPTY(t)
60+ defer release()
61+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
62+ ac.setTheme(backdropTheme())
63+ client := vt.NewScreen(80, 25)
64+ pane := sess.tabs[0].focusedPane()
65+ pane.screen.Write([]byte("X"))
66+
67+ d.enterPicker(sess, ac)
68+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
69+ pickerTitle := client.Frame.At(36, 2)
70+ require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
71+ undimmedPane := client.Frame.At(0, 1)
72+
73+ d.enterPalette(sess, ac)
74+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
75+ require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
76+ require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
77+}
78+
79+func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
80+ p, release := newBlockingPTY(t)
81+ defer release()
82+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
83+ ac.setTheme(backdropTheme())
84+ client := vt.NewScreen(80, 25)
85+ pane := sess.tabs[0].focusedPane()
86+ pane.screen.Write([]byte("X"))
87+ d.paint(sess, ac, true)
88+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
89+ undimmed := client.Frame.At(0, 1)
90+ topBar := client.Frame.At(0, 0)
91+ bottomBar := client.Frame.At(0, 24)
92+
93+ d.handleInput(sess, ac, []byte("\x1b "))
94+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
95+ dimmed := client.Frame.At(0, 1)
96+ require.Equal(t, 'X', dimmed.Rune)
97+ require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
98+ require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
99+ require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
100+
101+ d.handleInput(sess, ac, []byte("\x1b"))
102+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
103+ require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
104+ require.Equal(t, topBar, client.Frame.At(0, 0))
105+ require.Equal(t, bottomBar, client.Frame.At(0, 24))
106+}
107+
27108 // --- test doubles -----------------------------------------------------------
28109
29110 type discardTransport struct{}
@@ -22,8 +22,89 @@ import (
22 "github.com/bnema/vev/internal/usecase/layout"22 "github.com/bnema/vev/internal/usecase/layout"
23 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
24 "github.com/bnema/vev/pkg/renderer"24 "github.com/bnema/vev/pkg/renderer"
25+ "github.com/bnema/vev/pkg/vt"
25 )26 )
26 27
28+func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
29+ p, release := newBlockingPTY(t)
30+ defer release()
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ theme := backdropTheme()
33+ ac.setTheme(theme)
34+ client := vt.NewScreen(80, 25)
35+ pane := sess.tabs[0].focusedPane()
36+ pane.screen.Write([]byte("\x1b[38;2;180;90;30mX"))
37+
38+ d.enterCopyMode(sess, ac)
39+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
40+ undimmed := client.Frame.At(0, 1)
41+ require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")
42+ copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...)
43+ require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")
44+
45+ d.enterPalette(sess, ac)
46+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
47+ dimmed := client.Frame.At(0, 1)
48+ require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
49+ require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame")
50+ require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")
51+ paletteVisible := false
52+ for y := range client.Frame.Height {
53+ paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
54+ }
55+ require.True(t, paletteVisible, "palette must remain composed above copy mode")
56+}
57+
58+func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
59+ p, release := newBlockingPTY(t)
60+ defer release()
61+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
62+ ac.setTheme(backdropTheme())
63+ client := vt.NewScreen(80, 25)
64+ pane := sess.tabs[0].focusedPane()
65+ pane.screen.Write([]byte("X"))
66+
67+ d.enterPicker(sess, ac)
68+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
69+ pickerTitle := client.Frame.At(36, 2)
70+ require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
71+ undimmedPane := client.Frame.At(0, 1)
72+
73+ d.enterPalette(sess, ac)
74+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
75+ require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
76+ require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
77+}
78+
79+func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
80+ p, release := newBlockingPTY(t)
81+ defer release()
82+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
83+ ac.setTheme(backdropTheme())
84+ client := vt.NewScreen(80, 25)
85+ pane := sess.tabs[0].focusedPane()
86+ pane.screen.Write([]byte("X"))
87+ d.paint(sess, ac, true)
88+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
89+ undimmed := client.Frame.At(0, 1)
90+ topBar := client.Frame.At(0, 0)
91+ bottomBar := client.Frame.At(0, 24)
92+
93+ d.handleInput(sess, ac, []byte("\x1b "))
94+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
95+ dimmed := client.Frame.At(0, 1)
96+ require.Equal(t, 'X', dimmed.Rune)
97+ require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
98+ require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
99+ require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
100+
101+ d.handleInput(sess, ac, []byte("\x1b"))
102+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
103+ require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
104+ require.Equal(t, topBar, client.Frame.At(0, 0))
105+ require.Equal(t, bottomBar, client.Frame.At(0, 24))
106+}
107+
27 // --- test doubles -----------------------------------------------------------108 // --- test doubles -----------------------------------------------------------
28 109
29 type discardTransport struct{}110 type discardTransport struct{}
M internal/usecase/palette/fuzzy.go +1 -1
@@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) {
6363 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
6464 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
6565 }
66- text := strings.ToLower(cmd.Name + " " + cmd.Desc)
66+ text := strings.ToLower(cmd.Desc)
6767 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
6868 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
6969 }
@@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) {
63 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {63 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
64 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true64 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
65 }65 }
66- text := strings.ToLower(cmd.Name + " " + cmd.Desc)66+ text := strings.ToLower(cmd.Desc)
67 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {67 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
68 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true68 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
69 }69 }
M internal/usecase/palette/fuzzy_test.go +19 -3
@@ -19,16 +19,32 @@ func codes(matches []Match) []string {
1919 return out
2020 }
2121
22-func TestFuzzyOrdersScoringTiers(t *testing.T) {
22+func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
2323 matches := Fuzzy([]command.Command{
24- cmd("ZQQ", "alpha beta", "name subsequence"),
24+ cmd("ZQQ", "unused", "alpha beta"),
2525 cmd("XAB", "unused", "subsequence code"),
2626 cmd("ABX", "unused", "prefix code"),
2727 cmd("AB", "unused", "exact code"),
2828 }, "ab")
2929
3030 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31- require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions")
31+ require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
32+}
33+
34+func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35+ matches := Fuzzy([]command.Command{
36+ cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
37+ cmd("ZQQ", "unused", "Alpha Beta tools"),
38+ }, "ab")
39+
40+ require.Equal(t, []string{"ZQQ"}, codes(matches))
41+}
42+
43+func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44+ matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
45+
46+ require.Len(t, matches, 1)
47+ require.Empty(t, matches[0].Positions)
3248 }
3349
3450 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
@@ -19,16 +19,32 @@ func codes(matches []Match) []string {
19 return out19 return out
20 }20 }
21 21
22-func TestFuzzyOrdersScoringTiers(t *testing.T) {22+func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
23 matches := Fuzzy([]command.Command{23 matches := Fuzzy([]command.Command{
24- cmd("ZQQ", "alpha beta", "name subsequence"),24+ cmd("ZQQ", "unused", "alpha beta"),
25 cmd("XAB", "unused", "subsequence code"),25 cmd("XAB", "unused", "subsequence code"),
26 cmd("ABX", "unused", "prefix code"),26 cmd("ABX", "unused", "prefix code"),
27 cmd("AB", "unused", "exact code"),27 cmd("AB", "unused", "exact code"),
28 }, "ab")28 }, "ab")
29 29
30 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))30 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31- require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions")31+ require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
32+}
33+
34+func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35+ matches := Fuzzy([]command.Command{
36+ cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
37+ cmd("ZQQ", "unused", "Alpha Beta tools"),
38+ }, "ab")
39+
40+ require.Equal(t, []string{"ZQQ"}, codes(matches))
41+}
42+
43+func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44+ matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
45+
46+ require.Len(t, matches, 1)
47+ require.Empty(t, matches[0].Positions)
32 }48 }
33 49
34 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {50 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
M internal/usecase/palette/model.go +0 -1
@@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
174174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
175175 x++
176176 }
177- x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style)
178177 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
179178 }
180179 return frame
@@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
175 x++175 x++
176 }176 }
177- x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style)
178 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))177 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
179 }178 }
180 return frame179 return frame
M internal/usecase/palette/model_test.go +31 -7
@@ -1,6 +1,7 @@
11 package palette
22
33 import (
4+ "fmt"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
6667 require.Equal(t, []int{0}, fresh[0].Positions)
6768 }
6869
69-func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
70+func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
7071 m := New([]command.Command{
7172 cmd("CPY", "Copy", "Enter copy mode"),
7273 cmd("CNT", "New", "Create tab"),
@@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
8182 require.Equal(t, 'y', frame.At(3, 0).Rune)
8283 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
8384
84- require.Equal(t, 'C', frame.At(0, 1).Rune)
85- require.Equal(t, 'P', frame.At(1, 1).Rune)
86- require.Equal(t, 'Y', frame.At(2, 1).Rune)
85+ require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
86+ require.NotContains(t, frameRow(frame, 1), "Copy")
87+ require.NotContains(t, frameRow(frame, 1), "—")
8788 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
8889 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
8990 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
9091 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
9192 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
92- require.True(t, frame.At(13, 1).Style.Italic, "description is italic")
93+ require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
94+}
95+
96+func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
97+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
98+ want := []rune("CPY Enter copy mode")
99+
100+ for _, cols := range []int{0, 1, 3, 4, 7} {
101+ t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())
103+ require.Equal(t, cols, frame.Width)
104+ if cols > 0 {
105+ require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
106+ }
107+ })
108+ }
109+}
110+
111+func frameRow(frame renderer.Frame, y int) string {
112+ row := make([]rune, frame.Width)
113+ for x := range frame.Width {
114+ row[x] = frame.At(x, y).Rune
115+ }
116+ return string(row)
93117 }
94118
95119 func TestRenderUsesConfiguredStyles(t *testing.T) {
@@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
128152 styles.Description = muted
129153 return styles
130154 },
131- x: 13,
155+ x: 4,
132156 assert: func(t *testing.T, style renderer.Style) {
133157 require.True(t, style.Italic)
134158 require.True(t, style.HasForegroundRGB)
@@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
143167 styles.Description.Italic = false
144168 return styles
145169 },
146- x: 13,
170+ x: 4,
147171 assert: func(t *testing.T, style renderer.Style) {
148172 require.False(t, style.Italic)
149173 },
@@ -1,6 +1,7 @@
1 package palette1 package palette
2 2
3 import (3 import (
4+ "fmt"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
66 require.Equal(t, []int{0}, fresh[0].Positions)67 require.Equal(t, []int{0}, fresh[0].Positions)
67 }68 }
68 69
69-func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {70+func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
70 m := New([]command.Command{71 m := New([]command.Command{
71 cmd("CPY", "Copy", "Enter copy mode"),72 cmd("CPY", "Copy", "Enter copy mode"),
72 cmd("CNT", "New", "Create tab"),73 cmd("CNT", "New", "Create tab"),
@@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
81 require.Equal(t, 'y', frame.At(3, 0).Rune)82 require.Equal(t, 'y', frame.At(3, 0).Rune)
82 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")83 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
83 84
84- require.Equal(t, 'C', frame.At(0, 1).Rune)85+ require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
85- require.Equal(t, 'P', frame.At(1, 1).Rune)86+ require.NotContains(t, frameRow(frame, 1), "Copy")
86- require.Equal(t, 'Y', frame.At(2, 1).Rune)87+ require.NotContains(t, frameRow(frame, 1), "—")
87 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")88 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
88 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")89 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
89 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")90 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
90 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")91 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
91 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")92 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
92- require.True(t, frame.At(13, 1).Style.Italic, "description is italic")93+ require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
94+}
95+
96+func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
97+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
98+ want := []rune("CPY Enter copy mode")
99+
100+ for _, cols := range []int{0, 1, 3, 4, 7} {
101+ t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())
103+ require.Equal(t, cols, frame.Width)
104+ if cols > 0 {
105+ require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
106+ }
107+ })
108+ }
109+}
110+
111+func frameRow(frame renderer.Frame, y int) string {
112+ row := make([]rune, frame.Width)
113+ for x := range frame.Width {
114+ row[x] = frame.At(x, y).Rune
115+ }
116+ return string(row)
93 }117 }
94 118
95 func TestRenderUsesConfiguredStyles(t *testing.T) {119 func TestRenderUsesConfiguredStyles(t *testing.T) {
@@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
128 styles.Description = muted152 styles.Description = muted
129 return styles153 return styles
130 },154 },
131- x: 13,155+ x: 4,
132 assert: func(t *testing.T, style renderer.Style) {156 assert: func(t *testing.T, style renderer.Style) {
133 require.True(t, style.Italic)157 require.True(t, style.Italic)
134 require.True(t, style.HasForegroundRGB)158 require.True(t, style.HasForegroundRGB)
@@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
143 styles.Description.Italic = false167 styles.Description.Italic = false
144 return styles168 return styles
145 },169 },
146- x: 13,170+ x: 4,
147 assert: func(t *testing.T, style renderer.Style) {171 assert: func(t *testing.T, style renderer.Style) {
148 require.False(t, style.Italic)172 require.False(t, style.Italic)
149 },173 },
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