feat(theme): tint modal chrome surfaces
- commit
- e7fd3eb7ac95cd96dd7441ad5d8a89ec9f7619bc
- author
- brice · · 2026-07-18 08:57 UTC
- parent
- 84daa2c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 421 | 421 | func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s } | |
| 422 | 422 | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { | |
| 423 | 423 | for i := range row { | |
| 424 | - | row[i] = renderer.BlankCell() | |
| 424 | + | row[i] = renderer.Cell{Rune: ' ', Style: style} | |
| 425 | 425 | } | |
| 426 | 426 | text := " [SCROLL] " | |
| 427 | 427 | 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) { | |||
| 256 | 256 | encoded := base64.StdEncoding.EncodeToString([]byte("hello")) | |
| 257 | 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 | + | } | |
| @@ -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 ( | |||
| 7 | 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) { | |
| 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}) | |
| 13 | 13 | for y := range min(inner.Height, modalFrame.Height) { | |
| 14 | 14 | for x := range min(inner.Width, modalFrame.Width) { | |
| 15 | 15 | 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 { | |||
| 500 | 500 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | |
| 501 | 501 | styleSet := resolveStyles(styles) | |
| 502 | 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 { | |
| 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}) | |
| 505 | 505 | }) | |
| 506 | 506 | } | |
| @@ -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 { | |||
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | type capturedModal struct { | |
| 96 | - | modal ui.Modal | |
| 97 | - | inner renderer.Frame | |
| 96 | + | modal ui.Modal | |
| 97 | + | inner renderer.Frame | |
| 98 | + | focused bool | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | 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.Modal | 96 | + | modal ui.Modal |
| 97 | - | inner renderer.Frame | 97 | + | 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 ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/prompt" | |
| 12 | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 14 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| @@ -206,25 +208,29 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 206 | 208 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 207 | 209 | if snap.copySearchModel != nil { | |
| 208 | 210 | 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 | 214 | if snap.pickerActive && snap.pickerModel != nil { | |
| 212 | 215 | 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 | 218 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 215 | 219 | } | |
| 216 | 220 | if snap.paletteActive && snap.paletteModel != nil { | |
| 217 | 221 | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 222 | + | o.palette.focused = true | |
| 218 | 223 | guidance := "" | |
| 219 | 224 | if snap.paletteHints != nil { | |
| 220 | 225 | guidance = snap.paletteHints.Feedback | |
| 221 | 226 | } | |
| 222 | 227 | 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 | 230 | if snap.promptActive && snap.promptModel != nil { | |
| 226 | 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 | 235 | state.cursor.hiddenByOverlay = o.active() | |
| 230 | 236 | } | |
| @@ -259,7 +265,11 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 259 | 265 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 260 | 266 | 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 | 273 | for y := range min(inner.Height, modal.inner.Height) { | |
| 264 | 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 | } | |
| @@ -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 = copySearchModal | 210 | 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 = pickerModal | 215 | 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.Feedback | 225 | guidance = snap.paletteHints.Feedback | ||
| 221 | } | 226 | } | ||
| 222 | o.paletteGuidance = snap.paletteFeedback | 227 | 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 | continue | 266 | 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 ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | 15 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/ui" | |
| 18 | 19 | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 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 | + | } | |
| @@ -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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 18 | Selection renderer.Style | |
| 15 | 19 | Description renderer.Style | |
| 16 | 20 | } | |
| @@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles { | |||
| 53 | 57 | selection.Inverse = true | |
| 54 | 58 | description := renderer.DefaultStyle() | |
| 55 | 59 | 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 | 64 | func (m *Model) Insert(r rune) { | |
| @@ -235,11 +240,14 @@ func (m *Model) clamp() { | |||
| 235 | 240 | ||
| 236 | 241 | func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |
| 237 | 242 | styles := opts.Styles | |
| 243 | + | if styles == (RenderStyles{}) { | |
| 244 | + | styles = DefaultRenderStyles() | |
| 245 | + | } | |
| 238 | 246 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 239 | 247 | if frame.Width == 0 || frame.Height == 0 { | |
| 240 | 248 | return frame | |
| 241 | 249 | } | |
| 242 | - | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description | |
| 250 | + | base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description | |
| 243 | 251 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 244 | 252 | if m == nil { | |
| 245 | 253 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| @@ -269,7 +277,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 269 | 277 | break | |
| 270 | 278 | } | |
| 271 | 279 | match := m.matches[idx] | |
| 272 | - | style := base | |
| 280 | + | style := row | |
| 273 | 281 | if idx == m.selected { | |
| 274 | 282 | style = selection | |
| 275 | 283 | } | |
| @@ -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.Style | 18 | Selection renderer.Style | ||
| 15 | Description renderer.Style | 19 | Description renderer.Style | ||
| 16 | } | 20 | } | ||
| @@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles { | |||||
| 53 | selection.Inverse = true | 57 | selection.Inverse = true | ||
| 54 | description := renderer.DefaultStyle() | 58 | description := renderer.DefaultStyle() | ||
| 55 | description.Italic = true | 59 | 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.Styles | 242 | 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 frame | 248 | return frame | ||
| 241 | } | 249 | } | ||
| 242 | - | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description | 250 | + | 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 | break | 277 | break | ||
| 270 | } | 278 | } | ||
| 271 | match := m.matches[idx] | 279 | match := m.matches[idx] | ||
| 272 | - | style := base | 280 | + | style := row |
| 273 | if idx == m.selected { | 281 | if idx == m.selected { | ||
| 274 | style = selection | 282 | style = selection | ||
| 275 | } | 283 | } | ||
M internal/usecase/palette/model_test.go +12 -0
| @@ -400,3 +400,15 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |||
| 400 | 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 | + | } | |
| @@ -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 { | |||
| 42 | 42 | SelectionMuted renderer.Style // selected row detail segment | |
| 43 | 43 | Name renderer.Style // non-selected name segment | |
| 44 | 44 | 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 | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | func defaultRenderStyles() RenderStyles { | |
| @@ -52,7 +55,7 @@ func defaultRenderStyles() RenderStyles { | |||
| 52 | 55 | base := renderer.DefaultStyle() | |
| 53 | 56 | separator := renderer.DefaultStyle() | |
| 54 | 57 | 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 | 61 | type Target struct { | |
| @@ -203,6 +206,7 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle | |||
| 203 | 206 | if len(styles) > 0 { | |
| 204 | 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 | 210 | m.renderList(frame, layout.List, styleSet) | |
| 207 | 211 | switch layout.Mode { | |
| 208 | 212 | case LayoutHorizontal: | |
| @@ -42,8 +42,11 @@ type RenderStyles struct { | |||||
| 42 | SelectionMuted renderer.Style // selected row detail segment | 42 | SelectionMuted renderer.Style // selected row detail segment | ||
| 43 | Name renderer.Style // non-selected name segment | 43 | Name renderer.Style // non-selected name segment | ||
| 44 | Detail renderer.Style // non-selected detail segment | 44 | Detail renderer.Style // non-selected detail segment | ||
| 45 | - | Base renderer.Style // non-selected fill + suffixes | 45 | + | // Background fills otherwise unused interior cells. Base owns ordinary |
| 46 | - | Separator renderer.Style // preview separator | 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 | |||
| 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.AttrDim | 57 | 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) { | |||
| 332 | 332 | func cell(r rune) renderer.Cell { | |
| 333 | 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 | + | } | |
| @@ -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 ( | |||
| 6 | 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 | 17 | type Model struct { | |
| 10 | 18 | title string | |
| 11 | 19 | text ui.TextInput | |
| @@ -55,20 +63,25 @@ func (m *Model) SetError(msg string) { | |||
| 55 | 63 | } | |
| 56 | 64 | ||
| 57 | 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 | 77 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 59 | 78 | if frame.Width == 0 || frame.Height == 0 { | |
| 60 | 79 | return frame | |
| 61 | 80 | } | |
| 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) | |
| 70 | 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 | 86 | return frame | |
| 74 | 87 | } | |
| @@ -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 string | 18 | title string | ||
| 11 | text ui.TextInput | 19 | 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 frame | 79 | 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 := base | 82 | + | 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 frame | 86 | return frame | ||
| 74 | } | 87 | } | ||
M internal/usecase/prompt/model_test.go +10 -0
| @@ -57,3 +57,13 @@ func TestModelRenderHandlesTinySize(t *testing.T) { | |||
| 57 | 57 | require.Equal(t, 0, frame.Width) | |
| 58 | 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 | + | } | |
| @@ -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 { | |||
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 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 { | |
| 54 | 56 | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 55 | 57 | inner := domain.Rect{ | |
| 56 | 58 | X: bounds.X + 1, | |
| @@ -59,7 +61,7 @@ func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |||
| 59 | 61 | Height: max(0, bounds.Height-2), | |
| 60 | 62 | } | |
| 61 | 63 | DrawBox(f, bounds, border) | |
| 62 | - | FillRect(f, inner, renderer.BlankCell()) | |
| 64 | + | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 63 | 65 | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 64 | 66 | left := bounds.X + 1 | |
| 65 | 67 | 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 returns | 51 | + | // 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 + 1 | 66 | left := bounds.X + 1 | ||
| 65 | right := bounds.X + bounds.Width - 1 | 67 | right := bounds.X + bounds.Width - 1 | ||
M internal/usecase/ui/modal_test.go +34 -4
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | func TestModalBounds(t *testing.T) { | |
| @@ -130,9 +131,10 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 130 | 131 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 131 | 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 | 136 | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |
| 135 | - | inner := m.Composite(f, style) | |
| 137 | + | inner := m.Composite(f, border, interior) | |
| 136 | 138 | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |
| 137 | 139 | if inner != wantInner { | |
| 138 | 140 | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |
| @@ -149,11 +151,15 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 149 | 151 | ||
| 150 | 152 | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |
| 151 | 153 | 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) | |
| 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 | 163 | assertCell(t, f, 0, 0, exterior) | |
| 158 | 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 | 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 | + | } | |
| @@ -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() { | |||
| 133 | 133 | ||
| 134 | 134 | 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 | 142 | func (m *Model) ensureVisible(visible int) { | |
| 137 | 143 | if m == nil || visible <= 0 || len(m.matches) == 0 { | |
| 138 | 144 | return | |
| @@ -146,16 +152,22 @@ func (m *Model) ensureVisible(visible int) { | |||
| 146 | 152 | } | |
| 147 | 153 | ||
| 148 | 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 | 155 | base := renderer.DefaultStyle() | |
| 154 | 156 | selection := base | |
| 155 | 157 | selection.Inverse = true | |
| 156 | 158 | if len(selectedStyle) > 0 { | |
| 157 | 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 | 171 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 160 | 172 | ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection) | |
| 161 | 173 | visible := frame.Height - 1 | |
| @@ -133,6 +133,12 @@ func (m *Model) clamp() { | |||||
| 133 | 133 | ||||
| 134 | const defaultVisibleRows = 10 | 134 | 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 | return | 144 | 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 := base | 156 | selection := base | ||
| 155 | selection.Inverse = true | 157 | 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 - 1 | 173 | visible := frame.Height - 1 | ||
M internal/usecase/visualsearch/model_test.go +11 -0
| @@ -186,3 +186,14 @@ func rowText(row []renderer.Cell) string { | |||
| 186 | 186 | } | |
| 187 | 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 | + | } | |
| @@ -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 | + | } | |||