Merge pull request #95 from bnema/fix/issue-85-font-detection
feat: add font availability detection on first run
7 files · +691 -0
M .mockery.yaml +1 -0
| @@ -26,6 +26,7 @@ packages: | |||
| 26 | 26 | TextInputTarget: {} | |
| 27 | 27 | FocusedInputProvider: {} | |
| 28 | 28 | AccentPickerUI: {} | |
| 29 | + | FontDetector: {} | |
| 29 | 30 | github.com/bnema/dumber/internal/domain/repository: | |
| 30 | 31 | config: | |
| 31 | 32 | 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. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 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 | 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 | 12 | ### Changed | |
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### 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 | ### Changed | 12 | ### 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) | |||
| 1214 | 1214 | 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 | 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 | 1421 | // The first argument is typically a *testing.T value. | |
| 1219 | 1422 | func NewMockHardwareSurveyor(t interface { | |
| @@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||||
| 1214 | return _c | 1214 | 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 +30 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| @@ -8,6 +9,9 @@ import ( | |||
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/fonts" | |
| 14 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | 15 | "github.com/spf13/viper" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| @@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error { | |||
| 302 | 306 | return err | |
| 303 | 307 | } | |
| 304 | 308 | ||
| 309 | + | // Detect available fonts and override defaults before writing config. | |
| 310 | + | m.detectAndSetFonts() | |
| 311 | + | ||
| 305 | 312 | // Ensure TOML format and write config | |
| 306 | 313 | m.viper.SetConfigType("toml") | |
| 307 | 314 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |
| @@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error { | |||
| 313 | 320 | return nil | |
| 314 | 321 | } | |
| 315 | 322 | ||
| 323 | + | // detectAndSetFonts detects available system fonts and sets the best available | |
| 324 | + | // fonts from the fallback chains. This runs only during first-run config creation. | |
| 325 | + | func (m *Manager) detectAndSetFonts() { | |
| 326 | + | // Create context with logger for debugging first-run font detection. | |
| 327 | + | logger := logging.NewFromEnv() | |
| 328 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 329 | + | ||
| 330 | + | detector := fonts.NewDetector() | |
| 331 | + | ||
| 332 | + | if !detector.IsAvailable(ctx) { | |
| 333 | + | // fc-list not available, keep hardcoded defaults | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 338 | + | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 339 | + | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |
| 340 | + | ||
| 341 | + | m.viper.Set("appearance.sans_font", sansFont) | |
| 342 | + | m.viper.Set("appearance.serif_font", serifFont) | |
| 343 | + | m.viper.Set("appearance.monospace_font", monoFont) | |
| 344 | + | } | |
| 345 | + | ||
| 316 | 346 | // setDefaults sets default configuration values in Viper. | |
| 317 | 347 | func (m *Manager) setDefaults() { | |
| 318 | 348 | defaults := DefaultConfig() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | 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,9 @@ 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" | |||
| 14 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | "github.com/spf13/viper" | 15 | "github.com/spf13/viper" | ||
| 12 | ) | 16 | ) | ||
| 13 | 17 | ||||
| @@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 302 | return err | 306 | return err | ||
| 303 | } | 307 | } | ||
| 304 | 308 | ||||
| 309 | + | // Detect available fonts and override defaults before writing config. | |||
| 310 | + | m.detectAndSetFonts() | |||
| 311 | + | ||||
| 305 | // Ensure TOML format and write config | 312 | // Ensure TOML format and write config | ||
| 306 | m.viper.SetConfigType("toml") | 313 | m.viper.SetConfigType("toml") | ||
| 307 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | 314 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | ||
| @@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 313 | return nil | 320 | return nil | ||
| 314 | } | 321 | } | ||
| 315 | 322 | ||||
| 323 | + | // detectAndSetFonts detects available system fonts and sets the best available | |||
| 324 | + | // fonts from the fallback chains. This runs only during first-run config creation. | |||
| 325 | + | func (m *Manager) detectAndSetFonts() { | |||
| 326 | + | // Create context with logger for debugging first-run font detection. | |||
| 327 | + | logger := logging.NewFromEnv() | |||
| 328 | + | ctx := logging.WithContext(context.Background(), logger) | |||
| 329 | + | ||||
| 330 | + | detector := fonts.NewDetector() | |||
| 331 | + | ||||
| 332 | + | if !detector.IsAvailable(ctx) { | |||
| 333 | + | // fc-list not available, keep hardcoded defaults | |||
| 334 | + | return | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |||
| 338 | + | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |||
| 339 | + | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |||
| 340 | + | ||||
| 341 | + | m.viper.Set("appearance.sans_font", sansFont) | |||
| 342 | + | m.viper.Set("appearance.serif_font", serifFont) | |||
| 343 | + | m.viper.Set("appearance.monospace_font", monoFont) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 316 | // setDefaults sets default configuration values in Viper. | 346 | // setDefaults sets default configuration values in Viper. | ||
| 317 | func (m *Manager) setDefaults() { | 347 | func (m *Manager) setDefaults() { | ||
| 318 | defaults := DefaultConfig() | 348 | defaults := DefaultConfig() | ||
A internal/infrastructure/fonts/detector.go +217 -0
| @@ -0,0 +1,217 @@ | |||
| 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 (unexported to prevent modification). | |
| 15 | + | var ( | |
| 16 | + | sansSerifFallbackChain = []string{ | |
| 17 | + | "Fira Sans", | |
| 18 | + | "Noto Sans", | |
| 19 | + | "DejaVu Sans", | |
| 20 | + | "Liberation Sans", | |
| 21 | + | "FreeSans", | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | serifFallbackChain = []string{ | |
| 25 | + | "Noto Serif", | |
| 26 | + | "DejaVu Serif", | |
| 27 | + | "Liberation Serif", | |
| 28 | + | "FreeSerif", | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | monospaceFallbackChain = []string{ | |
| 32 | + | "Fira Code", | |
| 33 | + | "JetBrains Mono", | |
| 34 | + | "Noto Sans Mono", | |
| 35 | + | "DejaVu Sans Mono", | |
| 36 | + | "Liberation Mono", | |
| 37 | + | "FreeMono", | |
| 38 | + | } | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // SansSerifFallbackChain returns the fallback chain for sans-serif fonts. | |
| 42 | + | func SansSerifFallbackChain() []string { | |
| 43 | + | result := make([]string, len(sansSerifFallbackChain)) | |
| 44 | + | copy(result, sansSerifFallbackChain) | |
| 45 | + | return result | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // SerifFallbackChain returns the fallback chain for serif fonts. | |
| 49 | + | func SerifFallbackChain() []string { | |
| 50 | + | result := make([]string, len(serifFallbackChain)) | |
| 51 | + | copy(result, serifFallbackChain) | |
| 52 | + | return result | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // MonospaceFallbackChain returns the fallback chain for monospace fonts. | |
| 56 | + | func MonospaceFallbackChain() []string { | |
| 57 | + | result := make([]string, len(monospaceFallbackChain)) | |
| 58 | + | copy(result, monospaceFallbackChain) | |
| 59 | + | return result | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Generic CSS fallbacks when no fonts from the chain are found. | |
| 63 | + | const ( | |
| 64 | + | genericSansSerif = "sans-serif" | |
| 65 | + | genericSerif = "serif" | |
| 66 | + | genericMonospace = "monospace" | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | // Detector implements port.FontDetector using fontconfig's fc-list command. | |
| 70 | + | type Detector struct { | |
| 71 | + | mu sync.RWMutex | |
| 72 | + | cachedFonts []string | |
| 73 | + | cachePopulated bool | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // NewDetector creates a new font detector. | |
| 77 | + | func NewDetector() *Detector { | |
| 78 | + | return &Detector{} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // IsAvailable implements port.FontDetector. | |
| 82 | + | // Returns true if fc-list command is available on the system. | |
| 83 | + | func (*Detector) IsAvailable(_ context.Context) bool { | |
| 84 | + | _, err := exec.LookPath("fc-list") | |
| 85 | + | return err == nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // GetAvailableFonts implements port.FontDetector. | |
| 89 | + | // Returns a list of font family names installed on the system. | |
| 90 | + | func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |
| 91 | + | log := logging.FromContext(ctx) | |
| 92 | + | ||
| 93 | + | d.mu.RLock() | |
| 94 | + | if d.cachePopulated { | |
| 95 | + | fonts := d.cachedFonts | |
| 96 | + | d.mu.RUnlock() | |
| 97 | + | return fonts, nil | |
| 98 | + | } | |
| 99 | + | d.mu.RUnlock() | |
| 100 | + | ||
| 101 | + | d.mu.Lock() | |
| 102 | + | defer d.mu.Unlock() | |
| 103 | + | ||
| 104 | + | // Double-check after acquiring write lock. | |
| 105 | + | if d.cachePopulated { | |
| 106 | + | return d.cachedFonts, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fonts, err := d.queryFonts(ctx) | |
| 110 | + | if err != nil { | |
| 111 | + | log.Debug().Err(err).Msg("failed to query system fonts") | |
| 112 | + | return nil, err | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | d.cachedFonts = fonts | |
| 116 | + | d.cachePopulated = true | |
| 117 | + | log.Debug().Int("count", len(fonts)).Msg("cached system fonts") | |
| 118 | + | ||
| 119 | + | return fonts, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // SelectBestFont implements port.FontDetector. | |
| 123 | + | // Returns the first available font from the fallback chain for the given category. | |
| 124 | + | func (d *Detector) SelectBestFont( | |
| 125 | + | ctx context.Context, | |
| 126 | + | category port.FontCategory, | |
| 127 | + | fallbackChain []string, | |
| 128 | + | ) string { | |
| 129 | + | log := logging.FromContext(ctx) | |
| 130 | + | ||
| 131 | + | availableFonts, err := d.GetAvailableFonts(ctx) | |
| 132 | + | if err != nil { | |
| 133 | + | log.Debug(). | |
| 134 | + | Str("category", string(category)). | |
| 135 | + | Err(err). | |
| 136 | + | Msg("font detection unavailable, using generic fallback") | |
| 137 | + | return d.genericFallback(category) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Build a set for O(1) lookup. | |
| 141 | + | fontSet := make(map[string]struct{}, len(availableFonts)) | |
| 142 | + | for _, f := range availableFonts { | |
| 143 | + | fontSet[f] = struct{}{} | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Find the first available font in the chain. | |
| 147 | + | for _, font := range fallbackChain { | |
| 148 | + | if _, exists := fontSet[font]; exists { | |
| 149 | + | log.Debug(). | |
| 150 | + | Str("category", string(category)). | |
| 151 | + | Str("font", font). | |
| 152 | + | Msg("selected font from fallback chain") | |
| 153 | + | return font | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // No fonts from chain found, use generic fallback. | |
| 158 | + | generic := d.genericFallback(category) | |
| 159 | + | log.Debug(). | |
| 160 | + | Str("category", string(category)). | |
| 161 | + | Str("fallback", generic). | |
| 162 | + | Msg("no fonts from fallback chain available, using generic") | |
| 163 | + | return generic | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // queryFonts executes fc-list and parses the output. | |
| 167 | + | func (*Detector) queryFonts(ctx context.Context) ([]string, error) { | |
| 168 | + | cmd := exec.CommandContext(ctx, "fc-list", ":", "family") | |
| 169 | + | output, err := cmd.Output() | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, err | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | fontSet := make(map[string]struct{}) | |
| 175 | + | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |
| 176 | + | for scanner.Scan() { | |
| 177 | + | line := strings.TrimSpace(scanner.Text()) | |
| 178 | + | if line == "" { | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | // fc-list may return comma-separated families for fonts with aliases. | |
| 182 | + | // e.g., "DejaVu Sans,DejaVu Sans Light" | |
| 183 | + | families := strings.Split(line, ",") | |
| 184 | + | for _, family := range families { | |
| 185 | + | family = strings.TrimSpace(family) | |
| 186 | + | if family != "" { | |
| 187 | + | fontSet[family] = struct{}{} | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | if err := scanner.Err(); err != nil { | |
| 193 | + | return nil, err | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Convert set to slice. | |
| 197 | + | fonts := make([]string, 0, len(fontSet)) | |
| 198 | + | for font := range fontSet { | |
| 199 | + | fonts = append(fonts, font) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | return fonts, nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // genericFallback returns the CSS generic fallback for the given category. | |
| 206 | + | func (*Detector) genericFallback(category port.FontCategory) string { | |
| 207 | + | switch category { | |
| 208 | + | case port.FontCategorySansSerif: | |
| 209 | + | return genericSansSerif | |
| 210 | + | case port.FontCategorySerif: | |
| 211 | + | return genericSerif | |
| 212 | + | case port.FontCategoryMonospace: | |
| 213 | + | return genericMonospace | |
| 214 | + | default: | |
| 215 | + | return genericSansSerif | |
| 216 | + | } | |
| 217 | + | } | |
| @@ -0,0 +1,217 @@ | |||||
| 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 (unexported to prevent modification). | |||
| 15 | + | var ( | |||
| 16 | + | sansSerifFallbackChain = []string{ | |||
| 17 | + | "Fira Sans", | |||
| 18 | + | "Noto Sans", | |||
| 19 | + | "DejaVu Sans", | |||
| 20 | + | "Liberation Sans", | |||
| 21 | + | "FreeSans", | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | serifFallbackChain = []string{ | |||
| 25 | + | "Noto Serif", | |||
| 26 | + | "DejaVu Serif", | |||
| 27 | + | "Liberation Serif", | |||
| 28 | + | "FreeSerif", | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | monospaceFallbackChain = []string{ | |||
| 32 | + | "Fira Code", | |||
| 33 | + | "JetBrains Mono", | |||
| 34 | + | "Noto Sans Mono", | |||
| 35 | + | "DejaVu Sans Mono", | |||
| 36 | + | "Liberation Mono", | |||
| 37 | + | "FreeMono", | |||
| 38 | + | } | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // SansSerifFallbackChain returns the fallback chain for sans-serif fonts. | |||
| 42 | + | func SansSerifFallbackChain() []string { | |||
| 43 | + | result := make([]string, len(sansSerifFallbackChain)) | |||
| 44 | + | copy(result, sansSerifFallbackChain) | |||
| 45 | + | return result | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // SerifFallbackChain returns the fallback chain for serif fonts. | |||
| 49 | + | func SerifFallbackChain() []string { | |||
| 50 | + | result := make([]string, len(serifFallbackChain)) | |||
| 51 | + | copy(result, serifFallbackChain) | |||
| 52 | + | return result | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // MonospaceFallbackChain returns the fallback chain for monospace fonts. | |||
| 56 | + | func MonospaceFallbackChain() []string { | |||
| 57 | + | result := make([]string, len(monospaceFallbackChain)) | |||
| 58 | + | copy(result, monospaceFallbackChain) | |||
| 59 | + | return result | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Generic CSS fallbacks when no fonts from the chain are found. | |||
| 63 | + | const ( | |||
| 64 | + | genericSansSerif = "sans-serif" | |||
| 65 | + | genericSerif = "serif" | |||
| 66 | + | genericMonospace = "monospace" | |||
| 67 | + | ) | |||
| 68 | + | ||||
| 69 | + | // Detector implements port.FontDetector using fontconfig's fc-list command. | |||
| 70 | + | type Detector struct { | |||
| 71 | + | mu sync.RWMutex | |||
| 72 | + | cachedFonts []string | |||
| 73 | + | cachePopulated bool | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // NewDetector creates a new font detector. | |||
| 77 | + | func NewDetector() *Detector { | |||
| 78 | + | return &Detector{} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // IsAvailable implements port.FontDetector. | |||
| 82 | + | // Returns true if fc-list command is available on the system. | |||
| 83 | + | func (*Detector) IsAvailable(_ context.Context) bool { | |||
| 84 | + | _, err := exec.LookPath("fc-list") | |||
| 85 | + | return err == nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // GetAvailableFonts implements port.FontDetector. | |||
| 89 | + | // Returns a list of font family names installed on the system. | |||
| 90 | + | func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |||
| 91 | + | log := logging.FromContext(ctx) | |||
| 92 | + | ||||
| 93 | + | d.mu.RLock() | |||
| 94 | + | if d.cachePopulated { | |||
| 95 | + | fonts := d.cachedFonts | |||
| 96 | + | d.mu.RUnlock() | |||
| 97 | + | return fonts, nil | |||
| 98 | + | } | |||
| 99 | + | d.mu.RUnlock() | |||
| 100 | + | ||||
| 101 | + | d.mu.Lock() | |||
| 102 | + | defer d.mu.Unlock() | |||
| 103 | + | ||||
| 104 | + | // Double-check after acquiring write lock. | |||
| 105 | + | if d.cachePopulated { | |||
| 106 | + | return d.cachedFonts, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | fonts, err := d.queryFonts(ctx) | |||
| 110 | + | if err != nil { | |||
| 111 | + | log.Debug().Err(err).Msg("failed to query system fonts") | |||
| 112 | + | return nil, err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | d.cachedFonts = fonts | |||
| 116 | + | d.cachePopulated = true | |||
| 117 | + | log.Debug().Int("count", len(fonts)).Msg("cached system fonts") | |||
| 118 | + | ||||
| 119 | + | return fonts, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // SelectBestFont implements port.FontDetector. | |||
| 123 | + | // Returns the first available font from the fallback chain for the given category. | |||
| 124 | + | func (d *Detector) SelectBestFont( | |||
| 125 | + | ctx context.Context, | |||
| 126 | + | category port.FontCategory, | |||
| 127 | + | fallbackChain []string, | |||
| 128 | + | ) string { | |||
| 129 | + | log := logging.FromContext(ctx) | |||
| 130 | + | ||||
| 131 | + | availableFonts, err := d.GetAvailableFonts(ctx) | |||
| 132 | + | if err != nil { | |||
| 133 | + | log.Debug(). | |||
| 134 | + | Str("category", string(category)). | |||
| 135 | + | Err(err). | |||
| 136 | + | Msg("font detection unavailable, using generic fallback") | |||
| 137 | + | return d.genericFallback(category) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Build a set for O(1) lookup. | |||
| 141 | + | fontSet := make(map[string]struct{}, len(availableFonts)) | |||
| 142 | + | for _, f := range availableFonts { | |||
| 143 | + | fontSet[f] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Find the first available font in the chain. | |||
| 147 | + | for _, font := range fallbackChain { | |||
| 148 | + | if _, exists := fontSet[font]; exists { | |||
| 149 | + | log.Debug(). | |||
| 150 | + | Str("category", string(category)). | |||
| 151 | + | Str("font", font). | |||
| 152 | + | Msg("selected font from fallback chain") | |||
| 153 | + | return font | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // No fonts from chain found, use generic fallback. | |||
| 158 | + | generic := d.genericFallback(category) | |||
| 159 | + | log.Debug(). | |||
| 160 | + | Str("category", string(category)). | |||
| 161 | + | Str("fallback", generic). | |||
| 162 | + | Msg("no fonts from fallback chain available, using generic") | |||
| 163 | + | return generic | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // queryFonts executes fc-list and parses the output. | |||
| 167 | + | func (*Detector) queryFonts(ctx context.Context) ([]string, error) { | |||
| 168 | + | cmd := exec.CommandContext(ctx, "fc-list", ":", "family") | |||
| 169 | + | output, err := cmd.Output() | |||
| 170 | + | if err != nil { | |||
| 171 | + | return nil, err | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | fontSet := make(map[string]struct{}) | |||
| 175 | + | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |||
| 176 | + | for scanner.Scan() { | |||
| 177 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 178 | + | if line == "" { | |||
| 179 | + | continue | |||
| 180 | + | } | |||
| 181 | + | // fc-list may return comma-separated families for fonts with aliases. | |||
| 182 | + | // e.g., "DejaVu Sans,DejaVu Sans Light" | |||
| 183 | + | families := strings.Split(line, ",") | |||
| 184 | + | for _, family := range families { | |||
| 185 | + | family = strings.TrimSpace(family) | |||
| 186 | + | if family != "" { | |||
| 187 | + | fontSet[family] = struct{}{} | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | if err := scanner.Err(); err != nil { | |||
| 193 | + | return nil, err | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Convert set to slice. | |||
| 197 | + | fonts := make([]string, 0, len(fontSet)) | |||
| 198 | + | for font := range fontSet { | |||
| 199 | + | fonts = append(fonts, font) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | return fonts, nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // genericFallback returns the CSS generic fallback for the given category. | |||
| 206 | + | func (*Detector) genericFallback(category port.FontCategory) string { | |||
| 207 | + | switch category { | |||
| 208 | + | case port.FontCategorySansSerif: | |||
| 209 | + | return genericSansSerif | |||
| 210 | + | case port.FontCategorySerif: | |||
| 211 | + | return genericSerif | |||
| 212 | + | case port.FontCategoryMonospace: | |||
| 213 | + | return genericMonospace | |||
| 214 | + | default: | |||
| 215 | + | return genericSansSerif | |||
| 216 | + | } | |||
| 217 | + | } | |||
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 | + | } | |||