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/usecase/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 TrueColor: true,
741 Known: true,
742 SchemeKnown: true,
743 UsePalette: true,
744 }
745 placement := layout.Placement{ID: "benchmark-pane", Content: domain.Rect{Width: 80, Height: 24}}
746 return capturedRenderState{
747 layout: capturedTabLayout{
748 area: domain.Rect{Width: 80, Height: 24},
749 focus: placement.ID,
750 placements: []layout.Placement{placement},
751 fingerprint: "benchmark-one-pane",
752 valid: true,
753 },
754 panes: []capturedPaneRenderState{{id: placement.ID, frame: pane, placement: placement, focused: true}},
755 bars: barState{
756 status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},
757 topRight: "vev",
758 bottomRight: "main",
759 mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}},
760 theme: terminalTheme,
761 },
762 theme: terminalTheme,
763 }
764}
765
766func benchmarkComposeCacheInput(state capturedRenderState) composeCacheInput {
767 priming := state
768 priming.reset = true
769 priming.panes = append([]capturedPaneRenderState(nil), state.panes...)
770 priming.panes[0].damage = []renderer.Damage{renderer.FullRedraw()}
771 return composeFrame(priming, composeCacheInput{}).cache
772}
773
774func BenchmarkComposeCapturedFrame(b *testing.B) {
775 state := benchmarkCapturedRenderState()
776 cache := benchmarkComposeCacheInput(state)
777 scratch := composeCacheInput{}
778 draw := renderer.New(renderer.Capabilities{})
779 var totalBytes int64
780
781 b.ReportAllocs()
782 b.ResetTimer()
783 for b.Loop() {
784 composed := composeFrame(state, cache, scratch)
785 scratch = composed.cache
786 draw.Reset()
787 out, err := draw.Draw(composed.frame, composed.damage)
788 if err != nil {
789 b.Fatal(err)
790 }
791 totalBytes += int64(len(out))
792 benchmarkComposeSink, benchmarkOutputSink = composed, out
793 }
794 b.ReportMetric(float64(totalBytes)/float64(b.N), "output-bytes/op")
795}
796
797func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
798 base := renderer.NewFrame(80, 24)
799 content := domain.Rect{Y: 1, Width: 80, Height: 22}
800 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
801 input := floatingComposeInput{
802 baseFrame: base,
803 floating: capturedFloatingRenderState{
804 visible: true,
805 pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
806 geometry: geometry,
807 title: "float",
808 generation: 1,
809 titleGeneration: 1,
810 },
811 content: content,
812 theme: themeui.Theme{},
813 cache: composeCacheInput{
814 valid: true,
815 floatingGeneration: 1,
816 floatingGeometry: geometry.translate(content.X, content.Y),
817 floatingTitleGeneration: 1,
818 },
819 }
820
821 b.ReportAllocs()
822 b.ResetTimer()
823 for b.Loop() {
824 composeCapturedFloatingFrame(input)
825 }
826}
827
828func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
829 d := newTestDaemon(t, nil, stubClock{})
830 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
831 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
832 sess := &session{tabs: []*tab{tb}}
833
834 initialGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, d.currentFloatingConfig())
835 currentGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 40}, d.currentFloatingConfig())
836 initial := initialGeometry.Inner
837 current := currentGeometry.Inner
838 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
839 floating := newPane("floating", pty, rectSize(initial))
840 floating.rect = initial
841 floating.popupGeometry = initialGeometry
842
843 // Prewarming installs a hidden pane at the initial geometry.
844 tb.mu.Lock()
845 generation := tb.beginFloatingWarmLocked(false)
846 require.True(t, tb.installFloatingLocked(floating, generation))
847 require.Equal(t, floatingHidden, tb.floating.state)
848 require.False(t, tb.floating.desiredVisible)
849 tb.mu.Unlock()
850 // A client resize changes tab geometry but must leave the hidden PTY alone.
851 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
852 require.Empty(t, pty.sizes())
853
854 // Showing attempts the current size before paint. A failed resize retains
855 // the complete previously committed render and input geometry.
856 require.NoError(t, d.toggleFloating(sess, nil))
857 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
858 require.Equal(t, initial, floating.rect)
859 require.Equal(t, initial.Width, floating.screen.Columns())
860 require.Equal(t, initial.Height, floating.screen.Rows())
861 require.Equal(t, initialGeometry, floating.popupGeometry)
862
863 require.NoError(t, d.toggleFloating(sess, nil)) // hide
864 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
865 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
866 require.Equal(t, current, floating.rect)
867 require.Equal(t, current.Width, floating.screen.Columns())
868 require.Equal(t, current.Height, floating.screen.Rows())
869 require.Equal(t, currentGeometry, floating.popupGeometry)
870}
871
872func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
873 cfg := domain.FloatingConfig{Width: 50, Height: 50}
874 oldContent := domain.Rect{Width: 80, Height: 24}
875 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
876 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
877 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
878 pty := &resizePTY{err: errors.New("resize failed")}
879 p := newPane("floating", pty, rectSize(oldGeometry.Inner))
880 p.rect = oldGeometry.Inner
881 p.popupGeometry = oldGeometry
882 tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
883 installTestFloating(tb, p, true)
884 d := newTestDaemon(t, nil, stubClock{})
885 d.ApplyConfig(domain.Config{Floating: cfg})
886
887 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
888 tb.mu.Lock()
889 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
890 tb.mu.Unlock()
891 require.True(t, visible)
892 require.Equal(t, oldGeometry, inputGeometry)
893
894 p.mu.Lock()
895 captured := capturePaneRenderStateLockedInto(p, oldGeometry.Inner, capturedPaneRenderState{})
896 p.mu.Unlock()
897 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
898 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
899 baseFrame: base,
900 floating: capturedFloatingRenderState{
901 visible: true,
902 pane: captured,
903 geometry: oldGeometry,
904 title: captured.title,
905 generation: 1,
906 titleGeneration: captured.titleGeneration,
907 },
908 content: newContent,
909 theme: themeui.Theme{},
910 full: true,
911 })
912 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
913 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
914 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
915 if newFrameGeometry.Bounds.X != oldFrameGeometry.Bounds.X || newFrameGeometry.Bounds.Y != oldFrameGeometry.Bounds.Y {
916 require.NotEqual(t, '┌', frame.At(newFrameGeometry.Bounds.X, newFrameGeometry.Bounds.Y).Rune)
917 }
918}
919
920func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
921 cfg := domain.FloatingConfig{Width: 50, Height: 50}
922 oldSize := domain.Size{Cols: 80, Rows: 24}
923 newSize := domain.Size{Cols: 100, Rows: 40}
924 previousGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
925 oldGeometry := calculateContentFloatingGeometry(oldSize, cfg)
926 newGeometry := calculateContentFloatingGeometry(newSize, cfg)
927 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
928 p := newPane("floating", pty, rectSize(previousGeometry.Inner))
929 p.rect = previousGeometry.Inner
930 p.popupGeometry = previousGeometry
931 tb := newTab(nil, oldSize)
932 installTestFloating(tb, p, true)
933 d := newTestDaemon(t, nil, stubClock{})
934 d.ApplyConfig(domain.Config{Floating: cfg})
935 sess := &session{tabs: []*tab{tb}}
936
937 done := make(chan bool, 1)
938 go func() {
939 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
940 done <- ok
941 }()
942 <-pty.entered
943 tb.mu.Lock()
944 tb.size = newSize
945 tb.bumpLayoutGenerationLocked()
946 tb.mu.Unlock()
947 close(pty.release)
948 require.True(t, <-done)
949
950 p.mu.Lock()
951 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
952 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
953 require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen")
954 require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen")
955 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
956 p.mu.Unlock()
957
958 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
959 require.True(t, ok)
960 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
961 require.Equal(t, newGeometry.Inner, p.rect)
962 require.Equal(t, newGeometry, p.popupGeometry)
963 require.Equal(t, newGeometry.Inner.Width, p.screen.Columns())
964 require.Equal(t, newGeometry.Inner.Height, p.screen.Rows())
965}
966
967func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
968 t.Run("failure preserves state", func(t *testing.T) {
969 pty := &resizePTY{err: errors.New("nope")}
970 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
971 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
972 d := newTestDaemon(t, nil, stubClock{})
973 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
974 require.False(t, applyFloatingResizePlanForTest(d, p, requested))
975 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
976 require.Equal(t, floatingGeometry{}, p.popupGeometry)
977 require.Equal(t, 5, p.screen.Columns())
978 require.Equal(t, 4, p.screen.Rows())
979 })
980
981 t.Run("competing resizes serialize", func(t *testing.T) {
982 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
983 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
984 d := newTestDaemon(t, nil, stubClock{})
985 first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
986 second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
987 done1 := make(chan bool, 1)
988 done2 := make(chan bool, 1)
989 secondStarted := make(chan struct{})
990 go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
991 <-pty.entered
992 go func() {
993 close(secondStarted)
994 done2 <- applyFloatingResizePlanForTest(d, p, second)
995 }()
996 <-secondStarted
997 close(pty.release)
998 require.True(t, <-done1)
999 require.True(t, <-done2)
1000 require.Equal(t, second.Inner, p.rect)
1001 require.Equal(t, second, p.popupGeometry)
1002 require.Equal(t, 1, pty.maxConcurrentCalls())
1003 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
1004 p.mu.Lock()
1005 applying, pending := p.resizeApplying, append([]byte(nil), p.resizePending...)
1006 p.mu.Unlock()
1007 require.False(t, applying, "successful helper apply must release its parser gate")
1008 require.Empty(t, pending, "successful helper apply must drain pending parser bytes")
1009 })
1010}
1011
1012type resizePTY struct {
1013 mu sync.Mutex
1014 resizes []domain.Size
1015 err error
1016 errs []error
1017 entered chan struct{}
1018 release chan struct{}
1019 activeCalls int
1020 maxConcurrent int
1021}
1022
1023func (p *resizePTY) Resize(geometry domain.Geometry) error {
1024 p.mu.Lock()
1025 p.resizes = append(p.resizes, geometry.Size)
1026 p.activeCalls++
1027 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
1028 n := len(p.resizes)
1029 err := p.err
1030 if n <= len(p.errs) {
1031 err = p.errs[n-1]
1032 }
1033 p.mu.Unlock()
1034 if n == 1 && p.entered != nil {
1035 close(p.entered)
1036 <-p.release
1037 }
1038 p.mu.Lock()
1039 p.activeCalls--
1040 p.mu.Unlock()
1041 return err
1042}
1043func (p *resizePTY) maxConcurrentCalls() int {
1044 p.mu.Lock()
1045 defer p.mu.Unlock()
1046 return p.maxConcurrent
1047}
1048func (p *resizePTY) sizes() []domain.Size {
1049 p.mu.Lock()
1050 defer p.mu.Unlock()
1051 return append([]domain.Size(nil), p.resizes...)
1052}
1053func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
1054func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
1055func (*resizePTY) Close() error { return nil }
1056func (*resizePTY) Pid() int { return 0 }
1057func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }