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