v0.6.1 aa12db3

prepare.go RawHistoryFolder

1// Package render prepares ordered physical-pixel primitives for Vulkan submission.
2// It does not own the display list or its borrowed font faces.
3package render
4
5import (
6	"fmt"
7	"image"
8	"math"
9	"slices"
10
11	"github.com/bnema/nefergui/internal/css"
12	"github.com/bnema/nefergui/internal/layout"
13	"github.com/bnema/nefergui/internal/text"
14	"github.com/go-text/typesetting/font"
15)
16
17// Quad is a physical-pixel rectangle with a physical clip. Color is straight
18// sRGB; conversion to premultiplied linear color belongs at the GPU boundary.
19// Glyphs refer to an atlas placement valid only for this prepared frame.
20type Quad struct {
21	Op     string
22	Rect   layout.Rect
23	Clip   layout.Rect
24	Color  css.Color
25	Radii  [4]float64
26	Widths layout.Edges
27	Colors css.ColorSides
28	Glyph  text.Placement
29	Image  image.Image
30	// Shape is the original box when the draw bounds extend past it.
31	Shape layout.Rect
32	// Shadow holds physical offset, blur and spread. Inset is carried separately.
33	Shadow [4]float64
34	Inset  bool
35}
36
37type Frame struct {
38	Quads   []Quad
39	Uploads []text.Upload
40}
41
42// Preparer owns the glyph atlas and the obligation to deliver its changes to the
43// GPU mirror. Prepare hands the caller every change not yet known to be
44// submitted; Submitted is the only acknowledgement. A frame that is retried,
45// abandoned, superseded or never reached submission therefore never loses
46// atlas changes: the next Prepare resends whole pages (placements are kept).
47type Preparer struct {
48	Atlas *text.Atlas
49	// owed is true while the latest issued uploads have not been submitted.
50	owed   bool
51	issued []text.Upload
52}
53
54// NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
55// layout; the atlas and shaped faces are not safe for concurrent use.
56func NewPreparer(size, pages int) (*Preparer, error) {
57	a, err := text.NewAtlas(size, pages)
58	if err != nil {
59		return nil, err
60	}
61	return &Preparer{Atlas: a}, nil
62}
63
64func intersect(a, b layout.Rect) layout.Rect {
65	x, y := math.Max(a.X, b.X), math.Max(a.Y, b.Y)
66	return layout.Rect{X: x, Y: y, W: math.Max(0, math.Min(a.X+a.W, b.X+b.W)-x), H: math.Max(0, math.Min(a.Y+a.H, b.Y+b.H)-y)}
67}
68
69func physical(r layout.Rect, scale float64) layout.Rect {
70	x, y, w, h := layout.Physical(r, scale)
71	return layout.Rect{X: float64(x), Y: float64(y), W: float64(w), H: float64(h)}
72}
73
74func scaled(e layout.Edges, scale float64) layout.Edges {
75	return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
76}
77
78// maxQuadPrealloc bounds speculative storage for mostly clipped documents.
79// Visible frames larger than this grow normally as their quads are emitted.
80const maxQuadPrealloc = 512
81
82// quadCapacity estimates one quad per glyph and drawable command, capped so
83// offscreen content cannot force a large allocation before culling.
84func quadCapacity(commands []layout.Command) int {
85	n := 0
86	for i := range commands {
87		switch cmd := &commands[i]; cmd.Op {
88		case "clip-push", "clip-pop":
89		case "text", "preedit-text":
90			for j := range cmd.Runs {
91				n += len(cmd.Runs[j].Glyphs)
92			}
93		default:
94			n++
95		}
96	}
97	return min(n, maxQuadPrealloc)
98}
99
100// Prepare converts logical rectangles once, using layout.Physical as the sole
101// logical-to-physical edge-rounding boundary. A clip-push intersects its parent
102// clip; rounded clips intentionally use only their axis-aligned bounds in V1.
103// Clip-pop without a push and unknown operations are rejected, not dropped.
104// Image commands without a source are rejected. Failed frames do not produce
105// partial output.
106func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, height int) (Frame, error) {
107	// Frames are returned detached and may outlive later Prepare calls, so
108	// the backing array is never reused.
109	return p.PrepareInto(nil, commands, scale, width, height)
110}
111
112// PrepareInto is Prepare reusing quads' backing array for the returned frame's
113// Quads. The frame aliases quads, so it is valid only until quads is reused:
114// callers that keep frames must use Prepare. Pass the last frame's Quads
115// unchanged: its length tells how much of a past peak buffer is still needed.
116func (p *Preparer) PrepareInto(quads []Quad, commands []layout.Command, scale float64, width, height int) (Frame, error) {
117	if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 {
118		return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas")
119	}
120	p.Atlas.BeginFrame()
121	// Bound the initial reservation; larger visible frames grow through
122	// append after culling.
123	n := quadCapacity(commands)
124	if cap(quads) > bufferSlack*max(n, len(quads))+64 {
125		quads = nil // do not pin a past peak frame's buffer
126	}
127	clear(quads[:cap(quads)]) // drop image and glyph references from the last frame
128	frame := Frame{Quads: slices.Grow(quads[:0], n)}
129	clip := layout.Rect{W: float64(width), H: float64(height)}
130	var stackBuf [8]layout.Rect // nested clips beyond this depth grow on the heap
131	stack := stackBuf[:0]
132	for _, cmd := range commands {
133		switch cmd.Op {
134		case "clip-push":
135			stack = append(stack, clip)
136			clip = intersect(clip, physical(cmd.Rect, scale))
137			continue
138		case "clip-pop":
139			if len(stack) == 0 {
140				return Frame{}, fmt.Errorf("render: unbalanced clip-pop")
141			}
142			clip = stack[len(stack)-1]
143			stack = stack[:len(stack)-1]
144			continue
145		case "text", "preedit-text":
146			for _, run := range cmd.Runs {
147				if run.Face == nil || run.FaceID != run.Face.ID || run.Size <= 0 {
148					return Frame{}, fmt.Errorf("render: missing or mismatched face for %q", cmd.ID)
149				}
150				for _, glyph := range run.Glyphs {
151					x, y := glyph.X*scale, glyph.Y*scale
152					key, err := text.GlyphKey(run.Face, font.GID(glyph.ID), run.Size*scale, x, run.Face.Variations)
153					if err != nil {
154						return Frame{}, err
155					}
156					place, ok := p.Atlas.Lookup(key)
157					if !ok {
158						mask, err := text.Rasterize(run.Face, key)
159						if err != nil {
160							return Frame{}, err
161						}
162						place, err = p.Atlas.Insert(key, mask)
163						if err != nil {
164							return Frame{}, err
165						}
166					}
167					if place.Page < 0 || place.Rect.Empty() {
168						continue
169					}
170					r := layout.Rect{X: float64(int(math.Floor(x)) + place.Origin.X), Y: float64(int(math.Floor(y)) + place.Origin.Y), W: float64(place.Rect.Dx()), H: float64(place.Rect.Dy())}
171					if intersect(clip, r).W > 0 && intersect(clip, r).H > 0 {
172						frame.Quads = append(frame.Quads, Quad{Op: "glyph", Rect: r, Clip: clip, Color: opacity(cmd.Color, cmd.Opacity), Glyph: place})
173					}
174				}
175			}
176			continue
177		case "image":
178			if cmd.Image == nil {
179				return Frame{}, fmt.Errorf("render: image %q has no pixel source", cmd.ID)
180			}
181		case "rect", "border", "outline", "shadow", "selection", "caret", "preedit-underline", "preedit-caret":
182		default:
183			return Frame{}, fmt.Errorf("render: unknown display operation %q", cmd.Op)
184		}
185		r := physical(cmd.Rect, scale)
186		shape := r
187		var shadow [4]float64
188		inset := false
189		if cmd.Op == "outline" {
190			// Layout already applies outline-offset to Rect; width grows outward.
191			w := cmd.Widths.Top * scale
192			r = layout.Rect{X: r.X - w, Y: r.Y - w, W: r.W + 2*w, H: r.H + 2*w}
193		}
194		if cmd.Op == "shadow" {
195			if cmd.Shadow == nil {
196				return Frame{}, fmt.Errorf("render: shadow %q has no parameters", cmd.ID)
197			}
198			sh := cmd.Shadow
199			shadow = [4]float64{sh.X.Value * scale, sh.Y.Value * scale, sh.Blur.Value * scale, sh.Spread.Value * scale}
200			inset = sh.Inset
201			if !inset {
202				// Three standard deviations cover the visible Gaussian tail.
203				pad := shadow[2]*1.5 + math.Max(0, shadow[3])
204				r = layout.Rect{X: r.X + shadow[0] - pad, Y: r.Y + shadow[1] - pad, W: r.W + 2*pad, H: r.H + 2*pad}
205			}
206		}
207		if intersect(clip, r).W == 0 || intersect(clip, r).H == 0 {
208			continue
209		}
210		color := cmd.Color
211		if cmd.Op == "shadow" {
212			color = cmd.Shadow.Color
213		}
214		q := Quad{Op: cmd.Op, Rect: r, Clip: clip, Color: opacity(color, cmd.Opacity), Widths: scaled(cmd.Widths, scale), Colors: cmd.Colors, Shape: shape, Shadow: shadow, Inset: inset, Image: cmd.Image}
215		if cmd.Op == "image" {
216			q.Color = css.Color{R: 1, G: 1, B: 1, A: opacity(css.Color{A: 1}, cmd.Opacity).A}
217		}
218		for i, v := range cmd.Radii {
219			q.Radii[i] = v * scale
220		}
221		frame.Quads = append(frame.Quads, q)
222	}
223	if len(stack) > 0 {
224		return Frame{}, fmt.Errorf("render: unbalanced clip-push")
225	}
226	if p.owed {
227		// The previous frame's uploads may never have reached the GPU. Full pages
228		// subsume them and any changes made since.
229		p.Atlas.MarkAllDirty()
230	}
231	frame.Uploads = p.Atlas.Uploads()
232	p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
233	return frame, nil
234}
235
236// Submitted acknowledges that uploads, as returned in the most recent Prepare's
237// Frame.Uploads, were recorded in a successfully submitted GPU submission.
238// Any other slice (an older, superseded frame or foreign data) is ignored and
239// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
240func (p *Preparer) Submitted(uploads []text.Upload) {
241	if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
242		return
243	}
244	p.owed, p.issued = false, nil
245}
246
247func opacity(c css.Color, a float64) css.Color {
248	c.A *= math.Max(0, math.Min(1, a))
249	return c
250}

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 1

Tags 12

↑↓ moveenter openesc close