v0.2.0 c616855

glyph.go RawHistoryFolder

1// Package glyph rasterizes single glyphs of a monospace font into fixed-size
2// cell bitmaps (one byte of coverage per pixel).
3package glyph
4
5import (
6	"fmt"
7	"image"
8	"math"
9	"os"
10	"os/exec"
11	"strings"
12
13	"golang.org/x/image/font"
14	"golang.org/x/image/font/opentype"
15	"golang.org/x/image/math/fixed"
16	"golang.org/x/sys/unix"
17)
18
19// Style is a set of text attributes. Bold and Italic pick a font file;
20// Underline and Strike are drawn into the glyph bitmap.
21type Style uint8
22
23const (
24	Bold Style = 1 << iota
25	Italic
26	Underline
27	Strike
28)
29
30// fontBits is the part of a Style that selects one of the four font files.
31const fontBits = Bold | Italic
32
33// Paths holds the font file of each weight and slant, indexed by Bold|Italic.
34// An empty entry falls back to the nearest available face.
35type Paths [4]string
36
37// Face rasterizes runes of one font family at one pixel size. All glyphs are
38// clipped to a CellW x CellH box. It is used from one goroutine.
39type Face struct {
40	faces [4]font.Face
41	// maps are the font files, mapped read-only: they stay in the shared page
42	// cache instead of the Go heap, and only the pages read count as memory.
43	maps [][]byte
44	// CellW and CellH are the cell size in physical pixels.
45	CellW, CellH int
46	ascent       fixed.Int26_6
47	ascentPx     int
48	thick        int // line thickness for underline and strike, in pixels
49}
50
51// fcPatterns are the fontconfig suffixes for Paths' four entries.
52var fcPatterns = [4]string{"", ":bold", ":italic", ":bold:italic"}
53
54// FindFonts asks fontconfig for the four files of a family. Styles the family
55// lacks resolve to whatever fontconfig picks, usually the regular file.
56func FindFonts(family string) (Paths, error) {
57	var p Paths
58	for i, suffix := range fcPatterns {
59		out, err := exec.Command("fc-match", "-f", "%{file}", family+suffix).Output()
60		if err != nil {
61			return p, fmt.Errorf("fontconfig lookup of %q failed: %w", family+suffix, err)
62		}
63		p[i] = strings.TrimSpace(string(out))
64		if p[i] == "" {
65			return p, fmt.Errorf("fontconfig found no file for %q", family+suffix)
66		}
67	}
68	return p, nil
69}
70
71// FamilyOf reports the family fontconfig resolved a request to, so callers can
72// warn when it is not the family that was asked for.
73func FamilyOf(request string) string {
74	out, err := exec.Command("fc-match", "-f", "%{family[0]}", request).Output()
75	if err != nil {
76		return ""
77	}
78	return strings.TrimSpace(string(out))
79}
80
81// mapFile maps a whole file read-only.
82func mapFile(path string) ([]byte, error) {
83	file, err := os.Open(path)
84	if err != nil {
85		return nil, err
86	}
87	defer file.Close()
88	st, err := file.Stat()
89	if err != nil {
90		return nil, err
91	}
92	if st.Size() <= 0 || st.Size() != int64(int(st.Size())) {
93		return nil, fmt.Errorf("%s: unusable size %d", path, st.Size())
94	}
95	return unix.Mmap(int(file.Fd()), 0, int(st.Size()), unix.PROT_READ, unix.MAP_SHARED)
96}
97
98// loadFace opens the file at path and appends its mapping to f.maps.
99func (f *Face) loadFace(path string, px float64) (font.Face, error) {
100	data, err := mapFile(path)
101	if err != nil {
102		return nil, fmt.Errorf("glyph: %w", err)
103	}
104	f.maps = append(f.maps, data)
105	fnt, err := opentype.Parse(data)
106	if err != nil {
107		return nil, fmt.Errorf("glyph: parse %s: %w", path, err)
108	}
109	return opentype.NewFace(fnt, &opentype.FaceOptions{Size: px, DPI: 72, Hinting: font.HintingFull})
110}
111
112// Load opens the font files at px pixels. The cell size comes from the regular
113// face: the advance of "M", and ascent plus descent.
114func Load(paths Paths, px float64) (*Face, error) {
115	if px < 4 || px > 512 {
116		return nil, fmt.Errorf("glyph: unsupported font size %.1f px", px)
117	}
118	if paths[0] == "" {
119		return nil, fmt.Errorf("glyph: no regular font file")
120	}
121	f := &Face{}
122	byPath := map[string]font.Face{}
123	for i, path := range paths {
124		if path == "" {
125			continue // filled from a neighbour below
126		}
127		if face, ok := byPath[path]; ok {
128			f.faces[i] = face
129			continue
130		}
131		face, err := f.loadFace(path, px)
132		if err != nil {
133			f.Close()
134			return nil, err
135		}
136		byPath[path], f.faces[i] = face, face
137	}
138	// A missing bold-italic uses bold, then italic; any other gap uses regular.
139	for i := range f.faces {
140		if f.faces[i] != nil {
141			continue
142		}
143		switch {
144		case Style(i) == Bold|Italic && f.faces[Bold] != nil:
145			f.faces[i] = f.faces[Bold]
146		case Style(i) == Bold|Italic && f.faces[Italic] != nil:
147			f.faces[i] = f.faces[Italic]
148		default:
149			f.faces[i] = f.faces[0]
150		}
151	}
152	m := f.faces[0].Metrics()
153	adv, ok := f.faces[0].GlyphAdvance('M')
154	if !ok || adv <= 0 {
155		f.Close()
156		return nil, fmt.Errorf("glyph: font has no usable advance for 'M'")
157	}
158	f.CellW = int(math.Round(float64(adv) / 64))
159	f.CellH = int(math.Ceil(float64(m.Ascent+m.Descent) / 64))
160	f.ascent = m.Ascent
161	f.ascentPx = int(math.Round(float64(m.Ascent) / 64))
162	f.thick = max(1, int(math.Round(px/14)))
163	if f.CellW < 1 || f.CellH < 1 {
164		f.Close()
165		return nil, fmt.Errorf("glyph: degenerate cell %dx%d", f.CellW, f.CellH)
166	}
167	return f, nil
168}
169
170// Close releases the font faces and unmaps their files. The face must not be
171// used afterwards.
172func (f *Face) Close() {
173	if f == nil {
174		return
175	}
176	closed := map[font.Face]bool{}
177	for i, face := range f.faces {
178		if face != nil && !closed[face] {
179			closed[face] = true
180			face.Close()
181		}
182		f.faces[i] = nil
183	}
184	for _, m := range f.maps {
185		_ = unix.Munmap(m)
186	}
187	f.maps = nil
188}
189
190// Rasterize draws r into dst, which must hold CellW*CellH bytes (stride
191// CellW). Pixels outside the cell are clipped; a missing glyph leaves dst
192// empty. Underline and Strike add a full-width line in the foreground.
193func (f *Face) Rasterize(r rune, style Style, dst []byte) {
194	clear(dst[:f.CellW*f.CellH])
195	face := f.faces[style&fontBits]
196	if dr, mask, maskp, _, ok := face.Glyph(fixed.Point26_6{X: 0, Y: f.ascent}, r); ok {
197		alpha, isAlpha := mask.(*image.Alpha)
198		for y := dr.Min.Y; y < dr.Max.Y; y++ {
199			if y < 0 || y >= f.CellH {
200				continue
201			}
202			for x := dr.Min.X; x < dr.Max.X; x++ {
203				if x < 0 || x >= f.CellW {
204					continue
205				}
206				mx, my := maskp.X+x-dr.Min.X, maskp.Y+y-dr.Min.Y
207				if isAlpha {
208					dst[y*f.CellW+x] = alpha.Pix[alpha.PixOffset(mx, my)]
209					continue
210				}
211				_, _, _, a := mask.At(mx, my).RGBA()
212				dst[y*f.CellW+x] = uint8(a >> 8)
213			}
214		}
215	}
216	if style&Underline != 0 {
217		// Below the baseline, a third of the way into the descent.
218		descent := f.CellH - f.ascentPx
219		f.line(dst, f.ascentPx+max(1, descent/3))
220	}
221	if style&Strike != 0 {
222		// Through the middle of the lowercase letters.
223		f.line(dst, f.ascentPx-max(1, f.ascentPx*3/10)-f.thick/2)
224	}
225}
226
227// line fills f.thick rows starting at y, clipped to the cell.
228func (f *Face) line(dst []byte, y int) {
229	for row := y; row < y+f.thick; row++ {
230		if row < 0 || row >= f.CellH {
231			continue
232		}
233		for x := 0; x < f.CellW; x++ {
234			dst[row*f.CellW+x] = 255
235		}
236	}
237}

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 5

Tags 4

↑↓ moveenter openesc close