detached 8476ebc

refactor(daemon): group floating composition inputs

commit
8476ebc055405abf26be95c5636532440e9f871c
author
brice · · 2026-07-14 19:53 UTC
parent
31d36ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +126 -59
M internal/usecase/daemon/floating_render.go +22 -2
@@ -9,7 +9,7 @@ import (
99
1010 // floatingGeometry describes a popup's outer frame and terminal content area.
1111 // Bounds and Inner always share the same coordinate space. Committed pane
12-// geometry is origin-zero and tab-content-relative; composeFloatingFrame
12+// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
1313 // translates both fields and returns/caches frame-absolute geometry. Callers
1414 // must not translate a composed or cached value again.
1515 type floatingGeometry struct {
@@ -85,7 +85,27 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8585 }
8686 }
8787
88-func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
88+type floatingComposeInput struct {
89+ baseFrame renderer.Frame
90+ baseDamage []renderer.Damage
91+ floating capturedFloatingRenderState
92+ content domain.Rect
93+ layout capturedTabLayout
94+ theme themeui.Theme
95+ cache composeCacheInput
96+ full bool
97+}
98+
99+func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
100+ base := input.baseFrame
101+ baseDamage := input.baseDamage
102+ floating := input.floating
103+ content := input.content
104+ layoutSnap := input.layout
105+ theme := input.theme
106+ cache := input.cache
107+ full := input.full
108+
89109 frame := base.Clone()
90110 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91111 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
@@ -9,7 +9,7 @@ import (
9 9
10 // floatingGeometry describes a popup's outer frame and terminal content area.10 // floatingGeometry describes a popup's outer frame and terminal content area.
11 // Bounds and Inner always share the same coordinate space. Committed pane11 // Bounds and Inner always share the same coordinate space. Committed pane
12-// geometry is origin-zero and tab-content-relative; composeFloatingFrame12+// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
13 // translates both fields and returns/caches frame-absolute geometry. Callers13 // translates both fields and returns/caches frame-absolute geometry. Callers
14 // must not translate a composed or cached value again.14 // must not translate a composed or cached value again.
15 type floatingGeometry struct {15 type floatingGeometry struct {
@@ -85,7 +85,27 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }85 }
86 }86 }
87 87
88-func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {88+type floatingComposeInput struct {
89+ baseFrame renderer.Frame
90+ baseDamage []renderer.Damage
91+ floating capturedFloatingRenderState
92+ content domain.Rect
93+ layout capturedTabLayout
94+ theme themeui.Theme
95+ cache composeCacheInput
96+ full bool
97+}
98+
99+func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
100+ base := input.baseFrame
101+ baseDamage := input.baseDamage
102+ floating := input.floating
103+ content := input.content
104+ layoutSnap := input.layout
105+ theme := input.theme
106+ cache := input.cache
107+ full := input.full
108+
89 frame := base.Clone()109 frame := base.Clone()
90 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}110 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)111 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
M internal/usecase/daemon/floating_render_test.go +94 -56
@@ -128,35 +128,56 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
128128 }
129129 }
130130
131-func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
132- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
133- p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
134- p.title.processName, p.title.terminalTitle, p.title.generation = "nvim", "project/main.go", 1
131+func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
135132 base := renderer.NewFrame(40, 12)
136133 base.Set(2, 2, renderer.Cell{Rune: 'B'})
137134 content := domain.Rect{Y: 1, Width: 40, Height: 10}
138- cache := &composedFrameCache{}
139- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
135+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
136+ paneFrame := renderer.NewFrame(6, 3)
137+ paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
138+ floating := capturedFloatingRenderState{
139+ visible: true,
140+ pane: capturedPaneRenderState{frame: paneFrame, title: "nvim: project/main.go", titleGeneration: 1},
141+ geometry: geometry,
142+ title: "nvim: project/main.go",
143+ generation: 1,
144+ titleGeneration: 1,
145+ }
146+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
147+ baseFrame: base,
148+ floating: floating,
149+ content: content,
150+ theme: themeui.Theme{},
151+ })
140152 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")
141- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}).translate(content.X, content.Y)
142- require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)
143- require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)
153+ frameGeometry := geometry.translate(content.X, content.Y)
154+ require.Equal(t, '┌', frame.At(frameGeometry.Bounds.X, frameGeometry.Bounds.Y).Rune)
155+ require.Equal(t, 'F', frame.At(frameGeometry.Inner.X, frameGeometry.Inner.Y).Rune)
144156 var gotTitle strings.Builder
145- for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
146- gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
157+ for x := frameGeometry.Bounds.X + 2; x < frameGeometry.Bounds.X+frameGeometry.Bounds.Width-2; x++ {
158+ gotTitle.WriteRune(frame.At(x, frameGeometry.Bounds.Y).Rune)
147159 }
148160 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
149161
150- p.mu.Lock()
151- p.title.generation++
152- p.mu.Unlock()
153- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
162+ floating.titleGeneration++
163+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
164+ baseFrame: base,
165+ floating: floating,
166+ content: content,
167+ theme: themeui.Theme{},
168+ cache: composeCacheInput{
169+ valid: true,
170+ floatingGeneration: 1,
171+ floatingGeometry: frameGeometry,
172+ floatingTitleGeneration: 1,
173+ },
174+ })
154175 require.Len(t, damage, 1)
155- require.Equal(t, geometry.Bounds.Y, damage[0].Y)
176+ require.Equal(t, frameGeometry.Bounds.Y, damage[0].Y)
156177 require.Equal(t, 1, damage[0].Height)
157178 }
158179
159-func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
180+func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
160181 initial := floatingGeometry{
161182 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
162183 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
@@ -176,20 +197,27 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
176197 }
177198 for _, tt := range tests {
178199 t.Run(tt.name, func(t *testing.T) {
179- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
180- p.rect, p.popupGeometry = initial.Inner, initial
181- cache := &composedFrameCache{}
182200 base := renderer.NewFrame(30, 12)
183201 content := domain.Rect{Y: 1, Width: 30, Height: 10}
184- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
202+ pane := capturedPaneRenderState{frame: renderer.NewFrame(6, 3), titleGeneration: 1}
203+ initialInput := floatingComposeInput{
204+ baseFrame: base,
205+ floating: capturedFloatingRenderState{visible: true, pane: pane, geometry: initial, generation: 1, titleGeneration: 1},
206+ content: content,
207+ theme: themeui.Theme{},
208+ }
209+ _, damage := composeCapturedFloatingFrame(initialInput)
185210 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
186211
187- p.mu.Lock()
188- p.rect, p.popupGeometry = tt.next.Inner, tt.next
189- p.mu.Unlock()
190- _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
212+ initialInput.floating.geometry = tt.next
213+ initialInput.cache = composeCacheInput{
214+ valid: true,
215+ floatingGeneration: 1,
216+ floatingGeometry: initial.translate(content.X, content.Y),
217+ floatingTitleGeneration: 1,
218+ }
219+ _, damage = composeCapturedFloatingFrame(initialInput)
191220 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
192- require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry)
193221 })
194222 }
195223 }
@@ -287,41 +315,37 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
287315 }
288316 }
289317
290-func BenchmarkComposeFloatingFrameCached(b *testing.B) {
291- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
292- p.title.displayFallback = "float"
318+func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
293319 base := renderer.NewFrame(80, 24)
294320 content := domain.Rect{Y: 1, Width: 80, Height: 22}
295- cfg := domain.FloatingConfig{Width: 80, Height: 80}
296- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
297- cache := &composedFrameCache{}
298- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
299- p.screen.ClearDamage()
321+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
322+ input := floatingComposeInput{
323+ baseFrame: base,
324+ floating: capturedFloatingRenderState{
325+ visible: true,
326+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
327+ geometry: geometry,
328+ title: "float",
329+ generation: 1,
330+ titleGeneration: 1,
331+ },
332+ content: content,
333+ theme: themeui.Theme{},
334+ cache: composeCacheInput{
335+ valid: true,
336+ floatingGeneration: 1,
337+ floatingGeometry: geometry.translate(content.X, content.Y),
338+ floatingTitleGeneration: 1,
339+ },
340+ }
300341
301342 b.ReportAllocs()
302343 b.ResetTimer()
303344 for b.Loop() {
304- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
345+ composeCapturedFloatingFrame(input)
305346 }
306347 }
307348
308-func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
309- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
310- p.title.displayFallback = "float"
311- base := renderer.NewFrame(80, 24)
312- content := domain.Rect{Y: 1, Width: 80, Height: 22}
313- cfg := domain.FloatingConfig{Width: 80, Height: 80}
314- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
315- cache := &composedFrameCache{}
316- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
317- p.screen.ClearDamage()
318-
319- allocs := testing.AllocsPerRun(100, func() {
320- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
321- })
322- require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
323-}
324-
325349 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
326350 d := newTestDaemon(t, nil, stubClock{})
327351 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
@@ -368,7 +392,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
368392
369393 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
370394 cfg := domain.FloatingConfig{Width: 50, Height: 50}
371- oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
395+ oldContent := domain.Rect{Width: 80, Height: 24}
372396 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
373397 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
374398 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
@@ -384,14 +408,28 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
384408 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
385409 tb.mu.Lock()
386410 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
387- copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
388411 tb.mu.Unlock()
389412 require.True(t, visible)
390413 require.Equal(t, oldGeometry, inputGeometry)
391- require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect)
392414
415+ p.mu.Lock()
416+ captured := capturePaneRenderStateLocked(p, oldGeometry.Inner)
417+ p.mu.Unlock()
393418 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
394- frame, _, _ := composeFloatingFrame(base, nil, p, 1, newContent, calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)
419+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
420+ baseFrame: base,
421+ floating: capturedFloatingRenderState{
422+ visible: true,
423+ pane: captured,
424+ geometry: oldGeometry,
425+ title: captured.title,
426+ generation: 1,
427+ titleGeneration: captured.titleGeneration,
428+ },
429+ content: newContent,
430+ theme: themeui.Theme{},
431+ full: true,
432+ })
395433 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
396434 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
397435 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
@@ -128,35 +128,56 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
128 }128 }
129 }129 }
130 130
131-func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {131+func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
132- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
133- p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
134- p.title.processName, p.title.terminalTitle, p.title.generation = "nvim", "project/main.go", 1
135 base := renderer.NewFrame(40, 12)132 base := renderer.NewFrame(40, 12)
136 base.Set(2, 2, renderer.Cell{Rune: 'B'})133 base.Set(2, 2, renderer.Cell{Rune: 'B'})
137 content := domain.Rect{Y: 1, Width: 40, Height: 10}134 content := domain.Rect{Y: 1, Width: 40, Height: 10}
138- cache := &composedFrameCache{}135+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
139- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)136+ paneFrame := renderer.NewFrame(6, 3)
137+ paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
138+ floating := capturedFloatingRenderState{
139+ visible: true,
140+ pane: capturedPaneRenderState{frame: paneFrame, title: "nvim: project/main.go", titleGeneration: 1},
141+ geometry: geometry,
142+ title: "nvim: project/main.go",
143+ generation: 1,
144+ titleGeneration: 1,
145+ }
146+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
147+ baseFrame: base,
148+ floating: floating,
149+ content: content,
150+ theme: themeui.Theme{},
151+ })
140 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")152 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")
141- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}).translate(content.X, content.Y)153+ frameGeometry := geometry.translate(content.X, content.Y)
142- require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)154+ require.Equal(t, '┌', frame.At(frameGeometry.Bounds.X, frameGeometry.Bounds.Y).Rune)
143- require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)155+ require.Equal(t, 'F', frame.At(frameGeometry.Inner.X, frameGeometry.Inner.Y).Rune)
144 var gotTitle strings.Builder156 var gotTitle strings.Builder
145- for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {157+ for x := frameGeometry.Bounds.X + 2; x < frameGeometry.Bounds.X+frameGeometry.Bounds.Width-2; x++ {
146- gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)158+ gotTitle.WriteRune(frame.At(x, frameGeometry.Bounds.Y).Rune)
147 }159 }
148 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))160 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
149 161
150- p.mu.Lock()162+ floating.titleGeneration++
151- p.title.generation++163+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
152- p.mu.Unlock()164+ baseFrame: base,
153- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)165+ floating: floating,
166+ content: content,
167+ theme: themeui.Theme{},
168+ cache: composeCacheInput{
169+ valid: true,
170+ floatingGeneration: 1,
171+ floatingGeometry: frameGeometry,
172+ floatingTitleGeneration: 1,
173+ },
174+ })
154 require.Len(t, damage, 1)175 require.Len(t, damage, 1)
155- require.Equal(t, geometry.Bounds.Y, damage[0].Y)176+ require.Equal(t, frameGeometry.Bounds.Y, damage[0].Y)
156 require.Equal(t, 1, damage[0].Height)177 require.Equal(t, 1, damage[0].Height)
157 }178 }
158 179
159-func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {180+func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
160 initial := floatingGeometry{181 initial := floatingGeometry{
161 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},182 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
162 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},183 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
@@ -176,20 +197,27 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
176 }197 }
177 for _, tt := range tests {198 for _, tt := range tests {
178 t.Run(tt.name, func(t *testing.T) {199 t.Run(tt.name, func(t *testing.T) {
179- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
180- p.rect, p.popupGeometry = initial.Inner, initial
181- cache := &composedFrameCache{}
182 base := renderer.NewFrame(30, 12)200 base := renderer.NewFrame(30, 12)
183 content := domain.Rect{Y: 1, Width: 30, Height: 10}201 content := domain.Rect{Y: 1, Width: 30, Height: 10}
184- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)202+ pane := capturedPaneRenderState{frame: renderer.NewFrame(6, 3), titleGeneration: 1}
203+ initialInput := floatingComposeInput{
204+ baseFrame: base,
205+ floating: capturedFloatingRenderState{visible: true, pane: pane, geometry: initial, generation: 1, titleGeneration: 1},
206+ content: content,
207+ theme: themeui.Theme{},
208+ }
209+ _, damage := composeCapturedFloatingFrame(initialInput)
185 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)210 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
186 211
187- p.mu.Lock()212+ initialInput.floating.geometry = tt.next
188- p.rect, p.popupGeometry = tt.next.Inner, tt.next213+ initialInput.cache = composeCacheInput{
189- p.mu.Unlock()214+ valid: true,
190- _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)215+ floatingGeneration: 1,
216+ floatingGeometry: initial.translate(content.X, content.Y),
217+ floatingTitleGeneration: 1,
218+ }
219+ _, damage = composeCapturedFloatingFrame(initialInput)
191 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)220 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
192- require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry)
193 })221 })
194 }222 }
195 }223 }
@@ -287,41 +315,37 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
287 }315 }
288 }316 }
289 317
290-func BenchmarkComposeFloatingFrameCached(b *testing.B) {318+func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
291- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
292- p.title.displayFallback = "float"
293 base := renderer.NewFrame(80, 24)319 base := renderer.NewFrame(80, 24)
294 content := domain.Rect{Y: 1, Width: 80, Height: 22}320 content := domain.Rect{Y: 1, Width: 80, Height: 22}
295- cfg := domain.FloatingConfig{Width: 80, Height: 80}321+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
296- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)322+ input := floatingComposeInput{
297- cache := &composedFrameCache{}323+ baseFrame: base,
298- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)324+ floating: capturedFloatingRenderState{
299- p.screen.ClearDamage()325+ visible: true,
326+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
327+ geometry: geometry,
328+ title: "float",
329+ generation: 1,
330+ titleGeneration: 1,
331+ },
332+ content: content,
333+ theme: themeui.Theme{},
334+ cache: composeCacheInput{
335+ valid: true,
336+ floatingGeneration: 1,
337+ floatingGeometry: geometry.translate(content.X, content.Y),
338+ floatingTitleGeneration: 1,
339+ },
340+ }
300 341
301 b.ReportAllocs()342 b.ReportAllocs()
302 b.ResetTimer()343 b.ResetTimer()
303 for b.Loop() {344 for b.Loop() {
304- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)345+ composeCapturedFloatingFrame(input)
305 }346 }
306 }347 }
307 348
308-func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
309- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
310- p.title.displayFallback = "float"
311- base := renderer.NewFrame(80, 24)
312- content := domain.Rect{Y: 1, Width: 80, Height: 22}
313- cfg := domain.FloatingConfig{Width: 80, Height: 80}
314- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
315- cache := &composedFrameCache{}
316- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
317- p.screen.ClearDamage()
318-
319- allocs := testing.AllocsPerRun(100, func() {
320- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
321- })
322- require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
323-}
324-
325 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {349 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
326 d := newTestDaemon(t, nil, stubClock{})350 d := newTestDaemon(t, nil, stubClock{})
327 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})351 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
@@ -368,7 +392,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
368 392
369 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {393 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
370 cfg := domain.FloatingConfig{Width: 50, Height: 50}394 cfg := domain.FloatingConfig{Width: 50, Height: 50}
371- oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}395+ oldContent := domain.Rect{Width: 80, Height: 24}
372 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}396 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
373 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)397 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
374 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)398 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
@@ -384,14 +408,28 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
384 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))408 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
385 tb.mu.Lock()409 tb.mu.Lock()
386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)410 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
387- copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
388 tb.mu.Unlock()411 tb.mu.Unlock()
389 require.True(t, visible)412 require.True(t, visible)
390 require.Equal(t, oldGeometry, inputGeometry)413 require.Equal(t, oldGeometry, inputGeometry)
391- require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect)
392 414
415+ p.mu.Lock()
416+ captured := capturePaneRenderStateLocked(p, oldGeometry.Inner)
417+ p.mu.Unlock()
393 base := renderer.NewFrame(newContent.Width, newContent.Height+2)418 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
394- frame, _, _ := composeFloatingFrame(base, nil, p, 1, newContent, calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)419+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
420+ baseFrame: base,
421+ floating: capturedFloatingRenderState{
422+ visible: true,
423+ pane: captured,
424+ geometry: oldGeometry,
425+ title: captured.title,
426+ generation: 1,
427+ titleGeneration: captured.titleGeneration,
428+ },
429+ content: newContent,
430+ theme: themeui.Theme{},
431+ full: true,
432+ })
395 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)433 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
396 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)434 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
397 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)435 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
M internal/usecase/daemon/render_pipeline.go +10 -1
@@ -111,7 +111,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
111111 baseFrame := frame
112112 if state.floating.visible {
113113 var floatingDamage []renderer.Damage
114- frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())
114+ frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
115+ baseFrame: baseFrame,
116+ baseDamage: damage,
117+ floating: state.floating,
118+ content: content,
119+ layout: state.layout,
120+ theme: state.theme,
121+ cache: in,
122+ full: full || state.overlays.active(),
123+ })
115124 damage = floatingDamage
116125 }
117126 if !full {
@@ -111,7 +111,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
111 baseFrame := frame111 baseFrame := frame
112 if state.floating.visible {112 if state.floating.visible {
113 var floatingDamage []renderer.Damage113 var floatingDamage []renderer.Damage
114- frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())114+ frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
115+ baseFrame: baseFrame,
116+ baseDamage: damage,
117+ floating: state.floating,
118+ content: content,
119+ layout: state.layout,
120+ theme: state.theme,
121+ cache: in,
122+ full: full || state.overlays.active(),
123+ })
115 damage = floatingDamage124 damage = floatingDamage
116 }125 }
117 if !full {126 if !full {

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