v0.5.1 657f658

shape.go RawHistoryFolder

1package text
2
3import (
4	"errors"
5	"math"
6	"slices"
7
8	"github.com/bnema/nefergui/internal/bidi"
9	"github.com/go-text/typesetting/di"
10	"github.com/go-text/typesetting/font"
11	"github.com/go-text/typesetting/segmenter"
12	"github.com/go-text/typesetting/shaping"
13	"golang.org/x/image/math/fixed"
14)
15
16// Glyph contains a positioned glyph in logical pixels, with its original shaping metrics.
17type Glyph struct {
18	ID      font.GID
19	X, Y    float64
20	Advance float64
21	// Cluster is the rune index, in the original Measure input, of the first
22	// rune of the glyph's cluster. It is not a UTF-8 byte offset.
23	Cluster int
24	Missing bool
25}
26
27// Run is a shaped span with one face, direction and embedding level.
28type Run struct {
29	Face      *Face
30	Direction di.Direction
31	Level     uint8 // resolved embedding level after L1
32	// Start and End are rune indices in the original Measure input; End is
33	// exclusive. A CRLF separator counts as two runes.
34	Start, End    int
35	X, Y, Advance float64
36	Glyphs        []Glyph
37}
38type Line struct {
39	Runs                    []Run
40	Direction               di.Direction // resolved paragraph base direction
41	Width, Baseline, Height float64
42}
43type Layout struct {
44	Lines                                                       []Line
45	Direction                                                   di.Direction // paragraph basis, auto resolved by bidi analysis
46	Width, Height, MinContent, MaxContent, Baseline, LineHeight float64
47}
48
49// Engine is single-goroutine only: Measure and EndFrame are not synchronized.
50// Its catalog is fixed at NewEngine and must not change afterwards, because
51// cached measurements depend on its faces.
52type Engine struct {
53	catalog *Catalog
54	seg     shaping.Segmenter
55	shaper  shaping.HarfbuzzShaper
56	wrapper shaping.LineWrapper
57	never   shaping.LineWrapper
58	cache   measureCache
59	scratch measureScratch
60}
61
62// measureScratch holds per-call buffers reused across measure calls. Nothing
63// in it may be referenced by a returned Layout.
64type measureScratch struct {
65	runes    []rune
66	faces    []*Face
67	reverse  map[*font.Face]*Face
68	faceFor  []*font.Face
69	items    []shaping.Input
70	levelled []shaping.Input
71	outs     []shaping.Output
72	runs     shaping.RunIterator
73	visual   visualScratch
74}
75
76// iterate returns the reusable run iterator positioned at the start of outs.
77func (s *measureScratch) iterate(outs []shaping.Output) shaping.RunIterator {
78	if r, ok := s.runs.(interface{ Reset([]shaping.Output) }); ok {
79		r.Reset(outs)
80		return s.runs
81	}
82	s.runs = shaping.NewSliceIterator(outs)
83	return s.runs
84}
85
86func NewEngine(c *Catalog) *Engine { return &Engine{catalog: c} }
87
88type constantFace struct{ face *font.Face }
89
90func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
91
92// clusterFaces selects a single face for every UAX#29 extended grapheme. Format
93// characters (ZWJ and variation selectors) do not need outline coverage; they
94// remain in the cluster to let HarfBuzz apply substitutions.
95func clusterFaces(selected []*font.Face, text []rune, choices []*Face) []*font.Face {
96	selected = slices.Grow(selected[:0], len(text))[:len(text)]
97	var seg segmenter.Segmenter
98	seg.Init(text)
99	it := seg.GraphemeIterator()
100	for it.Next() {
101		g := it.Grapheme()
102		f := choices[0]
103		for _, candidate := range choices {
104			all := true
105			for _, r := range g.Text {
106				if r == 0x200d || r >= 0xfe00 && r <= 0xfe0f {
107					continue
108				}
109				if _, ok := candidate.Shape.NominalGlyph(r); !ok {
110					all = false
111					break
112				}
113			}
114			if all {
115				f = candidate
116				break
117			}
118		}
119		for i := range g.Text {
120			selected[g.Offset+i] = f.Shape
121		}
122	}
123	return selected
124}
125
126// faceCovers checks an entire grapheme, ignoring format selectors.
127func faceCovers(f *Face, cluster []rune) bool {
128	for _, ch := range cluster {
129		if ch == 0x200d || ch >= 0xfe00 && ch <= 0xfe0f {
130			continue
131		}
132		if _, ok := f.Shape.NominalGlyph(ch); !ok {
133			return false
134		}
135	}
136	return true
137}
138
139// split cluster-aligned face runs after go-text's bidi/script itemization.
140func splitFaces(out, items []shaping.Input, faceFor []*font.Face) []shaping.Input {
141	out = out[:0]
142	for _, item := range items {
143		if item.RunStart >= item.RunEnd {
144			continue
145		}
146		start := item.RunStart
147		face := faceFor[start]
148		for i := start + 1; i < item.RunEnd; i++ {
149			if faceFor[i] != face {
150				part := item
151				part.RunStart = start
152				part.RunEnd = i
153				part.Face = face
154				out = append(out, part)
155				start = i
156				face = faceFor[i]
157			}
158		}
159		item.RunStart = start
160		item.Face = face
161		out = append(out, item)
162	}
163	return out
164}
165func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []uint8) ([]shaping.Output, map[*font.Face]*Face, error) {
166	if e == nil || e.catalog == nil || len(e.catalog.Faces) == 0 {
167		return nil, nil, errors.New("no font catalog")
168	}
169	if r.Size <= 0 || math.IsNaN(r.Size) || math.IsInf(r.Size, 0) || r.Size > 4096 {
170		return nil, nil, errors.New("invalid font size")
171	}
172	choices := e.catalog.candidates(r)
173	if e.catalog.source != nil {
174		// Load named faces only as required for coverage. A failing face is
175		// excluded permanently and ranking is recomputed to admit its runner-up.
176		named := choices
177		var seg segmenter.Segmenter
178		seg.Init(text)
179		it := seg.GraphemeIterator()
180		var missing []rune
181		for it.Next() {
182			cluster := it.Grapheme().Text
183			covered := false
184			for {
185				named = e.catalog.candidates(r)
186				failed := false
187				for _, f := range named {
188					if !f.ready() {
189						failed = true
190						break
191					}
192					if faceCovers(f, cluster) {
193						covered = true
194						break
195					}
196				}
197				if covered || !failed {
198					break
199				}
200			}
201			if !covered {
202				missing = append(missing, cluster...)
203			}
204		}
205		// Rebuild after failed loads. The fallback window excludes named
206		// families by key, so it never depends on load or failure state.
207		named = e.catalog.candidates(r)
208		window := e.catalog.fallbackWindow(missing, r)
209		choices = nil
210		for _, f := range named {
211			if f.Shape != nil && !f.bad {
212				choices = append(choices, f)
213			}
214		}
215		if len(missing) != 0 {
216			for _, f := range window {
217				if !f.ready() {
218					continue
219				}
220				choices = append(choices, f)
221				complete := true
222				seg.Init(missing)
223				it = seg.GraphemeIterator()
224				for it.Next() {
225					covered := false
226					for _, candidate := range choices {
227						if faceCovers(candidate, it.Grapheme().Text) {
228							covered = true
229							break
230						}
231					}
232					if !covered {
233						complete = false
234						break
235					}
236				}
237				if complete {
238					break
239				}
240			}
241		}
242		// Nothing selected (empty text, or only uncovered runes with a failed
243		// window): the primary face shapes the text and supplies deterministic tofu.
244		if len(choices) == 0 {
245			if f := e.catalog.primary(r); f != nil {
246				choices = append(choices, f)
247			}
248		}
249	}
250	if len(choices) == 0 {
251		return nil, nil, errors.New("no usable fonts")
252	}
253	sc := &e.scratch
254	if sc.reverse == nil {
255		sc.reverse = map[*font.Face]*Face{}
256	}
257	clear(sc.reverse)
258	faces := sc.faces[:0]
259	// Variations belong to the engine invocation; do not mutate the catalog's shared faces.
260	for _, f := range choices {
261		if len(r.Variations) > 0 {
262			f = f.WithVariations(r.Variations)
263		}
264		faces = append(faces, f)
265		sc.reverse[f.Shape] = f
266	}
267	sc.faces = faces
268	sc.faceFor = clusterFaces(sc.faceFor, text, faces)
269	input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
270	sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
271	sc.levelled = splitLevels(sc.levelled, sc.items, levels)
272	out := sc.outs[:0]
273	for _, item := range sc.levelled {
274		if item.RunEnd > item.RunStart {
275			out = append(out, e.shaper.Shape(item))
276		}
277	}
278	clear(out[len(out):cap(out)]) // drop glyph references from longer earlier calls
279	sc.outs = out
280	return out, sc.reverse, nil
281}
282
283// Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
284// MinContent is the largest unbreakable UAX#14 segment; max-content ignores soft breaks.
285// Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px.
286//
287// Results are cached per (text, request, width) until EndFrame evicts them.
288// The returned Lines, Runs and Glyphs are shared with the cache: treat them as
289// immutable and copy before changing positions (see Layout.Clone).
290func (e *Engine) Measure(s string, r Request, width float64) (Layout, error) {
291	if l, ok := e.cache.get(s, r, width); ok {
292		return l, nil
293	}
294	l, err := e.measure(s, r, width)
295	if err == nil {
296		e.cache.put(s, r, width, l)
297	}
298	return l, err
299}
300
301// EndFrame evicts measurements not used during the last two frames. Call it
302// once after each frame that may have measured text.
303func (e *Engine) EndFrame() { e.cache.endFrame() }
304
305func appendRunes(dst []rune, s string) []rune {
306	for _, c := range s {
307		dst = append(dst, c)
308	}
309	return dst
310}
311
312func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
313	var result Layout
314	if math.IsNaN(width) || math.IsInf(width, 0) || width > 1<<24 {
315		return result, errors.New("invalid width")
316	}
317	if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 {
318		return result, errors.New("invalid letter spacing")
319	}
320	e.scratch.runes = appendRunes(e.scratch.runes[:0], s)
321	text := e.scratch.runes
322	if paragraphs, split := paragraphRanges(text); split {
323		// Each paragraph measure reuses the scratch runes; keep a copy.
324		return e.measureParagraphs(slices.Clone(text), paragraphs, r, width)
325	}
326	if r.Direction > bidi.RTL {
327		return result, errors.New("invalid paragraph direction")
328	}
329	paraLevels, baseLevel, err := bidi.Resolve(text, r.Direction, fullBreak(text))
330	if err != nil {
331		return result, err
332	}
333	direction := di.DirectionLTR
334	if baseLevel%2 == 1 {
335		direction = di.DirectionRTL
336	}
337	result.Direction = direction
338	outs, reverse, err := e.shape(text, r, direction, paraLevels)
339	if err != nil {
340		return result, err
341	}
342	if r.LetterSpacing != 0 {
343		shaping.AddSpacing(outs, text, 0, fixed.Int26_6(math.Round(r.LetterSpacing*64)))
344	}
345	baseline := r.Size * 0.8
346	height := r.Size * 1.2
347	if ext, ok := e.catalog.primary(r).Shape.FontHExtents(); ok {
348		unit := r.Size / float64(e.catalog.primary(r).Shape.Upem())
349		baseline = float64(ext.Ascender) * unit
350		height = float64(ext.Ascender-ext.Descender+ext.LineGap) * unit
351	}
352	if height <= 0 {
353		height = r.Size * 1.2
354	}
355	result.Baseline = baseline
356	result.LineHeight = height
357	if len(text) == 0 {
358		return result, nil
359	}
360	var minWidth float64
361	// Compute exact word widths even when a shaped run crosses a break opportunity.
362	// Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.
363	words, _ := e.never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, e.scratch.iterate(outs))
364	for _, line := range words {
365		w := 0.0
366		for _, o := range line {
367			w += float64(o.Advance) / 64
368		}
369		if w > minWidth {
370			minWidth = w
371		}
372	}
373	result.MinContent = minWidth
374	maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, e.scratch.iterate(outs))
375	for _, line := range maxLines {
376		w := 0.0
377		for _, o := range line {
378			w += float64(o.Advance) / 64
379		}
380		if w > result.MaxContent {
381			result.MaxContent = w
382		}
383	}
384	limit := fixed.Int26_6(1 << 30)
385	if width > 0 {
386		limit = fixed.Int26_6(math.Round(width * 64))
387	}
388	wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, e.scratch.iterate(outs))
389	if len(wrapped) > 0 {
390		result.Lines = make([]Line, 0, len(wrapped))
391	}
392	for idx, line := range wrapped {
393		ordered, err := e.scratch.visual.order(text, line, r.Direction)
394		if err != nil {
395			return result, err
396		}
397		dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction}
398		glyphCount := 0
399		for _, o := range ordered {
400			glyphCount += len(o.output.Glyphs)
401		}
402		var glyphs []Glyph
403		if len(ordered) > 0 {
404			dst.Runs = make([]Run, 0, len(ordered))
405			glyphs = make([]Glyph, 0, glyphCount)
406		}
407		for _, item := range ordered {
408			o := item.output
409			f := reverse[o.Face]
410			run := Run{Face: f, Direction: o.Direction, Level: item.level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
411			cursor := 0.0
412			first := len(glyphs)
413			for _, g := range o.Glyphs {
414				glyphs = append(glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})
415				cursor += float64(g.Advance) / 64
416			}
417			if len(glyphs) > first {
418				run.Glyphs = glyphs[first:len(glyphs):len(glyphs)]
419			}
420			dst.Runs = append(dst.Runs, run)
421			dst.Width += run.Advance
422		}
423		if dst.Width > result.Width {
424			result.Width = dst.Width
425		}
426		result.Lines = append(result.Lines, dst)
427	}
428	result.Height = float64(len(result.Lines)) * height
429	return result, nil
430}

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