floating_render_test.go RawHistoryFolder

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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close