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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 } | |||
| 100 | 100 | func TestCopySearchModalGeometry(t *testing.T) { | |
| 101 | 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 | 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | |
| 105 | 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | |
| 106 | 106 | } | |
| @@ -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 ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | - | 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) | |
| 18 | 15 | return base, []renderer.Damage{renderer.FullRedraw()} | |
| 19 | 16 | } | |
| @@ -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) { | |||
| 735 | 735 | ||
| 736 | 736 | auto := domain.PaletteConfig{} | |
| 737 | 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 | 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 | 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 | 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 | 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 | 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 | 754 | for _, tt := range tests { | |
| 753 | 755 | t.Run(tt.name, func(t *testing.T) { | |
| 754 | 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 | 766 | require.Equal(t, " Commands ", modal.Title) | |
| 757 | 767 | require.Equal(t, 11, modal.FixedHeight) | |
| 758 | 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 | 793 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | |
| 764 | 794 | model := palette.New(nil) | |
| 765 | 795 | base := renderer.NewFrame(120, 40) | |
| @@ -785,7 +815,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) { | |||
| 785 | 815 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 786 | 816 | ||
| 787 | 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 | 820 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | |
| 791 | 821 | } | |
| @@ -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}).Styles | 815 | 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) { | |||
| 1016 | 1016 | func TestPickerModalGeometry(t *testing.T) { | |
| 1017 | 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 | 1020 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | |
| 1021 | 1021 | } | |
| 1022 | 1022 | ||
| @@ -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 | |||
| 20 | 20 | func TestPromptModalGeometry(t *testing.T) { | |
| 21 | 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 | 26 | 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 { | |||
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | 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 | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | 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.Modal | 105 | + | active bool |
| 106 | - | inner renderer.Frame | 106 | + | title string |
| 107 | - | focused bool | 107 | + | 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 | |||
| 222 | 222 | } | |
| 223 | 223 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 224 | 224 | 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}) | |
| 228 | 228 | } | |
| 229 | 229 | 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} | |
| 232 | 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 | 235 | 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} | |
| 238 | 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 | 241 | 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} | |
| 244 | 245 | guidance := "" | |
| 245 | 246 | if snap.paletteHints != nil { | |
| 246 | 247 | guidance = snap.paletteHints.Feedback | |
| 247 | 248 | } | |
| 248 | 249 | 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 | 252 | 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}) | |
| 255 | 257 | } | |
| 256 | 258 | state.cursor.hiddenByOverlay = o.active() | |
| 257 | 259 | } | |
| @@ -291,22 +293,44 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 291 | 293 | // mismatch would let the picker own the keyboard while notices visually | |
| 292 | 294 | // covers it. | |
| 293 | 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 | 297 | continue | |
| 296 | 298 | } | |
| 297 | 299 | border := state.styles.BorderMuted | |
| 298 | 300 | if modal.focused { | |
| 299 | 301 | border = state.styles.BorderActive | |
| 300 | 302 | } | |
| 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) | |
| 305 | 305 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 306 | 306 | } | |
| 307 | 307 | 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 | 334 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | |
| 311 | 335 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |
| 312 | 336 | 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 = copySearchModal | 225 | + | presentation := copySearchModal.Resolve(size) |
| 226 | - | o.copySearch.focused = true | 226 | + | 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 = pickerModal | 230 | + | presentation := pickerModal.Resolve(size) |
| 231 | - | o.picker.focused = true | 231 | + | 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 = noticesModal | 236 | + | presentation := noticesModal.Resolve(size) |
| 237 | - | o.noticesOverlay.focused = true | 237 | + | 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 = true | 243 | + | 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.Feedback | 247 | guidance = snap.paletteHints.Feedback | ||
| 247 | } | 248 | } | ||
| 248 | o.paletteGuidance = snap.paletteFeedback | 249 | 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 = true | 254 | + | 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 visually | 293 | // 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 | continue | 297 | continue | ||
| 296 | } | 298 | } | ||
| 297 | border := state.styles.BorderMuted | 299 | border := state.styles.BorderMuted | ||
| 298 | if modal.focused { | 300 | if modal.focused { | ||
| 299 | border = state.styles.BorderActive | 301 | 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, damage | 307 | 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 nil | 336 | return nil | ||
M internal/usecase/daemon/render_pipeline_test.go +77 -5
| @@ -9,13 +9,20 @@ import ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 17 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 16 | 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 | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 18 | 24 | "github.com/bnema/vev/internal/usecase/ui" | |
| 25 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 19 | 26 | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 27 | "github.com/bnema/vev/pkg/vt" | |
| 21 | 28 | "github.com/stretchr/testify/require" | |
| @@ -222,19 +229,19 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 222 | 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 | 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 | 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 | 233 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | |
| 227 | 234 | styles: resolveStyles(nil), | |
| 228 | 235 | } | |
| 229 | 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 | 238 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | |
| 232 | 239 | ||
| 233 | 240 | stream := newOutputStateStream() | |
| 234 | 241 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | |
| 235 | 242 | require.NoError(t, err) | |
| 236 | 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 | 245 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | |
| 239 | 246 | client.Write(terminalBytes) | |
| 240 | 247 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | |
| @@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 298 | 305 | active := renderer.Style{Foreground: 3, Background: 4} | |
| 299 | 306 | interior := renderer.Style{Foreground: 5, Background: 6} | |
| 300 | 307 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | |
| 308 | + | presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8}) | |
| 301 | 309 | ||
| 302 | 310 | for _, tt := range []struct { | |
| 303 | 311 | name string | |
| @@ -310,16 +318,80 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 310 | 318 | t.Run(tt.name, func(t *testing.T) { | |
| 311 | 319 | state := capturedRenderState{ | |
| 312 | 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 | 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 | 325 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | |
| 318 | 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 | 395 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |
| 324 | 396 | terminal := renderer.DefaultStyle() | |
| 325 | 397 | 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 string | 311 | 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 { | |||
| 18 | 18 | FixedHeight int | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 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 { | |
| 23 | 22 | width := percentOf(base.Cols, m.WidthPct) | |
| 24 | 23 | height := percentOf(base.Rows, m.HeightPct) | |
| 25 | 24 | if m.FixedWidth > 0 { | |
| @@ -37,31 +36,13 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 37 | 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 | 39 | // Resolve computes the modal's responsive presentation from one preferred | |
| 46 | 40 | // bounds and inner pair. | |
| 47 | 41 | func (m Modal) Resolve(base domain.Size) Presentation { | |
| 48 | - | bounds := m.Bounds(base) | |
| 42 | + | bounds := m.preferredBounds(base) | |
| 49 | 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 | 46 | // CompositePresentation draws an already-resolved modal presentation and | |
| 66 | 47 | // returns its inner content rectangle. | |
| 67 | 48 | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |
| @@ -18,8 +18,7 @@ type Modal struct { | |||||
| 18 | FixedHeight int | 18 | 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 preferred | 39 | // 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 and | 46 | // 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 ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestModalBounds(t *testing.T) { | |
| 11 | + | func TestModalResolveBounds(t *testing.T) { | |
| 12 | 12 | tests := []struct { | |
| 13 | 13 | name string | |
| 14 | 14 | base domain.Size | |
| @@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) { | |||
| 22 | 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 | 26 | base: domain.Size{Cols: 20, Rows: 6}, | |
| 27 | 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 | 32 | base: domain.Size{Cols: 11, Rows: 7}, | |
| 33 | 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 | 38 | base: domain.Size{Cols: 12, Rows: 8}, | |
| 39 | 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 | 43 | name: "bottom anchor honors bottom margin", | |
| @@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) { | |||
| 63 | 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 | 67 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 68 | 68 | m: Modal{ | |
| 69 | 69 | FixedWidth: 8, | |
| @@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) { | |||
| 71 | 71 | Anchor: domain.AnchorRight, | |
| 72 | 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 | 78 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 79 | 79 | m: Modal{ | |
| 80 | 80 | FixedWidth: 30, | |
| @@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) { | |||
| 82 | 82 | Anchor: domain.AnchorRight, | |
| 83 | 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 | 89 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 90 | 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 | 95 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 96 | 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 | 100 | name: "bottom right anchor honors combined margins", | |
| @@ -110,58 +110,16 @@ func TestModalBounds(t *testing.T) { | |||
| 110 | 110 | } | |
| 111 | 111 | for _, tt := range tests { | |
| 112 | 112 | 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) | |
| 115 | 115 | } | |
| 116 | 116 | }) | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 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) | |
| 165 | 123 | } | |
| 166 | 124 | ||
| 167 | 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 | 338 | border := renderer.Style{Foreground: 1, Background: 2} | |
| 381 | 339 | 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 | + | } | |
| 400 | 351 | } | |
| 401 | 352 | } | |
| @@ -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 string | 13 | name string | ||
| 14 | base domain.Size | 14 | 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 string | 341 | + | modal := Modal{WidthPct: 100, HeightPct: 100} |
| 384 | - | size domain.Size | 342 | + | presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2}) |
| 385 | - | want domain.Rect | 343 | + | |
| 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 | } | ||