v0.7.2 71ebe61

fonts.go RawHistoryFolder

1// Package text implements CPU-side font selection, shaping, measurement and grayscale glyph preparation.
2// Catalog and Engine are not safe for concurrent use (go-text faces cache mutable state).
3package text
4
5import (
6	"bytes"
7	"crypto/sha256"
8	"encoding/binary"
9	"errors"
10	"fmt"
11	"io"
12	"io/fs"
13	"math"
14	"os"
15	"path/filepath"
16	"slices"
17	"sort"
18	"strings"
19	"sync"
20
21	"github.com/bnema/nefergui/internal/bidi"
22	"github.com/go-text/typesetting/font"
23	ot "github.com/go-text/typesetting/font/opentype"
24	"golang.org/x/image/font/sfnt"
25)
26
27// FontSource is the only external boundary: it provides files, not font matching policy.
28// Implementations must return a stable snapshot. Corrupt files are reported, not silently accepted.
29type FontSource interface {
30	Fonts() (map[string][]byte, error)
31}
32
33// DirectorySource reads font files recursively; paths are sorted before parsing.
34type DirectorySource string
35
36func (d DirectorySource) Fonts() (map[string][]byte, error) {
37	out := map[string][]byte{}
38	var problems []error
39	_ = filepath.WalkDir(string(d), func(path string, entry fs.DirEntry, err error) error {
40		if errors.Is(err, os.ErrNotExist) {
41			return nil
42		}
43		if err != nil {
44			problems = append(problems, fmt.Errorf("%s: %w", path, err))
45			return nil
46		}
47		if entry.IsDir() {
48			return nil
49		}
50		if !isFontPath(path) {
51			return nil
52		}
53		b, e := os.ReadFile(path)
54		if e != nil {
55			problems = append(problems, fmt.Errorf("%s: %w", path, e))
56			return nil
57		}
58		out[path] = b
59		return nil
60	})
61	return out, errors.Join(problems...)
62}
63
64func isFontPath(path string) bool {
65	switch strings.ToLower(filepath.Ext(path)) {
66	case ".ttf", ".otf", ".ttc":
67		return true
68	}
69	return false
70}
71
72// SystemSource searches XDG user and system font directories without fontconfig/cgo.
73// Earlier locations win identical family/aspect ties.
74type SystemSource struct{}
75
76// Fonts remains available for callers that explicitly need byte snapshots; Load uses Index instead.
77func (s SystemSource) Fonts() (map[string][]byte, error) {
78	paths, err := s.paths()
79	out := make(map[string][]byte, len(paths))
80	for _, p := range paths {
81		b, e := os.ReadFile(p)
82		if e != nil {
83			err = errors.Join(err, e)
84		} else {
85			out[p] = b
86		}
87	}
88	return out, err
89}
90
91func (SystemSource) paths() ([]string, error) {
92	home, _ := os.UserHomeDir()
93	user := os.Getenv("XDG_DATA_HOME")
94	if user == "" {
95		user = filepath.Join(home, ".local/share")
96	}
97	dirs := []string{filepath.Join(user, "fonts"), filepath.Join(home, ".fonts")}
98	data := os.Getenv("XDG_DATA_DIRS")
99	if data == "" {
100		data = "/usr/local/share:/usr/share"
101	}
102	for _, d := range strings.Split(data, ":") {
103		if d != "" {
104			dirs = append(dirs, filepath.Join(d, "fonts"))
105		}
106	}
107	dirs = append(dirs, "/usr/share/fonts")
108	seen := map[string]bool{}
109	var paths []string
110	var problems []error
111	for _, d := range dirs {
112		resolved, e := filepath.EvalSymlinks(d)
113		if e != nil {
114			if !errors.Is(e, os.ErrNotExist) {
115				problems = append(problems, e)
116			}
117			continue
118		}
119		if seen[resolved] {
120			continue
121		}
122		seen[resolved] = true
123		var local []string
124		_ = filepath.WalkDir(resolved, func(p string, entry fs.DirEntry, err error) error {
125			if err != nil {
126				problems = append(problems, err)
127				return nil
128			}
129			if entry.IsDir() {
130				return nil
131			}
132			if isFontPath(p) {
133				local = append(local, p)
134			}
135			return nil
136		})
137		sort.Strings(local)
138		for _, p := range local {
139			resolvedPath, e := filepath.EvalSymlinks(p)
140			if e != nil {
141				problems = append(problems, e)
142				continue
143			}
144			if !seen[resolvedPath] {
145				seen[resolvedPath] = true
146				paths = append(paths, p)
147			}
148		}
149	}
150	return paths, errors.Join(problems...)
151}
152
153// IndexSource supplies metadata without retaining font bytes. Open is invoked only
154// when a face is selected. Sources must keep paths stable during the catalog lifetime.
155type IndexSource interface {
156	Index() ([]FontFile, error)
157	Open(path string) (io.ReadCloser, error)
158}
159
160// LazySource lists font files cheaply and indexes chosen ones on demand, so a
161// catalog reads only the files a request needs. Paths fixes the catalog order;
162// IndexFiles returns entries in the order of its paths.
163type LazySource interface {
164	FontSource
165	IndexSource
166	Paths() ([]string, error)
167	IndexFiles(paths []string) ([]FontFile, error)
168}
169
170type FontFile struct {
171	Path          string
172	Family        string
173	Aspect        font.Aspect
174	Index         int
175	UnicodeRanges [4]uint32 // OS/2 ulUnicodeRange; zero means unknown
176}
177
178const maxFontBytes = 64 << 20
179const maxMetadataBytes = 1 << 20
180
181// metadataReader limits total metadata I/O. Font files larger than the
182// selected-face cap are excluded before go-text examines their tables.
183type metadataReader struct {
184	*os.File
185	remaining int64
186	exceeded  bool
187}
188
189func (m *metadataReader) ReadAt(p []byte, off int64) (int, error) {
190	if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
191		m.exceeded = true
192		return 0, errors.New("font metadata limit exceeded")
193	}
194	m.remaining -= int64(len(p))
195	return m.File.ReadAt(p, off)
196}
197func (m *metadataReader) Read(p []byte) (int, error) {
198	if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
199		m.exceeded = true
200		return 0, errors.New("font metadata limit exceeded")
201	}
202	n, err := m.File.Read(p)
203	m.remaining -= int64(n)
204	return n, err
205}
206
207func (s SystemSource) Index() ([]FontFile, error) {
208	paths, err := s.paths()
209	files, e := s.IndexFiles(paths)
210	return files, errors.Join(err, e)
211}
212
213// Paths lists the font files in catalog order without reading them.
214func (s SystemSource) Paths() ([]string, error) { return s.paths() }
215
216// indexWorkers bounds the files read at once. Parallel reads hide the latency
217// of a cold page cache, which dominates on encrypted disks.
218const indexWorkers = 32
219
220// IndexFiles reads the metadata of paths in parallel and returns the entries
221// in path order. Every worker ends before it returns.
222func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
223	files := make([][]FontFile, len(paths))
224	errs := make([]error, len(paths))
225	next := make(chan int)
226	var wg sync.WaitGroup
227	for range min(indexWorkers, len(paths)) {
228		wg.Add(1)
229		go func() {
230			defer wg.Done()
231			var buffer []byte
232			for i := range next {
233				files[i], buffer, errs[i] = safeIndexFile(paths[i], buffer)
234			}
235		}()
236	}
237	for i := range paths {
238		next <- i
239	}
240	close(next)
241	wg.Wait()
242	return slices.Concat(files...), errors.Join(errs...)
243}
244
245// safeIndexFile is indexFile on a worker goroutine: a parser panic on a
246// corrupt font becomes that file's error instead of ending the process.
247func safeIndexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
248	defer func() {
249		if r := recover(); r != nil {
250			files, buffer, err = nil, nil, fmt.Errorf("font %s: parser panic: %v", p, r)
251		}
252	}()
253	return indexFile(p, buffer)
254}
255
256// indexFile reads the metadata of every face in the file at p. buffer is
257// reusable scratch storage.
258func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
259	f, e := os.Open(p)
260	if e != nil {
261		return nil, buffer, fmt.Errorf("font %s: %w", p, e)
262	}
263	defer func() {
264		if closeErr := f.Close(); closeErr != nil {
265			err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
266		}
267	}()
268	info, statErr := f.Stat()
269	if statErr != nil || info.Size() > maxFontBytes {
270		if statErr == nil {
271			statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
272		}
273		return nil, buffer, fmt.Errorf("font %s: %w", p, statErr)
274	}
275	reader := &metadataReader{File: f, remaining: maxMetadataBytes}
276	loaders, e := ot.NewLoaders(reader)
277	if e != nil {
278		return nil, buffer, fmt.Errorf("font %s: %w", p, e)
279	}
280	for i, loader := range loaders {
281		var desc font.Description
282		// Describe ignores table read errors; a failed bounded read yields no
283		// usable family and is excluded from the index.
284		desc, buffer = font.Describe(loader, buffer)
285		if reader.exceeded {
286			return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
287		}
288		if desc.Family == "" {
289			continue
290		}
291		entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
292		if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
293			for j := range entry.UnicodeRanges {
294				entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
295			}
296		}
297		if reader.exceeded {
298			return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
299		}
300		files = append(files, entry)
301	}
302	return files, buffer, nil
303}
304func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }
305
306// Face is a parsed font, paired with its bytes for static TrueType rasterization.
307type Face struct {
308	ID            string
309	Family        string
310	Aspect        font.Aspect
311	Shape         *font.Face
312	Raster        *sfnt.Font
313	Data          []byte
314	Variations    []font.Variation // immutable after construction; use WithVariations to clone
315	Variable      bool             // fvar table present
316	path          string
317	index         int
318	unicodeRanges [4]uint32
319	catalog       *Catalog
320	bad           bool
321	familyKey     string // font.NormalizeFamily(Family), computed once by Load
322}
323
324// WithVariations clones a face, preserving the font bytes and sfnt handle while
325// configuring go-text's variation-aware outline and shaping caches independently.
326func (f *Face) WithVariations(v []font.Variation) *Face {
327	clone := *f
328	clone.Shape = font.NewFace(f.Shape.Font)
329	clone.Shape.SetVariations(v)
330	clone.Variations = append([]font.Variation(nil), v...)
331	return &clone
332}
333
334type Catalog struct {
335	Faces       []*Face
336	source      IndexSource
337	Diagnostics []error
338	// ranked memoizes candidates per request; a face failure or newly
339	// indexed faces clear it, because match depends only on them.
340	ranked []rankedFaces
341
342	// A LazySource catalog indexes files on demand. Faces stay in path order.
343	lazy     LazySource
344	paths    []string
345	indexed  []bool
346	faceAt   [][]*Face        // faces of each path
347	byStem   map[string][]int // normalized file-name stem to path positions
348	complete bool             // every path is indexed
349}
350
351// maxRanked bounds the candidates memo; distinct font requests per
352// application are few.
353const maxRanked = 64
354
355type rankedFaces struct {
356	families        []string
357	weight, stretch float32
358	italic          bool
359	faces           []*Face
360}
361
362// loadLazy lists the files and indexes only those named after the default
363// families, so a catalog is usable without reading every installed font.
364func loadLazy(source LazySource) (*Catalog, error) {
365	paths, err := source.Paths()
366	c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}}
367	if err != nil {
368		c.Diagnostics = append(c.Diagnostics, err)
369	}
370	for i, p := range paths {
371		key := fileStem(p)
372		c.byStem[key] = append(c.byStem[key], i)
373	}
374	c.indexStems(genericKeys["sans-serif"])
375	if len(c.Faces) == 0 {
376		c.indexAll()
377	}
378	if len(c.Faces) == 0 {
379		return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...))
380	}
381	return c, nil
382}
383
384// fileStem is the normalized family part of a font file name: the name up to
385// the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf
386// both give "notosans", the key of the "Noto Sans" family.
387func fileStem(path string) string {
388	name := filepath.Base(path)
389	if i := strings.IndexAny(name, "-[_."); i >= 0 {
390		name = name[:i]
391	}
392	return font.NormalizeFamily(name)
393}
394
395// styleWords may follow a family name inside a file-name stem, as in
396// DejaVuSansCondensed.ttf, whose family is "DejaVu Sans".
397var styleWords = []string{"ultracondensed", "extracondensed", "semicondensed", "condensed", "semiexpanded", "extraexpanded", "ultraexpanded", "expanded", "narrow", "wide",
398	"hairline", "thin", "extralight", "ultralight", "light", "book", "regular", "normal", "medium", "semibold", "demibold", "bold", "extrabold", "ultrabold", "heavy", "black",
399	"italic", "oblique"}
400
401// stemMatches reports whether a file stem names family key: the key itself,
402// or the key followed only by style words.
403func stemMatches(stem, key string) bool {
404	rest, ok := strings.CutPrefix(stem, key)
405	if !ok {
406		return false
407	}
408	for rest != "" {
409		word := ""
410		for _, w := range styleWords {
411			if strings.HasPrefix(rest, w) {
412				word = w
413				break
414			}
415		}
416		if word == "" {
417			return false
418		}
419		rest = rest[len(word):]
420	}
421	return true
422}
423
424// indexStems indexes the files whose stem names one of keys.
425func (c *Catalog) indexStems(keys []string) {
426	var todo []int
427	for stem, positions := range c.byStem {
428		if !slices.ContainsFunc(keys, func(key string) bool { return stemMatches(stem, key) }) {
429			continue
430		}
431		for _, i := range positions {
432			if !c.indexed[i] {
433				todo = append(todo, i)
434			}
435		}
436	}
437	c.indexPositions(todo)
438}
439
440// indexAll indexes every remaining file of a lazy catalog, once. It reports
441// whether it indexed anything.
442func (c *Catalog) indexAll() bool {
443	if c.lazy == nil || c.complete {
444		return false
445	}
446	var todo []int
447	for i, done := range c.indexed {
448		if !done {
449			todo = append(todo, i)
450		}
451	}
452	c.indexPositions(todo)
453	c.complete = true
454	return true
455}
456
457// indexPositions indexes the files at the given path positions and rebuilds
458// Faces in path order, keeping existing faces, so ties resolve as with a full
459// index.
460func (c *Catalog) indexPositions(todo []int) {
461	if len(todo) == 0 {
462		return
463	}
464	slices.Sort(todo)
465	paths := make([]string, len(todo))
466	for j, i := range todo {
467		paths[j] = c.paths[i]
468		c.indexed[i] = true
469	}
470	entries, err := c.lazy.IndexFiles(paths)
471	if err != nil {
472		c.Diagnostics = append(c.Diagnostics, err)
473	}
474	position := make(map[string]int, len(todo))
475	for _, i := range todo {
476		position[c.paths[i]] = i
477	}
478	for _, entry := range entries {
479		i, ok := position[entry.Path]
480		if !ok {
481			c.Diagnostics = append(c.Diagnostics, fmt.Errorf("font index entry for unrequested path %s", entry.Path))
482			continue
483		}
484		c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
485	}
486	c.Faces = slices.Concat(c.faceAt...)
487	clear(c.ranked)
488	c.ranked = c.ranked[:0]
489}
490
491func (c *Catalog) newFace(entry FontFile) *Face {
492	return &Face{ID: fmt.Sprintf("%s/%d", entry.Path, entry.Index), Family: entry.Family, familyKey: font.NormalizeFamily(entry.Family), Aspect: entry.Aspect, path: entry.Path, index: entry.Index, unicodeRanges: entry.UnicodeRanges, catalog: c}
493}
494
495func Load(source FontSource) (*Catalog, error) {
496	if lazy, ok := source.(LazySource); ok {
497		return loadLazy(lazy)
498	}
499	if indexed, ok := source.(IndexSource); ok {
500		entries, err := indexed.Index()
501		c := &Catalog{source: indexed}
502		if err != nil {
503			c.Diagnostics = append(c.Diagnostics, err)
504		}
505		for _, entry := range entries {
506			c.Faces = append(c.Faces, c.newFace(entry))
507		}
508		if len(c.Faces) == 0 {
509			return nil, fmt.Errorf("no usable fonts: %w", err)
510		}
511		return c, nil
512	}
513
514	files, err := source.Fonts()
515	var problems []error
516	if err != nil {
517		problems = append(problems, err)
518	}
519	names := make([]string, 0, len(files))
520	for n := range files {
521		names = append(names, n)
522	}
523	sort.Strings(names)
524	c := &Catalog{}
525	for _, name := range names {
526		data := files[name]
527		loaders, e := ot.NewLoaders(bytes.NewReader(data))
528		if e != nil {
529			problems = append(problems, fmt.Errorf("font %s: %w", name, e))
530			continue
531		}
532		collection, e := sfnt.ParseCollection(data)
533		if e != nil {
534			problems = append(problems, fmt.Errorf("raster font %s: %w", name, e))
535			continue
536		}
537		if collection.NumFonts() != len(loaders) {
538			problems = append(problems, fmt.Errorf("font %s: mismatched collection length", name))
539			continue
540		}
541		sum := sha256.Sum256(data)
542		for index, loader := range loaders {
543			parsed, err := font.NewFont(loader)
544			if err != nil {
545				problems = append(problems, fmt.Errorf("font %s face %d: %w", name, index, err))
546				continue
547			}
548			raster, err := collection.Font(index)
549			if err != nil {
550				problems = append(problems, fmt.Errorf("raster font %s face %d: %w", name, index, err))
551				continue
552			}
553			shape := font.NewFace(parsed)
554			raw, tableErr := loader.RawTable(ot.MustNewTag("fvar"))
555			desc := shape.Describe()
556			id := fmt.Sprintf("%x/%d", sum, index)
557			c.Faces = append(c.Faces, &Face{ID: id, Family: desc.Family, familyKey: font.NormalizeFamily(desc.Family), Aspect: desc.Aspect, Shape: shape, Raster: raster, Data: data, Variable: tableErr == nil && len(raw) > 0})
558		}
559	}
560	c.Diagnostics = problems
561	if len(c.Faces) == 0 {
562		return nil, fmt.Errorf("no usable fonts: %w", errors.Join(problems...))
563	}
564	return c, nil
565}
566
567// ready loads a selected face once. A failed face is permanently excluded from matching.
568func (f *Face) ready() bool {
569	if f == nil || f.bad {
570		return false
571	}
572	if f.Shape != nil {
573		return true
574	}
575	if f.catalog == nil {
576		return false
577	}
578	reader, err := f.catalog.source.Open(f.path)
579	if err != nil {
580		f.fail(err)
581		return false
582	}
583	data, err := io.ReadAll(io.LimitReader(reader, maxFontBytes+1))
584	if closeErr := reader.Close(); closeErr != nil {
585		f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s close: %w", f.path, closeErr))
586	}
587	if err == nil && len(data) > maxFontBytes {
588		err = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
589	}
590	if err != nil {
591		f.fail(err)
592		return false
593	}
594	loaders, err := ot.NewLoaders(bytes.NewReader(data))
595	if err != nil {
596		f.fail(err)
597		return false
598	}
599	collection, err := sfnt.ParseCollection(data)
600	if err != nil {
601		f.fail(err)
602		return false
603	}
604	if f.index >= len(loaders) || f.index >= collection.NumFonts() {
605		f.fail(errors.New("face index out of range"))
606		return false
607	}
608	parsed, err := font.NewFont(loaders[f.index])
609	if err != nil {
610		f.fail(err)
611		return false
612	}
613	raster, err := collection.Font(f.index)
614	if err != nil {
615		f.fail(err)
616		return false
617	}
618	raw, tableErr := loaders[f.index].RawTable(ot.MustNewTag("fvar"))
619	f.Data, f.Shape, f.Raster = data, font.NewFace(parsed), raster
620	f.Variable = tableErr == nil && len(raw) > 0
621	return true
622}
623func (f *Face) fail(err error) {
624	f.bad = true
625	if f.catalog == nil {
626		return
627	}
628	clear(f.catalog.ranked)
629	f.catalog.ranked = f.catalog.ranked[:0]
630	f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))
631}
632
633// Request specifies CSS font selection. Weight defaults to 400, Stretch to 1, Style normal.
634type Request struct {
635	Direction bidi.BaseDirection // Auto (default), LTR, or RTL
636	Families  []string
637	Weight    float32
638	Stretch   float32
639	Italic    bool
640	Size      float64
641	// LetterSpacing is the signed logical-pixel spacing between shaped clusters.
642	LetterSpacing float64
643	Variations    []font.Variation
644}
645
646// match takes a family already normalized with font.NormalizeFamily.
647func (c *Catalog) match(family string, r Request) *Face {
648	var best *Face
649	score := math.Inf(1)
650	for _, f := range c.Faces {
651		if f.familyKey != family || f.bad {
652			continue
653		}
654		stretch := r.Stretch
655		if stretch == 0 {
656			stretch = 1
657		}
658		weight := r.Weight
659		if weight == 0 {
660			weight = 400
661		}
662		// CSS subset: stretch first, style second, then nearest weight. On ties use source path order.
663		s := math.Abs(float64(f.Aspect.Stretch)-float64(stretch)) * 1e6
664		if (f.Aspect.Style == font.StyleItalic) != r.Italic {
665			s += 1e4
666		}
667		diff := float64(f.Aspect.Weight) - float64(weight)
668		if weight >= 400 && weight <= 500 {
669			// CSS: [target..500], then below target, then above 500.
670			switch {
671			case diff >= 0 && float64(f.Aspect.Weight) <= 500:
672				s += diff
673			case diff < 0:
674				s += 1000 - diff
675			default:
676				s += 2000 + diff
677			}
678		} else if weight < 400 {
679			if diff > 0 {
680				s += 1000 + diff
681			} else {
682				s -= diff
683			}
684		} else {
685			if diff < 0 {
686				s += 1000 - diff
687			} else {
688				s += diff
689			}
690		}
691		if s < score {
692			best = f
693			score = s
694		}
695	}
696	return best
697}
698
699// Generic family aliases are ordered, hard-coded and independent of installed fontconfig rules.
700var generics = map[string][]string{
701	"sans-serif": {"Noto Sans", "Adwaita Sans", "DejaVu Sans"},
702	"system-ui":  {"Adwaita Sans", "Noto Sans", "DejaVu Sans"},
703	"serif":      {"Noto Serif", "DejaVu Serif"},
704	"monospace":  {"Noto Sans Mono", "IBM Plex Mono", "DejaVu Sans Mono"},
705}
706
707// Normalized once so matching never allocates per face.
708var genericKeys = normalizeFamilies(generics)
709var scriptFallbackKeys = normalizeList([]string{"Noto Sans Arabic", "Noto Sans Devanagari", "IBM Plex Sans JP", "Noto Sans Symbols 2"})
710
711func normalizeList(names []string) []string {
712	out := make([]string, len(names))
713	for i, n := range names {
714		out[i] = font.NormalizeFamily(n)
715	}
716	return out
717}
718func normalizeFamilies(m map[string][]string) map[string][]string {
719	out := make(map[string][]string, len(m))
720	for k, v := range m {
721		out[k] = normalizeList(v)
722	}
723	return out
724}
725
726// candidates ranks the faces a request may use. The result is shared: callers
727// must not modify it.
728func (c *Catalog) candidates(r Request) []*Face {
729	for _, e := range c.ranked {
730		if e.weight == r.Weight && e.stretch == r.Stretch && e.italic == r.Italic && slices.Equal(e.families, r.Families) {
731			return e.faces
732		}
733	}
734	out := c.rank(r)
735	out = out[:len(out):len(out)] // an append by a caller cannot write into the memo
736	if len(c.ranked) < maxRanked {
737		c.ranked = append(c.ranked, rankedFaces{families: slices.Clone(r.Families), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, faces: out})
738	}
739	return out
740}
741
742func (c *Catalog) rank(r Request) []*Face {
743	if c.lazy != nil && !c.complete {
744		if out, ok := c.rankIndexed(r); ok {
745			return out
746		}
747		c.indexAll()
748	}
749	return c.rankFaces(r)
750}
751
752// rankIndexed indexes the files named after the request's families and ranks
753// them. ok is false when the full index is needed: an explicitly named family
754// matches no file name, or nothing at all matches.
755func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) {
756	var keys []string
757	explicit := map[string]bool{}
758	for _, family := range r.Families {
759		family = strings.Trim(family, " \t\"'")
760		if alias, ok := genericKeys[strings.ToLower(family)]; ok {
761			keys = append(keys, alias...)
762		} else {
763			key := font.NormalizeFamily(family)
764			keys = append(keys, key)
765			explicit[key] = true
766		}
767	}
768	keys = append(keys, genericKeys["sans-serif"]...)
769	keys = append(keys, scriptFallbackKeys...)
770	c.indexStems(keys)
771	for key := range explicit {
772		if !c.hasFamily(key) {
773			return nil, false
774		}
775	}
776	out = c.rankFaces(r)
777	return out, len(out) > 0
778}
779
780func (c *Catalog) hasFamily(key string) bool {
781	for _, f := range c.Faces {
782		if f.familyKey == key {
783			return true
784		}
785	}
786	return false
787}
788
789func (c *Catalog) rankFaces(r Request) []*Face {
790	seen := map[*Face]bool{}
791	var out []*Face
792	add := func(f *Face) {
793		if f != nil && !seen[f] {
794			out = append(out, f)
795			seen[f] = true
796		}
797	}
798	for _, family := range r.Families {
799		family = strings.Trim(family, " \t\"'")
800		if alias, ok := genericKeys[strings.ToLower(family)]; ok {
801			for _, a := range alias {
802				add(c.match(a, r))
803			}
804		} else {
805			add(c.match(font.NormalizeFamily(family), r))
806		}
807	}
808	for _, a := range genericKeys["sans-serif"] {
809		add(c.match(a, r))
810	}
811	// Script fallback: deterministically prefer known Unicode families, never a color bitmap font.
812	for _, a := range scriptFallbackKeys {
813		add(c.match(a, r))
814	}
815	if c.source == nil {
816		for _, f := range c.Faces {
817			add(f)
818		}
819	}
820
821	return out
822}
823
824const maxFallbackFaces = 16
825
826// The OS/2 Unicode range bits are coarse hints, not glyph coverage. Unmapped
827// blocks and absent ranges remain eligible, so NominalGlyph decides coverage.
828func unicodeRangeBit(r rune) int {
829	switch {
830	case r <= 0x007f:
831		return 0 // Basic Latin
832	case r <= 0x00ff:
833		return 1 // Latin-1
834	case r >= 0x0100 && r <= 0x017f:
835		return 2
836	case r >= 0x0180 && r <= 0x024f:
837		return 3
838	case r >= 0x0300 && r <= 0x036f:
839		return 6
840	case r >= 0x0370 && r <= 0x03ff:
841		return 7
842	case r >= 0x0400 && r <= 0x052f:
843		return 9
844	case r >= 0x0590 && r <= 0x05ff:
845		return 11
846	case r >= 0x0600 && r <= 0x06ff:
847		return 13
848	case r >= 0x0900 && r <= 0x097f:
849		return 15
850	case r >= 0x3040 && r <= 0x309f:
851		return 49
852	case r >= 0x30a0 && r <= 0x30ff:
853		return 50
854	case r >= 0x4e00 && r <= 0x9fff:
855		return 59
856	case r >= 0xac00 && r <= 0xd7af:
857		return 56
858	case r >= 0x1f300 && r <= 0x1faff:
859		return 57 // non-BMP (OS/2 bit 57)
860	default:
861		return -1
862	}
863}
864func (f *Face) mayCover(r rune) bool {
865	bits := f.unicodeRanges
866	if bits == [4]uint32{} {
867		return true
868	}
869	bit := unicodeRangeBit(r)
870	return bit < 0 || bits[bit/32]&(1<<uint(bit%32)) != 0
871}
872
873// namedKeys lists every family key the request can name, in any aspect, so
874// the fallback window excludes them whether or not their faces loaded.
875func namedKeys(r Request) map[string]bool {
876	keys := map[string]bool{}
877	for _, family := range r.Families {
878		family = strings.Trim(family, " \t\"'")
879		if alias, ok := genericKeys[strings.ToLower(family)]; ok {
880			for _, a := range alias {
881				keys[a] = true
882			}
883		} else {
884			keys[font.NormalizeFamily(family)] = true
885		}
886	}
887	for _, a := range genericKeys["sans-serif"] {
888		keys[a] = true
889	}
890	for _, a := range scriptFallbackKeys {
891		keys[a] = true
892	}
893	return keys
894}
895
896// fallbackWindow is fixed by the request, text and metadata index. Faces with
897// unknown range data remain eligible; loaded or failed state never moves the
898// window, so later measures cannot admit a different face.
899func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
900	c.indexAll() // any installed face may cover a missing glyph
901	named := namedKeys(r)
902	out := make([]*Face, 0, maxFallbackFaces)
903	for _, f := range c.Faces {
904		if named[f.familyKey] {
905			continue
906		}
907		for _, r := range text {
908			if r != 0x200d && (r < 0xfe00 || r > 0xfe0f) && f.mayCover(r) {
909				out = append(out, f)
910				break
911			}
912		}
913		if len(out) == maxFallbackFaces {
914			break
915		}
916	}
917	return out
918}
919
920// primary is the first loadable named candidate, else the first loadable face
921// of the fallback window. A failed face is marked bad, so each step advances.
922func (c *Catalog) primary(r Request) *Face {
923	for {
924		candidates := c.candidates(r)
925		if len(candidates) == 0 {
926			break
927		}
928		if candidates[0].ready() {
929			return candidates[0]
930		}
931		if !candidates[0].bad {
932			return nil
933		}
934	}
935	for _, f := range c.fallbackWindow([]rune{'A'}, r) {
936		if f.ready() {
937			return f
938		}
939	}
940	return nil
941}

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