feat(ui): add responsive overlay presentation
- commit
- 1ace180f699cc0e90d1f7f7b931b1babb45da016
- author
- brice · · 2026-07-27 15:18 UTC
- committer
- brice · · 2026-07-27 17:06 UTC
- parent
- 8c7be4b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +303 -6
M internal/usecase/ui/modal.go +41 -6
| @@ -48,6 +48,19 @@ func (m Modal) Inner(base domain.Size) domain.Rect { | |||
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // Resolve computes the modal's responsive presentation from one preferred | |
| 52 | + | // bounds and inner pair. | |
| 53 | + | func (m Modal) Resolve(base domain.Size) Presentation { | |
| 54 | + | bounds := m.Bounds(base) | |
| 55 | + | inner := domain.Rect{ | |
| 56 | + | X: bounds.X + 1, | |
| 57 | + | Y: bounds.Y + 1, | |
| 58 | + | Width: max(0, bounds.Width-2), | |
| 59 | + | Height: max(0, bounds.Height-2), | |
| 60 | + | } | |
| 61 | + | return ResolvePresentation(base, bounds, inner) | |
| 62 | + | } | |
| 63 | + | ||
| 51 | 64 | // Composite draws the modal border and title, fills its interior, and returns | |
| 52 | 65 | // the inner content rectangle. Border and interior styles are deliberately | |
| 53 | 66 | // independent so unfocused structure and chrome surfaces retain their roles. | |
| @@ -62,15 +75,37 @@ func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) doma | |||
| 62 | 75 | } | |
| 63 | 76 | DrawBox(f, bounds, border) | |
| 64 | 77 | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 65 | - | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 66 | - | left := bounds.X + 1 | |
| 67 | - | right := bounds.X + bounds.Width - 1 | |
| 68 | - | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |
| 69 | - | DrawText(f, start, bounds.Y, right, m.Title, border) | |
| 70 | - | } | |
| 78 | + | m.drawTitle(f, bounds, border) | |
| 71 | 79 | return inner | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 82 | + | // CompositePresentation draws an already-resolved modal presentation and | |
| 83 | + | // returns its inner content rectangle. Drawers use only top chrome. | |
| 84 | + | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |
| 85 | + | FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 86 | + | if p.Mode == PresentationDrawer { | |
| 87 | + | if p.Bounds.Width > 0 && p.Bounds.Height > 0 { | |
| 88 | + | for x := p.Bounds.X; x < p.Bounds.X+p.Bounds.Width; x++ { | |
| 89 | + | setCell(f, x, p.Bounds.Y, '─', border) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } else { | |
| 93 | + | DrawBox(f, p.Bounds, border) | |
| 94 | + | } | |
| 95 | + | m.drawTitle(f, p.Bounds, border) | |
| 96 | + | return p.Inner | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) { | |
| 100 | + | if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" { | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | left := bounds.X + 1 | |
| 104 | + | right := bounds.X + bounds.Width - 1 | |
| 105 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |
| 106 | + | DrawText(f, start, bounds.Y, right, m.Title, style) | |
| 107 | + | } | |
| 108 | + | ||
| 74 | 109 | // FillRect fills rect with cell, clipped to the frame bounds. | |
| 75 | 110 | func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |
| 76 | 111 | left := clamp(rect.X, 0, f.Width) | |
| @@ -48,6 +48,19 @@ func (m Modal) Inner(base domain.Size) domain.Rect { | |||||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | // Resolve computes the modal's responsive presentation from one preferred | |||
| 52 | + | // bounds and inner pair. | |||
| 53 | + | func (m Modal) Resolve(base domain.Size) Presentation { | |||
| 54 | + | bounds := m.Bounds(base) | |||
| 55 | + | inner := domain.Rect{ | |||
| 56 | + | X: bounds.X + 1, | |||
| 57 | + | Y: bounds.Y + 1, | |||
| 58 | + | Width: max(0, bounds.Width-2), | |||
| 59 | + | Height: max(0, bounds.Height-2), | |||
| 60 | + | } | |||
| 61 | + | return ResolvePresentation(base, bounds, inner) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 51 | // Composite draws the modal border and title, fills its interior, and returns | 64 | // Composite draws the modal border and title, fills its interior, and returns | ||
| 52 | // the inner content rectangle. Border and interior styles are deliberately | 65 | // the inner content rectangle. Border and interior styles are deliberately | ||
| 53 | // independent so unfocused structure and chrome surfaces retain their roles. | 66 | // independent so unfocused structure and chrome surfaces retain their roles. | ||
| @@ -62,15 +75,37 @@ func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) doma | |||||
| 62 | } | 75 | } | ||
| 63 | DrawBox(f, bounds, border) | 76 | DrawBox(f, bounds, border) | ||
| 64 | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | 77 | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | ||
| 65 | - | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | 78 | + | m.drawTitle(f, bounds, border) |
| 66 | - | left := bounds.X + 1 | |||
| 67 | - | right := bounds.X + bounds.Width - 1 | |||
| 68 | - | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 69 | - | DrawText(f, start, bounds.Y, right, m.Title, border) | |||
| 70 | - | } | |||
| 71 | return inner | 79 | return inner | ||
| 72 | } | 80 | } | ||
| 73 | 81 | ||||
| 82 | + | // CompositePresentation draws an already-resolved modal presentation and | |||
| 83 | + | // returns its inner content rectangle. Drawers use only top chrome. | |||
| 84 | + | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |||
| 85 | + | FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior}) | |||
| 86 | + | if p.Mode == PresentationDrawer { | |||
| 87 | + | if p.Bounds.Width > 0 && p.Bounds.Height > 0 { | |||
| 88 | + | for x := p.Bounds.X; x < p.Bounds.X+p.Bounds.Width; x++ { | |||
| 89 | + | setCell(f, x, p.Bounds.Y, '─', border) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } else { | |||
| 93 | + | DrawBox(f, p.Bounds, border) | |||
| 94 | + | } | |||
| 95 | + | m.drawTitle(f, p.Bounds, border) | |||
| 96 | + | return p.Inner | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) { | |||
| 100 | + | if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" { | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | left := bounds.X + 1 | |||
| 104 | + | right := bounds.X + bounds.Width - 1 | |||
| 105 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 106 | + | DrawText(f, start, bounds.Y, right, m.Title, style) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 74 | // FillRect fills rect with cell, clipped to the frame bounds. | 109 | // FillRect fills rect with cell, clipped to the frame bounds. | ||
| 75 | func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | 110 | func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | ||
| 76 | left := clamp(rect.X, 0, f.Width) | 111 | left := clamp(rect.X, 0, f.Width) | ||
M internal/usecase/ui/modal_test.go +76 -0
| @@ -164,6 +164,82 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 164 | 164 | assertCell(t, f, 9, 5, exterior) | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | func TestModalResolve(t *testing.T) { | |
| 168 | + | tests := []struct { | |
| 169 | + | name string | |
| 170 | + | base domain.Size | |
| 171 | + | want Presentation | |
| 172 | + | }{ | |
| 173 | + | { | |
| 174 | + | name: "narrow modal resolves to drawer", | |
| 175 | + | base: domain.Size{Cols: 20, Rows: 10}, | |
| 176 | + | want: Presentation{ | |
| 177 | + | Mode: PresentationDrawer, | |
| 178 | + | Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |
| 179 | + | Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3}, | |
| 180 | + | Borders: BorderTop, | |
| 181 | + | }, | |
| 182 | + | }, | |
| 183 | + | { | |
| 184 | + | name: "complete frame preserves floating modal geometry", | |
| 185 | + | base: domain.Size{Cols: 80, Rows: 10}, | |
| 186 | + | want: Presentation{ | |
| 187 | + | Mode: PresentationFloating, | |
| 188 | + | Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4}, | |
| 189 | + | Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2}, | |
| 190 | + | Borders: BorderAll, | |
| 191 | + | }, | |
| 192 | + | }, | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |
| 196 | + | for _, tt := range tests { | |
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |
| 198 | + | require.Equal(t, tt.want, modal.Resolve(tt.base)) | |
| 199 | + | }) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) { | |
| 204 | + | modal := Modal{FixedHeight: 4, Title: " Rename "} | |
| 205 | + | presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10}) | |
| 206 | + | frame := renderer.NewFrame(20, 10) | |
| 207 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |
| 208 | + | FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior) | |
| 209 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 210 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 211 | + | ||
| 212 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |
| 213 | + | require.Equal(t, presentation.Inner, inner) | |
| 214 | + | require.Equal(t, '─', frame.At(0, 5).Rune) | |
| 215 | + | require.True(t, frame.At(0, 5).Style.Equal(border)) | |
| 216 | + | require.Equal(t, 'R', frame.At(7, 5).Rune) | |
| 217 | + | require.NotEqual(t, '│', frame.At(0, 6).Rune) | |
| 218 | + | require.Equal(t, ' ', frame.At(0, 6).Rune) | |
| 219 | + | require.NotEqual(t, '─', frame.At(0, 8).Rune) | |
| 220 | + | require.Equal(t, ' ', frame.At(0, 8).Rune) | |
| 221 | + | ||
| 222 | + | for _, y := range []int{0, 1, 2, 3, 4, 9} { | |
| 223 | + | for x := range frame.Width { | |
| 224 | + | assertCell(t, frame, x, y, exterior) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) { | |
| 230 | + | modal := Modal{FixedHeight: 4, Title: "你你"} | |
| 231 | + | presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10}) | |
| 232 | + | frame := renderer.NewFrame(4, 10) | |
| 233 | + | border := renderer.Style{Foreground: 2, Background: -1} | |
| 234 | + | ||
| 235 | + | modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle()) | |
| 236 | + | ||
| 237 | + | require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune) | |
| 238 | + | require.True(t, frame.At(2, presentation.Bounds.Y).Continuation) | |
| 239 | + | require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune) | |
| 240 | + | require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip") | |
| 241 | + | } | |
| 242 | + | ||
| 167 | 243 | func TestFillRectClips(t *testing.T) { | |
| 168 | 244 | f := renderer.NewFrame(3, 2) | |
| 169 | 245 | cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()} | |
| @@ -164,6 +164,82 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||||
| 164 | assertCell(t, f, 9, 5, exterior) | 164 | assertCell(t, f, 9, 5, exterior) | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | + | func TestModalResolve(t *testing.T) { | |||
| 168 | + | tests := []struct { | |||
| 169 | + | name string | |||
| 170 | + | base domain.Size | |||
| 171 | + | want Presentation | |||
| 172 | + | }{ | |||
| 173 | + | { | |||
| 174 | + | name: "narrow modal resolves to drawer", | |||
| 175 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 176 | + | want: Presentation{ | |||
| 177 | + | Mode: PresentationDrawer, | |||
| 178 | + | Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |||
| 179 | + | Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3}, | |||
| 180 | + | Borders: BorderTop, | |||
| 181 | + | }, | |||
| 182 | + | }, | |||
| 183 | + | { | |||
| 184 | + | name: "complete frame preserves floating modal geometry", | |||
| 185 | + | base: domain.Size{Cols: 80, Rows: 10}, | |||
| 186 | + | want: Presentation{ | |||
| 187 | + | Mode: PresentationFloating, | |||
| 188 | + | Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4}, | |||
| 189 | + | Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2}, | |||
| 190 | + | Borders: BorderAll, | |||
| 191 | + | }, | |||
| 192 | + | }, | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |||
| 196 | + | for _, tt := range tests { | |||
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 198 | + | require.Equal(t, tt.want, modal.Resolve(tt.base)) | |||
| 199 | + | }) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) { | |||
| 204 | + | modal := Modal{FixedHeight: 4, Title: " Rename "} | |||
| 205 | + | presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10}) | |||
| 206 | + | frame := renderer.NewFrame(20, 10) | |||
| 207 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 208 | + | FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior) | |||
| 209 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 210 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |||
| 211 | + | ||||
| 212 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |||
| 213 | + | require.Equal(t, presentation.Inner, inner) | |||
| 214 | + | require.Equal(t, '─', frame.At(0, 5).Rune) | |||
| 215 | + | require.True(t, frame.At(0, 5).Style.Equal(border)) | |||
| 216 | + | require.Equal(t, 'R', frame.At(7, 5).Rune) | |||
| 217 | + | require.NotEqual(t, '│', frame.At(0, 6).Rune) | |||
| 218 | + | require.Equal(t, ' ', frame.At(0, 6).Rune) | |||
| 219 | + | require.NotEqual(t, '─', frame.At(0, 8).Rune) | |||
| 220 | + | require.Equal(t, ' ', frame.At(0, 8).Rune) | |||
| 221 | + | ||||
| 222 | + | for _, y := range []int{0, 1, 2, 3, 4, 9} { | |||
| 223 | + | for x := range frame.Width { | |||
| 224 | + | assertCell(t, frame, x, y, exterior) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) { | |||
| 230 | + | modal := Modal{FixedHeight: 4, Title: "你你"} | |||
| 231 | + | presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10}) | |||
| 232 | + | frame := renderer.NewFrame(4, 10) | |||
| 233 | + | border := renderer.Style{Foreground: 2, Background: -1} | |||
| 234 | + | ||||
| 235 | + | modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle()) | |||
| 236 | + | ||||
| 237 | + | require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune) | |||
| 238 | + | require.True(t, frame.At(2, presentation.Bounds.Y).Continuation) | |||
| 239 | + | require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune) | |||
| 240 | + | require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip") | |||
| 241 | + | } | |||
| 242 | + | ||||
| 167 | func TestFillRectClips(t *testing.T) { | 243 | func TestFillRectClips(t *testing.T) { | ||
| 168 | f := renderer.NewFrame(3, 2) | 244 | f := renderer.NewFrame(3, 2) | ||
| 169 | cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()} | 245 | cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()} | ||
A internal/usecase/ui/presentation.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | ResponsiveDrawerBreakpoint = 80 | |
| 7 | + | DrawerContextRows = 3 | |
| 8 | + | DrawerBottomRows = 1 | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // PresentationMode describes whether an overlay uses its preferred floating | |
| 12 | + | // geometry or becomes a narrow-terminal drawer. | |
| 13 | + | type PresentationMode uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | PresentationFloating PresentationMode = iota | |
| 17 | + | PresentationDrawer | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // BorderEdges identifies the edges drawn around a presentation. | |
| 21 | + | type BorderEdges uint8 | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | BorderTop BorderEdges = 1 << iota | |
| 25 | + | BorderRight | |
| 26 | + | BorderBottom | |
| 27 | + | BorderLeft | |
| 28 | + | BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // Presentation is the resolved outer and content geometry for an overlay. | |
| 32 | + | type Presentation struct { | |
| 33 | + | Mode PresentationMode | |
| 34 | + | Bounds domain.Rect | |
| 35 | + | Inner domain.Rect | |
| 36 | + | Borders BorderEdges | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ResolvePresentation preserves preferred geometry on complete frames at or | |
| 40 | + | // above the responsive breakpoint. Narrow frames use a full-width bottom | |
| 41 | + | // drawer while reserving frame rows 0-2 and the bottom bar. | |
| 42 | + | func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation { | |
| 43 | + | if !base.Valid() { | |
| 44 | + | return Presentation{} | |
| 45 | + | } | |
| 46 | + | if base.Cols >= ResponsiveDrawerBreakpoint { | |
| 47 | + | return Presentation{ | |
| 48 | + | Mode: PresentationFloating, | |
| 49 | + | Bounds: preferredBounds, | |
| 50 | + | Inner: preferredInner, | |
| 51 | + | Borders: BorderAll, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows) | |
| 56 | + | height := min(max(preferredBounds.Height, 0), available) | |
| 57 | + | bounds := domain.Rect{ | |
| 58 | + | X: 0, | |
| 59 | + | Y: base.Rows - DrawerBottomRows - height, | |
| 60 | + | Width: base.Cols, | |
| 61 | + | Height: height, | |
| 62 | + | } | |
| 63 | + | inner := domain.Rect{ | |
| 64 | + | X: 0, | |
| 65 | + | Y: bounds.Y + min(1, height), | |
| 66 | + | Width: base.Cols, | |
| 67 | + | Height: max(0, height-1), | |
| 68 | + | } | |
| 69 | + | return Presentation{ | |
| 70 | + | Mode: PresentationDrawer, | |
| 71 | + | Bounds: bounds, | |
| 72 | + | Inner: inner, | |
| 73 | + | Borders: BorderTop, | |
| 74 | + | } | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | ResponsiveDrawerBreakpoint = 80 | |||
| 7 | + | DrawerContextRows = 3 | |||
| 8 | + | DrawerBottomRows = 1 | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // PresentationMode describes whether an overlay uses its preferred floating | |||
| 12 | + | // geometry or becomes a narrow-terminal drawer. | |||
| 13 | + | type PresentationMode uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | PresentationFloating PresentationMode = iota | |||
| 17 | + | PresentationDrawer | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // BorderEdges identifies the edges drawn around a presentation. | |||
| 21 | + | type BorderEdges uint8 | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | BorderTop BorderEdges = 1 << iota | |||
| 25 | + | BorderRight | |||
| 26 | + | BorderBottom | |||
| 27 | + | BorderLeft | |||
| 28 | + | BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // Presentation is the resolved outer and content geometry for an overlay. | |||
| 32 | + | type Presentation struct { | |||
| 33 | + | Mode PresentationMode | |||
| 34 | + | Bounds domain.Rect | |||
| 35 | + | Inner domain.Rect | |||
| 36 | + | Borders BorderEdges | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ResolvePresentation preserves preferred geometry on complete frames at or | |||
| 40 | + | // above the responsive breakpoint. Narrow frames use a full-width bottom | |||
| 41 | + | // drawer while reserving frame rows 0-2 and the bottom bar. | |||
| 42 | + | func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation { | |||
| 43 | + | if !base.Valid() { | |||
| 44 | + | return Presentation{} | |||
| 45 | + | } | |||
| 46 | + | if base.Cols >= ResponsiveDrawerBreakpoint { | |||
| 47 | + | return Presentation{ | |||
| 48 | + | Mode: PresentationFloating, | |||
| 49 | + | Bounds: preferredBounds, | |||
| 50 | + | Inner: preferredInner, | |||
| 51 | + | Borders: BorderAll, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows) | |||
| 56 | + | height := min(max(preferredBounds.Height, 0), available) | |||
| 57 | + | bounds := domain.Rect{ | |||
| 58 | + | X: 0, | |||
| 59 | + | Y: base.Rows - DrawerBottomRows - height, | |||
| 60 | + | Width: base.Cols, | |||
| 61 | + | Height: height, | |||
| 62 | + | } | |||
| 63 | + | inner := domain.Rect{ | |||
| 64 | + | X: 0, | |||
| 65 | + | Y: bounds.Y + min(1, height), | |||
| 66 | + | Width: base.Cols, | |||
| 67 | + | Height: max(0, height-1), | |||
| 68 | + | } | |||
| 69 | + | return Presentation{ | |||
| 70 | + | Mode: PresentationDrawer, | |||
| 71 | + | Bounds: bounds, | |||
| 72 | + | Inner: inner, | |||
| 73 | + | Borders: BorderTop, | |||
| 74 | + | } | |||
| 75 | + | } | |||
A internal/usecase/ui/presentation_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolvePresentation(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | base domain.Size | |
| 14 | + | bounds, inner domain.Rect | |
| 15 | + | want Presentation | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "79 columns becomes full-width drawer", | |
| 19 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 20 | + | bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11}, | |
| 21 | + | inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9}, | |
| 22 | + | want: Presentation{ | |
| 23 | + | Mode: PresentationDrawer, | |
| 24 | + | Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11}, | |
| 25 | + | Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10}, | |
| 26 | + | Borders: BorderTop, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "80 columns preserves preferred geometry", | |
| 31 | + | base: domain.Size{Cols: 80, Rows: 24}, | |
| 32 | + | bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |
| 33 | + | inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |
| 34 | + | want: Presentation{ | |
| 35 | + | Mode: PresentationFloating, | |
| 36 | + | Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |
| 37 | + | Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |
| 38 | + | Borders: BorderAll, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "100 percent height preserves three rows and bottom bar", | |
| 43 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 44 | + | bounds: domain.Rect{Width: 79, Height: 24}, | |
| 45 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22}, | |
| 46 | + | want: Presentation{ | |
| 47 | + | Mode: PresentationDrawer, | |
| 48 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20}, | |
| 49 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19}, | |
| 50 | + | Borders: BorderTop, | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "preferred height larger than available is capped", | |
| 55 | + | base: domain.Size{Cols: 79, Rows: 10}, | |
| 56 | + | bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000}, | |
| 57 | + | inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998}, | |
| 58 | + | want: Presentation{ | |
| 59 | + | Mode: PresentationDrawer, | |
| 60 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6}, | |
| 61 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5}, | |
| 62 | + | Borders: BorderTop, | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "negative preferred height becomes empty drawer", | |
| 67 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 68 | + | bounds: domain.Rect{Height: -1}, | |
| 69 | + | inner: domain.Rect{Height: -3}, | |
| 70 | + | want: Presentation{ | |
| 71 | + | Mode: PresentationDrawer, | |
| 72 | + | Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |
| 73 | + | Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |
| 74 | + | Borders: BorderTop, | |
| 75 | + | }, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "four-row terminal reserves all protected rows", | |
| 79 | + | base: domain.Size{Cols: 79, Rows: 4}, | |
| 80 | + | bounds: domain.Rect{Width: 79, Height: 4}, | |
| 81 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2}, | |
| 82 | + | want: Presentation{ | |
| 83 | + | Mode: PresentationDrawer, | |
| 84 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79}, | |
| 85 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79}, | |
| 86 | + | Borders: BorderTop, | |
| 87 | + | }, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "one-row terminal is panic-free and empty", | |
| 91 | + | base: domain.Size{Cols: 1, Rows: 1}, | |
| 92 | + | bounds: domain.Rect{Width: 1, Height: 1}, | |
| 93 | + | inner: domain.Rect{X: 1, Y: 1}, | |
| 94 | + | want: Presentation{ | |
| 95 | + | Mode: PresentationDrawer, | |
| 96 | + | Bounds: domain.Rect{X: 0, Y: 0, Width: 1}, | |
| 97 | + | Inner: domain.Rect{X: 0, Y: 0, Width: 1}, | |
| 98 | + | Borders: BorderTop, | |
| 99 | + | }, | |
| 100 | + | }, | |
| 101 | + | {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}}, | |
| 102 | + | {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}}, | |
| 103 | + | {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}}, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | for _, tt := range tests { | |
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |
| 108 | + | require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner)) | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolvePresentation(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | base domain.Size | |||
| 14 | + | bounds, inner domain.Rect | |||
| 15 | + | want Presentation | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "79 columns becomes full-width drawer", | |||
| 19 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 20 | + | bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11}, | |||
| 21 | + | inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9}, | |||
| 22 | + | want: Presentation{ | |||
| 23 | + | Mode: PresentationDrawer, | |||
| 24 | + | Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11}, | |||
| 25 | + | Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10}, | |||
| 26 | + | Borders: BorderTop, | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "80 columns preserves preferred geometry", | |||
| 31 | + | base: domain.Size{Cols: 80, Rows: 24}, | |||
| 32 | + | bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 33 | + | inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 34 | + | want: Presentation{ | |||
| 35 | + | Mode: PresentationFloating, | |||
| 36 | + | Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 37 | + | Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 38 | + | Borders: BorderAll, | |||
| 39 | + | }, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "100 percent height preserves three rows and bottom bar", | |||
| 43 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 44 | + | bounds: domain.Rect{Width: 79, Height: 24}, | |||
| 45 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22}, | |||
| 46 | + | want: Presentation{ | |||
| 47 | + | Mode: PresentationDrawer, | |||
| 48 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20}, | |||
| 49 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19}, | |||
| 50 | + | Borders: BorderTop, | |||
| 51 | + | }, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "preferred height larger than available is capped", | |||
| 55 | + | base: domain.Size{Cols: 79, Rows: 10}, | |||
| 56 | + | bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000}, | |||
| 57 | + | inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998}, | |||
| 58 | + | want: Presentation{ | |||
| 59 | + | Mode: PresentationDrawer, | |||
| 60 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6}, | |||
| 61 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5}, | |||
| 62 | + | Borders: BorderTop, | |||
| 63 | + | }, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "negative preferred height becomes empty drawer", | |||
| 67 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 68 | + | bounds: domain.Rect{Height: -1}, | |||
| 69 | + | inner: domain.Rect{Height: -3}, | |||
| 70 | + | want: Presentation{ | |||
| 71 | + | Mode: PresentationDrawer, | |||
| 72 | + | Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 73 | + | Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 74 | + | Borders: BorderTop, | |||
| 75 | + | }, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "four-row terminal reserves all protected rows", | |||
| 79 | + | base: domain.Size{Cols: 79, Rows: 4}, | |||
| 80 | + | bounds: domain.Rect{Width: 79, Height: 4}, | |||
| 81 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2}, | |||
| 82 | + | want: Presentation{ | |||
| 83 | + | Mode: PresentationDrawer, | |||
| 84 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 85 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 86 | + | Borders: BorderTop, | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "one-row terminal is panic-free and empty", | |||
| 91 | + | base: domain.Size{Cols: 1, Rows: 1}, | |||
| 92 | + | bounds: domain.Rect{Width: 1, Height: 1}, | |||
| 93 | + | inner: domain.Rect{X: 1, Y: 1}, | |||
| 94 | + | want: Presentation{ | |||
| 95 | + | Mode: PresentationDrawer, | |||
| 96 | + | Bounds: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 97 | + | Inner: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 98 | + | Borders: BorderTop, | |||
| 99 | + | }, | |||
| 100 | + | }, | |||
| 101 | + | {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}}, | |||
| 102 | + | {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}}, | |||
| 103 | + | {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}}, | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | for _, tt := range tests { | |||
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 108 | + | require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner)) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||