detached 1ace180

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
4848 }
4949 }
5050
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+
5164 // Composite draws the modal border and title, fills its interior, and returns
5265 // the inner content rectangle. Border and interior styles are deliberately
5366 // 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
6275 }
6376 DrawBox(f, bounds, border)
6477 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)
7179 return inner
7280 }
7381
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+
74109 // FillRect fills rect with cell, clipped to the frame bounds.
75110 func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
76111 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 returns64 // Composite draws the modal border and title, fills its interior, and returns
52 // the inner content rectangle. Border and interior styles are deliberately65 // 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 inner79 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) {
164164 assertCell(t, f, 9, 5, exterior)
165165 }
166166
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+
167243 func TestFillRectClips(t *testing.T) {
168244 f := renderer.NewFrame(3, 2)
169245 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close