1package daemon
2
3import (
4 "errors"
5 "io"
6 "strings"
7 "sync"
8 "testing"
9
10 "github.com/stretchr/testify/require"
11
12 renderer "github.com/bnema/vev-vt/ansi"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/domain/layout"
15 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/internal/usecase/ui"
17)
18
19// applyFloatingResizePlanForTest exercises the transactional apply primitive.
20func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
21 if p == nil || !geometry.valid() {
22 return false
23 }
24 plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
25 geometryModule := &sharedPTYGeometry{}
26 geometryModule.applyPreparedTabMembers(d, &plan)
27 member := plan.members[0]
28 if !member.ok {
29 return false
30 }
31 p.mu.Lock()
32 p.rect = geometry.Inner
33 p.popupGeometry = geometry
34 p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
35 p.mu.Unlock()
36 p.resizeMu.Lock()
37 d.replayResizePending(member.session, member.tab, p, false, member.rect)
38 p.resizeMu.Unlock()
39 return true
40}
41
42func TestFloatingGeometryTranslate(t *testing.T) {
43 geometry := floatingGeometry{
44 Mode: ui.PresentationDrawer,
45 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
46 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
47 }
48
49 translated := geometry.translate(11, 13)
50 require.Equal(t, floatingGeometry{
51 Mode: ui.PresentationDrawer,
52 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},
53 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},
54 }, translated)
55 require.Equal(t, floatingGeometry{
56 Mode: ui.PresentationDrawer,
57 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
58 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
59 }, geometry)
60}
61
62func TestResponsiveFloatingGeometry(t *testing.T) {
63 cfg := domain.FloatingConfig{Width: 100, Height: 100}
64 tests := []struct {
65 name string
66 content domain.Size
67 want floatingGeometry
68 }{
69 {
70 name: "narrow full height becomes content-relative drawer",
71 content: domain.Size{Cols: 79, Rows: 22},
72 want: floatingGeometry{
73 Mode: ui.PresentationDrawer,
74 Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20},
75 Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19},
76 },
77 },
78 {
79 name: "breakpoint preserves centered floating geometry",
80 content: domain.Size{Cols: 80, Rows: 22},
81 want: floatingGeometry{
82 Mode: ui.PresentationFloating,
83 Bounds: domain.Rect{Width: 80, Height: 22},
84 Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20},
85 },
86 },
87 {
88 name: "tiny narrow frame retains zero-content drawer",
89 content: domain.Size{Cols: 79, Rows: 2},
90 want: floatingGeometry{
91 Mode: ui.PresentationDrawer,
92 Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
93 Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
94 },
95 },
96 }
97 for _, tt := range tests {
98 t.Run(tt.name, func(t *testing.T) {
99 require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg))
100 })
101 }
102}
103
104func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
105 const frameX, frameY = 7, 3
106 tests := []struct {
107 name string
108 content domain.Size
109 cfg domain.FloatingConfig
110 tinyWidth, tinyHeight bool
111 }{
112 {name: "normal", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 80, Height: 75}},
113 {name: "tiny width omits horizontal borders", content: domain.Size{Cols: 2, Rows: 20}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyWidth: true},
114 {name: "tiny height omits vertical borders", content: domain.Size{Cols: 20, Rows: 2}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyHeight: true},
115 {name: "full size", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 100, Height: 100}},
116 }
117 for _, tt := range tests {
118 t.Run(tt.name, func(t *testing.T) {
119 contentGeometry := calculateContentFloatingGeometry(tt.content, tt.cfg)
120 frameGeometry := contentGeometry.translate(frameX, frameY)
121
122 require.Equal(t, contentGeometry.Bounds.X+frameX, frameGeometry.Bounds.X)
123 require.Equal(t, contentGeometry.Bounds.Y+frameY, frameGeometry.Bounds.Y)
124 require.Equal(t, contentGeometry.Inner.X+frameX, frameGeometry.Inner.X)
125 require.Equal(t, contentGeometry.Inner.Y+frameY, frameGeometry.Inner.Y)
126 require.Equal(t, contentGeometry.Bounds.Width, frameGeometry.Bounds.Width)
127 require.Equal(t, contentGeometry.Bounds.Height, frameGeometry.Bounds.Height)
128 require.Equal(t, contentGeometry.Inner.Width, frameGeometry.Inner.Width)
129 require.Equal(t, contentGeometry.Inner.Height, frameGeometry.Inner.Height)
130
131 if tt.tinyWidth {
132 require.Equal(t, frameGeometry.Bounds.X, frameGeometry.Inner.X)
133 require.Equal(t, frameGeometry.Bounds.Width, frameGeometry.Inner.Width)
134 }
135 if tt.tinyHeight {
136 require.Equal(t, frameGeometry.Bounds.Y, frameGeometry.Inner.Y)
137 require.Equal(t, frameGeometry.Bounds.Height, frameGeometry.Inner.Height)
138 }
139 })
140 }
141}
142
143func TestCalculatePreferredFloatingGeometry(t *testing.T) {
144 tests := []struct {
145 name string
146 content domain.Size
147 cfg domain.FloatingConfig
148 want floatingGeometry
149 }{
150 {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},
151 {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},
152 {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},
153 {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},
154 {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
155 }
156 for _, tt := range tests {
157 t.Run(tt.name, func(t *testing.T) {
158 require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg))
159 })
160 }
161}
162
163func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) {
164 cfg := domain.FloatingConfig{Width: 100, Height: 100}
165 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
166 require.False(t, geometry.valid())
167 require.True(t, geometry.committable())
168 require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect())
169
170 d := newTestDaemon(t, nil, stubClock{})
171 d.ApplyConfig(domain.Config{Floating: cfg})
172 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
173 sess := &session{sessionCore: sessionCore{name: "test"}, tabs: []*tab{tb}, ctx: t.Context()}
174 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)
175 require.NoError(t, err)
176 require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")
177 require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size)
178
179 floating := newPane("floating", nil, spec.size)
180 floating.popupGeometry = spec.geometry
181 installTestFloating(tb, floating, true)
182 tb.mu.Lock()
183 gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
184 require.Same(t, floating, tb.terminalTargetLocked())
185 tb.mu.Unlock()
186 require.True(t, visible)
187 require.Same(t, floating, gotPane)
188 require.Equal(t, geometry, gotGeometry)
189}
190
191func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) {
192 cfg := domain.FloatingConfig{Width: 100, Height: 100}
193 initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
194 requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
195 pty := &resizePTY{}
196 floating := newPane("floating", pty, rectSize(initial.Inner))
197 floating.rect = initial.Inner
198 floating.popupGeometry = initial
199 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
200 installTestFloating(tb, floating, true)
201 d := newTestDaemon(t, nil, stubClock{})
202 d.ApplyConfig(domain.Config{Floating: cfg})
203
204 sess := &session{tabs: []*tab{tb}}
205 failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
206 require.True(t, ok)
207 require.Empty(t, failed)
208 require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
209 require.Equal(t, requested, floating.popupGeometry)
210 require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
211 require.Equal(t, initial.Inner.Width, floating.screen.Columns())
212 require.Equal(t, initial.Inner.Height, floating.screen.Rows())
213}
214
215func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
216 tests := []struct {
217 name string
218 available, percent int
219 want floatingAxisGeometry
220 }{
221 {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}},
222 {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}},
223 {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}},
224 {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}},
225 {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}},
226 }
227 for _, tt := range tests {
228 t.Run(tt.name, func(t *testing.T) {
229 require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent))
230 })
231 }
232}
233
234func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
235 base := renderer.NewFrame(80, 12)
236 base.Set(2, 2, renderer.Cell{Rune: 'B'})
237 content := domain.Rect{Y: 1, Width: 80, Height: 10}
238 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
239 paneFrame := renderer.NewFrame(6, 3)
240 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
241 floating := capturedFloatingRenderState{
242 visible: true,
243 pane: capturedPaneRenderState{frame: paneFrame, title: "nvim: project/main.go", titleGeneration: 1},
244 geometry: geometry,
245 title: "nvim: project/main.go",
246 generation: 1,
247 titleGeneration: 1,
248 }
249 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
250 baseFrame: base,
251 floating: floating,
252 content: content,
253 theme: themeui.Theme{},
254 })
255 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")
256 frameGeometry := geometry.translate(content.X, content.Y)
257 require.Equal(t, '┌', frame.At(frameGeometry.Bounds.X, frameGeometry.Bounds.Y).Rune)
258 require.Equal(t, 'F', frame.At(frameGeometry.Inner.X, frameGeometry.Inner.Y).Rune)
259 var gotTitle strings.Builder
260 for x := frameGeometry.Bounds.X + 2; x < frameGeometry.Bounds.X+frameGeometry.Bounds.Width-2; x++ {
261 gotTitle.WriteRune(frame.At(x, frameGeometry.Bounds.Y).Rune)
262 }
263 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
264
265 floating.titleGeneration++
266 _, damage := composeCapturedFloatingFrame(floatingComposeInput{
267 baseFrame: base,
268 floating: floating,
269 content: content,
270 theme: themeui.Theme{},
271 cache: composeCacheInput{
272 valid: true,
273 floatingGeneration: 1,
274 floatingGeometry: frameGeometry,
275 floatingTitleGeneration: 1,
276 },
277 })
278 require.Len(t, damage, 1)
279 require.Equal(t, frameGeometry.Bounds.Y, damage[0].Y)
280 require.Equal(t, 1, damage[0].Height)
281}
282
283func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) {
284 base := renderer.NewFrame(15, 7)
285 for y := range base.Height {
286 for x := range base.Width {
287 base.Set(x, y, renderer.Cell{Rune: '·'})
288 }
289 }
290 content := domain.Rect{Y: 1, Width: 15, Height: 5}
291 retained := calculateContentFloatingGeometry(
292 domain.Size{Cols: 100, Rows: 30},
293 domain.FloatingConfig{Width: 80, Height: 80},
294 )
295 pty := &resizePTY{err: errors.New("resize failed")}
296 floatingPane := newPane("floating", pty, rectSize(retained.Inner))
297 floatingPane.rect = retained.Inner
298 floatingPane.popupGeometry = retained
299 seed := renderer.NewFrame(floatingPane.screen.Columns(), floatingPane.screen.Rows())
300 for y := range seed.Height {
301 for x := range seed.Width {
302 seed.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
303 }
304 }
305 writeTestFrame(t, floatingPane.screen, seed)
306 requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
307 d := newTestDaemon(t, nil, stubClock{})
308 require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
309 require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry")
310 floatingPane.mu.Lock()
311 captured := capturePaneRenderStateLockedInto(floatingPane, retained.Inner, capturedPaneRenderState{})
312 floatingPane.mu.Unlock()
313
314 var frame renderer.Frame
315 require.NotPanics(t, func() {
316 frame, _ = composeCapturedFloatingFrame(floatingComposeInput{
317 baseFrame: base,
318 floating: capturedFloatingRenderState{
319 visible: true,
320 pane: captured,
321 geometry: retained,
322 generation: 1,
323 },
324 content: content,
325 full: true,
326 })
327 })
328
329 for y := range 4 {
330 require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y)
331 }
332 require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted")
333 require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates")
334 require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source")
335}
336
337func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) {
338 base := renderer.NewFrame(4, 4)
339 pane := renderer.NewFrame(5, 5)
340 for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} {
341 for x, r := range text {
342 pane.Set(x, y, renderer.Cell{Rune: r})
343 }
344 }
345 geometry := floatingGeometry{
346 Mode: ui.PresentationFloating,
347 Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7},
348 Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5},
349 }
350
351 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
352 baseFrame: base,
353 floating: capturedFloatingRenderState{
354 visible: true,
355 pane: capturedPaneRenderState{frame: pane},
356 geometry: geometry,
357 generation: 1,
358 },
359 full: true,
360 })
361
362 require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{
363 rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)),
364 })
365}
366
367func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) {
368 tests := []struct {
369 name string
370 geometry floatingGeometry
371 want []string
372 }{
373 {
374 name: "negative top and left retain bottom and right edges",
375 geometry: floatingGeometry{
376 Mode: ui.PresentationFloating,
377 Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5},
378 },
379 want: []string{"···│", "···│", "···│", "───┘"},
380 },
381 {
382 name: "overflowing bottom and right retain top and left edges",
383 geometry: floatingGeometry{
384 Mode: ui.PresentationFloating,
385 Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4},
386 },
387 want: []string{"····", "····", "··┌─", "··│·"},
388 },
389 {
390 name: "drawer clips its separator",
391 geometry: floatingGeometry{
392 Mode: ui.PresentationDrawer,
393 Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3},
394 },
395 want: []string{"····", "····", "xy──", "····"},
396 },
397 }
398 for _, tt := range tests {
399 t.Run(tt.name, func(t *testing.T) {
400 frame := renderer.NewFrame(4, 4)
401 for y := range frame.Height {
402 for x := range frame.Width {
403 frame.Set(x, y, renderer.Cell{Rune: '·'})
404 }
405 }
406
407 require.NotPanics(t, func() {
408 drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{})
409 })
410 for y, want := range tt.want {
411 require.Equal(t, want, rowText(frame.Row(y)), "row %d", y)
412 }
413 })
414 }
415}
416
417func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
418 base := renderer.NewFrame(20, 9)
419 content := domain.Rect{Y: 1, Width: 20, Height: 7}
420 geometry := floatingGeometry{
421 Bounds: domain.Rect{X: 3, Y: 1, Width: 8, Height: 5},
422 Inner: domain.Rect{X: 4, Y: 2, Width: 6, Height: 3},
423 }
424 active := renderer.Style{Foreground: 2, Background: 3}
425 muted := renderer.Style{Foreground: 4, Background: 5}
426 pane := renderer.NewFrame(6, 3)
427 for y := range pane.Height {
428 for x := range pane.Width {
429 pane.Set(x, y, renderer.Cell{Rune: rune('a' + y*pane.Width + x), Style: renderer.Style{Foreground: x + y*10, Background: x + y*10 + 1}})
430 }
431 }
432
433 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
434 baseFrame: base,
435 floating: capturedFloatingRenderState{
436 visible: true, focused: true, pane: capturedPaneRenderState{frame: pane}, geometry: geometry, title: "float", generation: 1,
437 },
438 content: content,
439 borderActive: active,
440 borderMuted: muted,
441 full: true,
442 })
443 bounds := geometry.translate(content.X, content.Y)
444 require.Equal(t, active, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style, "focused floating border must use BorderActive")
445 require.NotEqual(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
446 for y := range bounds.Inner.Height {
447 for x := range bounds.Inner.Width {
448 require.Equal(t, pane.At(x, y), frame.At(bounds.Inner.X+x, bounds.Inner.Y+y), "pane cell (%d,%d) must remain byte-identical", x, y)
449 }
450 }
451}
452
453func TestComposeCapturedFloatingFrameUsesMutedBorderWhenUnfocused(t *testing.T) {
454 base := renderer.NewFrame(12, 7)
455 content := domain.Rect{Y: 1, Width: 12, Height: 5}
456 geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
457 active, muted := renderer.Style{Foreground: 2}, renderer.Style{Foreground: 4}
458
459 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
460 baseFrame: base,
461 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, title: "float", generation: 1},
462 content: content,
463 borderActive: active,
464 borderMuted: muted,
465 full: true,
466 })
467 bounds := geometry.translate(content.X, content.Y)
468 require.Equal(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
469}
470
471func TestComposeCapturedFloatingFrameFocusChangeInvalidatesCache(t *testing.T) {
472 content := domain.Rect{Y: 1, Width: 12, Height: 5}
473 geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
474 frame, damage := composeCapturedFloatingFrame(floatingComposeInput{
475 baseFrame: renderer.NewFrame(12, 7),
476 floating: capturedFloatingRenderState{
477 visible: true, focused: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, generation: 1,
478 },
479 content: content,
480 cache: composeCacheInput{
481 valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y),
482 },
483 })
484 require.NotNil(t, frame)
485 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage, "a focused border role change must redraw the popup")
486}
487
488func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
489 initial := floatingGeometry{
490 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
491 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
492 }
493 tests := []struct {
494 name string
495 next floatingGeometry
496 }{
497 {
498 name: "position change",
499 next: floatingGeometry{Bounds: domain.Rect{X: 12, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 13, Y: 5, Width: 6, Height: 3}},
500 },
501 {
502 name: "bounds change with same inner size",
503 next: floatingGeometry{Bounds: domain.Rect{X: 10, Y: 2, Width: 10, Height: 7}, Inner: domain.Rect{X: 12, Y: 4, Width: 6, Height: 3}},
504 },
505 }
506 for _, tt := range tests {
507 t.Run(tt.name, func(t *testing.T) {
508 base := renderer.NewFrame(30, 12)
509 content := domain.Rect{Y: 1, Width: 30, Height: 10}
510 pane := capturedPaneRenderState{frame: renderer.NewFrame(6, 3), titleGeneration: 1}
511 initialInput := floatingComposeInput{
512 baseFrame: base,
513 floating: capturedFloatingRenderState{visible: true, pane: pane, geometry: initial, generation: 1, titleGeneration: 1},
514 content: content,
515 theme: themeui.Theme{},
516 }
517 _, damage := composeCapturedFloatingFrame(initialInput)
518 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
519
520 initialInput.floating.geometry = tt.next
521 initialInput.cache = composeCacheInput{
522 valid: true,
523 floatingGeneration: 1,
524 floatingGeometry: initial.translate(content.X, content.Y),
525 floatingTitleGeneration: 1,
526 }
527 _, damage = composeCapturedFloatingFrame(initialInput)
528 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
529 })
530 }
531}
532
533func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) {
534 initial := floatingGeometry{
535 Bounds: domain.Rect{X: 1, Y: 1, Width: 8, Height: 5},
536 Inner: domain.Rect{X: 2, Y: 2, Width: 6, Height: 3},
537 }
538 tests := []struct {
539 name string
540 next floatingGeometry
541 }{
542 {
543 name: "position change",
544 next: floatingGeometry{Bounds: domain.Rect{X: 9, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 10, Y: 5, Width: 6, Height: 3}},
545 },
546 {
547 name: "bounds change",
548 next: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 10, Height: 7}, Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3}},
549 },
550 }
551 for _, tt := range tests {
552 t.Run(tt.name, func(t *testing.T) {
553 pty := &resizePTY{}
554 p := newPane("floating", pty, rectSize(initial.Inner))
555 p.rect, p.popupGeometry = initial.Inner, initial
556 d := newTestDaemon(t, nil, stubClock{})
557
558 require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
559 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
560 require.Equal(t, tt.next.Inner, p.rect)
561 require.Equal(t, tt.next, p.popupGeometry)
562 require.Equal(t, 6, p.screen.Columns())
563 require.Equal(t, 3, p.screen.Rows())
564 })
565 }
566}
567
568func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
569 d := newTestDaemon(t, nil, stubClock{})
570 d.shell = "/usr/bin/fish"
571 p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
572 p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
573 require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true))
574
575 base := renderer.NewFrame(40, 12)
576 content := domain.Rect{Y: 1, Width: 40, Height: 10}
577 cfg := domain.FloatingConfig{Width: 80, Height: 80}
578 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
579 captured := capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "btop", titleGeneration: 1}
580 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
581 baseFrame: base,
582 floating: capturedFloatingRenderState{
583 visible: true, pane: captured, geometry: geometry, title: captured.title, generation: 1, titleGeneration: captured.titleGeneration,
584 },
585 content: content,
586 theme: themeui.Theme{},
587 })
588 geometry = geometry.translate(content.X, content.Y)
589 var gotTitle strings.Builder
590 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
591 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
592 }
593 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
594}
595
596func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
597 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
598 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
599 installTestFloating(tb, p, true)
600 ac := &attachedClient{}
601 ac.initOverlays()
602 sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
603 base := barState{}
604 cfg := domain.FloatingConfig{Width: 100, Height: 100}
605
606 start := make(chan struct{})
607 var wg sync.WaitGroup
608 wg.Add(2)
609 go func() {
610 defer wg.Done()
611 <-start
612 for range 500 {
613 p.mu.Lock()
614 p.screen.Write([]byte("\x1b[Hreader"))
615 p.mu.Unlock()
616 }
617 }()
618 go func() {
619 defer wg.Done()
620 <-start
621 cache := composeCacheInput{}
622 for range 500 {
623 ac.sendMu.Lock()
624 state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: base, floatingCfg: cfg})
625 ac.sendMu.Unlock()
626 require.True(t, ok)
627 out := composeFrame(*state, cache)
628 cache = out.cache
629 }
630 }()
631 close(start)
632 wg.Wait()
633}
634
635func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
636 frame := renderer.NewFrame(2, 1)
637 drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
638 for _, cell := range frame.Row(0) {
639 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
640 }
641}
642
643func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) {
644 frame := renderer.NewFrame(12, 6)
645 geometry := floatingGeometry{
646 Mode: ui.PresentationDrawer,
647 Bounds: domain.Rect{Y: 2, Width: 12, Height: 4},
648 Inner: domain.Rect{Y: 3, Width: 12, Height: 3},
649 }
650 drawFloatingBorder(frame, geometry, "float", renderer.Style{})
651
652 require.Equal(t, "──float─────", rowText(frame.Row(2)))
653 for y := 3; y < frame.Height; y++ {
654 require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders")
655 }
656}
657
658func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) {
659 content := domain.Rect{Y: 1, Width: 12, Height: 5}
660 bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}
661 inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}
662 floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner}
663 _, damage := composeCapturedFloatingFrame(floatingComposeInput{
664 baseFrame: renderer.NewFrame(12, 7),
665 floating: capturedFloatingRenderState{
666 visible: true,
667 pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)},
668 geometry: floating,
669 generation: 1,
670 },
671 content: content,
672 cache: composeCacheInput{
673 valid: true,
674 floatingGeneration: 1,
675 floatingGeometry: floatingGeometry{
676 Mode: ui.PresentationFloating,
677 Bounds: bounds,
678 Inner: inner,
679 }.translate(content.X, content.Y),
680 },
681 })
682 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
683}
684
685func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
686 base := renderer.NewFrame(80, 24)
687 content := domain.Rect{Y: 1, Width: 80, Height: 22}
688 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
689 input := floatingComposeInput{
690 baseFrame: base,
691 floating: capturedFloatingRenderState{
692 visible: true,
693 pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
694 geometry: geometry, title: "float", generation: 1, titleGeneration: 1,
695 },
696 content: content,
697 cache: composeCacheInput{
698 valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
699 },
700 }
701
702 allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
703 // Compare with the actual compact clone rather than assuming the old two
704 // dense slices. One additional allocation permits the first style insertion.
705 cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
706 require.LessOrEqual(t, allocs, cloneAllocs+1)
707 if copyEnterAllocationBudgetEnabled {
708 assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 40<<10)
709 }
710}
711
712var (
713 benchmarkComposeSink composedRenderFrame
714 benchmarkOutputSink []byte
715)
716
717// benchmarkCapturedRenderState is a stable, post-capture production input: one
718// 80x24 pane, both chrome bars, and no pending pane damage. The primed cache
719// below makes each benchmark operation exercise the cached compose path.
720func benchmarkCapturedRenderState() capturedRenderState {
721 pane := renderer.NewFrame(80, 24)
722 for y := range pane.Height {
723 for x := range pane.Width {
724 pane.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26)})
725 }
726 }
727 terminalTheme := themeui.Theme{
728 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
729 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
730 Palette: [16]renderer.RGB{
731 2: {R: 0x7d, G: 0xb5, B: 0xb5},
732 4: {R: 0x6c, G: 0x9b, B: 0xd9},
733 10: {R: 0x7d, G: 0xb5, B: 0xb5},
734 12: {R: 0x6c, G: 0x9b, B: 0xd9},
735 14: {R: 0x7d, G: 0xb5, B: 0xb5},
736 },
737 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
738 HasFG: true,
739 HasBG: true,
740 Known: true,
741 SchemeKnown: true,
742 UsePalette: true,
743 }
744 placement := layout.Placement{ID: "benchmark-pane", Content: domain.Rect{Width: 80, Height: 24}}
745 return capturedRenderState{
746 layout: capturedTabLayout{
747 area: domain.Rect{Width: 80, Height: 24},
748 focus: placement.ID,
749 placements: []layout.Placement{placement},
750 fingerprint: "benchmark-one-pane",
751 valid: true,
752 },
753 panes: []capturedPaneRenderState{{id: placement.ID, frame: pane, placement: placement, focused: true}},
754 bars: barState{
755 status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},
756 topRight: "vev",
757 bottomRight: "main",
758 mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}},
759 theme: terminalTheme,
760 },
761 theme: terminalTheme,
762 }
763}
764
765func benchmarkComposeCacheInput(state capturedRenderState) composeCacheInput {
766 priming := state
767 priming.reset = true
768 priming.panes = append([]capturedPaneRenderState(nil), state.panes...)
769 priming.panes[0].damage = []renderer.Damage{renderer.FullRedraw()}
770 return composeFrame(priming, composeCacheInput{}).cache
771}
772
773func BenchmarkComposeCapturedFrame(b *testing.B) {
774 state := benchmarkCapturedRenderState()
775 cache := benchmarkComposeCacheInput(state)
776 scratch := composeCacheInput{}
777 draw := renderer.New(renderer.Capabilities{})
778 var totalBytes int64
779
780 b.ReportAllocs()
781 b.ResetTimer()
782 for b.Loop() {
783 composed := composeFrame(state, cache, scratch)
784 scratch = composed.cache
785 draw.Reset()
786 out, err := draw.Draw(composed.frame, composed.damage)
787 if err != nil {
788 b.Fatal(err)
789 }
790 totalBytes += int64(len(out))
791 benchmarkComposeSink, benchmarkOutputSink = composed, out
792 }
793 b.ReportMetric(float64(totalBytes)/float64(b.N), "output-bytes/op")
794}
795
796func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
797 base := renderer.NewFrame(80, 24)
798 content := domain.Rect{Y: 1, Width: 80, Height: 22}
799 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
800 input := floatingComposeInput{
801 baseFrame: base,
802 floating: capturedFloatingRenderState{
803 visible: true,
804 pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
805 geometry: geometry,
806 title: "float",
807 generation: 1,
808 titleGeneration: 1,
809 },
810 content: content,
811 theme: themeui.Theme{},
812 cache: composeCacheInput{
813 valid: true,
814 floatingGeneration: 1,
815 floatingGeometry: geometry.translate(content.X, content.Y),
816 floatingTitleGeneration: 1,
817 },
818 }
819
820 b.ReportAllocs()
821 b.ResetTimer()
822 for b.Loop() {
823 composeCapturedFloatingFrame(input)
824 }
825}
826
827func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
828 d := newTestDaemon(t, nil, stubClock{})
829 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
830 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
831 sess := &session{tabs: []*tab{tb}}
832
833 initialGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, d.currentFloatingConfig())
834 currentGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 40}, d.currentFloatingConfig())
835 initial := initialGeometry.Inner
836 current := currentGeometry.Inner
837 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
838 floating := newPane("floating", pty, rectSize(initial))
839 floating.rect = initial
840 floating.popupGeometry = initialGeometry
841
842 // Prewarming installs a hidden pane at the initial geometry.
843 tb.mu.Lock()
844 generation := tb.beginFloatingWarmLocked(false)
845 require.True(t, tb.installFloatingLocked(floating, generation))
846 require.Equal(t, floatingHidden, tb.floating.state)
847 require.False(t, tb.floating.desiredVisible)
848 tb.mu.Unlock()
849 // A client resize changes tab geometry but must leave the hidden PTY alone.
850 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
851 require.Empty(t, pty.sizes())
852
853 // Showing attempts the current size before paint. A failed resize retains
854 // the complete previously committed render and input geometry.
855 require.NoError(t, d.toggleFloating(sess, nil))
856 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
857 require.Equal(t, initial, floating.rect)
858 require.Equal(t, initial.Width, floating.screen.Columns())
859 require.Equal(t, initial.Height, floating.screen.Rows())
860 require.Equal(t, initialGeometry, floating.popupGeometry)
861
862 require.NoError(t, d.toggleFloating(sess, nil)) // hide
863 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
864 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
865 require.Equal(t, current, floating.rect)
866 require.Equal(t, current.Width, floating.screen.Columns())
867 require.Equal(t, current.Height, floating.screen.Rows())
868 require.Equal(t, currentGeometry, floating.popupGeometry)
869}
870
871func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
872 cfg := domain.FloatingConfig{Width: 50, Height: 50}
873 oldContent := domain.Rect{Width: 80, Height: 24}
874 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
875 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
876 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
877 pty := &resizePTY{err: errors.New("resize failed")}
878 p := newPane("floating", pty, rectSize(oldGeometry.Inner))
879 p.rect = oldGeometry.Inner
880 p.popupGeometry = oldGeometry
881 tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
882 installTestFloating(tb, p, true)
883 d := newTestDaemon(t, nil, stubClock{})
884 d.ApplyConfig(domain.Config{Floating: cfg})
885
886 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
887 tb.mu.Lock()
888 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
889 tb.mu.Unlock()
890 require.True(t, visible)
891 require.Equal(t, oldGeometry, inputGeometry)
892
893 p.mu.Lock()
894 captured := capturePaneRenderStateLockedInto(p, oldGeometry.Inner, capturedPaneRenderState{})
895 p.mu.Unlock()
896 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
897 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
898 baseFrame: base,
899 floating: capturedFloatingRenderState{
900 visible: true,
901 pane: captured,
902 geometry: oldGeometry,
903 title: captured.title,
904 generation: 1,
905 titleGeneration: captured.titleGeneration,
906 },
907 content: newContent,
908 theme: themeui.Theme{},
909 full: true,
910 })
911 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
912 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
913 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
914 if newFrameGeometry.Bounds.X != oldFrameGeometry.Bounds.X || newFrameGeometry.Bounds.Y != oldFrameGeometry.Bounds.Y {
915 require.NotEqual(t, '┌', frame.At(newFrameGeometry.Bounds.X, newFrameGeometry.Bounds.Y).Rune)
916 }
917}
918
919func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
920 cfg := domain.FloatingConfig{Width: 50, Height: 50}
921 oldSize := domain.Size{Cols: 80, Rows: 24}
922 newSize := domain.Size{Cols: 100, Rows: 40}
923 previousGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
924 oldGeometry := calculateContentFloatingGeometry(oldSize, cfg)
925 newGeometry := calculateContentFloatingGeometry(newSize, cfg)
926 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
927 p := newPane("floating", pty, rectSize(previousGeometry.Inner))
928 p.rect = previousGeometry.Inner
929 p.popupGeometry = previousGeometry
930 tb := newTab(nil, oldSize)
931 installTestFloating(tb, p, true)
932 d := newTestDaemon(t, nil, stubClock{})
933 d.ApplyConfig(domain.Config{Floating: cfg})
934 sess := &session{tabs: []*tab{tb}}
935
936 done := make(chan bool, 1)
937 go func() {
938 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
939 done <- ok
940 }()
941 <-pty.entered
942 tb.mu.Lock()
943 tb.size = newSize
944 tb.bumpLayoutGenerationLocked()
945 tb.mu.Unlock()
946 close(pty.release)
947 require.True(t, <-done)
948
949 p.mu.Lock()
950 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
951 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
952 require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen")
953 require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen")
954 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
955 p.mu.Unlock()
956
957 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
958 require.True(t, ok)
959 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
960 require.Equal(t, newGeometry.Inner, p.rect)
961 require.Equal(t, newGeometry, p.popupGeometry)
962 require.Equal(t, newGeometry.Inner.Width, p.screen.Columns())
963 require.Equal(t, newGeometry.Inner.Height, p.screen.Rows())
964}
965
966func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
967 t.Run("failure preserves state", func(t *testing.T) {
968 pty := &resizePTY{err: errors.New("nope")}
969 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
970 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
971 d := newTestDaemon(t, nil, stubClock{})
972 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
973 require.False(t, applyFloatingResizePlanForTest(d, p, requested))
974 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
975 require.Equal(t, floatingGeometry{}, p.popupGeometry)
976 require.Equal(t, 5, p.screen.Columns())
977 require.Equal(t, 4, p.screen.Rows())
978 })
979
980 t.Run("competing resizes serialize", func(t *testing.T) {
981 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
982 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
983 d := newTestDaemon(t, nil, stubClock{})
984 first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
985 second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
986 done1 := make(chan bool, 1)
987 done2 := make(chan bool, 1)
988 secondStarted := make(chan struct{})
989 go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
990 <-pty.entered
991 go func() {
992 close(secondStarted)
993 done2 <- applyFloatingResizePlanForTest(d, p, second)
994 }()
995 <-secondStarted
996 close(pty.release)
997 require.True(t, <-done1)
998 require.True(t, <-done2)
999 require.Equal(t, second.Inner, p.rect)
1000 require.Equal(t, second, p.popupGeometry)
1001 require.Equal(t, 1, pty.maxConcurrentCalls())
1002 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
1003 p.mu.Lock()
1004 applying, pending := p.resizeApplying, append([]byte(nil), p.resizePending...)
1005 p.mu.Unlock()
1006 require.False(t, applying, "successful helper apply must release its parser gate")
1007 require.Empty(t, pending, "successful helper apply must drain pending parser bytes")
1008 })
1009}
1010
1011type resizePTY struct {
1012 mu sync.Mutex
1013 resizes []domain.Size
1014 err error
1015 errs []error
1016 entered chan struct{}
1017 release chan struct{}
1018 activeCalls int
1019 maxConcurrent int
1020}
1021
1022func (p *resizePTY) Resize(geometry domain.Geometry) error {
1023 p.mu.Lock()
1024 p.resizes = append(p.resizes, geometry.Size)
1025 p.activeCalls++
1026 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
1027 n := len(p.resizes)
1028 err := p.err
1029 if n <= len(p.errs) {
1030 err = p.errs[n-1]
1031 }
1032 p.mu.Unlock()
1033 if n == 1 && p.entered != nil {
1034 close(p.entered)
1035 <-p.release
1036 }
1037 p.mu.Lock()
1038 p.activeCalls--
1039 p.mu.Unlock()
1040 return err
1041}
1042func (p *resizePTY) maxConcurrentCalls() int {
1043 p.mu.Lock()
1044 defer p.mu.Unlock()
1045 return p.maxConcurrent
1046}
1047func (p *resizePTY) sizes() []domain.Size {
1048 p.mu.Lock()
1049 defer p.mu.Unlock()
1050 return append([]domain.Size(nil), p.resizes...)
1051}
1052func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
1053func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
1054func (*resizePTY) Close() error { return nil }
1055func (*resizePTY) Pid() int { return 0 }
1056func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }