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
712// TestComposeCapturedFloatingFrameScratchReusesPopupPage pins that a primed
713// scratch page removes the per-frame base clone, leaves the base untouched,
714// and yields exactly the frame an unscratched composition builds.
715func TestComposeCapturedFloatingFrameScratchReusesPopupPage(t *testing.T) {
716 base := renderer.NewFrame(80, 24)
717 for y := range base.Height {
718 for x := range base.Width {
719 base.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
720 }
721 }
722 content := domain.Rect{Y: 1, Width: 80, Height: 22}
723 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
724 input := floatingComposeInput{
725 baseFrame: base,
726 floating: capturedFloatingRenderState{
727 visible: true,
728 pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
729 geometry: geometry,
730 title: "float",
731 generation: 1,
732 titleGeneration: 1,
733 },
734 content: content,
735 cache: composeCacheInput{
736 valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
737 },
738 }
739 baseBefore := frameRows(base)
740 want, wantDamage := composeCapturedFloatingFrame(input)
741
742 var popup renderer.Frame
743 input.scratch = &popup
744 got, gotDamage := composeCapturedFloatingFrame(input) // sizes the scratch page
745 require.Equal(t, frameRows(want), frameRows(got))
746 require.Equal(t, wantDamage, gotDamage)
747 require.False(t, framesShareStorage(got, base), "popup frame must not alias the base page")
748 require.Equal(t, baseBefore, frameRows(base), "base must stay free of popup cells")
749
750 allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
751 cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
752 require.Less(t, allocs, cloneAllocs, "scratch composition must allocate less than a clone")
753 if copyEnterAllocationBudgetEnabled {
754 assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 1<<10)
755 }
756}
757
758var (
759 benchmarkComposeSink composedRenderFrame
760 benchmarkOutputSink []byte
761)
762
763// benchmarkCapturedRenderState is a stable, post-capture production input: one
764// 80x24 pane, both chrome bars, and no pending pane damage. The primed cache
765// below makes each benchmark operation exercise the cached compose path.
766func benchmarkCapturedRenderState() capturedRenderState {
767 pane := renderer.NewFrame(80, 24)
768 for y := range pane.Height {
769 for x := range pane.Width {
770 pane.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26)})
771 }
772 }
773 terminalTheme := themeui.Theme{
774 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
775 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
776 Palette: [16]renderer.RGB{
777 2: {R: 0x7d, G: 0xb5, B: 0xb5},
778 4: {R: 0x6c, G: 0x9b, B: 0xd9},
779 10: {R: 0x7d, G: 0xb5, B: 0xb5},
780 12: {R: 0x6c, G: 0x9b, B: 0xd9},
781 14: {R: 0x7d, G: 0xb5, B: 0xb5},
782 },
783 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
784 HasFG: true,
785 HasBG: true,
786 Known: true,
787 SchemeKnown: true,
788 UsePalette: true,
789 }
790 placement := layout.Placement{ID: "benchmark-pane", Content: domain.Rect{Width: 80, Height: 24}}
791 return capturedRenderState{
792 layout: capturedTabLayout{
793 area: domain.Rect{Width: 80, Height: 24},
794 focus: placement.ID,
795 placements: []layout.Placement{placement},
796 fingerprint: "benchmark-one-pane",
797 valid: true,
798 },
799 panes: []capturedPaneRenderState{{id: placement.ID, frame: pane, placement: placement, focused: true}},
800 bars: barState{
801 status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},
802 topRight: "vev",
803 bottomRight: "main",
804 mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}},
805 theme: terminalTheme,
806 },
807 theme: terminalTheme,
808 }
809}
810
811func benchmarkComposeCacheInput(state capturedRenderState) composeCacheInput {
812 priming := state
813 priming.reset = true
814 priming.panes = append([]capturedPaneRenderState(nil), state.panes...)
815 priming.panes[0].damage = []renderer.Damage{renderer.FullRedraw()}
816 return composeFrame(priming, composeCacheInput{}).cache
817}
818
819func BenchmarkComposeCapturedFrame(b *testing.B) {
820 state := benchmarkCapturedRenderState()
821 cache := benchmarkComposeCacheInput(state)
822 scratch := composeCacheInput{}
823 draw := renderer.New(renderer.Capabilities{})
824 var totalBytes int64
825
826 b.ReportAllocs()
827 b.ResetTimer()
828 for b.Loop() {
829 composed := composeFrame(state, cache, scratch)
830 scratch = composed.cache
831 draw.Reset()
832 out, err := draw.Draw(composed.frame, composed.damage)
833 if err != nil {
834 b.Fatal(err)
835 }
836 totalBytes += int64(len(out))
837 benchmarkComposeSink, benchmarkOutputSink = composed, out
838 }
839 b.ReportMetric(float64(totalBytes)/float64(b.N), "output-bytes/op")
840}
841
842func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
843 base := renderer.NewFrame(80, 24)
844 content := domain.Rect{Y: 1, Width: 80, Height: 22}
845 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
846 input := floatingComposeInput{
847 baseFrame: base,
848 floating: capturedFloatingRenderState{
849 visible: true,
850 pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
851 geometry: geometry,
852 title: "float",
853 generation: 1,
854 titleGeneration: 1,
855 },
856 content: content,
857 theme: themeui.Theme{},
858 cache: composeCacheInput{
859 valid: true,
860 floatingGeneration: 1,
861 floatingGeometry: geometry.translate(content.X, content.Y),
862 floatingTitleGeneration: 1,
863 },
864 }
865
866 // Production passes the attachment's reusable popup page.
867 var popup renderer.Frame
868 input.scratch = &popup
869
870 b.ReportAllocs()
871 b.ResetTimer()
872 for b.Loop() {
873 composeCapturedFloatingFrame(input)
874 }
875}
876
877func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
878 d := newTestDaemon(t, nil, stubClock{})
879 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
880 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
881 sess := &session{tabs: []*tab{tb}}
882
883 initialGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, d.currentFloatingConfig())
884 currentGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 40}, d.currentFloatingConfig())
885 initial := initialGeometry.Inner
886 current := currentGeometry.Inner
887 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
888 floating := newPane("floating", pty, rectSize(initial))
889 floating.rect = initial
890 floating.popupGeometry = initialGeometry
891
892 // Prewarming installs a hidden pane at the initial geometry.
893 tb.mu.Lock()
894 generation := tb.beginFloatingWarmLocked(false)
895 require.True(t, tb.installFloatingLocked(floating, generation))
896 require.Equal(t, floatingHidden, tb.floating.state)
897 require.False(t, tb.floating.desiredVisible)
898 tb.mu.Unlock()
899 // A client resize changes tab geometry but must leave the hidden PTY alone.
900 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
901 require.Empty(t, pty.sizes())
902
903 // Showing attempts the current size before paint. A failed resize retains
904 // the complete previously committed render and input geometry.
905 require.NoError(t, d.toggleFloating(sess, nil))
906 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
907 require.Equal(t, initial, floating.rect)
908 require.Equal(t, initial.Width, floating.screen.Columns())
909 require.Equal(t, initial.Height, floating.screen.Rows())
910 require.Equal(t, initialGeometry, floating.popupGeometry)
911
912 require.NoError(t, d.toggleFloating(sess, nil)) // hide
913 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
914 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
915 require.Equal(t, current, floating.rect)
916 require.Equal(t, current.Width, floating.screen.Columns())
917 require.Equal(t, current.Height, floating.screen.Rows())
918 require.Equal(t, currentGeometry, floating.popupGeometry)
919}
920
921func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
922 cfg := domain.FloatingConfig{Width: 50, Height: 50}
923 oldContent := domain.Rect{Width: 80, Height: 24}
924 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
925 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
926 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
927 pty := &resizePTY{err: errors.New("resize failed")}
928 p := newPane("floating", pty, rectSize(oldGeometry.Inner))
929 p.rect = oldGeometry.Inner
930 p.popupGeometry = oldGeometry
931 tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
932 installTestFloating(tb, p, true)
933 d := newTestDaemon(t, nil, stubClock{})
934 d.ApplyConfig(domain.Config{Floating: cfg})
935
936 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
937 tb.mu.Lock()
938 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
939 tb.mu.Unlock()
940 require.True(t, visible)
941 require.Equal(t, oldGeometry, inputGeometry)
942
943 p.mu.Lock()
944 captured := capturePaneRenderStateLockedInto(p, oldGeometry.Inner, capturedPaneRenderState{})
945 p.mu.Unlock()
946 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
947 frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
948 baseFrame: base,
949 floating: capturedFloatingRenderState{
950 visible: true,
951 pane: captured,
952 geometry: oldGeometry,
953 title: captured.title,
954 generation: 1,
955 titleGeneration: captured.titleGeneration,
956 },
957 content: newContent,
958 theme: themeui.Theme{},
959 full: true,
960 })
961 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
962 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
963 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
964 if newFrameGeometry.Bounds.X != oldFrameGeometry.Bounds.X || newFrameGeometry.Bounds.Y != oldFrameGeometry.Bounds.Y {
965 require.NotEqual(t, '┌', frame.At(newFrameGeometry.Bounds.X, newFrameGeometry.Bounds.Y).Rune)
966 }
967}
968
969func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
970 cfg := domain.FloatingConfig{Width: 50, Height: 50}
971 oldSize := domain.Size{Cols: 80, Rows: 24}
972 newSize := domain.Size{Cols: 100, Rows: 40}
973 previousGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
974 oldGeometry := calculateContentFloatingGeometry(oldSize, cfg)
975 newGeometry := calculateContentFloatingGeometry(newSize, cfg)
976 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
977 p := newPane("floating", pty, rectSize(previousGeometry.Inner))
978 p.rect = previousGeometry.Inner
979 p.popupGeometry = previousGeometry
980 tb := newTab(nil, oldSize)
981 installTestFloating(tb, p, true)
982 d := newTestDaemon(t, nil, stubClock{})
983 d.ApplyConfig(domain.Config{Floating: cfg})
984 sess := &session{tabs: []*tab{tb}}
985
986 done := make(chan bool, 1)
987 go func() {
988 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
989 done <- ok
990 }()
991 <-pty.entered
992 tb.mu.Lock()
993 tb.size = newSize
994 tb.bumpLayoutGenerationLocked()
995 tb.mu.Unlock()
996 close(pty.release)
997 require.True(t, <-done)
998
999 p.mu.Lock()
1000 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
1001 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
1002 require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen")
1003 require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen")
1004 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
1005 p.mu.Unlock()
1006
1007 _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
1008 require.True(t, ok)
1009 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
1010 require.Equal(t, newGeometry.Inner, p.rect)
1011 require.Equal(t, newGeometry, p.popupGeometry)
1012 require.Equal(t, newGeometry.Inner.Width, p.screen.Columns())
1013 require.Equal(t, newGeometry.Inner.Height, p.screen.Rows())
1014}
1015
1016func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
1017 t.Run("failure preserves state", func(t *testing.T) {
1018 pty := &resizePTY{err: errors.New("nope")}
1019 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
1020 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
1021 d := newTestDaemon(t, nil, stubClock{})
1022 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
1023 require.False(t, applyFloatingResizePlanForTest(d, p, requested))
1024 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
1025 require.Equal(t, floatingGeometry{}, p.popupGeometry)
1026 require.Equal(t, 5, p.screen.Columns())
1027 require.Equal(t, 4, p.screen.Rows())
1028 })
1029
1030 t.Run("competing resizes serialize", func(t *testing.T) {
1031 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
1032 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
1033 d := newTestDaemon(t, nil, stubClock{})
1034 first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
1035 second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
1036 done1 := make(chan bool, 1)
1037 done2 := make(chan bool, 1)
1038 secondStarted := make(chan struct{})
1039 go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
1040 <-pty.entered
1041 go func() {
1042 close(secondStarted)
1043 done2 <- applyFloatingResizePlanForTest(d, p, second)
1044 }()
1045 <-secondStarted
1046 close(pty.release)
1047 require.True(t, <-done1)
1048 require.True(t, <-done2)
1049 require.Equal(t, second.Inner, p.rect)
1050 require.Equal(t, second, p.popupGeometry)
1051 require.Equal(t, 1, pty.maxConcurrentCalls())
1052 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
1053 p.mu.Lock()
1054 applying, pending := p.resizeApplying, append([]byte(nil), p.resizePending...)
1055 p.mu.Unlock()
1056 require.False(t, applying, "successful helper apply must release its parser gate")
1057 require.Empty(t, pending, "successful helper apply must drain pending parser bytes")
1058 })
1059}
1060
1061type resizePTY struct {
1062 mu sync.Mutex
1063 resizes []domain.Size
1064 err error
1065 errs []error
1066 entered chan struct{}
1067 release chan struct{}
1068 activeCalls int
1069 maxConcurrent int
1070}
1071
1072func (p *resizePTY) Resize(geometry domain.Geometry) error {
1073 p.mu.Lock()
1074 p.resizes = append(p.resizes, geometry.Size)
1075 p.activeCalls++
1076 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
1077 n := len(p.resizes)
1078 err := p.err
1079 if n <= len(p.errs) {
1080 err = p.errs[n-1]
1081 }
1082 p.mu.Unlock()
1083 if n == 1 && p.entered != nil {
1084 close(p.entered)
1085 <-p.release
1086 }
1087 p.mu.Lock()
1088 p.activeCalls--
1089 p.mu.Unlock()
1090 return err
1091}
1092func (p *resizePTY) maxConcurrentCalls() int {
1093 p.mu.Lock()
1094 defer p.mu.Unlock()
1095 return p.maxConcurrent
1096}
1097func (p *resizePTY) sizes() []domain.Size {
1098 p.mu.Lock()
1099 defer p.mu.Unlock()
1100 return append([]domain.Size(nil), p.resizes...)
1101}
1102func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
1103func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
1104func (*resizePTY) Close() error { return nil }
1105func (*resizePTY) Pid() int { return 0 }
1106func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }