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/domain/layout"
8	"github.com/bnema/vev/internal/protocol"
9	scopy "github.com/bnema/vev/internal/usecase/copy"
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	// caretCol is the input caret column on row 0 of inner, valid when
145	// hasCaret. The topmost modal with a caret owns the terminal cursor.
146	caretCol int
147	hasCaret bool
148}
149
150func (o capturedOverlayRenderState) active() bool {
151	return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
152}
153
154type capturedCursorInputs struct {
155	row, col, style               int
156	hasStyle, visible, renderable bool
157	content                       domain.Rect
158}
159
160// writePaneScreenLocked is the production VT write boundary. The first
161// placement fixes the current scene's coordinate units before a later resize
162// can change Screen.Geometry; deleting the last placement ends that boundary.
163// Caller holds p.mu.
164func writePaneScreenLocked(p *pane, data []byte) {
165	if p == nil || p.screen == nil {
166		return
167	}
168	before := p.screen.GraphicsSnapshot()
169	beforeHasPlacements := before != nil && before.Usage().Placements != 0
170	p.screen.Write(data)
171	after := p.screen.GraphicsSnapshot()
172	afterHasPlacements := after != nil && after.Usage().Placements != 0
173	if !beforeHasPlacements && afterHasPlacements {
174		geometry := p.screen.Geometry()
175		p.graphicsCoordinateGeometry = domain.Geometry{
176			Size:        domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
177			PixelWidth:  geometry.PixelWidth,
178			PixelHeight: geometry.PixelHeight,
179		}.NormalizePixels()
180	}
181	p.graphicsPlacementScene = afterHasPlacements
182	if !afterHasPlacements {
183		p.graphicsCoordinateGeometry = domain.Geometry{}
184	}
185}
186
187// capturePaneRenderStateLockedInto copies only the visible rectangle required
188// by composition into out. It never copies scrollback/history and never lets
189// the mutable VT frame's Cells or row-offset slices escape pane.mu.
190func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
191	out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation
192	// GraphicsSnapshot never allocates graphics state for a text-only screen and
193	// returns an immutable scene reference when Kitty graphics were used.
194	out.graphics = p.screen.GraphicsSnapshot()
195	geometry := p.screen.Geometry()
196	currentGraphicsGeometry := domain.Geometry{
197		Size:       domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
198		PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
199	}.NormalizePixels()
200	// The scene retains placement coordinates across SetGeometry. Capture the
201	// unit basis when the first placement appears and keep it until the placement
202	// scene becomes empty; otherwise an attachment resize silently reinterprets
203	// old pixel coordinates using the new cell dimensions.
204	hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0
205	if hasPlacements && !p.graphicsPlacementScene {
206		p.graphicsCoordinateGeometry = currentGraphicsGeometry
207	}
208	p.graphicsPlacementScene = hasPlacements
209	if hasPlacements {
210		out.graphicsGeometry = p.graphicsCoordinateGeometry
211	} else {
212		p.graphicsCoordinateGeometry = domain.Geometry{}
213		out.graphicsGeometry = currentGraphicsGeometry
214	}
215	damage := p.screen.CaptureDamage()
216	out.damageGeneration = damage.Generation
217	out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
218
219	width := min(max(visible.Width, 0), p.screen.Columns())
220	height := min(max(visible.Height, 0), p.screen.Rows())
221	needsFrame := len(damage.Damage) > 0
222	frameChanged := out.frame.Width != width || out.frame.Height != height
223	if frameChanged {
224		out.frame = renderer.NewFrame(width, height)
225		needsFrame = true
226	}
227	// A retained attachment snapshot is already an immutable copy of this
228	// pane. With no VT damage, retain it rather than copying inactive panes.
229	if needsFrame {
230		for y := range height {
231			for x := range width {
232				out.frame.Set(x, y, p.screen.Cell(x, y))
233			}
234		}
235	}
236
237	if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
238		// A cache entry with a new frame has no terminal-shadow equivalent.
239		// Redraw it even when the pane has no VT damage, such as an untouched
240		// pane first made visible after a tab or session switch.
241		out.damage = []renderer.Damage{renderer.FullRedraw()}
242	} else {
243		out.damage = append(out.damage[:0], damage.Damage...)
244	}
245	// Consumption is transactional: capture only snapshots damage. publishFrameLocked
246	// acknowledges this generation after preparation and transport success.
247	return out
248}
249
250func uncertainDamage(damage []renderer.Damage, width, height int) bool {
251	for _, d := range damage {
252		if d.Kind == renderer.DamageFullRedraw {
253			if d != renderer.FullRedraw() {
254				return true
255			}
256			continue
257		}
258		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 {
259			return true
260		}
261		if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
262			return true
263		}
264	}
265	return false
266}
267
268type renderCaptureRequest struct {
269	bars            barState
270	overlays        capturedOverlayRenderState
271	floatingCfg     domain.FloatingConfig
272	styles          themeui.Styles
273	styleGeneration uint64
274	reset           bool
275	lease           *attachmentLease
276	uiFence         uint64
277}
278
279// captureLocalRenderState is the ownership boundary for a local attachment
280// render transaction. Callers hold attachment sendMu; this function then follows
281// session -> tab -> pane lock order. ACK-blocked capture returns before touching
282// VT damage, and every captured pane records a receipt for successful emission.
283func captureLocalRenderState(
284	sess *session,
285	ac *attachedClient,
286	request renderCaptureRequest,
287) (*capturedRenderState, bool) {
288	bars := request.bars
289	overlays := request.overlays
290	floatingCfg := request.floatingCfg
291	reset := request.reset
292	lease := request.lease
293	if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
294		return nil, false
295	}
296	sess.mu.Lock()
297	_, owned := sess.attachments[ac]
298	sessionID := sess.id
299	incarnation := sess.incarnation
300	route := protocol.CommittedRouteIdentity{
301		Target:    protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
302		Ephemeral: sess.ephemeral,
303	}
304	sess.mu.Unlock()
305	if !owned {
306		return nil, false
307	}
308	tb := sess.tabForAttachment(ac)
309	if tb == nil {
310		return nil, false
311	}
312	// Resolve all pane selection from one view value. Navigation after this
313	// point invalidates the captured revision at prepared-output admission.
314	view := ac.viewSnapshot()
315	if view.tabID != domain.TabStableID(tb.stableID) {
316		return nil, false
317	}
318
319	// A reset is an attachment-local capture boundary. The shared pane damage
320	// receipt may have been acknowledged by another fan-out attachment before
321	// this capture runs, so retaining this attachment's prior pane snapshots
322	// would compose an old frame with a fresh reset epoch.
323	if reset {
324		ac.render.forgetAllPanes()
325	}
326	scratch := &ac.render.capture
327	scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
328	bars.status.tabs = scratch.statusTabs
329	scratch.mru = append(scratch.mru[:0], bars.mru...)
330	bars.mru = scratch.mru
331	if bars.rankedRecent != nil {
332		scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
333		bars.rankedRecent = scratch.ranked
334	}
335	tb.mu.Lock()
336	defer tb.mu.Unlock()
337
338	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
339	var focus layout.PaneID
340	var focusedPaneID domain.PaneStableID
341	for _, pane := range tb.panes {
342		if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID {
343			focus = pane.id
344			focusedPaneID = domain.PaneStableID(pane.stableID)
345			break
346		}
347	}
348	if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) {
349		// A removed input target is not a committed semantic view. Its owning
350		// topology mutation will repair/invalidate the attachment separately.
351		return nil, false
352	}
353	layoutSnap := tabLayoutSnapshot{
354		area: area, focus: focus, placements: scratch.placements,
355		dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
356	}
357	var layoutRoot *layout.Node
358	if tb.tree != nil {
359		layoutRoot = tb.tree.Root
360	}
361	if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
362		layoutSnap = solveTabLayoutLocked(tb)
363		layoutSnap.focus = focus
364		scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
365		scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
366		scratch.layoutTab = tb
367		scratch.layoutRoot = layoutRoot
368		scratch.layoutGeneration = tb.layoutGeneration
369		scratch.layoutArea = area
370		scratch.layoutFocus = focus
371		scratch.layoutFingerprint = layoutSnap.fingerprint
372		scratch.layoutValid = layoutSnap.ok
373		layoutSnap.placements = scratch.placements
374		layoutSnap.dividers = scratch.dividers
375	}
376	state := &scratch.state
377	window := domain.Size{}
378	if view.windowSet {
379		window = ac.sizeSnapshot()
380	}
381	*state = capturedRenderState{
382		attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window,
383		reset: reset, bars: bars, theme: bars.theme,
384		uiFence:       request.uiFence,
385		focusedPaneID: focusedPaneID,
386		styles:        request.styles, styleGeneration: request.styleGeneration,
387		overlays:           overlays,
388		layout:             capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
389		floatingGeneration: tb.floating.generation,
390		receipts:           scratch.receipts[:0],
391	}
392	state.panes = scratch.panes[:0]
393	state.tabGeneration = uint64(len(layoutSnap.fingerprint))
394	for _, placement := range layoutSnap.placements {
395		p := tb.panes[placement.ID]
396		if p == nil {
397			continue
398		}
399		visible := placement.Content
400		if placement.Collapsed {
401			visible = domain.Rect{}
402		}
403		p.mu.Lock()
404		captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
405		if !ac.terminalCapabilities.SupportsKittyGraphics() {
406			if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
407				state.suppressedGraphics = true
408			}
409			captured.graphics = nil
410		}
411		state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
412		captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
413		if captured.focused {
414			state.cursor = captureCursorInputsLocked(p, placement.Content)
415		}
416		p.mu.Unlock()
417		translated := captured.rawDamage[:0]
418		for _, damage := range captured.damage {
419			translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
420		}
421		captured.rawDamage, captured.damage = captured.damage, translated
422		ac.render.storePaneSnapshot(p, captured)
423		state.panes = append(state.panes, captured)
424	}
425	scratch.panes = state.panes
426	if tb.floating.state == floatingVisible && tb.floating.pane != nil {
427		p := tb.floating.pane
428		p.mu.Lock()
429		geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
430		captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p))
431		if !ac.terminalCapabilities.SupportsKittyGraphics() {
432			if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
433				state.suppressedGraphics = true
434			}
435			captured.graphics = nil
436		}
437		seen := false
438		for _, receipt := range state.receipts {
439			if receipt.pane == p {
440				seen = true
441				break
442			}
443		}
444		if !seen {
445			state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
446		}
447		ac.render.storePaneSnapshot(p, captured)
448		// A visible floating pane is the terminal input target, so its structural
449		// border carries the focused semantic role independently of its content.
450		state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
451		state.cursor = captureCursorInputsLocked(p, geometry.Inner)
452		p.mu.Unlock()
453	}
454	scratch.receipts = state.receipts
455	return state, true
456}
457
458// captureRenderState preserves the focused local test seam. Production
459// rendering delegates through *session.captureRenderState.
460func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
461	return captureLocalRenderState(sess, ac, request)
462}
463
464// copyRankedRecentInto preserves the non-nil empty slice that selects
465// contextual recent-session mode while retaining scratch capacity for reuse.
466func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
467	if len(src) == 0 {
468		if dst != nil {
469			return dst[:0]
470		}
471		return []rankedRecent{}
472	}
473	return append(dst[:0], src...)
474}
475
476func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
477	style, hasStyle := p.screen.CursorStyle()
478	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, content: content}
479}
480
481// appendStackPaneIDs returns exactly the panes whose solved placement can own a
482// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
483func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
484	if node == nil {
485		return dst
486	}
487	if node.Kind == layout.Stack {
488		for _, child := range node.Children {
489			if child != nil && child.Kind == layout.Leaf {
490				dst = append(dst, child.Leaf)
491			}
492		}
493		return dst
494	}
495	for _, child := range node.Children {
496		dst = appendStackPaneIDs(dst, child)
497	}
498	return dst
499}

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