diff --git a/app.go b/app.go index 589f820..c7dab3c 100644 --- a/app.go +++ b/app.go @@ -19,6 +19,7 @@ import ( "solo/internal/configuration" "solo/internal/environment" "solo/internal/exporter" + "solo/internal/fonts" "solo/internal/git" "solo/internal/host" "solo/internal/importer" @@ -305,6 +306,32 @@ func (a *App) GetThemeByName(themeName string) (*theme.Theme, error) { return a.configManager.GetThemeByName(themeName) } +// Font Management Methods + +// SetBaseFontSizePx sets the base font size and persists the change. +func (a *App) SetBaseFontSizePx(fontSize int) error { + if a.configManager == nil { + return fmt.Errorf("configuration manager not initialized") + } + return a.configManager.SetBaseFontSizePx(fontSize) +} + +// SetDefaultFontFamily sets the default font family and persists the change. +func (a *App) SetDefaultFontFamily(fontFamily string) error { + if a.configManager == nil { + return fmt.Errorf("configuration manager not initialized") + } + return a.configManager.SetDefaultFontFamily(fontFamily) +} + +// SetMonoFontFamily sets the monospace font family and persists the change. +func (a *App) SetMonoFontFamily(fontFamily string) error { + if a.configManager == nil { + return fmt.Errorf("configuration manager not initialized") + } + return a.configManager.SetMonoFontFamily(fontFamily) +} + // Host Management Methods // GetAllHosts returns a list of all configured hosts. @@ -744,6 +771,11 @@ func (a *App) GetDefaultConfiguration() (configuration.Configuration, error) { return a.configManager.GetDefaultConfiguration(), nil } +// ListSystemFonts returns installed system font families with monospace metadata. +func (a *App) ListSystemFonts(refresh bool) ([]fonts.SystemFont, error) { + return fonts.ListFamilies(refresh) +} + // Request Management Methods // GetRequests returns all requests within a specific collection. diff --git a/go.mod b/go.mod index 81f3e65..e257562 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/google/go-github/v76 v76.0.0 + golang.org/x/image v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -52,5 +53,5 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 72b6cf0..217213c 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= @@ -97,8 +99,8 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go index e663bed..0d829f7 100644 --- a/internal/configuration/configuration.go +++ b/internal/configuration/configuration.go @@ -20,6 +20,9 @@ type GeneralSettings struct { IncludePrereleaseUpdates bool `json:"includePrereleaseUpdates"` DebugMode bool `json:"debugMode"` SelectedEnvironment string `json:"selectedEnvironment,omitempty"` + BaseFontSizePx int `json:"baseFontSizePx"` + DefaultFontFamily string `json:"defaultFontFamily"` + MonoFontFamily string `json:"monoFontFamily"` } type RequestSettings struct { diff --git a/internal/configuration/configuration_manager.go b/internal/configuration/configuration_manager.go index dd27302..9059aac 100644 --- a/internal/configuration/configuration_manager.go +++ b/internal/configuration/configuration_manager.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "os" + "solo/internal/fonts" "solo/internal/theme" "solo/internal/tools" "sync" @@ -60,6 +61,9 @@ func (cm *ConfigurationManager) createDefault() Configuration { CheckForUpdates: tools.DEFAULT_CHECK_UPDATES, IncludePrereleaseUpdates: tools.DEFAULT_INCLUDE_PRERELEASE_UPDATES, DebugMode: false, + BaseFontSizePx: tools.DEFAULT_BASE_FONT_SIZE_PX, + DefaultFontFamily: "", + MonoFontFamily: "", }, Request: RequestSettings{ TimeoutSeconds: tools.DEFAULT_TIMEOUT_SECONDS, @@ -223,3 +227,39 @@ func (cm *ConfigurationManager) DeleteCustomTheme(themeName string) error { return cm.Save(configToSave) } + +func (cm *ConfigurationManager) SetBaseFontSizePx(fontSize int) error { + cm.mu.Lock() + cm.config.General.BaseFontSizePx = fonts.ClampBaseFontSizePx(fontSize) + configToSave := *cm.config + cm.mu.Unlock() + return cm.Save(configToSave) // Save immediately to persist +} + +func (cm *ConfigurationManager) SetDefaultFontFamily(fontFamily string) error { + if fonts.IsValidFontFamily(fontFamily) { + cm.mu.Lock() + cm.config.General.DefaultFontFamily = fontFamily + configToSave := *cm.config + cm.mu.Unlock() + return cm.Save(configToSave) // Save immediately to persist + } + return fmt.Errorf("invalid font family: %s", fontFamily) +} + +func (cm *ConfigurationManager) GetMonoFontFamily() string { + cm.mu.RLock() + defer cm.mu.RUnlock() + return cm.config.General.MonoFontFamily +} + +func (cm *ConfigurationManager) SetMonoFontFamily(fontFamily string) error { + if fonts.IsValidMonoFontFamily(fontFamily) { + cm.mu.Lock() + cm.config.General.MonoFontFamily = fontFamily + configToSave := *cm.config + cm.mu.Unlock() + return cm.Save(configToSave) // Save immediately to persist + } + return fmt.Errorf("invalid font family: %s", fontFamily) +} diff --git a/internal/configuration/configuration_test.go b/internal/configuration/configuration_test.go index a63cccc..0c99b17 100644 --- a/internal/configuration/configuration_test.go +++ b/internal/configuration/configuration_test.go @@ -4,6 +4,8 @@ package configuration import ( + "os" + "path/filepath" "solo/internal/testutil" "solo/internal/theme" "solo/internal/tools" @@ -32,6 +34,15 @@ func TestConfigurationManager_Defaults(t *testing.T) { cfg.General.IncludePrereleaseUpdates, ) } + if cfg.General.BaseFontSizePx != tools.DEFAULT_BASE_FONT_SIZE_PX { + t.Errorf("Expected default base font size %d, got %d", tools.DEFAULT_BASE_FONT_SIZE_PX, cfg.General.BaseFontSizePx) + } + if cfg.General.DefaultFontFamily != "" { + t.Errorf("Expected default sans font family to be empty, got %q", cfg.General.DefaultFontFamily) + } + if cfg.General.MonoFontFamily != "" { + t.Errorf("Expected default mono font family to be empty, got %q", cfg.General.MonoFontFamily) + } newTheme := "nord" cfg.General.ActiveTheme = newTheme @@ -53,6 +64,77 @@ func TestConfigurationManager_Defaults(t *testing.T) { } } +func TestConfigurationManager_LegacyTypographyFieldsRemainUnsetOnLoad(t *testing.T) { + testutil.IsolateUserConfigDir(t) + + configDir, err := tools.GetOrCreateConfigDir() + if err != nil { + t.Fatalf("GetOrCreateConfigDir failed: %v", err) + } + + legacyConfig := []byte(`{ + "general": { + "activeTheme": "ocean", + "themeMode": "system", + "dayTheme": "ocean", + "nightTheme": "nord", + "checkForUpdates": true, + "includePrereleaseUpdates": false, + "debugMode": false + }, + "request": { + "timeoutSeconds": 30, + "followRedirects": true, + "maxRedirects": 10, + "validateSSL": true, + "defaultUserAgent": "Solo/1.0" + }, + "customThemes": [] +}`) + if err := os.WriteFile(filepath.Join(configDir, tools.CONFIG_JSON_FILENAME), legacyConfig, 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cm, err := NewConfigurationManager() + if err != nil { + t.Fatalf("NewConfigurationManager failed: %v", err) + } + + cfg := cm.Get() + if cfg.General.BaseFontSizePx != 0 { + t.Errorf("Expected legacy base font size to remain unset (0), got %d", cfg.General.BaseFontSizePx) + } + if cfg.General.DefaultFontFamily != "" { + t.Errorf("Expected legacy sans font family to remain empty, got %q", cfg.General.DefaultFontFamily) + } + if cfg.General.MonoFontFamily != "" { + t.Errorf("Expected legacy mono font family to remain empty, got %q", cfg.General.MonoFontFamily) + } +} + +func TestConfigurationManager_ClampsInvalidBaseFontSize(t *testing.T) { + testutil.IsolateUserConfigDir(t) + + cm, err := NewConfigurationManager() + if err != nil { + t.Fatalf("NewConfigurationManager failed: %v", err) + } + + if err := cm.SetBaseFontSizePx(99); err != nil { + t.Fatalf("Save failed: %v", err) + } + + cm2, err := NewConfigurationManager() + if err != nil { + t.Fatalf("Second NewConfigurationManager failed: %v", err) + } + + cfg2 := cm2.Get() + if cfg2.General.BaseFontSizePx != tools.DEFAULT_BASE_FONT_SIZE_PX { + t.Errorf("Expected clamped base font size %d, got %d", tools.DEFAULT_BASE_FONT_SIZE_PX, cfg2.General.BaseFontSizePx) + } +} + func TestConfigurationManager_ThemeManagement(t *testing.T) { testutil.IsolateUserConfigDir(t) diff --git a/internal/fonts/dirs_darwin.go b/internal/fonts/dirs_darwin.go new file mode 100644 index 0000000..7c240a9 --- /dev/null +++ b/internal/fonts/dirs_darwin.go @@ -0,0 +1,20 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build darwin + +package fonts + +import ( + "os" + "path/filepath" +) + +func fontDirs() []string { + home, _ := os.UserHomeDir() + return []string{ + filepath.Join(home, "Library", "Fonts"), + "/Library/Fonts", + "/System/Library/Fonts", + } +} diff --git a/internal/fonts/dirs_linux.go b/internal/fonts/dirs_linux.go new file mode 100644 index 0000000..f69af29 --- /dev/null +++ b/internal/fonts/dirs_linux.go @@ -0,0 +1,21 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build linux + +package fonts + +import ( + "os" + "path/filepath" +) + +func fontDirs() []string { + home, _ := os.UserHomeDir() + return []string{ + filepath.Join(home, ".fonts"), + filepath.Join(home, ".local", "share", "fonts"), + "/usr/share/fonts", + "/usr/local/share/fonts", + } +} diff --git a/internal/fonts/dirs_windows.go b/internal/fonts/dirs_windows.go new file mode 100644 index 0000000..df6e084 --- /dev/null +++ b/internal/fonts/dirs_windows.go @@ -0,0 +1,20 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build windows + +package fonts + +import ( + "os" + "path/filepath" +) + +func fontDirs() []string { + windir := os.Getenv("WINDIR") + home, _ := os.UserHomeDir() + return []string{ + filepath.Join(windir, "Fonts"), + filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "Fonts"), + } +} diff --git a/internal/fonts/list.go b/internal/fonts/list.go new file mode 100644 index 0000000..ce0f888 --- /dev/null +++ b/internal/fonts/list.go @@ -0,0 +1,180 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +package fonts + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + + "golang.org/x/image/font/sfnt" +) + +type SystemFont struct { + Family string `json:"family"` + IsMonospace bool `json:"isMonospace"` +} + +var errNoUsableFontMetadata = errors.New("fonts: no usable font metadata") + +var getFontDirs = fontDirs + +var ( + cacheMu sync.RWMutex + cachedFamilies []SystemFont +) + +func ListFamilies(refresh bool) ([]SystemFont, error) { + if !refresh { + cacheMu.RLock() + if cachedFamilies != nil { + cached := slices.Clone(cachedFamilies) + cacheMu.RUnlock() + return cached, nil + } + cacheMu.RUnlock() + } + + families, err := scanFamilies() + if err != nil { + return nil, err + } + + cacheMu.Lock() + cachedFamilies = slices.Clone(families) + cacheMu.Unlock() + + return families, nil +} + +func scanFamilies() ([]SystemFont, error) { + seen := map[string]SystemFont{} + + for _, dir := range getFontDirs() { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !isSupportedFontExtension(strings.ToLower(filepath.Ext(d.Name()))) { + return nil + } + + fonts, err := parseFontFile(path) + if err != nil { + return nil + } + + for _, font := range fonts { + if font.Family == "" { + continue + } + if _, exists := seen[font.Family]; exists { + continue + } + seen[font.Family] = font + } + return nil + }) + } + + families := make([]SystemFont, 0, len(seen)) + for _, font := range seen { + families = append(families, font) + } + + sort.Slice(families, func(i, j int) bool { + return families[i].Family < families[j].Family + }) + return families, nil +} + +func parseFontFile(path string) ([]SystemFont, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".ttc" { + collection, err := sfnt.ParseCollection(data) + if err != nil { + return nil, err + } + + fonts := make([]SystemFont, 0, collection.NumFonts()) + for i := 0; i < collection.NumFonts(); i++ { + font, err := collection.Font(i) + if err != nil { + continue + } + parsed, err := parseSFNTFont(font) + if err != nil { + continue + } + fonts = append(fonts, parsed) + } + if len(fonts) == 0 { + return nil, errNoUsableFontMetadata + } + return fonts, nil + } + + font, err := sfnt.Parse(data) + if err != nil { + return nil, err + } + + parsed, err := parseSFNTFont(font) + if err != nil { + return nil, err + } + return []SystemFont{parsed}, nil +} + +func parseSFNTFont(font *sfnt.Font) (SystemFont, error) { + family, err := fontFamilyName(font) + if err != nil { + return SystemFont{}, err + } + + post := font.PostTable() + return SystemFont{ + Family: family, + IsMonospace: post != nil && post.IsFixedPitch, + }, nil +} + +func fontFamilyName(font *sfnt.Font) (string, error) { + for _, id := range []sfnt.NameID{sfnt.NameIDTypographicFamily, sfnt.NameIDFamily} { + name, err := font.Name(nil, id) + if err != nil { + continue + } + name = strings.TrimSpace(name) + if name != "" { + return name, nil + } + } + + return "", errNoUsableFontMetadata +} + +func isSupportedFontExtension(ext string) bool { + switch ext { + case ".ttf", ".otf", ".ttc": + return true + default: + return false + } +} diff --git a/internal/fonts/list_test.go b/internal/fonts/list_test.go new file mode 100644 index 0000000..05073e3 --- /dev/null +++ b/internal/fonts/list_test.go @@ -0,0 +1,241 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +package fonts + +import ( + "errors" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "testing" +) + +func resetCache() { + cacheMu.Lock() + cachedFamilies = nil + cacheMu.Unlock() +} + +func fixturePath(parts ...string) string { + segments := append([]string{"..", "..", "test", "fonts"}, parts...) + return filepath.Join(segments...) +} + +func copyFile(t *testing.T, srcPath, dstPath string) { + t.Helper() + + src, err := os.Open(srcPath) + if err != nil { + t.Fatalf("Open(%q) failed: %v", srcPath, err) + } + defer src.Close() + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + dst, err := os.Create(dstPath) + if err != nil { + t.Fatalf("Create(%q) failed: %v", dstPath, err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + t.Fatalf("Copy failed: %v", err) + } +} + +func TestParseFontFile(t *testing.T) { + t.Run("ttf sans fixture", func(t *testing.T) { + path := fixturePath("inter", "Inter-Regular.ttf") + + fonts, err := parseFontFile(path) + if err != nil { + t.Fatalf("parseFontFile(%q) failed: %v", path, err) + } + + want := []SystemFont{{Family: "Inter", IsMonospace: false}} + if !reflect.DeepEqual(fonts, want) { + t.Fatalf("parseFontFile(%q) = %#v, want %#v", path, fonts, want) + } + }) + + t.Run("otf sans fixture", func(t *testing.T) { + path := fixturePath("inter", "Inter-Regular.otf") + + fonts, err := parseFontFile(path) + if err != nil { + t.Fatalf("parseFontFile(%q) failed: %v", path, err) + } + + want := []SystemFont{{Family: "Inter", IsMonospace: false}} + if !reflect.DeepEqual(fonts, want) { + t.Fatalf("parseFontFile(%q) = %#v, want %#v", path, fonts, want) + } + }) + + t.Run("ttf monospace fixture", func(t *testing.T) { + path := fixturePath("jetbrainsmono", "JetBrainsMono-Regular.ttf") + + fonts, err := parseFontFile(path) + if err != nil { + t.Fatalf("parseFontFile(%q) failed: %v", path, err) + } + + want := []SystemFont{{Family: "JetBrains Mono", IsMonospace: true}} + if !reflect.DeepEqual(fonts, want) { + t.Fatalf("parseFontFile(%q) = %#v, want %#v", path, fonts, want) + } + }) + + t.Run("ttc fixture", func(t *testing.T) { + path := fixturePath("inter", "Inter.ttc") + + fonts, err := parseFontFile(path) + if err != nil { + t.Fatalf("parseFontFile(%q) failed: %v", path, err) + } + if len(fonts) != 36 { + t.Fatalf("parseFontFile(%q) returned %d fonts, want 36", path, len(fonts)) + } + + families := make([]string, 0, len(fonts)) + for _, font := range fonts { + families = append(families, font.Family) + if font.IsMonospace { + t.Fatalf("parseFontFile(%q) returned monospace TTC face: %#v", path, font) + } + } + + if !slices.Contains(families, "Inter") { + t.Fatalf("parseFontFile(%q) families = %#v, want family %q", path, families, "Inter") + } + if !slices.Contains(families, "Inter Display") { + t.Fatalf("parseFontFile(%q) families = %#v, want family %q", path, families, "Inter Display") + } + }) + + t.Run("invalid font fixture", func(t *testing.T) { + path := fixturePath("InvalidFont.otf") + + _, err := parseFontFile(path) + if err == nil { + t.Fatalf("parseFontFile(%q) succeeded, want error", path) + } + }) +} + +func TestListFamiliesSortedAndDeduplicated(t *testing.T) { + resetCache() + t.Cleanup(resetCache) + + tempDir := t.TempDir() + + copyFile(t, fixturePath("inter", "Inter-Regular.ttf"), filepath.Join(tempDir, "A", "Inter-Regular.ttf")) + copyFile(t, fixturePath("inter", "Inter-Bold.ttf"), filepath.Join(tempDir, "B", "Inter-Bold.ttf")) + copyFile(t, fixturePath("inter", "Inter-Regular.otf"), filepath.Join(tempDir, "C", "Inter-Regular.otf")) + copyFile(t, fixturePath("inter", "Inter-Bold.otf"), filepath.Join(tempDir, "D", "Inter-Bold.otf")) + copyFile(t, fixturePath("jetbrainsmono", "JetBrainsMono-Regular.ttf"), filepath.Join(tempDir, "E", "JetBrainsMono-Regular.ttf")) + copyFile(t, fixturePath("InvalidFont.otf"), filepath.Join(tempDir, "broken", "InvalidFont.otf")) + copyFile(t, fixturePath("NotAFont.txt"), filepath.Join(tempDir, "ignored", "NotAFont.txt")) + + originalGetFontDirs := getFontDirs + getFontDirs = func() []string { return []string{tempDir} } + t.Cleanup(func() { getFontDirs = originalGetFontDirs }) + + got, err := ListFamilies(true) + if err != nil { + t.Fatalf("ListFamilies(true) failed: %v", err) + } + + want := []SystemFont{ + {Family: "Inter", IsMonospace: false}, + {Family: "JetBrains Mono", IsMonospace: true}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListFamilies(true) = %#v, want %#v", got, want) + } +} + +func TestListFamiliesUsesCacheUntilRefresh(t *testing.T) { + resetCache() + t.Cleanup(resetCache) + + tempDir := t.TempDir() + copyFile(t, fixturePath("inter", "Inter-Regular.ttf"), filepath.Join(tempDir, "Inter-Regular.ttf")) + + originalGetFontDirs := getFontDirs + getFontDirs = func() []string { return []string{tempDir} } + t.Cleanup(func() { getFontDirs = originalGetFontDirs }) + + got, err := ListFamilies(false) + if err != nil { + t.Fatalf("ListFamilies(false) failed: %v", err) + } + + wantInitial := []SystemFont{{Family: "Inter", IsMonospace: false}} + if !reflect.DeepEqual(got, wantInitial) { + t.Fatalf("initial ListFamilies(false) = %#v, want %#v", got, wantInitial) + } + + copyFile(t, fixturePath("jetbrainsmono", "JetBrainsMono-Regular.ttf"), filepath.Join(tempDir, "nested", "JetBrainsMono-Regular.ttf")) + + cached, err := ListFamilies(false) + if err != nil { + t.Fatalf("cached ListFamilies(false) failed: %v", err) + } + if !reflect.DeepEqual(cached, got) { + t.Fatalf("cached ListFamilies(false) = %#v, want %#v", cached, got) + } + + fresh, err := ListFamilies(true) + if err != nil { + t.Fatalf("ListFamilies(true) failed: %v", err) + } + + wantFresh := []SystemFont{ + {Family: "Inter", IsMonospace: false}, + {Family: "JetBrains Mono", IsMonospace: true}, + } + if !reflect.DeepEqual(fresh, wantFresh) { + t.Fatalf("ListFamilies(true) = %#v, want %#v", fresh, wantFresh) + } +} + +func TestListFamiliesSkipsUnusableDirectories(t *testing.T) { + resetCache() + t.Cleanup(resetCache) + + tempDir := t.TempDir() + missingDir := filepath.Join(tempDir, "missing") + validDir := filepath.Join(tempDir, "valid") + + copyFile(t, fixturePath("inter", "Inter-Regular.ttf"), filepath.Join(validDir, "Inter-Regular.ttf")) + + originalGetFontDirs := getFontDirs + getFontDirs = func() []string { return []string{missingDir, validDir} } + t.Cleanup(func() { getFontDirs = originalGetFontDirs }) + + got, err := ListFamilies(true) + if err != nil { + t.Fatalf("ListFamilies(true) failed: %v", err) + } + + want := []SystemFont{{Family: "Inter", IsMonospace: false}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListFamilies(true) = %#v, want %#v", got, want) + } +} + +func TestParseFontFileMissingFile(t *testing.T) { + _, err := parseFontFile(filepath.Join(t.TempDir(), "missing.ttf")) + if err == nil { + t.Fatal("parseFontFile(missing) succeeded, want error") + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("parseFontFile(missing) error = %v, want os.ErrNotExist", err) + } +} diff --git a/internal/fonts/utils.go b/internal/fonts/utils.go new file mode 100644 index 0000000..b6e5bc7 --- /dev/null +++ b/internal/fonts/utils.go @@ -0,0 +1,40 @@ +// Copyright 2026-present raml-dev +// SPDX-License-Identifier: AGPL-3.0-only + +package fonts + +import "solo/internal/tools" + +func IsValidFontFamily(family string) bool { + families, err := ListFamilies(false) + if err != nil { + return false + } + for _, f := range families { + if f.Family == family { + return true + } + } + return false +} + +func IsValidMonoFontFamily(family string) bool { + families, err := ListFamilies(false) + if err != nil { + return false + } + for _, f := range families { + if f.Family == family { + // found the requested family, return true only if it is monospace + return f.IsMonospace + } + } + return false +} + +func ClampBaseFontSizePx(v int) int { + if v < tools.MIN_BASE_FONT_SIZE_PX || v > tools.MAX_BASE_FONT_SIZE_PX { + return tools.DEFAULT_BASE_FONT_SIZE_PX + } + return v +} diff --git a/internal/tools/constants.go b/internal/tools/constants.go index 61f5e93..0df4aff 100644 --- a/internal/tools/constants.go +++ b/internal/tools/constants.go @@ -19,6 +19,9 @@ const ( DEFAULT_THEME = "ocean" DEFAULT_THEME_LIGHT = "ocean" DEFAULT_THEME_DARK = "nord" + MIN_BASE_FONT_SIZE_PX = 11 + MAX_BASE_FONT_SIZE_PX = 18 + DEFAULT_BASE_FONT_SIZE_PX = 14 DEFAULT_CHECK_UPDATES = true DEFAULT_INCLUDE_PRERELEASE_UPDATES = false DEFAULT_TIMEOUT_SECONDS = 30 diff --git a/test/fonts/InvalidFont.otf b/test/fonts/InvalidFont.otf new file mode 100644 index 0000000..61feedb --- /dev/null +++ b/test/fonts/InvalidFont.otf @@ -0,0 +1 @@ +this is a text file, not a font diff --git a/test/fonts/NotAFont.txt b/test/fonts/NotAFont.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fonts/inter/Inter-Bold.otf b/test/fonts/inter/Inter-Bold.otf new file mode 100644 index 0000000..07cd5f0 Binary files /dev/null and b/test/fonts/inter/Inter-Bold.otf differ diff --git a/test/fonts/inter/Inter-Bold.ttf b/test/fonts/inter/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/test/fonts/inter/Inter-Bold.ttf differ diff --git a/test/fonts/inter/Inter-Regular.otf b/test/fonts/inter/Inter-Regular.otf new file mode 100644 index 0000000..13c3ec4 Binary files /dev/null and b/test/fonts/inter/Inter-Regular.otf differ diff --git a/test/fonts/inter/Inter-Regular.ttf b/test/fonts/inter/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/test/fonts/inter/Inter-Regular.ttf differ diff --git a/test/fonts/inter/Inter.ttc b/test/fonts/inter/Inter.ttc new file mode 100644 index 0000000..eb1f583 Binary files /dev/null and b/test/fonts/inter/Inter.ttc differ diff --git a/test/fonts/inter/LICENSE.txt b/test/fonts/inter/LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/test/fonts/inter/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/test/fonts/jetbrainsmono/AUTHORS.txt b/test/fonts/jetbrainsmono/AUTHORS.txt new file mode 100755 index 0000000..8814941 --- /dev/null +++ b/test/fonts/jetbrainsmono/AUTHORS.txt @@ -0,0 +1,10 @@ +# This is the official list of project authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS.txt file. +# See the latter for an explanation. +# +# Names should be added to this file as: +# Name or Organization + +JetBrains <> +Philipp Nurullin +Konstantin Bulenkov diff --git a/test/fonts/jetbrainsmono/JetBrainsMono-Bold.ttf b/test/fonts/jetbrainsmono/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/test/fonts/jetbrainsmono/JetBrainsMono-Bold.ttf differ diff --git a/test/fonts/jetbrainsmono/JetBrainsMono-Regular.ttf b/test/fonts/jetbrainsmono/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/test/fonts/jetbrainsmono/JetBrainsMono-Regular.ttf differ diff --git a/test/fonts/jetbrainsmono/OFL.txt b/test/fonts/jetbrainsmono/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/test/fonts/jetbrainsmono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.