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