detached 4614c6c

feat: add font availability detection on first run

commit
4614c6c40684a1850cb3161f804eb0955ffa6aad
author
brice · · 2026-01-01 14:34 UTC
parent
de42acc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +667 -0
M .mockery.yaml +1 -0
@@ -26,6 +26,7 @@ packages:
2626 TextInputTarget: {}
2727 FocusedInputProvider: {}
2828 AccentPickerUI: {}
29+ FontDetector: {}
2930 github.com/bnema/dumber/internal/domain/repository:
3031 config:
3132 dir: '{{.InterfaceDir}}/mocks'
@@ -26,6 +26,7 @@ packages:
26 TextInputTarget: {}26 TextInputTarget: {}
27 FocusedInputProvider: {}27 FocusedInputProvider: {}
28 AccentPickerUI: {}28 AccentPickerUI: {}
29+ FontDetector: {}
29 github.com/bnema/dumber/internal/domain/repository:30 github.com/bnema/dumber/internal/domain/repository:
30 config:31 config:
31 dir: '{{.InterfaceDir}}/mocks'32 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +1 -0
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88 - **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
9+- **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85.
910 - **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal.
1011
1112 ### Changed
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 - **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.8 - **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
9+- **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85.
9 - **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal.10 - **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal.
10 11
11 ### Changed12 ### Changed
A internal/application/port/font_detector.go +32 -0
@@ -0,0 +1,32 @@
1+package port
2+
3+import "context"
4+
5+// FontCategory represents a category of fonts for fallback selection.
6+type FontCategory string
7+
8+const (
9+ // FontCategorySansSerif is for sans-serif fonts (UI text).
10+ FontCategorySansSerif FontCategory = "sans-serif"
11+ // FontCategorySerif is for serif fonts (reading content).
12+ FontCategorySerif FontCategory = "serif"
13+ // FontCategoryMonospace is for monospace fonts (code, fixed-width).
14+ FontCategoryMonospace FontCategory = "monospace"
15+)
16+
17+// FontDetector detects available system fonts and selects the best
18+// font from a fallback chain. This is used during first-run config
19+// creation to ensure configured fonts are actually installed.
20+type FontDetector interface {
21+ // GetAvailableFonts returns a list of font family names installed on the system.
22+ // Returns an error if font detection is not available (e.g., fc-list missing).
23+ GetAvailableFonts(ctx context.Context) ([]string, error)
24+
25+ // SelectBestFont returns the first available font from the fallback chain,
26+ // or the generic CSS fallback (e.g., "sans-serif") if none are installed.
27+ // The category is used to determine the generic fallback.
28+ SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string
29+
30+ // IsAvailable returns true if font detection is available on this system.
31+ IsAvailable(ctx context.Context) bool
32+}
@@ -0,0 +1,32 @@
1+package port
2+
3+import "context"
4+
5+// FontCategory represents a category of fonts for fallback selection.
6+type FontCategory string
7+
8+const (
9+ // FontCategorySansSerif is for sans-serif fonts (UI text).
10+ FontCategorySansSerif FontCategory = "sans-serif"
11+ // FontCategorySerif is for serif fonts (reading content).
12+ FontCategorySerif FontCategory = "serif"
13+ // FontCategoryMonospace is for monospace fonts (code, fixed-width).
14+ FontCategoryMonospace FontCategory = "monospace"
15+)
16+
17+// FontDetector detects available system fonts and selects the best
18+// font from a fallback chain. This is used during first-run config
19+// creation to ensure configured fonts are actually installed.
20+type FontDetector interface {
21+ // GetAvailableFonts returns a list of font family names installed on the system.
22+ // Returns an error if font detection is not available (e.g., fc-list missing).
23+ GetAvailableFonts(ctx context.Context) ([]string, error)
24+
25+ // SelectBestFont returns the first available font from the fallback chain,
26+ // or the generic CSS fallback (e.g., "sans-serif") if none are installed.
27+ // The category is used to determine the generic fallback.
28+ SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string
29+
30+ // IsAvailable returns true if font detection is available on this system.
31+ IsAvailable(ctx context.Context) bool
32+}
M internal/application/port/mocks/mocks.go +203 -0
@@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
12141214 return _c
12151215 }
12161216
1217+// NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218+// The first argument is typically a *testing.T value.
1219+func NewMockFontDetector(t interface {
1220+ mock.TestingT
1221+ Cleanup(func())
1222+}) *MockFontDetector {
1223+ mock := &MockFontDetector{}
1224+ mock.Mock.Test(t)
1225+
1226+ t.Cleanup(func() { mock.AssertExpectations(t) })
1227+
1228+ return mock
1229+}
1230+
1231+// MockFontDetector is an autogenerated mock type for the FontDetector type
1232+type MockFontDetector struct {
1233+ mock.Mock
1234+}
1235+
1236+type MockFontDetector_Expecter struct {
1237+ mock *mock.Mock
1238+}
1239+
1240+func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter {
1241+ return &MockFontDetector_Expecter{mock: &_m.Mock}
1242+}
1243+
1244+// GetAvailableFonts provides a mock function for the type MockFontDetector
1245+func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) {
1246+ ret := _mock.Called(ctx)
1247+
1248+ if len(ret) == 0 {
1249+ panic("no return value specified for GetAvailableFonts")
1250+ }
1251+
1252+ var r0 []string
1253+ var r1 error
1254+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
1255+ return returnFunc(ctx)
1256+ }
1257+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
1258+ r0 = returnFunc(ctx)
1259+ } else {
1260+ if ret.Get(0) != nil {
1261+ r0 = ret.Get(0).([]string)
1262+ }
1263+ }
1264+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1265+ r1 = returnFunc(ctx)
1266+ } else {
1267+ r1 = ret.Error(1)
1268+ }
1269+ return r0, r1
1270+}
1271+
1272+// MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts'
1273+type MockFontDetector_GetAvailableFonts_Call struct {
1274+ *mock.Call
1275+}
1276+
1277+// GetAvailableFonts is a helper method to define mock.On call
1278+// - ctx context.Context
1279+func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call {
1280+ return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)}
1281+}
1282+
1283+func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call {
1284+ _c.Call.Run(func(args mock.Arguments) {
1285+ var arg0 context.Context
1286+ if args[0] != nil {
1287+ arg0 = args[0].(context.Context)
1288+ }
1289+ run(
1290+ arg0,
1291+ )
1292+ })
1293+ return _c
1294+}
1295+
1296+func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call {
1297+ _c.Call.Return(strings, err)
1298+ return _c
1299+}
1300+
1301+func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call {
1302+ _c.Call.Return(run)
1303+ return _c
1304+}
1305+
1306+// IsAvailable provides a mock function for the type MockFontDetector
1307+func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool {
1308+ ret := _mock.Called(ctx)
1309+
1310+ if len(ret) == 0 {
1311+ panic("no return value specified for IsAvailable")
1312+ }
1313+
1314+ var r0 bool
1315+ if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
1316+ r0 = returnFunc(ctx)
1317+ } else {
1318+ r0 = ret.Get(0).(bool)
1319+ }
1320+ return r0
1321+}
1322+
1323+// MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
1324+type MockFontDetector_IsAvailable_Call struct {
1325+ *mock.Call
1326+}
1327+
1328+// IsAvailable is a helper method to define mock.On call
1329+// - ctx context.Context
1330+func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call {
1331+ return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)}
1332+}
1333+
1334+func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call {
1335+ _c.Call.Run(func(args mock.Arguments) {
1336+ var arg0 context.Context
1337+ if args[0] != nil {
1338+ arg0 = args[0].(context.Context)
1339+ }
1340+ run(
1341+ arg0,
1342+ )
1343+ })
1344+ return _c
1345+}
1346+
1347+func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call {
1348+ _c.Call.Return(b)
1349+ return _c
1350+}
1351+
1352+func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call {
1353+ _c.Call.Return(run)
1354+ return _c
1355+}
1356+
1357+// SelectBestFont provides a mock function for the type MockFontDetector
1358+func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string {
1359+ ret := _mock.Called(ctx, category, fallbackChain)
1360+
1361+ if len(ret) == 0 {
1362+ panic("no return value specified for SelectBestFont")
1363+ }
1364+
1365+ var r0 string
1366+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok {
1367+ r0 = returnFunc(ctx, category, fallbackChain)
1368+ } else {
1369+ r0 = ret.Get(0).(string)
1370+ }
1371+ return r0
1372+}
1373+
1374+// MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont'
1375+type MockFontDetector_SelectBestFont_Call struct {
1376+ *mock.Call
1377+}
1378+
1379+// SelectBestFont is a helper method to define mock.On call
1380+// - ctx context.Context
1381+// - category port.FontCategory
1382+// - fallbackChain []string
1383+func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call {
1384+ return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)}
1385+}
1386+
1387+func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call {
1388+ _c.Call.Run(func(args mock.Arguments) {
1389+ var arg0 context.Context
1390+ if args[0] != nil {
1391+ arg0 = args[0].(context.Context)
1392+ }
1393+ var arg1 port.FontCategory
1394+ if args[1] != nil {
1395+ arg1 = args[1].(port.FontCategory)
1396+ }
1397+ var arg2 []string
1398+ if args[2] != nil {
1399+ arg2 = args[2].([]string)
1400+ }
1401+ run(
1402+ arg0,
1403+ arg1,
1404+ arg2,
1405+ )
1406+ })
1407+ return _c
1408+}
1409+
1410+func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call {
1411+ _c.Call.Return(s)
1412+ return _c
1413+}
1414+
1415+func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call {
1416+ _c.Call.Return(run)
1417+ return _c
1418+}
1419+
12171420 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12181421 // The first argument is typically a *testing.T value.
12191422 func NewMockHardwareSurveyor(t interface {
@@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
1214 return _c1214 return _c
1215 }1215 }
1216 1216
1217+// NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218+// The first argument is typically a *testing.T value.
1219+func NewMockFontDetector(t interface {
1220+ mock.TestingT
1221+ Cleanup(func())
1222+}) *MockFontDetector {
1223+ mock := &MockFontDetector{}
1224+ mock.Mock.Test(t)
1225+
1226+ t.Cleanup(func() { mock.AssertExpectations(t) })
1227+
1228+ return mock
1229+}
1230+
1231+// MockFontDetector is an autogenerated mock type for the FontDetector type
1232+type MockFontDetector struct {
1233+ mock.Mock
1234+}
1235+
1236+type MockFontDetector_Expecter struct {
1237+ mock *mock.Mock
1238+}
1239+
1240+func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter {
1241+ return &MockFontDetector_Expecter{mock: &_m.Mock}
1242+}
1243+
1244+// GetAvailableFonts provides a mock function for the type MockFontDetector
1245+func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) {
1246+ ret := _mock.Called(ctx)
1247+
1248+ if len(ret) == 0 {
1249+ panic("no return value specified for GetAvailableFonts")
1250+ }
1251+
1252+ var r0 []string
1253+ var r1 error
1254+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
1255+ return returnFunc(ctx)
1256+ }
1257+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
1258+ r0 = returnFunc(ctx)
1259+ } else {
1260+ if ret.Get(0) != nil {
1261+ r0 = ret.Get(0).([]string)
1262+ }
1263+ }
1264+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1265+ r1 = returnFunc(ctx)
1266+ } else {
1267+ r1 = ret.Error(1)
1268+ }
1269+ return r0, r1
1270+}
1271+
1272+// MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts'
1273+type MockFontDetector_GetAvailableFonts_Call struct {
1274+ *mock.Call
1275+}
1276+
1277+// GetAvailableFonts is a helper method to define mock.On call
1278+// - ctx context.Context
1279+func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call {
1280+ return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)}
1281+}
1282+
1283+func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call {
1284+ _c.Call.Run(func(args mock.Arguments) {
1285+ var arg0 context.Context
1286+ if args[0] != nil {
1287+ arg0 = args[0].(context.Context)
1288+ }
1289+ run(
1290+ arg0,
1291+ )
1292+ })
1293+ return _c
1294+}
1295+
1296+func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call {
1297+ _c.Call.Return(strings, err)
1298+ return _c
1299+}
1300+
1301+func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call {
1302+ _c.Call.Return(run)
1303+ return _c
1304+}
1305+
1306+// IsAvailable provides a mock function for the type MockFontDetector
1307+func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool {
1308+ ret := _mock.Called(ctx)
1309+
1310+ if len(ret) == 0 {
1311+ panic("no return value specified for IsAvailable")
1312+ }
1313+
1314+ var r0 bool
1315+ if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
1316+ r0 = returnFunc(ctx)
1317+ } else {
1318+ r0 = ret.Get(0).(bool)
1319+ }
1320+ return r0
1321+}
1322+
1323+// MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
1324+type MockFontDetector_IsAvailable_Call struct {
1325+ *mock.Call
1326+}
1327+
1328+// IsAvailable is a helper method to define mock.On call
1329+// - ctx context.Context
1330+func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call {
1331+ return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)}
1332+}
1333+
1334+func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call {
1335+ _c.Call.Run(func(args mock.Arguments) {
1336+ var arg0 context.Context
1337+ if args[0] != nil {
1338+ arg0 = args[0].(context.Context)
1339+ }
1340+ run(
1341+ arg0,
1342+ )
1343+ })
1344+ return _c
1345+}
1346+
1347+func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call {
1348+ _c.Call.Return(b)
1349+ return _c
1350+}
1351+
1352+func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call {
1353+ _c.Call.Return(run)
1354+ return _c
1355+}
1356+
1357+// SelectBestFont provides a mock function for the type MockFontDetector
1358+func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string {
1359+ ret := _mock.Called(ctx, category, fallbackChain)
1360+
1361+ if len(ret) == 0 {
1362+ panic("no return value specified for SelectBestFont")
1363+ }
1364+
1365+ var r0 string
1366+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok {
1367+ r0 = returnFunc(ctx, category, fallbackChain)
1368+ } else {
1369+ r0 = ret.Get(0).(string)
1370+ }
1371+ return r0
1372+}
1373+
1374+// MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont'
1375+type MockFontDetector_SelectBestFont_Call struct {
1376+ *mock.Call
1377+}
1378+
1379+// SelectBestFont is a helper method to define mock.On call
1380+// - ctx context.Context
1381+// - category port.FontCategory
1382+// - fallbackChain []string
1383+func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call {
1384+ return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)}
1385+}
1386+
1387+func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call {
1388+ _c.Call.Run(func(args mock.Arguments) {
1389+ var arg0 context.Context
1390+ if args[0] != nil {
1391+ arg0 = args[0].(context.Context)
1392+ }
1393+ var arg1 port.FontCategory
1394+ if args[1] != nil {
1395+ arg1 = args[1].(port.FontCategory)
1396+ }
1397+ var arg2 []string
1398+ if args[2] != nil {
1399+ arg2 = args[2].([]string)
1400+ }
1401+ run(
1402+ arg0,
1403+ arg1,
1404+ arg2,
1405+ )
1406+ })
1407+ return _c
1408+}
1409+
1410+func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call {
1411+ _c.Call.Return(s)
1412+ return _c
1413+}
1414+
1415+func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call {
1416+ _c.Call.Return(run)
1417+ return _c
1418+}
1419+
1217 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1420 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218 // The first argument is typically a *testing.T value.1421 // The first argument is typically a *testing.T value.
1219 func NewMockHardwareSurveyor(t interface {1422 func NewMockHardwareSurveyor(t interface {
M internal/infrastructure/config/loader.go +26 -0
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "context"
45 "errors"
56 "fmt"
67 "os"
@@ -8,6 +9,8 @@ import (
89 "strings"
910 "sync"
1011
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/fonts"
1114 "github.com/spf13/viper"
1215 )
1316
@@ -302,6 +305,9 @@ func (m *Manager) createDefaultConfig() error {
302305 return err
303306 }
304307
308+ // Detect available fonts and override defaults before writing config.
309+ m.detectAndSetFonts()
310+
305311 // Ensure TOML format and write config
306312 m.viper.SetConfigType("toml")
307313 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
@@ -313,6 +319,26 @@ func (m *Manager) createDefaultConfig() error {
313319 return nil
314320 }
315321
322+// detectAndSetFonts detects available system fonts and sets the best available
323+// fonts from the fallback chains. This runs only during first-run config creation.
324+func (m *Manager) detectAndSetFonts() {
325+ ctx := context.Background()
326+ detector := fonts.NewDetector()
327+
328+ if !detector.IsAvailable(ctx) {
329+ // fc-list not available, keep hardcoded defaults
330+ return
331+ }
332+
333+ sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain)
334+ serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain)
335+ monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain)
336+
337+ m.viper.Set("appearance.sans_font", sansFont)
338+ m.viper.Set("appearance.serif_font", serifFont)
339+ m.viper.Set("appearance.monospace_font", monoFont)
340+}
341+
316342 // setDefaults sets default configuration values in Viper.
317343 func (m *Manager) setDefaults() {
318344 defaults := DefaultConfig()
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
@@ -8,6 +9,8 @@ import (
8 "strings"9 "strings"
9 "sync"10 "sync"
10 11
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/fonts"
11 "github.com/spf13/viper"14 "github.com/spf13/viper"
12 )15 )
13 16
@@ -302,6 +305,9 @@ func (m *Manager) createDefaultConfig() error {
302 return err305 return err
303 }306 }
304 307
308+ // Detect available fonts and override defaults before writing config.
309+ m.detectAndSetFonts()
310+
305 // Ensure TOML format and write config311 // Ensure TOML format and write config
306 m.viper.SetConfigType("toml")312 m.viper.SetConfigType("toml")
307 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {313 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
@@ -313,6 +319,26 @@ func (m *Manager) createDefaultConfig() error {
313 return nil319 return nil
314 }320 }
315 321
322+// detectAndSetFonts detects available system fonts and sets the best available
323+// fonts from the fallback chains. This runs only during first-run config creation.
324+func (m *Manager) detectAndSetFonts() {
325+ ctx := context.Background()
326+ detector := fonts.NewDetector()
327+
328+ if !detector.IsAvailable(ctx) {
329+ // fc-list not available, keep hardcoded defaults
330+ return
331+ }
332+
333+ sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain)
334+ serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain)
335+ monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain)
336+
337+ m.viper.Set("appearance.sans_font", sansFont)
338+ m.viper.Set("appearance.serif_font", serifFont)
339+ m.viper.Set("appearance.monospace_font", monoFont)
340+}
341+
316 // setDefaults sets default configuration values in Viper.342 // setDefaults sets default configuration values in Viper.
317 func (m *Manager) setDefaults() {343 func (m *Manager) setDefaults() {
318 defaults := DefaultConfig()344 defaults := DefaultConfig()
A internal/infrastructure/fonts/detector.go +197 -0
@@ -0,0 +1,197 @@
1+package fonts
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os/exec"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Fallback chains for each font category.
15+// The first available font in the chain will be selected.
16+var (
17+ SansSerifFallbackChain = []string{
18+ "Fira Sans",
19+ "Noto Sans",
20+ "DejaVu Sans",
21+ "Liberation Sans",
22+ "FreeSans",
23+ }
24+
25+ SerifFallbackChain = []string{
26+ "Noto Serif",
27+ "DejaVu Serif",
28+ "Liberation Serif",
29+ "FreeSerif",
30+ }
31+
32+ MonospaceFallbackChain = []string{
33+ "Fira Code",
34+ "JetBrains Mono",
35+ "Noto Sans Mono",
36+ "DejaVu Sans Mono",
37+ "Liberation Mono",
38+ "FreeMono",
39+ }
40+)
41+
42+// Generic CSS fallbacks when no fonts from the chain are found.
43+const (
44+ genericSansSerif = "sans-serif"
45+ genericSerif = "serif"
46+ genericMonospace = "monospace"
47+)
48+
49+// Detector implements port.FontDetector using fontconfig's fc-list command.
50+type Detector struct {
51+ mu sync.RWMutex
52+ cachedFonts []string
53+ cachePopulated bool
54+}
55+
56+// NewDetector creates a new font detector.
57+func NewDetector() *Detector {
58+ return &Detector{}
59+}
60+
61+// IsAvailable implements port.FontDetector.
62+// Returns true if fc-list command is available on the system.
63+func (*Detector) IsAvailable(_ context.Context) bool {
64+ _, err := exec.LookPath("fc-list")
65+ return err == nil
66+}
67+
68+// GetAvailableFonts implements port.FontDetector.
69+// Returns a list of font family names installed on the system.
70+func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) {
71+ log := logging.FromContext(ctx)
72+
73+ d.mu.RLock()
74+ if d.cachePopulated {
75+ fonts := d.cachedFonts
76+ d.mu.RUnlock()
77+ return fonts, nil
78+ }
79+ d.mu.RUnlock()
80+
81+ d.mu.Lock()
82+ defer d.mu.Unlock()
83+
84+ // Double-check after acquiring write lock.
85+ if d.cachePopulated {
86+ return d.cachedFonts, nil
87+ }
88+
89+ fonts, err := d.queryFonts(ctx)
90+ if err != nil {
91+ log.Debug().Err(err).Msg("failed to query system fonts")
92+ return nil, err
93+ }
94+
95+ d.cachedFonts = fonts
96+ d.cachePopulated = true
97+ log.Debug().Int("count", len(fonts)).Msg("cached system fonts")
98+
99+ return fonts, nil
100+}
101+
102+// SelectBestFont implements port.FontDetector.
103+// Returns the first available font from the fallback chain for the given category.
104+func (d *Detector) SelectBestFont(
105+ ctx context.Context,
106+ category port.FontCategory,
107+ fallbackChain []string,
108+) string {
109+ log := logging.FromContext(ctx)
110+
111+ availableFonts, err := d.GetAvailableFonts(ctx)
112+ if err != nil {
113+ log.Debug().
114+ Str("category", string(category)).
115+ Err(err).
116+ Msg("font detection unavailable, using generic fallback")
117+ return d.genericFallback(category)
118+ }
119+
120+ // Build a set for O(1) lookup.
121+ fontSet := make(map[string]struct{}, len(availableFonts))
122+ for _, f := range availableFonts {
123+ fontSet[f] = struct{}{}
124+ }
125+
126+ // Find the first available font in the chain.
127+ for _, font := range fallbackChain {
128+ if _, exists := fontSet[font]; exists {
129+ log.Debug().
130+ Str("category", string(category)).
131+ Str("font", font).
132+ Msg("selected font from fallback chain")
133+ return font
134+ }
135+ }
136+
137+ // No fonts from chain found, use generic fallback.
138+ generic := d.genericFallback(category)
139+ log.Debug().
140+ Str("category", string(category)).
141+ Str("fallback", generic).
142+ Msg("no fonts from fallback chain available, using generic")
143+ return generic
144+}
145+
146+// queryFonts executes fc-list and parses the output.
147+func (*Detector) queryFonts(ctx context.Context) ([]string, error) {
148+ cmd := exec.CommandContext(ctx, "fc-list", ":", "family")
149+ output, err := cmd.Output()
150+ if err != nil {
151+ return nil, err
152+ }
153+
154+ fontSet := make(map[string]struct{})
155+ scanner := bufio.NewScanner(strings.NewReader(string(output)))
156+ for scanner.Scan() {
157+ line := strings.TrimSpace(scanner.Text())
158+ if line == "" {
159+ continue
160+ }
161+ // fc-list may return comma-separated families for fonts with aliases.
162+ // e.g., "DejaVu Sans,DejaVu Sans Light"
163+ families := strings.Split(line, ",")
164+ for _, family := range families {
165+ family = strings.TrimSpace(family)
166+ if family != "" {
167+ fontSet[family] = struct{}{}
168+ }
169+ }
170+ }
171+
172+ if err := scanner.Err(); err != nil {
173+ return nil, err
174+ }
175+
176+ // Convert set to slice.
177+ fonts := make([]string, 0, len(fontSet))
178+ for font := range fontSet {
179+ fonts = append(fonts, font)
180+ }
181+
182+ return fonts, nil
183+}
184+
185+// genericFallback returns the CSS generic fallback for the given category.
186+func (*Detector) genericFallback(category port.FontCategory) string {
187+ switch category {
188+ case port.FontCategorySansSerif:
189+ return genericSansSerif
190+ case port.FontCategorySerif:
191+ return genericSerif
192+ case port.FontCategoryMonospace:
193+ return genericMonospace
194+ default:
195+ return genericSansSerif
196+ }
197+}
@@ -0,0 +1,197 @@
1+package fonts
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os/exec"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Fallback chains for each font category.
15+// The first available font in the chain will be selected.
16+var (
17+ SansSerifFallbackChain = []string{
18+ "Fira Sans",
19+ "Noto Sans",
20+ "DejaVu Sans",
21+ "Liberation Sans",
22+ "FreeSans",
23+ }
24+
25+ SerifFallbackChain = []string{
26+ "Noto Serif",
27+ "DejaVu Serif",
28+ "Liberation Serif",
29+ "FreeSerif",
30+ }
31+
32+ MonospaceFallbackChain = []string{
33+ "Fira Code",
34+ "JetBrains Mono",
35+ "Noto Sans Mono",
36+ "DejaVu Sans Mono",
37+ "Liberation Mono",
38+ "FreeMono",
39+ }
40+)
41+
42+// Generic CSS fallbacks when no fonts from the chain are found.
43+const (
44+ genericSansSerif = "sans-serif"
45+ genericSerif = "serif"
46+ genericMonospace = "monospace"
47+)
48+
49+// Detector implements port.FontDetector using fontconfig's fc-list command.
50+type Detector struct {
51+ mu sync.RWMutex
52+ cachedFonts []string
53+ cachePopulated bool
54+}
55+
56+// NewDetector creates a new font detector.
57+func NewDetector() *Detector {
58+ return &Detector{}
59+}
60+
61+// IsAvailable implements port.FontDetector.
62+// Returns true if fc-list command is available on the system.
63+func (*Detector) IsAvailable(_ context.Context) bool {
64+ _, err := exec.LookPath("fc-list")
65+ return err == nil
66+}
67+
68+// GetAvailableFonts implements port.FontDetector.
69+// Returns a list of font family names installed on the system.
70+func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) {
71+ log := logging.FromContext(ctx)
72+
73+ d.mu.RLock()
74+ if d.cachePopulated {
75+ fonts := d.cachedFonts
76+ d.mu.RUnlock()
77+ return fonts, nil
78+ }
79+ d.mu.RUnlock()
80+
81+ d.mu.Lock()
82+ defer d.mu.Unlock()
83+
84+ // Double-check after acquiring write lock.
85+ if d.cachePopulated {
86+ return d.cachedFonts, nil
87+ }
88+
89+ fonts, err := d.queryFonts(ctx)
90+ if err != nil {
91+ log.Debug().Err(err).Msg("failed to query system fonts")
92+ return nil, err
93+ }
94+
95+ d.cachedFonts = fonts
96+ d.cachePopulated = true
97+ log.Debug().Int("count", len(fonts)).Msg("cached system fonts")
98+
99+ return fonts, nil
100+}
101+
102+// SelectBestFont implements port.FontDetector.
103+// Returns the first available font from the fallback chain for the given category.
104+func (d *Detector) SelectBestFont(
105+ ctx context.Context,
106+ category port.FontCategory,
107+ fallbackChain []string,
108+) string {
109+ log := logging.FromContext(ctx)
110+
111+ availableFonts, err := d.GetAvailableFonts(ctx)
112+ if err != nil {
113+ log.Debug().
114+ Str("category", string(category)).
115+ Err(err).
116+ Msg("font detection unavailable, using generic fallback")
117+ return d.genericFallback(category)
118+ }
119+
120+ // Build a set for O(1) lookup.
121+ fontSet := make(map[string]struct{}, len(availableFonts))
122+ for _, f := range availableFonts {
123+ fontSet[f] = struct{}{}
124+ }
125+
126+ // Find the first available font in the chain.
127+ for _, font := range fallbackChain {
128+ if _, exists := fontSet[font]; exists {
129+ log.Debug().
130+ Str("category", string(category)).
131+ Str("font", font).
132+ Msg("selected font from fallback chain")
133+ return font
134+ }
135+ }
136+
137+ // No fonts from chain found, use generic fallback.
138+ generic := d.genericFallback(category)
139+ log.Debug().
140+ Str("category", string(category)).
141+ Str("fallback", generic).
142+ Msg("no fonts from fallback chain available, using generic")
143+ return generic
144+}
145+
146+// queryFonts executes fc-list and parses the output.
147+func (*Detector) queryFonts(ctx context.Context) ([]string, error) {
148+ cmd := exec.CommandContext(ctx, "fc-list", ":", "family")
149+ output, err := cmd.Output()
150+ if err != nil {
151+ return nil, err
152+ }
153+
154+ fontSet := make(map[string]struct{})
155+ scanner := bufio.NewScanner(strings.NewReader(string(output)))
156+ for scanner.Scan() {
157+ line := strings.TrimSpace(scanner.Text())
158+ if line == "" {
159+ continue
160+ }
161+ // fc-list may return comma-separated families for fonts with aliases.
162+ // e.g., "DejaVu Sans,DejaVu Sans Light"
163+ families := strings.Split(line, ",")
164+ for _, family := range families {
165+ family = strings.TrimSpace(family)
166+ if family != "" {
167+ fontSet[family] = struct{}{}
168+ }
169+ }
170+ }
171+
172+ if err := scanner.Err(); err != nil {
173+ return nil, err
174+ }
175+
176+ // Convert set to slice.
177+ fonts := make([]string, 0, len(fontSet))
178+ for font := range fontSet {
179+ fonts = append(fonts, font)
180+ }
181+
182+ return fonts, nil
183+}
184+
185+// genericFallback returns the CSS generic fallback for the given category.
186+func (*Detector) genericFallback(category port.FontCategory) string {
187+ switch category {
188+ case port.FontCategorySansSerif:
189+ return genericSansSerif
190+ case port.FontCategorySerif:
191+ return genericSerif
192+ case port.FontCategoryMonospace:
193+ return genericMonospace
194+ default:
195+ return genericSansSerif
196+ }
197+}
A internal/infrastructure/fonts/detector_test.go +207 -0
@@ -0,0 +1,207 @@
1+package fonts
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/rs/zerolog"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func testContext() context.Context {
14+ logger := zerolog.Nop()
15+ return logger.WithContext(context.Background())
16+}
17+
18+func TestDetector_IsAvailable(t *testing.T) {
19+ ctx := testContext()
20+ detector := NewDetector()
21+
22+ // fc-list should be available on most Linux systems
23+ // This test documents the behavior rather than asserting a specific value
24+ available := detector.IsAvailable(ctx)
25+ t.Logf("fc-list available: %v", available)
26+}
27+
28+func TestDetector_GetAvailableFonts(t *testing.T) {
29+ ctx := testContext()
30+ detector := NewDetector()
31+
32+ if !detector.IsAvailable(ctx) {
33+ t.Skip("fc-list not available on this system")
34+ }
35+
36+ fonts, err := detector.GetAvailableFonts(ctx)
37+ require.NoError(t, err)
38+ assert.NotEmpty(t, fonts, "expected at least some fonts to be installed")
39+
40+ t.Logf("Found %d font families", len(fonts))
41+}
42+
43+func TestDetector_GetAvailableFonts_Caching(t *testing.T) {
44+ ctx := testContext()
45+ detector := NewDetector()
46+
47+ if !detector.IsAvailable(ctx) {
48+ t.Skip("fc-list not available on this system")
49+ }
50+
51+ // First call populates cache
52+ fonts1, err := detector.GetAvailableFonts(ctx)
53+ require.NoError(t, err)
54+
55+ // Second call should return cached result
56+ fonts2, err := detector.GetAvailableFonts(ctx)
57+ require.NoError(t, err)
58+
59+ assert.Len(t, fonts2, len(fonts1), "cached result should match original")
60+}
61+
62+func TestDetector_SelectBestFont_SansSerif(t *testing.T) {
63+ ctx := testContext()
64+ detector := NewDetector()
65+
66+ if !detector.IsAvailable(ctx) {
67+ t.Skip("fc-list not available on this system")
68+ }
69+
70+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain)
71+ assert.NotEmpty(t, font)
72+
73+ // Should either be a font from the chain or the generic fallback
74+ isFromChain := false
75+ for _, f := range SansSerifFallbackChain {
76+ if font == f {
77+ isFromChain = true
78+ break
79+ }
80+ }
81+ if !isFromChain {
82+ assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available")
83+ }
84+
85+ t.Logf("Selected sans-serif font: %s", font)
86+}
87+
88+func TestDetector_SelectBestFont_Serif(t *testing.T) {
89+ ctx := testContext()
90+ detector := NewDetector()
91+
92+ if !detector.IsAvailable(ctx) {
93+ t.Skip("fc-list not available on this system")
94+ }
95+
96+ font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain)
97+ assert.NotEmpty(t, font)
98+
99+ isFromChain := false
100+ for _, f := range SerifFallbackChain {
101+ if font == f {
102+ isFromChain = true
103+ break
104+ }
105+ }
106+ if !isFromChain {
107+ assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available")
108+ }
109+
110+ t.Logf("Selected serif font: %s", font)
111+}
112+
113+func TestDetector_SelectBestFont_Monospace(t *testing.T) {
114+ ctx := testContext()
115+ detector := NewDetector()
116+
117+ if !detector.IsAvailable(ctx) {
118+ t.Skip("fc-list not available on this system")
119+ }
120+
121+ font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain)
122+ assert.NotEmpty(t, font)
123+
124+ isFromChain := false
125+ for _, f := range MonospaceFallbackChain {
126+ if font == f {
127+ isFromChain = true
128+ break
129+ }
130+ }
131+ if !isFromChain {
132+ assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available")
133+ }
134+
135+ t.Logf("Selected monospace font: %s", font)
136+}
137+
138+func TestDetector_SelectBestFont_EmptyChain(t *testing.T) {
139+ ctx := testContext()
140+ detector := NewDetector()
141+
142+ if !detector.IsAvailable(ctx) {
143+ t.Skip("fc-list not available on this system")
144+ }
145+
146+ // Empty chain should return generic fallback
147+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{})
148+ assert.Equal(t, "sans-serif", font)
149+
150+ font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{})
151+ assert.Equal(t, "serif", font)
152+
153+ font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{})
154+ assert.Equal(t, "monospace", font)
155+}
156+
157+func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) {
158+ ctx := testContext()
159+ detector := NewDetector()
160+
161+ if !detector.IsAvailable(ctx) {
162+ t.Skip("fc-list not available on this system")
163+ }
164+
165+ // Chain with fonts that definitely don't exist
166+ chain := []string{
167+ "NonExistentFont1234",
168+ "AnotherFakeFont5678",
169+ }
170+
171+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain)
172+ assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match")
173+}
174+
175+func TestDetector_genericFallback(t *testing.T) {
176+ detector := NewDetector()
177+
178+ tests := []struct {
179+ category port.FontCategory
180+ expected string
181+ }{
182+ {port.FontCategorySansSerif, "sans-serif"},
183+ {port.FontCategorySerif, "serif"},
184+ {port.FontCategoryMonospace, "monospace"},
185+ {port.FontCategory("unknown"), "sans-serif"}, // default case
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(string(tt.category), func(t *testing.T) {
190+ result := detector.genericFallback(tt.category)
191+ assert.Equal(t, tt.expected, result)
192+ })
193+ }
194+}
195+
196+func TestFallbackChains_NotEmpty(t *testing.T) {
197+ assert.NotEmpty(t, SansSerifFallbackChain, "sans-serif fallback chain should not be empty")
198+ assert.NotEmpty(t, SerifFallbackChain, "serif fallback chain should not be empty")
199+ assert.NotEmpty(t, MonospaceFallbackChain, "monospace fallback chain should not be empty")
200+}
201+
202+func TestFallbackChains_PreferredFontsFirst(t *testing.T) {
203+ // Verify preferred fonts are first in chain
204+ assert.Equal(t, "Fira Sans", SansSerifFallbackChain[0], "Fira Sans should be first in sans-serif chain")
205+ assert.Equal(t, "Noto Serif", SerifFallbackChain[0], "Noto Serif should be first in serif chain")
206+ assert.Equal(t, "Fira Code", MonospaceFallbackChain[0], "Fira Code should be first in monospace chain")
207+}
@@ -0,0 +1,207 @@
1+package fonts
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/rs/zerolog"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func testContext() context.Context {
14+ logger := zerolog.Nop()
15+ return logger.WithContext(context.Background())
16+}
17+
18+func TestDetector_IsAvailable(t *testing.T) {
19+ ctx := testContext()
20+ detector := NewDetector()
21+
22+ // fc-list should be available on most Linux systems
23+ // This test documents the behavior rather than asserting a specific value
24+ available := detector.IsAvailable(ctx)
25+ t.Logf("fc-list available: %v", available)
26+}
27+
28+func TestDetector_GetAvailableFonts(t *testing.T) {
29+ ctx := testContext()
30+ detector := NewDetector()
31+
32+ if !detector.IsAvailable(ctx) {
33+ t.Skip("fc-list not available on this system")
34+ }
35+
36+ fonts, err := detector.GetAvailableFonts(ctx)
37+ require.NoError(t, err)
38+ assert.NotEmpty(t, fonts, "expected at least some fonts to be installed")
39+
40+ t.Logf("Found %d font families", len(fonts))
41+}
42+
43+func TestDetector_GetAvailableFonts_Caching(t *testing.T) {
44+ ctx := testContext()
45+ detector := NewDetector()
46+
47+ if !detector.IsAvailable(ctx) {
48+ t.Skip("fc-list not available on this system")
49+ }
50+
51+ // First call populates cache
52+ fonts1, err := detector.GetAvailableFonts(ctx)
53+ require.NoError(t, err)
54+
55+ // Second call should return cached result
56+ fonts2, err := detector.GetAvailableFonts(ctx)
57+ require.NoError(t, err)
58+
59+ assert.Len(t, fonts2, len(fonts1), "cached result should match original")
60+}
61+
62+func TestDetector_SelectBestFont_SansSerif(t *testing.T) {
63+ ctx := testContext()
64+ detector := NewDetector()
65+
66+ if !detector.IsAvailable(ctx) {
67+ t.Skip("fc-list not available on this system")
68+ }
69+
70+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain)
71+ assert.NotEmpty(t, font)
72+
73+ // Should either be a font from the chain or the generic fallback
74+ isFromChain := false
75+ for _, f := range SansSerifFallbackChain {
76+ if font == f {
77+ isFromChain = true
78+ break
79+ }
80+ }
81+ if !isFromChain {
82+ assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available")
83+ }
84+
85+ t.Logf("Selected sans-serif font: %s", font)
86+}
87+
88+func TestDetector_SelectBestFont_Serif(t *testing.T) {
89+ ctx := testContext()
90+ detector := NewDetector()
91+
92+ if !detector.IsAvailable(ctx) {
93+ t.Skip("fc-list not available on this system")
94+ }
95+
96+ font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain)
97+ assert.NotEmpty(t, font)
98+
99+ isFromChain := false
100+ for _, f := range SerifFallbackChain {
101+ if font == f {
102+ isFromChain = true
103+ break
104+ }
105+ }
106+ if !isFromChain {
107+ assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available")
108+ }
109+
110+ t.Logf("Selected serif font: %s", font)
111+}
112+
113+func TestDetector_SelectBestFont_Monospace(t *testing.T) {
114+ ctx := testContext()
115+ detector := NewDetector()
116+
117+ if !detector.IsAvailable(ctx) {
118+ t.Skip("fc-list not available on this system")
119+ }
120+
121+ font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain)
122+ assert.NotEmpty(t, font)
123+
124+ isFromChain := false
125+ for _, f := range MonospaceFallbackChain {
126+ if font == f {
127+ isFromChain = true
128+ break
129+ }
130+ }
131+ if !isFromChain {
132+ assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available")
133+ }
134+
135+ t.Logf("Selected monospace font: %s", font)
136+}
137+
138+func TestDetector_SelectBestFont_EmptyChain(t *testing.T) {
139+ ctx := testContext()
140+ detector := NewDetector()
141+
142+ if !detector.IsAvailable(ctx) {
143+ t.Skip("fc-list not available on this system")
144+ }
145+
146+ // Empty chain should return generic fallback
147+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{})
148+ assert.Equal(t, "sans-serif", font)
149+
150+ font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{})
151+ assert.Equal(t, "serif", font)
152+
153+ font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{})
154+ assert.Equal(t, "monospace", font)
155+}
156+
157+func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) {
158+ ctx := testContext()
159+ detector := NewDetector()
160+
161+ if !detector.IsAvailable(ctx) {
162+ t.Skip("fc-list not available on this system")
163+ }
164+
165+ // Chain with fonts that definitely don't exist
166+ chain := []string{
167+ "NonExistentFont1234",
168+ "AnotherFakeFont5678",
169+ }
170+
171+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain)
172+ assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match")
173+}
174+
175+func TestDetector_genericFallback(t *testing.T) {
176+ detector := NewDetector()
177+
178+ tests := []struct {
179+ category port.FontCategory
180+ expected string
181+ }{
182+ {port.FontCategorySansSerif, "sans-serif"},
183+ {port.FontCategorySerif, "serif"},
184+ {port.FontCategoryMonospace, "monospace"},
185+ {port.FontCategory("unknown"), "sans-serif"}, // default case
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(string(tt.category), func(t *testing.T) {
190+ result := detector.genericFallback(tt.category)
191+ assert.Equal(t, tt.expected, result)
192+ })
193+ }
194+}
195+
196+func TestFallbackChains_NotEmpty(t *testing.T) {
197+ assert.NotEmpty(t, SansSerifFallbackChain, "sans-serif fallback chain should not be empty")
198+ assert.NotEmpty(t, SerifFallbackChain, "serif fallback chain should not be empty")
199+ assert.NotEmpty(t, MonospaceFallbackChain, "monospace fallback chain should not be empty")
200+}
201+
202+func TestFallbackChains_PreferredFontsFirst(t *testing.T) {
203+ // Verify preferred fonts are first in chain
204+ assert.Equal(t, "Fira Sans", SansSerifFallbackChain[0], "Fira Sans should be first in sans-serif chain")
205+ assert.Equal(t, "Noto Serif", SerifFallbackChain[0], "Noto Serif should be first in serif chain")
206+ assert.Equal(t, "Fira Code", MonospaceFallbackChain[0], "Fira Code should be first in monospace chain")
207+}

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 8

Tags 42

↑↓ moveenter openesc close