detached e29d86a

Merge pull request #2: feat(tray): system tray as Nerd Font icons

commit
e29d86a9012d14b7f12792049a644b8b1bf4f1d7
author
bnema · · 2026-10-08 20:20 UTC
merge of
c616855 + 27f82c1 · diff against the first parent
signature
unsigned
15 files · +2469 -13
M README.md +32 -0
@@ -259,6 +259,38 @@ neferbar app -once title # print once and exit
259259
260260 A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`.
261261
262+## System tray
263+
264+`neferbar tray` shows the system tray (StatusNotifierItem: Steam, Discord, network and volume applets…) as one Nerd Font icon per application. Add it as a module:
265+
266+```toml
267+[[module]]
268+name = "tray"
269+zone = "right"
270+exec = '"$NEFERBAR_BIN" tray'
271+```
272+
273+Each icon is chosen without a list to maintain, from what the system already knows:
274+
275+1. your choice in `[tray.icons]` (see below);
276+2. a glyph for standard status icons, such as a muted speaker or a Wi-Fi signal;
277+3. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
278+4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279+5. the first letter of its name, in a circle.
280+
281+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
282+
283+Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name, the text to show, or `""` to hide the item:
284+
285+```toml
286+[tray.icons]
287+sunshine = "md-weather_sunny"
288+handy = "H"
289+nm-applet = ""
290+```
291+
292+The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
293+
262294 ## Test without touching your desktop
263295
264296 ```sh
@@ -259,6 +259,38 @@ neferbar app -once title # print once and exit
259 259
260 A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`.260 A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`.
261 261
262+## System tray
263+
264+`neferbar tray` shows the system tray (StatusNotifierItem: Steam, Discord, network and volume applets…) as one Nerd Font icon per application. Add it as a module:
265+
266+```toml
267+[[module]]
268+name = "tray"
269+zone = "right"
270+exec = '"$NEFERBAR_BIN" tray'
271+```
272+
273+Each icon is chosen without a list to maintain, from what the system already knows:
274+
275+1. your choice in `[tray.icons]` (see below);
276+2. a glyph for standard status icons, such as a muted speaker or a Wi-Fi signal;
277+3. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
278+4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279+5. the first letter of its name, in a circle.
280+
281+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
282+
283+Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name, the text to show, or `""` to hide the item:
284+
285+```toml
286+[tray.icons]
287+sunshine = "md-weather_sunny"
288+handy = "H"
289+nm-applet = ""
290+```
291+
292+The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
293+
262 ## Test without touching your desktop294 ## Test without touching your desktop
263 295
264 ```sh296 ```sh
M cmd/neferbar/main.go +8 -3
@@ -48,12 +48,17 @@ func main() {
4848 }
4949 return
5050 }
51- if len(os.Args) > 1 && os.Args[1] == "app" {
51+ if len(os.Args) > 1 && (os.Args[1] == "app" || os.Args[1] == "tray") {
5252 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
53- err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))
53+ var err error
54+ if os.Args[1] == "app" {
55+ err = runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))
56+ } else {
57+ err = runTray(ctx, os.Args[2:])
58+ }
5459 stop()
5560 if err != nil && !errors.Is(err, flag.ErrHelp) {
56- fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err)
61+ fmt.Fprintf(os.Stderr, "neferbar %s: %v\n", os.Args[1], err)
5762 os.Exit(1)
5863 }
5964 return
@@ -48,12 +48,17 @@ func main() {
48 }48 }
49 return49 return
50 }50 }
51- if len(os.Args) > 1 && os.Args[1] == "app" {51+ if len(os.Args) > 1 && (os.Args[1] == "app" || os.Args[1] == "tray") {
52 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)52 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
53- err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))53+ var err error
54+ if os.Args[1] == "app" {
55+ err = runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))
56+ } else {
57+ err = runTray(ctx, os.Args[2:])
58+ }
54 stop()59 stop()
55 if err != nil && !errors.Is(err, flag.ErrHelp) {60 if err != nil && !errors.Is(err, flag.ErrHelp) {
56- fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err)61+ fmt.Fprintf(os.Stderr, "neferbar %s: %v\n", os.Args[1], err)
57 os.Exit(1)62 os.Exit(1)
58 }63 }
59 return64 return
A cmd/neferbar/tray.go +110 -0
@@ -0,0 +1,110 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "log/slog"
9+ "os"
10+
11+ "github.com/bnema/zerobus"
12+
13+ "github.com/bnema/neferbar/internal/config"
14+ "github.com/bnema/neferbar/internal/glyph"
15+ "github.com/bnema/neferbar/internal/theme"
16+ "github.com/bnema/neferbar/internal/tray"
17+)
18+
19+const trayUsage = `usage: neferbar tray [-config file]
20+
21+Prints the system tray as one line of Nerd Font icons: one line now, then a
22+new one each time an item appears, changes or goes away. Each icon takes the
23+color of the application's own icon. Hidden (passive) items are left out, and
24+items that need attention use the accent color.
25+
26+Run it as a module:
27+
28+ [[module]]
29+ name = "tray"
30+ zone = "right"
31+ exec = '"$NEFERBAR_BIN" tray'
32+
33+flags:
34+`
35+
36+// runTray is "neferbar tray": the StatusNotifierItem tray, for a module.
37+func runTray(ctx context.Context, args []string) error {
38+ fs := flag.NewFlagSet("neferbar tray", flag.ContinueOnError)
39+ cfgPath := fs.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
40+ fs.Usage = func() { fmt.Fprint(os.Stderr, trayUsage); fs.PrintDefaults() }
41+ if err := fs.Parse(args); err != nil {
42+ return err
43+ }
44+ if fs.NArg() > 0 {
45+ fs.Usage()
46+ return fmt.Errorf("unexpected argument %q", fs.Arg(0))
47+ }
48+ explicit := *cfgPath != ""
49+ if !explicit {
50+ *cfgPath = config.DefaultPath()
51+ }
52+ cfg, err := config.Load(*cfgPath, explicit)
53+ if err != nil {
54+ return err
55+ }
56+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
57+
58+ // The icons are glyph names of the bar's font.
59+ paths, err := glyph.FindFonts(cfg.Bar.Font)
60+ if err != nil {
61+ return err
62+ }
63+ names, err := glyph.LoadNames(paths[0])
64+ if err != nil {
65+ return err
66+ }
67+ defer names.Close()
68+ if names.Len() == 0 {
69+ log.Warn("the font has no icon names: is bar.font a Nerd Font?", "font", cfg.Bar.Font)
70+ }
71+
72+ opt := tray.Options{
73+ Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
74+ Out: os.Stdout,
75+ Log: log,
76+ }
77+ opt.Foreground, opt.Background, opt.Accent = trayColors(log)
78+
79+ c, err := zerobus.SessionBus()
80+ if err != nil {
81+ return err
82+ }
83+ err = tray.Run(ctx, c, opt)
84+ if errors.Is(err, zerobus.ErrClosed) {
85+ return fmt.Errorf("the session bus closed the connection")
86+ }
87+ return err
88+}
89+
90+// trayColors are the colors the bar gives its scripts, so the tray matches
91+// the bar. Run outside the bar, in a terminal, it uses the default theme.
92+func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) {
93+ def := theme.Default()
94+ return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground),
95+ envColor(log, "NEFERBAR_BACKGROUND", def.Background),
96+ envColor(log, "NEFERBAR_ACCENT", def.Palette[4])
97+}
98+
99+func envColor(log *slog.Logger, name string, def tray.RGB) tray.RGB {
100+ v, ok := os.LookupEnv(name)
101+ if !ok {
102+ return def
103+ }
104+ c, err := config.ParseColor(v)
105+ if err != nil {
106+ log.Warn("tray: using the default color", "var", name, "err", err)
107+ return def
108+ }
109+ return c
110+}
@@ -0,0 +1,110 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "log/slog"
9+ "os"
10+
11+ "github.com/bnema/zerobus"
12+
13+ "github.com/bnema/neferbar/internal/config"
14+ "github.com/bnema/neferbar/internal/glyph"
15+ "github.com/bnema/neferbar/internal/theme"
16+ "github.com/bnema/neferbar/internal/tray"
17+)
18+
19+const trayUsage = `usage: neferbar tray [-config file]
20+
21+Prints the system tray as one line of Nerd Font icons: one line now, then a
22+new one each time an item appears, changes or goes away. Each icon takes the
23+color of the application's own icon. Hidden (passive) items are left out, and
24+items that need attention use the accent color.
25+
26+Run it as a module:
27+
28+ [[module]]
29+ name = "tray"
30+ zone = "right"
31+ exec = '"$NEFERBAR_BIN" tray'
32+
33+flags:
34+`
35+
36+// runTray is "neferbar tray": the StatusNotifierItem tray, for a module.
37+func runTray(ctx context.Context, args []string) error {
38+ fs := flag.NewFlagSet("neferbar tray", flag.ContinueOnError)
39+ cfgPath := fs.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
40+ fs.Usage = func() { fmt.Fprint(os.Stderr, trayUsage); fs.PrintDefaults() }
41+ if err := fs.Parse(args); err != nil {
42+ return err
43+ }
44+ if fs.NArg() > 0 {
45+ fs.Usage()
46+ return fmt.Errorf("unexpected argument %q", fs.Arg(0))
47+ }
48+ explicit := *cfgPath != ""
49+ if !explicit {
50+ *cfgPath = config.DefaultPath()
51+ }
52+ cfg, err := config.Load(*cfgPath, explicit)
53+ if err != nil {
54+ return err
55+ }
56+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
57+
58+ // The icons are glyph names of the bar's font.
59+ paths, err := glyph.FindFonts(cfg.Bar.Font)
60+ if err != nil {
61+ return err
62+ }
63+ names, err := glyph.LoadNames(paths[0])
64+ if err != nil {
65+ return err
66+ }
67+ defer names.Close()
68+ if names.Len() == 0 {
69+ log.Warn("the font has no icon names: is bar.font a Nerd Font?", "font", cfg.Bar.Font)
70+ }
71+
72+ opt := tray.Options{
73+ Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
74+ Out: os.Stdout,
75+ Log: log,
76+ }
77+ opt.Foreground, opt.Background, opt.Accent = trayColors(log)
78+
79+ c, err := zerobus.SessionBus()
80+ if err != nil {
81+ return err
82+ }
83+ err = tray.Run(ctx, c, opt)
84+ if errors.Is(err, zerobus.ErrClosed) {
85+ return fmt.Errorf("the session bus closed the connection")
86+ }
87+ return err
88+}
89+
90+// trayColors are the colors the bar gives its scripts, so the tray matches
91+// the bar. Run outside the bar, in a terminal, it uses the default theme.
92+func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) {
93+ def := theme.Default()
94+ return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground),
95+ envColor(log, "NEFERBAR_BACKGROUND", def.Background),
96+ envColor(log, "NEFERBAR_ACCENT", def.Palette[4])
97+}
98+
99+func envColor(log *slog.Logger, name string, def tray.RGB) tray.RGB {
100+ v, ok := os.LookupEnv(name)
101+ if !ok {
102+ return def
103+ }
104+ c, err := config.ParseColor(v)
105+ if err != nil {
106+ log.Warn("tray: using the default color", "var", name, "err", err)
107+ return def
108+ }
109+ return c
110+}
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 github.com/bnema/purego-vulkan v0.6.0
1010 github.com/bnema/vev-vt v0.6.0
1111 github.com/bnema/wlturbo v0.6.2
12+ github.com/bnema/zerobus v0.1.1
1213 golang.org/x/image v0.46.0
1314 golang.org/x/sys v0.48.0
1415 )
@@ -9,6 +9,7 @@ require (
9 github.com/bnema/purego-vulkan v0.6.09 github.com/bnema/purego-vulkan v0.6.0
10 github.com/bnema/vev-vt v0.6.010 github.com/bnema/vev-vt v0.6.0
11 github.com/bnema/wlturbo v0.6.211 github.com/bnema/wlturbo v0.6.2
12+ github.com/bnema/zerobus v0.1.1
12 golang.org/x/image v0.46.013 golang.org/x/image v0.46.0
13 golang.org/x/sys v0.48.014 golang.org/x/sys v0.48.0
14 )15 )
M go.sum +2 -0 generated file: collapsed
M internal/config/config.go +17 -0
@@ -5,6 +5,8 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
9+ "unicode"
810
911 "github.com/BurntSushi/toml"
1012 )
@@ -37,9 +39,18 @@ type Module struct {
3739 Exec string `toml:"exec"`
3840 }
3941
42+// Tray configures "neferbar tray".
43+type Tray struct {
44+ // Icons replaces the icon chosen for an item. The key is the item id or
45+ // application name, in any case; the value is a Nerd Font glyph name such
46+ // as "fa-steam", or the text to show; "" hides the item.
47+ Icons map[string]string `toml:"icons"`
48+}
49+
4050 // Config is the whole file.
4151 type Config struct {
4252 Bar Bar `toml:"bar"`
53+ Tray Tray `toml:"tray"`
4354 Module []Module `toml:"module"`
4455 }
4556
@@ -126,6 +137,12 @@ func (c *Config) Validate() error {
126137 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
127138 }
128139 }
140+ for k, v := range c.Tray.Icons {
141+ // The tray prints the value on its one output line.
142+ if strings.ContainsFunc(v, unicode.IsControl) {
143+ return fmt.Errorf("config: tray.icons.%q contains a control character", k)
144+ }
145+ }
129146 return nil
130147 }
131148
@@ -5,6 +5,8 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
9+ "unicode"
8 10
9 "github.com/BurntSushi/toml"11 "github.com/BurntSushi/toml"
10 )12 )
@@ -37,9 +39,18 @@ type Module struct {
37 Exec string `toml:"exec"`39 Exec string `toml:"exec"`
38 }40 }
39 41
42+// Tray configures "neferbar tray".
43+type Tray struct {
44+ // Icons replaces the icon chosen for an item. The key is the item id or
45+ // application name, in any case; the value is a Nerd Font glyph name such
46+ // as "fa-steam", or the text to show; "" hides the item.
47+ Icons map[string]string `toml:"icons"`
48+}
49+
40 // Config is the whole file.50 // Config is the whole file.
41 type Config struct {51 type Config struct {
42 Bar Bar `toml:"bar"`52 Bar Bar `toml:"bar"`
53+ Tray Tray `toml:"tray"`
43 Module []Module `toml:"module"`54 Module []Module `toml:"module"`
44 }55 }
45 56
@@ -126,6 +137,12 @@ func (c *Config) Validate() error {
126 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)137 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
127 }138 }
128 }139 }
140+ for k, v := range c.Tray.Icons {
141+ // The tray prints the value on its one output line.
142+ if strings.ContainsFunc(v, unicode.IsControl) {
143+ return fmt.Errorf("config: tray.icons.%q contains a control character", k)
144+ }
145+ }
129 return nil146 return nil
130 }147 }
131 148
M internal/config/config_test.go +11 -10
@@ -31,16 +31,17 @@ func TestLoadDefaultsAndModules(t *testing.T) {
3131
3232 func TestLoadErrors(t *testing.T) {
3333 for name, body := range map[string]string{
34- "unknown key": "[bar]\nfnot = 1\n",
35- "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36- "no exec": "[[module]]\nzone = \"left\"\n",
37- "bad size": "[bar]\nsize = 1\n",
38- "bad scale": "[bar]\nscale = 20\n",
39- "bad color": "[bar]\nbackground = \"red\"\n",
40- "position": "[bar]\nposition = \"middle\"\n",
41- "accent low": "[bar]\naccent = -1\n",
42- "accent high": "[bar]\naccent = 16\n",
43- "syntax error": "[bar\n",
34+ "unknown key": "[bar]\nfnot = 1\n",
35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36+ "no exec": "[[module]]\nzone = \"left\"\n",
37+ "bad size": "[bar]\nsize = 1\n",
38+ "bad scale": "[bar]\nscale = 20\n",
39+ "bad color": "[bar]\nbackground = \"red\"\n",
40+ "position": "[bar]\nposition = \"middle\"\n",
41+ "accent low": "[bar]\naccent = -1\n",
42+ "accent high": "[bar]\naccent = 16\n",
43+ "syntax error": "[bar\n",
44+ "tray icon newline": "[tray.icons]\nsteam = \"a\\nb\"\n",
4445 } {
4546 if _, err := Load(write(t, body), true); err == nil {
4647 t.Errorf("%s: want error", name)
@@ -31,16 +31,17 @@ func TestLoadDefaultsAndModules(t *testing.T) {
31 31
32 func TestLoadErrors(t *testing.T) {32 func TestLoadErrors(t *testing.T) {
33 for name, body := range map[string]string{33 for name, body := range map[string]string{
34- "unknown key": "[bar]\nfnot = 1\n",34+ "unknown key": "[bar]\nfnot = 1\n",
35- "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36- "no exec": "[[module]]\nzone = \"left\"\n",36+ "no exec": "[[module]]\nzone = \"left\"\n",
37- "bad size": "[bar]\nsize = 1\n",37+ "bad size": "[bar]\nsize = 1\n",
38- "bad scale": "[bar]\nscale = 20\n",38+ "bad scale": "[bar]\nscale = 20\n",
39- "bad color": "[bar]\nbackground = \"red\"\n",39+ "bad color": "[bar]\nbackground = \"red\"\n",
40- "position": "[bar]\nposition = \"middle\"\n",40+ "position": "[bar]\nposition = \"middle\"\n",
41- "accent low": "[bar]\naccent = -1\n",41+ "accent low": "[bar]\naccent = -1\n",
42- "accent high": "[bar]\naccent = 16\n",42+ "accent high": "[bar]\naccent = 16\n",
43- "syntax error": "[bar\n",43+ "syntax error": "[bar\n",
44+ "tray icon newline": "[tray.icons]\nsteam = \"a\\nb\"\n",
44 } {45 } {
45 if _, err := Load(write(t, body), true); err == nil {46 if _, err := Load(write(t, body), true); err == nil {
46 t.Errorf("%s: want error", name)47 t.Errorf("%s: want error", name)
A internal/glyph/names.go +160 -0
@@ -0,0 +1,160 @@
1+package glyph
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "fmt"
7+ "slices"
8+
9+ "golang.org/x/image/font/sfnt"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Names maps the glyph names stored in a font file to the runes that draw
14+// them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use
15+// areas are indexed: that is where icon fonts put their glyphs.
16+//
17+// The names stay in the mapped font file; a lookup does not allocate.
18+type Names struct {
19+ data []byte
20+ idx []nameEntry // sorted by name
21+}
22+
23+type nameEntry struct {
24+ off, n uint32 // the name is data[off:off+n]
25+ r rune
26+}
27+
28+// iconRanges are the private use areas: the BMP one and plane 15.
29+var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}}
30+
31+// LoadNames indexes the glyph names of the font file at path. A font without
32+// glyph names (a version 3 "post" table) gives an empty index, not an error.
33+func LoadNames(path string) (*Names, error) {
34+ data, err := mapFile(path)
35+ if err != nil {
36+ return nil, fmt.Errorf("glyph: %w", err)
37+ }
38+ n := &Names{data: data}
39+ f, err := sfnt.Parse(data)
40+ if err != nil {
41+ n.Close()
42+ return nil, fmt.Errorf("glyph: parse %s: %w", path, err)
43+ }
44+ spans := postNames(data)
45+ var buf sfnt.Buffer
46+ for _, rg := range iconRanges {
47+ for r := rg[0]; r <= rg[1]; r++ {
48+ gi, err := f.GlyphIndex(&buf, r)
49+ if err != nil || gi == 0 || int(gi) >= len(spans) {
50+ continue
51+ }
52+ if s := spans[gi]; s.n > 0 {
53+ n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r})
54+ }
55+ }
56+ }
57+ slices.SortFunc(n.idx, func(a, b nameEntry) int {
58+ return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n])
59+ })
60+ return n, nil
61+}
62+
63+// Lookup returns the rune of the glyph called name.
64+func (n *Names) Lookup(name string) (rune, bool) {
65+ if n == nil {
66+ return 0, false
67+ }
68+ lo, hi := 0, len(n.idx)
69+ for lo < hi {
70+ m := int(uint(lo+hi) >> 1)
71+ e := n.idx[m]
72+ // string(bytes) in a comparison does not allocate.
73+ switch s := n.data[e.off : e.off+e.n]; {
74+ case string(s) < name:
75+ lo = m + 1
76+ case string(s) > name:
77+ hi = m
78+ default:
79+ return e.r, true
80+ }
81+ }
82+ return 0, false
83+}
84+
85+// Len returns the number of indexed names.
86+func (n *Names) Len() int { return len(n.idx) }
87+
88+// Close unmaps the font file. n must not be used afterwards.
89+func (n *Names) Close() {
90+ if n != nil && n.data != nil {
91+ _ = unix.Munmap(n.data)
92+ n.data, n.idx = nil, nil
93+ }
94+}
95+
96+type span struct{ off, n uint32 }
97+
98+// postNames returns, for each glyph index, where its custom name sits in
99+// data, from a version 2 "post" table. Glyphs with a standard Macintosh name
100+// (index < 258) or no table at all give empty spans. Every offset is checked
101+// against the file, so a damaged font gives fewer names, never a panic.
102+func postNames(data []byte) []span {
103+ u16 := func(off int) (int, bool) {
104+ if off < 0 || off+2 > len(data) {
105+ return 0, false
106+ }
107+ return int(binary.BigEndian.Uint16(data[off:])), true
108+ }
109+ u32 := func(off int) (uint32, bool) {
110+ if off < 0 || off+4 > len(data) {
111+ return 0, false
112+ }
113+ return binary.BigEndian.Uint32(data[off:]), true
114+ }
115+ tables, ok := u16(4)
116+ if !ok {
117+ return nil
118+ }
119+ post, end := -1, 0
120+ for i := range tables {
121+ rec := 12 + 16*i
122+ if rec+16 > len(data) {
123+ return nil
124+ }
125+ if string(data[rec:rec+4]) == "post" {
126+ off, _ := u32(rec + 8)
127+ n, _ := u32(rec + 12)
128+ if uint64(off)+uint64(n) > uint64(len(data)) {
129+ return nil
130+ }
131+ post, end = int(off), int(off)+int(n)
132+ break
133+ }
134+ }
135+ if v, ok := u32(post); !ok || v != 0x00020000 {
136+ return nil
137+ }
138+ glyphs, ok := u16(post + 32)
139+ if !ok || post+34+2*glyphs > end {
140+ return nil
141+ }
142+ // The custom names follow the index array as Pascal strings.
143+ var custom []uint32
144+ for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) {
145+ if o+1+int(data[o]) > end {
146+ break
147+ }
148+ custom = append(custom, uint32(o))
149+ }
150+ spans := make([]span, glyphs)
151+ for g := range glyphs {
152+ k, _ := u16(post + 34 + 2*g)
153+ if k < 258 || k-258 >= len(custom) {
154+ continue
155+ }
156+ o := custom[k-258]
157+ spans[g] = span{off: o + 1, n: uint32(data[o])}
158+ }
159+ return spans
160+}
@@ -0,0 +1,160 @@
1+package glyph
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "fmt"
7+ "slices"
8+
9+ "golang.org/x/image/font/sfnt"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Names maps the glyph names stored in a font file to the runes that draw
14+// them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use
15+// areas are indexed: that is where icon fonts put their glyphs.
16+//
17+// The names stay in the mapped font file; a lookup does not allocate.
18+type Names struct {
19+ data []byte
20+ idx []nameEntry // sorted by name
21+}
22+
23+type nameEntry struct {
24+ off, n uint32 // the name is data[off:off+n]
25+ r rune
26+}
27+
28+// iconRanges are the private use areas: the BMP one and plane 15.
29+var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}}
30+
31+// LoadNames indexes the glyph names of the font file at path. A font without
32+// glyph names (a version 3 "post" table) gives an empty index, not an error.
33+func LoadNames(path string) (*Names, error) {
34+ data, err := mapFile(path)
35+ if err != nil {
36+ return nil, fmt.Errorf("glyph: %w", err)
37+ }
38+ n := &Names{data: data}
39+ f, err := sfnt.Parse(data)
40+ if err != nil {
41+ n.Close()
42+ return nil, fmt.Errorf("glyph: parse %s: %w", path, err)
43+ }
44+ spans := postNames(data)
45+ var buf sfnt.Buffer
46+ for _, rg := range iconRanges {
47+ for r := rg[0]; r <= rg[1]; r++ {
48+ gi, err := f.GlyphIndex(&buf, r)
49+ if err != nil || gi == 0 || int(gi) >= len(spans) {
50+ continue
51+ }
52+ if s := spans[gi]; s.n > 0 {
53+ n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r})
54+ }
55+ }
56+ }
57+ slices.SortFunc(n.idx, func(a, b nameEntry) int {
58+ return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n])
59+ })
60+ return n, nil
61+}
62+
63+// Lookup returns the rune of the glyph called name.
64+func (n *Names) Lookup(name string) (rune, bool) {
65+ if n == nil {
66+ return 0, false
67+ }
68+ lo, hi := 0, len(n.idx)
69+ for lo < hi {
70+ m := int(uint(lo+hi) >> 1)
71+ e := n.idx[m]
72+ // string(bytes) in a comparison does not allocate.
73+ switch s := n.data[e.off : e.off+e.n]; {
74+ case string(s) < name:
75+ lo = m + 1
76+ case string(s) > name:
77+ hi = m
78+ default:
79+ return e.r, true
80+ }
81+ }
82+ return 0, false
83+}
84+
85+// Len returns the number of indexed names.
86+func (n *Names) Len() int { return len(n.idx) }
87+
88+// Close unmaps the font file. n must not be used afterwards.
89+func (n *Names) Close() {
90+ if n != nil && n.data != nil {
91+ _ = unix.Munmap(n.data)
92+ n.data, n.idx = nil, nil
93+ }
94+}
95+
96+type span struct{ off, n uint32 }
97+
98+// postNames returns, for each glyph index, where its custom name sits in
99+// data, from a version 2 "post" table. Glyphs with a standard Macintosh name
100+// (index < 258) or no table at all give empty spans. Every offset is checked
101+// against the file, so a damaged font gives fewer names, never a panic.
102+func postNames(data []byte) []span {
103+ u16 := func(off int) (int, bool) {
104+ if off < 0 || off+2 > len(data) {
105+ return 0, false
106+ }
107+ return int(binary.BigEndian.Uint16(data[off:])), true
108+ }
109+ u32 := func(off int) (uint32, bool) {
110+ if off < 0 || off+4 > len(data) {
111+ return 0, false
112+ }
113+ return binary.BigEndian.Uint32(data[off:]), true
114+ }
115+ tables, ok := u16(4)
116+ if !ok {
117+ return nil
118+ }
119+ post, end := -1, 0
120+ for i := range tables {
121+ rec := 12 + 16*i
122+ if rec+16 > len(data) {
123+ return nil
124+ }
125+ if string(data[rec:rec+4]) == "post" {
126+ off, _ := u32(rec + 8)
127+ n, _ := u32(rec + 12)
128+ if uint64(off)+uint64(n) > uint64(len(data)) {
129+ return nil
130+ }
131+ post, end = int(off), int(off)+int(n)
132+ break
133+ }
134+ }
135+ if v, ok := u32(post); !ok || v != 0x00020000 {
136+ return nil
137+ }
138+ glyphs, ok := u16(post + 32)
139+ if !ok || post+34+2*glyphs > end {
140+ return nil
141+ }
142+ // The custom names follow the index array as Pascal strings.
143+ var custom []uint32
144+ for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) {
145+ if o+1+int(data[o]) > end {
146+ break
147+ }
148+ custom = append(custom, uint32(o))
149+ }
150+ spans := make([]span, glyphs)
151+ for g := range glyphs {
152+ k, _ := u16(post + 34 + 2*g)
153+ if k < 258 || k-258 >= len(custom) {
154+ continue
155+ }
156+ o := custom[k-258]
157+ spans[g] = span{off: o + 1, n: uint32(data[o])}
158+ }
159+ return spans
160+}
A internal/glyph/names_test.go +129 -0
@@ -0,0 +1,129 @@
1+package glyph
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "strings"
7+ "testing"
8+)
9+
10+// nerdFont returns the path of an installed Nerd Font, or skips the test.
11+func nerdFont(t *testing.T) string {
12+ t.Helper()
13+ paths, err := FindFonts("JetBrainsMono Nerd Font Mono")
14+ if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") {
15+ t.Skip("no Nerd Font installed")
16+ }
17+ return paths[0]
18+}
19+
20+func TestNamesLookup(t *testing.T) {
21+ n, err := LoadNames(nerdFont(t))
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ defer n.Close()
26+ if n.Len() < 1000 {
27+ t.Fatalf("only %d names indexed", n.Len())
28+ }
29+ for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} {
30+ if r, ok := n.Lookup(name); !ok || r != want {
31+ t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want)
32+ }
33+ }
34+ if _, ok := n.Lookup("no-such-glyph"); ok {
35+ t.Error("an unknown name was found")
36+ }
37+ if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 {
38+ t.Errorf("Lookup allocates %v times", allocs)
39+ }
40+}
41+
42+func TestNilNamesLookup(t *testing.T) {
43+ var n *Names
44+ if _, ok := n.Lookup("fa-steam"); ok {
45+ t.Error("a nil index found a name")
46+ }
47+}
48+
49+// postFont builds a font file holding only a "post" table of the given
50+// version, with one glyph index per entry of indices and the custom names
51+// after them.
52+func postFont(version uint32, indices []uint16, names ...string) []byte {
53+ post := make([]byte, 34+2*len(indices))
54+ binary.BigEndian.PutUint32(post, version)
55+ binary.BigEndian.PutUint16(post[32:], uint16(len(indices)))
56+ for i, k := range indices {
57+ binary.BigEndian.PutUint16(post[34+2*i:], k)
58+ }
59+ for _, s := range names {
60+ post = append(post, byte(len(s)))
61+ post = append(post, s...)
62+ }
63+ b := make([]byte, 12+16, 12+16+len(post))
64+ b[5] = 1 // one table
65+ copy(b[12:], "post")
66+ binary.BigEndian.PutUint32(b[12+8:], 28)
67+ binary.BigEndian.PutUint32(b[12+12:], uint32(len(post)))
68+ return append(b, post...)
69+}
70+
71+func TestPostNames(t *testing.T) {
72+ // Glyph 0 has a standard name, 1 and 2 custom ones, 3 a custom index
73+ // past the names.
74+ data := postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second")
75+ spans := postNames(data)
76+ if len(spans) != 4 {
77+ t.Fatalf("%d spans, want 4", len(spans))
78+ }
79+ name := func(g int) string { return string(data[spans[g].off : spans[g].off+spans[g].n]) }
80+ if spans[0].n != 0 || spans[3].n != 0 {
81+ t.Errorf("standard or missing names gave spans: %v", spans)
82+ }
83+ if name(1) != "second" || name(2) != "first" {
84+ t.Errorf("names = %q, %q", name(1), name(2))
85+ }
86+}
87+
88+func TestPostNamesVersion3(t *testing.T) {
89+ // A version 3 table stores no names: nothing to index, no error.
90+ if spans := postNames(postFont(0x00030000, []uint16{259}, "x")); spans != nil {
91+ t.Fatalf("got %d spans", len(spans))
92+ }
93+}
94+
95+func TestPostNamesDamagedFont(t *testing.T) {
96+ // Truncated or garbage files give no names and never panic.
97+ var good []byte
98+ if paths, err := FindFonts("monospace"); err == nil {
99+ good, _ = os.ReadFile(paths[0])
100+ }
101+ inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)}
102+ for _, cut := range []int{12, 100, 1000, len(good) / 2} {
103+ if cut <= len(good) {
104+ inputs = append(inputs, good[:cut])
105+ }
106+ }
107+ for _, b := range inputs {
108+ _ = postNames(b)
109+ }
110+ // A "post" table that claims more glyphs than it holds.
111+ b := postFont(0x00020000, nil)
112+ binary.BigEndian.PutUint16(b[28+32:], 0xff)
113+ if spans := postNames(b); spans != nil {
114+ t.Fatalf("got %d spans from a bad table", len(spans))
115+ }
116+}
117+
118+func FuzzPostNames(f *testing.F) {
119+ f.Add(postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second"))
120+ f.Add(postFont(0x00030000, nil))
121+ f.Add([]byte{0, 1, 0, 0})
122+ f.Fuzz(func(t *testing.T, data []byte) {
123+ for g, s := range postNames(data) {
124+ if uint64(s.off)+uint64(s.n) > uint64(len(data)) {
125+ t.Fatalf("glyph %d: span %v past %d bytes", g, s, len(data))
126+ }
127+ }
128+ })
129+}
@@ -0,0 +1,129 @@
1+package glyph
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "strings"
7+ "testing"
8+)
9+
10+// nerdFont returns the path of an installed Nerd Font, or skips the test.
11+func nerdFont(t *testing.T) string {
12+ t.Helper()
13+ paths, err := FindFonts("JetBrainsMono Nerd Font Mono")
14+ if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") {
15+ t.Skip("no Nerd Font installed")
16+ }
17+ return paths[0]
18+}
19+
20+func TestNamesLookup(t *testing.T) {
21+ n, err := LoadNames(nerdFont(t))
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ defer n.Close()
26+ if n.Len() < 1000 {
27+ t.Fatalf("only %d names indexed", n.Len())
28+ }
29+ for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} {
30+ if r, ok := n.Lookup(name); !ok || r != want {
31+ t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want)
32+ }
33+ }
34+ if _, ok := n.Lookup("no-such-glyph"); ok {
35+ t.Error("an unknown name was found")
36+ }
37+ if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 {
38+ t.Errorf("Lookup allocates %v times", allocs)
39+ }
40+}
41+
42+func TestNilNamesLookup(t *testing.T) {
43+ var n *Names
44+ if _, ok := n.Lookup("fa-steam"); ok {
45+ t.Error("a nil index found a name")
46+ }
47+}
48+
49+// postFont builds a font file holding only a "post" table of the given
50+// version, with one glyph index per entry of indices and the custom names
51+// after them.
52+func postFont(version uint32, indices []uint16, names ...string) []byte {
53+ post := make([]byte, 34+2*len(indices))
54+ binary.BigEndian.PutUint32(post, version)
55+ binary.BigEndian.PutUint16(post[32:], uint16(len(indices)))
56+ for i, k := range indices {
57+ binary.BigEndian.PutUint16(post[34+2*i:], k)
58+ }
59+ for _, s := range names {
60+ post = append(post, byte(len(s)))
61+ post = append(post, s...)
62+ }
63+ b := make([]byte, 12+16, 12+16+len(post))
64+ b[5] = 1 // one table
65+ copy(b[12:], "post")
66+ binary.BigEndian.PutUint32(b[12+8:], 28)
67+ binary.BigEndian.PutUint32(b[12+12:], uint32(len(post)))
68+ return append(b, post...)
69+}
70+
71+func TestPostNames(t *testing.T) {
72+ // Glyph 0 has a standard name, 1 and 2 custom ones, 3 a custom index
73+ // past the names.
74+ data := postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second")
75+ spans := postNames(data)
76+ if len(spans) != 4 {
77+ t.Fatalf("%d spans, want 4", len(spans))
78+ }
79+ name := func(g int) string { return string(data[spans[g].off : spans[g].off+spans[g].n]) }
80+ if spans[0].n != 0 || spans[3].n != 0 {
81+ t.Errorf("standard or missing names gave spans: %v", spans)
82+ }
83+ if name(1) != "second" || name(2) != "first" {
84+ t.Errorf("names = %q, %q", name(1), name(2))
85+ }
86+}
87+
88+func TestPostNamesVersion3(t *testing.T) {
89+ // A version 3 table stores no names: nothing to index, no error.
90+ if spans := postNames(postFont(0x00030000, []uint16{259}, "x")); spans != nil {
91+ t.Fatalf("got %d spans", len(spans))
92+ }
93+}
94+
95+func TestPostNamesDamagedFont(t *testing.T) {
96+ // Truncated or garbage files give no names and never panic.
97+ var good []byte
98+ if paths, err := FindFonts("monospace"); err == nil {
99+ good, _ = os.ReadFile(paths[0])
100+ }
101+ inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)}
102+ for _, cut := range []int{12, 100, 1000, len(good) / 2} {
103+ if cut <= len(good) {
104+ inputs = append(inputs, good[:cut])
105+ }
106+ }
107+ for _, b := range inputs {
108+ _ = postNames(b)
109+ }
110+ // A "post" table that claims more glyphs than it holds.
111+ b := postFont(0x00020000, nil)
112+ binary.BigEndian.PutUint16(b[28+32:], 0xff)
113+ if spans := postNames(b); spans != nil {
114+ t.Fatalf("got %d spans from a bad table", len(spans))
115+ }
116+}
117+
118+func FuzzPostNames(f *testing.F) {
119+ f.Add(postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second"))
120+ f.Add(postFont(0x00030000, nil))
121+ f.Add([]byte{0, 1, 0, 0})
122+ f.Fuzz(func(t *testing.T, data []byte) {
123+ for g, s := range postNames(data) {
124+ if uint64(s.off)+uint64(s.n) > uint64(len(data)) {
125+ t.Fatalf("glyph %d: span %v past %d bytes", g, s, len(data))
126+ }
127+ }
128+ })
129+}
A internal/tray/color.go +97 -0
@@ -0,0 +1,97 @@
1+package tray
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/neferbar/internal/theme"
7+)
8+
9+// maxSamples caps the pixels counted per icon: enough for a color, and it
10+// keeps the sums below 2^32 (4096 * 255 * 255) and the cost of a large icon
11+// low.
12+const maxSamples = 4096
13+
14+// histogram finds the dominant color of an icon. Colors are counted in 4096
15+// buckets (4 bits per channel), weighted by opacity and saturation so a logo's
16+// color wins over its grey outline. It is reused between icons: no
17+// allocation.
18+type histogram struct {
19+ weight [4096]uint32
20+ sum [4096][3]uint32
21+}
22+
23+// dominant returns the dominant color of argb, an SNI pixmap: big-endian
24+// ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
25+// transparent or grey. Large icons are sampled evenly.
26+func (h *histogram) dominant(argb []byte) (RGB, bool) {
27+ if len(argb) < 4 {
28+ return RGB{}, false
29+ }
30+ clear(h.weight[:])
31+ clear(h.sum[:])
32+ // An odd pixel step crosses every column of a power-of-two wide icon,
33+ // so samples do not line up in a few columns.
34+ step := (len(argb)/4 + maxSamples - 1) / maxSamples
35+ if step > 1 {
36+ step |= 1
37+ }
38+ for i := 0; i+3 < len(argb); i += 4 * step {
39+ a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
40+ if a < 128 {
41+ continue
42+ }
43+ hi, lo := max(r, g, b), min(r, g, b)
44+ chroma := hi - lo
45+ if chroma < 40 {
46+ continue // grey, black or white: outline and shading
47+ }
48+ w := a * chroma >> 8
49+ k := (r>>4)<<8 | (g>>4)<<4 | b>>4
50+ h.weight[k] += w
51+ h.sum[k][0] += r * w
52+ h.sum[k][1] += g * w
53+ h.sum[k][2] += b * w
54+ }
55+ best := -1
56+ for k, w := range h.weight {
57+ if w > 0 && (best < 0 || w > h.weight[best]) {
58+ best = k
59+ }
60+ }
61+ if best < 0 {
62+ return RGB{}, false
63+ }
64+ w := h.weight[best]
65+ s := h.sum[best]
66+ return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true
67+}
68+
69+// readable moves c toward fg until it has enough contrast with bg, so a dark
70+// logo still shows on a dark bar.
71+func readable(c, bg, fg RGB) RGB {
72+ const minContrast = 3.0 // WCAG's minimum for large text and icons
73+ for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
74+ c = theme.Mix(c, fg, 0.25)
75+ }
76+ return c
77+}
78+
79+// contrast is the WCAG contrast ratio of two colors, from 1 to 21.
80+func contrast(a, b RGB) float64 {
81+ la, lb := luminance(a), luminance(b)
82+ if la < lb {
83+ la, lb = lb, la
84+ }
85+ return (la + 0.05) / (lb + 0.05)
86+}
87+
88+func luminance(c RGB) float64 {
89+ lin := func(v uint8) float64 {
90+ x := float64(v) / 255
91+ if x <= 0.04045 {
92+ return x / 12.92
93+ }
94+ return math.Pow((x+0.055)/1.055, 2.4)
95+ }
96+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
97+}
@@ -0,0 +1,97 @@
1+package tray
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/neferbar/internal/theme"
7+)
8+
9+// maxSamples caps the pixels counted per icon: enough for a color, and it
10+// keeps the sums below 2^32 (4096 * 255 * 255) and the cost of a large icon
11+// low.
12+const maxSamples = 4096
13+
14+// histogram finds the dominant color of an icon. Colors are counted in 4096
15+// buckets (4 bits per channel), weighted by opacity and saturation so a logo's
16+// color wins over its grey outline. It is reused between icons: no
17+// allocation.
18+type histogram struct {
19+ weight [4096]uint32
20+ sum [4096][3]uint32
21+}
22+
23+// dominant returns the dominant color of argb, an SNI pixmap: big-endian
24+// ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
25+// transparent or grey. Large icons are sampled evenly.
26+func (h *histogram) dominant(argb []byte) (RGB, bool) {
27+ if len(argb) < 4 {
28+ return RGB{}, false
29+ }
30+ clear(h.weight[:])
31+ clear(h.sum[:])
32+ // An odd pixel step crosses every column of a power-of-two wide icon,
33+ // so samples do not line up in a few columns.
34+ step := (len(argb)/4 + maxSamples - 1) / maxSamples
35+ if step > 1 {
36+ step |= 1
37+ }
38+ for i := 0; i+3 < len(argb); i += 4 * step {
39+ a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
40+ if a < 128 {
41+ continue
42+ }
43+ hi, lo := max(r, g, b), min(r, g, b)
44+ chroma := hi - lo
45+ if chroma < 40 {
46+ continue // grey, black or white: outline and shading
47+ }
48+ w := a * chroma >> 8
49+ k := (r>>4)<<8 | (g>>4)<<4 | b>>4
50+ h.weight[k] += w
51+ h.sum[k][0] += r * w
52+ h.sum[k][1] += g * w
53+ h.sum[k][2] += b * w
54+ }
55+ best := -1
56+ for k, w := range h.weight {
57+ if w > 0 && (best < 0 || w > h.weight[best]) {
58+ best = k
59+ }
60+ }
61+ if best < 0 {
62+ return RGB{}, false
63+ }
64+ w := h.weight[best]
65+ s := h.sum[best]
66+ return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true
67+}
68+
69+// readable moves c toward fg until it has enough contrast with bg, so a dark
70+// logo still shows on a dark bar.
71+func readable(c, bg, fg RGB) RGB {
72+ const minContrast = 3.0 // WCAG's minimum for large text and icons
73+ for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
74+ c = theme.Mix(c, fg, 0.25)
75+ }
76+ return c
77+}
78+
79+// contrast is the WCAG contrast ratio of two colors, from 1 to 21.
80+func contrast(a, b RGB) float64 {
81+ la, lb := luminance(a), luminance(b)
82+ if la < lb {
83+ la, lb = lb, la
84+ }
85+ return (la + 0.05) / (lb + 0.05)
86+}
87+
88+func luminance(c RGB) float64 {
89+ lin := func(v uint8) float64 {
90+ x := float64(v) / 255
91+ if x <= 0.04045 {
92+ return x / 12.92
93+ }
94+ return math.Pow((x+0.055)/1.055, 2.4)
95+ }
96+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
97+}
A internal/tray/color_test.go +79 -0
@@ -0,0 +1,79 @@
1+package tray
2+
3+import "testing"
4+
5+// pixmap builds an ARGB32 icon from pixels of the given colors, n of each.
6+func pixmap(colors map[[4]uint8]int) []byte {
7+ var b []byte
8+ for c, n := range colors {
9+ for range n {
10+ b = append(b, c[:]...)
11+ }
12+ }
13+ return b
14+}
15+
16+func TestDominant(t *testing.T) {
17+ var h histogram
18+ // A blue logo with a larger grey outline and a transparent background.
19+ got, ok := h.dominant(pixmap(map[[4]uint8]int{
20+ {255, 30, 60, 200}: 40, // blue
21+ {255, 128, 128, 128}: 100,
22+ {0, 255, 0, 0}: 500, // transparent red
23+ }))
24+ if !ok || got != (RGB{30, 60, 200}) {
25+ t.Fatalf("dominant = %v, %v", got, ok)
26+ }
27+ if _, ok := h.dominant(pixmap(map[[4]uint8]int{{255, 200, 200, 200}: 10})); ok {
28+ t.Fatal("a grey icon has no dominant color")
29+ }
30+ if _, ok := h.dominant(nil); ok {
31+ t.Fatal("an empty icon has no dominant color")
32+ }
33+ icon := pixmap(map[[4]uint8]int{{255, 30, 60, 200}: 1024})
34+ if allocs := testing.AllocsPerRun(50, func() { h.dominant(icon) }); allocs != 0 {
35+ t.Fatalf("dominant allocates %v times", allocs)
36+ }
37+}
38+
39+func TestDominantLargeIcon(t *testing.T) {
40+ // 512x512 of one saturated color: summing every pixel would overflow.
41+ var h histogram
42+ icon := pixmap(map[[4]uint8]int{{255, 250, 20, 20}: 512 * 512})
43+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
44+ t.Fatalf("dominant = %v, %v", got, ok)
45+ }
46+}
47+
48+func TestDominantLargeIconOffColumns(t *testing.T) {
49+ // 512x512, transparent but for a red logo in columns 10..49: a stride of
50+ // 64 pixels would sample only columns 0, 64, 128... and miss it.
51+ const side = 512
52+ icon := make([]byte, 0, side*side*4)
53+ for range side {
54+ for x := range side {
55+ if x >= 10 && x < 50 {
56+ icon = append(icon, 255, 250, 20, 20)
57+ } else {
58+ icon = append(icon, 0, 0, 0, 0)
59+ }
60+ }
61+ }
62+ var h histogram
63+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
64+ t.Fatalf("dominant = %v, %v", got, ok)
65+ }
66+}
67+
68+func TestReadable(t *testing.T) {
69+ bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
70+ dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
71+ got := readable(dark, bg, fg)
72+ if contrast(got, bg) < 3 {
73+ t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg))
74+ }
75+ bright := RGB{0xf9, 0xe2, 0xaf}
76+ if readable(bright, bg, fg) != bright {
77+ t.Fatal("a readable color must not change")
78+ }
79+}
@@ -0,0 +1,79 @@
1+package tray
2+
3+import "testing"
4+
5+// pixmap builds an ARGB32 icon from pixels of the given colors, n of each.
6+func pixmap(colors map[[4]uint8]int) []byte {
7+ var b []byte
8+ for c, n := range colors {
9+ for range n {
10+ b = append(b, c[:]...)
11+ }
12+ }
13+ return b
14+}
15+
16+func TestDominant(t *testing.T) {
17+ var h histogram
18+ // A blue logo with a larger grey outline and a transparent background.
19+ got, ok := h.dominant(pixmap(map[[4]uint8]int{
20+ {255, 30, 60, 200}: 40, // blue
21+ {255, 128, 128, 128}: 100,
22+ {0, 255, 0, 0}: 500, // transparent red
23+ }))
24+ if !ok || got != (RGB{30, 60, 200}) {
25+ t.Fatalf("dominant = %v, %v", got, ok)
26+ }
27+ if _, ok := h.dominant(pixmap(map[[4]uint8]int{{255, 200, 200, 200}: 10})); ok {
28+ t.Fatal("a grey icon has no dominant color")
29+ }
30+ if _, ok := h.dominant(nil); ok {
31+ t.Fatal("an empty icon has no dominant color")
32+ }
33+ icon := pixmap(map[[4]uint8]int{{255, 30, 60, 200}: 1024})
34+ if allocs := testing.AllocsPerRun(50, func() { h.dominant(icon) }); allocs != 0 {
35+ t.Fatalf("dominant allocates %v times", allocs)
36+ }
37+}
38+
39+func TestDominantLargeIcon(t *testing.T) {
40+ // 512x512 of one saturated color: summing every pixel would overflow.
41+ var h histogram
42+ icon := pixmap(map[[4]uint8]int{{255, 250, 20, 20}: 512 * 512})
43+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
44+ t.Fatalf("dominant = %v, %v", got, ok)
45+ }
46+}
47+
48+func TestDominantLargeIconOffColumns(t *testing.T) {
49+ // 512x512, transparent but for a red logo in columns 10..49: a stride of
50+ // 64 pixels would sample only columns 0, 64, 128... and miss it.
51+ const side = 512
52+ icon := make([]byte, 0, side*side*4)
53+ for range side {
54+ for x := range side {
55+ if x >= 10 && x < 50 {
56+ icon = append(icon, 255, 250, 20, 20)
57+ } else {
58+ icon = append(icon, 0, 0, 0, 0)
59+ }
60+ }
61+ }
62+ var h histogram
63+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
64+ t.Fatalf("dominant = %v, %v", got, ok)
65+ }
66+}
67+
68+func TestReadable(t *testing.T) {
69+ bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
70+ dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
71+ got := readable(dark, bg, fg)
72+ if contrast(got, bg) < 3 {
73+ t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg))
74+ }
75+ bright := RGB{0xf9, 0xe2, 0xaf}
76+ if readable(bright, bg, fg) != bright {
77+ t.Fatal("a readable color must not change")
78+ }
79+}
A internal/tray/resolve.go +415 -0
@@ -0,0 +1,415 @@
1+package tray
2+
3+import (
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.
20+type 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.
29+type 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.
39+func 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.
51+var 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.
55+var 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.
66+var 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 "".
79+func 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".
91+var 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.
106+func (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+
160+func 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.
168+func (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.
176+func (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".
185+func 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.
195+func (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+
209+func (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.
223+func 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+
235+func 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.
247+func (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+
261+func (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.
268+func (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.
281+func (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.
300+func (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+
310+func 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.
329+func 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.
368+func 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+}
@@ -0,0 +1,415 @@
1+package tray
2+
3+import (
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.
20+type 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.
29+type 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.
39+func 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.
51+var 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.
55+var 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.
66+var 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 "".
79+func 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".
91+var 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.
106+func (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+
160+func 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.
168+func (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.
176+func (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".
185+func 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.
195+func (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+
209+func (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.
223+func 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+
235+func 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.
247+func (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+
261+func (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.
268+func (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.
281+func (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.
300+func (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+
310+func 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.
329+func 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.
368+func 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+}
A internal/tray/resolve_test.go +134 -0
@@ -0,0 +1,134 @@
1+package tray
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+)
9+
10+// fakeFont knows a few glyph names; each maps to a letter so results read
11+// easily.
12+var fakeFont = map[string]rune{
13+ "fa-steam": 'S',
14+ "custom-obsidian": 'O',
15+ "md-volume_off": 'M',
16+ "md-gamepad_variant": 'G',
17+ "md-web": 'W',
18+ "md-lan": 'N',
19+ "md-alpha_z_circle": 'Z',
20+ "md-google_chrome": 'C',
21+ "md-wifi": 'F',
22+ "md-sync": 'Y',
23+ "md-chat": 'H',
24+ "md-cog": 'K',
25+}
26+
27+func lookup(name string) (rune, bool) {
28+ r, ok := fakeFont[name]
29+ return r, ok
30+}
31+
32+func writeDesktop(t *testing.T, dir, name, body string) {
33+ t.Helper()
34+ apps := filepath.Join(dir, "applications")
35+ if err := os.MkdirAll(apps, 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil {
39+ t.Fatal(err)
40+ }
41+}
42+
43+func TestResolve(t *testing.T) {
44+ dir := t.TempDir()
45+ writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n")
46+ writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
47+ writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
48+ writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
49+ writeDesktop(t, dir, "org.gnome.Settings", "[Desktop Entry]\nName=Settings\nCategories=Settings;\n")
50+ writeDesktop(t, dir, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Chat;\n")
51+ writeDesktop(t, dir, "ghost", "[Desktop Entry]\nName=Ghost\nHidden=true\n")
52+ later := t.TempDir()
53+ writeDesktop(t, later, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Game;\n")
54+ writeDesktop(t, later, "ghost", "[Desktop Entry]\nName=Ghost\nCategories=Game;\n")
55+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir, later})
56+
57+ for _, c := range []struct {
58+ name, id, icon, title, want string
59+ }{
60+ {"brand from the id", "steam", "", "", "S"},
61+ {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
62+ {"brand from the whole name", "google-chrome", "", "", "C"},
63+ {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
64+ {"status icon name before a brand", "steam", "audio-volume-muted", "", "M"},
65+ {"status word inside an app name is not a status", "signal-desktop", "signal-desktop", "", "S"},
66+ {"wireless status", "nm-applet", "nm-signal-75", "", "F"},
67+ {"title words are not brands", "", "", "Sync complete", "S"},
68+ {"whole title as a brand", "", "", "Google Chrome", "C"},
69+ {"title words find no desktop file", "", "", "Open settings", "O"},
70+ {"last part of a reverse-DNS name", "settings", "", "", "K"},
71+ {"earlier data dir wins", "chatter", "", "", "H"},
72+ {"hidden desktop file counts as deleted", "ghost", "", "", "G"},
73+ {"category from the desktop file", "racer", "racer-game", "", "G"},
74+ {"most specific category wins", "chromium", "", "", "W"},
75+ {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
76+ {"literal override, any case", "sunshine", "", "", "☀"},
77+ {"glyph-name override", "", "", "mail", "N"},
78+ {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"},
79+ {"letter without the font glyph", "Quux", "", "", "Q"},
80+ {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"},
81+ {"nothing to go on", "", "", "", "•"},
82+ } {
83+ if got := r.Resolve(c.id, c.icon, c.title); got != c.want {
84+ t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want)
85+ }
86+ }
87+}
88+
89+func TestResolveFindsAppsInstalledLater(t *testing.T) {
90+ dir := t.TempDir()
91+ r := NewResolver(lookup, nil, []string{dir})
92+ if got := r.Resolve("racer", "", ""); got != "R" {
93+ t.Fatalf("before install: %q", got)
94+ }
95+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96+ // Make sure the directory's mtime differs from the one at the load,
97+ // even on a coarse clock.
98+ later := time.Now().Add(time.Hour)
99+ if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil {
100+ t.Fatal(err)
101+ }
102+ if got := r.Resolve("racer", "", ""); got != "G" {
103+ t.Fatalf("after install: %q, want G", got)
104+ }
105+}
106+
107+func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) {
108+ // A directory dated in the future does not make every miss rescan.
109+ dir := t.TempDir()
110+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
111+ future := time.Now().Add(24 * time.Hour)
112+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
113+ t.Fatal(err)
114+ }
115+ r := NewResolver(lookup, nil, []string{dir})
116+ r.Resolve("unknown", "", "")
117+ if r.changed() {
118+ t.Fatal("an unchanged directory counts as changed")
119+ }
120+}
121+
122+func TestEmptyOverrideHides(t *testing.T) {
123+ r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
124+ if got := r.Resolve("steam", "", ""); got != "" {
125+ t.Fatalf("Resolve = %q, want empty", got)
126+ }
127+}
128+
129+func TestWords(t *testing.T) {
130+ got := words("org.telegram.desktop_2-Tray 42")
131+ if len(got) != 1 || got[0] != "telegram" {
132+ t.Fatalf("words = %q", got)
133+ }
134+}
@@ -0,0 +1,134 @@
1+package tray
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+)
9+
10+// fakeFont knows a few glyph names; each maps to a letter so results read
11+// easily.
12+var fakeFont = map[string]rune{
13+ "fa-steam": 'S',
14+ "custom-obsidian": 'O',
15+ "md-volume_off": 'M',
16+ "md-gamepad_variant": 'G',
17+ "md-web": 'W',
18+ "md-lan": 'N',
19+ "md-alpha_z_circle": 'Z',
20+ "md-google_chrome": 'C',
21+ "md-wifi": 'F',
22+ "md-sync": 'Y',
23+ "md-chat": 'H',
24+ "md-cog": 'K',
25+}
26+
27+func lookup(name string) (rune, bool) {
28+ r, ok := fakeFont[name]
29+ return r, ok
30+}
31+
32+func writeDesktop(t *testing.T, dir, name, body string) {
33+ t.Helper()
34+ apps := filepath.Join(dir, "applications")
35+ if err := os.MkdirAll(apps, 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil {
39+ t.Fatal(err)
40+ }
41+}
42+
43+func TestResolve(t *testing.T) {
44+ dir := t.TempDir()
45+ writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n")
46+ writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
47+ writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
48+ writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
49+ writeDesktop(t, dir, "org.gnome.Settings", "[Desktop Entry]\nName=Settings\nCategories=Settings;\n")
50+ writeDesktop(t, dir, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Chat;\n")
51+ writeDesktop(t, dir, "ghost", "[Desktop Entry]\nName=Ghost\nHidden=true\n")
52+ later := t.TempDir()
53+ writeDesktop(t, later, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Game;\n")
54+ writeDesktop(t, later, "ghost", "[Desktop Entry]\nName=Ghost\nCategories=Game;\n")
55+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir, later})
56+
57+ for _, c := range []struct {
58+ name, id, icon, title, want string
59+ }{
60+ {"brand from the id", "steam", "", "", "S"},
61+ {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
62+ {"brand from the whole name", "google-chrome", "", "", "C"},
63+ {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
64+ {"status icon name before a brand", "steam", "audio-volume-muted", "", "M"},
65+ {"status word inside an app name is not a status", "signal-desktop", "signal-desktop", "", "S"},
66+ {"wireless status", "nm-applet", "nm-signal-75", "", "F"},
67+ {"title words are not brands", "", "", "Sync complete", "S"},
68+ {"whole title as a brand", "", "", "Google Chrome", "C"},
69+ {"title words find no desktop file", "", "", "Open settings", "O"},
70+ {"last part of a reverse-DNS name", "settings", "", "", "K"},
71+ {"earlier data dir wins", "chatter", "", "", "H"},
72+ {"hidden desktop file counts as deleted", "ghost", "", "", "G"},
73+ {"category from the desktop file", "racer", "racer-game", "", "G"},
74+ {"most specific category wins", "chromium", "", "", "W"},
75+ {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
76+ {"literal override, any case", "sunshine", "", "", "☀"},
77+ {"glyph-name override", "", "", "mail", "N"},
78+ {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"},
79+ {"letter without the font glyph", "Quux", "", "", "Q"},
80+ {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"},
81+ {"nothing to go on", "", "", "", "•"},
82+ } {
83+ if got := r.Resolve(c.id, c.icon, c.title); got != c.want {
84+ t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want)
85+ }
86+ }
87+}
88+
89+func TestResolveFindsAppsInstalledLater(t *testing.T) {
90+ dir := t.TempDir()
91+ r := NewResolver(lookup, nil, []string{dir})
92+ if got := r.Resolve("racer", "", ""); got != "R" {
93+ t.Fatalf("before install: %q", got)
94+ }
95+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96+ // Make sure the directory's mtime differs from the one at the load,
97+ // even on a coarse clock.
98+ later := time.Now().Add(time.Hour)
99+ if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil {
100+ t.Fatal(err)
101+ }
102+ if got := r.Resolve("racer", "", ""); got != "G" {
103+ t.Fatalf("after install: %q, want G", got)
104+ }
105+}
106+
107+func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) {
108+ // A directory dated in the future does not make every miss rescan.
109+ dir := t.TempDir()
110+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
111+ future := time.Now().Add(24 * time.Hour)
112+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
113+ t.Fatal(err)
114+ }
115+ r := NewResolver(lookup, nil, []string{dir})
116+ r.Resolve("unknown", "", "")
117+ if r.changed() {
118+ t.Fatal("an unchanged directory counts as changed")
119+ }
120+}
121+
122+func TestEmptyOverrideHides(t *testing.T) {
123+ r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
124+ if got := r.Resolve("steam", "", ""); got != "" {
125+ t.Fatalf("Resolve = %q, want empty", got)
126+ }
127+}
128+
129+func TestWords(t *testing.T) {
130+ got := words("org.telegram.desktop_2-Tray 42")
131+ if len(got) != 1 || got[0] != "telegram" {
132+ t.Fatalf("words = %q", got)
133+ }
134+}
A internal/tray/tray.go +732 -0
@@ -0,0 +1,732 @@
1+// Package tray shows the system tray (StatusNotifierItem) as one line of
2+// Nerd Font glyphs, for "neferbar tray".
3+//
4+// It acts as the StatusNotifierWatcher when no other program does, and as a
5+// plain host of the existing watcher otherwise, so it can run next to another
6+// panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage;
7+// calls to applications never block it, as their replies are matched by
8+// serial when they arrive.
9+package tray
10+
11+import (
12+ "bytes"
13+ "context"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "log/slog"
18+ "os"
19+ "slices"
20+ "strconv"
21+ "strings"
22+
23+ "github.com/bnema/zerobus"
24+)
25+
26+const (
27+ watcherName = "org.kde.StatusNotifierWatcher"
28+ watcherPath = "/StatusNotifierWatcher"
29+ watcherIface = "org.kde.StatusNotifierWatcher"
30+ itemIface = "org.kde.StatusNotifierItem"
31+ itemPath = "/StatusNotifierItem"
32+ propsIface = "org.freedesktop.DBus.Properties"
33+ busName = "org.freedesktop.DBus"
34+ busPath = "/org/freedesktop/DBus"
35+)
36+
37+// RGB is a color.
38+type RGB = [3]uint8
39+
40+// Options configure a Tray.
41+type Options struct {
42+ Resolver *Resolver
43+ // Foreground colors items without an icon to take a color from;
44+ // Background is what tinted icons must stand out against; Accent marks
45+ // items that need attention.
46+ Foreground, Background, Accent RGB
47+ Out io.Writer // one line per change
48+ Log *slog.Logger
49+}
50+
51+type status uint8
52+
53+const (
54+ active status = iota
55+ passive
56+ attention
57+)
58+
59+type item struct {
60+ service string // as listed by the watcher: "bus/path" or "bus"
61+ dest string // where to send calls: the owner, or the registered bus name
62+ owner string // the unique name; signals come from it
63+ path string
64+
65+ id, iconName, title string
66+ status status
67+ icon string // resolved glyph; "" hides the item
68+ resolved bool
69+ color RGB
70+ hasColor bool
71+ loaded bool // GetAll answered at least once
72+ pending uint32 // serial of a GetAll in flight
73+ stale bool // a change arrived while GetAll was in flight
74+}
75+
76+type pendKind uint8
77+
78+const (
79+ pendGetAll pendKind = iota + 1
80+ pendItems // the watcher's RegisteredStatusNotifierItems
81+ pendName // RequestName of the watcher name
82+ pendIgnore
83+)
84+
85+type pend struct {
86+ serial uint32
87+ kind pendKind
88+ it *item
89+}
90+
91+// Tray is one running tray.
92+type Tray struct {
93+ c *zerobus.Conn
94+ opt Options
95+ watcher bool // this process owns the watcher name
96+ host string
97+ items []*item
98+ pending []pend
99+ frame []byte
100+ last []byte
101+ hist histogram
102+ err error // first fatal error from dispatch
103+}
104+
105+// Run serves the tray on c until ctx ends or the bus connection fails. It
106+// closes c.
107+func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
108+ t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
109+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
110+ if t.opt.Log == nil {
111+ t.opt.Log = slog.New(slog.DiscardHandler)
112+ }
113+ stop := context.AfterFunc(ctx, func() { _ = c.Close() })
114+ defer stop()
115+ defer func() { _ = c.Close() }()
116+
117+ // Calls made from here on only go to the bus, which always answers.
118+ // Handle sees what arrives meanwhile.
119+ c.Handle = t.dispatch
120+ for _, rule := range [...]string{
121+ "type='signal',interface='" + itemIface + "'",
122+ // The bus matches a well-known sender by its current owner.
123+ "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
124+ "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
125+ } {
126+ if err := c.AddMatch(rule); err != nil {
127+ return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
128+ }
129+ }
130+ if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
131+ return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
132+ }
133+ t.claim()
134+ if err := t.render(true); err != nil {
135+ return t.exit(ctx, err)
136+ }
137+ for t.err == nil {
138+ m, err := c.ReadMessage()
139+ if err != nil {
140+ return t.exit(ctx, err)
141+ }
142+ t.dispatch(m)
143+ }
144+ // A send fails too when ctx ends during dispatch and closes c.
145+ return t.exit(ctx, t.err)
146+}
147+
148+// exit returns nil when the error comes from ctx ending.
149+func (t *Tray) exit(ctx context.Context, err error) error {
150+ if ctx.Err() != nil {
151+ return nil
152+ }
153+ return err
154+}
155+
156+func (t *Tray) send() uint32 {
157+ serial, err := t.c.Send()
158+ if err != nil {
159+ if errors.Is(err, zerobus.ErrInvalid) {
160+ // A name or path an application gave us that D-Bus refuses.
161+ t.opt.Log.Warn("tray: message not sent", "err", err)
162+ return 0
163+ }
164+ t.fatal(err)
165+ return 0
166+ }
167+ return serial
168+}
169+
170+func (t *Tray) fatal(err error) {
171+ if t.err == nil {
172+ t.err = err
173+ }
174+}
175+
176+func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
177+ if serial != 0 {
178+ t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
179+ }
180+}
181+
182+// claim asks for the watcher name; the reply decides the mode.
183+func (t *Tray) claim() {
184+ e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
185+ e.Str(watcherName)
186+ e.Uint32(zerobus.NameFlagDoNotQueue)
187+ t.expect(t.send(), pendName, nil)
188+}
189+
190+func (t *Tray) dispatch(m *zerobus.Message) {
191+ switch m.Type {
192+ case zerobus.TypeMethodReturn, zerobus.TypeError:
193+ i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
194+ if i < 0 {
195+ return
196+ }
197+ p := t.pending[i]
198+ t.pending = slices.Delete(t.pending, i, i+1)
199+ t.reply(p, m)
200+ case zerobus.TypeSignal:
201+ t.signal(m)
202+ case zerobus.TypeMethodCall:
203+ t.call(m)
204+ }
205+}
206+
207+func (t *Tray) reply(p pend, m *zerobus.Message) {
208+ failed := m.Type == zerobus.TypeError
209+ switch p.kind {
210+ case pendName:
211+ code := m.Body().Uint32()
212+ if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
213+ t.becomeWatcher()
214+ return
215+ }
216+ t.becomeHost()
217+ case pendItems:
218+ if failed {
219+ t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
220+ return
221+ }
222+ r := m.Body()
223+ if m.Signature != "v" || r.Variant() != "as" {
224+ t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
225+ return
226+ }
227+ end := r.Array('s')
228+ for r.More(end) {
229+ t.add(r.Str(), "")
230+ }
231+ case pendGetAll:
232+ it := p.it
233+ if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
234+ return // removed, or an older request
235+ }
236+ it.pending = 0
237+ if failed || m.Signature != "a{sv}" {
238+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
239+ if !it.loaded {
240+ t.remove(it)
241+ }
242+ return
243+ }
244+ if it.owner != m.Sender {
245+ it.owner = strings.Clone(m.Sender)
246+ }
247+ t.load(it, m.Body())
248+ if it.stale {
249+ t.refresh(it)
250+ }
251+ }
252+}
253+
254+func (t *Tray) becomeWatcher() {
255+ t.watcher = true
256+ t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
257+ // Items that waited for a host register now.
258+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
259+ t.send()
260+}
261+
262+func (t *Tray) becomeHost() {
263+ t.watcher = false
264+ t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
265+ t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
266+ t.expect(t.send(), pendIgnore, nil)
267+ e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
268+ e.Str(watcherIface)
269+ e.Str("RegisteredStatusNotifierItems")
270+ t.expect(t.send(), pendItems, nil)
271+}
272+
273+func (t *Tray) signal(m *zerobus.Message) {
274+ switch m.Interface {
275+ case busName:
276+ if m.Member != "NameOwnerChanged" || m.Sender != busName {
277+ return
278+ }
279+ r := m.Body()
280+ name, old, owner := r.Str(), r.Str(), r.Str()
281+ switch {
282+ case r.Err() != nil:
283+ case owner == "":
284+ t.vanished(name)
285+ case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286+ // Another watcher replaced the one we used: its items are
287+ // the ones to show now, not a late list from the old one.
288+ t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
289+ t.dropAll()
290+ t.becomeHost()
291+ }
292+ case watcherIface:
293+ if t.watcher || m.Signature != "s" {
294+ return // our own signals come back to us too
295+ }
296+ s := m.Body().Str()
297+ switch m.Member {
298+ case "StatusNotifierItemRegistered":
299+ t.add(s, "")
300+ case "StatusNotifierItemUnregistered":
301+ svc, _, _ := normService(s)
302+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
303+ t.remove(t.items[i])
304+ }
305+ }
306+ case itemIface:
307+ switch m.Member {
308+ case "NewToolTip", "NewMenu", "NewOverlayIcon":
309+ return // nothing the tray shows
310+ }
311+ for _, it := range t.items {
312+ if it.owner == m.Sender && it.path == m.Path {
313+ t.refresh(it)
314+ return
315+ }
316+ }
317+ }
318+}
319+
320+// vanished drops the items of a bus name that left, and takes over the
321+// watcher name when its owner left.
322+func (t *Tray) vanished(name string) {
323+ changed := false
324+ for i := len(t.items) - 1; i >= 0; i-- {
325+ if it := t.items[i]; it.owner == name || it.dest == name {
326+ t.drop(i)
327+ changed = true
328+ }
329+ }
330+ if changed {
331+ t.renderOrFail()
332+ }
333+ if name == watcherName && !t.watcher {
334+ // Items register again with the new watcher.
335+ t.dropAll()
336+ t.claim()
337+ }
338+}
339+
340+func (t *Tray) dropAll() {
341+ if len(t.items) == 0 {
342+ return
343+ }
344+ for i := len(t.items) - 1; i >= 0; i-- {
345+ t.drop(i)
346+ }
347+ t.renderOrFail()
348+}
349+
350+// normService turns the watcher's form of an item, "bus/path" or "bus", into
351+// "bus/path", its bus and its path.
352+func normService(s string) (service, bus, path string) {
353+ bus, p, ok := strings.Cut(s, "/")
354+ if !ok {
355+ return bus + itemPath, bus, itemPath
356+ }
357+ return s, bus, "/" + p
358+}
359+
360+// validBusName reports whether s is a unique (":1.42") or well-known
361+// ("org.example.App") bus name, as D-Bus defines them.
362+func validBusName(s string) bool {
363+ if s == "" || len(s) > 255 {
364+ return false
365+ }
366+ unique := s[0] == ':'
367+ if unique {
368+ s = s[1:]
369+ }
370+ n := 0
371+ for more := true; more; n++ {
372+ var e string
373+ e, s, more = strings.Cut(s, ".")
374+ if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
375+ return false
376+ }
377+ for _, c := range []byte(e) {
378+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
379+ return false
380+ }
381+ }
382+ }
383+ return n >= 2
384+}
385+
386+// addResult says what add did.
387+type addResult uint8
388+
389+const (
390+ addNew addResult = iota
391+ addKnown
392+ addInvalid // D-Bus refuses its bus name or path
393+)
394+
395+// add starts tracking an item. service is the watcher's form, "bus/path" or
396+// "bus"; owner is its unique name when known.
397+func (t *Tray) add(service, owner string) addResult {
398+ service, dest, path := normService(service)
399+ if owner == "" && strings.HasPrefix(dest, ":") {
400+ owner = dest
401+ }
402+ for _, it := range t.items {
403+ if it.service == service || owner != "" && it.owner == owner && it.path == path {
404+ return addKnown
405+ }
406+ }
407+ it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
408+ t.items = append(t.items, it)
409+ t.refresh(it)
410+ if it.pending == 0 {
411+ // Never shown nor announced: forget it quietly.
412+ t.items = t.items[:len(t.items)-1]
413+ return addInvalid
414+ }
415+ return addNew
416+}
417+
418+func (t *Tray) remove(it *item) {
419+ if i := slices.Index(t.items, it); i >= 0 {
420+ t.drop(i)
421+ t.renderOrFail()
422+ }
423+}
424+
425+func (t *Tray) drop(i int) {
426+ it := t.items[i]
427+ t.items = slices.Delete(t.items, i, i+1)
428+ if t.watcher {
429+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
430+ t.send()
431+ }
432+}
433+
434+// refresh asks an item for its properties, unless a request is in flight.
435+func (t *Tray) refresh(it *item) {
436+ if it.pending != 0 {
437+ it.stale = true
438+ return
439+ }
440+ it.stale = false
441+ dest := it.dest
442+ if it.owner != "" {
443+ dest = it.owner
444+ }
445+ t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
446+ it.pending = t.send()
447+ t.expect(it.pending, pendGetAll, it)
448+}
449+
450+// load reads a GetAll reply into it and redraws when something shown
451+// changed. Strings are copied only when they change.
452+func (t *Tray) load(it *item, r *zerobus.Reader) {
453+ was := *it
454+ var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
455+ end := r.Array('{')
456+ for r.More(end) {
457+ r.Struct()
458+ key := r.Str()
459+ sig := r.Variant()
460+ switch {
461+ case sig == "s" && key == "Id":
462+ setStr(&it.id, r.Str())
463+ case sig == "s" && key == "Title":
464+ setStr(&it.title, r.Str())
465+ case sig == "s" && key == "IconName":
466+ setStr(&it.iconName, r.Str())
467+ case sig == "s" && key == "Status":
468+ it.status = parseStatus(r.Str())
469+ case sig == "a(iiay)" && key == "IconPixmap":
470+ pixmaps = pickPixmap(r)
471+ case sig == "a(iiay)" && key == "AttentionIconPixmap":
472+ attentionPixmaps = pickPixmap(r)
473+ default:
474+ r.Skip(sig)
475+ }
476+ }
477+ if r.Err() != nil {
478+ t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
479+ *it = was
480+ return
481+ }
482+ it.loaded = true
483+ if it.status == attention && attentionPixmaps != nil {
484+ pixmaps = attentionPixmaps
485+ }
486+ it.color, it.hasColor = t.hist.dominant(pixmaps)
487+ if it.hasColor {
488+ it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
489+ }
490+ if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
491+ it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
492+ }
493+ t.renderOrFail()
494+}
495+
496+// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
497+// best suited to find a color: the largest up to 64x64, else the smallest.
498+// The result points into the message buffer.
499+func pickPixmap(r *zerobus.Reader) []byte {
500+ var best []byte
501+ bestN := 0
502+ end := r.Array('(')
503+ for r.More(end) {
504+ r.Struct()
505+ w, h := int(r.Int32()), int(r.Int32())
506+ data := r.ByteArray()
507+ n := w * h
508+ if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
509+ continue
510+ }
511+ if best == nil || better(n, bestN) {
512+ best, bestN = data, n
513+ }
514+ }
515+ return best
516+}
517+
518+// better reports whether an icon of n pixels suits the color search better
519+// than one of cur pixels: the largest up to 64x64, else the smallest.
520+func better(n, cur int) bool {
521+ const enough = 64 * 64
522+ if cur > enough {
523+ return n < cur
524+ }
525+ return n > cur && n <= enough
526+}
527+
528+func setStr(dst *string, s string) {
529+ if *dst != s {
530+ *dst = strings.Clone(s)
531+ }
532+}
533+
534+func parseStatus(s string) status {
535+ switch s {
536+ case "Passive":
537+ return passive
538+ case "NeedsAttention":
539+ return attention
540+ }
541+ return active
542+}
543+
544+// call answers method calls when this process is the watcher.
545+func (t *Tray) call(m *zerobus.Message) {
546+ noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
547+ if !t.watcher || m.Path != watcherPath {
548+ if !noReply {
549+ t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
550+ t.send()
551+ }
552+ return
553+ }
554+ switch {
555+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
556+ s := m.Body().Str()
557+ if s == "" {
558+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
559+ return
560+ }
561+ if strings.HasPrefix(s, "/") {
562+ s = m.Sender + s // registered by object path
563+ } else if validBusName(s) {
564+ s += itemPath
565+ } else {
566+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
567+ return
568+ }
569+ // add copies s before the next read, and sends only a GetAll.
570+ switch t.add(s, m.Sender) {
571+ case addInvalid:
572+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
573+ return
574+ case addNew:
575+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
576+ t.send()
577+ }
578+ if !noReply {
579+ t.c.NewReply(m, "")
580+ t.send()
581+ }
582+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
583+ if !noReply {
584+ t.c.NewReply(m, "")
585+ t.send()
586+ }
587+ case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
588+ r := m.Body()
589+ iface, prop := r.Str(), r.Str()
590+ if iface != watcherIface || !t.prop(m, prop, noReply) {
591+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
592+ }
593+ case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
594+ if noReply {
595+ return
596+ }
597+ e := t.c.NewReply(m, "a{sv}")
598+ a := e.BeginArray('{')
599+ if m.Body().Str() == watcherIface {
600+ for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
601+ e.Struct()
602+ e.Str(p)
603+ t.propValue(e, p)
604+ }
605+ }
606+ e.EndArray(a)
607+ t.send()
608+ case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
609+ if !noReply {
610+ t.c.NewReply(m, "s").Str(introspection)
611+ t.send()
612+ }
613+ case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
614+ if !noReply {
615+ t.c.NewReply(m, "")
616+ t.send()
617+ }
618+ default:
619+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
620+ }
621+}
622+
623+func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
624+ if !noReply {
625+ t.c.NewError(m, name, "s").Str(text)
626+ t.send()
627+ }
628+}
629+
630+// prop answers Properties.Get; it reports false for an unknown property.
631+func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
632+ switch name {
633+ case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
634+ default:
635+ return false
636+ }
637+ if !noReply {
638+ t.propValue(t.c.NewReply(m, "v"), name)
639+ t.send()
640+ }
641+ return true
642+}
643+
644+func (t *Tray) propValue(e *zerobus.Encoder, name string) {
645+ switch name {
646+ case "RegisteredStatusNotifierItems":
647+ e.Variant("as")
648+ a := e.BeginArray('s')
649+ for _, it := range t.items {
650+ e.Str(it.service)
651+ }
652+ e.EndArray(a)
653+ case "IsStatusNotifierHostRegistered":
654+ e.Variant("b")
655+ e.Bool(true) // this process is the host
656+ case "ProtocolVersion":
657+ e.Variant("i")
658+ e.Int32(0)
659+ }
660+}
661+
662+func (t *Tray) renderOrFail() {
663+ if err := t.render(false); err != nil {
664+ t.fatal(err)
665+ }
666+}
667+
668+// render writes the line of visible items when it differs from the last one.
669+func (t *Tray) render(force bool) error {
670+ f := t.frame[:0]
671+ for _, it := range t.items {
672+ if !it.loaded || it.status == passive || it.icon == "" {
673+ continue
674+ }
675+ if len(f) > 0 {
676+ f = append(f, ' ')
677+ }
678+ c, bold := t.opt.Foreground, false
679+ switch {
680+ case it.status == attention:
681+ c, bold = t.opt.Accent, true
682+ case it.hasColor:
683+ c = it.color
684+ }
685+ f = append(f, "\x1b["...)
686+ if bold {
687+ f = append(f, "1;"...)
688+ }
689+ f = append(f, "38;2;"...)
690+ f = strconv.AppendUint(f, uint64(c[0]), 10)
691+ f = append(f, ';')
692+ f = strconv.AppendUint(f, uint64(c[1]), 10)
693+ f = append(f, ';')
694+ f = strconv.AppendUint(f, uint64(c[2]), 10)
695+ f = append(f, 'm')
696+ f = append(f, it.icon...)
697+ f = append(f, "\x1b[0m"...)
698+ }
699+ f = append(f, '\n')
700+ t.frame = f
701+ if !force && bytes.Equal(f, t.last) {
702+ return nil
703+ }
704+ t.frame, t.last = t.last, t.frame
705+ if _, err := t.opt.Out.Write(t.last); err != nil {
706+ return fmt.Errorf("tray: %w", err)
707+ }
708+ return nil
709+}
710+
711+const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713+<node>
714+ <interface name="org.kde.StatusNotifierWatcher">
715+ <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
716+ <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
717+ <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
718+ <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
719+ <property name="ProtocolVersion" type="i" access="read"/>
720+ <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
721+ <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
722+ <signal name="StatusNotifierHostRegistered"/>
723+ </interface>
724+ <interface name="org.freedesktop.DBus.Properties">
725+ <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
726+ <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
727+ </interface>
728+ <interface name="org.freedesktop.DBus.Introspectable">
729+ <method name="Introspect"><arg type="s" direction="out"/></method>
730+ </interface>
731+</node>
732+`
@@ -0,0 +1,732 @@
1+// Package tray shows the system tray (StatusNotifierItem) as one line of
2+// Nerd Font glyphs, for "neferbar tray".
3+//
4+// It acts as the StatusNotifierWatcher when no other program does, and as a
5+// plain host of the existing watcher otherwise, so it can run next to another
6+// panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage;
7+// calls to applications never block it, as their replies are matched by
8+// serial when they arrive.
9+package tray
10+
11+import (
12+ "bytes"
13+ "context"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "log/slog"
18+ "os"
19+ "slices"
20+ "strconv"
21+ "strings"
22+
23+ "github.com/bnema/zerobus"
24+)
25+
26+const (
27+ watcherName = "org.kde.StatusNotifierWatcher"
28+ watcherPath = "/StatusNotifierWatcher"
29+ watcherIface = "org.kde.StatusNotifierWatcher"
30+ itemIface = "org.kde.StatusNotifierItem"
31+ itemPath = "/StatusNotifierItem"
32+ propsIface = "org.freedesktop.DBus.Properties"
33+ busName = "org.freedesktop.DBus"
34+ busPath = "/org/freedesktop/DBus"
35+)
36+
37+// RGB is a color.
38+type RGB = [3]uint8
39+
40+// Options configure a Tray.
41+type Options struct {
42+ Resolver *Resolver
43+ // Foreground colors items without an icon to take a color from;
44+ // Background is what tinted icons must stand out against; Accent marks
45+ // items that need attention.
46+ Foreground, Background, Accent RGB
47+ Out io.Writer // one line per change
48+ Log *slog.Logger
49+}
50+
51+type status uint8
52+
53+const (
54+ active status = iota
55+ passive
56+ attention
57+)
58+
59+type item struct {
60+ service string // as listed by the watcher: "bus/path" or "bus"
61+ dest string // where to send calls: the owner, or the registered bus name
62+ owner string // the unique name; signals come from it
63+ path string
64+
65+ id, iconName, title string
66+ status status
67+ icon string // resolved glyph; "" hides the item
68+ resolved bool
69+ color RGB
70+ hasColor bool
71+ loaded bool // GetAll answered at least once
72+ pending uint32 // serial of a GetAll in flight
73+ stale bool // a change arrived while GetAll was in flight
74+}
75+
76+type pendKind uint8
77+
78+const (
79+ pendGetAll pendKind = iota + 1
80+ pendItems // the watcher's RegisteredStatusNotifierItems
81+ pendName // RequestName of the watcher name
82+ pendIgnore
83+)
84+
85+type pend struct {
86+ serial uint32
87+ kind pendKind
88+ it *item
89+}
90+
91+// Tray is one running tray.
92+type Tray struct {
93+ c *zerobus.Conn
94+ opt Options
95+ watcher bool // this process owns the watcher name
96+ host string
97+ items []*item
98+ pending []pend
99+ frame []byte
100+ last []byte
101+ hist histogram
102+ err error // first fatal error from dispatch
103+}
104+
105+// Run serves the tray on c until ctx ends or the bus connection fails. It
106+// closes c.
107+func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
108+ t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
109+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
110+ if t.opt.Log == nil {
111+ t.opt.Log = slog.New(slog.DiscardHandler)
112+ }
113+ stop := context.AfterFunc(ctx, func() { _ = c.Close() })
114+ defer stop()
115+ defer func() { _ = c.Close() }()
116+
117+ // Calls made from here on only go to the bus, which always answers.
118+ // Handle sees what arrives meanwhile.
119+ c.Handle = t.dispatch
120+ for _, rule := range [...]string{
121+ "type='signal',interface='" + itemIface + "'",
122+ // The bus matches a well-known sender by its current owner.
123+ "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
124+ "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
125+ } {
126+ if err := c.AddMatch(rule); err != nil {
127+ return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
128+ }
129+ }
130+ if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
131+ return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
132+ }
133+ t.claim()
134+ if err := t.render(true); err != nil {
135+ return t.exit(ctx, err)
136+ }
137+ for t.err == nil {
138+ m, err := c.ReadMessage()
139+ if err != nil {
140+ return t.exit(ctx, err)
141+ }
142+ t.dispatch(m)
143+ }
144+ // A send fails too when ctx ends during dispatch and closes c.
145+ return t.exit(ctx, t.err)
146+}
147+
148+// exit returns nil when the error comes from ctx ending.
149+func (t *Tray) exit(ctx context.Context, err error) error {
150+ if ctx.Err() != nil {
151+ return nil
152+ }
153+ return err
154+}
155+
156+func (t *Tray) send() uint32 {
157+ serial, err := t.c.Send()
158+ if err != nil {
159+ if errors.Is(err, zerobus.ErrInvalid) {
160+ // A name or path an application gave us that D-Bus refuses.
161+ t.opt.Log.Warn("tray: message not sent", "err", err)
162+ return 0
163+ }
164+ t.fatal(err)
165+ return 0
166+ }
167+ return serial
168+}
169+
170+func (t *Tray) fatal(err error) {
171+ if t.err == nil {
172+ t.err = err
173+ }
174+}
175+
176+func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
177+ if serial != 0 {
178+ t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
179+ }
180+}
181+
182+// claim asks for the watcher name; the reply decides the mode.
183+func (t *Tray) claim() {
184+ e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
185+ e.Str(watcherName)
186+ e.Uint32(zerobus.NameFlagDoNotQueue)
187+ t.expect(t.send(), pendName, nil)
188+}
189+
190+func (t *Tray) dispatch(m *zerobus.Message) {
191+ switch m.Type {
192+ case zerobus.TypeMethodReturn, zerobus.TypeError:
193+ i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
194+ if i < 0 {
195+ return
196+ }
197+ p := t.pending[i]
198+ t.pending = slices.Delete(t.pending, i, i+1)
199+ t.reply(p, m)
200+ case zerobus.TypeSignal:
201+ t.signal(m)
202+ case zerobus.TypeMethodCall:
203+ t.call(m)
204+ }
205+}
206+
207+func (t *Tray) reply(p pend, m *zerobus.Message) {
208+ failed := m.Type == zerobus.TypeError
209+ switch p.kind {
210+ case pendName:
211+ code := m.Body().Uint32()
212+ if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
213+ t.becomeWatcher()
214+ return
215+ }
216+ t.becomeHost()
217+ case pendItems:
218+ if failed {
219+ t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
220+ return
221+ }
222+ r := m.Body()
223+ if m.Signature != "v" || r.Variant() != "as" {
224+ t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
225+ return
226+ }
227+ end := r.Array('s')
228+ for r.More(end) {
229+ t.add(r.Str(), "")
230+ }
231+ case pendGetAll:
232+ it := p.it
233+ if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
234+ return // removed, or an older request
235+ }
236+ it.pending = 0
237+ if failed || m.Signature != "a{sv}" {
238+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
239+ if !it.loaded {
240+ t.remove(it)
241+ }
242+ return
243+ }
244+ if it.owner != m.Sender {
245+ it.owner = strings.Clone(m.Sender)
246+ }
247+ t.load(it, m.Body())
248+ if it.stale {
249+ t.refresh(it)
250+ }
251+ }
252+}
253+
254+func (t *Tray) becomeWatcher() {
255+ t.watcher = true
256+ t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
257+ // Items that waited for a host register now.
258+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
259+ t.send()
260+}
261+
262+func (t *Tray) becomeHost() {
263+ t.watcher = false
264+ t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
265+ t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
266+ t.expect(t.send(), pendIgnore, nil)
267+ e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
268+ e.Str(watcherIface)
269+ e.Str("RegisteredStatusNotifierItems")
270+ t.expect(t.send(), pendItems, nil)
271+}
272+
273+func (t *Tray) signal(m *zerobus.Message) {
274+ switch m.Interface {
275+ case busName:
276+ if m.Member != "NameOwnerChanged" || m.Sender != busName {
277+ return
278+ }
279+ r := m.Body()
280+ name, old, owner := r.Str(), r.Str(), r.Str()
281+ switch {
282+ case r.Err() != nil:
283+ case owner == "":
284+ t.vanished(name)
285+ case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286+ // Another watcher replaced the one we used: its items are
287+ // the ones to show now, not a late list from the old one.
288+ t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
289+ t.dropAll()
290+ t.becomeHost()
291+ }
292+ case watcherIface:
293+ if t.watcher || m.Signature != "s" {
294+ return // our own signals come back to us too
295+ }
296+ s := m.Body().Str()
297+ switch m.Member {
298+ case "StatusNotifierItemRegistered":
299+ t.add(s, "")
300+ case "StatusNotifierItemUnregistered":
301+ svc, _, _ := normService(s)
302+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
303+ t.remove(t.items[i])
304+ }
305+ }
306+ case itemIface:
307+ switch m.Member {
308+ case "NewToolTip", "NewMenu", "NewOverlayIcon":
309+ return // nothing the tray shows
310+ }
311+ for _, it := range t.items {
312+ if it.owner == m.Sender && it.path == m.Path {
313+ t.refresh(it)
314+ return
315+ }
316+ }
317+ }
318+}
319+
320+// vanished drops the items of a bus name that left, and takes over the
321+// watcher name when its owner left.
322+func (t *Tray) vanished(name string) {
323+ changed := false
324+ for i := len(t.items) - 1; i >= 0; i-- {
325+ if it := t.items[i]; it.owner == name || it.dest == name {
326+ t.drop(i)
327+ changed = true
328+ }
329+ }
330+ if changed {
331+ t.renderOrFail()
332+ }
333+ if name == watcherName && !t.watcher {
334+ // Items register again with the new watcher.
335+ t.dropAll()
336+ t.claim()
337+ }
338+}
339+
340+func (t *Tray) dropAll() {
341+ if len(t.items) == 0 {
342+ return
343+ }
344+ for i := len(t.items) - 1; i >= 0; i-- {
345+ t.drop(i)
346+ }
347+ t.renderOrFail()
348+}
349+
350+// normService turns the watcher's form of an item, "bus/path" or "bus", into
351+// "bus/path", its bus and its path.
352+func normService(s string) (service, bus, path string) {
353+ bus, p, ok := strings.Cut(s, "/")
354+ if !ok {
355+ return bus + itemPath, bus, itemPath
356+ }
357+ return s, bus, "/" + p
358+}
359+
360+// validBusName reports whether s is a unique (":1.42") or well-known
361+// ("org.example.App") bus name, as D-Bus defines them.
362+func validBusName(s string) bool {
363+ if s == "" || len(s) > 255 {
364+ return false
365+ }
366+ unique := s[0] == ':'
367+ if unique {
368+ s = s[1:]
369+ }
370+ n := 0
371+ for more := true; more; n++ {
372+ var e string
373+ e, s, more = strings.Cut(s, ".")
374+ if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
375+ return false
376+ }
377+ for _, c := range []byte(e) {
378+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
379+ return false
380+ }
381+ }
382+ }
383+ return n >= 2
384+}
385+
386+// addResult says what add did.
387+type addResult uint8
388+
389+const (
390+ addNew addResult = iota
391+ addKnown
392+ addInvalid // D-Bus refuses its bus name or path
393+)
394+
395+// add starts tracking an item. service is the watcher's form, "bus/path" or
396+// "bus"; owner is its unique name when known.
397+func (t *Tray) add(service, owner string) addResult {
398+ service, dest, path := normService(service)
399+ if owner == "" && strings.HasPrefix(dest, ":") {
400+ owner = dest
401+ }
402+ for _, it := range t.items {
403+ if it.service == service || owner != "" && it.owner == owner && it.path == path {
404+ return addKnown
405+ }
406+ }
407+ it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
408+ t.items = append(t.items, it)
409+ t.refresh(it)
410+ if it.pending == 0 {
411+ // Never shown nor announced: forget it quietly.
412+ t.items = t.items[:len(t.items)-1]
413+ return addInvalid
414+ }
415+ return addNew
416+}
417+
418+func (t *Tray) remove(it *item) {
419+ if i := slices.Index(t.items, it); i >= 0 {
420+ t.drop(i)
421+ t.renderOrFail()
422+ }
423+}
424+
425+func (t *Tray) drop(i int) {
426+ it := t.items[i]
427+ t.items = slices.Delete(t.items, i, i+1)
428+ if t.watcher {
429+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
430+ t.send()
431+ }
432+}
433+
434+// refresh asks an item for its properties, unless a request is in flight.
435+func (t *Tray) refresh(it *item) {
436+ if it.pending != 0 {
437+ it.stale = true
438+ return
439+ }
440+ it.stale = false
441+ dest := it.dest
442+ if it.owner != "" {
443+ dest = it.owner
444+ }
445+ t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
446+ it.pending = t.send()
447+ t.expect(it.pending, pendGetAll, it)
448+}
449+
450+// load reads a GetAll reply into it and redraws when something shown
451+// changed. Strings are copied only when they change.
452+func (t *Tray) load(it *item, r *zerobus.Reader) {
453+ was := *it
454+ var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
455+ end := r.Array('{')
456+ for r.More(end) {
457+ r.Struct()
458+ key := r.Str()
459+ sig := r.Variant()
460+ switch {
461+ case sig == "s" && key == "Id":
462+ setStr(&it.id, r.Str())
463+ case sig == "s" && key == "Title":
464+ setStr(&it.title, r.Str())
465+ case sig == "s" && key == "IconName":
466+ setStr(&it.iconName, r.Str())
467+ case sig == "s" && key == "Status":
468+ it.status = parseStatus(r.Str())
469+ case sig == "a(iiay)" && key == "IconPixmap":
470+ pixmaps = pickPixmap(r)
471+ case sig == "a(iiay)" && key == "AttentionIconPixmap":
472+ attentionPixmaps = pickPixmap(r)
473+ default:
474+ r.Skip(sig)
475+ }
476+ }
477+ if r.Err() != nil {
478+ t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
479+ *it = was
480+ return
481+ }
482+ it.loaded = true
483+ if it.status == attention && attentionPixmaps != nil {
484+ pixmaps = attentionPixmaps
485+ }
486+ it.color, it.hasColor = t.hist.dominant(pixmaps)
487+ if it.hasColor {
488+ it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
489+ }
490+ if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
491+ it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
492+ }
493+ t.renderOrFail()
494+}
495+
496+// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
497+// best suited to find a color: the largest up to 64x64, else the smallest.
498+// The result points into the message buffer.
499+func pickPixmap(r *zerobus.Reader) []byte {
500+ var best []byte
501+ bestN := 0
502+ end := r.Array('(')
503+ for r.More(end) {
504+ r.Struct()
505+ w, h := int(r.Int32()), int(r.Int32())
506+ data := r.ByteArray()
507+ n := w * h
508+ if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
509+ continue
510+ }
511+ if best == nil || better(n, bestN) {
512+ best, bestN = data, n
513+ }
514+ }
515+ return best
516+}
517+
518+// better reports whether an icon of n pixels suits the color search better
519+// than one of cur pixels: the largest up to 64x64, else the smallest.
520+func better(n, cur int) bool {
521+ const enough = 64 * 64
522+ if cur > enough {
523+ return n < cur
524+ }
525+ return n > cur && n <= enough
526+}
527+
528+func setStr(dst *string, s string) {
529+ if *dst != s {
530+ *dst = strings.Clone(s)
531+ }
532+}
533+
534+func parseStatus(s string) status {
535+ switch s {
536+ case "Passive":
537+ return passive
538+ case "NeedsAttention":
539+ return attention
540+ }
541+ return active
542+}
543+
544+// call answers method calls when this process is the watcher.
545+func (t *Tray) call(m *zerobus.Message) {
546+ noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
547+ if !t.watcher || m.Path != watcherPath {
548+ if !noReply {
549+ t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
550+ t.send()
551+ }
552+ return
553+ }
554+ switch {
555+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
556+ s := m.Body().Str()
557+ if s == "" {
558+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
559+ return
560+ }
561+ if strings.HasPrefix(s, "/") {
562+ s = m.Sender + s // registered by object path
563+ } else if validBusName(s) {
564+ s += itemPath
565+ } else {
566+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
567+ return
568+ }
569+ // add copies s before the next read, and sends only a GetAll.
570+ switch t.add(s, m.Sender) {
571+ case addInvalid:
572+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
573+ return
574+ case addNew:
575+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
576+ t.send()
577+ }
578+ if !noReply {
579+ t.c.NewReply(m, "")
580+ t.send()
581+ }
582+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
583+ if !noReply {
584+ t.c.NewReply(m, "")
585+ t.send()
586+ }
587+ case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
588+ r := m.Body()
589+ iface, prop := r.Str(), r.Str()
590+ if iface != watcherIface || !t.prop(m, prop, noReply) {
591+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
592+ }
593+ case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
594+ if noReply {
595+ return
596+ }
597+ e := t.c.NewReply(m, "a{sv}")
598+ a := e.BeginArray('{')
599+ if m.Body().Str() == watcherIface {
600+ for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
601+ e.Struct()
602+ e.Str(p)
603+ t.propValue(e, p)
604+ }
605+ }
606+ e.EndArray(a)
607+ t.send()
608+ case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
609+ if !noReply {
610+ t.c.NewReply(m, "s").Str(introspection)
611+ t.send()
612+ }
613+ case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
614+ if !noReply {
615+ t.c.NewReply(m, "")
616+ t.send()
617+ }
618+ default:
619+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
620+ }
621+}
622+
623+func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
624+ if !noReply {
625+ t.c.NewError(m, name, "s").Str(text)
626+ t.send()
627+ }
628+}
629+
630+// prop answers Properties.Get; it reports false for an unknown property.
631+func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
632+ switch name {
633+ case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
634+ default:
635+ return false
636+ }
637+ if !noReply {
638+ t.propValue(t.c.NewReply(m, "v"), name)
639+ t.send()
640+ }
641+ return true
642+}
643+
644+func (t *Tray) propValue(e *zerobus.Encoder, name string) {
645+ switch name {
646+ case "RegisteredStatusNotifierItems":
647+ e.Variant("as")
648+ a := e.BeginArray('s')
649+ for _, it := range t.items {
650+ e.Str(it.service)
651+ }
652+ e.EndArray(a)
653+ case "IsStatusNotifierHostRegistered":
654+ e.Variant("b")
655+ e.Bool(true) // this process is the host
656+ case "ProtocolVersion":
657+ e.Variant("i")
658+ e.Int32(0)
659+ }
660+}
661+
662+func (t *Tray) renderOrFail() {
663+ if err := t.render(false); err != nil {
664+ t.fatal(err)
665+ }
666+}
667+
668+// render writes the line of visible items when it differs from the last one.
669+func (t *Tray) render(force bool) error {
670+ f := t.frame[:0]
671+ for _, it := range t.items {
672+ if !it.loaded || it.status == passive || it.icon == "" {
673+ continue
674+ }
675+ if len(f) > 0 {
676+ f = append(f, ' ')
677+ }
678+ c, bold := t.opt.Foreground, false
679+ switch {
680+ case it.status == attention:
681+ c, bold = t.opt.Accent, true
682+ case it.hasColor:
683+ c = it.color
684+ }
685+ f = append(f, "\x1b["...)
686+ if bold {
687+ f = append(f, "1;"...)
688+ }
689+ f = append(f, "38;2;"...)
690+ f = strconv.AppendUint(f, uint64(c[0]), 10)
691+ f = append(f, ';')
692+ f = strconv.AppendUint(f, uint64(c[1]), 10)
693+ f = append(f, ';')
694+ f = strconv.AppendUint(f, uint64(c[2]), 10)
695+ f = append(f, 'm')
696+ f = append(f, it.icon...)
697+ f = append(f, "\x1b[0m"...)
698+ }
699+ f = append(f, '\n')
700+ t.frame = f
701+ if !force && bytes.Equal(f, t.last) {
702+ return nil
703+ }
704+ t.frame, t.last = t.last, t.frame
705+ if _, err := t.opt.Out.Write(t.last); err != nil {
706+ return fmt.Errorf("tray: %w", err)
707+ }
708+ return nil
709+}
710+
711+const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713+<node>
714+ <interface name="org.kde.StatusNotifierWatcher">
715+ <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
716+ <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
717+ <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
718+ <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
719+ <property name="ProtocolVersion" type="i" access="read"/>
720+ <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
721+ <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
722+ <signal name="StatusNotifierHostRegistered"/>
723+ </interface>
724+ <interface name="org.freedesktop.DBus.Properties">
725+ <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
726+ <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
727+ </interface>
728+ <interface name="org.freedesktop.DBus.Introspectable">
729+ <method name="Introspect"><arg type="s" direction="out"/></method>
730+ </interface>
731+</node>
732+`
A internal/tray/tray_test.go +542 -0
@@ -0,0 +1,542 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zerobus"
14+)
15+
16+// privateBus starts a dbus-daemon for the test and returns its address.
17+func privateBus(t *testing.T) string {
18+ t.Helper()
19+ path, err := exec.LookPath("dbus-daemon")
20+ if err != nil {
21+ t.Skip("dbus-daemon not installed")
22+ }
23+ cmd := exec.Command(path, "--session", "--nofork", "--print-address")
24+ out, err := cmd.StdoutPipe()
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := cmd.Start(); err != nil {
29+ t.Fatal(err)
30+ }
31+ t.Cleanup(func() {
32+ _ = cmd.Process.Kill()
33+ _ = cmd.Wait()
34+ })
35+ addr, err := bufio.NewReader(out).ReadString('\n')
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ return strings.TrimSpace(addr)
40+}
41+
42+func dial(t *testing.T, addr string) *zerobus.Conn {
43+ t.Helper()
44+ c, err := zerobus.Dial(addr)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ t.Cleanup(func() { _ = c.Close() })
49+ return c
50+}
51+
52+// fakeItem is an application with one StatusNotifierItem. Its connection is
53+// used only by its own goroutine, as a Conn requires: the test drives it
54+// through a peer that calls its Do method.
55+type fakeItem struct {
56+ c *zerobus.Conn
57+ name string
58+ driver *zerobus.Conn
59+ cmds chan func()
60+ cur [3]string // id, icon name, status
61+ pixel [4]byte // the one ARGB pixel of its icon
62+ closed chan struct{}
63+}
64+
65+func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
66+ c := dial(t, addr)
67+ f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
68+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})}
69+ go f.serve()
70+ return f
71+}
72+
73+// run hands fn to the item's goroutine, which runs it right after answering:
74+// run may return before fn ran. Tests wait for its effect on the output.
75+func (f *fakeItem) run(t *testing.T, fn func()) {
76+ t.Helper()
77+ f.cmds <- fn
78+ f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
79+ if _, err := f.driver.Call(); err != nil {
80+ t.Fatal(err)
81+ }
82+}
83+
84+func (f *fakeItem) serve() {
85+ defer close(f.closed)
86+ for {
87+ m, err := f.c.ReadMessage()
88+ if err != nil {
89+ return
90+ }
91+ if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
92+ f.c.NewReply(m, "")
93+ if _, err := f.c.Send(); err != nil {
94+ return
95+ }
96+ (<-f.cmds)()
97+ continue
98+ }
99+ if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100+ continue
101+ }
102+ e := f.c.NewReply(m, "a{sv}")
103+ a := e.BeginArray('{')
104+ for i, k := range [...]string{"Id", "IconName", "Status"} {
105+ e.Struct()
106+ e.Str(k)
107+ e.Variant("s")
108+ e.Str(f.cur[i])
109+ }
110+ e.Struct()
111+ e.Str("IconPixmap")
112+ e.Variant("a(iiay)")
113+ px := e.BeginArray('(')
114+ e.Struct()
115+ e.Int32(1)
116+ e.Int32(1)
117+ e.ByteArray(f.pixel[:])
118+ e.EndArray(px)
119+ e.EndArray(a)
120+ if _, err := f.c.Send(); err != nil {
121+ return
122+ }
123+ }
124+}
125+
126+// register registers the item with the watcher, by bus name.
127+func (f *fakeItem) register(t *testing.T) {
128+ f.run(t, func() {
129+ f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
130+ _, _ = f.c.Send()
131+ })
132+}
133+
134+// setStatus changes the item's status and emits NewStatus.
135+func (f *fakeItem) setStatus(t *testing.T, status string) {
136+ f.run(t, func() {
137+ f.cur[2] = status
138+ f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
139+ _, _ = f.c.Send()
140+ })
141+}
142+
143+// quit closes the item's connection, as when the application exits.
144+func (f *fakeItem) quit(t *testing.T) {
145+ f.run(t, func() { _ = f.c.Close() })
146+ <-f.closed
147+}
148+
149+// lines reads the tray's output.
150+type lines struct {
151+ ch chan string
152+}
153+
154+func readLines(r io.Reader) *lines {
155+ l := &lines{ch: make(chan string, 64)}
156+ go func() {
157+ sc := bufio.NewScanner(r)
158+ for sc.Scan() {
159+ l.ch <- sc.Text()
160+ }
161+ close(l.ch)
162+ }()
163+ return l
164+}
165+
166+// waitFor returns the first line for which ok is true.
167+func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
168+ t.Helper()
169+ timeout := time.After(5 * time.Second)
170+ for {
171+ select {
172+ case s, open := <-l.ch:
173+ if !open {
174+ t.Fatalf("output ended while waiting for %s", what)
175+ }
176+ if ok(s) {
177+ return s
178+ }
179+ case <-timeout:
180+ t.Fatalf("timed out waiting for %s", what)
181+ }
182+ }
183+}
184+
185+// startTray runs a tray on the bus. Cancel it and receive from the channel
186+// to stop it; the test cleanup does both when the test did not.
187+func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
188+ pr, pw := io.Pipe()
189+ ctx, cancel := context.WithCancel(context.Background())
190+ done := make(chan error, 1)
191+ var result error
192+ stopped := make(chan struct{})
193+ opt := Options{
194+ Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195+ Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196+ Out: pw,
197+ }
198+ c := dial(t, addr)
199+ go func() {
200+ result = Run(ctx, c, opt)
201+ _ = pw.Close()
202+ close(stopped)
203+ done <- result
204+ }()
205+ t.Cleanup(func() {
206+ cancel()
207+ <-stopped
208+ })
209+ return readLines(pr), cancel, done
210+}
211+
212+func TestTrayAsWatcher(t *testing.T) {
213+ addr := privateBus(t)
214+ out, cancel, done := startTray(t, addr)
215+ if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
216+ t.Fatalf("first line %q, want empty", s)
217+ }
218+ // Wait until the tray owns the watcher name.
219+ probe := dial(t, addr)
220+ deadline := time.Now().Add(5 * time.Second)
221+ for {
222+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
223+ m, err := probe.Call()
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if m.Body().Bool() {
228+ break
229+ }
230+ if time.Now().After(deadline) {
231+ t.Fatal("the tray did not become the watcher")
232+ }
233+ time.Sleep(10 * time.Millisecond)
234+ }
235+
236+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
237+ steam.register(t)
238+ line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
239+ if !strings.Contains(line, "38;2;") {
240+ t.Fatalf("the icon is not colored: %q", line)
241+ }
242+
243+ steam.setStatus(t, "NeedsAttention")
244+ out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
245+
246+ steam.setStatus(t, "Passive")
247+ out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
248+
249+ steam.setStatus(t, "Active")
250+ out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
251+
252+ // The application quits: its item goes away.
253+ steam.quit(t)
254+ out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
255+
256+ cancel()
257+ if err := <-done; err != nil {
258+ t.Fatalf("Run: %v", err)
259+ }
260+}
261+
262+// waitWatcher waits until the tray owns the watcher name.
263+func waitWatcher(t *testing.T, addr string) {
264+ t.Helper()
265+ probe := dial(t, addr)
266+ deadline := time.Now().Add(5 * time.Second)
267+ for {
268+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
269+ m, err := probe.Call()
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ if m.Body().Bool() {
274+ return
275+ }
276+ if time.Now().After(deadline) {
277+ t.Fatal("the tray did not become the watcher")
278+ }
279+ time.Sleep(10 * time.Millisecond)
280+ }
281+}
282+
283+// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
284+// connection is used only by its own goroutine, which the test drives
285+// through Ping.
286+type fakeWatcher struct {
287+ c *zerobus.Conn
288+ probe *zerobus.Conn
289+ items []string
290+ cmds chan func()
291+ closed chan struct{}
292+}
293+
294+func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
295+ t.Helper()
296+ w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
297+ if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
298+ t.Fatalf("RequestName = %d, %v", code, err)
299+ }
300+ go w.serve()
301+ return w
302+}
303+
304+func (w *fakeWatcher) serve() {
305+ defer close(w.closed)
306+ for {
307+ m, err := w.c.ReadMessage()
308+ if err != nil {
309+ return
310+ }
311+ if m.Type != zerobus.TypeMethodCall {
312+ continue
313+ }
314+ switch m.Member {
315+ case "Ping":
316+ w.c.NewReply(m, "")
317+ if _, err := w.c.Send(); err != nil {
318+ return
319+ }
320+ (<-w.cmds)()
321+ continue
322+ case "Get":
323+ e := w.c.NewReply(m, "v")
324+ e.Variant("as")
325+ a := e.BeginArray('s')
326+ for _, s := range w.items {
327+ e.Str(s)
328+ }
329+ e.EndArray(a)
330+ default:
331+ w.c.NewReply(m, "")
332+ }
333+ if _, err := w.c.Send(); err != nil {
334+ return
335+ }
336+ }
337+}
338+
339+// do hands fn to the watcher's goroutine; see fakeItem.run.
340+func (w *fakeWatcher) do(t *testing.T, fn func()) {
341+ t.Helper()
342+ w.cmds <- fn
343+ w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
344+ if _, err := w.probe.Call(); err != nil {
345+ t.Fatal(err)
346+ }
347+}
348+
349+func (w *fakeWatcher) emit(t *testing.T, member, service string) {
350+ w.do(t, func() {
351+ w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
352+ _, _ = w.c.Send()
353+ })
354+}
355+
356+func (w *fakeWatcher) quit(t *testing.T) {
357+ w.do(t, func() { _ = w.c.Close() })
358+ <-w.closed
359+}
360+
361+func TestTrayAsHost(t *testing.T) {
362+ addr := privateBus(t)
363+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
364+ // Another program is the watcher, and lists one item.
365+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
366+
367+ out, _, _ := startTray(t, addr)
368+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
369+
370+ // A second item is announced by the other watcher, by bus name only.
371+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
372+ w.emit(t, "StatusNotifierItemRegistered", steam.name)
373+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
374+
375+ // And removed in the same form.
376+ w.emit(t, "StatusNotifierItemUnregistered", steam.name)
377+ out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
378+}
379+
380+func TestHostFollowsReplacedWatcher(t *testing.T) {
381+ addr := privateBus(t)
382+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
383+ newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
384+ out, _, _ := startTray(t, addr)
385+ out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
386+
387+ // A new panel replaces the watcher: its items are the ones to show.
388+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
389+ newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
390+ out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
391+}
392+
393+func TestHostTakesOverWatcher(t *testing.T) {
394+ addr := privateBus(t)
395+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
396+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
397+ out, cancel, done := startTray(t, addr)
398+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
399+
400+ // The other panel exits: the tray drops its items and becomes the
401+ // watcher, where items register again.
402+ w.quit(t)
403+ out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
404+ waitWatcher(t, addr)
405+ obsidian.register(t)
406+ out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
407+
408+ cancel()
409+ if err := <-done; err != nil {
410+ t.Fatalf("Run: %v", err)
411+ }
412+}
413+
414+// registered returns the watcher's RegisteredStatusNotifierItems.
415+func registered(t *testing.T, c *zerobus.Conn) []string {
416+ t.Helper()
417+ e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
418+ e.Str(watcherIface)
419+ e.Str("RegisteredStatusNotifierItems")
420+ m, err := c.Call()
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ r := m.Body()
425+ if v := r.Variant(); v != "as" {
426+ t.Fatalf("variant %q", v)
427+ }
428+ var items []string
429+ for end := r.Array('s'); r.More(end); {
430+ items = append(items, strings.Clone(r.Str()))
431+ }
432+ return items
433+}
434+
435+func TestWatcherRegistration(t *testing.T) {
436+ addr := privateBus(t)
437+ out, _, _ := startTray(t, addr)
438+ waitWatcher(t, addr)
439+
440+ // libappindicator and Electron register an object path, not a name.
441+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
442+ steam.run(t, func() {
443+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
444+ _, _ = steam.c.Send()
445+ })
446+ out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
447+
448+ // The same item again, by bus name: still one item.
449+ dup := make(chan error, 1)
450+ steam.run(t, func() {
451+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
452+ _, err := steam.c.Call()
453+ dup <- err
454+ })
455+ if err := <-dup; err != nil {
456+ t.Fatal(err)
457+ }
458+
459+ // A path D-Bus refuses is rejected and leaves no trace.
460+ probe := dial(t, addr)
461+ for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
462+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
463+ if _, err := probe.Call(); err == nil {
464+ t.Fatalf("%q was accepted", bad)
465+ }
466+ }
467+ if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
468+ t.Fatalf("registered items %q", items)
469+ }
470+}
471+
472+// TestRefreshNoAllocs checks the steady state: an item signals a change, the
473+// tray asks for its properties and reads the reply. Nothing allocates once
474+// the buffers have grown.
475+func TestRefreshNoAllocs(t *testing.T) {
476+ addr := privateBus(t)
477+ app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
478+ var out strings.Builder
479+ tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
480+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
481+ tr.opt.Log = slog.New(slog.DiscardHandler)
482+ tr.add(app.name, "")
483+ cycle := func() {
484+ tr.refresh(tr.items[0])
485+ for len(tr.pending) > 0 {
486+ m, err := tr.c.ReadMessage()
487+ if err != nil {
488+ t.Fatal(err)
489+ }
490+ tr.dispatch(m)
491+ }
492+ }
493+ for range 3 {
494+ cycle() // resolve the icon and grow the buffers
495+ }
496+ if tr.err != nil || !tr.items[0].loaded {
497+ t.Fatalf("item not loaded: %v", tr.err)
498+ }
499+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
500+ t.Fatalf("%v allocations per refresh, want 0", allocs)
501+ }
502+}
503+
504+func TestRenderSkipsUnchangedFrames(t *testing.T) {
505+ var buf strings.Builder
506+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
507+ tr.items = []*item{{loaded: true, icon: "X"}}
508+ for range 3 {
509+ if err := tr.render(false); err != nil {
510+ t.Fatal(err)
511+ }
512+ }
513+ if got := strings.Count(buf.String(), "\n"); got != 1 {
514+ t.Fatalf("%d frames written, want 1", got)
515+ }
516+ if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
517+ t.Fatalf("render allocates %v times", allocs)
518+ }
519+}
520+
521+func TestBetterPixmapSize(t *testing.T) {
522+ // The largest icon up to 64x64 wins; above that, the smallest.
523+ for _, c := range []struct {
524+ sizes []int // sides, in the order the item lists them
525+ want int
526+ }{
527+ {[]int{16, 32, 128}, 32},
528+ {[]int{128, 32, 16}, 32},
529+ {[]int{256, 128}, 128},
530+ {[]int{22}, 22},
531+ } {
532+ best := 0
533+ for _, s := range c.sizes {
534+ if best == 0 || better(s*s, best*best) {
535+ best = s
536+ }
537+ }
538+ if best != c.want {
539+ t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
540+ }
541+ }
542+}
@@ -0,0 +1,542 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zerobus"
14+)
15+
16+// privateBus starts a dbus-daemon for the test and returns its address.
17+func privateBus(t *testing.T) string {
18+ t.Helper()
19+ path, err := exec.LookPath("dbus-daemon")
20+ if err != nil {
21+ t.Skip("dbus-daemon not installed")
22+ }
23+ cmd := exec.Command(path, "--session", "--nofork", "--print-address")
24+ out, err := cmd.StdoutPipe()
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := cmd.Start(); err != nil {
29+ t.Fatal(err)
30+ }
31+ t.Cleanup(func() {
32+ _ = cmd.Process.Kill()
33+ _ = cmd.Wait()
34+ })
35+ addr, err := bufio.NewReader(out).ReadString('\n')
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ return strings.TrimSpace(addr)
40+}
41+
42+func dial(t *testing.T, addr string) *zerobus.Conn {
43+ t.Helper()
44+ c, err := zerobus.Dial(addr)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ t.Cleanup(func() { _ = c.Close() })
49+ return c
50+}
51+
52+// fakeItem is an application with one StatusNotifierItem. Its connection is
53+// used only by its own goroutine, as a Conn requires: the test drives it
54+// through a peer that calls its Do method.
55+type fakeItem struct {
56+ c *zerobus.Conn
57+ name string
58+ driver *zerobus.Conn
59+ cmds chan func()
60+ cur [3]string // id, icon name, status
61+ pixel [4]byte // the one ARGB pixel of its icon
62+ closed chan struct{}
63+}
64+
65+func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
66+ c := dial(t, addr)
67+ f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
68+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})}
69+ go f.serve()
70+ return f
71+}
72+
73+// run hands fn to the item's goroutine, which runs it right after answering:
74+// run may return before fn ran. Tests wait for its effect on the output.
75+func (f *fakeItem) run(t *testing.T, fn func()) {
76+ t.Helper()
77+ f.cmds <- fn
78+ f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
79+ if _, err := f.driver.Call(); err != nil {
80+ t.Fatal(err)
81+ }
82+}
83+
84+func (f *fakeItem) serve() {
85+ defer close(f.closed)
86+ for {
87+ m, err := f.c.ReadMessage()
88+ if err != nil {
89+ return
90+ }
91+ if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
92+ f.c.NewReply(m, "")
93+ if _, err := f.c.Send(); err != nil {
94+ return
95+ }
96+ (<-f.cmds)()
97+ continue
98+ }
99+ if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100+ continue
101+ }
102+ e := f.c.NewReply(m, "a{sv}")
103+ a := e.BeginArray('{')
104+ for i, k := range [...]string{"Id", "IconName", "Status"} {
105+ e.Struct()
106+ e.Str(k)
107+ e.Variant("s")
108+ e.Str(f.cur[i])
109+ }
110+ e.Struct()
111+ e.Str("IconPixmap")
112+ e.Variant("a(iiay)")
113+ px := e.BeginArray('(')
114+ e.Struct()
115+ e.Int32(1)
116+ e.Int32(1)
117+ e.ByteArray(f.pixel[:])
118+ e.EndArray(px)
119+ e.EndArray(a)
120+ if _, err := f.c.Send(); err != nil {
121+ return
122+ }
123+ }
124+}
125+
126+// register registers the item with the watcher, by bus name.
127+func (f *fakeItem) register(t *testing.T) {
128+ f.run(t, func() {
129+ f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
130+ _, _ = f.c.Send()
131+ })
132+}
133+
134+// setStatus changes the item's status and emits NewStatus.
135+func (f *fakeItem) setStatus(t *testing.T, status string) {
136+ f.run(t, func() {
137+ f.cur[2] = status
138+ f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
139+ _, _ = f.c.Send()
140+ })
141+}
142+
143+// quit closes the item's connection, as when the application exits.
144+func (f *fakeItem) quit(t *testing.T) {
145+ f.run(t, func() { _ = f.c.Close() })
146+ <-f.closed
147+}
148+
149+// lines reads the tray's output.
150+type lines struct {
151+ ch chan string
152+}
153+
154+func readLines(r io.Reader) *lines {
155+ l := &lines{ch: make(chan string, 64)}
156+ go func() {
157+ sc := bufio.NewScanner(r)
158+ for sc.Scan() {
159+ l.ch <- sc.Text()
160+ }
161+ close(l.ch)
162+ }()
163+ return l
164+}
165+
166+// waitFor returns the first line for which ok is true.
167+func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
168+ t.Helper()
169+ timeout := time.After(5 * time.Second)
170+ for {
171+ select {
172+ case s, open := <-l.ch:
173+ if !open {
174+ t.Fatalf("output ended while waiting for %s", what)
175+ }
176+ if ok(s) {
177+ return s
178+ }
179+ case <-timeout:
180+ t.Fatalf("timed out waiting for %s", what)
181+ }
182+ }
183+}
184+
185+// startTray runs a tray on the bus. Cancel it and receive from the channel
186+// to stop it; the test cleanup does both when the test did not.
187+func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
188+ pr, pw := io.Pipe()
189+ ctx, cancel := context.WithCancel(context.Background())
190+ done := make(chan error, 1)
191+ var result error
192+ stopped := make(chan struct{})
193+ opt := Options{
194+ Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195+ Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196+ Out: pw,
197+ }
198+ c := dial(t, addr)
199+ go func() {
200+ result = Run(ctx, c, opt)
201+ _ = pw.Close()
202+ close(stopped)
203+ done <- result
204+ }()
205+ t.Cleanup(func() {
206+ cancel()
207+ <-stopped
208+ })
209+ return readLines(pr), cancel, done
210+}
211+
212+func TestTrayAsWatcher(t *testing.T) {
213+ addr := privateBus(t)
214+ out, cancel, done := startTray(t, addr)
215+ if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
216+ t.Fatalf("first line %q, want empty", s)
217+ }
218+ // Wait until the tray owns the watcher name.
219+ probe := dial(t, addr)
220+ deadline := time.Now().Add(5 * time.Second)
221+ for {
222+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
223+ m, err := probe.Call()
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if m.Body().Bool() {
228+ break
229+ }
230+ if time.Now().After(deadline) {
231+ t.Fatal("the tray did not become the watcher")
232+ }
233+ time.Sleep(10 * time.Millisecond)
234+ }
235+
236+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
237+ steam.register(t)
238+ line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
239+ if !strings.Contains(line, "38;2;") {
240+ t.Fatalf("the icon is not colored: %q", line)
241+ }
242+
243+ steam.setStatus(t, "NeedsAttention")
244+ out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
245+
246+ steam.setStatus(t, "Passive")
247+ out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
248+
249+ steam.setStatus(t, "Active")
250+ out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
251+
252+ // The application quits: its item goes away.
253+ steam.quit(t)
254+ out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
255+
256+ cancel()
257+ if err := <-done; err != nil {
258+ t.Fatalf("Run: %v", err)
259+ }
260+}
261+
262+// waitWatcher waits until the tray owns the watcher name.
263+func waitWatcher(t *testing.T, addr string) {
264+ t.Helper()
265+ probe := dial(t, addr)
266+ deadline := time.Now().Add(5 * time.Second)
267+ for {
268+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
269+ m, err := probe.Call()
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ if m.Body().Bool() {
274+ return
275+ }
276+ if time.Now().After(deadline) {
277+ t.Fatal("the tray did not become the watcher")
278+ }
279+ time.Sleep(10 * time.Millisecond)
280+ }
281+}
282+
283+// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
284+// connection is used only by its own goroutine, which the test drives
285+// through Ping.
286+type fakeWatcher struct {
287+ c *zerobus.Conn
288+ probe *zerobus.Conn
289+ items []string
290+ cmds chan func()
291+ closed chan struct{}
292+}
293+
294+func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
295+ t.Helper()
296+ w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
297+ if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
298+ t.Fatalf("RequestName = %d, %v", code, err)
299+ }
300+ go w.serve()
301+ return w
302+}
303+
304+func (w *fakeWatcher) serve() {
305+ defer close(w.closed)
306+ for {
307+ m, err := w.c.ReadMessage()
308+ if err != nil {
309+ return
310+ }
311+ if m.Type != zerobus.TypeMethodCall {
312+ continue
313+ }
314+ switch m.Member {
315+ case "Ping":
316+ w.c.NewReply(m, "")
317+ if _, err := w.c.Send(); err != nil {
318+ return
319+ }
320+ (<-w.cmds)()
321+ continue
322+ case "Get":
323+ e := w.c.NewReply(m, "v")
324+ e.Variant("as")
325+ a := e.BeginArray('s')
326+ for _, s := range w.items {
327+ e.Str(s)
328+ }
329+ e.EndArray(a)
330+ default:
331+ w.c.NewReply(m, "")
332+ }
333+ if _, err := w.c.Send(); err != nil {
334+ return
335+ }
336+ }
337+}
338+
339+// do hands fn to the watcher's goroutine; see fakeItem.run.
340+func (w *fakeWatcher) do(t *testing.T, fn func()) {
341+ t.Helper()
342+ w.cmds <- fn
343+ w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
344+ if _, err := w.probe.Call(); err != nil {
345+ t.Fatal(err)
346+ }
347+}
348+
349+func (w *fakeWatcher) emit(t *testing.T, member, service string) {
350+ w.do(t, func() {
351+ w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
352+ _, _ = w.c.Send()
353+ })
354+}
355+
356+func (w *fakeWatcher) quit(t *testing.T) {
357+ w.do(t, func() { _ = w.c.Close() })
358+ <-w.closed
359+}
360+
361+func TestTrayAsHost(t *testing.T) {
362+ addr := privateBus(t)
363+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
364+ // Another program is the watcher, and lists one item.
365+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
366+
367+ out, _, _ := startTray(t, addr)
368+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
369+
370+ // A second item is announced by the other watcher, by bus name only.
371+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
372+ w.emit(t, "StatusNotifierItemRegistered", steam.name)
373+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
374+
375+ // And removed in the same form.
376+ w.emit(t, "StatusNotifierItemUnregistered", steam.name)
377+ out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
378+}
379+
380+func TestHostFollowsReplacedWatcher(t *testing.T) {
381+ addr := privateBus(t)
382+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
383+ newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
384+ out, _, _ := startTray(t, addr)
385+ out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
386+
387+ // A new panel replaces the watcher: its items are the ones to show.
388+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
389+ newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
390+ out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
391+}
392+
393+func TestHostTakesOverWatcher(t *testing.T) {
394+ addr := privateBus(t)
395+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
396+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
397+ out, cancel, done := startTray(t, addr)
398+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
399+
400+ // The other panel exits: the tray drops its items and becomes the
401+ // watcher, where items register again.
402+ w.quit(t)
403+ out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
404+ waitWatcher(t, addr)
405+ obsidian.register(t)
406+ out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
407+
408+ cancel()
409+ if err := <-done; err != nil {
410+ t.Fatalf("Run: %v", err)
411+ }
412+}
413+
414+// registered returns the watcher's RegisteredStatusNotifierItems.
415+func registered(t *testing.T, c *zerobus.Conn) []string {
416+ t.Helper()
417+ e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
418+ e.Str(watcherIface)
419+ e.Str("RegisteredStatusNotifierItems")
420+ m, err := c.Call()
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ r := m.Body()
425+ if v := r.Variant(); v != "as" {
426+ t.Fatalf("variant %q", v)
427+ }
428+ var items []string
429+ for end := r.Array('s'); r.More(end); {
430+ items = append(items, strings.Clone(r.Str()))
431+ }
432+ return items
433+}
434+
435+func TestWatcherRegistration(t *testing.T) {
436+ addr := privateBus(t)
437+ out, _, _ := startTray(t, addr)
438+ waitWatcher(t, addr)
439+
440+ // libappindicator and Electron register an object path, not a name.
441+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
442+ steam.run(t, func() {
443+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
444+ _, _ = steam.c.Send()
445+ })
446+ out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
447+
448+ // The same item again, by bus name: still one item.
449+ dup := make(chan error, 1)
450+ steam.run(t, func() {
451+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
452+ _, err := steam.c.Call()
453+ dup <- err
454+ })
455+ if err := <-dup; err != nil {
456+ t.Fatal(err)
457+ }
458+
459+ // A path D-Bus refuses is rejected and leaves no trace.
460+ probe := dial(t, addr)
461+ for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
462+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
463+ if _, err := probe.Call(); err == nil {
464+ t.Fatalf("%q was accepted", bad)
465+ }
466+ }
467+ if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
468+ t.Fatalf("registered items %q", items)
469+ }
470+}
471+
472+// TestRefreshNoAllocs checks the steady state: an item signals a change, the
473+// tray asks for its properties and reads the reply. Nothing allocates once
474+// the buffers have grown.
475+func TestRefreshNoAllocs(t *testing.T) {
476+ addr := privateBus(t)
477+ app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
478+ var out strings.Builder
479+ tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
480+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
481+ tr.opt.Log = slog.New(slog.DiscardHandler)
482+ tr.add(app.name, "")
483+ cycle := func() {
484+ tr.refresh(tr.items[0])
485+ for len(tr.pending) > 0 {
486+ m, err := tr.c.ReadMessage()
487+ if err != nil {
488+ t.Fatal(err)
489+ }
490+ tr.dispatch(m)
491+ }
492+ }
493+ for range 3 {
494+ cycle() // resolve the icon and grow the buffers
495+ }
496+ if tr.err != nil || !tr.items[0].loaded {
497+ t.Fatalf("item not loaded: %v", tr.err)
498+ }
499+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
500+ t.Fatalf("%v allocations per refresh, want 0", allocs)
501+ }
502+}
503+
504+func TestRenderSkipsUnchangedFrames(t *testing.T) {
505+ var buf strings.Builder
506+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
507+ tr.items = []*item{{loaded: true, icon: "X"}}
508+ for range 3 {
509+ if err := tr.render(false); err != nil {
510+ t.Fatal(err)
511+ }
512+ }
513+ if got := strings.Count(buf.String(), "\n"); got != 1 {
514+ t.Fatalf("%d frames written, want 1", got)
515+ }
516+ if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
517+ t.Fatalf("render allocates %v times", allocs)
518+ }
519+}
520+
521+func TestBetterPixmapSize(t *testing.T) {
522+ // The largest icon up to 64x64 wins; above that, the smallest.
523+ for _, c := range []struct {
524+ sizes []int // sides, in the order the item lists them
525+ want int
526+ }{
527+ {[]int{16, 32, 128}, 32},
528+ {[]int{128, 32, 16}, 32},
529+ {[]int{256, 128}, 128},
530+ {[]int{22}, 22},
531+ } {
532+ best := 0
533+ for _, s := range c.sizes {
534+ if best == 0 || better(s*s, best*best) {
535+ best = s
536+ }
537+ }
538+ if best != c.want {
539+ t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
540+ }
541+ }
542+}

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