detached e40e7a1

perf(text): index system fonts on demand and in parallel

Load indexed every installed font before the first frame: 5 s on a cold
page cache with about 1000 fonts on an encrypted disk. The system catalog
now indexes only files named after the requested and default families,
and scans every font, with 32 parallel readers, only when a named family
has no matching file or a glyph needs a fallback face. Faces keep the
order of a full index.
commit
e40e7a189dfd4dfc45175f7251b03222f147e43a
author
brice · · 2026-10-09 10:10 UTC
parent
438604c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +734 -49
M .mockery.yml +8 -0
@@ -14,6 +14,14 @@ packages:
1414 filename: '{{.InterfaceName}}_mock_test.go'
1515 interfaces:
1616 target:
17+ # Font source seam of the text catalog.
18+ github.com/bnema/nefergui/internal/text:
19+ config:
20+ dir: internal/text
21+ pkgname: text
22+ filename: '{{.InterfaceName}}_mock_test.go'
23+ interfaces:
24+ LazySource:
1725 # Editor ports: same-package mocks in test files.
1826 github.com/bnema/nefergui/internal/edit:
1927 config:
@@ -14,6 +14,14 @@ packages:
14 filename: '{{.InterfaceName}}_mock_test.go'14 filename: '{{.InterfaceName}}_mock_test.go'
15 interfaces:15 interfaces:
16 target:16 target:
17+ # Font source seam of the text catalog.
18+ github.com/bnema/nefergui/internal/text:
19+ config:
20+ dir: internal/text
21+ pkgname: text
22+ filename: '{{.InterfaceName}}_mock_test.go'
23+ interfaces:
24+ LazySource:
17 # Editor ports: same-package mocks in test files.25 # Editor ports: same-package mocks in test files.
18 github.com/bnema/nefergui/internal/edit:26 github.com/bnema/nefergui/internal/edit:
19 config:27 config:
M docs/troubleshooting.md +1 -0
@@ -7,3 +7,4 @@
77 - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles).
88 - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`.
99 - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig.
10+- System fonts are indexed on demand: at startup only files whose name matches a requested or default family (for example `NotoSans-Regular.ttf` for `Noto Sans`). A family that no file name reveals, or a glyph the named families lack, indexes every remaining font once, in parallel. Name a family that is installed under a matching file name to keep the first frame fast.
@@ -7,3 +7,4 @@
7 - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles).7 - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles).
8 - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`.8 - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`.
9 - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig.9 - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig.
10+- System fonts are indexed on demand: at startup only files whose name matches a requested or default family (for example `NotoSans-Regular.ttf` for `Noto Sans`). A family that no file name reveals, or a glyph the named families lack, indexes every remaining font once, in parallel. Name a family that is installed under a matching file name to keep the first frame fast.
A internal/text/LazySource_mock_test.go +336 -0
@@ -0,0 +1,336 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package text
6+
7+import (
8+ "io"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLazySource creates a new instance of MockLazySource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLazySource(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLazySource {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &MockLazySource{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// MockLazySource is an autogenerated mock type for the LazySource type
37+type MockLazySource struct {
38+ mock.Mock
39+}
40+
41+type MockLazySource_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *MockLazySource) EXPECT() *MockLazySource_Expecter {
46+ return &MockLazySource_Expecter{mock: &_m.Mock}
47+}
48+
49+// Fonts provides a mock function for the type MockLazySource
50+func (_mock *MockLazySource) Fonts() (map[string][]byte, error) {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Fonts")
55+ }
56+
57+ var r0 map[string][]byte
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func() (map[string][]byte, error)); ok {
60+ return returnFunc()
61+ }
62+ if returnFunc, ok := ret.Get(0).(func() map[string][]byte); ok {
63+ r0 = returnFunc()
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).(map[string][]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func() error); ok {
70+ r1 = returnFunc()
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// MockLazySource_Fonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fonts'
78+type MockLazySource_Fonts_Call struct {
79+ *mock.Call
80+}
81+
82+// Fonts is a helper method to define mock.On call
83+func (_e *MockLazySource_Expecter) Fonts() *MockLazySource_Fonts_Call {
84+ return &MockLazySource_Fonts_Call{Call: _e.mock.On("Fonts")}
85+}
86+
87+func (_c *MockLazySource_Fonts_Call) Run(run func()) *MockLazySource_Fonts_Call {
88+ _c.Call.Run(func(args mock.Arguments) {
89+ run()
90+ })
91+ return _c
92+}
93+
94+func (_c *MockLazySource_Fonts_Call) Return(stringToBytes map[string][]byte, err error) *MockLazySource_Fonts_Call {
95+ _c.Call.Return(stringToBytes, err)
96+ return _c
97+}
98+
99+func (_c *MockLazySource_Fonts_Call) RunAndReturn(run func() (map[string][]byte, error)) *MockLazySource_Fonts_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
104+// Index provides a mock function for the type MockLazySource
105+func (_mock *MockLazySource) Index() ([]FontFile, error) {
106+ ret := _mock.Called()
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for Index")
110+ }
111+
112+ var r0 []FontFile
113+ var r1 error
114+ if returnFunc, ok := ret.Get(0).(func() ([]FontFile, error)); ok {
115+ return returnFunc()
116+ }
117+ if returnFunc, ok := ret.Get(0).(func() []FontFile); ok {
118+ r0 = returnFunc()
119+ } else {
120+ if ret.Get(0) != nil {
121+ r0 = ret.Get(0).([]FontFile)
122+ }
123+ }
124+ if returnFunc, ok := ret.Get(1).(func() error); ok {
125+ r1 = returnFunc()
126+ } else {
127+ r1 = ret.Error(1)
128+ }
129+ return r0, r1
130+}
131+
132+// MockLazySource_Index_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Index'
133+type MockLazySource_Index_Call struct {
134+ *mock.Call
135+}
136+
137+// Index is a helper method to define mock.On call
138+func (_e *MockLazySource_Expecter) Index() *MockLazySource_Index_Call {
139+ return &MockLazySource_Index_Call{Call: _e.mock.On("Index")}
140+}
141+
142+func (_c *MockLazySource_Index_Call) Run(run func()) *MockLazySource_Index_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ run()
145+ })
146+ return _c
147+}
148+
149+func (_c *MockLazySource_Index_Call) Return(fontFiles []FontFile, err error) *MockLazySource_Index_Call {
150+ _c.Call.Return(fontFiles, err)
151+ return _c
152+}
153+
154+func (_c *MockLazySource_Index_Call) RunAndReturn(run func() ([]FontFile, error)) *MockLazySource_Index_Call {
155+ _c.Call.Return(run)
156+ return _c
157+}
158+
159+// IndexFiles provides a mock function for the type MockLazySource
160+func (_mock *MockLazySource) IndexFiles(paths []string) ([]FontFile, error) {
161+ ret := _mock.Called(paths)
162+
163+ if len(ret) == 0 {
164+ panic("no return value specified for IndexFiles")
165+ }
166+
167+ var r0 []FontFile
168+ var r1 error
169+ if returnFunc, ok := ret.Get(0).(func([]string) ([]FontFile, error)); ok {
170+ return returnFunc(paths)
171+ }
172+ if returnFunc, ok := ret.Get(0).(func([]string) []FontFile); ok {
173+ r0 = returnFunc(paths)
174+ } else {
175+ if ret.Get(0) != nil {
176+ r0 = ret.Get(0).([]FontFile)
177+ }
178+ }
179+ if returnFunc, ok := ret.Get(1).(func([]string) error); ok {
180+ r1 = returnFunc(paths)
181+ } else {
182+ r1 = ret.Error(1)
183+ }
184+ return r0, r1
185+}
186+
187+// MockLazySource_IndexFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexFiles'
188+type MockLazySource_IndexFiles_Call struct {
189+ *mock.Call
190+}
191+
192+// IndexFiles is a helper method to define mock.On call
193+// - paths []string
194+func (_e *MockLazySource_Expecter) IndexFiles(paths any) *MockLazySource_IndexFiles_Call {
195+ return &MockLazySource_IndexFiles_Call{Call: _e.mock.On("IndexFiles", paths)}
196+}
197+
198+func (_c *MockLazySource_IndexFiles_Call) Run(run func(paths []string)) *MockLazySource_IndexFiles_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 []string
201+ if args[0] != nil {
202+ arg0 = args[0].([]string)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockLazySource_IndexFiles_Call) Return(fontFiles []FontFile, err error) *MockLazySource_IndexFiles_Call {
212+ _c.Call.Return(fontFiles, err)
213+ return _c
214+}
215+
216+func (_c *MockLazySource_IndexFiles_Call) RunAndReturn(run func(paths []string) ([]FontFile, error)) *MockLazySource_IndexFiles_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
221+// Open provides a mock function for the type MockLazySource
222+func (_mock *MockLazySource) Open(path string) (io.ReadCloser, error) {
223+ ret := _mock.Called(path)
224+
225+ if len(ret) == 0 {
226+ panic("no return value specified for Open")
227+ }
228+
229+ var r0 io.ReadCloser
230+ var r1 error
231+ if returnFunc, ok := ret.Get(0).(func(string) (io.ReadCloser, error)); ok {
232+ return returnFunc(path)
233+ }
234+ if returnFunc, ok := ret.Get(0).(func(string) io.ReadCloser); ok {
235+ r0 = returnFunc(path)
236+ } else {
237+ if ret.Get(0) != nil {
238+ r0 = ret.Get(0).(io.ReadCloser)
239+ }
240+ }
241+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
242+ r1 = returnFunc(path)
243+ } else {
244+ r1 = ret.Error(1)
245+ }
246+ return r0, r1
247+}
248+
249+// MockLazySource_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
250+type MockLazySource_Open_Call struct {
251+ *mock.Call
252+}
253+
254+// Open is a helper method to define mock.On call
255+// - path string
256+func (_e *MockLazySource_Expecter) Open(path any) *MockLazySource_Open_Call {
257+ return &MockLazySource_Open_Call{Call: _e.mock.On("Open", path)}
258+}
259+
260+func (_c *MockLazySource_Open_Call) Run(run func(path string)) *MockLazySource_Open_Call {
261+ _c.Call.Run(func(args mock.Arguments) {
262+ var arg0 string
263+ if args[0] != nil {
264+ arg0 = args[0].(string)
265+ }
266+ run(
267+ arg0,
268+ )
269+ })
270+ return _c
271+}
272+
273+func (_c *MockLazySource_Open_Call) Return(readCloser io.ReadCloser, err error) *MockLazySource_Open_Call {
274+ _c.Call.Return(readCloser, err)
275+ return _c
276+}
277+
278+func (_c *MockLazySource_Open_Call) RunAndReturn(run func(path string) (io.ReadCloser, error)) *MockLazySource_Open_Call {
279+ _c.Call.Return(run)
280+ return _c
281+}
282+
283+// Paths provides a mock function for the type MockLazySource
284+func (_mock *MockLazySource) Paths() ([]string, error) {
285+ ret := _mock.Called()
286+
287+ if len(ret) == 0 {
288+ panic("no return value specified for Paths")
289+ }
290+
291+ var r0 []string
292+ var r1 error
293+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
294+ return returnFunc()
295+ }
296+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
297+ r0 = returnFunc()
298+ } else {
299+ if ret.Get(0) != nil {
300+ r0 = ret.Get(0).([]string)
301+ }
302+ }
303+ if returnFunc, ok := ret.Get(1).(func() error); ok {
304+ r1 = returnFunc()
305+ } else {
306+ r1 = ret.Error(1)
307+ }
308+ return r0, r1
309+}
310+
311+// MockLazySource_Paths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Paths'
312+type MockLazySource_Paths_Call struct {
313+ *mock.Call
314+}
315+
316+// Paths is a helper method to define mock.On call
317+func (_e *MockLazySource_Expecter) Paths() *MockLazySource_Paths_Call {
318+ return &MockLazySource_Paths_Call{Call: _e.mock.On("Paths")}
319+}
320+
321+func (_c *MockLazySource_Paths_Call) Run(run func()) *MockLazySource_Paths_Call {
322+ _c.Call.Run(func(args mock.Arguments) {
323+ run()
324+ })
325+ return _c
326+}
327+
328+func (_c *MockLazySource_Paths_Call) Return(strings []string, err error) *MockLazySource_Paths_Call {
329+ _c.Call.Return(strings, err)
330+ return _c
331+}
332+
333+func (_c *MockLazySource_Paths_Call) RunAndReturn(run func() ([]string, error)) *MockLazySource_Paths_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
@@ -0,0 +1,336 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package text
6+
7+import (
8+ "io"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLazySource creates a new instance of MockLazySource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLazySource(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLazySource {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &MockLazySource{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// MockLazySource is an autogenerated mock type for the LazySource type
37+type MockLazySource struct {
38+ mock.Mock
39+}
40+
41+type MockLazySource_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *MockLazySource) EXPECT() *MockLazySource_Expecter {
46+ return &MockLazySource_Expecter{mock: &_m.Mock}
47+}
48+
49+// Fonts provides a mock function for the type MockLazySource
50+func (_mock *MockLazySource) Fonts() (map[string][]byte, error) {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Fonts")
55+ }
56+
57+ var r0 map[string][]byte
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func() (map[string][]byte, error)); ok {
60+ return returnFunc()
61+ }
62+ if returnFunc, ok := ret.Get(0).(func() map[string][]byte); ok {
63+ r0 = returnFunc()
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).(map[string][]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func() error); ok {
70+ r1 = returnFunc()
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// MockLazySource_Fonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fonts'
78+type MockLazySource_Fonts_Call struct {
79+ *mock.Call
80+}
81+
82+// Fonts is a helper method to define mock.On call
83+func (_e *MockLazySource_Expecter) Fonts() *MockLazySource_Fonts_Call {
84+ return &MockLazySource_Fonts_Call{Call: _e.mock.On("Fonts")}
85+}
86+
87+func (_c *MockLazySource_Fonts_Call) Run(run func()) *MockLazySource_Fonts_Call {
88+ _c.Call.Run(func(args mock.Arguments) {
89+ run()
90+ })
91+ return _c
92+}
93+
94+func (_c *MockLazySource_Fonts_Call) Return(stringToBytes map[string][]byte, err error) *MockLazySource_Fonts_Call {
95+ _c.Call.Return(stringToBytes, err)
96+ return _c
97+}
98+
99+func (_c *MockLazySource_Fonts_Call) RunAndReturn(run func() (map[string][]byte, error)) *MockLazySource_Fonts_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
104+// Index provides a mock function for the type MockLazySource
105+func (_mock *MockLazySource) Index() ([]FontFile, error) {
106+ ret := _mock.Called()
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for Index")
110+ }
111+
112+ var r0 []FontFile
113+ var r1 error
114+ if returnFunc, ok := ret.Get(0).(func() ([]FontFile, error)); ok {
115+ return returnFunc()
116+ }
117+ if returnFunc, ok := ret.Get(0).(func() []FontFile); ok {
118+ r0 = returnFunc()
119+ } else {
120+ if ret.Get(0) != nil {
121+ r0 = ret.Get(0).([]FontFile)
122+ }
123+ }
124+ if returnFunc, ok := ret.Get(1).(func() error); ok {
125+ r1 = returnFunc()
126+ } else {
127+ r1 = ret.Error(1)
128+ }
129+ return r0, r1
130+}
131+
132+// MockLazySource_Index_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Index'
133+type MockLazySource_Index_Call struct {
134+ *mock.Call
135+}
136+
137+// Index is a helper method to define mock.On call
138+func (_e *MockLazySource_Expecter) Index() *MockLazySource_Index_Call {
139+ return &MockLazySource_Index_Call{Call: _e.mock.On("Index")}
140+}
141+
142+func (_c *MockLazySource_Index_Call) Run(run func()) *MockLazySource_Index_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ run()
145+ })
146+ return _c
147+}
148+
149+func (_c *MockLazySource_Index_Call) Return(fontFiles []FontFile, err error) *MockLazySource_Index_Call {
150+ _c.Call.Return(fontFiles, err)
151+ return _c
152+}
153+
154+func (_c *MockLazySource_Index_Call) RunAndReturn(run func() ([]FontFile, error)) *MockLazySource_Index_Call {
155+ _c.Call.Return(run)
156+ return _c
157+}
158+
159+// IndexFiles provides a mock function for the type MockLazySource
160+func (_mock *MockLazySource) IndexFiles(paths []string) ([]FontFile, error) {
161+ ret := _mock.Called(paths)
162+
163+ if len(ret) == 0 {
164+ panic("no return value specified for IndexFiles")
165+ }
166+
167+ var r0 []FontFile
168+ var r1 error
169+ if returnFunc, ok := ret.Get(0).(func([]string) ([]FontFile, error)); ok {
170+ return returnFunc(paths)
171+ }
172+ if returnFunc, ok := ret.Get(0).(func([]string) []FontFile); ok {
173+ r0 = returnFunc(paths)
174+ } else {
175+ if ret.Get(0) != nil {
176+ r0 = ret.Get(0).([]FontFile)
177+ }
178+ }
179+ if returnFunc, ok := ret.Get(1).(func([]string) error); ok {
180+ r1 = returnFunc(paths)
181+ } else {
182+ r1 = ret.Error(1)
183+ }
184+ return r0, r1
185+}
186+
187+// MockLazySource_IndexFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexFiles'
188+type MockLazySource_IndexFiles_Call struct {
189+ *mock.Call
190+}
191+
192+// IndexFiles is a helper method to define mock.On call
193+// - paths []string
194+func (_e *MockLazySource_Expecter) IndexFiles(paths any) *MockLazySource_IndexFiles_Call {
195+ return &MockLazySource_IndexFiles_Call{Call: _e.mock.On("IndexFiles", paths)}
196+}
197+
198+func (_c *MockLazySource_IndexFiles_Call) Run(run func(paths []string)) *MockLazySource_IndexFiles_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 []string
201+ if args[0] != nil {
202+ arg0 = args[0].([]string)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockLazySource_IndexFiles_Call) Return(fontFiles []FontFile, err error) *MockLazySource_IndexFiles_Call {
212+ _c.Call.Return(fontFiles, err)
213+ return _c
214+}
215+
216+func (_c *MockLazySource_IndexFiles_Call) RunAndReturn(run func(paths []string) ([]FontFile, error)) *MockLazySource_IndexFiles_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
221+// Open provides a mock function for the type MockLazySource
222+func (_mock *MockLazySource) Open(path string) (io.ReadCloser, error) {
223+ ret := _mock.Called(path)
224+
225+ if len(ret) == 0 {
226+ panic("no return value specified for Open")
227+ }
228+
229+ var r0 io.ReadCloser
230+ var r1 error
231+ if returnFunc, ok := ret.Get(0).(func(string) (io.ReadCloser, error)); ok {
232+ return returnFunc(path)
233+ }
234+ if returnFunc, ok := ret.Get(0).(func(string) io.ReadCloser); ok {
235+ r0 = returnFunc(path)
236+ } else {
237+ if ret.Get(0) != nil {
238+ r0 = ret.Get(0).(io.ReadCloser)
239+ }
240+ }
241+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
242+ r1 = returnFunc(path)
243+ } else {
244+ r1 = ret.Error(1)
245+ }
246+ return r0, r1
247+}
248+
249+// MockLazySource_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
250+type MockLazySource_Open_Call struct {
251+ *mock.Call
252+}
253+
254+// Open is a helper method to define mock.On call
255+// - path string
256+func (_e *MockLazySource_Expecter) Open(path any) *MockLazySource_Open_Call {
257+ return &MockLazySource_Open_Call{Call: _e.mock.On("Open", path)}
258+}
259+
260+func (_c *MockLazySource_Open_Call) Run(run func(path string)) *MockLazySource_Open_Call {
261+ _c.Call.Run(func(args mock.Arguments) {
262+ var arg0 string
263+ if args[0] != nil {
264+ arg0 = args[0].(string)
265+ }
266+ run(
267+ arg0,
268+ )
269+ })
270+ return _c
271+}
272+
273+func (_c *MockLazySource_Open_Call) Return(readCloser io.ReadCloser, err error) *MockLazySource_Open_Call {
274+ _c.Call.Return(readCloser, err)
275+ return _c
276+}
277+
278+func (_c *MockLazySource_Open_Call) RunAndReturn(run func(path string) (io.ReadCloser, error)) *MockLazySource_Open_Call {
279+ _c.Call.Return(run)
280+ return _c
281+}
282+
283+// Paths provides a mock function for the type MockLazySource
284+func (_mock *MockLazySource) Paths() ([]string, error) {
285+ ret := _mock.Called()
286+
287+ if len(ret) == 0 {
288+ panic("no return value specified for Paths")
289+ }
290+
291+ var r0 []string
292+ var r1 error
293+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
294+ return returnFunc()
295+ }
296+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
297+ r0 = returnFunc()
298+ } else {
299+ if ret.Get(0) != nil {
300+ r0 = ret.Get(0).([]string)
301+ }
302+ }
303+ if returnFunc, ok := ret.Get(1).(func() error); ok {
304+ r1 = returnFunc()
305+ } else {
306+ r1 = ret.Error(1)
307+ }
308+ return r0, r1
309+}
310+
311+// MockLazySource_Paths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Paths'
312+type MockLazySource_Paths_Call struct {
313+ *mock.Call
314+}
315+
316+// Paths is a helper method to define mock.On call
317+func (_e *MockLazySource_Expecter) Paths() *MockLazySource_Paths_Call {
318+ return &MockLazySource_Paths_Call{Call: _e.mock.On("Paths")}
319+}
320+
321+func (_c *MockLazySource_Paths_Call) Run(run func()) *MockLazySource_Paths_Call {
322+ _c.Call.Run(func(args mock.Arguments) {
323+ run()
324+ })
325+ return _c
326+}
327+
328+func (_c *MockLazySource_Paths_Call) Return(strings []string, err error) *MockLazySource_Paths_Call {
329+ _c.Call.Return(strings, err)
330+ return _c
331+}
332+
333+func (_c *MockLazySource_Paths_Call) RunAndReturn(run func() ([]string, error)) *MockLazySource_Paths_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
A internal/text/font_lazy_index_test.go +139 -0
@@ -0,0 +1,139 @@
1+package text
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "slices"
9+ "testing"
10+
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// lazyFixture is a LazySource over the test fonts, with extra paths listed
15+// before them that index to nothing. indexed records every path handed to
16+// IndexFiles.
17+func lazyFixture(t *testing.T, extra ...string) (*MockLazySource, *[]string) {
18+ t.Helper()
19+ dir := "../../testdata/fonts"
20+ real, err := filepath.Glob(filepath.Join(dir, "*.ttf"))
21+ if err != nil || len(real) == 0 {
22+ t.Fatalf("fixture fonts: %v", err)
23+ }
24+ paths := append(slices.Clone(extra), real...)
25+ var indexed []string
26+ src := NewMockLazySource(t)
27+ src.EXPECT().Paths().Return(paths, nil)
28+ src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
29+ indexed = append(indexed, ps...)
30+ var real []string
31+ for _, p := range ps {
32+ if !slices.Contains(extra, p) {
33+ real = append(real, p)
34+ }
35+ }
36+ return SystemSource{}.IndexFiles(real)
37+ }).Maybe()
38+ src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
39+ b, err := os.ReadFile(p)
40+ return io.NopCloser(bytes.NewReader(b)), err
41+ }).Maybe()
42+ return src, &indexed
43+}
44+
45+func base(paths []string) []string {
46+ out := make([]string, len(paths))
47+ for i, p := range paths {
48+ out[i] = filepath.Base(p)
49+ }
50+ slices.Sort(out)
51+ return out
52+}
53+
54+func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
55+ src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
56+ c, err := Load(src)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0)
61+ if err != nil || l.Width == 0 {
62+ t.Fatalf("measure: %v %v", err, c.Diagnostics)
63+ }
64+ // monospace names no fixture family; the sans-serif and script fallbacks do.
65+ want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
66+ if got := base(*indexed); !slices.Equal(got, want) {
67+ t.Fatalf("indexed %v, want %v", got, want)
68+ }
69+ if c.complete {
70+ t.Fatal("catalog fully indexed for a covered request")
71+ }
72+}
73+
74+func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
75+ src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
76+ c, err := Load(src)
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ // The file name does not reveal the family: only a full scan can find it.
81+ if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil {
82+ t.Fatal(err)
83+ }
84+ if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") {
85+ t.Fatalf("unknown family did not index every file: %v", *indexed)
86+ }
87+}
88+
89+func TestLazyIndexFallbackGlyph(t *testing.T) {
90+ src, _ := lazyFixture(t)
91+ c, err := Load(src)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ run := l.Lines[0].Runs[0]
100+ if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing {
101+ t.Fatalf("fallback face %q", run.Face.Family)
102+ }
103+}
104+
105+// Indexing in steps keeps the catalog order of a full index.
106+func TestLazyIndexKeepsPathOrder(t *testing.T) {
107+ src, _ := lazyFixture(t)
108+ c, err := Load(src)
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ c.indexAll()
113+ paths, _ := src.Paths()
114+ full, err := SystemSource{}.IndexFiles(paths)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if len(full) != len(c.Faces) {
119+ t.Fatalf("%d faces, want %d", len(c.Faces), len(full))
120+ }
121+ for i, f := range c.Faces {
122+ if f.path != full[i].Path || f.index != full[i].Index {
123+ t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index)
124+ }
125+ }
126+}
127+
128+func TestFileStem(t *testing.T) {
129+ for path, want := range map[string]string{
130+ "/f/NotoSans-Bold.ttf": "notosans",
131+ "/f/NotoSans[wght].ttf": "notosans",
132+ "/f/DejaVuSansMono.ttf": "dejavusansmono",
133+ "/f/IBMPlexMono_Regular.otf": "ibmplexmono",
134+ } {
135+ if got := fileStem(path); got != want {
136+ t.Errorf("%s: %q, want %q", path, got, want)
137+ }
138+ }
139+}
@@ -0,0 +1,139 @@
1+package text
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "slices"
9+ "testing"
10+
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// lazyFixture is a LazySource over the test fonts, with extra paths listed
15+// before them that index to nothing. indexed records every path handed to
16+// IndexFiles.
17+func lazyFixture(t *testing.T, extra ...string) (*MockLazySource, *[]string) {
18+ t.Helper()
19+ dir := "../../testdata/fonts"
20+ real, err := filepath.Glob(filepath.Join(dir, "*.ttf"))
21+ if err != nil || len(real) == 0 {
22+ t.Fatalf("fixture fonts: %v", err)
23+ }
24+ paths := append(slices.Clone(extra), real...)
25+ var indexed []string
26+ src := NewMockLazySource(t)
27+ src.EXPECT().Paths().Return(paths, nil)
28+ src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
29+ indexed = append(indexed, ps...)
30+ var real []string
31+ for _, p := range ps {
32+ if !slices.Contains(extra, p) {
33+ real = append(real, p)
34+ }
35+ }
36+ return SystemSource{}.IndexFiles(real)
37+ }).Maybe()
38+ src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
39+ b, err := os.ReadFile(p)
40+ return io.NopCloser(bytes.NewReader(b)), err
41+ }).Maybe()
42+ return src, &indexed
43+}
44+
45+func base(paths []string) []string {
46+ out := make([]string, len(paths))
47+ for i, p := range paths {
48+ out[i] = filepath.Base(p)
49+ }
50+ slices.Sort(out)
51+ return out
52+}
53+
54+func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
55+ src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
56+ c, err := Load(src)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0)
61+ if err != nil || l.Width == 0 {
62+ t.Fatalf("measure: %v %v", err, c.Diagnostics)
63+ }
64+ // monospace names no fixture family; the sans-serif and script fallbacks do.
65+ want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
66+ if got := base(*indexed); !slices.Equal(got, want) {
67+ t.Fatalf("indexed %v, want %v", got, want)
68+ }
69+ if c.complete {
70+ t.Fatal("catalog fully indexed for a covered request")
71+ }
72+}
73+
74+func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
75+ src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
76+ c, err := Load(src)
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ // The file name does not reveal the family: only a full scan can find it.
81+ if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil {
82+ t.Fatal(err)
83+ }
84+ if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") {
85+ t.Fatalf("unknown family did not index every file: %v", *indexed)
86+ }
87+}
88+
89+func TestLazyIndexFallbackGlyph(t *testing.T) {
90+ src, _ := lazyFixture(t)
91+ c, err := Load(src)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ run := l.Lines[0].Runs[0]
100+ if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing {
101+ t.Fatalf("fallback face %q", run.Face.Family)
102+ }
103+}
104+
105+// Indexing in steps keeps the catalog order of a full index.
106+func TestLazyIndexKeepsPathOrder(t *testing.T) {
107+ src, _ := lazyFixture(t)
108+ c, err := Load(src)
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ c.indexAll()
113+ paths, _ := src.Paths()
114+ full, err := SystemSource{}.IndexFiles(paths)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if len(full) != len(c.Faces) {
119+ t.Fatalf("%d faces, want %d", len(c.Faces), len(full))
120+ }
121+ for i, f := range c.Faces {
122+ if f.path != full[i].Path || f.index != full[i].Index {
123+ t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index)
124+ }
125+ }
126+}
127+
128+func TestFileStem(t *testing.T) {
129+ for path, want := range map[string]string{
130+ "/f/NotoSans-Bold.ttf": "notosans",
131+ "/f/NotoSans[wght].ttf": "notosans",
132+ "/f/DejaVuSansMono.ttf": "dejavusansmono",
133+ "/f/IBMPlexMono_Regular.otf": "ibmplexmono",
134+ } {
135+ if got := fileStem(path); got != want {
136+ t.Errorf("%s: %q, want %q", path, got, want)
137+ }
138+ }
139+}
M internal/text/fonts.go +250 -49
@@ -16,6 +16,7 @@ import (
1616 "slices"
1717 "sort"
1818 "strings"
19+ "sync"
1920
2021 "github.com/bnema/nefergui/internal/bidi"
2122 "github.com/go-text/typesetting/font"
@@ -156,6 +157,16 @@ type IndexSource interface {
156157 Open(path string) (io.ReadCloser, error)
157158 }
158159
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.
163+type LazySource interface {
164+ FontSource
165+ IndexSource
166+ Paths() ([]string, error)
167+ IndexFiles(paths []string) ([]FontFile, error)
168+}
169+
159170 type FontFile struct {
160171 Path string
161172 Family string
@@ -195,59 +206,89 @@ func (m *metadataReader) Read(p []byte) (int, error) {
195206
196207 func (s SystemSource) Index() ([]FontFile, error) {
197208 paths, err := s.paths()
198- var files []FontFile
199- var buffer []byte
200- for _, p := range paths {
201- f, e := os.Open(p)
202- if e != nil {
203- err = errors.Join(err, fmt.Errorf("font %s: %w", p, e))
204- continue
205- }
206- info, statErr := f.Stat()
207- if statErr != nil || info.Size() > maxFontBytes {
208- if statErr == nil {
209- statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
210- }
211- err = errors.Join(err, fmt.Errorf("font %s: %w", p, statErr))
212- if closeErr := f.Close(); closeErr != nil {
213- err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
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.
214+func (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.
218+const 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.
222+func (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] = indexFile(paths[i], buffer)
214234 }
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+// indexFile reads the metadata of every face in the file at p. buffer is
246+// reusable scratch storage.
247+func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
248+ f, e := os.Open(p)
249+ if e != nil {
250+ return nil, buffer, fmt.Errorf("font %s: %w", p, e)
251+ }
252+ defer func() {
253+ if closeErr := f.Close(); closeErr != nil {
254+ err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
255+ }
256+ }()
257+ info, statErr := f.Stat()
258+ if statErr != nil || info.Size() > maxFontBytes {
259+ if statErr == nil {
260+ statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
261+ }
262+ return nil, buffer, fmt.Errorf("font %s: %w", p, statErr)
263+ }
264+ reader := &metadataReader{File: f, remaining: maxMetadataBytes}
265+ loaders, e := ot.NewLoaders(reader)
266+ if e != nil {
267+ return nil, buffer, fmt.Errorf("font %s: %w", p, e)
268+ }
269+ for i, loader := range loaders {
270+ var desc font.Description
271+ // Describe ignores table read errors; a failed bounded read yields no
272+ // usable family and is excluded from the index.
273+ desc, buffer = font.Describe(loader, buffer)
274+ if reader.exceeded {
275+ return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
276+ }
277+ if desc.Family == "" {
215278 continue
216279 }
217- reader := &metadataReader{File: f, remaining: maxMetadataBytes}
218- loaders, e := ot.NewLoaders(reader)
219- if e == nil {
220- for i, loader := range loaders {
221- var desc font.Description
222- // Describe ignores table read errors; a failed bounded read
223- // yields no usable family and is excluded from the index.
224- desc, buffer = font.Describe(loader, buffer)
225- if reader.exceeded {
226- err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i))
227- break
228- }
229- if desc.Family != "" {
230- entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
231- if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
232- for j := range entry.UnicodeRanges {
233- entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
234- }
235- }
236- if reader.exceeded {
237- err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i))
238- break
239- }
240- files = append(files, entry)
241- }
280+ entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
281+ if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
282+ for j := range entry.UnicodeRanges {
283+ entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
242284 }
243- } else {
244- err = errors.Join(err, fmt.Errorf("font %s: %w", p, e))
245285 }
246- if closeErr := f.Close(); closeErr != nil {
247- err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
286+ if reader.exceeded {
287+ return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
248288 }
289+ files = append(files, entry)
249290 }
250- return files, err
291+ return files, buffer, nil
251292 }
252293 func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }
253294
@@ -283,9 +324,17 @@ type Catalog struct {
283324 Faces []*Face
284325 source IndexSource
285326 Diagnostics []error
286- // ranked memoizes candidates per request; a face failure clears it,
287- // because failed faces are the only catalog state match depends on.
327+ // ranked memoizes candidates per request; a face failure or newly
328+ // indexed faces clear it, because match depends only on them.
288329 ranked []rankedFaces
330+
331+ // A LazySource catalog indexes files on demand. Faces stay in path order.
332+ lazy LazySource
333+ paths []string
334+ indexed []bool
335+ faceAt [][]*Face // faces of each path
336+ byStem map[string][]int // normalized file-name stem to path positions
337+ complete bool // every path is indexed
289338 }
290339
291340 // maxRanked bounds the candidates memo; distinct font requests per
@@ -299,7 +348,108 @@ type rankedFaces struct {
299348 faces []*Face
300349 }
301350
351+// loadLazy lists the files and indexes only those named after the default
352+// families, so a catalog is usable without reading every installed font.
353+func loadLazy(source LazySource) (*Catalog, error) {
354+ paths, err := source.Paths()
355+ c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}}
356+ if err != nil {
357+ c.Diagnostics = append(c.Diagnostics, err)
358+ }
359+ for i, p := range paths {
360+ key := fileStem(p)
361+ c.byStem[key] = append(c.byStem[key], i)
362+ }
363+ c.indexStems(genericKeys["sans-serif"])
364+ if len(c.Faces) == 0 {
365+ c.indexAll()
366+ }
367+ if len(c.Faces) == 0 {
368+ return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...))
369+ }
370+ return c, nil
371+}
372+
373+// fileStem is the normalized family part of a font file name: the name up to
374+// the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf
375+// both give "notosans", the key of the "Noto Sans" family.
376+func fileStem(path string) string {
377+ name := filepath.Base(path)
378+ if i := strings.IndexAny(name, "-[_."); i >= 0 {
379+ name = name[:i]
380+ }
381+ return font.NormalizeFamily(name)
382+}
383+
384+// indexStems indexes the files whose stem is one of keys.
385+func (c *Catalog) indexStems(keys []string) {
386+ var todo []int
387+ for _, key := range keys {
388+ for _, i := range c.byStem[key] {
389+ if !c.indexed[i] && !slices.Contains(todo, i) {
390+ todo = append(todo, i)
391+ }
392+ }
393+ }
394+ c.indexPositions(todo)
395+}
396+
397+// indexAll indexes every remaining file, once.
398+func (c *Catalog) indexAll() {
399+ if c.complete {
400+ return
401+ }
402+ var todo []int
403+ for i, done := range c.indexed {
404+ if !done {
405+ todo = append(todo, i)
406+ }
407+ }
408+ c.indexPositions(todo)
409+ c.complete = true
410+}
411+
412+// indexPositions indexes the files at the given path positions and rebuilds
413+// Faces in path order, keeping existing faces, so ties resolve as with a full
414+// index.
415+func (c *Catalog) indexPositions(todo []int) {
416+ if len(todo) == 0 {
417+ return
418+ }
419+ slices.Sort(todo)
420+ paths := make([]string, len(todo))
421+ for j, i := range todo {
422+ paths[j] = c.paths[i]
423+ c.indexed[i] = true
424+ }
425+ entries, err := c.lazy.IndexFiles(paths)
426+ if err != nil {
427+ c.Diagnostics = append(c.Diagnostics, err)
428+ }
429+ position := make(map[string]int, len(todo))
430+ for _, i := range todo {
431+ position[c.paths[i]] = i
432+ }
433+ for _, entry := range entries {
434+ i, ok := position[entry.Path]
435+ if !ok {
436+ continue
437+ }
438+ c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
439+ }
440+ c.Faces = slices.Concat(c.faceAt...)
441+ clear(c.ranked)
442+ c.ranked = c.ranked[:0]
443+}
444+
445+func (c *Catalog) newFace(entry FontFile) *Face {
446+ 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}
447+}
448+
302449 func Load(source FontSource) (*Catalog, error) {
450+ if lazy, ok := source.(LazySource); ok {
451+ return loadLazy(lazy)
452+ }
303453 if indexed, ok := source.(IndexSource); ok {
304454 entries, err := indexed.Index()
305455 c := &Catalog{source: indexed}
@@ -307,7 +457,7 @@ func Load(source FontSource) (*Catalog, error) {
307457 c.Diagnostics = append(c.Diagnostics, err)
308458 }
309459 for _, entry := range entries {
310- c.Faces = append(c.Faces, &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})
460+ c.Faces = append(c.Faces, c.newFace(entry))
311461 }
312462 if len(c.Faces) == 0 {
313463 return nil, fmt.Errorf("no usable fonts: %w", err)
@@ -544,6 +694,53 @@ func (c *Catalog) candidates(r Request) []*Face {
544694 }
545695
546696 func (c *Catalog) rank(r Request) []*Face {
697+ if c.lazy != nil && !c.complete {
698+ if out, ok := c.rankIndexed(r); ok {
699+ return out
700+ }
701+ c.indexAll()
702+ }
703+ return c.rankFaces(r)
704+}
705+
706+// rankIndexed indexes the files named after the request's families and ranks
707+// them. ok is false when the full index is needed: an explicitly named family
708+// matches no file name, or nothing at all matches.
709+func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) {
710+ var keys []string
711+ explicit := map[string]bool{}
712+ for _, family := range r.Families {
713+ family = strings.Trim(family, " \t\"'")
714+ if alias, ok := genericKeys[strings.ToLower(family)]; ok {
715+ keys = append(keys, alias...)
716+ } else {
717+ key := font.NormalizeFamily(family)
718+ keys = append(keys, key)
719+ explicit[key] = true
720+ }
721+ }
722+ keys = append(keys, genericKeys["sans-serif"]...)
723+ keys = append(keys, scriptFallbackKeys...)
724+ c.indexStems(keys)
725+ for key := range explicit {
726+ if !c.hasFamily(key) {
727+ return nil, false
728+ }
729+ }
730+ out = c.rankFaces(r)
731+ return out, len(out) > 0
732+}
733+
734+func (c *Catalog) hasFamily(key string) bool {
735+ for _, f := range c.Faces {
736+ if f.familyKey == key {
737+ return true
738+ }
739+ }
740+ return false
741+}
742+
743+func (c *Catalog) rankFaces(r Request) []*Face {
547744 seen := map[*Face]bool{}
548745 var out []*Face
549746 add := func(f *Face) {
@@ -654,6 +851,10 @@ func namedKeys(r Request) map[string]bool {
654851 // unknown range data remain eligible; loaded or failed state never moves the
655852 // window, so later measures cannot admit a different face.
656853 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
854+ if c.lazy != nil {
855+ // Any installed face may cover a missing glyph.
856+ c.indexAll()
857+ }
657858 named := namedKeys(r)
658859 out := make([]*Face, 0, maxFallbackFaces)
659860 for _, f := range c.Faces {
@@ -16,6 +16,7 @@ import (
16 "slices"16 "slices"
17 "sort"17 "sort"
18 "strings"18 "strings"
19+ "sync"
19 20
20 "github.com/bnema/nefergui/internal/bidi"21 "github.com/bnema/nefergui/internal/bidi"
21 "github.com/go-text/typesetting/font"22 "github.com/go-text/typesetting/font"
@@ -156,6 +157,16 @@ type IndexSource interface {
156 Open(path string) (io.ReadCloser, error)157 Open(path string) (io.ReadCloser, error)
157 }158 }
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.
163+type LazySource interface {
164+ FontSource
165+ IndexSource
166+ Paths() ([]string, error)
167+ IndexFiles(paths []string) ([]FontFile, error)
168+}
169+
159 type FontFile struct {170 type FontFile struct {
160 Path string171 Path string
161 Family string172 Family string
@@ -195,59 +206,89 @@ func (m *metadataReader) Read(p []byte) (int, error) {
195 206
196 func (s SystemSource) Index() ([]FontFile, error) {207 func (s SystemSource) Index() ([]FontFile, error) {
197 paths, err := s.paths()208 paths, err := s.paths()
198- var files []FontFile209+ files, e := s.IndexFiles(paths)
199- var buffer []byte210+ return files, errors.Join(err, e)
200- for _, p := range paths {211+}
201- f, e := os.Open(p)212+
202- if e != nil {213+// Paths lists the font files in catalog order without reading them.
203- err = errors.Join(err, fmt.Errorf("font %s: %w", p, e))214+func (s SystemSource) Paths() ([]string, error) { return s.paths() }
204- continue215+
205- }216+// indexWorkers bounds the files read at once. Parallel reads hide the latency
206- info, statErr := f.Stat()217+// of a cold page cache, which dominates on encrypted disks.
207- if statErr != nil || info.Size() > maxFontBytes {218+const indexWorkers = 32
208- if statErr == nil {219+
209- statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)220+// IndexFiles reads the metadata of paths in parallel and returns the entries
210- }221+// in path order. Every worker ends before it returns.
211- err = errors.Join(err, fmt.Errorf("font %s: %w", p, statErr))222+func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
212- if closeErr := f.Close(); closeErr != nil {223+ files := make([][]FontFile, len(paths))
213- err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))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] = indexFile(paths[i], buffer)
214 }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+// indexFile reads the metadata of every face in the file at p. buffer is
246+// reusable scratch storage.
247+func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
248+ f, e := os.Open(p)
249+ if e != nil {
250+ return nil, buffer, fmt.Errorf("font %s: %w", p, e)
251+ }
252+ defer func() {
253+ if closeErr := f.Close(); closeErr != nil {
254+ err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
255+ }
256+ }()
257+ info, statErr := f.Stat()
258+ if statErr != nil || info.Size() > maxFontBytes {
259+ if statErr == nil {
260+ statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
261+ }
262+ return nil, buffer, fmt.Errorf("font %s: %w", p, statErr)
263+ }
264+ reader := &metadataReader{File: f, remaining: maxMetadataBytes}
265+ loaders, e := ot.NewLoaders(reader)
266+ if e != nil {
267+ return nil, buffer, fmt.Errorf("font %s: %w", p, e)
268+ }
269+ for i, loader := range loaders {
270+ var desc font.Description
271+ // Describe ignores table read errors; a failed bounded read yields no
272+ // usable family and is excluded from the index.
273+ desc, buffer = font.Describe(loader, buffer)
274+ if reader.exceeded {
275+ return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
276+ }
277+ if desc.Family == "" {
215 continue278 continue
216 }279 }
217- reader := &metadataReader{File: f, remaining: maxMetadataBytes}280+ entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
218- loaders, e := ot.NewLoaders(reader)281+ if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
219- if e == nil {282+ for j := range entry.UnicodeRanges {
220- for i, loader := range loaders {283+ entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
221- var desc font.Description
222- // Describe ignores table read errors; a failed bounded read
223- // yields no usable family and is excluded from the index.
224- desc, buffer = font.Describe(loader, buffer)
225- if reader.exceeded {
226- err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i))
227- break
228- }
229- if desc.Family != "" {
230- entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
231- if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
232- for j := range entry.UnicodeRanges {
233- entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
234- }
235- }
236- if reader.exceeded {
237- err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i))
238- break
239- }
240- files = append(files, entry)
241- }
242 }284 }
243- } else {
244- err = errors.Join(err, fmt.Errorf("font %s: %w", p, e))
245 }285 }
246- if closeErr := f.Close(); closeErr != nil {286+ if reader.exceeded {
247- err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))287+ return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
248 }288 }
289+ files = append(files, entry)
249 }290 }
250- return files, err291+ return files, buffer, nil
251 }292 }
252 func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }293 func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }
253 294
@@ -283,9 +324,17 @@ type Catalog struct {
283 Faces []*Face324 Faces []*Face
284 source IndexSource325 source IndexSource
285 Diagnostics []error326 Diagnostics []error
286- // ranked memoizes candidates per request; a face failure clears it,327+ // ranked memoizes candidates per request; a face failure or newly
287- // because failed faces are the only catalog state match depends on.328+ // indexed faces clear it, because match depends only on them.
288 ranked []rankedFaces329 ranked []rankedFaces
330+
331+ // A LazySource catalog indexes files on demand. Faces stay in path order.
332+ lazy LazySource
333+ paths []string
334+ indexed []bool
335+ faceAt [][]*Face // faces of each path
336+ byStem map[string][]int // normalized file-name stem to path positions
337+ complete bool // every path is indexed
289 }338 }
290 339
291 // maxRanked bounds the candidates memo; distinct font requests per340 // maxRanked bounds the candidates memo; distinct font requests per
@@ -299,7 +348,108 @@ type rankedFaces struct {
299 faces []*Face348 faces []*Face
300 }349 }
301 350
351+// loadLazy lists the files and indexes only those named after the default
352+// families, so a catalog is usable without reading every installed font.
353+func loadLazy(source LazySource) (*Catalog, error) {
354+ paths, err := source.Paths()
355+ c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}}
356+ if err != nil {
357+ c.Diagnostics = append(c.Diagnostics, err)
358+ }
359+ for i, p := range paths {
360+ key := fileStem(p)
361+ c.byStem[key] = append(c.byStem[key], i)
362+ }
363+ c.indexStems(genericKeys["sans-serif"])
364+ if len(c.Faces) == 0 {
365+ c.indexAll()
366+ }
367+ if len(c.Faces) == 0 {
368+ return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...))
369+ }
370+ return c, nil
371+}
372+
373+// fileStem is the normalized family part of a font file name: the name up to
374+// the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf
375+// both give "notosans", the key of the "Noto Sans" family.
376+func fileStem(path string) string {
377+ name := filepath.Base(path)
378+ if i := strings.IndexAny(name, "-[_."); i >= 0 {
379+ name = name[:i]
380+ }
381+ return font.NormalizeFamily(name)
382+}
383+
384+// indexStems indexes the files whose stem is one of keys.
385+func (c *Catalog) indexStems(keys []string) {
386+ var todo []int
387+ for _, key := range keys {
388+ for _, i := range c.byStem[key] {
389+ if !c.indexed[i] && !slices.Contains(todo, i) {
390+ todo = append(todo, i)
391+ }
392+ }
393+ }
394+ c.indexPositions(todo)
395+}
396+
397+// indexAll indexes every remaining file, once.
398+func (c *Catalog) indexAll() {
399+ if c.complete {
400+ return
401+ }
402+ var todo []int
403+ for i, done := range c.indexed {
404+ if !done {
405+ todo = append(todo, i)
406+ }
407+ }
408+ c.indexPositions(todo)
409+ c.complete = true
410+}
411+
412+// indexPositions indexes the files at the given path positions and rebuilds
413+// Faces in path order, keeping existing faces, so ties resolve as with a full
414+// index.
415+func (c *Catalog) indexPositions(todo []int) {
416+ if len(todo) == 0 {
417+ return
418+ }
419+ slices.Sort(todo)
420+ paths := make([]string, len(todo))
421+ for j, i := range todo {
422+ paths[j] = c.paths[i]
423+ c.indexed[i] = true
424+ }
425+ entries, err := c.lazy.IndexFiles(paths)
426+ if err != nil {
427+ c.Diagnostics = append(c.Diagnostics, err)
428+ }
429+ position := make(map[string]int, len(todo))
430+ for _, i := range todo {
431+ position[c.paths[i]] = i
432+ }
433+ for _, entry := range entries {
434+ i, ok := position[entry.Path]
435+ if !ok {
436+ continue
437+ }
438+ c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
439+ }
440+ c.Faces = slices.Concat(c.faceAt...)
441+ clear(c.ranked)
442+ c.ranked = c.ranked[:0]
443+}
444+
445+func (c *Catalog) newFace(entry FontFile) *Face {
446+ 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}
447+}
448+
302 func Load(source FontSource) (*Catalog, error) {449 func Load(source FontSource) (*Catalog, error) {
450+ if lazy, ok := source.(LazySource); ok {
451+ return loadLazy(lazy)
452+ }
303 if indexed, ok := source.(IndexSource); ok {453 if indexed, ok := source.(IndexSource); ok {
304 entries, err := indexed.Index()454 entries, err := indexed.Index()
305 c := &Catalog{source: indexed}455 c := &Catalog{source: indexed}
@@ -307,7 +457,7 @@ func Load(source FontSource) (*Catalog, error) {
307 c.Diagnostics = append(c.Diagnostics, err)457 c.Diagnostics = append(c.Diagnostics, err)
308 }458 }
309 for _, entry := range entries {459 for _, entry := range entries {
310- c.Faces = append(c.Faces, &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})460+ c.Faces = append(c.Faces, c.newFace(entry))
311 }461 }
312 if len(c.Faces) == 0 {462 if len(c.Faces) == 0 {
313 return nil, fmt.Errorf("no usable fonts: %w", err)463 return nil, fmt.Errorf("no usable fonts: %w", err)
@@ -544,6 +694,53 @@ func (c *Catalog) candidates(r Request) []*Face {
544 }694 }
545 695
546 func (c *Catalog) rank(r Request) []*Face {696 func (c *Catalog) rank(r Request) []*Face {
697+ if c.lazy != nil && !c.complete {
698+ if out, ok := c.rankIndexed(r); ok {
699+ return out
700+ }
701+ c.indexAll()
702+ }
703+ return c.rankFaces(r)
704+}
705+
706+// rankIndexed indexes the files named after the request's families and ranks
707+// them. ok is false when the full index is needed: an explicitly named family
708+// matches no file name, or nothing at all matches.
709+func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) {
710+ var keys []string
711+ explicit := map[string]bool{}
712+ for _, family := range r.Families {
713+ family = strings.Trim(family, " \t\"'")
714+ if alias, ok := genericKeys[strings.ToLower(family)]; ok {
715+ keys = append(keys, alias...)
716+ } else {
717+ key := font.NormalizeFamily(family)
718+ keys = append(keys, key)
719+ explicit[key] = true
720+ }
721+ }
722+ keys = append(keys, genericKeys["sans-serif"]...)
723+ keys = append(keys, scriptFallbackKeys...)
724+ c.indexStems(keys)
725+ for key := range explicit {
726+ if !c.hasFamily(key) {
727+ return nil, false
728+ }
729+ }
730+ out = c.rankFaces(r)
731+ return out, len(out) > 0
732+}
733+
734+func (c *Catalog) hasFamily(key string) bool {
735+ for _, f := range c.Faces {
736+ if f.familyKey == key {
737+ return true
738+ }
739+ }
740+ return false
741+}
742+
743+func (c *Catalog) rankFaces(r Request) []*Face {
547 seen := map[*Face]bool{}744 seen := map[*Face]bool{}
548 var out []*Face745 var out []*Face
549 add := func(f *Face) {746 add := func(f *Face) {
@@ -654,6 +851,10 @@ func namedKeys(r Request) map[string]bool {
654 // unknown range data remain eligible; loaded or failed state never moves the851 // unknown range data remain eligible; loaded or failed state never moves the
655 // window, so later measures cannot admit a different face.852 // window, so later measures cannot admit a different face.
656 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {853 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
854+ if c.lazy != nil {
855+ // Any installed face may cover a missing glyph.
856+ c.indexAll()
857+ }
657 named := namedKeys(r)858 named := namedKeys(r)
658 out := make([]*Face, 0, maxFallbackFaces)859 out := make([]*Face, 0, maxFallbackFaces)
659 for _, f := range c.Faces {860 for _, f := range c.Faces {

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