v0.3.0 99dc266

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

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