v0.3.0 da0887c

resolve.go RawHistoryFolder

1package tray
2
3import (
4	"bufio"
5	"os"
6	"path/filepath"
7	"strings"
8	"time"
9	"unicode"
10)
11
12// Resolver picks the icon of a tray item from what the system already knows,
13// so a new application needs no table entry:
14//
15//  1. the user's [tray.icons] override;
16//  2. a glyph for a freedesktop status icon name, such as "audio-volume-muted";
17//  3. a Nerd Font glyph named after the application, such as "fa-steam";
18//  4. a glyph for the category of its .desktop file, such as Game;
19//  5. a letter in a circle.
20type Resolver struct {
21	lookup   func(name string) (rune, bool)
22	icons    map[string]string // lowercased keys
23	dataDirs []string
24	apps     map[string]*desktopApp // nil until the first lookup
25	mtimes   []time.Time            // of each applications dir when apps was read
26}
27
28// desktopApp is what the resolver uses from a .desktop file.
29type desktopApp struct {
30	name       string
31	words      []string // file name, Icon and StartupWMClass, lowercased
32	categories []string
33	hidden     bool // Hidden=true: the application counts as deleted
34}
35
36// NewResolver returns a resolver that finds glyphs with lookup (a font's
37// glyph names) and .desktop files under dataDirs/applications. Nil dataDirs
38// uses the XDG data directories.
39func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver {
40	r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs}
41	for k, v := range icons {
42		r.icons[strings.ToLower(k)] = v
43	}
44	if r.dataDirs == nil {
45		r.dataDirs = xdgDataDirs()
46	}
47	return r
48}
49
50// prefixes are the Nerd Font sets, brand-rich ones first.
51var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"}
52
53// stopwords are parts of item ids and names that say nothing about the
54// application.
55var stopwords = map[string]bool{
56	"org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true,
57	"desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true,
58	"notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true,
59	"gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true,
60	"ayatana": true, "notification": true, "electron": true, "bin": true,
61}
62
63// statusIcons maps freedesktop status icon names, by prefix, to glyphs, most
64// specific first. A prefix matches whole dash-separated parts, so
65// "signal-desktop" or "evolution-mail" are not status icons.
66var statusIcons = [...][2]string{
67	{"audio-volume-muted", "md-volume_off"}, {"audio-volume", "md-volume_high"},
68	{"microphone-sensitivity-muted", "md-microphone_off"}, {"microphone-sensitivity", "md-microphone"},
69	{"audio-input-microphone-muted", "md-microphone_off"}, {"audio-input-microphone", "md-microphone"},
70	{"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
71	{"network-vpn", "md-vpn"}, {"nm-vpn", "md-vpn"}, {"network-wired", "md-lan"}, {"nm-device-wired", "md-lan"},
72	{"network-wireless", "md-wifi"}, {"nm-signal", "md-wifi"}, {"nm-device-wireless", "md-wifi"},
73	{"network", "md-lan"},
74	{"software-update", "md-update"}, {"system-software-update", "md-update"},
75	{"mail-unread", "md-email"}, {"mail-read", "md-email"}, {"mail-message-new", "md-email"},
76}
77
78// statusIcon returns the glyph name for a status icon name, or "".
79func statusIcon(iconName string) string {
80	n := strings.ToLower(iconName)
81	for _, s := range statusIcons {
82		if rest, ok := strings.CutPrefix(n, s[0]); ok && (rest == "" || rest[0] == '-') {
83			return s[1]
84		}
85	}
86	return ""
87}
88
89// categories maps .desktop categories to glyphs, most specific first: an
90// application lists broad categories too, such as "Network;WebBrowser".
91var categories = [...][2]string{
92	{"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"},
93	{"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"},
94	{"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"},
95	{"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"},
96	{"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"},
97	{"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"},
98	{"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"},
99	{"System", "md-cog"}, {"Utility", "md-tools"},
100}
101
102// Resolve returns the icon text of an item: one glyph, or the user's
103// override. id, iconName and title are the item's properties; any may be
104// empty. A title can say anything ("Sync complete"), so it only counts as a
105// whole name, after the stronger signals.
106func (r *Resolver) Resolve(id, iconName, title string) string {
107	if strings.HasPrefix(iconName, "/") {
108		iconName = "" // a file path names no application
109	}
110	keys := [...]string{id, iconName, title}
111	for _, k := range keys {
112		if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" {
113			return r.glyphOr(v, v)
114		}
115	}
116	app := r.app(id, iconName, title)
117	if app != nil {
118		if v, ok := r.icons[strings.ToLower(app.name)]; ok {
119			return r.glyphOr(v, v)
120		}
121	}
122	// Status icon names first: "audio-volume-muted" is a state, not a brand.
123	if s := statusIcon(iconName); s != "" {
124		if g, ok := r.glyph(s); ok {
125			return g
126		}
127	}
128	names := []string{id, iconName}
129	if app != nil {
130		names = append(names, app.words...)
131		names = append(names, app.name)
132	}
133	for _, n := range names {
134		if g, ok := r.brand(n); ok {
135			return g
136		}
137	}
138	if g, ok := r.prefixed(underscored(title)); ok {
139		return g
140	}
141	if app != nil {
142		for _, c := range categories {
143			for _, ac := range app.categories {
144				if ac == c[0] {
145					if g, ok := r.glyph(c[1]); ok {
146						return g
147					}
148				}
149			}
150		}
151	}
152	for _, n := range [...]string{nameOf(app), title, id} {
153		if l := firstLetter(n); l != 0 {
154			return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l)))
155		}
156	}
157	return "•"
158}
159
160func nameOf(app *desktopApp) string {
161	if app == nil {
162		return ""
163	}
164	return app.name
165}
166
167// glyph returns the glyph called name as a string.
168func (r *Resolver) glyph(name string) (string, bool) {
169	if rn, ok := r.lookup(name); ok {
170		return string(rn), true
171	}
172	return "", false
173}
174
175// glyphOr returns the glyph called name, or fallback when the font has none.
176func (r *Resolver) glyphOr(name, fallback string) string {
177	if g, ok := r.glyph(name); ok {
178		return g
179	}
180	return fallback
181}
182
183// underscored lowercases s and joins its parts with '_', the way glyph names
184// are spelled: "Google Chrome" gives "google_chrome".
185func underscored(s string) string {
186	return strings.Map(func(c rune) rune {
187		if c == '-' || c == '.' || c == ' ' {
188			return '_'
189		}
190		return unicode.ToLower(c)
191	}, s)
192}
193
194// brand finds a glyph named after s: its whole name, then each word.
195func (r *Resolver) brand(s string) (string, bool) {
196	if whole := underscored(s); !stopwords[whole] {
197		if g, ok := r.prefixed(whole); ok {
198			return g, true
199		}
200	}
201	for _, w := range words(s) {
202		if g, ok := r.prefixed(w); ok {
203			return g, true
204		}
205	}
206	return "", false
207}
208
209func (r *Resolver) prefixed(w string) (string, bool) {
210	if len(w) < 3 {
211		return "", false
212	}
213	for _, p := range prefixes {
214		if g, ok := r.glyph(p + "-" + w); ok {
215			return g, true
216		}
217	}
218	return "", false
219}
220
221// words splits s into lowercase alphanumeric words of 3 or more letters,
222// without stopwords or numbers.
223func words(s string) []string {
224	var out []string
225	for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool {
226		return !unicode.IsLetter(c) && !unicode.IsDigit(c)
227	}) {
228		if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) {
229			out = append(out, f)
230		}
231	}
232	return out
233}
234
235func firstLetter(s string) rune {
236	for _, c := range strings.ToLower(s) {
237		if c >= 'a' && c <= 'z' {
238			return c
239		}
240	}
241	return 0
242}
243
244// app finds the .desktop file of an item. The index is read on the first
245// call, and again on a miss when an applications directory changed since,
246// as when an application is installed during the session.
247func (r *Resolver) app(id, iconName, title string) *desktopApp {
248	if r.apps == nil {
249		r.reload()
250	}
251	if a := r.findApp(id, iconName, title); a != nil {
252		return a
253	}
254	if r.changed() {
255		r.reload()
256		return r.findApp(id, iconName, title)
257	}
258	return nil
259}
260
261func (r *Resolver) reload() {
262	r.mtimes = r.dirTimes(r.mtimes[:0])
263	r.apps = loadApps(r.dataDirs)
264}
265
266// dirTimes appends the mtime of each applications directory, zero when it
267// is missing.
268func (r *Resolver) dirTimes(ts []time.Time) []time.Time {
269	for _, d := range r.dataDirs {
270		var t time.Time
271		if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil {
272			t = st.ModTime()
273		}
274		ts = append(ts, t)
275	}
276	return ts
277}
278
279// findApp looks up the whole id, icon name and title, then the words of the
280// id and icon name. Titles are free text: their words are not used.
281func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
282	for _, k := range [...]string{id, iconName, title} {
283		if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
284			return a
285		}
286	}
287	for _, k := range [...]string{id, iconName} {
288		for _, w := range words(k) {
289			if a := r.apps[w]; a != nil {
290				return a
291			}
292		}
293	}
294	return nil
295}
296
297// changed reports whether an applications directory changed since the
298// index was read. Comparing mtimes with themselves, not with the clock,
299// is safe from clock skew.
300func (r *Resolver) changed() bool {
301	var buf [8]time.Time
302	for i, t := range r.dirTimes(buf[:0]) {
303		if !t.Equal(r.mtimes[i]) {
304			return true
305		}
306	}
307	return false
308}
309
310func xdgDataDirs() []string {
311	home := os.Getenv("XDG_DATA_HOME")
312	if home == "" {
313		if h, err := os.UserHomeDir(); err == nil {
314			home = filepath.Join(h, ".local", "share")
315		}
316	}
317	dirs := os.Getenv("XDG_DATA_DIRS")
318	if dirs == "" {
319		dirs = "/usr/local/share:/usr/share"
320	}
321	return append([]string{home}, filepath.SplitList(dirs)...)
322}
323
324// loadApps indexes the .desktop files of dataDirs by lowercased file name,
325// Icon and StartupWMClass, and by the last part of reverse-DNS ones
326// ("org.gnome.Settings" gives "settings" too). Whole values win over last
327// parts, and earlier directories over later ones, as in the XDG spec. A
328// hidden application keeps its keys, with no application behind them.
329func loadApps(dataDirs []string) map[string]*desktopApp {
330	apps := map[string]*desktopApp{}
331	var all []*desktopApp
332	for _, d := range dataDirs {
333		if d == "" {
334			continue
335		}
336		files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop"))
337		for _, f := range files {
338			if a := readDesktop(f); a != nil {
339				all = append(all, a)
340			}
341		}
342	}
343	add := func(k string, a *desktopApp) {
344		if _, taken := apps[k]; !taken && k != "" {
345			if a.hidden {
346				a = nil
347			}
348			apps[k] = a
349		}
350	}
351	for _, a := range all {
352		for _, w := range a.words {
353			add(w, a)
354		}
355	}
356	for _, a := range all {
357		for _, v := range a.words {
358			if i := strings.LastIndexByte(v, '.'); i >= 0 {
359				add(v[i+1:], a)
360			}
361		}
362	}
363	return apps
364}
365
366// readDesktop reads the keys the resolver needs from the [Desktop Entry]
367// group of a .desktop file.
368func readDesktop(path string) *desktopApp {
369	f, err := os.Open(path)
370	if err != nil {
371		return nil
372	}
373	defer func() { _ = f.Close() }()
374	a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}}
375	in := false
376	sc := bufio.NewScanner(f)
377	for sc.Scan() {
378		line := strings.TrimSpace(sc.Text())
379		if strings.HasPrefix(line, "[") {
380			if in {
381				break // only the first group is the entry
382			}
383			in = line == "[Desktop Entry]"
384			continue
385		}
386		k, v, ok := strings.Cut(line, "=")
387		if !in || !ok {
388			continue
389		}
390		switch strings.TrimSpace(k) {
391		case "Name":
392			a.name = strings.TrimSpace(v)
393		case "Icon":
394			if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") {
395				a.words = append(a.words, strings.ToLower(v))
396			}
397		case "StartupWMClass":
398			if v = strings.TrimSpace(v); v != "" {
399				a.words = append(a.words, strings.ToLower(v))
400			}
401		case "Categories":
402			a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
403		case "Hidden":
404			a.hidden = strings.TrimSpace(v) == "true"
405		case "Type":
406			if strings.TrimSpace(v) != "Application" {
407				return nil
408			}
409		}
410	}
411	if !in {
412		return nil
413	}
414	return a
415}

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 5

Tags 4

↑↓ moveenter openesc close