detached 1dd8eef

perf(daemon): serialize render composition and emission

commit
1dd8eef0bbf0d6a538160168af837293d06b6e91
author
brice · · 2026-07-12 05:12 UTC
parent
eb37c83
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3232 coordinatorReadyEpoch atomic.Uint64
3333 echoAck atomic.Uint64
3434 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
3637 resizePaint pendingByteTimer // guarded by sendMu
3738 resizePaintGeneration uint64 // guarded by sendMu
3839 resizePaintPending bool // guarded by sendMu
@@ -32,7 +32,8 @@ type attachedClient struct {
32 coordinatorReadyEpoch atomic.Uint6432 coordinatorReadyEpoch atomic.Uint64
33 echoAck atomic.Uint6433 echoAck atomic.Uint64
34 bars barCache // only touched while sendMu is held34 bars barCache // only touched while sendMu is held
35- composed composedFrameCache // only touched while sendMu is held35+ composed composedFrameCache // test-only legacy composition cache
36+ pipelineCache composeCacheInput // only touched while sendMu is held
36 resizePaint pendingByteTimer // guarded by sendMu37 resizePaint pendingByteTimer // guarded by sendMu
37 resizePaintGeneration uint64 // guarded by sendMu38 resizePaintGeneration uint64 // guarded by sendMu
38 resizePaintPending bool // guarded by sendMu39 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 *
147147 return frame, damage, geometry
148148 }
149149
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+
150172 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
151173 if bounds.Width <= 0 || bounds.Height <= 0 {
152174 return
@@ -147,6 +147,28 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
147 return frame, damage, geometry147 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 return174 return
M internal/usecase/daemon/render.go +17 -182
@@ -3,8 +3,6 @@ package daemon
33
44 import (
55 "bytes"
6- "errors"
7- "strconv"
86
97 "github.com/bnema/vev/internal/domain"
108 "github.com/bnema/vev/internal/ports"
@@ -12,7 +10,6 @@ import (
1210 themeui "github.com/bnema/vev/internal/usecase/theme"
1311 "github.com/bnema/vev/internal/usecase/ui"
1412 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1613 )
1714
1815 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
@@ -332,8 +329,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
332329 attentionVisible := pulseVisible(bars.attentionFrame)
333330 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
334331
335- styles := newThemeStyles(ac.getTheme())
336332 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.
337336 tb.mu.Lock()
338337 layoutSnap := solveTabLayoutLocked(tb)
339338 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
@@ -347,189 +346,25 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
347346 d.refreshPaneDisplayTitle(sess, floating, false)
348347 }
349348
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,
380353 }
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 {
441356 ac.sendMu.Unlock()
442357 return
443358 }
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)
513364 }
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)
533368 }
534369
535370 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 one15 // 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 return357 return
443 }358 }
444- prepared, err := ac.output.prepare(frame, damage, reset || overlayActive)359+ if overlays.pickerActive && overlays.previewTab == tb {
445- var data []byte360+ previewState := *state
446- if err == nil {361+ previewState.overlays = capturedOverlayRenderState{}
447- data = prepared.data362+ 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.lastCursor365+ captureOverlayLayers(state, overlays, paletteCfg)
515- ac.lastCursor = desired366+ composed := composeFrame(*state, ac.pipelineCache)
516- ac.lastCursor.valid = true367+ 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
22
33 import (
44 "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
56 "github.com/bnema/vev/internal/usecase/layout"
67 "github.com/bnema/vev/internal/usecase/picker"
78 themeui "github.com/bnema/vev/internal/usecase/theme"
9+ "github.com/bnema/vev/internal/usecase/ui"
810 "github.com/bnema/vev/pkg/renderer"
911 )
1012
@@ -38,6 +40,7 @@ type capturedRenderState struct {
3840 }
3941
4042 type capturedTabLayout struct {
43+ root *layout.Node
4144 area domain.Rect
4245 focus layout.PaneID
4346 placements []layout.Placement
@@ -68,9 +71,22 @@ type capturedFloatingRenderState struct {
6871
6972 type capturedOverlayRenderState struct {
7073 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
74+ copyMode *scopy.Mode
75+ copySnapshot *scopy.Snapshot
76+ copyPaneID layout.PaneID
7177 copyTarget domain.Rect
7278 copyFeedback string
7379 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
7490 }
7591
7692 type capturedCursorInputs struct {
@@ -136,7 +152,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
136152 // Callers hold attachment sendMu; this function then follows session -> tab ->
137153 // pane lock order. ACK-blocked primary capture returns before touching VT
138154 // 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) {
140156 if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
141157 return nil, false
142158 }
@@ -160,7 +176,12 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
160176 state := &capturedRenderState{
161177 attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,
162178 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},
164185 floatingGeneration: tb.floating.generation,
165186 }
166187 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
@@ -191,7 +212,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
191212 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
192213 p := tb.floating.pane
193214 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))
195216 captured := capturePaneRenderStateLocked(p, geometry.Inner, mode)
196217 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
197218 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.Rect44 area domain.Rect
42 focus layout.PaneID45 focus layout.PaneID
43 placements []layout.Placement46 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 bool73 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
74+ copyMode *scopy.Mode
75+ copySnapshot *scopy.Snapshot
76+ copyPaneID layout.PaneID
71 copyTarget domain.Rect77 copyTarget domain.Rect
72 copyFeedback string78 copyFeedback string
73 paletteGuidance string79 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 VT153 // 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, false157 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.pane213 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 (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
78 "github.com/bnema/vev/pkg/renderer"
89 "github.com/stretchr/testify/require"
910 )
@@ -91,3 +92,26 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
9192 default:
9293 }
9394 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close