render_capture.go RawHistoryFolder

1package daemon
2
3import (
4	renderer "github.com/bnema/vev-vt"
5	vevgraphics "github.com/bnema/vev-vt/graphics"
6	"github.com/bnema/vev/internal/domain"
7	"github.com/bnema/vev/internal/protocol"
8	scopy "github.com/bnema/vev/internal/usecase/copy"
9	"github.com/bnema/vev/internal/usecase/layout"
10	themeui "github.com/bnema/vev/internal/usecase/theme"
11	"github.com/bnema/vev/internal/usecase/ui"
12)
13
14// capturedRenderState is the immutable hand-off from authoritative daemon
15// state to composition. Every field is a value or an owned bounded copy; the
16// composer must not retain or consult session, tab, pane, or overlay owners.
17// renderCaptureScratch owns bounded snapshots for one attachment. The complete
18// capture/compose/emit transaction holds that attachment's sendMu, so these
19// buffers cannot be observed or reused before emission completes.
20type renderCaptureScratch struct {
21	state             capturedRenderState
22	panes             []capturedPaneRenderState
23	placements        []layout.Placement
24	dividers          []layout.Divider
25	layoutTab         *tab
26	layoutRoot        *layout.Node
27	layoutGeneration  uint64
28	layoutArea        domain.Rect
29	layoutFocus       layout.PaneID
30	layoutFingerprint string
31	layoutValid       bool
32	statusTabs        []statusTab
33	mru               []recentRouteDisplay
34	ranked            []rankedRecent
35	titleIDs          []layout.PaneID
36	receipts          []damageReceipt
37}
38
39type damageReceipt struct {
40	pane       *pane
41	generation uint64
42}
43
44type capturedRenderState struct {
45	attachment      *attachedClient // identity only; never dereferenced by composition
46	sessionID       domain.SessionID
47	route           protocol.CommittedRouteIdentity
48	incarnation     domain.IncarnationID
49	lease           *attachmentLease
50	uiFence         uint64
51	frameCapture    uint64 // numbers this capture in the attachment's afterFrame queue
52	view            attachmentView
53	focusedPaneID   domain.PaneStableID
54	window          domain.Size
55	reset           bool
56	layout          capturedTabLayout
57	panes           []capturedPaneRenderState
58	floating        capturedFloatingRenderState
59	bars            barState
60	theme           themeui.Theme
61	styles          themeui.Styles
62	styleGeneration uint64
63	overlays        capturedOverlayRenderState
64	// suppressedGraphics records a late Kitty scene observed on an attachment
65	// that cannot emit graphics. Capture keeps the scene out of the output but
66	// lets the daemon issue one bounded user warning after pane locks release.
67	suppressedGraphics bool
68	cursor             capturedCursorInputs
69	tabGeneration      uint64
70	floatingGeneration uint64
71	receipts           []damageReceipt // private capture receipts; compose must not inspect these
72}
73
74// viewContext describes only this captured composition, never live session state.
75func (s *capturedRenderState) viewContext() protocol.ViewContext {
76	context := protocol.ViewContext{Route: s.route, TabID: s.view.tabID, FocusedPaneID: s.focusedPaneID}
77	if s.floating.visible && s.floating.focused {
78		context.FocusedPaneID = s.floating.pane.stableID
79		return context
80	}
81	for _, pane := range s.panes {
82		if pane.focused {
83			context.FocusedPaneID = pane.stableID
84			break
85		}
86	}
87	return context
88}
89
90type capturedTabLayout struct {
91	area        domain.Rect
92	focus       layout.PaneID
93	placements  []layout.Placement
94	dividers    []layout.Divider
95	fingerprint string
96	valid       bool
97}
98
99type capturedPaneRenderState struct {
100	id               layout.PaneID
101	stableID         domain.PaneStableID
102	frame            renderer.Frame
103	rawDamage        []renderer.Damage
104	damage           []renderer.Damage
105	damageGeneration uint64
106	// graphics is an immutable copy-on-write reference owned by the VT screen.
107	// It is nil for ordinary text panes, preserving the fast text path.
108	graphics         *vevgraphics.Snapshot
109	graphicsGeometry domain.Geometry
110	title            string
111	titleGeneration  uint64
112	placement        layout.Placement
113	focused          bool
114}
115
116type capturedFloatingRenderState struct {
117	visible         bool
118	focused         bool
119	pane            capturedPaneRenderState
120	geometry        floatingGeometry
121	title           string
122	generation      uint64
123	titleGeneration uint64
124}
125
126type capturedOverlayRenderState struct {
127	copyActive, copySearchActive, paletteActive, promptActive bool
128	noticesOverlayActive, resizeActive                        bool
129	copyMode                                                  *scopy.Mode
130	copyPaneID                                                layout.PaneID
131	statusFeedback                                            string
132	paletteGuidance                                           string
133	copySearch, palette, prompt, noticesOverlay               capturedModal
134	notices                                                   []domain.Notification
135	noticeOverflow                                            int
136}
137
138type capturedModal struct {
139	active       bool
140	title        string
141	presentation ui.Presentation
142	inner        renderer.Frame
143	focused      bool
144}
145
146func (o capturedOverlayRenderState) active() bool {
147	return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
148}
149
150type capturedCursorInputs struct {
151	row, col, style                                int
152	hasStyle, visible, renderable, hiddenByOverlay bool
153	content                                        domain.Rect
154}
155
156// writePaneScreenLocked is the production VT write boundary. The first
157// placement fixes the current scene's coordinate units before a later resize
158// can change Screen.Geometry; deleting the last placement ends that boundary.
159// Caller holds p.mu.
160func writePaneScreenLocked(p *pane, data []byte) {
161	if p == nil || p.screen == nil {
162		return
163	}
164	before := p.screen.GraphicsSnapshot()
165	beforeHasPlacements := before != nil && before.Usage().Placements != 0
166	p.screen.Write(data)
167	after := p.screen.GraphicsSnapshot()
168	afterHasPlacements := after != nil && after.Usage().Placements != 0
169	if !beforeHasPlacements && afterHasPlacements {
170		geometry := p.screen.Geometry()
171		p.graphicsCoordinateGeometry = domain.Geometry{
172			Size:        domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
173			PixelWidth:  geometry.PixelWidth,
174			PixelHeight: geometry.PixelHeight,
175		}.NormalizePixels()
176	}
177	p.graphicsPlacementScene = afterHasPlacements
178	if !afterHasPlacements {
179		p.graphicsCoordinateGeometry = domain.Geometry{}
180	}
181}
182
183// capturePaneRenderStateLockedInto copies only the visible rectangle required
184// by composition into out. It never copies scrollback/history and never lets
185// the mutable VT frame's Cells or row-offset slices escape pane.mu.
186func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
187	out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation
188	// GraphicsSnapshot never allocates graphics state for a text-only screen and
189	// returns an immutable scene reference when Kitty graphics were used.
190	out.graphics = p.screen.GraphicsSnapshot()
191	geometry := p.screen.Geometry()
192	currentGraphicsGeometry := domain.Geometry{
193		Size:       domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
194		PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
195	}.NormalizePixels()
196	// The scene retains placement coordinates across SetGeometry. Capture the
197	// unit basis when the first placement appears and keep it until the placement
198	// scene becomes empty; otherwise an attachment resize silently reinterprets
199	// old pixel coordinates using the new cell dimensions.
200	hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0
201	if hasPlacements && !p.graphicsPlacementScene {
202		p.graphicsCoordinateGeometry = currentGraphicsGeometry
203	}
204	p.graphicsPlacementScene = hasPlacements
205	if hasPlacements {
206		out.graphicsGeometry = p.graphicsCoordinateGeometry
207	} else {
208		p.graphicsCoordinateGeometry = domain.Geometry{}
209		out.graphicsGeometry = currentGraphicsGeometry
210	}
211	damage := p.screen.CaptureDamage()
212	out.damageGeneration = damage.Generation
213	out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
214
215	width := min(max(visible.Width, 0), p.screen.Columns())
216	height := min(max(visible.Height, 0), p.screen.Rows())
217	needsFrame := len(damage.Damage) > 0
218	frameChanged := out.frame.Width != width || out.frame.Height != height
219	if frameChanged {
220		out.frame = renderer.NewFrame(width, height)
221		needsFrame = true
222	}
223	// A retained attachment snapshot is already an immutable copy of this
224	// pane. With no VT damage, retain it rather than copying inactive panes.
225	if needsFrame {
226		for y := range height {
227			for x := range width {
228				out.frame.Set(x, y, p.screen.Cell(x, y))
229			}
230		}
231	}
232
233	if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
234		// A cache entry with a new frame has no terminal-shadow equivalent.
235		// Redraw it even when the pane has no VT damage, such as an untouched
236		// pane first made visible after a tab or session switch.
237		out.damage = []renderer.Damage{renderer.FullRedraw()}
238	} else {
239		out.damage = append(out.damage[:0], damage.Damage...)
240	}
241	// Consumption is transactional: capture only snapshots damage. publishFrameLocked
242	// acknowledges this generation after preparation and transport success.
243	return out
244}
245
246func uncertainDamage(damage []renderer.Damage, width, height int) bool {
247	for _, d := range damage {
248		if d.Kind == renderer.DamageFullRedraw {
249			if d != renderer.FullRedraw() {
250				return true
251			}
252			continue
253		}
254		if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
255			return true
256		}
257		if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
258			return true
259		}
260	}
261	return false
262}
263
264type renderCaptureRequest struct {
265	bars            barState
266	overlays        capturedOverlayRenderState
267	floatingCfg     domain.FloatingConfig
268	styles          themeui.Styles
269	styleGeneration uint64
270	reset           bool
271	lease           *attachmentLease
272	uiFence         uint64
273}
274
275// captureLocalRenderState is the ownership boundary for a local attachment
276// render transaction. Callers hold attachment sendMu; this function then follows
277// session -> tab -> pane lock order. ACK-blocked capture returns before touching
278// VT damage, and every captured pane records a receipt for successful emission.
279func captureLocalRenderState(
280	sess *session,
281	ac *attachedClient,
282	request renderCaptureRequest,
283) (*capturedRenderState, bool) {
284	bars := request.bars
285	overlays := request.overlays
286	floatingCfg := request.floatingCfg
287	reset := request.reset
288	lease := request.lease
289	if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
290		return nil, false
291	}
292	sess.mu.Lock()
293	_, owned := sess.attachments[ac]
294	sessionID := sess.id
295	incarnation := sess.incarnation
296	route := protocol.CommittedRouteIdentity{
297		Target:    protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
298		Ephemeral: sess.ephemeral,
299	}
300	sess.mu.Unlock()
301	if !owned {
302		return nil, false
303	}
304	tb := sess.tabForAttachment(ac)
305	if tb == nil {
306		return nil, false
307	}
308	// Resolve all pane selection from one view value. Navigation after this
309	// point invalidates the captured revision at prepared-output admission.
310	view := ac.viewSnapshot()
311	if view.tabID != domain.TabStableID(tb.stableID) {
312		return nil, false
313	}
314
315	// A reset is an attachment-local capture boundary. The shared pane damage
316	// receipt may have been acknowledged by another fan-out attachment before
317	// this capture runs, so retaining this attachment's prior pane snapshots
318	// would compose an old frame with a fresh reset epoch.
319	if reset {
320		ac.render.forgetAllPanes()
321	}
322	scratch := &ac.render.capture
323	scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
324	bars.status.tabs = scratch.statusTabs
325	scratch.mru = append(scratch.mru[:0], bars.mru...)
326	bars.mru = scratch.mru
327	if bars.rankedRecent != nil {
328		scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
329		bars.rankedRecent = scratch.ranked
330	}
331	tb.mu.Lock()
332	defer tb.mu.Unlock()
333
334	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
335	var focus layout.PaneID
336	var focusedPaneID domain.PaneStableID
337	for _, pane := range tb.panes {
338		if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID {
339			focus = pane.id
340			focusedPaneID = domain.PaneStableID(pane.stableID)
341			break
342		}
343	}
344	if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) {
345		// A removed input target is not a committed semantic view. Its owning
346		// topology mutation will repair/invalidate the attachment separately.
347		return nil, false
348	}
349	layoutSnap := tabLayoutSnapshot{
350		area: area, focus: focus, placements: scratch.placements,
351		dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
352	}
353	var layoutRoot *layout.Node
354	if tb.tree != nil {
355		layoutRoot = tb.tree.Root
356	}
357	if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
358		layoutSnap = solveTabLayoutLocked(tb)
359		layoutSnap.focus = focus
360		scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
361		scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
362		scratch.layoutTab = tb
363		scratch.layoutRoot = layoutRoot
364		scratch.layoutGeneration = tb.layoutGeneration
365		scratch.layoutArea = area
366		scratch.layoutFocus = focus
367		scratch.layoutFingerprint = layoutSnap.fingerprint
368		scratch.layoutValid = layoutSnap.ok
369		layoutSnap.placements = scratch.placements
370		layoutSnap.dividers = scratch.dividers
371	}
372	state := &scratch.state
373	window := domain.Size{}
374	if view.windowSet {
375		window = ac.sizeSnapshot()
376	}
377	*state = capturedRenderState{
378		attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window,
379		reset: reset, bars: bars, theme: bars.theme,
380		uiFence:       request.uiFence,
381		focusedPaneID: focusedPaneID,
382		styles:        request.styles, styleGeneration: request.styleGeneration,
383		overlays:           overlays,
384		layout:             capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
385		floatingGeneration: tb.floating.generation,
386		receipts:           scratch.receipts[:0],
387	}
388	state.panes = scratch.panes[:0]
389	state.tabGeneration = uint64(len(layoutSnap.fingerprint))
390	for _, placement := range layoutSnap.placements {
391		p := tb.panes[placement.ID]
392		if p == nil {
393			continue
394		}
395		visible := placement.Content
396		if placement.Collapsed {
397			visible = domain.Rect{}
398		}
399		p.mu.Lock()
400		captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
401		if !ac.terminalCapabilities.SupportsKittyGraphics() {
402			if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
403				state.suppressedGraphics = true
404			}
405			captured.graphics = nil
406		}
407		state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
408		captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
409		if captured.focused {
410			state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
411		}
412		p.mu.Unlock()
413		translated := captured.rawDamage[:0]
414		for _, damage := range captured.damage {
415			translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
416		}
417		captured.rawDamage, captured.damage = captured.damage, translated
418		ac.render.storePaneSnapshot(p, captured)
419		state.panes = append(state.panes, captured)
420	}
421	scratch.panes = state.panes
422	if tb.floating.state == floatingVisible && tb.floating.pane != nil {
423		p := tb.floating.pane
424		p.mu.Lock()
425		geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
426		captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p))
427		if !ac.terminalCapabilities.SupportsKittyGraphics() {
428			if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
429				state.suppressedGraphics = true
430			}
431			captured.graphics = nil
432		}
433		seen := false
434		for _, receipt := range state.receipts {
435			if receipt.pane == p {
436				seen = true
437				break
438			}
439		}
440		if !seen {
441			state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
442		}
443		ac.render.storePaneSnapshot(p, captured)
444		// A visible floating pane is the terminal input target, so its structural
445		// border carries the focused semantic role independently of its content.
446		state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
447		state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
448		p.mu.Unlock()
449	}
450	scratch.receipts = state.receipts
451	return state, true
452}
453
454// captureRenderState preserves the focused local test seam. Production
455// rendering delegates through *session.captureRenderState.
456func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
457	return captureLocalRenderState(sess, ac, request)
458}
459
460// copyRankedRecentInto preserves the non-nil empty slice that selects
461// contextual recent-session mode while retaining scratch capacity for reuse.
462func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
463	if len(src) == 0 {
464		if dst != nil {
465			return dst[:0]
466		}
467		return []rankedRecent{}
468	}
469	return append(dst[:0], src...)
470}
471
472func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
473	style, hasStyle := p.screen.CursorStyle()
474	hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
475	return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
476}
477
478// appendStackPaneIDs returns exactly the panes whose solved placement can own a
479// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
480func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
481	if node == nil {
482		return dst
483	}
484	if node.Kind == layout.Stack {
485		for _, child := range node.Children {
486			if child != nil && child.Kind == layout.Leaf {
487				dst = append(dst, child.Leaf)
488			}
489		}
490		return dst
491	}
492	for _, child := range node.Children {
493		dst = appendStackPaneIDs(dst, child)
494	}
495	return dst
496}

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