v0.1.0 551649b

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

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