detached 956165e

refactor(overlays): capture resolved modal layouts

commit
956165edd456c2ed905f8b1fd75156c226c55cb8
author
brice · · 2026-07-27 15:37 UTC
committer
brice · · 2026-07-27 17:06 UTC
parent
5014839
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +200 -143
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
100100 func TestCopySearchModalGeometry(t *testing.T) {
101101 base := domain.Size{Cols: 100, Rows: 40}
102102
103- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))
103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds)
104104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106106 }
@@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
100 func TestCopySearchModalGeometry(t *testing.T) {100 func TestCopySearchModalGeometry(t *testing.T) {
101 base := domain.Size{Cols: 100, Rows: 40}101 base := domain.Size{Cols: 100, Rows: 40}
102 102
103- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds)
104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106 }106 }
M internal/usecase/daemon/modal_compose.go +4 -7
@@ -8,12 +8,9 @@ import (
88 )
99
1010 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- for y := range min(inner.Height, modalFrame.Height) {
14- for x := range min(inner.Width, modalFrame.Width) {
15- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
16- }
17- }
11+ presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height})
12+ modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase)
13+ modalFrame := renderInner(rectSize(presentation.Inner))
14+ copyModalInner(base, presentation.Inner, modalFrame)
1815 return base, []renderer.Damage{renderer.FullRedraw()}
1916 }
@@ -8,12 +8,9 @@ import (
8 )8 )
9 9
10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) 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.BorderMuted, styles.PickerBase)11+ presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height})
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})12+ modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase)
13- for y := range min(inner.Height, modalFrame.Height) {13+ modalFrame := renderInner(rectSize(presentation.Inner))
14- for x := range min(inner.Width, modalFrame.Width) {14+ copyModalInner(base, presentation.Inner, modalFrame)
15- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
16- }
17- }
18 return base, []renderer.Damage{renderer.FullRedraw()}15 return base, []renderer.Damage{renderer.FullRedraw()}
19 }16 }
M internal/usecase/daemon/palette_test.go +33 -3
@@ -735,10 +735,12 @@ func TestPaletteModalGeometry(t *testing.T) {
735735
736736 auto := domain.PaletteConfig{}
737737 tests := []testCase{
738+ {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
739+ {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}},
738740 {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
739741 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
740742 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
741- {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
743+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
742744 }
743745 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
744746 wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
@@ -752,7 +754,15 @@ func TestPaletteModalGeometry(t *testing.T) {
752754 for _, tt := range tests {
753755 t.Run(tt.name, func(t *testing.T) {
754756 modal := paletteModalFor(tt.size, tt.cfg)
755- require.Equal(t, tt.want, modal.Bounds(tt.size))
757+ presentation := modal.Resolve(tt.size)
758+ require.Equal(t, tt.want, presentation.Bounds)
759+ if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
760+ require.Equal(t, ui.PresentationDrawer, presentation.Mode)
761+ require.Equal(t, ui.BorderTop, presentation.Borders)
762+ } else {
763+ require.Equal(t, ui.PresentationFloating, presentation.Mode)
764+ require.Equal(t, ui.BorderAll, presentation.Borders)
765+ }
756766 require.Equal(t, " Commands ", modal.Title)
757767 require.Equal(t, 11, modal.FixedHeight)
758768 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -760,6 +770,26 @@ func TestPaletteModalGeometry(t *testing.T) {
760770 }
761771 }
762772
773+func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
774+ model := palette.New(nil)
775+ for _, tt := range []struct {
776+ name string
777+ width int
778+ topLeft rune
779+ innerLeft rune
780+ }{
781+ {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
782+ {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
783+ } {
784+ t.Run(tt.name, func(t *testing.T) {
785+ frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
786+
787+ require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
788+ require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
789+ })
790+ }
791+}
792+
763793 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
764794 model := palette.New(nil)
765795 base := renderer.NewFrame(120, 40)
@@ -785,7 +815,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
785815 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
786816
787817 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
788- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)
818+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
789819
790820 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
791821 }
@@ -735,10 +735,12 @@ func TestPaletteModalGeometry(t *testing.T) {
735 735
736 auto := domain.PaletteConfig{}736 auto := domain.PaletteConfig{}
737 tests := []testCase{737 tests := []testCase{
738+ {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
739+ {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}},
738 {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},740 {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
739 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},741 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
740 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},742 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
741- {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},743+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
742 }744 }
743 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {745 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
744 wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]746 wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
@@ -752,7 +754,15 @@ func TestPaletteModalGeometry(t *testing.T) {
752 for _, tt := range tests {754 for _, tt := range tests {
753 t.Run(tt.name, func(t *testing.T) {755 t.Run(tt.name, func(t *testing.T) {
754 modal := paletteModalFor(tt.size, tt.cfg)756 modal := paletteModalFor(tt.size, tt.cfg)
755- require.Equal(t, tt.want, modal.Bounds(tt.size))757+ presentation := modal.Resolve(tt.size)
758+ require.Equal(t, tt.want, presentation.Bounds)
759+ if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
760+ require.Equal(t, ui.PresentationDrawer, presentation.Mode)
761+ require.Equal(t, ui.BorderTop, presentation.Borders)
762+ } else {
763+ require.Equal(t, ui.PresentationFloating, presentation.Mode)
764+ require.Equal(t, ui.BorderAll, presentation.Borders)
765+ }
756 require.Equal(t, " Commands ", modal.Title)766 require.Equal(t, " Commands ", modal.Title)
757 require.Equal(t, 11, modal.FixedHeight)767 require.Equal(t, 11, modal.FixedHeight)
758 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)768 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -760,6 +770,26 @@ func TestPaletteModalGeometry(t *testing.T) {
760 }770 }
761 }771 }
762 772
773+func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
774+ model := palette.New(nil)
775+ for _, tt := range []struct {
776+ name string
777+ width int
778+ topLeft rune
779+ innerLeft rune
780+ }{
781+ {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
782+ {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
783+ } {
784+ t.Run(tt.name, func(t *testing.T) {
785+ frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
786+
787+ require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
788+ require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
789+ })
790+ }
791+}
792+
763 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {793 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
764 model := palette.New(nil)794 model := palette.New(nil)
765 base := renderer.NewFrame(120, 40)795 base := renderer.NewFrame(120, 40)
@@ -785,7 +815,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
785 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles815 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
786 816
787 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)817 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
788- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)818+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
789 819
790 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))820 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
791 }821 }
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
10161016 func TestPickerModalGeometry(t *testing.T) {
10171017 base := domain.Size{Cols: 100, Rows: 40}
10181018
1019- require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
1019+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
10201020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
10211021 }
10221022
@@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
1016 func TestPickerModalGeometry(t *testing.T) {1016 func TestPickerModalGeometry(t *testing.T) {
1017 base := domain.Size{Cols: 100, Rows: 40}1017 base := domain.Size{Cols: 100, Rows: 40}
1018 1018
1019- require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))1019+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
1020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)1020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
1021 }1021 }
1022 1022
M internal/usecase/daemon/prompt_test.go +1 -1
@@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str
2020 func TestPromptModalGeometry(t *testing.T) {
2121 base := domain.Size{Cols: 100, Rows: 40}
2222
23- require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))
23+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
2424 }
2525
2626 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
@@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str
20 func TestPromptModalGeometry(t *testing.T) {20 func TestPromptModalGeometry(t *testing.T) {
21 base := domain.Size{Cols: 100, Rows: 40}21 base := domain.Size{Cols: 100, Rows: 40}
22 22
23- require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))23+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
24 }24 }
25 25
26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
M internal/usecase/daemon/render_capture.go +5 -3
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
102102 }
103103
104104 type capturedModal struct {
105- modal ui.Modal
106- inner renderer.Frame
107- focused bool
105+ active bool
106+ title string
107+ presentation ui.Presentation
108+ inner renderer.Frame
109+ focused bool
108110 }
109111
110112 func (o capturedOverlayRenderState) active() bool {
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
102 }102 }
103 103
104 type capturedModal struct {104 type capturedModal struct {
105- modal ui.Modal105+ active bool
106- inner renderer.Frame106+ title string
107- focused bool107+ presentation ui.Presentation
108+ inner renderer.Frame
109+ focused bool
108 }110 }
109 111
110 func (o capturedOverlayRenderState) active() bool {112 func (o capturedOverlayRenderState) active() bool {
M internal/usecase/daemon/render_pipeline.go +44 -20
@@ -222,36 +222,38 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222222 }
223223 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
224224 if snap.copySearchModel != nil {
225- o.copySearch.modal = copySearchModal
226- o.copySearch.focused = true
227- o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
225+ presentation := copySearchModal.Resolve(size)
226+ o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
227+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
228228 }
229229 if snap.pickerActive && snap.pickerModel != nil {
230- o.picker.modal = pickerModal
231- o.picker.focused = true
230+ presentation := pickerModal.Resolve(size)
231+ o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
232232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}
233- o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
233+ o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
234234 }
235235 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236- o.noticesOverlay.modal = noticesModal
237- o.noticesOverlay.focused = true
236+ presentation := noticesModal.Resolve(size)
237+ o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
238238 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
239- o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)
239+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
240240 }
241241 if snap.paletteActive && snap.paletteModel != nil {
242- o.palette.modal = paletteModalFor(size, paletteCfg)
243- o.palette.focused = true
242+ modal := paletteModalFor(size, paletteCfg)
243+ presentation := modal.Resolve(size)
244+ o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
244245 guidance := ""
245246 if snap.paletteHints != nil {
246247 guidance = snap.paletteHints.Feedback
247248 }
248249 o.paletteGuidance = snap.paletteFeedback
249- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
250+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
250251 }
251252 if snap.promptActive && snap.promptModel != nil {
252- o.prompt.modal = promptModalFor(snap.promptModel.Title())
253- o.prompt.focused = true
254- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
253+ modal := promptModalFor(snap.promptModel.Title())
254+ presentation := modal.Resolve(size)
255+ o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
256+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255257 }
256258 state.cursor.hiddenByOverlay = o.active()
257259 }
@@ -291,22 +293,44 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
291293 // mismatch would let the picker own the keyboard while notices visually
292294 // covers it.
293295 for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
294- if modal.inner.Width == 0 && modal.inner.Height == 0 {
296+ if !modal.active {
295297 continue
296298 }
297299 border := state.styles.BorderMuted
298300 if modal.focused {
299301 border = state.styles.BorderActive
300302 }
301- inner := modal.modal.Composite(frame, border, state.styles.PickerBase)
302- for y := range min(inner.Height, modal.inner.Height) {
303- 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)])
304- }
303+ (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
304+ copyModalInner(frame, modal.presentation.Inner, modal.inner)
305305 damage = []renderer.Damage{renderer.FullRedraw()}
306306 }
307307 return frame, damage
308308 }
309309
310+// copyModalInner copies a captured model frame into its resolved destination.
311+// Both source and destination are clipped so degenerate presentations remain
312+// safe immutable composition inputs.
313+func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
314+ left := max(target.X, 0)
315+ top := max(target.Y, 0)
316+ right := min(target.X+target.Width, dst.Width)
317+ bottom := min(target.Y+target.Height, dst.Height)
318+ if left >= right || top >= bottom {
319+ return
320+ }
321+
322+ sourceX := left - target.X
323+ sourceY := top - target.Y
324+ width := min(right-left, src.Width-sourceX)
325+ height := min(bottom-top, src.Height-sourceY)
326+ if width <= 0 || height <= 0 {
327+ return
328+ }
329+ for y := range height {
330+ copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
331+ }
332+}
333+
310334 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311335 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312336 return nil
@@ -222,36 +222,38 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222 }222 }
223 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}223 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
224 if snap.copySearchModel != nil {224 if snap.copySearchModel != nil {
225- o.copySearch.modal = copySearchModal225+ presentation := copySearchModal.Resolve(size)
226- o.copySearch.focused = true226+ o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
227- o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})227+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
228 }228 }
229 if snap.pickerActive && snap.pickerModel != nil {229 if snap.pickerActive && snap.pickerModel != nil {
230- o.picker.modal = pickerModal230+ presentation := pickerModal.Resolve(size)
231- o.picker.focused = true231+ o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}
233- o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)233+ o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
234 }234 }
235 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {235 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236- o.noticesOverlay.modal = noticesModal236+ presentation := noticesModal.Resolve(size)
237- o.noticesOverlay.focused = true237+ o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
238 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}238 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
239- o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)239+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
240 }240 }
241 if snap.paletteActive && snap.paletteModel != nil {241 if snap.paletteActive && snap.paletteModel != nil {
242- o.palette.modal = paletteModalFor(size, paletteCfg)242+ modal := paletteModalFor(size, paletteCfg)
243- o.palette.focused = true243+ presentation := modal.Resolve(size)
244+ o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
244 guidance := ""245 guidance := ""
245 if snap.paletteHints != nil {246 if snap.paletteHints != nil {
246 guidance = snap.paletteHints.Feedback247 guidance = snap.paletteHints.Feedback
247 }248 }
248 o.paletteGuidance = snap.paletteFeedback249 o.paletteGuidance = snap.paletteFeedback
249- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})250+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
250 }251 }
251 if snap.promptActive && snap.promptModel != nil {252 if snap.promptActive && snap.promptModel != nil {
252- o.prompt.modal = promptModalFor(snap.promptModel.Title())253+ modal := promptModalFor(snap.promptModel.Title())
253- o.prompt.focused = true254+ presentation := modal.Resolve(size)
254- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})255+ o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
256+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255 }257 }
256 state.cursor.hiddenByOverlay = o.active()258 state.cursor.hiddenByOverlay = o.active()
257 }259 }
@@ -291,22 +293,44 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
291 // mismatch would let the picker own the keyboard while notices visually293 // mismatch would let the picker own the keyboard while notices visually
292 // covers it.294 // covers it.
293 for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {295 for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
294- if modal.inner.Width == 0 && modal.inner.Height == 0 {296+ if !modal.active {
295 continue297 continue
296 }298 }
297 border := state.styles.BorderMuted299 border := state.styles.BorderMuted
298 if modal.focused {300 if modal.focused {
299 border = state.styles.BorderActive301 border = state.styles.BorderActive
300 }302 }
301- inner := modal.modal.Composite(frame, border, state.styles.PickerBase)303+ (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
302- for y := range min(inner.Height, modal.inner.Height) {304+ copyModalInner(frame, modal.presentation.Inner, modal.inner)
303- 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)])
304- }
305 damage = []renderer.Damage{renderer.FullRedraw()}305 damage = []renderer.Damage{renderer.FullRedraw()}
306 }306 }
307 return frame, damage307 return frame, damage
308 }308 }
309 309
310+// copyModalInner copies a captured model frame into its resolved destination.
311+// Both source and destination are clipped so degenerate presentations remain
312+// safe immutable composition inputs.
313+func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
314+ left := max(target.X, 0)
315+ top := max(target.Y, 0)
316+ right := min(target.X+target.Width, dst.Width)
317+ bottom := min(target.Y+target.Height, dst.Height)
318+ if left >= right || top >= bottom {
319+ return
320+ }
321+
322+ sourceX := left - target.X
323+ sourceY := top - target.Y
324+ width := min(right-left, src.Width-sourceX)
325+ height := min(bottom-top, src.Height-sourceY)
326+ if width <= 0 || height <= 0 {
327+ return
328+ }
329+ for y := range height {
330+ copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
331+ }
332+}
333+
310 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {334 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {335 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312 return nil336 return nil
M internal/usecase/daemon/render_pipeline_test.go +77 -5
@@ -9,13 +9,20 @@ import (
99 "path/filepath"
1010 "strings"
1111 "testing"
12+ "time"
1213
1314 "github.com/bnema/vev/internal/domain"
1415 "github.com/bnema/vev/internal/ports"
1516 "github.com/bnema/vev/internal/testutil/replaytest"
17+ scopy "github.com/bnema/vev/internal/usecase/copy"
1618 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/internal/usecase/notices"
20+ "github.com/bnema/vev/internal/usecase/palette"
21+ "github.com/bnema/vev/internal/usecase/picker"
22+ promptui "github.com/bnema/vev/internal/usecase/prompt"
1723 themeui "github.com/bnema/vev/internal/usecase/theme"
1824 "github.com/bnema/vev/internal/usecase/ui"
25+ "github.com/bnema/vev/internal/usecase/visualsearch"
1926 "github.com/bnema/vev/pkg/renderer"
2027 "github.com/bnema/vev/pkg/vt"
2128 "github.com/stretchr/testify/require"
@@ -222,19 +229,19 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
222229 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
223230 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
224231 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
225- overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
232+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
226233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227234 styles: resolveStyles(nil),
228235 }
229236 composed := composeFrame(state, composeCacheInput{})
230- require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
237+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame))
231238 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
232239
233240 stream := newOutputStateStream()
234241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
235242 require.NoError(t, err)
236243 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
237- require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
244+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
238245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
239246 client.Write(terminalBytes)
240247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
@@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
298305 active := renderer.Style{Foreground: 3, Background: 4}
299306 interior := renderer.Style{Foreground: 5, Background: 6}
300307 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
308+ presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
301309
302310 for _, tt := range []struct {
303311 name string
@@ -310,16 +318,80 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
310318 t.Run(tt.name, func(t *testing.T) {
311319 state := capturedRenderState{
312320 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313- overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
321+ overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314322 }
315323 frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316- bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})
324+ bounds := presentation.Bounds
317325 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318326 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319327 })
320328 }
321329 }
322330
331+func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
332+ state := capturedRenderState{
333+ layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
334+ styles: fallbackChromeStyles,
335+ }
336+ snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
337+ snap := &overlayRenderSnapshot{
338+ copySearchModel: visualsearch.New(snapshot),
339+ pickerActive: true,
340+ pickerModel: picker.New(nil, "", 0),
341+ noticesOverlayActive: true,
342+ noticesOverlayModel: notices.New(nil, time.Time{}),
343+ paletteActive: true,
344+ paletteModel: palette.New(nil),
345+ promptActive: true,
346+ promptModel: promptui.New(" Prompt ", ""),
347+ }
348+
349+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
350+
351+ for name, modal := range map[string]capturedModal{
352+ "copy search": state.overlays.copySearch,
353+ "picker": state.overlays.picker,
354+ "notices": state.overlays.noticesOverlay,
355+ "palette": state.overlays.palette,
356+ "prompt": state.overlays.prompt,
357+ } {
358+ t.Run(name, func(t *testing.T) {
359+ require.True(t, modal.active)
360+ require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
361+ require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
362+ })
363+ }
364+}
365+
366+func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
367+ base := renderer.NewFrame(4, 3)
368+ inner := renderer.NewFrame(4, 2)
369+ for y, text := range []string{"ABCD", "EFGH"} {
370+ for x, r := range text {
371+ inner.Set(x, y, renderer.Cell{Rune: r})
372+ }
373+ }
374+
375+ copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
376+
377+ require.Equal(t, "BCD ", rowText(base.Row(2)))
378+}
379+
380+func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
381+ presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
382+ require.Zero(t, presentation.Bounds.Height)
383+ state := capturedRenderState{
384+ styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
385+ overlays: capturedOverlayRenderState{
386+ copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
387+ },
388+ }
389+
390+ _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil, domain.Rect{})
391+
392+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
393+}
394+
323395 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324396 terminal := renderer.DefaultStyle()
325397 muted := renderer.Style{Foreground: 1}
@@ -9,13 +9,20 @@ import (
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/testutil/replaytest"16 "github.com/bnema/vev/internal/testutil/replaytest"
17+ scopy "github.com/bnema/vev/internal/usecase/copy"
16 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/internal/usecase/notices"
20+ "github.com/bnema/vev/internal/usecase/palette"
21+ "github.com/bnema/vev/internal/usecase/picker"
22+ promptui "github.com/bnema/vev/internal/usecase/prompt"
17 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
18 "github.com/bnema/vev/internal/usecase/ui"24 "github.com/bnema/vev/internal/usecase/ui"
25+ "github.com/bnema/vev/internal/usecase/visualsearch"
19 "github.com/bnema/vev/pkg/renderer"26 "github.com/bnema/vev/pkg/renderer"
20 "github.com/bnema/vev/pkg/vt"27 "github.com/bnema/vev/pkg/vt"
21 "github.com/stretchr/testify/require"28 "github.com/stretchr/testify/require"
@@ -222,19 +229,19 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
222 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},229 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
223 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},230 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
224 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},231 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
225- overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},232+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
226 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227 styles: resolveStyles(nil),234 styles: resolveStyles(nil),
228 }235 }
229 composed := composeFrame(state, composeCacheInput{})236 composed := composeFrame(state, composeCacheInput{})
230- require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))237+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame))
231 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")238 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
232 239
233 stream := newOutputStateStream()240 stream := newOutputStateStream()
234 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
235 require.NoError(t, err)242 require.NoError(t, err)
236 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)243 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
237- require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))244+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
238 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
239 client.Write(terminalBytes)246 client.Write(terminalBytes)
240 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
@@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
298 active := renderer.Style{Foreground: 3, Background: 4}305 active := renderer.Style{Foreground: 3, Background: 4}
299 interior := renderer.Style{Foreground: 5, Background: 6}306 interior := renderer.Style{Foreground: 5, Background: 6}
300 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}307 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
308+ presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
301 309
302 for _, tt := range []struct {310 for _, tt := range []struct {
303 name string311 name string
@@ -310,16 +318,80 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
310 t.Run(tt.name, func(t *testing.T) {318 t.Run(tt.name, func(t *testing.T) {
311 state := capturedRenderState{319 state := capturedRenderState{
312 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},320 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313- overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},321+ overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314 }322 }
315 frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})323 frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316- bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})324+ bounds := presentation.Bounds
317 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")325 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")326 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319 })327 })
320 }328 }
321 }329 }
322 330
331+func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
332+ state := capturedRenderState{
333+ layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
334+ styles: fallbackChromeStyles,
335+ }
336+ snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
337+ snap := &overlayRenderSnapshot{
338+ copySearchModel: visualsearch.New(snapshot),
339+ pickerActive: true,
340+ pickerModel: picker.New(nil, "", 0),
341+ noticesOverlayActive: true,
342+ noticesOverlayModel: notices.New(nil, time.Time{}),
343+ paletteActive: true,
344+ paletteModel: palette.New(nil),
345+ promptActive: true,
346+ promptModel: promptui.New(" Prompt ", ""),
347+ }
348+
349+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
350+
351+ for name, modal := range map[string]capturedModal{
352+ "copy search": state.overlays.copySearch,
353+ "picker": state.overlays.picker,
354+ "notices": state.overlays.noticesOverlay,
355+ "palette": state.overlays.palette,
356+ "prompt": state.overlays.prompt,
357+ } {
358+ t.Run(name, func(t *testing.T) {
359+ require.True(t, modal.active)
360+ require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
361+ require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
362+ })
363+ }
364+}
365+
366+func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
367+ base := renderer.NewFrame(4, 3)
368+ inner := renderer.NewFrame(4, 2)
369+ for y, text := range []string{"ABCD", "EFGH"} {
370+ for x, r := range text {
371+ inner.Set(x, y, renderer.Cell{Rune: r})
372+ }
373+ }
374+
375+ copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
376+
377+ require.Equal(t, "BCD ", rowText(base.Row(2)))
378+}
379+
380+func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
381+ presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
382+ require.Zero(t, presentation.Bounds.Height)
383+ state := capturedRenderState{
384+ styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
385+ overlays: capturedOverlayRenderState{
386+ copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
387+ },
388+ }
389+
390+ _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil, domain.Rect{})
391+
392+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
393+}
394+
323 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {395 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324 terminal := renderer.DefaultStyle()396 terminal := renderer.DefaultStyle()
325 muted := renderer.Style{Foreground: 1}397 muted := renderer.Style{Foreground: 1}
M internal/usecase/ui/modal.go +2 -21
@@ -18,8 +18,7 @@ type Modal struct {
1818 FixedHeight int
1919 }
2020
21-// Bounds returns the modal rectangle positioned within base and clamped to base.
22-func (m Modal) Bounds(base domain.Size) domain.Rect {
21+func (m Modal) preferredBounds(base domain.Size) domain.Rect {
2322 width := percentOf(base.Cols, m.WidthPct)
2423 height := percentOf(base.Rows, m.HeightPct)
2524 if m.FixedWidth > 0 {
@@ -37,31 +36,13 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
3736 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
3837 }
3938
40-// Inner returns the modal content rectangle after removing a one-cell border.
41-func (m Modal) Inner(base domain.Size) domain.Rect {
42- return modalInner(m.Bounds(base))
43-}
44-
4539 // Resolve computes the modal's responsive presentation from one preferred
4640 // bounds and inner pair.
4741 func (m Modal) Resolve(base domain.Size) Presentation {
48- bounds := m.Bounds(base)
42+ bounds := m.preferredBounds(base)
4943 return ResolvePresentation(base, bounds, modalInner(bounds))
5044 }
5145
52-// Composite draws the modal border and title, fills its interior, and returns
53-// the inner content rectangle. Border and interior styles are deliberately
54-// independent so unfocused structure and chrome surfaces retain their roles.
55-// Cells outside the modal bounds are not changed.
56-func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
57- bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
58- inner := modalInner(bounds)
59- DrawBox(f, bounds, border)
60- FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
61- m.drawTitle(f, bounds, border)
62- return inner
63-}
64-
6546 // CompositePresentation draws an already-resolved modal presentation and
6647 // returns its inner content rectangle.
6748 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
@@ -18,8 +18,7 @@ type Modal struct {
18 FixedHeight int18 FixedHeight int
19 }19 }
20 20
21-// Bounds returns the modal rectangle positioned within base and clamped to base.21+func (m Modal) preferredBounds(base domain.Size) domain.Rect {
22-func (m Modal) Bounds(base domain.Size) domain.Rect {
23 width := percentOf(base.Cols, m.WidthPct)22 width := percentOf(base.Cols, m.WidthPct)
24 height := percentOf(base.Rows, m.HeightPct)23 height := percentOf(base.Rows, m.HeightPct)
25 if m.FixedWidth > 0 {24 if m.FixedWidth > 0 {
@@ -37,31 +36,13 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
37 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)36 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
38 }37 }
39 38
40-// Inner returns the modal content rectangle after removing a one-cell border.
41-func (m Modal) Inner(base domain.Size) domain.Rect {
42- return modalInner(m.Bounds(base))
43-}
44-
45 // Resolve computes the modal's responsive presentation from one preferred39 // Resolve computes the modal's responsive presentation from one preferred
46 // bounds and inner pair.40 // bounds and inner pair.
47 func (m Modal) Resolve(base domain.Size) Presentation {41 func (m Modal) Resolve(base domain.Size) Presentation {
48- bounds := m.Bounds(base)42+ bounds := m.preferredBounds(base)
49 return ResolvePresentation(base, bounds, modalInner(bounds))43 return ResolvePresentation(base, bounds, modalInner(bounds))
50 }44 }
51 45
52-// Composite draws the modal border and title, fills its interior, and returns
53-// the inner content rectangle. Border and interior styles are deliberately
54-// independent so unfocused structure and chrome surfaces retain their roles.
55-// Cells outside the modal bounds are not changed.
56-func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
57- bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
58- inner := modalInner(bounds)
59- DrawBox(f, bounds, border)
60- FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
61- m.drawTitle(f, bounds, border)
62- return inner
63-}
64-
65 // CompositePresentation draws an already-resolved modal presentation and46 // CompositePresentation draws an already-resolved modal presentation and
66 // returns its inner content rectangle.47 // returns its inner content rectangle.
67 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {48 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
M internal/usecase/ui/modal_test.go +32 -81
@@ -8,7 +8,7 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestModalBounds(t *testing.T) {
11+func TestModalResolveBounds(t *testing.T) {
1212 tests := []struct {
1313 name string
1414 base domain.Size
@@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) {
2222 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
2323 },
2424 {
25- name: "minimum clamps to base",
25+ name: "minimum clamps to drawer availability",
2626 base: domain.Size{Cols: 20, Rows: 6},
2727 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28- want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},
28+ want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
2929 },
3030 {
31- name: "odd centering rounds down",
31+ name: "narrow preferred height becomes drawer height",
3232 base: domain.Size{Cols: 11, Rows: 7},
3333 m: Modal{WidthPct: 50, HeightPct: 50},
34- want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},
34+ want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
3535 },
3636 {
37- name: "zero value fills base centered",
37+ name: "zero value fills available drawer area",
3838 base: domain.Size{Cols: 12, Rows: 8},
3939 m: Modal{},
40- want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8},
40+ want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
4141 },
4242 {
4343 name: "bottom anchor honors bottom margin",
@@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) {
6363 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
6464 },
6565 {
66- name: "right margin clamps to screen",
66+ name: "drawer ignores right margin",
6767 base: domain.Size{Cols: 20, Rows: 10},
6868 m: Modal{
6969 FixedWidth: 8,
@@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) {
7171 Anchor: domain.AnchorRight,
7272 Margins: Margins{Right: 20},
7373 },
74- want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},
74+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
7575 },
7676 {
77- name: "right anchored fixed dimensions clamp on small terminal",
77+ name: "right anchored drawer can have zero height",
7878 base: domain.Size{Cols: 10, Rows: 4},
7979 m: Modal{
8080 FixedWidth: 30,
@@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) {
8282 Anchor: domain.AnchorRight,
8383 Margins: Margins{Right: 3},
8484 },
85- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
85+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
8686 },
8787 {
88- name: "fixed dimensions clamp on small terminal",
88+ name: "fixed drawer dimensions clamp on small terminal",
8989 base: domain.Size{Cols: 10, Rows: 4},
9090 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
91+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
9292 },
9393 {
94- name: "bottom margin clamps to screen",
94+ name: "drawer ignores bottom margin",
9595 base: domain.Size{Cols: 20, Rows: 10},
9696 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97- want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
97+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
9898 },
9999 {
100100 name: "bottom right anchor honors combined margins",
@@ -110,58 +110,16 @@ func TestModalBounds(t *testing.T) {
110110 }
111111 for _, tt := range tests {
112112 t.Run(tt.name, func(t *testing.T) {
113- if got := tt.m.Bounds(tt.base); got != tt.want {
114- t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)
113+ if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114+ t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115115 }
116116 })
117117 }
118118 }
119119
120-func TestModalInnerDegenerate(t *testing.T) {
121- m := Modal{WidthPct: 100, HeightPct: 100}
122- got := m.Inner(domain.Size{Cols: 2, Rows: 2})
123- want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
124- if got != want {
125- t.Fatalf("Inner() = %+v, want %+v", got, want)
126- }
127-}
128-
129-func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130- f := renderer.NewFrame(10, 6)
131- exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
132- FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
133-
134- border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135- interior := renderer.Style{Foreground: 3, Background: 4}
136- m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
137- inner := m.Composite(f, border, interior)
138- wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
139- if inner != wantInner {
140- t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
141- }
142-
143- assertRune(t, f, 2, 1, '┌')
144- assertRune(t, f, 7, 1, '┐')
145- assertRune(t, f, 2, 4, '└')
146- assertRune(t, f, 7, 4, '┘')
147- assertRune(t, f, 3, 1, '─')
148- assertRune(t, f, 2, 2, '│')
149- assertRune(t, f, 4, 1, 'H')
150- assertRune(t, f, 5, 1, 'i')
151-
152- for y := inner.Y; y < inner.Y+inner.Height; y++ {
153- for x := inner.X; x < inner.X+inner.Width; x++ {
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)
157- }
158- }
159- }
160- if got := f.At(2, 1).Style; !got.Equal(border) {
161- t.Fatalf("border style = %+v, want %+v", got, border)
162- }
163- assertCell(t, f, 0, 0, exterior)
164- assertCell(t, f, 9, 5, exterior)
120+func TestModalResolveInnerDegenerate(t *testing.T) {
121+ got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122+ require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
165123 }
166124
167125 func TestModalResolve(t *testing.T) {
@@ -376,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
376334 }
377335 }
378336
379-func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {
337+func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
380338 border := renderer.Style{Foreground: 1, Background: 2}
381339 interior := renderer.Style{Foreground: 3, Background: 4}
382- for _, tt := range []struct {
383- name string
384- size domain.Size
385- want domain.Rect
386- }{
387- {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},
388- {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},
389- } {
390- t.Run(tt.name, func(t *testing.T) {
391- frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)
392- got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)
393- require.Equal(t, tt.want, got)
394- for y := range frame.Height {
395- for x := range frame.Width {
396- require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
397- }
398- }
399- })
340+ frame := renderer.NewFrame(2, 2)
341+ modal := Modal{WidthPct: 100, HeightPct: 100}
342+ presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
343+
344+ got := modal.CompositePresentation(frame, presentation, border, interior)
345+
346+ require.Equal(t, presentation.Inner, got)
347+ for y := range frame.Height {
348+ for x := range frame.Width {
349+ require.Equal(t, renderer.BlankCell(), frame.At(x, y))
350+ }
400351 }
401352 }
@@ -8,7 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestModalBounds(t *testing.T) {11+func TestModalResolveBounds(t *testing.T) {
12 tests := []struct {12 tests := []struct {
13 name string13 name string
14 base domain.Size14 base domain.Size
@@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) {
22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
23 },23 },
24 {24 {
25- name: "minimum clamps to base",25+ name: "minimum clamps to drawer availability",
26 base: domain.Size{Cols: 20, Rows: 6},26 base: domain.Size{Cols: 20, Rows: 6},
27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28- want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},28+ want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
29 },29 },
30 {30 {
31- name: "odd centering rounds down",31+ name: "narrow preferred height becomes drawer height",
32 base: domain.Size{Cols: 11, Rows: 7},32 base: domain.Size{Cols: 11, Rows: 7},
33 m: Modal{WidthPct: 50, HeightPct: 50},33 m: Modal{WidthPct: 50, HeightPct: 50},
34- want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},34+ want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
35 },35 },
36 {36 {
37- name: "zero value fills base centered",37+ name: "zero value fills available drawer area",
38 base: domain.Size{Cols: 12, Rows: 8},38 base: domain.Size{Cols: 12, Rows: 8},
39 m: Modal{},39 m: Modal{},
40- want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8},40+ want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
41 },41 },
42 {42 {
43 name: "bottom anchor honors bottom margin",43 name: "bottom anchor honors bottom margin",
@@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) {
63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
64 },64 },
65 {65 {
66- name: "right margin clamps to screen",66+ name: "drawer ignores right margin",
67 base: domain.Size{Cols: 20, Rows: 10},67 base: domain.Size{Cols: 20, Rows: 10},
68 m: Modal{68 m: Modal{
69 FixedWidth: 8,69 FixedWidth: 8,
@@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) {
71 Anchor: domain.AnchorRight,71 Anchor: domain.AnchorRight,
72 Margins: Margins{Right: 20},72 Margins: Margins{Right: 20},
73 },73 },
74- want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},74+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
75 },75 },
76 {76 {
77- name: "right anchored fixed dimensions clamp on small terminal",77+ name: "right anchored drawer can have zero height",
78 base: domain.Size{Cols: 10, Rows: 4},78 base: domain.Size{Cols: 10, Rows: 4},
79 m: Modal{79 m: Modal{
80 FixedWidth: 30,80 FixedWidth: 30,
@@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) {
82 Anchor: domain.AnchorRight,82 Anchor: domain.AnchorRight,
83 Margins: Margins{Right: 3},83 Margins: Margins{Right: 3},
84 },84 },
85- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},85+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
86 },86 },
87 {87 {
88- name: "fixed dimensions clamp on small terminal",88+ name: "fixed drawer dimensions clamp on small terminal",
89 base: domain.Size{Cols: 10, Rows: 4},89 base: domain.Size{Cols: 10, Rows: 4},
90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},91+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
92 },92 },
93 {93 {
94- name: "bottom margin clamps to screen",94+ name: "drawer ignores bottom margin",
95 base: domain.Size{Cols: 20, Rows: 10},95 base: domain.Size{Cols: 20, Rows: 10},
96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97- want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},97+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
98 },98 },
99 {99 {
100 name: "bottom right anchor honors combined margins",100 name: "bottom right anchor honors combined margins",
@@ -110,58 +110,16 @@ func TestModalBounds(t *testing.T) {
110 }110 }
111 for _, tt := range tests {111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {112 t.Run(tt.name, func(t *testing.T) {
113- if got := tt.m.Bounds(tt.base); got != tt.want {113+ if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114- t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)114+ t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115 }115 }
116 })116 })
117 }117 }
118 }118 }
119 119
120-func TestModalInnerDegenerate(t *testing.T) {120+func TestModalResolveInnerDegenerate(t *testing.T) {
121- m := Modal{WidthPct: 100, HeightPct: 100}121+ got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122- got := m.Inner(domain.Size{Cols: 2, Rows: 2})122+ require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
123- want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
124- if got != want {
125- t.Fatalf("Inner() = %+v, want %+v", got, want)
126- }
127-}
128-
129-func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130- f := renderer.NewFrame(10, 6)
131- exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
132- FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
133-
134- border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135- interior := renderer.Style{Foreground: 3, Background: 4}
136- m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
137- inner := m.Composite(f, border, interior)
138- wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
139- if inner != wantInner {
140- t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
141- }
142-
143- assertRune(t, f, 2, 1, '┌')
144- assertRune(t, f, 7, 1, '┐')
145- assertRune(t, f, 2, 4, '└')
146- assertRune(t, f, 7, 4, '┘')
147- assertRune(t, f, 3, 1, '─')
148- assertRune(t, f, 2, 2, '│')
149- assertRune(t, f, 4, 1, 'H')
150- assertRune(t, f, 5, 1, 'i')
151-
152- for y := inner.Y; y < inner.Y+inner.Height; y++ {
153- for x := inner.X; x < inner.X+inner.Width; x++ {
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)
157- }
158- }
159- }
160- if got := f.At(2, 1).Style; !got.Equal(border) {
161- t.Fatalf("border style = %+v, want %+v", got, border)
162- }
163- assertCell(t, f, 0, 0, exterior)
164- assertCell(t, f, 9, 5, exterior)
165 }123 }
166 124
167 func TestModalResolve(t *testing.T) {125 func TestModalResolve(t *testing.T) {
@@ -376,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
376 }334 }
377 }335 }
378 336
379-func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {337+func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
380 border := renderer.Style{Foreground: 1, Background: 2}338 border := renderer.Style{Foreground: 1, Background: 2}
381 interior := renderer.Style{Foreground: 3, Background: 4}339 interior := renderer.Style{Foreground: 3, Background: 4}
382- for _, tt := range []struct {340+ frame := renderer.NewFrame(2, 2)
383- name string341+ modal := Modal{WidthPct: 100, HeightPct: 100}
384- size domain.Size342+ presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
385- want domain.Rect343+
386- }{344+ got := modal.CompositePresentation(frame, presentation, border, interior)
387- {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},345+
388- {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},346+ require.Equal(t, presentation.Inner, got)
389- } {347+ for y := range frame.Height {
390- t.Run(tt.name, func(t *testing.T) {348+ for x := range frame.Width {
391- frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)349+ require.Equal(t, renderer.BlankCell(), frame.At(x, y))
392- got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)350+ }
393- require.Equal(t, tt.want, got)
394- for y := range frame.Height {
395- for x := range frame.Width {
396- require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
397- }
398- }
399- })
400 }351 }
401 }352 }

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