v0.8.1 1f54f61

floating_render.go RawHistoryFolder

1package daemon
2
3import (
4	renderer "github.com/bnema/vev-vt"
5	"github.com/bnema/vev/internal/domain"
6	themeui "github.com/bnema/vev/internal/usecase/theme"
7	"github.com/bnema/vev/internal/usecase/ui"
8)
9
10// floatingGeometry describes a popup's outer frame and terminal content area.
11// Bounds and Inner always share the same coordinate space. Committed pane
12// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
13// translates both fields and returns/caches frame-absolute geometry. Callers
14// must not translate a composed or cached value again.
15type floatingGeometry struct {
16	Mode   ui.PresentationMode
17	Bounds domain.Rect
18	Inner  domain.Rect
19}
20
21func (g floatingGeometry) valid() bool {
22	return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
23}
24
25// committable accepts a drawer with no terminal content. Its presentation must
26// remain canonical even though the PTY and VT screen use a 1x1 fallback.
27func (g floatingGeometry) committable() bool {
28	if g.Mode == ui.PresentationDrawer {
29		return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0
30	}
31	return g.valid()
32}
33
34func (g floatingGeometry) ptyRect() domain.Rect {
35	if g.Inner.Width > 0 && g.Inner.Height > 0 {
36		return g.Inner
37	}
38	return domain.Rect{Width: 1, Height: 1}
39}
40
41func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
42	g.Bounds.X += dx
43	g.Bounds.Y += dy
44	g.Inner.X += dx
45	g.Inner.Y += dy
46	return g
47}
48
49// committedFloatingGeometryLocked returns the geometry whose PTY resize was
50// last committed. Newly constructed test panes fall back to the requested
51// geometry until their first resize. The caller holds p.mu.
52func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
53	if p.popupGeometry.committable() {
54		return p.popupGeometry
55	}
56	return fallback
57}
58
59// floatingAxisGeometry is the canonical percentage and border calculation for
60// one popup axis. Popups smaller than three cells spend no cells on borders.
61type floatingAxisGeometry struct {
62	BoundsSize   int
63	InnerSize    int
64	BorderOffset int
65}
66
67func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry {
68	percent = min(max(percent, 1), 100)
69	bounds := min(max(available*percent/100, 1), available)
70	axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds}
71	if bounds >= 3 {
72		axis.InnerSize -= 2
73		axis.BorderOffset = 1
74	}
75	return axis
76}
77
78// calculatePreferredFloatingGeometry retains the canonical percentage and
79// tiny-axis rules used by wide floating terminals.
80func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
81	if !content.Valid() {
82		return floatingGeometry{}
83	}
84	x := calculateFloatingAxisGeometry(content.Cols, cfg.Width)
85	y := calculateFloatingAxisGeometry(content.Rows, cfg.Height)
86	bounds := domain.Rect{
87		X:      (content.Cols - x.BoundsSize) / 2,
88		Y:      (content.Rows - y.BoundsSize) / 2,
89		Width:  x.BoundsSize,
90		Height: y.BoundsSize,
91	}
92	return floatingGeometry{
93		Mode:   ui.PresentationFloating,
94		Bounds: bounds,
95		Inner: domain.Rect{
96			X:      bounds.X + x.BorderOffset,
97			Y:      bounds.Y + y.BorderOffset,
98			Width:  x.InnerSize,
99			Height: y.InnerSize,
100		},
101	}
102}
103
104// calculateContentFloatingGeometry returns canonical tab-content-relative
105// coordinates. Presentation resolution consumes complete-frame coordinates,
106// so the preferred geometry crosses the top-bar boundary exactly once in each
107// direction.
108func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
109	preferred := calculatePreferredFloatingGeometry(content, cfg)
110	if !preferred.valid() {
111		return floatingGeometry{}
112	}
113	framePreferred := preferred.translate(0, 1)
114	presentation := ui.ResolvePresentation(
115		domain.Size{Cols: content.Cols, Rows: content.Rows + 2},
116		framePreferred.Bounds,
117		framePreferred.Inner,
118	)
119	return floatingGeometry{
120		Mode:   presentation.Mode,
121		Bounds: presentation.Bounds,
122		Inner:  presentation.Inner,
123	}.translate(0, -1)
124}
125
126type floatingComposeInput struct {
127	baseFrame    renderer.Frame
128	baseDamage   []renderer.Damage
129	floating     capturedFloatingRenderState
130	content      domain.Rect
131	theme        themeui.Theme
132	borderMuted  renderer.Style
133	borderActive renderer.Style
134	cache        composeCacheInput
135	full         bool
136	// scratch, when non-nil, is the page the popup frame is composed into. It
137	// must not alias baseFrame's page; nil allocates a clone.
138	scratch *renderer.Frame
139}
140
141func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
142	base := input.baseFrame
143	baseDamage := input.baseDamage
144	floating := input.floating
145	content := input.content
146	theme := input.theme
147	borderMuted := input.borderMuted
148	borderActive := input.borderActive
149	cache := input.cache
150	full := input.full
151
152	frame := copyFrameInto(input.scratch, base)
153	applyOverlayBackdrop(frame, theme)
154	geometry := floating.geometry.translate(content.X, content.Y)
155	blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
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 || cache.floatingFocused != floating.focused
161	titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
162	border := borderMuted
163	if floating.focused {
164		border = borderActive
165	}
166	drawFloatingBorder(frame, geometry, floating.title, border)
167	if full || popupChanged {
168		return frame, []renderer.Damage{renderer.FullRedraw()}
169	}
170	hasTitleBorder := geometry.Bounds.Height >= 3
171	if geometry.Mode == ui.PresentationDrawer {
172		hasTitleBorder = geometry.Bounds.Height > 0
173	}
174	if titleChanged && hasTitleBorder {
175		if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
176			damage = append(damage, titleDamage)
177		}
178	}
179	return frame, damage
180}
181
182type floatingSpan struct {
183	source, destination, length int
184}
185
186// clippedFloatingSpan intersects a source span placed at origin with the
187// destination interval [0, limit). The source offset preserves pane cell
188// mapping when the retained committed geometry starts above or left of frame.
189func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
190	if length <= 0 || limit <= 0 {
191		return floatingSpan{}, false
192	}
193	if origin < 0 {
194		if origin <= -length {
195			return floatingSpan{}, false
196		}
197		trim := -origin
198		length -= trim
199		origin = 0
200		if length <= 0 {
201			return floatingSpan{}, false
202		}
203		return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
204	}
205	if origin >= limit {
206		return floatingSpan{}, false
207	}
208	length = min(length, limit-origin)
209	return floatingSpan{destination: origin, length: length}, length > 0
210}
211
212func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
213	x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
214	if !ok {
215		return
216	}
217	y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
218	if !ok {
219		return
220	}
221	for dy := range y.length {
222		for dx := range x.length {
223			dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
224		}
225	}
226}
227
228func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
229	if bounds.Y < 0 || bounds.Y >= frame.Height {
230		return renderer.Damage{}, false
231	}
232	x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
233	if !ok {
234		return renderer.Damage{}, false
235	}
236	return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
237}
238
239func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
240	bounds := geometry.Bounds
241	if bounds.Width <= 0 || bounds.Height <= 0 {
242		return
243	}
244	if geometry.Mode == ui.PresentationDrawer {
245		drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
246		drawFloatingTitle(frame, bounds, title, style)
247		return
248	}
249	// Each axis omits its borders independently for tiny popups, matching
250	// floatingInnerSize and leaving every cell available to the terminal.
251	right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
252	bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
253	if bounds.Height >= 3 {
254		drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
255		if hasBottom {
256			drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
257		}
258	}
259	if bounds.Width >= 3 {
260		drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
261		if hasRight {
262			drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
263		}
264	}
265	if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
266		setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
267		setFloatingCell(frame, right, bounds.Y, '┐', style)
268		setFloatingCell(frame, bounds.X, bottom, '└', style)
269		setFloatingCell(frame, right, bottom, '┘', style)
270		drawFloatingTitle(frame, bounds, title, style)
271	}
272}
273
274func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
275	if y < 0 || y >= frame.Height {
276		return
277	}
278	span, ok := clippedFloatingSpan(x, width, frame.Width)
279	if !ok {
280		return
281	}
282	for dx := range span.length {
283		frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
284	}
285}
286
287func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
288	if x < 0 || x >= frame.Width {
289		return
290	}
291	span, ok := clippedFloatingSpan(y, height, frame.Height)
292	if !ok {
293		return
294	}
295	for dy := range span.length {
296		frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
297	}
298}
299
300func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
301	if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
302		return
303	}
304	frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
305}
306
307func floatingLastCoordinate(origin, size int) (int, bool) {
308	if size <= 0 {
309		return 0, false
310	}
311	last := origin + size - 1
312	if last < origin {
313		return 0, false
314	}
315	return last, true
316}
317
318func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
319	const inset = 2
320	titleWidth := bounds.Width - 2*inset
321	if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
322		return
323	}
324	titleX := bounds.X + inset
325	span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
326	if !ok {
327		return
328	}
329	ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
330}

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