detached e7fd3eb

feat(theme): tint modal chrome surfaces

commit
e7fd3eb7ac95cd96dd7441ad5d8a89ec9f7619bc
author
brice · · 2026-07-18 08:57 UTC
parent
84daa2c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +206 -40
M internal/usecase/copy/mode.go +1 -1
@@ -421,7 +421,7 @@ func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
421421 func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
422422 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
423423 for i := range row {
424- row[i] = renderer.BlankCell()
424+ row[i] = renderer.Cell{Rune: ' ', Style: style}
425425 }
426426 text := " [SCROLL] "
427427 if m.selection.Enabled {
@@ -421,7 +421,7 @@ func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
421 func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }421 func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
422 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {422 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
423 for i := range row {423 for i := range row {
424- row[i] = renderer.BlankCell()424+ row[i] = renderer.Cell{Rune: ' ', Style: style}
425 }425 }
426 text := " [SCROLL] "426 text := " [SCROLL] "
427 if m.selection.Enabled {427 if m.selection.Enabled {
M internal/usecase/copy/mode_test.go +9 -0
@@ -256,3 +256,12 @@ func TestOSC52LimitsAndBase64(t *testing.T) {
256256 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
257257 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
258258 }
259+
260+func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
261+ m := modeFor([]string{"alpha"}, 5)
262+ status := renderer.Style{Foreground: 1, Background: 2}
263+ selection := renderer.Style{Foreground: 3, Background: 4}
264+ frame := m.Render(status, selection)
265+
266+ require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
267+}
@@ -256,3 +256,12 @@ func TestOSC52LimitsAndBase64(t *testing.T) {
256 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))256 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
257 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))257 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
258 }258 }
259+
260+func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
261+ m := modeFor([]string{"alpha"}, 5)
262+ status := renderer.Style{Foreground: 1, Background: 2}
263+ selection := renderer.Style{Foreground: 3, Background: 4}
264+ frame := m.Render(status, selection)
265+
266+ require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
267+}
M internal/usecase/daemon/modal_compose.go +3 -3
@@ -7,9 +7,9 @@ import (
77 "github.com/bnema/vev/pkg/renderer"
88 )
99
10-func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.Border)
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
10+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11+ inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)
12+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})
1313 for y := range min(inner.Height, modalFrame.Height) {
1414 for x := range min(inner.Width, modalFrame.Width) {
1515 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
@@ -7,9 +7,9 @@ import (
7 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10-func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {10+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.Border)11+ inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)12+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})
13 for y := range min(inner.Height, modalFrame.Height) {13 for y := range min(inner.Height, modalFrame.Height) {
14 for x := range min(inner.Width, modalFrame.Width) {14 for x := range min(inner.Width, modalFrame.Width) {
15 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))15 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
M internal/usecase/daemon/palette.go +2 -2
@@ -500,7 +500,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
500500 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
501501 styleSet := resolveStyles(styles)
502502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
503- return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
504- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance})
503+ return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance})
505505 })
506506 }
@@ -500,7 +500,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
500 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {500 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
501 styleSet := resolveStyles(styles)501 styleSet := resolveStyles(styles)
502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
503- return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {503+ return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
504- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance})504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance})
505 })505 })
506 }506 }
M internal/usecase/daemon/render_capture.go +3 -2
@@ -93,8 +93,9 @@ type capturedOverlayRenderState struct {
9393 }
9494
9595 type capturedModal struct {
96- modal ui.Modal
97- inner renderer.Frame
96+ modal ui.Modal
97+ inner renderer.Frame
98+ focused bool
9899 }
99100
100101 func (o capturedOverlayRenderState) active() bool {
@@ -93,8 +93,9 @@ type capturedOverlayRenderState struct {
93 }93 }
94 94
95 type capturedModal struct {95 type capturedModal struct {
96- modal ui.Modal96+ modal ui.Modal
97- inner renderer.Frame97+ inner renderer.Frame
98+ focused bool
98 }99 }
99 100
100 func (o capturedOverlayRenderState) active() bool {101 func (o capturedOverlayRenderState) active() bool {
M internal/usecase/daemon/render_pipeline.go +15 -5
@@ -9,8 +9,10 @@ import (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/bnema/vev/internal/usecase/palette"
1111 "github.com/bnema/vev/internal/usecase/picker"
12+ "github.com/bnema/vev/internal/usecase/prompt"
1213 themeui "github.com/bnema/vev/internal/usecase/theme"
1314 "github.com/bnema/vev/internal/usecase/ui"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
1416 "github.com/bnema/vev/pkg/renderer"
1517 )
1618
@@ -206,25 +208,29 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206208 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
207209 if snap.copySearchModel != nil {
208210 o.copySearch.modal = copySearchModal
209- o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection)
211+ o.copySearch.focused = true
212+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
210213 }
211214 if snap.pickerActive && snap.pickerModel != nil {
212215 o.picker.modal = pickerModal
213- renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator}
216+ o.picker.focused = true
217+ renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator}
214218 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
215219 }
216220 if snap.paletteActive && snap.paletteModel != nil {
217221 o.palette.modal = paletteModalFor(size, paletteCfg)
222+ o.palette.focused = true
218223 guidance := ""
219224 if snap.paletteHints != nil {
220225 guidance = snap.paletteHints.Feedback
221226 }
222227 o.paletteGuidance = snap.paletteFeedback
223- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
228+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback})
224229 }
225230 if snap.promptActive && snap.promptModel != nil {
226231 o.prompt.modal = promptModalFor(snap.promptModel.Title())
227- o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent)
232+ o.prompt.focused = true
233+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
228234 }
229235 state.cursor.hiddenByOverlay = o.active()
230236 }
@@ -259,7 +265,11 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
259265 if modal.inner.Width == 0 && modal.inner.Height == 0 {
260266 continue
261267 }
262- inner := modal.modal.Composite(frame, state.styles.BorderMuted)
268+ border := state.styles.BorderMuted
269+ if modal.focused {
270+ border = state.styles.BorderActive
271+ }
272+ inner := modal.modal.Composite(frame, border, state.styles.PickerBase)
263273 for y := range min(inner.Height, modal.inner.Height) {
264274 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
265275 }
@@ -9,8 +9,10 @@ import (
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/bnema/vev/internal/usecase/palette"10 "github.com/bnema/vev/internal/usecase/palette"
11 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
12+ "github.com/bnema/vev/internal/usecase/prompt"
12 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/bnema/vev/internal/usecase/ui"14 "github.com/bnema/vev/internal/usecase/ui"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
14 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
15 )17 )
16 18
@@ -206,25 +208,29 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}208 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
207 if snap.copySearchModel != nil {209 if snap.copySearchModel != nil {
208 o.copySearch.modal = copySearchModal210 o.copySearch.modal = copySearchModal
209- o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection)211+ o.copySearch.focused = true
212+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
210 }213 }
211 if snap.pickerActive && snap.pickerModel != nil {214 if snap.pickerActive && snap.pickerModel != nil {
212 o.picker.modal = pickerModal215 o.picker.modal = pickerModal
213- renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator}216+ o.picker.focused = true
217+ renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator}
214 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)218 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
215 }219 }
216 if snap.paletteActive && snap.paletteModel != nil {220 if snap.paletteActive && snap.paletteModel != nil {
217 o.palette.modal = paletteModalFor(size, paletteCfg)221 o.palette.modal = paletteModalFor(size, paletteCfg)
222+ o.palette.focused = true
218 guidance := ""223 guidance := ""
219 if snap.paletteHints != nil {224 if snap.paletteHints != nil {
220 guidance = snap.paletteHints.Feedback225 guidance = snap.paletteHints.Feedback
221 }226 }
222 o.paletteGuidance = snap.paletteFeedback227 o.paletteGuidance = snap.paletteFeedback
223- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})228+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback})
224 }229 }
225 if snap.promptActive && snap.promptModel != nil {230 if snap.promptActive && snap.promptModel != nil {
226 o.prompt.modal = promptModalFor(snap.promptModel.Title())231 o.prompt.modal = promptModalFor(snap.promptModel.Title())
227- o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent)232+ o.prompt.focused = true
233+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
228 }234 }
229 state.cursor.hiddenByOverlay = o.active()235 state.cursor.hiddenByOverlay = o.active()
230 }236 }
@@ -259,7 +265,11 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
259 if modal.inner.Width == 0 && modal.inner.Height == 0 {265 if modal.inner.Width == 0 && modal.inner.Height == 0 {
260 continue266 continue
261 }267 }
262- inner := modal.modal.Composite(frame, state.styles.BorderMuted)268+ border := state.styles.BorderMuted
269+ if modal.focused {
270+ border = state.styles.BorderActive
271+ }
272+ inner := modal.modal.Composite(frame, border, state.styles.PickerBase)
263 for y := range min(inner.Height, modal.inner.Height) {273 for y := range min(inner.Height, modal.inner.Height) {
264 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])274 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
265 }275 }
M internal/usecase/daemon/render_pipeline_test.go +28 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/ports"
1515 "github.com/bnema/vev/internal/testutil/replaytest"
1616 "github.com/bnema/vev/internal/usecase/layout"
17+ themeui "github.com/bnema/vev/internal/usecase/theme"
1718 "github.com/bnema/vev/internal/usecase/ui"
1819 "github.com/bnema/vev/pkg/renderer"
1920 "github.com/bnema/vev/pkg/vt"
@@ -291,3 +292,30 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
291292 })
292293 }
293294 }
295+
296+func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
297+ muted := renderer.Style{Foreground: 1, Background: 2}
298+ active := renderer.Style{Foreground: 3, Background: 4}
299+ interior := renderer.Style{Foreground: 5, Background: 6}
300+ modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
301+
302+ for _, tt := range []struct {
303+ name string
304+ focused bool
305+ border renderer.Style
306+ }{
307+ {name: "unfocused", focused: false, border: muted},
308+ {name: "focused", focused: true, border: active},
309+ } {
310+ t.Run(tt.name, func(t *testing.T) {
311+ state := capturedRenderState{
312+ styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313+ overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314+ }
315+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316+ bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})
317+ require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318+ require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319+ })
320+ }
321+}
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/testutil/replaytest"15 "github.com/bnema/vev/internal/testutil/replaytest"
16 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17+ themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/internal/usecase/ui"18 "github.com/bnema/vev/internal/usecase/ui"
18 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
19 "github.com/bnema/vev/pkg/vt"20 "github.com/bnema/vev/pkg/vt"
@@ -291,3 +292,30 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
291 })292 })
292 }293 }
293 }294 }
295+
296+func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
297+ muted := renderer.Style{Foreground: 1, Background: 2}
298+ active := renderer.Style{Foreground: 3, Background: 4}
299+ interior := renderer.Style{Foreground: 5, Background: 6}
300+ modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
301+
302+ for _, tt := range []struct {
303+ name string
304+ focused bool
305+ border renderer.Style
306+ }{
307+ {name: "unfocused", focused: false, border: muted},
308+ {name: "focused", focused: true, border: active},
309+ } {
310+ t.Run(tt.name, func(t *testing.T) {
311+ state := capturedRenderState{
312+ styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313+ overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314+ }
315+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316+ bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})
317+ require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318+ require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319+ })
320+ }
321+}
M internal/usecase/palette/model.go +11 -3
@@ -11,6 +11,10 @@ import (
1111 )
1212
1313 type RenderStyles struct {
14+ // Base fills unused modal interior cells; Row is the ordinary inactive
15+ // result surface. They are separate to retain the chrome hierarchy.
16+ Base renderer.Style
17+ Row renderer.Style
1418 Selection renderer.Style
1519 Description renderer.Style
1620 }
@@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles {
5357 selection.Inverse = true
5458 description := renderer.DefaultStyle()
5559 description.Italic = true
56- return RenderStyles{Selection: selection, Description: description}
60+ base := renderer.DefaultStyle()
61+ return RenderStyles{Base: base, Row: base, Selection: selection, Description: description}
5762 }
5863
5964 func (m *Model) Insert(r rune) {
@@ -235,11 +240,14 @@ func (m *Model) clamp() {
235240
236241 func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
237242 styles := opts.Styles
243+ if styles == (RenderStyles{}) {
244+ styles = DefaultRenderStyles()
245+ }
238246 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
239247 if frame.Width == 0 || frame.Height == 0 {
240248 return frame
241249 }
242- base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
250+ base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
243251 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
244252 if m == nil {
245253 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -269,7 +277,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
269277 break
270278 }
271279 match := m.matches[idx]
272- style := base
280+ style := row
273281 if idx == m.selected {
274282 style = selection
275283 }
@@ -11,6 +11,10 @@ import (
11 )11 )
12 12
13 type RenderStyles struct {13 type RenderStyles struct {
14+ // Base fills unused modal interior cells; Row is the ordinary inactive
15+ // result surface. They are separate to retain the chrome hierarchy.
16+ Base renderer.Style
17+ Row renderer.Style
14 Selection renderer.Style18 Selection renderer.Style
15 Description renderer.Style19 Description renderer.Style
16 }20 }
@@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles {
53 selection.Inverse = true57 selection.Inverse = true
54 description := renderer.DefaultStyle()58 description := renderer.DefaultStyle()
55 description.Italic = true59 description.Italic = true
56- return RenderStyles{Selection: selection, Description: description}60+ base := renderer.DefaultStyle()
61+ return RenderStyles{Base: base, Row: base, Selection: selection, Description: description}
57 }62 }
58 63
59 func (m *Model) Insert(r rune) {64 func (m *Model) Insert(r rune) {
@@ -235,11 +240,14 @@ func (m *Model) clamp() {
235 240
236 func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {241 func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
237 styles := opts.Styles242 styles := opts.Styles
243+ if styles == (RenderStyles{}) {
244+ styles = DefaultRenderStyles()
245+ }
238 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))246 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
239 if frame.Width == 0 || frame.Height == 0 {247 if frame.Width == 0 || frame.Height == 0 {
240 return frame248 return frame
241 }249 }
242- base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description250+ base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
243 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})251 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
244 if m == nil {252 if m == nil {
245 ui.DrawInputLine(frame, 0, "> ", "", base, selection)253 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -269,7 +277,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
269 break277 break
270 }278 }
271 match := m.matches[idx]279 match := m.matches[idx]
272- style := base280+ style := row
273 if idx == m.selected {281 if idx == m.selected {
274 style = selection282 style = selection
275 }283 }
M internal/usecase/palette/model_test.go +12 -0
@@ -400,3 +400,15 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
400400 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
401401 }
402402 }
403+
404+func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
405+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
406+ base := renderer.Style{Foreground: 1, Background: 2}
407+ row := renderer.Style{Foreground: 3, Background: 4}
408+ selection := renderer.Style{Foreground: 5, Background: 6}
409+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
410+
411+ require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
412+ require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
413+ require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
414+}
@@ -400,3 +400,15 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
400 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)400 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
401 }401 }
402 }402 }
403+
404+func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
405+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
406+ base := renderer.Style{Foreground: 1, Background: 2}
407+ row := renderer.Style{Foreground: 3, Background: 4}
408+ selection := renderer.Style{Foreground: 5, Background: 6}
409+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
410+
411+ require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
412+ require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
413+ require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
414+}
M internal/usecase/picker/model.go +7 -3
@@ -42,8 +42,11 @@ type RenderStyles struct {
4242 SelectionMuted renderer.Style // selected row detail segment
4343 Name renderer.Style // non-selected name segment
4444 Detail renderer.Style // non-selected detail segment
45- Base renderer.Style // non-selected fill + suffixes
46- Separator renderer.Style // preview separator
45+ // Background fills otherwise unused interior cells. Base owns ordinary
46+ // rows, preserving a distinct inactive surface from modal chrome.
47+ Background renderer.Style
48+ Base renderer.Style // non-selected row fill + suffixes
49+ Separator renderer.Style // preview separator
4750 }
4851
4952 func defaultRenderStyles() RenderStyles {
@@ -52,7 +55,7 @@ func defaultRenderStyles() RenderStyles {
5255 base := renderer.DefaultStyle()
5356 separator := renderer.DefaultStyle()
5457 separator.Attrs = renderer.AttrDim
55- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator}
58+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator}
5659 }
5760
5861 type Target struct {
@@ -203,6 +206,7 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle
203206 if len(styles) > 0 {
204207 styleSet = styles[0]
205208 }
209+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
206210 m.renderList(frame, layout.List, styleSet)
207211 switch layout.Mode {
208212 case LayoutHorizontal:
@@ -42,8 +42,11 @@ type RenderStyles struct {
42 SelectionMuted renderer.Style // selected row detail segment42 SelectionMuted renderer.Style // selected row detail segment
43 Name renderer.Style // non-selected name segment43 Name renderer.Style // non-selected name segment
44 Detail renderer.Style // non-selected detail segment44 Detail renderer.Style // non-selected detail segment
45- Base renderer.Style // non-selected fill + suffixes45+ // Background fills otherwise unused interior cells. Base owns ordinary
46- Separator renderer.Style // preview separator46+ // rows, preserving a distinct inactive surface from modal chrome.
47+ Background renderer.Style
48+ Base renderer.Style // non-selected row fill + suffixes
49+ Separator renderer.Style // preview separator
47 }50 }
48 51
49 func defaultRenderStyles() RenderStyles {52 func defaultRenderStyles() RenderStyles {
@@ -52,7 +55,7 @@ func defaultRenderStyles() RenderStyles {
52 base := renderer.DefaultStyle()55 base := renderer.DefaultStyle()
53 separator := renderer.DefaultStyle()56 separator := renderer.DefaultStyle()
54 separator.Attrs = renderer.AttrDim57 separator.Attrs = renderer.AttrDim
55- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator}58+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator}
56 }59 }
57 60
58 type Target struct {61 type Target struct {
@@ -203,6 +206,7 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle
203 if len(styles) > 0 {206 if len(styles) > 0 {
204 styleSet = styles[0]207 styleSet = styles[0]
205 }208 }
209+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
206 m.renderList(frame, layout.List, styleSet)210 m.renderList(frame, layout.List, styleSet)
207 switch layout.Mode {211 switch layout.Mode {
208 case LayoutHorizontal:212 case LayoutHorizontal:
M internal/usecase/picker/model_test.go +16 -0
@@ -332,3 +332,19 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
332332 func cell(r rune) renderer.Cell {
333333 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
334334 }
335+
336+func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
337+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
338+ background := renderer.Style{Foreground: 1, Background: 2}
339+ base := renderer.Style{Foreground: 3, Background: 4}
340+ selection := renderer.Style{Foreground: 5, Background: 6}
341+ frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
342+ Background: background, Base: base, Name: base, Detail: base,
343+ Selection: selection, SelectionName: selection, SelectionMuted: selection,
344+ Separator: base,
345+ })
346+
347+ require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")
348+ require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
349+ require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
350+}
@@ -332,3 +332,19 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
332 func cell(r rune) renderer.Cell {332 func cell(r rune) renderer.Cell {
333 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}333 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
334 }334 }
335+
336+func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
337+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
338+ background := renderer.Style{Foreground: 1, Background: 2}
339+ base := renderer.Style{Foreground: 3, Background: 4}
340+ selection := renderer.Style{Foreground: 5, Background: 6}
341+ frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
342+ Background: background, Base: base, Name: base, Detail: base,
343+ Selection: selection, SelectionName: selection, SelectionMuted: selection,
344+ Separator: base,
345+ })
346+
347+ require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")
348+ require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
349+ require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
350+}
M internal/usecase/prompt/model.go +22 -9
@@ -6,6 +6,14 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9+// RenderStyles contains daemon-supplied semantic chrome roles. The zero value
10+// is intentionally not used by RenderStyled; isolated Render callers retain
11+// the historic neutral defaults.
12+type RenderStyles struct {
13+ Base renderer.Style
14+ Selection renderer.Style
15+}
16+
917 type Model struct {
1018 title string
1119 text ui.TextInput
@@ -55,20 +63,25 @@ func (m *Model) SetError(msg string) {
5563 }
5664
5765 func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {
66+ base := renderer.DefaultStyle()
67+ selection := base
68+ selection.Inverse = true
69+ if len(accentStyle) > 0 {
70+ selection = accentStyle[0]
71+ }
72+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
73+}
74+
75+// RenderStyled renders with explicit chrome roles captured by the daemon.
76+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
5877 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
5978 if frame.Width == 0 || frame.Height == 0 {
6079 return frame
6180 }
62- base := renderer.DefaultStyle()
63- accent := base
64- accent.Inverse = true
65- if len(accentStyle) > 0 {
66- accent = accentStyle[0]
67- }
68- ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
69- ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent)
81+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})
82+ ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection)
7083 if frame.Height > 1 && m != nil && m.errMsg != "" {
71- ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent)
84+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
7285 }
7386 return frame
7487 }
@@ -6,6 +6,14 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9+// RenderStyles contains daemon-supplied semantic chrome roles. The zero value
10+// is intentionally not used by RenderStyled; isolated Render callers retain
11+// the historic neutral defaults.
12+type RenderStyles struct {
13+ Base renderer.Style
14+ Selection renderer.Style
15+}
16+
9 type Model struct {17 type Model struct {
10 title string18 title string
11 text ui.TextInput19 text ui.TextInput
@@ -55,20 +63,25 @@ func (m *Model) SetError(msg string) {
55 }63 }
56 64
57 func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {65 func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {
66+ base := renderer.DefaultStyle()
67+ selection := base
68+ selection.Inverse = true
69+ if len(accentStyle) > 0 {
70+ selection = accentStyle[0]
71+ }
72+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
73+}
74+
75+// RenderStyled renders with explicit chrome roles captured by the daemon.
76+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
58 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))77 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
59 if frame.Width == 0 || frame.Height == 0 {78 if frame.Width == 0 || frame.Height == 0 {
60 return frame79 return frame
61 }80 }
62- base := renderer.DefaultStyle()81+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})
63- accent := base82+ ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection)
64- accent.Inverse = true
65- if len(accentStyle) > 0 {
66- accent = accentStyle[0]
67- }
68- ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
69- ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent)
70 if frame.Height > 1 && m != nil && m.errMsg != "" {83 if frame.Height > 1 && m != nil && m.errMsg != "" {
71- ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent)84+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
72 }85 }
73 return frame86 return frame
74 }87 }
M internal/usecase/prompt/model_test.go +10 -0
@@ -57,3 +57,13 @@ func TestModelRenderHandlesTinySize(t *testing.T) {
5757 require.Equal(t, 0, frame.Width)
5858 require.Equal(t, 0, frame.Height)
5959 }
60+
61+func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) {
62+ m := New(" Rename ", "x")
63+ base := renderer.Style{Foreground: 1, Background: 2}
64+ selection := renderer.Style{Foreground: 3, Background: 4}
65+ frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})
66+
67+ require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")
68+ require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface")
69+}
@@ -57,3 +57,13 @@ func TestModelRenderHandlesTinySize(t *testing.T) {
57 require.Equal(t, 0, frame.Width)57 require.Equal(t, 0, frame.Width)
58 require.Equal(t, 0, frame.Height)58 require.Equal(t, 0, frame.Height)
59 }59 }
60+
61+func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) {
62+ m := New(" Rename ", "x")
63+ base := renderer.Style{Foreground: 1, Background: 2}
64+ selection := renderer.Style{Foreground: 3, Background: 4}
65+ frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})
66+
67+ require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")
68+ require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface")
69+}
M internal/usecase/ui/modal.go +6 -4
@@ -48,9 +48,11 @@ func (m Modal) Inner(base domain.Size) domain.Rect {
4848 }
4949 }
5050
51-// Composite draws the modal border and title, clears the interior, and returns
52-// the inner content rectangle. Cells outside the modal bounds are not changed.
53-func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
51+// Composite draws the modal border and title, fills its interior, and returns
52+// the inner content rectangle. Border and interior styles are deliberately
53+// independent so unfocused structure and chrome surfaces retain their roles.
54+// Cells outside the modal bounds are not changed.
55+func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
5456 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
5557 inner := domain.Rect{
5658 X: bounds.X + 1,
@@ -59,7 +61,7 @@ func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
5961 Height: max(0, bounds.Height-2),
6062 }
6163 DrawBox(f, bounds, border)
62- FillRect(f, inner, renderer.BlankCell())
64+ FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
6365 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
6466 left := bounds.X + 1
6567 right := bounds.X + bounds.Width - 1
@@ -48,9 +48,11 @@ func (m Modal) Inner(base domain.Size) domain.Rect {
48 }48 }
49 }49 }
50 50
51-// Composite draws the modal border and title, clears the interior, and returns51+// Composite draws the modal border and title, fills its interior, and returns
52-// the inner content rectangle. Cells outside the modal bounds are not changed.52+// the inner content rectangle. Border and interior styles are deliberately
53-func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {53+// independent so unfocused structure and chrome surfaces retain their roles.
54+// Cells outside the modal bounds are not changed.
55+func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
54 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})56 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
55 inner := domain.Rect{57 inner := domain.Rect{
56 X: bounds.X + 1,58 X: bounds.X + 1,
@@ -59,7 +61,7 @@ func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
59 Height: max(0, bounds.Height-2),61 Height: max(0, bounds.Height-2),
60 }62 }
61 DrawBox(f, bounds, border)63 DrawBox(f, bounds, border)
62- FillRect(f, inner, renderer.BlankCell())64+ FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
63 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {65 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
64 left := bounds.X + 166 left := bounds.X + 1
65 right := bounds.X + bounds.Width - 167 right := bounds.X + bounds.Width - 1
M internal/usecase/ui/modal_test.go +34 -4
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
89 )
910
1011 func TestModalBounds(t *testing.T) {
@@ -130,9 +131,10 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130131 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
131132 FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
132133
133- style := renderer.Style{Bold: true, Foreground: 2, Background: -1}
134+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135+ interior := renderer.Style{Foreground: 3, Background: 4}
134136 m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
135- inner := m.Composite(f, style)
137+ inner := m.Composite(f, border, interior)
136138 wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
137139 if inner != wantInner {
138140 t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
@@ -149,11 +151,15 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
149151
150152 for y := inner.Y; y < inner.Y+inner.Height; y++ {
151153 for x := inner.X; x < inner.X+inner.Width; x++ {
152- if got := f.At(x, y); !got.Equal(renderer.BlankCell()) {
153- t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got)
154+ want := renderer.Cell{Rune: ' ', Style: interior}
155+ if got := f.At(x, y); !got.Equal(want) {
156+ t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
154157 }
155158 }
156159 }
160+ if got := f.At(2, 1).Style; !got.Equal(border) {
161+ t.Fatalf("border style = %+v, want %+v", got, border)
162+ }
157163 assertCell(t, f, 0, 0, exterior)
158164 assertCell(t, f, 9, 5, exterior)
159165 }
@@ -214,3 +220,27 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
214220 t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
215221 }
216222 }
223+
224+func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {
225+ border := renderer.Style{Foreground: 1, Background: 2}
226+ interior := renderer.Style{Foreground: 3, Background: 4}
227+ for _, tt := range []struct {
228+ name string
229+ size domain.Size
230+ want domain.Rect
231+ }{
232+ {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},
233+ {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},
234+ } {
235+ t.Run(tt.name, func(t *testing.T) {
236+ frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)
237+ got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)
238+ require.Equal(t, tt.want, got)
239+ for y := range frame.Height {
240+ for x := range frame.Width {
241+ require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242+ }
243+ }
244+ })
245+ }
246+}
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
8 )9 )
9 10
10 func TestModalBounds(t *testing.T) {11 func TestModalBounds(t *testing.T) {
@@ -130,9 +131,10 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}131 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
131 FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)132 FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
132 133
133- style := renderer.Style{Bold: true, Foreground: 2, Background: -1}134+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135+ interior := renderer.Style{Foreground: 3, Background: 4}
134 m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}136 m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
135- inner := m.Composite(f, style)137+ inner := m.Composite(f, border, interior)
136 wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}138 wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
137 if inner != wantInner {139 if inner != wantInner {
138 t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)140 t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
@@ -149,11 +151,15 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
149 151
150 for y := inner.Y; y < inner.Y+inner.Height; y++ {152 for y := inner.Y; y < inner.Y+inner.Height; y++ {
151 for x := inner.X; x < inner.X+inner.Width; x++ {153 for x := inner.X; x < inner.X+inner.Width; x++ {
152- if got := f.At(x, y); !got.Equal(renderer.BlankCell()) {154+ want := renderer.Cell{Rune: ' ', Style: interior}
153- t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got)155+ if got := f.At(x, y); !got.Equal(want) {
156+ t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
154 }157 }
155 }158 }
156 }159 }
160+ if got := f.At(2, 1).Style; !got.Equal(border) {
161+ t.Fatalf("border style = %+v, want %+v", got, border)
162+ }
157 assertCell(t, f, 0, 0, exterior)163 assertCell(t, f, 0, 0, exterior)
158 assertCell(t, f, 9, 5, exterior)164 assertCell(t, f, 9, 5, exterior)
159 }165 }
@@ -214,3 +220,27 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
214 t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)220 t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
215 }221 }
216 }222 }
223+
224+func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {
225+ border := renderer.Style{Foreground: 1, Background: 2}
226+ interior := renderer.Style{Foreground: 3, Background: 4}
227+ for _, tt := range []struct {
228+ name string
229+ size domain.Size
230+ want domain.Rect
231+ }{
232+ {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},
233+ {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},
234+ } {
235+ t.Run(tt.name, func(t *testing.T) {
236+ frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)
237+ got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)
238+ require.Equal(t, tt.want, got)
239+ for y := range frame.Height {
240+ for x := range frame.Width {
241+ require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242+ }
243+ }
244+ })
245+ }
246+}
M internal/usecase/visualsearch/model.go +16 -4
@@ -133,6 +133,12 @@ func (m *Model) clamp() {
133133
134134 const defaultVisibleRows = 10
135135
136+// RenderStyles contains the base chrome surface and active selection role.
137+type RenderStyles struct {
138+ Base renderer.Style
139+ Selection renderer.Style
140+}
141+
136142 func (m *Model) ensureVisible(visible int) {
137143 if m == nil || visible <= 0 || len(m.matches) == 0 {
138144 return
@@ -146,16 +152,22 @@ func (m *Model) ensureVisible(visible int) {
146152 }
147153
148154 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
149- frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
150- if frame.Width == 0 || frame.Height == 0 {
151- return frame
152- }
153155 base := renderer.DefaultStyle()
154156 selection := base
155157 selection.Inverse = true
156158 if len(selectedStyle) > 0 {
157159 selection = selectedStyle[0]
158160 }
161+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
162+}
163+
164+// RenderStyled renders with explicit chrome roles captured by the daemon.
165+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
166+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
167+ if frame.Width == 0 || frame.Height == 0 {
168+ return frame
169+ }
170+ base, selection := styles.Base, styles.Selection
159171 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
160172 ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
161173 visible := frame.Height - 1
@@ -133,6 +133,12 @@ func (m *Model) clamp() {
133 133
134 const defaultVisibleRows = 10134 const defaultVisibleRows = 10
135 135
136+// RenderStyles contains the base chrome surface and active selection role.
137+type RenderStyles struct {
138+ Base renderer.Style
139+ Selection renderer.Style
140+}
141+
136 func (m *Model) ensureVisible(visible int) {142 func (m *Model) ensureVisible(visible int) {
137 if m == nil || visible <= 0 || len(m.matches) == 0 {143 if m == nil || visible <= 0 || len(m.matches) == 0 {
138 return144 return
@@ -146,16 +152,22 @@ func (m *Model) ensureVisible(visible int) {
146 }152 }
147 153
148 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {154 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
149- frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
150- if frame.Width == 0 || frame.Height == 0 {
151- return frame
152- }
153 base := renderer.DefaultStyle()155 base := renderer.DefaultStyle()
154 selection := base156 selection := base
155 selection.Inverse = true157 selection.Inverse = true
156 if len(selectedStyle) > 0 {158 if len(selectedStyle) > 0 {
157 selection = selectedStyle[0]159 selection = selectedStyle[0]
158 }160 }
161+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
162+}
163+
164+// RenderStyled renders with explicit chrome roles captured by the daemon.
165+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
166+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
167+ if frame.Width == 0 || frame.Height == 0 {
168+ return frame
169+ }
170+ base, selection := styles.Base, styles.Selection
159 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})171 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
160 ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)172 ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
161 visible := frame.Height - 1173 visible := frame.Height - 1
M internal/usecase/visualsearch/model_test.go +11 -0
@@ -186,3 +186,14 @@ func rowText(row []renderer.Cell) string {
186186 }
187187 return b.String()
188188 }
189+
190+func TestVisualSearchRenderStyledFillsBaseAndSelection(t *testing.T) {
191+ m := New(testSnapshot("alpha"))
192+ m.Insert('a')
193+ base := renderer.Style{Foreground: 1, Background: 2}
194+ selection := renderer.Style{Foreground: 3, Background: 4}
195+ frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection})
196+
197+ require.True(t, frame.At(19, 3).Style.Equal(base), "blank filler keeps base surface")
198+ require.True(t, frame.At(2, 0).Style.Equal(selection), "input caret keeps selection surface")
199+}
@@ -186,3 +186,14 @@ func rowText(row []renderer.Cell) string {
186 }186 }
187 return b.String()187 return b.String()
188 }188 }
189+
190+func TestVisualSearchRenderStyledFillsBaseAndSelection(t *testing.T) {
191+ m := New(testSnapshot("alpha"))
192+ m.Insert('a')
193+ base := renderer.Style{Foreground: 1, Background: 2}
194+ selection := renderer.Style{Foreground: 3, Background: 4}
195+ frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection})
196+
197+ require.True(t, frame.At(19, 3).Style.Equal(base), "blank filler keeps base surface")
198+ require.True(t, frame.At(2, 0).Style.Equal(selection), "input caret keeps selection surface")
199+}

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