v0.3.0 99dc266

render_capture.go RawHistoryFolder

1package daemon
2
3import (
4	renderer "github.com/bnema/vev-vt"
5	"github.com/bnema/vev/internal/domain"
6	scopy "github.com/bnema/vev/internal/usecase/copy"
7	"github.com/bnema/vev/internal/usecase/layout"
8	"github.com/bnema/vev/internal/usecase/picker"
9	themeui "github.com/bnema/vev/internal/usecase/theme"
10	"github.com/bnema/vev/internal/usecase/ui"
11)
12
13// capturedRenderState is the immutable hand-off from authoritative daemon
14// state to composition. Every field is a value or an owned bounded copy; the
15// composer must not retain or consult session, tab, pane, or overlay owners.
16// renderCaptureScratch owns bounded snapshots for one attachment. The complete
17// capture/compose/emit transaction holds that attachment's sendMu, so these
18// buffers cannot be observed or reused before emission completes.
19type renderCaptureScratch struct {
20	state             capturedRenderState
21	panes             []capturedPaneRenderState
22	placements        []layout.Placement
23	dividers          []layout.Divider
24	layoutTab         *tab
25	layoutGeneration  uint64
26	layoutArea        domain.Rect
27	layoutFocus       layout.PaneID
28	layoutFingerprint string
29	layoutValid       bool
30	statusTabs        []statusTab
31	mru               []recentRouteDisplay
32	ranked            []rankedRecent
33	titleIDs          []layout.PaneID
34	receipts          []damageReceipt
35}
36
37type damageReceipt struct {
38	pane       *pane
39	generation uint64
40}
41
42type capturedRenderState struct {
43	attachment         *attachedClient // identity only; never dereferenced by composition
44	lease              *attachmentLease
45	view               attachmentView
46	window             domain.Size
47	reset              bool
48	layout             capturedTabLayout
49	panes              []capturedPaneRenderState
50	floating           capturedFloatingRenderState
51	bars               barState
52	theme              themeui.Theme
53	styles             themeui.Styles
54	styleGeneration    uint64
55	overlays           capturedOverlayRenderState
56	preview            picker.Preview
57	cursor             capturedCursorInputs
58	tabGeneration      uint64
59	floatingGeneration uint64
60	receipts           []damageReceipt // private capture receipts; compose must not inspect these
61}
62
63type capturedTabLayout struct {
64	area        domain.Rect
65	focus       layout.PaneID
66	placements  []layout.Placement
67	dividers    []layout.Divider
68	fingerprint string
69	valid       bool
70}
71
72type capturedPaneRenderState struct {
73	id               layout.PaneID
74	frame            renderer.Frame
75	rawDamage        []renderer.Damage
76	damage           []renderer.Damage
77	damageGeneration uint64
78	title            string
79	titleGeneration  uint64
80	placement        layout.Placement
81	focused          bool
82}
83
84type capturedFloatingRenderState struct {
85	visible         bool
86	focused         bool
87	pane            capturedPaneRenderState
88	geometry        floatingGeometry
89	title           string
90	generation      uint64
91	titleGeneration uint64
92}
93
94type capturedOverlayRenderState struct {
95	copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
96	noticesOverlayActive, resizeActive                                      bool
97	copyMode                                                                *scopy.Mode
98	copyPaneID                                                              layout.PaneID
99	statusFeedback                                                          string
100	paletteGuidance                                                         string
101	copySearch, picker, palette, prompt, noticesOverlay                     capturedModal
102	notices                                                                 []domain.Notification
103	noticeOverflow                                                          int
104}
105
106type capturedModal struct {
107	active       bool
108	title        string
109	presentation ui.Presentation
110	inner        renderer.Frame
111	focused      bool
112}
113
114func (o capturedOverlayRenderState) active() bool {
115	return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
116}
117
118type capturedCursorInputs struct {
119	row, col, style                                int
120	hasStyle, visible, renderable, hiddenByOverlay bool
121	content                                        domain.Rect
122}
123
124// capturePaneRenderStateLocked copies only the visible rectangle required by
125// composition. It never copies scrollback/history and never lets the mutable
126// VT frame's Cells or row-offset slices escape pane.mu.
127func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRenderState {
128	return capturePaneRenderStateLockedInto(p, visible, capturedPaneRenderState{})
129}
130
131func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
132	out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
133	damage := p.screen.CaptureDamage()
134	out.damageGeneration = damage.Generation
135	out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
136
137	width := min(max(visible.Width, 0), p.screen.Frame.Width)
138	height := min(max(visible.Height, 0), p.screen.Frame.Height)
139	needsFrame := len(damage.Damage) > 0
140	frameChanged := out.frame.Width != width || out.frame.Height != height
141	if frameChanged {
142		out.frame = renderer.NewFrame(width, height)
143		needsFrame = true
144	}
145	// A retained attachment snapshot is already an immutable copy of this
146	// pane. With no VT damage, retain it rather than copying inactive panes.
147	if needsFrame {
148		for y := range height {
149			copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
150		}
151	}
152
153	if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
154		// A cache entry with a new frame has no terminal-shadow equivalent.
155		// Redraw it even when the pane has no VT damage, such as an untouched
156		// pane first made visible after a tab or session switch.
157		out.damage = []renderer.Damage{renderer.FullRedraw()}
158	} else {
159		out.damage = append(out.damage[:0], damage.Damage...)
160	}
161	// Consumption is transactional: capture only snapshots damage. emitFrame
162	// acknowledges this generation after preparation and transport success.
163	return out
164}
165
166func uncertainDamage(damage []renderer.Damage, width, height int) bool {
167	for _, d := range damage {
168		if d.Kind == renderer.DamageFullRedraw {
169			if d != renderer.FullRedraw() {
170				return true
171			}
172			continue
173		}
174		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 {
175			return true
176		}
177		if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
178			return true
179		}
180	}
181	return false
182}
183
184type renderCaptureRequest struct {
185	bars            barState
186	overlays        capturedOverlayRenderState
187	preview         picker.Preview
188	floatingCfg     domain.FloatingConfig
189	styles          themeui.Styles
190	styleGeneration uint64
191	reset           bool
192	lease           *attachmentLease
193}
194
195// captureLocalRenderState is the ownership boundary for a local attachment
196// render transaction. Callers hold attachment sendMu; this function then follows
197// session -> tab -> pane lock order. ACK-blocked capture returns before touching
198// VT damage, and every captured pane records a receipt for successful emission.
199func captureLocalRenderState(
200	sess *session,
201	ac *attachedClient,
202	request renderCaptureRequest,
203) (*capturedRenderState, bool) {
204	bars := request.bars
205	overlays := request.overlays
206	preview := request.preview
207	floatingCfg := request.floatingCfg
208	reset := request.reset
209	lease := request.lease
210	if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
211		return nil, false
212	}
213	sess.mu.Lock()
214	_, owned := sess.attachments[ac]
215	sess.mu.Unlock()
216	if !owned {
217		return nil, false
218	}
219	tb, focusedPane := sess.paneForAttachment(ac)
220	if tb == nil {
221		tb = sess.tabForAttachment(ac)
222	}
223	if tb == nil {
224		return nil, false
225	}
226
227	// A reset is an attachment-local capture boundary. The shared pane damage
228	// receipt may have been acknowledged by another fan-out attachment before
229	// this capture runs, so retaining this attachment's prior pane snapshots
230	// would compose an old frame with a fresh reset epoch.
231	if reset {
232		ac.captureFrames = nil
233	}
234	scratch := &ac.renderScratch
235	scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
236	bars.status.tabs = scratch.statusTabs
237	scratch.mru = append(scratch.mru[:0], bars.mru...)
238	bars.mru = scratch.mru
239	if bars.rankedRecent != nil {
240		scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
241		bars.rankedRecent = scratch.ranked
242	}
243	if len(preview.Rows) > 0 {
244		preview = clonePickerPreview(preview)
245	} else {
246		preview = picker.Preview{Width: preview.Width, Height: preview.Height}
247	}
248
249	tb.mu.Lock()
250	defer tb.mu.Unlock()
251	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
252	var focus layout.PaneID
253	if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
254		focus = focusedPane.id
255	}
256	layoutSnap := tabLayoutSnapshot{
257		area: area, focus: focus, placements: scratch.placements,
258		dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
259	}
260	if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
261		layoutSnap = solveTabLayoutLocked(tb)
262		layoutSnap.focus = focus
263		scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
264		scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
265		scratch.layoutTab = tb
266		scratch.layoutGeneration = tb.layoutGeneration
267		scratch.layoutArea = area
268		scratch.layoutFocus = focus
269		scratch.layoutFingerprint = layoutSnap.fingerprint
270		scratch.layoutValid = layoutSnap.ok
271		layoutSnap.placements = scratch.placements
272		layoutSnap.dividers = scratch.dividers
273	}
274	state := &scratch.state
275	view := ac.viewSnapshot()
276	window := domain.Size{}
277	if view.windowSet {
278		window = ac.sizeSnapshot()
279	}
280	*state = capturedRenderState{
281		attachment: ac, lease: lease, view: view, window: window,
282		reset: reset, bars: bars, theme: bars.theme,
283		styles: request.styles, styleGeneration: request.styleGeneration,
284		overlays: overlays, preview: preview,
285		layout:             capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
286		floatingGeneration: tb.floating.generation,
287		receipts:           scratch.receipts[:0],
288	}
289	state.panes = scratch.panes[:0]
290	state.tabGeneration = uint64(len(layoutSnap.fingerprint))
291	if ac.captureFrames == nil {
292		ac.captureFrames = make(map[*pane]capturedPaneRenderState)
293	}
294	for _, placement := range layoutSnap.placements {
295		p := tb.panes[placement.ID]
296		if p == nil {
297			continue
298		}
299		visible := placement.Content
300		if placement.Collapsed {
301			visible = domain.Rect{}
302		}
303		p.mu.Lock()
304		captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
305		state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
306		captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
307		if captured.focused {
308			state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
309		}
310		p.mu.Unlock()
311		translated := captured.rawDamage[:0]
312		for _, damage := range captured.damage {
313			translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
314		}
315		captured.rawDamage, captured.damage = captured.damage, translated
316		ac.captureFrames[p] = captured
317		state.panes = append(state.panes, captured)
318	}
319	scratch.panes = state.panes
320	if tb.floating.state == floatingVisible && tb.floating.pane != nil {
321		p := tb.floating.pane
322		p.mu.Lock()
323		geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
324		captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
325		seen := false
326		for _, receipt := range state.receipts {
327			if receipt.pane == p {
328				seen = true
329				break
330			}
331		}
332		if !seen {
333			state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
334		}
335		ac.captureFrames[p] = captured
336		// A visible floating pane is the terminal input target, so its structural
337		// border carries the focused semantic role independently of its content.
338		state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
339		state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
340		p.mu.Unlock()
341	}
342	scratch.receipts = state.receipts
343	return state, true
344}
345
346// captureRenderState preserves the focused local test seam. Production
347// rendering delegates through *session.captureRenderState.
348func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349	return captureLocalRenderState(sess, ac, request)
350}
351
352// copyRankedRecentInto preserves the non-nil empty slice that selects
353// contextual recent-session mode while retaining scratch capacity for reuse.
354func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
355	if len(src) == 0 {
356		if dst != nil {
357			return dst[:0]
358		}
359		return []rankedRecent{}
360	}
361	return append(dst[:0], src...)
362}
363
364func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
365	style, hasStyle := p.screen.CursorStyle()
366	hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
367	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}
368}
369
370func clonePickerPreview(in picker.Preview) picker.Preview {
371	out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
372	for i := range in.Rows {
373		out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
374	}
375	return out
376}
377
378// appendStackPaneIDs returns exactly the panes whose solved placement can own a
379// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
380func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
381	if node == nil {
382		return dst
383	}
384	if node.Kind == layout.Stack {
385		for _, child := range node.Children {
386			if child != nil && child.Kind == layout.Leaf {
387				dst = append(dst, child.Leaf)
388			}
389		}
390		return dst
391	}
392	for _, child := range node.Children {
393		dst = appendStackPaneIDs(dst, child)
394	}
395	return dst
396}

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