perf(daemon): serialize render composition and emission
- commit
- 1dd8eef0bbf0d6a538160168af837293d06b6e91
- author
- brice · · 2026-07-12 05:12 UTC
- parent
- eb37c83
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +375 -186
M internal/usecase/daemon/client.go +2 -1
| @@ -32,7 +32,8 @@ type attachedClient struct { | |||
| 32 | 32 | coordinatorReadyEpoch atomic.Uint64 | |
| 33 | 33 | echoAck atomic.Uint64 | |
| 34 | 34 | bars barCache // only touched while sendMu is held | |
| 35 | - | composed composedFrameCache // only touched while sendMu is held | |
| 35 | + | composed composedFrameCache // test-only legacy composition cache | |
| 36 | + | pipelineCache composeCacheInput // only touched while sendMu is held | |
| 36 | 37 | resizePaint pendingByteTimer // guarded by sendMu | |
| 37 | 38 | resizePaintGeneration uint64 // guarded by sendMu | |
| 38 | 39 | resizePaintPending bool // guarded by sendMu | |
| @@ -32,7 +32,8 @@ type attachedClient struct { | |||||
| 32 | coordinatorReadyEpoch atomic.Uint64 | 32 | coordinatorReadyEpoch atomic.Uint64 | ||
| 33 | echoAck atomic.Uint64 | 33 | echoAck atomic.Uint64 | ||
| 34 | bars barCache // only touched while sendMu is held | 34 | bars barCache // only touched while sendMu is held | ||
| 35 | - | composed composedFrameCache // only touched while sendMu is held | 35 | + | composed composedFrameCache // test-only legacy composition cache |
| 36 | + | pipelineCache composeCacheInput // only touched while sendMu is held | |||
| 36 | resizePaint pendingByteTimer // guarded by sendMu | 37 | resizePaint pendingByteTimer // guarded by sendMu | ||
| 37 | resizePaintGeneration uint64 // guarded by sendMu | 38 | resizePaintGeneration uint64 // guarded by sendMu | ||
| 38 | resizePaintPending bool // guarded by sendMu | 39 | resizePaintPending bool // guarded by sendMu | ||
M internal/usecase/daemon/floating_render.go +22 -0
| @@ -147,6 +147,28 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 147 | 147 | return frame, damage, geometry | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | 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) { | |
| 151 | + | frame := base.Clone() | |
| 152 | + | legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 153 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme) | |
| 154 | + | geometry := floating.geometry.translate(content.X, content.Y) | |
| 155 | + | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme) | |
| 156 | + | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 157 | + | for _, d := range floating.pane.damage { | |
| 158 | + | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 159 | + | } | |
| 160 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |
| 161 | + | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | |
| 162 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | |
| 163 | + | if full || popupChanged { | |
| 164 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 165 | + | } | |
| 166 | + | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 167 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |
| 168 | + | } | |
| 169 | + | return frame, damage | |
| 170 | + | } | |
| 171 | + | ||
| 150 | 172 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |
| 151 | 173 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 152 | 174 | return | |
| @@ -147,6 +147,28 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 147 | return frame, damage, geometry | 147 | return frame, damage, geometry | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | + | 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) { | |||
| 151 | + | frame := base.Clone() | |||
| 152 | + | legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |||
| 153 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme) | |||
| 154 | + | geometry := floating.geometry.translate(content.X, content.Y) | |||
| 155 | + | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme) | |||
| 156 | + | damage := append([]renderer.Damage(nil), baseDamage...) | |||
| 157 | + | for _, d := range floating.pane.damage { | |||
| 158 | + | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |||
| 159 | + | } | |||
| 160 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |||
| 161 | + | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | |||
| 162 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | |||
| 163 | + | if full || popupChanged { | |||
| 164 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |||
| 165 | + | } | |||
| 166 | + | if titleChanged && geometry.Bounds.Height >= 3 { | |||
| 167 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |||
| 168 | + | } | |||
| 169 | + | return frame, damage | |||
| 170 | + | } | |||
| 171 | + | ||||
| 150 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | 172 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | ||
| 151 | if bounds.Width <= 0 || bounds.Height <= 0 { | 173 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 152 | return | 174 | return | ||
M internal/usecase/daemon/render.go +17 -182
| @@ -3,8 +3,6 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | - | "errors" | |
| 7 | - | "strconv" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/vev/internal/domain" | |
| 10 | 8 | "github.com/bnema/vev/internal/ports" | |
| @@ -12,7 +10,6 @@ import ( | |||
| 12 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | 12 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | "github.com/bnema/vev/pkg/vt" | |
| 16 | 13 | ) | |
| 17 | 14 | ||
| 18 | 15 | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | |
| @@ -332,8 +329,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 332 | 329 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 333 | 330 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 334 | 331 | ||
| 335 | - | styles := newThemeStyles(ac.getTheme()) | |
| 336 | 332 | paletteCfg := d.currentPaletteConfig() | |
| 333 | + | floatingCfg := d.currentFloatingConfig() | |
| 334 | + | // Title refresh may inspect process state, so it remains before the capture | |
| 335 | + | // boundary and outside tab/pane ownership locks. | |
| 337 | 336 | tb.mu.Lock() | |
| 338 | 337 | layoutSnap := solveTabLayoutLocked(tb) | |
| 339 | 338 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | |
| @@ -347,189 +346,25 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 347 | 346 | d.refreshPaneDisplayTitle(sess, floating, false) | |
| 348 | 347 | } | |
| 349 | 348 | ||
| 350 | - | tb.mu.Lock() | |
| 351 | - | if !layoutSnap.matchesLocked(tb) { | |
| 352 | - | layoutSnap = solveTabLayoutLocked(tb) | |
| 353 | - | } | |
| 354 | - | p := tb.focusedPane() | |
| 355 | - | if p == nil { | |
| 356 | - | tb.mu.Unlock() | |
| 357 | - | ac.sendMu.Unlock() | |
| 358 | - | return | |
| 359 | - | } | |
| 360 | - | // Recheck the slot after refreshing its title outside the tab lock. One | |
| 361 | - | // paint uses one immutable floating config snapshot across composition, | |
| 362 | - | // copy targeting, and cursor placement. | |
| 363 | - | floating = tb.floating.pane | |
| 364 | - | hasFloating = tb.floating.state == floatingVisible && floating != nil | |
| 365 | - | floatingCfg := d.currentFloatingConfig() | |
| 366 | - | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 367 | - | if reset || overlayActive { | |
| 368 | - | ac.bars.Reset() | |
| 369 | - | ac.composed.invalidate() | |
| 370 | - | } | |
| 371 | - | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |
| 372 | - | ac.lastCursor.valid = false | |
| 373 | - | } | |
| 374 | - | var frame renderer.Frame | |
| 375 | - | var damage []renderer.Damage | |
| 376 | - | if overlayActive { | |
| 377 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |
| 378 | - | } else { | |
| 379 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |
| 349 | + | capturedOverlays := capturedOverlayRenderState{ | |
| 350 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 351 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 352 | + | copyFeedback: overlays.copyFeedback, | |
| 380 | 353 | } | |
| 381 | - | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 382 | - | floatingFrameGeometry := floatingGeometry{} | |
| 383 | - | if hasFloating { | |
| 384 | - | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) | |
| 385 | - | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 386 | - | } | |
| 387 | - | if overlays.copyActive { | |
| 388 | - | copyPane := overlays.copyPane | |
| 389 | - | if copyPane == nil { | |
| 390 | - | copyPane = p | |
| 391 | - | } | |
| 392 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) | |
| 393 | - | frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars) | |
| 394 | - | } | |
| 395 | - | // A palette above normal/copy content dims that composed content. When a | |
| 396 | - | // floating pane is present its own backdrop already dims normal pane | |
| 397 | - | // contents; applying the palette backdrop here would also dim the popup. | |
| 398 | - | if overlays.paletteActive && !hasFloating { | |
| 399 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |
| 400 | - | } | |
| 401 | - | if overlays.copySearchModel != nil { | |
| 402 | - | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |
| 403 | - | } | |
| 404 | - | if overlays.pickerActive { | |
| 405 | - | if overlays.previewTab == tb { | |
| 406 | - | if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf { | |
| 407 | - | previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap) | |
| 408 | - | preview = pickerPreviewFromFrame(previewFrame) | |
| 409 | - | } else { | |
| 410 | - | preview = pickerPreviewFromLockedTab(tb) | |
| 411 | - | } | |
| 412 | - | } | |
| 413 | - | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |
| 414 | - | } | |
| 415 | - | if overlays.paletteActive { | |
| 416 | - | guidance := "" | |
| 417 | - | if overlays.paletteHints != nil { | |
| 418 | - | guidance = overlays.paletteHints.Feedback | |
| 419 | - | } | |
| 420 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |
| 421 | - | } | |
| 422 | - | if overlays.promptActive { | |
| 423 | - | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | |
| 424 | - | } | |
| 425 | - | overlays.Unlock() | |
| 426 | - | cursorPane := p | |
| 427 | - | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 428 | - | cursorContent.X += contentArea.X | |
| 429 | - | cursorContent.Y += contentArea.Y | |
| 430 | - | if hasFloating { | |
| 431 | - | cursorPane = floating | |
| 432 | - | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |
| 433 | - | } | |
| 434 | - | cursorPane.mu.Lock() | |
| 435 | - | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |
| 436 | - | cursorPane.mu.Unlock() | |
| 437 | - | ac.sess.mu.Lock() | |
| 438 | - | if ac.sess.v != sess { | |
| 439 | - | ac.sess.mu.Unlock() | |
| 440 | - | tb.mu.Unlock() | |
| 354 | + | state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume) | |
| 355 | + | if !ok { | |
| 441 | 356 | ac.sendMu.Unlock() | |
| 442 | 357 | return | |
| 443 | 358 | } | |
| 444 | - | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | |
| 445 | - | var data []byte | |
| 446 | - | if err == nil { | |
| 447 | - | data = prepared.data | |
| 448 | - | } | |
| 449 | - | var cursorTail []byte | |
| 450 | - | if err == nil { | |
| 451 | - | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 452 | - | } | |
| 453 | - | tb.mu.Unlock() | |
| 454 | - | ||
| 455 | - | var serr error | |
| 456 | - | var sendTr ports.Transport | |
| 457 | - | if err == nil { | |
| 458 | - | data = append(data, cursorTail...) | |
| 459 | - | if len(data) > 0 { | |
| 460 | - | sendTr = ac.transport() | |
| 461 | - | if sendTr == nil { | |
| 462 | - | serr = errors.New("client transport is nil") | |
| 463 | - | } else { | |
| 464 | - | send := sendTr.Send | |
| 465 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 466 | - | send = async.SendAsync | |
| 467 | - | } | |
| 468 | - | serr = prepared.send(data, ac.echoAck.Load(), send) | |
| 469 | - | } | |
| 470 | - | } | |
| 471 | - | } | |
| 472 | - | ac.sess.mu.Unlock() | |
| 473 | - | ac.sendMu.Unlock() | |
| 474 | - | ||
| 475 | - | if err != nil { | |
| 476 | - | d.log.Error("render draw failed", "err", err, "session", sess.name) | |
| 477 | - | return | |
| 478 | - | } | |
| 479 | - | if serr != nil { | |
| 480 | - | d.detachOnSendError(sess, ac, sendTr) | |
| 481 | - | } | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | |
| 485 | - | if !layoutSnap.ok { | |
| 486 | - | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | |
| 487 | - | } | |
| 488 | - | for _, pl := range layoutSnap.placements { | |
| 489 | - | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 490 | - | return pl.Content, true | |
| 491 | - | } | |
| 492 | - | } | |
| 493 | - | return domain.Rect{}, false | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | // desiredCursorOut computes the terminal cursor state that should be shown to | |
| 497 | - | // the client for the current pane placement and overlay mode. | |
| 498 | - | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |
| 499 | - | if hide || !s.CursorVisible() { | |
| 500 | - | return cursorOut{hidden: true} | |
| 501 | - | } | |
| 502 | - | style, ok := s.CursorStyle() | |
| 503 | - | if !ok { | |
| 504 | - | style = 1 | |
| 505 | - | } | |
| 506 | - | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} | |
| 507 | - | } | |
| 508 | - | ||
| 509 | - | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| 510 | - | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |
| 511 | - | if !changed { | |
| 512 | - | return nil | |
| 359 | + | if overlays.pickerActive && overlays.previewTab == tb { | |
| 360 | + | previewState := *state | |
| 361 | + | previewState.overlays = capturedOverlayRenderState{} | |
| 362 | + | previewState.reset = true | |
| 363 | + | state.preview = pickerPreviewFromFrame(composeFrame(previewState, composeCacheInput{}).frame) | |
| 513 | 364 | } | |
| 514 | - | prev := ac.lastCursor | |
| 515 | - | ac.lastCursor = desired | |
| 516 | - | ac.lastCursor.valid = true | |
| 517 | - | if desired.hidden { | |
| 518 | - | return []byte("\x1b[?25l") | |
| 519 | - | } | |
| 520 | - | var b []byte | |
| 521 | - | b = append(b, "\x1b["...) | |
| 522 | - | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |
| 523 | - | b = append(b, ';') | |
| 524 | - | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |
| 525 | - | b = append(b, 'H') | |
| 526 | - | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |
| 527 | - | b = append(b, "\x1b["...) | |
| 528 | - | b = strconv.AppendInt(b, int64(desired.style), 10) | |
| 529 | - | b = append(b, " q"...) | |
| 530 | - | } | |
| 531 | - | b = append(b, "\x1b[?25h"...) | |
| 532 | - | return b | |
| 365 | + | captureOverlayLayers(state, overlays, paletteCfg) | |
| 366 | + | composed := composeFrame(*state, ac.pipelineCache) | |
| 367 | + | d.emitFrame(sess, ac, state, composed) | |
| 533 | 368 | } | |
| 534 | 369 | ||
| 535 | 370 | type themeStyles struct { | |
| @@ -3,8 +3,6 @@ package daemon | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | - | "errors" | |||
| 7 | - | "strconv" | |||
| 8 | 6 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| @@ -12,7 +10,6 @@ import ( | |||||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 13 | "github.com/bnema/vev/internal/usecase/ui" | 11 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 14 | "github.com/bnema/vev/pkg/renderer" | 12 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | - | "github.com/bnema/vev/pkg/vt" | |||
| 16 | ) | 13 | ) | ||
| 17 | 14 | ||||
| 18 | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | 15 | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | ||
| @@ -332,8 +329,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 332 | attentionVisible := pulseVisible(bars.attentionFrame) | 329 | attentionVisible := pulseVisible(bars.attentionFrame) | ||
| 333 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | 330 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | ||
| 334 | 331 | ||||
| 335 | - | styles := newThemeStyles(ac.getTheme()) | |||
| 336 | paletteCfg := d.currentPaletteConfig() | 332 | paletteCfg := d.currentPaletteConfig() | ||
| 333 | + | floatingCfg := d.currentFloatingConfig() | |||
| 334 | + | // Title refresh may inspect process state, so it remains before the capture | |||
| 335 | + | // boundary and outside tab/pane ownership locks. | |||
| 337 | tb.mu.Lock() | 336 | tb.mu.Lock() | ||
| 338 | layoutSnap := solveTabLayoutLocked(tb) | 337 | layoutSnap := solveTabLayoutLocked(tb) | ||
| 339 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | 338 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | ||
| @@ -347,189 +346,25 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 347 | d.refreshPaneDisplayTitle(sess, floating, false) | 346 | d.refreshPaneDisplayTitle(sess, floating, false) | ||
| 348 | } | 347 | } | ||
| 349 | 348 | ||||
| 350 | - | tb.mu.Lock() | 349 | + | capturedOverlays := capturedOverlayRenderState{ |
| 351 | - | if !layoutSnap.matchesLocked(tb) { | 350 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, |
| 352 | - | layoutSnap = solveTabLayoutLocked(tb) | 351 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, |
| 353 | - | } | 352 | + | copyFeedback: overlays.copyFeedback, |
| 354 | - | p := tb.focusedPane() | |||
| 355 | - | if p == nil { | |||
| 356 | - | tb.mu.Unlock() | |||
| 357 | - | ac.sendMu.Unlock() | |||
| 358 | - | return | |||
| 359 | - | } | |||
| 360 | - | // Recheck the slot after refreshing its title outside the tab lock. One | |||
| 361 | - | // paint uses one immutable floating config snapshot across composition, | |||
| 362 | - | // copy targeting, and cursor placement. | |||
| 363 | - | floating = tb.floating.pane | |||
| 364 | - | hasFloating = tb.floating.state == floatingVisible && floating != nil | |||
| 365 | - | floatingCfg := d.currentFloatingConfig() | |||
| 366 | - | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |||
| 367 | - | if reset || overlayActive { | |||
| 368 | - | ac.bars.Reset() | |||
| 369 | - | ac.composed.invalidate() | |||
| 370 | - | } | |||
| 371 | - | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |||
| 372 | - | ac.lastCursor.valid = false | |||
| 373 | - | } | |||
| 374 | - | var frame renderer.Frame | |||
| 375 | - | var damage []renderer.Damage | |||
| 376 | - | if overlayActive { | |||
| 377 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |||
| 378 | - | } else { | |||
| 379 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |||
| 380 | } | 353 | } | ||
| 381 | - | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | 354 | + | state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume) |
| 382 | - | floatingFrameGeometry := floatingGeometry{} | 355 | + | if !ok { |
| 383 | - | if hasFloating { | |||
| 384 | - | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) | |||
| 385 | - | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |||
| 386 | - | } | |||
| 387 | - | if overlays.copyActive { | |||
| 388 | - | copyPane := overlays.copyPane | |||
| 389 | - | if copyPane == nil { | |||
| 390 | - | copyPane = p | |||
| 391 | - | } | |||
| 392 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) | |||
| 393 | - | frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars) | |||
| 394 | - | } | |||
| 395 | - | // A palette above normal/copy content dims that composed content. When a | |||
| 396 | - | // floating pane is present its own backdrop already dims normal pane | |||
| 397 | - | // contents; applying the palette backdrop here would also dim the popup. | |||
| 398 | - | if overlays.paletteActive && !hasFloating { | |||
| 399 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |||
| 400 | - | } | |||
| 401 | - | if overlays.copySearchModel != nil { | |||
| 402 | - | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |||
| 403 | - | } | |||
| 404 | - | if overlays.pickerActive { | |||
| 405 | - | if overlays.previewTab == tb { | |||
| 406 | - | if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf { | |||
| 407 | - | previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap) | |||
| 408 | - | preview = pickerPreviewFromFrame(previewFrame) | |||
| 409 | - | } else { | |||
| 410 | - | preview = pickerPreviewFromLockedTab(tb) | |||
| 411 | - | } | |||
| 412 | - | } | |||
| 413 | - | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |||
| 414 | - | } | |||
| 415 | - | if overlays.paletteActive { | |||
| 416 | - | guidance := "" | |||
| 417 | - | if overlays.paletteHints != nil { | |||
| 418 | - | guidance = overlays.paletteHints.Feedback | |||
| 419 | - | } | |||
| 420 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |||
| 421 | - | } | |||
| 422 | - | if overlays.promptActive { | |||
| 423 | - | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | |||
| 424 | - | } | |||
| 425 | - | overlays.Unlock() | |||
| 426 | - | cursorPane := p | |||
| 427 | - | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |||
| 428 | - | cursorContent.X += contentArea.X | |||
| 429 | - | cursorContent.Y += contentArea.Y | |||
| 430 | - | if hasFloating { | |||
| 431 | - | cursorPane = floating | |||
| 432 | - | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |||
| 433 | - | } | |||
| 434 | - | cursorPane.mu.Lock() | |||
| 435 | - | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |||
| 436 | - | cursorPane.mu.Unlock() | |||
| 437 | - | ac.sess.mu.Lock() | |||
| 438 | - | if ac.sess.v != sess { | |||
| 439 | - | ac.sess.mu.Unlock() | |||
| 440 | - | tb.mu.Unlock() | |||
| 441 | ac.sendMu.Unlock() | 356 | ac.sendMu.Unlock() | ||
| 442 | return | 357 | return | ||
| 443 | } | 358 | } | ||
| 444 | - | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | 359 | + | if overlays.pickerActive && overlays.previewTab == tb { |
| 445 | - | var data []byte | 360 | + | previewState := *state |
| 446 | - | if err == nil { | 361 | + | previewState.overlays = capturedOverlayRenderState{} |
| 447 | - | data = prepared.data | 362 | + | previewState.reset = true |
| 448 | - | } | 363 | + | state.preview = pickerPreviewFromFrame(composeFrame(previewState, composeCacheInput{}).frame) |
| 449 | - | var cursorTail []byte | |||
| 450 | - | if err == nil { | |||
| 451 | - | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |||
| 452 | - | } | |||
| 453 | - | tb.mu.Unlock() | |||
| 454 | - | ||||
| 455 | - | var serr error | |||
| 456 | - | var sendTr ports.Transport | |||
| 457 | - | if err == nil { | |||
| 458 | - | data = append(data, cursorTail...) | |||
| 459 | - | if len(data) > 0 { | |||
| 460 | - | sendTr = ac.transport() | |||
| 461 | - | if sendTr == nil { | |||
| 462 | - | serr = errors.New("client transport is nil") | |||
| 463 | - | } else { | |||
| 464 | - | send := sendTr.Send | |||
| 465 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | |||
| 466 | - | send = async.SendAsync | |||
| 467 | - | } | |||
| 468 | - | serr = prepared.send(data, ac.echoAck.Load(), send) | |||
| 469 | - | } | |||
| 470 | - | } | |||
| 471 | - | } | |||
| 472 | - | ac.sess.mu.Unlock() | |||
| 473 | - | ac.sendMu.Unlock() | |||
| 474 | - | ||||
| 475 | - | if err != nil { | |||
| 476 | - | d.log.Error("render draw failed", "err", err, "session", sess.name) | |||
| 477 | - | return | |||
| 478 | - | } | |||
| 479 | - | if serr != nil { | |||
| 480 | - | d.detachOnSendError(sess, ac, sendTr) | |||
| 481 | - | } | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | - | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | |||
| 485 | - | if !layoutSnap.ok { | |||
| 486 | - | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | |||
| 487 | - | } | |||
| 488 | - | for _, pl := range layoutSnap.placements { | |||
| 489 | - | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |||
| 490 | - | return pl.Content, true | |||
| 491 | - | } | |||
| 492 | - | } | |||
| 493 | - | return domain.Rect{}, false | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | // desiredCursorOut computes the terminal cursor state that should be shown to | |||
| 497 | - | // the client for the current pane placement and overlay mode. | |||
| 498 | - | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |||
| 499 | - | if hide || !s.CursorVisible() { | |||
| 500 | - | return cursorOut{hidden: true} | |||
| 501 | - | } | |||
| 502 | - | style, ok := s.CursorStyle() | |||
| 503 | - | if !ok { | |||
| 504 | - | style = 1 | |||
| 505 | - | } | |||
| 506 | - | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} | |||
| 507 | - | } | |||
| 508 | - | ||||
| 509 | - | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |||
| 510 | - | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |||
| 511 | - | if !changed { | |||
| 512 | - | return nil | |||
| 513 | } | 364 | } | ||
| 514 | - | prev := ac.lastCursor | 365 | + | captureOverlayLayers(state, overlays, paletteCfg) |
| 515 | - | ac.lastCursor = desired | 366 | + | composed := composeFrame(*state, ac.pipelineCache) |
| 516 | - | ac.lastCursor.valid = true | 367 | + | d.emitFrame(sess, ac, state, composed) |
| 517 | - | if desired.hidden { | |||
| 518 | - | return []byte("\x1b[?25l") | |||
| 519 | - | } | |||
| 520 | - | var b []byte | |||
| 521 | - | b = append(b, "\x1b["...) | |||
| 522 | - | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |||
| 523 | - | b = append(b, ';') | |||
| 524 | - | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |||
| 525 | - | b = append(b, 'H') | |||
| 526 | - | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |||
| 527 | - | b = append(b, "\x1b["...) | |||
| 528 | - | b = strconv.AppendInt(b, int64(desired.style), 10) | |||
| 529 | - | b = append(b, " q"...) | |||
| 530 | - | } | |||
| 531 | - | b = append(b, "\x1b[?25h"...) | |||
| 532 | - | return b | |||
| 533 | } | 368 | } | ||
| 534 | 369 | ||||
| 535 | type themeStyles struct { | 370 | type themeStyles struct { | ||
M internal/usecase/daemon/render_capture.go +24 -3
| @@ -2,9 +2,11 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 5 | 6 | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | 8 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -38,6 +40,7 @@ type capturedRenderState struct { | |||
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | type capturedTabLayout struct { | |
| 43 | + | root *layout.Node | |
| 41 | 44 | area domain.Rect | |
| 42 | 45 | focus layout.PaneID | |
| 43 | 46 | placements []layout.Placement | |
| @@ -68,9 +71,22 @@ type capturedFloatingRenderState struct { | |||
| 68 | 71 | ||
| 69 | 72 | type capturedOverlayRenderState struct { | |
| 70 | 73 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 74 | + | copyMode *scopy.Mode | |
| 75 | + | copySnapshot *scopy.Snapshot | |
| 76 | + | copyPaneID layout.PaneID | |
| 71 | 77 | copyTarget domain.Rect | |
| 72 | 78 | copyFeedback string | |
| 73 | 79 | paletteGuidance string | |
| 80 | + | copySearch, picker, palette, prompt capturedModal | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | type capturedModal struct { | |
| 84 | + | modal ui.Modal | |
| 85 | + | inner renderer.Frame | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (o capturedOverlayRenderState) active() bool { | |
| 89 | + | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive | |
| 74 | 90 | } | |
| 75 | 91 | ||
| 76 | 92 | type capturedCursorInputs struct { | |
| @@ -136,7 +152,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 136 | 152 | // Callers hold attachment sendMu; this function then follows session -> tab -> | |
| 137 | 153 | // pane lock order. ACK-blocked primary capture returns before touching VT | |
| 138 | 154 | // damage. Preview mode is always non-destructive. | |
| 139 | - | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { | |
| 155 | + | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { | |
| 140 | 156 | if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) { | |
| 141 | 157 | return nil, false | |
| 142 | 158 | } | |
| @@ -160,7 +176,12 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 160 | 176 | state := &capturedRenderState{ | |
| 161 | 177 | attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme, | |
| 162 | 178 | overlays: overlays, preview: preview, | |
| 163 | - | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 179 | + | layout: capturedTabLayout{root: func() *layout.Node { | |
| 180 | + | if tb.tree == nil { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | return tb.tree.Clone().Root | |
| 184 | + | }(), area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 164 | 185 | floatingGeneration: tb.floating.generation, | |
| 165 | 186 | } | |
| 166 | 187 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | |
| @@ -191,7 +212,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 191 | 212 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 192 | 213 | p := tb.floating.pane | |
| 193 | 214 | p.mu.Lock() | |
| 194 | - | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100})) | |
| 215 | + | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | |
| 195 | 216 | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | |
| 196 | 217 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 197 | 218 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| @@ -2,9 +2,11 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 5 | "github.com/bnema/vev/internal/usecase/layout" | 6 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 6 | "github.com/bnema/vev/internal/usecase/picker" | 7 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 7 | themeui "github.com/bnema/vev/internal/usecase/theme" | 8 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 8 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -38,6 +40,7 @@ type capturedRenderState struct { | |||||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | type capturedTabLayout struct { | 42 | type capturedTabLayout struct { | ||
| 43 | + | root *layout.Node | |||
| 41 | area domain.Rect | 44 | area domain.Rect | ||
| 42 | focus layout.PaneID | 45 | focus layout.PaneID | ||
| 43 | placements []layout.Placement | 46 | placements []layout.Placement | ||
| @@ -68,9 +71,22 @@ type capturedFloatingRenderState struct { | |||||
| 68 | 71 | ||||
| 69 | type capturedOverlayRenderState struct { | 72 | type capturedOverlayRenderState struct { | ||
| 70 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | 73 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | ||
| 74 | + | copyMode *scopy.Mode | |||
| 75 | + | copySnapshot *scopy.Snapshot | |||
| 76 | + | copyPaneID layout.PaneID | |||
| 71 | copyTarget domain.Rect | 77 | copyTarget domain.Rect | ||
| 72 | copyFeedback string | 78 | copyFeedback string | ||
| 73 | paletteGuidance string | 79 | paletteGuidance string | ||
| 80 | + | copySearch, picker, palette, prompt capturedModal | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | type capturedModal struct { | |||
| 84 | + | modal ui.Modal | |||
| 85 | + | inner renderer.Frame | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (o capturedOverlayRenderState) active() bool { | |||
| 89 | + | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive | |||
| 74 | } | 90 | } | ||
| 75 | 91 | ||||
| 76 | type capturedCursorInputs struct { | 92 | type capturedCursorInputs struct { | ||
| @@ -136,7 +152,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||||
| 136 | // Callers hold attachment sendMu; this function then follows session -> tab -> | 152 | // Callers hold attachment sendMu; this function then follows session -> tab -> | ||
| 137 | // pane lock order. ACK-blocked primary capture returns before touching VT | 153 | // pane lock order. ACK-blocked primary capture returns before touching VT | ||
| 138 | // damage. Preview mode is always non-destructive. | 154 | // damage. Preview mode is always non-destructive. | ||
| 139 | - | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { | 155 | + | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { |
| 140 | if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) { | 156 | if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) { | ||
| 141 | return nil, false | 157 | return nil, false | ||
| 142 | } | 158 | } | ||
| @@ -160,7 +176,12 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 160 | state := &capturedRenderState{ | 176 | state := &capturedRenderState{ | ||
| 161 | attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme, | 177 | attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme, | ||
| 162 | overlays: overlays, preview: preview, | 178 | overlays: overlays, preview: preview, | ||
| 163 | - | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | 179 | + | layout: capturedTabLayout{root: func() *layout.Node { |
| 180 | + | if tb.tree == nil { | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | return tb.tree.Clone().Root | |||
| 184 | + | }(), area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |||
| 164 | floatingGeneration: tb.floating.generation, | 185 | floatingGeneration: tb.floating.generation, | ||
| 165 | } | 186 | } | ||
| 166 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | 187 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | ||
| @@ -191,7 +212,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 191 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | 212 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | ||
| 192 | p := tb.floating.pane | 213 | p := tb.floating.pane | ||
| 193 | p.mu.Lock() | 214 | p.mu.Lock() | ||
| 194 | - | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100})) | 215 | + | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) |
| 195 | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | 216 | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | ||
| 196 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | 217 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | ||
| 197 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | 218 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | ||
A internal/usecase/daemon/render_pipeline.go +286 -0
| @@ -0,0 +1,286 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strconv" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // composeCacheInput is an attachment-local value snapshot. composeFrame never | |
| 18 | + | // retains or accesses the attachment that owns it. | |
| 19 | + | type composeCacheInput struct { | |
| 20 | + | valid bool | |
| 21 | + | frame renderer.Frame | |
| 22 | + | layoutFingerprint string | |
| 23 | + | titleGenerations map[layout.PaneID]uint64 | |
| 24 | + | floatingGeneration uint64 | |
| 25 | + | floatingGeometry floatingGeometry | |
| 26 | + | floatingTitleGeneration uint64 | |
| 27 | + | bars barCache | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type composedRenderFrame struct { | |
| 31 | + | frame renderer.Frame | |
| 32 | + | damage []renderer.Damage | |
| 33 | + | cursor cursorOut | |
| 34 | + | cache composeCacheInput | |
| 35 | + | reset bool | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // composeFrame is pure with respect to daemon ownership: it consumes only the | |
| 39 | + | // capture and an attachment-local cache value, and returns a replacement cache. | |
| 40 | + | func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame { | |
| 41 | + | width, rows := state.layout.area.Width, state.layout.area.Height | |
| 42 | + | if width <= 0 || rows < 0 { | |
| 43 | + | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | |
| 44 | + | } | |
| 45 | + | frame := renderer.NewFrame(width, rows+2) | |
| 46 | + | styles := newThemeStyles(state.theme) | |
| 47 | + | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 48 | + | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 49 | + | content := domain.Rect{Y: 1, Width: width, Height: rows} | |
| 50 | + | if state.layout.valid && state.layout.root != nil { | |
| 51 | + | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint | |
| 55 | + | titles := make(map[layout.PaneID]uint64, len(state.panes)) | |
| 56 | + | var damage []renderer.Damage | |
| 57 | + | for _, pane := range state.panes { | |
| 58 | + | pl := offsetPlacement(pane.placement, 0, 1) | |
| 59 | + | if pl.TitleBar.Height > 0 { | |
| 60 | + | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme) | |
| 61 | + | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | |
| 62 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | |
| 63 | + | } | |
| 64 | + | titles[pane.id] = pane.titleGeneration | |
| 65 | + | } | |
| 66 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 67 | + | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme) | |
| 68 | + | } | |
| 69 | + | for _, d := range pane.damage { | |
| 70 | + | if d.Kind != renderer.DamageFullRedraw { | |
| 71 | + | d.Y++ | |
| 72 | + | } | |
| 73 | + | damage = append(damage, d) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | if state.floating.visible { | |
| 77 | + | var floatingDamage []renderer.Damage | |
| 78 | + | frame, floatingDamage = composeCapturedFloatingFrame(frame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) | |
| 79 | + | damage = floatingDamage | |
| 80 | + | } | |
| 81 | + | if !full { | |
| 82 | + | if !sameCells(in.bars.top, frame.Row(0)) { | |
| 83 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 84 | + | } | |
| 85 | + | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | |
| 86 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 90 | + | if full || state.overlays.active() { | |
| 91 | + | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 92 | + | } | |
| 93 | + | cursor := desiredCapturedCursor(state.cursor) | |
| 94 | + | outCache := composeCacheInput{valid: true, frame: frame.Clone(), layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration} | |
| 95 | + | outCache.bars.capture(frame.Row(0), frame.Row(rows+1)) | |
| 96 | + | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) { | |
| 100 | + | styles := newThemeStyles(theme) | |
| 101 | + | style := styles.border | |
| 102 | + | if focused { | |
| 103 | + | style = styles.statusBar | |
| 104 | + | } else { | |
| 105 | + | style = themeui.DimStyle(style, theme) | |
| 106 | + | } | |
| 107 | + | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| 108 | + | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| 109 | + | } | |
| 110 | + | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | |
| 114 | + | if !c.renderable || c.hiddenByOverlay || !c.visible { | |
| 115 | + | return cursorOut{hidden: true} | |
| 116 | + | } | |
| 117 | + | style := c.style | |
| 118 | + | if !c.hasStyle { | |
| 119 | + | style = 1 | |
| 120 | + | } | |
| 121 | + | return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | |
| 125 | + | if state == nil || snap == nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | o := &state.overlays | |
| 129 | + | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 130 | + | o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot | |
| 131 | + | if snap.copyPane != nil { | |
| 132 | + | o.copyPaneID = snap.copyPane.id | |
| 133 | + | } | |
| 134 | + | styles := newThemeStyles(state.theme) | |
| 135 | + | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 136 | + | if snap.copySearchModel != nil { | |
| 137 | + | o.copySearch.modal = copySearchModal | |
| 138 | + | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection) | |
| 139 | + | } | |
| 140 | + | if snap.pickerActive && snap.pickerModel != nil { | |
| 141 | + | o.picker.modal = pickerModal | |
| 142 | + | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()} | |
| 143 | + | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 144 | + | } | |
| 145 | + | if snap.paletteActive && snap.paletteModel != nil { | |
| 146 | + | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 147 | + | guidance := "" | |
| 148 | + | if snap.paletteHints != nil { | |
| 149 | + | guidance = snap.paletteHints.Feedback | |
| 150 | + | } | |
| 151 | + | o.paletteGuidance = guidance | |
| 152 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance}) | |
| 153 | + | } | |
| 154 | + | if snap.promptActive && snap.promptModel != nil { | |
| 155 | + | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| 156 | + | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent) | |
| 157 | + | } | |
| 158 | + | state.cursor.hiddenByOverlay = o.active() | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | |
| 162 | + | o := state.overlays | |
| 163 | + | if o.copyActive { | |
| 164 | + | target := domain.Rect{} | |
| 165 | + | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |
| 166 | + | target = state.floating.geometry.translate(content.X, content.Y).Inner | |
| 167 | + | } | |
| 168 | + | if target.Width == 0 { | |
| 169 | + | id := o.copyPaneID | |
| 170 | + | if id == "" { | |
| 171 | + | id = state.layout.focus | |
| 172 | + | } | |
| 173 | + | for _, p := range state.panes { | |
| 174 | + | if p.id == id && !p.placement.Collapsed { | |
| 175 | + | target = p.placement.Content | |
| 176 | + | target.Y += content.Y | |
| 177 | + | break | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars) | |
| 182 | + | } | |
| 183 | + | legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 184 | + | if o.paletteActive && !state.floating.visible { | |
| 185 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme) | |
| 186 | + | } | |
| 187 | + | for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} { | |
| 188 | + | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border) | |
| 192 | + | for y := range min(inner.Height, modal.inner.Height) { | |
| 193 | + | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |
| 194 | + | } | |
| 195 | + | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 196 | + | } | |
| 197 | + | return frame, damage | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| 201 | + | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |
| 202 | + | if !changed { | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | prev := ac.lastCursor | |
| 206 | + | ac.lastCursor = desired | |
| 207 | + | ac.lastCursor.valid = true | |
| 208 | + | if desired.hidden { | |
| 209 | + | return []byte("\x1b[?25l") | |
| 210 | + | } | |
| 211 | + | var b []byte | |
| 212 | + | b = append(b, "\x1b["...) | |
| 213 | + | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |
| 214 | + | b = append(b, ';') | |
| 215 | + | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |
| 216 | + | b = append(b, 'H') | |
| 217 | + | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |
| 218 | + | b = append(b, "\x1b["...) | |
| 219 | + | b = strconv.AppendInt(b, int64(desired.style), 10) | |
| 220 | + | b = append(b, " q"...) | |
| 221 | + | } | |
| 222 | + | b = append(b, "\x1b[?25h"...) | |
| 223 | + | return b | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | |
| 227 | + | // sendMu for the complete capture/compose/emit transaction. | |
| 228 | + | func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool { | |
| 229 | + | if sess == nil || ac == nil || state == nil { | |
| 230 | + | if ac != nil { | |
| 231 | + | ac.sendMu.Unlock() | |
| 232 | + | } | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | sess.mu.Lock() | |
| 236 | + | owned := sess.client == ac | |
| 237 | + | sess.mu.Unlock() | |
| 238 | + | if !owned || state.attachment != ac || state.attachmentEpoch != ac.coordinatorEpoch.Load() || ac.currentSession() != sess { | |
| 239 | + | ac.sendMu.Unlock() | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | ac.sess.mu.Lock() | |
| 243 | + | if ac.sess.v != sess { | |
| 244 | + | ac.sess.mu.Unlock() | |
| 245 | + | ac.sendMu.Unlock() | |
| 246 | + | return false | |
| 247 | + | } | |
| 248 | + | prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset) | |
| 249 | + | if err != nil { | |
| 250 | + | ac.sess.mu.Unlock() | |
| 251 | + | ac.sendMu.Unlock() | |
| 252 | + | d.log.Error("render draw failed", "err", err, "session", sess.name) | |
| 253 | + | return true | |
| 254 | + | } | |
| 255 | + | data := append([]byte(nil), prepared.data...) | |
| 256 | + | data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...) | |
| 257 | + | var sendTr ports.Transport | |
| 258 | + | var sendErr error | |
| 259 | + | if len(data) > 0 { | |
| 260 | + | sendTr = ac.transport() | |
| 261 | + | if sendTr == nil { | |
| 262 | + | sendErr = errors.New("client transport is nil") | |
| 263 | + | } else { | |
| 264 | + | send := sendTr.Send | |
| 265 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 266 | + | send = async.SendAsync | |
| 267 | + | } | |
| 268 | + | sendErr = prepared.send(data, ac.echoAck.Load(), send) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | if sendErr == nil { | |
| 272 | + | ac.pipelineCache = composed.cache | |
| 273 | + | if state.overlays.active() { | |
| 274 | + | ac.composed.invalidate() | |
| 275 | + | } else { | |
| 276 | + | ac.composed.frame = composed.frame.Clone() | |
| 277 | + | ac.composed.valid = true | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ac.sess.mu.Unlock() | |
| 281 | + | ac.sendMu.Unlock() | |
| 282 | + | if sendErr != nil { | |
| 283 | + | d.detachOnSendError(sess, ac, sendTr) | |
| 284 | + | } | |
| 285 | + | return true | |
| 286 | + | } | |
| @@ -0,0 +1,286 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "strconv" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // composeCacheInput is an attachment-local value snapshot. composeFrame never | |||
| 18 | + | // retains or accesses the attachment that owns it. | |||
| 19 | + | type composeCacheInput struct { | |||
| 20 | + | valid bool | |||
| 21 | + | frame renderer.Frame | |||
| 22 | + | layoutFingerprint string | |||
| 23 | + | titleGenerations map[layout.PaneID]uint64 | |||
| 24 | + | floatingGeneration uint64 | |||
| 25 | + | floatingGeometry floatingGeometry | |||
| 26 | + | floatingTitleGeneration uint64 | |||
| 27 | + | bars barCache | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type composedRenderFrame struct { | |||
| 31 | + | frame renderer.Frame | |||
| 32 | + | damage []renderer.Damage | |||
| 33 | + | cursor cursorOut | |||
| 34 | + | cache composeCacheInput | |||
| 35 | + | reset bool | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // composeFrame is pure with respect to daemon ownership: it consumes only the | |||
| 39 | + | // capture and an attachment-local cache value, and returns a replacement cache. | |||
| 40 | + | func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame { | |||
| 41 | + | width, rows := state.layout.area.Width, state.layout.area.Height | |||
| 42 | + | if width <= 0 || rows < 0 { | |||
| 43 | + | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | |||
| 44 | + | } | |||
| 45 | + | frame := renderer.NewFrame(width, rows+2) | |||
| 46 | + | styles := newThemeStyles(state.theme) | |||
| 47 | + | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |||
| 48 | + | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |||
| 49 | + | content := domain.Rect{Y: 1, Width: width, Height: rows} | |||
| 50 | + | if state.layout.valid && state.layout.root != nil { | |||
| 51 | + | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint | |||
| 55 | + | titles := make(map[layout.PaneID]uint64, len(state.panes)) | |||
| 56 | + | var damage []renderer.Damage | |||
| 57 | + | for _, pane := range state.panes { | |||
| 58 | + | pl := offsetPlacement(pane.placement, 0, 1) | |||
| 59 | + | if pl.TitleBar.Height > 0 { | |||
| 60 | + | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme) | |||
| 61 | + | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | |||
| 62 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | |||
| 63 | + | } | |||
| 64 | + | titles[pane.id] = pane.titleGeneration | |||
| 65 | + | } | |||
| 66 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |||
| 67 | + | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme) | |||
| 68 | + | } | |||
| 69 | + | for _, d := range pane.damage { | |||
| 70 | + | if d.Kind != renderer.DamageFullRedraw { | |||
| 71 | + | d.Y++ | |||
| 72 | + | } | |||
| 73 | + | damage = append(damage, d) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if state.floating.visible { | |||
| 77 | + | var floatingDamage []renderer.Damage | |||
| 78 | + | frame, floatingDamage = composeCapturedFloatingFrame(frame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) | |||
| 79 | + | damage = floatingDamage | |||
| 80 | + | } | |||
| 81 | + | if !full { | |||
| 82 | + | if !sameCells(in.bars.top, frame.Row(0)) { | |||
| 83 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |||
| 84 | + | } | |||
| 85 | + | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | |||
| 86 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |||
| 90 | + | if full || state.overlays.active() { | |||
| 91 | + | damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 92 | + | } | |||
| 93 | + | cursor := desiredCapturedCursor(state.cursor) | |||
| 94 | + | outCache := composeCacheInput{valid: true, frame: frame.Clone(), layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration} | |||
| 95 | + | outCache.bars.capture(frame.Row(0), frame.Row(rows+1)) | |||
| 96 | + | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) { | |||
| 100 | + | styles := newThemeStyles(theme) | |||
| 101 | + | style := styles.border | |||
| 102 | + | if focused { | |||
| 103 | + | style = styles.statusBar | |||
| 104 | + | } else { | |||
| 105 | + | style = themeui.DimStyle(style, theme) | |||
| 106 | + | } | |||
| 107 | + | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |||
| 108 | + | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |||
| 109 | + | } | |||
| 110 | + | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | |||
| 114 | + | if !c.renderable || c.hiddenByOverlay || !c.visible { | |||
| 115 | + | return cursorOut{hidden: true} | |||
| 116 | + | } | |||
| 117 | + | style := c.style | |||
| 118 | + | if !c.hasStyle { | |||
| 119 | + | style = 1 | |||
| 120 | + | } | |||
| 121 | + | return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | |||
| 125 | + | if state == nil || snap == nil { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | o := &state.overlays | |||
| 129 | + | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |||
| 130 | + | o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot | |||
| 131 | + | if snap.copyPane != nil { | |||
| 132 | + | o.copyPaneID = snap.copyPane.id | |||
| 133 | + | } | |||
| 134 | + | styles := newThemeStyles(state.theme) | |||
| 135 | + | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |||
| 136 | + | if snap.copySearchModel != nil { | |||
| 137 | + | o.copySearch.modal = copySearchModal | |||
| 138 | + | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection) | |||
| 139 | + | } | |||
| 140 | + | if snap.pickerActive && snap.pickerModel != nil { | |||
| 141 | + | o.picker.modal = pickerModal | |||
| 142 | + | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()} | |||
| 143 | + | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |||
| 144 | + | } | |||
| 145 | + | if snap.paletteActive && snap.paletteModel != nil { | |||
| 146 | + | o.palette.modal = paletteModalFor(size, paletteCfg) | |||
| 147 | + | guidance := "" | |||
| 148 | + | if snap.paletteHints != nil { | |||
| 149 | + | guidance = snap.paletteHints.Feedback | |||
| 150 | + | } | |||
| 151 | + | o.paletteGuidance = guidance | |||
| 152 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance}) | |||
| 153 | + | } | |||
| 154 | + | if snap.promptActive && snap.promptModel != nil { | |||
| 155 | + | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |||
| 156 | + | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent) | |||
| 157 | + | } | |||
| 158 | + | state.cursor.hiddenByOverlay = o.active() | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | |||
| 162 | + | o := state.overlays | |||
| 163 | + | if o.copyActive { | |||
| 164 | + | target := domain.Rect{} | |||
| 165 | + | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |||
| 166 | + | target = state.floating.geometry.translate(content.X, content.Y).Inner | |||
| 167 | + | } | |||
| 168 | + | if target.Width == 0 { | |||
| 169 | + | id := o.copyPaneID | |||
| 170 | + | if id == "" { | |||
| 171 | + | id = state.layout.focus | |||
| 172 | + | } | |||
| 173 | + | for _, p := range state.panes { | |||
| 174 | + | if p.id == id && !p.placement.Collapsed { | |||
| 175 | + | target = p.placement.Content | |||
| 176 | + | target.Y += content.Y | |||
| 177 | + | break | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars) | |||
| 182 | + | } | |||
| 183 | + | legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |||
| 184 | + | if o.paletteActive && !state.floating.visible { | |||
| 185 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme) | |||
| 186 | + | } | |||
| 187 | + | for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} { | |||
| 188 | + | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |||
| 189 | + | continue | |||
| 190 | + | } | |||
| 191 | + | inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border) | |||
| 192 | + | for y := range min(inner.Height, modal.inner.Height) { | |||
| 193 | + | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |||
| 194 | + | } | |||
| 195 | + | damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 196 | + | } | |||
| 197 | + | return frame, damage | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |||
| 201 | + | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |||
| 202 | + | if !changed { | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | prev := ac.lastCursor | |||
| 206 | + | ac.lastCursor = desired | |||
| 207 | + | ac.lastCursor.valid = true | |||
| 208 | + | if desired.hidden { | |||
| 209 | + | return []byte("\x1b[?25l") | |||
| 210 | + | } | |||
| 211 | + | var b []byte | |||
| 212 | + | b = append(b, "\x1b["...) | |||
| 213 | + | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |||
| 214 | + | b = append(b, ';') | |||
| 215 | + | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |||
| 216 | + | b = append(b, 'H') | |||
| 217 | + | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |||
| 218 | + | b = append(b, "\x1b["...) | |||
| 219 | + | b = strconv.AppendInt(b, int64(desired.style), 10) | |||
| 220 | + | b = append(b, " q"...) | |||
| 221 | + | } | |||
| 222 | + | b = append(b, "\x1b[?25h"...) | |||
| 223 | + | return b | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | |||
| 227 | + | // sendMu for the complete capture/compose/emit transaction. | |||
| 228 | + | func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool { | |||
| 229 | + | if sess == nil || ac == nil || state == nil { | |||
| 230 | + | if ac != nil { | |||
| 231 | + | ac.sendMu.Unlock() | |||
| 232 | + | } | |||
| 233 | + | return false | |||
| 234 | + | } | |||
| 235 | + | sess.mu.Lock() | |||
| 236 | + | owned := sess.client == ac | |||
| 237 | + | sess.mu.Unlock() | |||
| 238 | + | if !owned || state.attachment != ac || state.attachmentEpoch != ac.coordinatorEpoch.Load() || ac.currentSession() != sess { | |||
| 239 | + | ac.sendMu.Unlock() | |||
| 240 | + | return false | |||
| 241 | + | } | |||
| 242 | + | ac.sess.mu.Lock() | |||
| 243 | + | if ac.sess.v != sess { | |||
| 244 | + | ac.sess.mu.Unlock() | |||
| 245 | + | ac.sendMu.Unlock() | |||
| 246 | + | return false | |||
| 247 | + | } | |||
| 248 | + | prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset) | |||
| 249 | + | if err != nil { | |||
| 250 | + | ac.sess.mu.Unlock() | |||
| 251 | + | ac.sendMu.Unlock() | |||
| 252 | + | d.log.Error("render draw failed", "err", err, "session", sess.name) | |||
| 253 | + | return true | |||
| 254 | + | } | |||
| 255 | + | data := append([]byte(nil), prepared.data...) | |||
| 256 | + | data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...) | |||
| 257 | + | var sendTr ports.Transport | |||
| 258 | + | var sendErr error | |||
| 259 | + | if len(data) > 0 { | |||
| 260 | + | sendTr = ac.transport() | |||
| 261 | + | if sendTr == nil { | |||
| 262 | + | sendErr = errors.New("client transport is nil") | |||
| 263 | + | } else { | |||
| 264 | + | send := sendTr.Send | |||
| 265 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |||
| 266 | + | send = async.SendAsync | |||
| 267 | + | } | |||
| 268 | + | sendErr = prepared.send(data, ac.echoAck.Load(), send) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | if sendErr == nil { | |||
| 272 | + | ac.pipelineCache = composed.cache | |||
| 273 | + | if state.overlays.active() { | |||
| 274 | + | ac.composed.invalidate() | |||
| 275 | + | } else { | |||
| 276 | + | ac.composed.frame = composed.frame.Clone() | |||
| 277 | + | ac.composed.valid = true | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ac.sess.mu.Unlock() | |||
| 281 | + | ac.sendMu.Unlock() | |||
| 282 | + | if sendErr != nil { | |||
| 283 | + | d.detachOnSendError(sess, ac, sendTr) | |||
| 284 | + | } | |||
| 285 | + | return true | |||
| 286 | + | } | |||
M internal/usecase/daemon/render_pipeline_test.go +24 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| @@ -91,3 +92,26 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||
| 91 | 92 | default: | |
| 92 | 93 | } | |
| 93 | 94 | } | |
| 95 | + | ||
| 96 | + | func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) { | |
| 97 | + | state := &capturedRenderState{ | |
| 98 | + | layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true}, | |
| 99 | + | panes: []capturedPaneRenderState{{ | |
| 100 | + | id: "p", frame: func() renderer.Frame { | |
| 101 | + | f := renderer.NewFrame(4, 1) | |
| 102 | + | for i, r := range "old " { | |
| 103 | + | f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 104 | + | } | |
| 105 | + | return f | |
| 106 | + | }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true, | |
| 107 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 108 | + | }}, | |
| 109 | + | bars: barState{}, | |
| 110 | + | cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}}, | |
| 111 | + | } | |
| 112 | + | cache := composeCacheInput{} | |
| 113 | + | out := composeFrame(*state, cache) | |
| 114 | + | state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |
| 115 | + | require.Equal(t, "old ", rowText(out.frame.Row(1))) | |
| 116 | + | require.False(t, out.cursor.hidden) | |
| 117 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 7 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| @@ -91,3 +92,26 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||||
| 91 | default: | 92 | default: | ||
| 92 | } | 93 | } | ||
| 93 | } | 94 | } | ||
| 95 | + | ||||
| 96 | + | func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) { | |||
| 97 | + | state := &capturedRenderState{ | |||
| 98 | + | layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true}, | |||
| 99 | + | panes: []capturedPaneRenderState{{ | |||
| 100 | + | id: "p", frame: func() renderer.Frame { | |||
| 101 | + | f := renderer.NewFrame(4, 1) | |||
| 102 | + | for i, r := range "old " { | |||
| 103 | + | f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 104 | + | } | |||
| 105 | + | return f | |||
| 106 | + | }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true, | |||
| 107 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 108 | + | }}, | |||
| 109 | + | bars: barState{}, | |||
| 110 | + | cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}}, | |||
| 111 | + | } | |||
| 112 | + | cache := composeCacheInput{} | |||
| 113 | + | out := composeFrame(*state, cache) | |||
| 114 | + | state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 115 | + | require.Equal(t, "old ", rowText(out.frame.Row(1))) | |||
| 116 | + | require.False(t, out.cursor.hidden) | |||
| 117 | + | } | |||