refactor(daemon): group floating composition inputs
- commit
- 8476ebc055405abf26be95c5636532440e9f871c
- author
- brice · · 2026-07-14 19:53 UTC
- parent
- 31d36ea
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | |
| 11 | 11 | // 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 | |
| 13 | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | |
| 14 | 14 | // must not translate a composed or cached value again. | |
| 15 | 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 | 109 | frame := base.Clone() | |
| 90 | 110 | legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 91 | 111 | (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 pane | 11 | // 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 |
| 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | 13 | // 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) { | |||
| 128 | 128 | } | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 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) { | |
| 135 | 132 | base := renderer.NewFrame(40, 12) | |
| 136 | 133 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | |
| 137 | 134 | 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 | + | }) | |
| 140 | 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) | |
| 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) | |
| 144 | 156 | 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) | |
| 147 | 159 | } | |
| 148 | 160 | require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─")) | |
| 149 | 161 | ||
| 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 | + | }) | |
| 154 | 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 | 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 | 181 | initial := floatingGeometry{ | |
| 161 | 182 | Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5}, | |
| 162 | 183 | Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3}, | |
| @@ -176,20 +197,27 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |||
| 176 | 197 | } | |
| 177 | 198 | for _, tt := range tests { | |
| 178 | 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 | 200 | base := renderer.NewFrame(30, 12) | |
| 183 | 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 | 210 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 186 | 211 | ||
| 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) | |
| 191 | 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) { | |
| 291 | - | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |
| 292 | - | p.title.displayFallback = "float" | |
| 318 | + | func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) { | |
| 293 | 319 | base := renderer.NewFrame(80, 24) | |
| 294 | 320 | 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 | + | } | |
| 300 | 341 | ||
| 301 | 342 | b.ReportAllocs() | |
| 302 | 343 | b.ResetTimer() | |
| 303 | 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 | 349 | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |
| 326 | 350 | d := newTestDaemon(t, nil, stubClock{}) | |
| 327 | 351 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | |
| @@ -368,7 +392,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 368 | 392 | ||
| 369 | 393 | func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) { | |
| 370 | 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 | 396 | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | |
| 373 | 397 | oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg) | |
| 374 | 398 | newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg) | |
| @@ -384,14 +408,28 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 384 | 408 | require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry)) | |
| 385 | 409 | tb.mu.Lock() | |
| 386 | 410 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 387 | - | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | |
| 388 | 411 | tb.mu.Unlock() | |
| 389 | 412 | require.True(t, visible) | |
| 390 | 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 | 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 | 433 | oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y) | |
| 396 | 434 | newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y) | |
| 397 | 435 | 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.Builder | 156 | 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.next | 213 | + | 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 ... | |||
| 111 | 111 | baseFrame := frame | |
| 112 | 112 | if state.floating.visible { | |
| 113 | 113 | 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 | 124 | damage = floatingDamage | |
| 116 | 125 | } | |
| 117 | 126 | if !full { | |
| @@ -111,7 +111,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 111 | baseFrame := frame | 111 | baseFrame := frame | ||
| 112 | if state.floating.visible { | 112 | if state.floating.visible { | ||
| 113 | var floatingDamage []renderer.Damage | 113 | 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 = floatingDamage | 124 | damage = floatingDamage | ||
| 116 | } | 125 | } | ||
| 117 | if !full { | 126 | if !full { | ||