v0.27.2 5960dd8

detector.go RawHistoryFolder

1package fonts
2
3import (
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).
15var (
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.
42func 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.
49func 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.
56func 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.
63const (
64	genericSansSerif = "sans-serif"
65	genericSerif     = "serif"
66	genericMonospace = "monospace"
67)
68
69// Detector implements port.FontDetector using fontconfig's fc-list command.
70type Detector struct {
71	mu             sync.RWMutex
72	cachedFonts    []string
73	cachePopulated bool
74}
75
76// NewDetector creates a new font detector.
77func NewDetector() *Detector {
78	return &Detector{}
79}
80
81// IsAvailable implements port.FontDetector.
82// Returns true if fc-list command is available on the system.
83func (*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.
90func (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.
124func (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.
167func (*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.
206func (*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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close