v0.1.0 47aaa0f

shape.go RawHistoryFolder

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

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