v0.3.0 99dc266

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}
137
138func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
139	base := input.baseFrame
140	baseDamage := input.baseDamage
141	floating := input.floating
142	content := input.content
143	theme := input.theme
144	borderMuted := input.borderMuted
145	borderActive := input.borderActive
146	cache := input.cache
147	full := input.full
148
149	frame := base.Clone()
150	applyOverlayBackdrop(frame, theme)
151	geometry := floating.geometry.translate(content.X, content.Y)
152	blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
153	damage := append([]renderer.Damage(nil), baseDamage...)
154	for _, d := range floating.pane.damage {
155		damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
156	}
157	popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
158	titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
159	border := borderMuted
160	if floating.focused {
161		border = borderActive
162	}
163	drawFloatingBorder(frame, geometry, floating.title, border)
164	if full || popupChanged {
165		return frame, []renderer.Damage{renderer.FullRedraw()}
166	}
167	hasTitleBorder := geometry.Bounds.Height >= 3
168	if geometry.Mode == ui.PresentationDrawer {
169		hasTitleBorder = geometry.Bounds.Height > 0
170	}
171	if titleChanged && hasTitleBorder {
172		if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173			damage = append(damage, titleDamage)
174		}
175	}
176	return frame, damage
177}
178
179type floatingSpan struct {
180	source, destination, length int
181}
182
183// clippedFloatingSpan intersects a source span placed at origin with the
184// destination interval [0, limit). The source offset preserves pane cell
185// mapping when the retained committed geometry starts above or left of frame.
186func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
187	if length <= 0 || limit <= 0 {
188		return floatingSpan{}, false
189	}
190	if origin < 0 {
191		if origin <= -length {
192			return floatingSpan{}, false
193		}
194		trim := -origin
195		length -= trim
196		origin = 0
197		if length <= 0 {
198			return floatingSpan{}, false
199		}
200		return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
201	}
202	if origin >= limit {
203		return floatingSpan{}, false
204	}
205	length = min(length, limit-origin)
206	return floatingSpan{destination: origin, length: length}, length > 0
207}
208
209func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
210	x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
211	if !ok {
212		return
213	}
214	y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
215	if !ok {
216		return
217	}
218	for dy := range y.length {
219		for dx := range x.length {
220			dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
221		}
222	}
223}
224
225func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
226	if bounds.Y < 0 || bounds.Y >= frame.Height {
227		return renderer.Damage{}, false
228	}
229	x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
230	if !ok {
231		return renderer.Damage{}, false
232	}
233	return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
234}
235
236func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
237	bounds := geometry.Bounds
238	if bounds.Width <= 0 || bounds.Height <= 0 {
239		return
240	}
241	if geometry.Mode == ui.PresentationDrawer {
242		drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
243		drawFloatingTitle(frame, bounds, title, style)
244		return
245	}
246	// Each axis omits its borders independently for tiny popups, matching
247	// floatingInnerSize and leaving every cell available to the terminal.
248	right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
249	bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
250	if bounds.Height >= 3 {
251		drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
252		if hasBottom {
253			drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
254		}
255	}
256	if bounds.Width >= 3 {
257		drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
258		if hasRight {
259			drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
260		}
261	}
262	if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
263		setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
264		setFloatingCell(frame, right, bounds.Y, '┐', style)
265		setFloatingCell(frame, bounds.X, bottom, '└', style)
266		setFloatingCell(frame, right, bottom, '┘', style)
267		drawFloatingTitle(frame, bounds, title, style)
268	}
269}
270
271func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272	if y < 0 || y >= frame.Height {
273		return
274	}
275	span, ok := clippedFloatingSpan(x, width, frame.Width)
276	if !ok {
277		return
278	}
279	for dx := range span.length {
280		frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281	}
282}
283
284func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285	if x < 0 || x >= frame.Width {
286		return
287	}
288	span, ok := clippedFloatingSpan(y, height, frame.Height)
289	if !ok {
290		return
291	}
292	for dy := range span.length {
293		frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294	}
295}
296
297func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298	if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299		return
300	}
301	frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302}
303
304func floatingLastCoordinate(origin, size int) (int, bool) {
305	if size <= 0 {
306		return 0, false
307	}
308	last := origin + size - 1
309	if last < origin {
310		return 0, false
311	}
312	return last, true
313}
314
315func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316	const inset = 2
317	titleWidth := bounds.Width - 2*inset
318	if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319		return
320	}
321	titleX := bounds.X + inset
322	span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323	if !ok {
324		return
325	}
326	ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
327}

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