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

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