1package ui
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/stretchr/testify/require"
9)
10
11func TestModalResolveBounds(t *testing.T) {
12 tests := []struct {
13 name string
14 base domain.Size
15 m Modal
16 want domain.Rect
17 }{
18 {
19 name: "eighty percent centered",
20 base: domain.Size{Cols: 100, Rows: 40},
21 m: Modal{WidthPct: 80, HeightPct: 80},
22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
23 },
24 {
25 name: "minimum clamps to drawer availability",
26 base: domain.Size{Cols: 20, Rows: 6},
27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28 want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
29 },
30 {
31 name: "narrow preferred height becomes drawer height",
32 base: domain.Size{Cols: 11, Rows: 7},
33 m: Modal{WidthPct: 50, HeightPct: 50},
34 want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
35 },
36 {
37 name: "zero value fills available drawer area",
38 base: domain.Size{Cols: 12, Rows: 8},
39 m: Modal{},
40 want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
41 },
42 {
43 name: "bottom anchor honors bottom margin",
44 base: domain.Size{Cols: 100, Rows: 40},
45 m: Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}},
46 want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
47 },
48 {
49 name: "fixed dimensions override percentages",
50 base: domain.Size{Cols: 100, Rows: 40},
51 m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12},
52 want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12},
53 },
54 {
55 name: "right anchor honors right margin",
56 base: domain.Size{Cols: 100, Rows: 40},
57 m: Modal{
58 FixedWidth: 30,
59 FixedHeight: 12,
60 Anchor: domain.AnchorRight,
61 Margins: Margins{Right: 3},
62 },
63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
64 },
65 {
66 name: "drawer ignores right margin",
67 base: domain.Size{Cols: 20, Rows: 10},
68 m: Modal{
69 FixedWidth: 8,
70 FixedHeight: 4,
71 Anchor: domain.AnchorRight,
72 Margins: Margins{Right: 20},
73 },
74 want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
75 },
76 {
77 name: "right anchored drawer can have zero height",
78 base: domain.Size{Cols: 10, Rows: 4},
79 m: Modal{
80 FixedWidth: 30,
81 FixedHeight: 12,
82 Anchor: domain.AnchorRight,
83 Margins: Margins{Right: 3},
84 },
85 want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
86 },
87 {
88 name: "fixed drawer dimensions clamp on small terminal",
89 base: domain.Size{Cols: 10, Rows: 4},
90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91 want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
92 },
93 {
94 name: "drawer ignores bottom margin",
95 base: domain.Size{Cols: 20, Rows: 10},
96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97 want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
98 },
99 {
100 name: "bottom right anchor honors combined margins",
101 base: domain.Size{Cols: 100, Rows: 40},
102 m: Modal{
103 FixedWidth: 30,
104 FixedHeight: 12,
105 Anchor: domain.AnchorBottomRight,
106 Margins: Margins{Bottom: 3, Right: 5},
107 },
108 want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
109 },
110 }
111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {
113 if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114 t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115 }
116 })
117 }
118}
119
120func 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)
123}
124
125func TestModalResolve(t *testing.T) {
126 tests := []struct {
127 name string
128 base domain.Size
129 want Presentation
130 }{
131 {
132 name: "narrow modal resolves to drawer",
133 base: domain.Size{Cols: 20, Rows: 10},
134 want: Presentation{
135 Mode: PresentationDrawer,
136 Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
137 Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3},
138 Borders: BorderTop,
139 },
140 },
141 {
142 name: "complete frame preserves floating modal geometry",
143 base: domain.Size{Cols: 80, Rows: 10},
144 want: Presentation{
145 Mode: PresentationFloating,
146 Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4},
147 Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2},
148 Borders: BorderAll,
149 },
150 },
151 }
152
153 modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
154 for _, tt := range tests {
155 t.Run(tt.name, func(t *testing.T) {
156 require.Equal(t, tt.want, modal.Resolve(tt.base))
157 })
158 }
159}
160
161func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
162 modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
163 presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
164 frame := renderer.NewFrame(80, 10)
165 exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
166 FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
167 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
168 interior := renderer.Style{Foreground: 3, Background: 4}
169
170 inner := modal.CompositePresentation(frame, presentation, border, interior)
171 require.Equal(t, presentation.Inner, inner)
172 require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
173 require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
174 require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
175 require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
176 require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
177 require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
178 assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
179}
180
181func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
182 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
183 exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
184 bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
185 inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
186
187 tests := []struct {
188 name string
189 presentation Presentation
190 assert func(*testing.T, renderer.Frame)
191 }{
192 {
193 name: "all edges render for drawer mode",
194 presentation: Presentation{
195 Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
196 },
197 assert: func(t *testing.T, frame renderer.Frame) {
198 require.Equal(t, '┌', frame.At(1, 1).Rune)
199 require.Equal(t, '│', frame.At(1, 2).Rune)
200 require.Equal(t, '┘', frame.At(8, 4).Rune)
201 require.Equal(t, 'T', frame.At(2, 1).Rune)
202 },
203 },
204 {
205 name: "top edge alone renders for floating mode",
206 presentation: Presentation{
207 Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
208 },
209 assert: func(t *testing.T, frame renderer.Frame) {
210 require.Equal(t, '─', frame.At(1, 1).Rune)
211 require.Equal(t, 'T', frame.At(2, 1).Rune)
212 assertCell(t, frame, 1, 2, exterior)
213 assertCell(t, frame, 8, 4, exterior)
214 },
215 },
216 {
217 name: "title is hidden without top edge",
218 presentation: Presentation{
219 Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
220 },
221 assert: func(t *testing.T, frame renderer.Frame) {
222 require.Equal(t, '│', frame.At(1, 1).Rune)
223 require.Equal(t, '│', frame.At(1, 4).Rune)
224 assertCell(t, frame, 4, 1, exterior)
225 assertCell(t, frame, 8, 4, exterior)
226 },
227 },
228 }
229
230 for _, tt := range tests {
231 t.Run(tt.name, func(t *testing.T) {
232 frame := renderer.NewFrame(10, 6)
233 FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
234 (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
235 tt.assert(t, frame)
236 })
237 }
238}
239
240func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
241 modal := Modal{FixedHeight: 4, Title: " Rename "}
242 presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
243 frame := renderer.NewFrame(20, 10)
244 exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
245 FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior)
246 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
247 interior := renderer.Style{Foreground: 3, Background: 4}
248
249 inner := modal.CompositePresentation(frame, presentation, border, interior)
250 require.Equal(t, presentation.Inner, inner)
251 require.Equal(t, '─', frame.At(0, 5).Rune)
252 require.True(t, frame.At(0, 5).Style.Equal(border))
253 require.Equal(t, 'R', frame.At(7, 5).Rune)
254 require.NotEqual(t, '│', frame.At(0, 6).Rune)
255 require.Equal(t, ' ', frame.At(0, 6).Rune)
256 require.NotEqual(t, '─', frame.At(0, 8).Rune)
257 require.Equal(t, ' ', frame.At(0, 8).Rune)
258
259 for _, y := range []int{0, 1, 2, 3, 4, 9} {
260 for x := range frame.Width {
261 assertCell(t, frame, x, y, exterior)
262 }
263 }
264}
265
266func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) {
267 modal := Modal{FixedHeight: 4, Title: "你你"}
268 presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10})
269 frame := renderer.NewFrame(4, 10)
270 border := renderer.Style{Foreground: 2, Background: -1}
271
272 modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle())
273
274 require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune)
275 require.True(t, frame.At(2, presentation.Bounds.Y).Continuation)
276 require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune)
277 require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip")
278}
279
280func TestFillRectClips(t *testing.T) {
281 f := renderer.NewFrame(3, 2)
282 cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}
283 FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell)
284 assertCell(t, f, 0, 0, cell)
285 assertCell(t, f, 1, 1, cell)
286 assertCell(t, f, 2, 1, renderer.BlankCell())
287}
288
289func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) {
290 style := renderer.Style{Inverse: true, Foreground: -1, Background: -1}
291 t.Run("clips ascii", func(t *testing.T) {
292 f := renderer.NewFrame(5, 1)
293 next := DrawText(f, 1, 0, 3, "abcd", style)
294 if next != 3 {
295 t.Fatalf("DrawText next = %d, want 3", next)
296 }
297 assertRune(t, f, 1, 0, 'a')
298 assertRune(t, f, 2, 0, 'b')
299 assertCell(t, f, 3, 0, renderer.BlankCell())
300 })
301 t.Run("draws wide rune when it fits", func(t *testing.T) {
302 f := renderer.NewFrame(5, 1)
303 next := DrawText(f, 1, 0, 3, "你", style)
304 if next != 3 {
305 t.Fatalf("DrawText next = %d, want 3", next)
306 }
307 assertRune(t, f, 1, 0, '你')
308 if got := f.At(2, 0); !got.Continuation {
309 t.Fatalf("wide continuation = %+v, want continuation", got)
310 }
311 })
312 t.Run("drops wide rune crossing clip", func(t *testing.T) {
313 f := renderer.NewFrame(5, 1)
314 next := DrawText(f, 2, 0, 3, "你A", style)
315 if next != 2 {
316 t.Fatalf("DrawText next = %d, want 2", next)
317 }
318 assertCell(t, f, 2, 0, renderer.BlankCell())
319 assertCell(t, f, 3, 0, renderer.BlankCell())
320 })
321}
322
323func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) {
324 t.Helper()
325 if got := f.At(x, y).Rune; got != want {
326 t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want)
327 }
328}
329
330func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
331 t.Helper()
332 if got := f.At(x, y); !got.Equal(want) {
333 t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
334 }
335}
336
337func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
338 border := renderer.Style{Foreground: 1, Background: 2}
339 interior := renderer.Style{Foreground: 3, Background: 4}
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 }
351 }
352}