detached 770277a

feat(tray): add neferbar tray, the system tray as Nerd Font icons

neferbar tray is a StatusNotifierItem host on zerobus. It becomes the
watcher when none runs and joins the existing one otherwise. Icons come
from the font's own glyph names, .desktop categories and a letter badge,
tinted with the application icon's main color. [tray.icons] overrides
an icon.
commit
770277ac3c157d643d946ebe3bf64204d497f2c5
author
brice · · 2026-10-08 19:43 UTC
parent
c616855
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +2008 -3
M README.md +30 -0
@@ -259,6 +259,36 @@ 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. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
276+2. a glyph for common status icons, such as a muted speaker;
277+3. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
278+4. the first letter of its name, in a circle.
279+
280+The icon takes the main color of the application's own icon, lightened 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.
281+
282+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 or the text to show:
283+
284+```toml
285+[tray.icons]
286+sunshine = "md-weather_sunny"
287+handy = "H"
288+```
289+
290+The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
291+
262292 ## Test without touching your desktop
263293
264294 ```sh
@@ -259,6 +259,36 @@ 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. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
276+2. a glyph for common status icons, such as a muted speaker;
277+3. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
278+4. the first letter of its name, in a circle.
279+
280+The icon takes the main color of the application's own icon, lightened 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.
281+
282+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 or the text to show:
283+
284+```toml
285+[tray.icons]
286+sunshine = "md-weather_sunny"
287+handy = "H"
288+```
289+
290+The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
291+
262 ## Test without touching your desktop292 ## Test without touching your desktop
263 293
264 ```sh294 ```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 +104 -0
@@ -0,0 +1,104 @@
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 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()
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() (fg, bg, accent tray.RGB) {
93+ def := theme.Default()
94+ return envColor("NEFERBAR_FOREGROUND", def.Foreground),
95+ envColor("NEFERBAR_BACKGROUND", def.Background),
96+ envColor("NEFERBAR_ACCENT", def.Palette[4])
97+}
98+
99+func envColor(name string, def tray.RGB) tray.RGB {
100+ if c, err := config.ParseColor(os.Getenv(name)); err == nil {
101+ return c
102+ }
103+ return def
104+}
@@ -0,0 +1,104 @@
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 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()
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() (fg, bg, accent tray.RGB) {
93+ def := theme.Default()
94+ return envColor("NEFERBAR_FOREGROUND", def.Foreground),
95+ envColor("NEFERBAR_BACKGROUND", def.Background),
96+ envColor("NEFERBAR_ACCENT", def.Palette[4])
97+}
98+
99+func envColor(name string, def tray.RGB) tray.RGB {
100+ if c, err := config.ParseColor(os.Getenv(name)); err == nil {
101+ return c
102+ }
103+ return def
104+}
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 +9 -0
@@ -37,9 +37,18 @@ type Module struct {
3737 Exec string `toml:"exec"`
3838 }
3939
40+// Tray configures "neferbar tray".
41+type Tray struct {
42+ // Icons replaces the icon chosen for an item. The key is the item id or
43+ // application name, in any case; the value is a Nerd Font glyph name such
44+ // as "fa-steam", or the text to show.
45+ Icons map[string]string `toml:"icons"`
46+}
47+
4048 // Config is the whole file.
4149 type Config struct {
4250 Bar Bar `toml:"bar"`
51+ Tray Tray `toml:"tray"`
4352 Module []Module `toml:"module"`
4453 }
4554
@@ -37,9 +37,18 @@ type Module struct {
37 Exec string `toml:"exec"`37 Exec string `toml:"exec"`
38 }38 }
39 39
40+// Tray configures "neferbar tray".
41+type Tray struct {
42+ // Icons replaces the icon chosen for an item. The key is the item id or
43+ // application name, in any case; the value is a Nerd Font glyph name such
44+ // as "fa-steam", or the text to show.
45+ Icons map[string]string `toml:"icons"`
46+}
47+
40 // Config is the whole file.48 // Config is the whole file.
41 type Config struct {49 type Config struct {
42 Bar Bar `toml:"bar"`50 Bar Bar `toml:"bar"`
51+ Tray Tray `toml:"tray"`
43 Module []Module `toml:"module"`52 Module []Module `toml:"module"`
44 }53 }
45 54
A internal/glyph/names.go +168 -0
@@ -0,0 +1,168 @@
1+package glyph
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "slices"
7+
8+ "golang.org/x/image/font/sfnt"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Names maps the glyph names stored in a font file to the runes that draw
13+// them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use
14+// areas are indexed: that is where icon fonts put their glyphs.
15+//
16+// The names stay in the mapped font file; a lookup does not allocate.
17+type Names struct {
18+ data []byte
19+ idx []nameEntry // sorted by name
20+}
21+
22+type nameEntry struct {
23+ off, n uint32 // the name is data[off:off+n]
24+ r rune
25+}
26+
27+// iconRanges are the private use areas: the BMP one and plane 15.
28+var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}}
29+
30+// LoadNames indexes the glyph names of the font file at path. A font without
31+// glyph names (a version 3 "post" table) gives an empty index, not an error.
32+func LoadNames(path string) (*Names, error) {
33+ data, err := mapFile(path)
34+ if err != nil {
35+ return nil, fmt.Errorf("glyph: %w", err)
36+ }
37+ n := &Names{data: data}
38+ f, err := sfnt.Parse(data)
39+ if err != nil {
40+ n.Close()
41+ return nil, fmt.Errorf("glyph: parse %s: %w", path, err)
42+ }
43+ spans := postNames(data)
44+ var buf sfnt.Buffer
45+ for _, rg := range iconRanges {
46+ for r := rg[0]; r <= rg[1]; r++ {
47+ gi, err := f.GlyphIndex(&buf, r)
48+ if err != nil || gi == 0 || int(gi) >= len(spans) {
49+ continue
50+ }
51+ if s := spans[gi]; s.n > 0 {
52+ n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r})
53+ }
54+ }
55+ }
56+ slices.SortFunc(n.idx, func(a, b nameEntry) int {
57+ x, y := n.name(a), n.name(b)
58+ switch {
59+ case x < y:
60+ return -1
61+ case x > y:
62+ return 1
63+ }
64+ return 0
65+ })
66+ return n, nil
67+}
68+
69+func (n *Names) name(e nameEntry) string { return string(n.data[e.off : e.off+e.n]) }
70+
71+// Lookup returns the rune of the glyph called name.
72+func (n *Names) Lookup(name string) (rune, bool) {
73+ if n == nil {
74+ return 0, false
75+ }
76+ lo, hi := 0, len(n.idx)
77+ for lo < hi {
78+ m := int(uint(lo+hi) >> 1)
79+ e := n.idx[m]
80+ // string(bytes) in a comparison does not allocate.
81+ switch s := n.data[e.off : e.off+e.n]; {
82+ case string(s) < name:
83+ lo = m + 1
84+ case string(s) > name:
85+ hi = m
86+ default:
87+ return e.r, true
88+ }
89+ }
90+ return 0, false
91+}
92+
93+// Len returns the number of indexed names.
94+func (n *Names) Len() int { return len(n.idx) }
95+
96+// Close unmaps the font file. n must not be used afterwards.
97+func (n *Names) Close() {
98+ if n != nil && n.data != nil {
99+ _ = unix.Munmap(n.data)
100+ n.data, n.idx = nil, nil
101+ }
102+}
103+
104+type span struct{ off, n uint32 }
105+
106+// postNames returns, for each glyph index, where its custom name sits in
107+// data, from a version 2 "post" table. Glyphs with a standard Macintosh name
108+// (index < 258) or no table at all give empty spans. Every offset is checked
109+// against the file, so a damaged font gives fewer names, never a panic.
110+func postNames(data []byte) []span {
111+ u16 := func(off int) (int, bool) {
112+ if off < 0 || off+2 > len(data) {
113+ return 0, false
114+ }
115+ return int(binary.BigEndian.Uint16(data[off:])), true
116+ }
117+ u32 := func(off int) (uint32, bool) {
118+ if off < 0 || off+4 > len(data) {
119+ return 0, false
120+ }
121+ return binary.BigEndian.Uint32(data[off:]), true
122+ }
123+ tables, ok := u16(4)
124+ if !ok {
125+ return nil
126+ }
127+ post, end := -1, 0
128+ for i := range tables {
129+ rec := 12 + 16*i
130+ if rec+16 > len(data) {
131+ return nil
132+ }
133+ if string(data[rec:rec+4]) == "post" {
134+ off, _ := u32(rec + 8)
135+ n, _ := u32(rec + 12)
136+ if uint64(off)+uint64(n) > uint64(len(data)) {
137+ return nil
138+ }
139+ post, end = int(off), int(off)+int(n)
140+ break
141+ }
142+ }
143+ if v, ok := u32(post); !ok || v != 0x00020000 {
144+ return nil
145+ }
146+ glyphs, ok := u16(post + 32)
147+ if !ok || post+34+2*glyphs > end {
148+ return nil
149+ }
150+ // The custom names follow the index array as Pascal strings.
151+ var custom []uint32
152+ for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) {
153+ if o+1+int(data[o]) > end {
154+ break
155+ }
156+ custom = append(custom, uint32(o))
157+ }
158+ spans := make([]span, glyphs)
159+ for g := range glyphs {
160+ k, _ := u16(post + 34 + 2*g)
161+ if k < 258 || k-258 >= len(custom) {
162+ continue
163+ }
164+ o := custom[k-258]
165+ spans[g] = span{off: o + 1, n: uint32(data[o])}
166+ }
167+ return spans
168+}
@@ -0,0 +1,168 @@
1+package glyph
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "slices"
7+
8+ "golang.org/x/image/font/sfnt"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Names maps the glyph names stored in a font file to the runes that draw
13+// them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use
14+// areas are indexed: that is where icon fonts put their glyphs.
15+//
16+// The names stay in the mapped font file; a lookup does not allocate.
17+type Names struct {
18+ data []byte
19+ idx []nameEntry // sorted by name
20+}
21+
22+type nameEntry struct {
23+ off, n uint32 // the name is data[off:off+n]
24+ r rune
25+}
26+
27+// iconRanges are the private use areas: the BMP one and plane 15.
28+var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}}
29+
30+// LoadNames indexes the glyph names of the font file at path. A font without
31+// glyph names (a version 3 "post" table) gives an empty index, not an error.
32+func LoadNames(path string) (*Names, error) {
33+ data, err := mapFile(path)
34+ if err != nil {
35+ return nil, fmt.Errorf("glyph: %w", err)
36+ }
37+ n := &Names{data: data}
38+ f, err := sfnt.Parse(data)
39+ if err != nil {
40+ n.Close()
41+ return nil, fmt.Errorf("glyph: parse %s: %w", path, err)
42+ }
43+ spans := postNames(data)
44+ var buf sfnt.Buffer
45+ for _, rg := range iconRanges {
46+ for r := rg[0]; r <= rg[1]; r++ {
47+ gi, err := f.GlyphIndex(&buf, r)
48+ if err != nil || gi == 0 || int(gi) >= len(spans) {
49+ continue
50+ }
51+ if s := spans[gi]; s.n > 0 {
52+ n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r})
53+ }
54+ }
55+ }
56+ slices.SortFunc(n.idx, func(a, b nameEntry) int {
57+ x, y := n.name(a), n.name(b)
58+ switch {
59+ case x < y:
60+ return -1
61+ case x > y:
62+ return 1
63+ }
64+ return 0
65+ })
66+ return n, nil
67+}
68+
69+func (n *Names) name(e nameEntry) string { return string(n.data[e.off : e.off+e.n]) }
70+
71+// Lookup returns the rune of the glyph called name.
72+func (n *Names) Lookup(name string) (rune, bool) {
73+ if n == nil {
74+ return 0, false
75+ }
76+ lo, hi := 0, len(n.idx)
77+ for lo < hi {
78+ m := int(uint(lo+hi) >> 1)
79+ e := n.idx[m]
80+ // string(bytes) in a comparison does not allocate.
81+ switch s := n.data[e.off : e.off+e.n]; {
82+ case string(s) < name:
83+ lo = m + 1
84+ case string(s) > name:
85+ hi = m
86+ default:
87+ return e.r, true
88+ }
89+ }
90+ return 0, false
91+}
92+
93+// Len returns the number of indexed names.
94+func (n *Names) Len() int { return len(n.idx) }
95+
96+// Close unmaps the font file. n must not be used afterwards.
97+func (n *Names) Close() {
98+ if n != nil && n.data != nil {
99+ _ = unix.Munmap(n.data)
100+ n.data, n.idx = nil, nil
101+ }
102+}
103+
104+type span struct{ off, n uint32 }
105+
106+// postNames returns, for each glyph index, where its custom name sits in
107+// data, from a version 2 "post" table. Glyphs with a standard Macintosh name
108+// (index < 258) or no table at all give empty spans. Every offset is checked
109+// against the file, so a damaged font gives fewer names, never a panic.
110+func postNames(data []byte) []span {
111+ u16 := func(off int) (int, bool) {
112+ if off < 0 || off+2 > len(data) {
113+ return 0, false
114+ }
115+ return int(binary.BigEndian.Uint16(data[off:])), true
116+ }
117+ u32 := func(off int) (uint32, bool) {
118+ if off < 0 || off+4 > len(data) {
119+ return 0, false
120+ }
121+ return binary.BigEndian.Uint32(data[off:]), true
122+ }
123+ tables, ok := u16(4)
124+ if !ok {
125+ return nil
126+ }
127+ post, end := -1, 0
128+ for i := range tables {
129+ rec := 12 + 16*i
130+ if rec+16 > len(data) {
131+ return nil
132+ }
133+ if string(data[rec:rec+4]) == "post" {
134+ off, _ := u32(rec + 8)
135+ n, _ := u32(rec + 12)
136+ if uint64(off)+uint64(n) > uint64(len(data)) {
137+ return nil
138+ }
139+ post, end = int(off), int(off)+int(n)
140+ break
141+ }
142+ }
143+ if v, ok := u32(post); !ok || v != 0x00020000 {
144+ return nil
145+ }
146+ glyphs, ok := u16(post + 32)
147+ if !ok || post+34+2*glyphs > end {
148+ return nil
149+ }
150+ // The custom names follow the index array as Pascal strings.
151+ var custom []uint32
152+ for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) {
153+ if o+1+int(data[o]) > end {
154+ break
155+ }
156+ custom = append(custom, uint32(o))
157+ }
158+ spans := make([]span, glyphs)
159+ for g := range glyphs {
160+ k, _ := u16(post + 34 + 2*g)
161+ if k < 258 || k-258 >= len(custom) {
162+ continue
163+ }
164+ o := custom[k-258]
165+ spans[g] = span{off: o + 1, n: uint32(data[o])}
166+ }
167+ return spans
168+}
A internal/glyph/names_test.go +87 -0
@@ -0,0 +1,87 @@
1+package glyph
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+// nerdFont returns the path of an installed Nerd Font, or skips the test.
10+func nerdFont(t *testing.T) string {
11+ t.Helper()
12+ paths, err := FindFonts("JetBrainsMono Nerd Font Mono")
13+ if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") {
14+ t.Skip("no Nerd Font installed")
15+ }
16+ return paths[0]
17+}
18+
19+func TestNamesLookup(t *testing.T) {
20+ n, err := LoadNames(nerdFont(t))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer n.Close()
25+ if n.Len() < 1000 {
26+ t.Fatalf("only %d names indexed", n.Len())
27+ }
28+ for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} {
29+ if r, ok := n.Lookup(name); !ok || r != want {
30+ t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want)
31+ }
32+ }
33+ if _, ok := n.Lookup("no-such-glyph"); ok {
34+ t.Error("an unknown name was found")
35+ }
36+ if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 {
37+ t.Errorf("Lookup allocates %v times", allocs)
38+ }
39+}
40+
41+func TestNamesWithoutIcons(t *testing.T) {
42+ // A font without icon names indexes nothing, and that is not an error.
43+ paths, err := FindFonts("monospace")
44+ if err != nil {
45+ t.Skip(err)
46+ }
47+ if _, err := os.Stat(paths[0]); err != nil {
48+ t.Skip(err)
49+ }
50+ n, err := LoadNames(paths[0])
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ defer n.Close()
55+ var nilNames *Names
56+ if _, ok := nilNames.Lookup("fa-steam"); ok {
57+ t.Error("a nil index found a name")
58+ }
59+}
60+
61+func TestPostNamesDamagedFont(t *testing.T) {
62+ // Truncated or garbage files give no names and never panic.
63+ var good []byte
64+ if paths, err := FindFonts("monospace"); err == nil {
65+ good, _ = os.ReadFile(paths[0])
66+ }
67+ inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)}
68+ for _, cut := range []int{12, 100, 1000, len(good) / 2} {
69+ if cut <= len(good) {
70+ inputs = append(inputs, good[:cut])
71+ }
72+ }
73+ for _, b := range inputs {
74+ _ = postNames(b)
75+ }
76+ // A "post" table that claims more glyphs than it holds.
77+ b := make([]byte, 12+16+40)
78+ b[5] = 1 // one table
79+ copy(b[12:], "post")
80+ b[12+11] = 28 // offset 28
81+ b[12+15] = 40 // length 40
82+ b[28+1] = 2 // version 2.0
83+ b[28+32] = 0xff
84+ if spans := postNames(b); spans != nil {
85+ t.Fatalf("got %d spans from a bad table", len(spans))
86+ }
87+}
@@ -0,0 +1,87 @@
1+package glyph
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+// nerdFont returns the path of an installed Nerd Font, or skips the test.
10+func nerdFont(t *testing.T) string {
11+ t.Helper()
12+ paths, err := FindFonts("JetBrainsMono Nerd Font Mono")
13+ if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") {
14+ t.Skip("no Nerd Font installed")
15+ }
16+ return paths[0]
17+}
18+
19+func TestNamesLookup(t *testing.T) {
20+ n, err := LoadNames(nerdFont(t))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer n.Close()
25+ if n.Len() < 1000 {
26+ t.Fatalf("only %d names indexed", n.Len())
27+ }
28+ for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} {
29+ if r, ok := n.Lookup(name); !ok || r != want {
30+ t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want)
31+ }
32+ }
33+ if _, ok := n.Lookup("no-such-glyph"); ok {
34+ t.Error("an unknown name was found")
35+ }
36+ if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 {
37+ t.Errorf("Lookup allocates %v times", allocs)
38+ }
39+}
40+
41+func TestNamesWithoutIcons(t *testing.T) {
42+ // A font without icon names indexes nothing, and that is not an error.
43+ paths, err := FindFonts("monospace")
44+ if err != nil {
45+ t.Skip(err)
46+ }
47+ if _, err := os.Stat(paths[0]); err != nil {
48+ t.Skip(err)
49+ }
50+ n, err := LoadNames(paths[0])
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ defer n.Close()
55+ var nilNames *Names
56+ if _, ok := nilNames.Lookup("fa-steam"); ok {
57+ t.Error("a nil index found a name")
58+ }
59+}
60+
61+func TestPostNamesDamagedFont(t *testing.T) {
62+ // Truncated or garbage files give no names and never panic.
63+ var good []byte
64+ if paths, err := FindFonts("monospace"); err == nil {
65+ good, _ = os.ReadFile(paths[0])
66+ }
67+ inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)}
68+ for _, cut := range []int{12, 100, 1000, len(good) / 2} {
69+ if cut <= len(good) {
70+ inputs = append(inputs, good[:cut])
71+ }
72+ }
73+ for _, b := range inputs {
74+ _ = postNames(b)
75+ }
76+ // A "post" table that claims more glyphs than it holds.
77+ b := make([]byte, 12+16+40)
78+ b[5] = 1 // one table
79+ copy(b[12:], "post")
80+ b[12+11] = 28 // offset 28
81+ b[12+15] = 40 // length 40
82+ b[28+1] = 2 // version 2.0
83+ b[28+32] = 0xff
84+ if spans := postNames(b); spans != nil {
85+ t.Fatalf("got %d spans from a bad table", len(spans))
86+ }
87+}
A internal/tray/color.go +90 -0
@@ -0,0 +1,90 @@
1+package tray
2+
3+import "math"
4+
5+// histogram finds the dominant color of an icon. Colors are counted in 4096
6+// buckets (4 bits per channel), weighted by opacity and saturation so a logo's
7+// color wins over its grey outline. It is reused between icons: no
8+// allocation.
9+type histogram struct {
10+ weight [4096]uint32
11+ sum [4096][3]uint32
12+}
13+
14+// dominant returns the dominant color of argb, an SNI pixmap: big-endian
15+// ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
16+// transparent or grey.
17+func (h *histogram) dominant(argb []byte) (RGB, bool) {
18+ if len(argb) < 4 {
19+ return RGB{}, false
20+ }
21+ clear(h.weight[:])
22+ clear(h.sum[:])
23+ for i := 0; i+3 < len(argb); i += 4 {
24+ a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
25+ if a < 128 {
26+ continue
27+ }
28+ hi, lo := max(r, g, b), min(r, g, b)
29+ chroma := hi - lo
30+ if chroma < 40 {
31+ continue // grey, black or white: outline and shading
32+ }
33+ w := a * chroma >> 8
34+ k := (r>>4)<<8 | (g>>4)<<4 | b>>4
35+ h.weight[k] += w
36+ h.sum[k][0] += r * w
37+ h.sum[k][1] += g * w
38+ h.sum[k][2] += b * w
39+ }
40+ best := -1
41+ for k, w := range h.weight {
42+ if w > 0 && (best < 0 || w > h.weight[best]) {
43+ best = k
44+ }
45+ }
46+ if best < 0 {
47+ return RGB{}, false
48+ }
49+ w := h.weight[best]
50+ s := h.sum[best]
51+ return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true
52+}
53+
54+// readable moves c toward fg until it has enough contrast with bg, so a dark
55+// logo still shows on a dark bar.
56+func readable(c, bg, fg RGB) RGB {
57+ const minContrast = 3.0 // WCAG's minimum for large text and icons
58+ for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
59+ c = mix(c, fg, 0.25)
60+ }
61+ return c
62+}
63+
64+func mix(a, b RGB, t float64) RGB {
65+ var out RGB
66+ for i := range out {
67+ out[i] = uint8(math.Round(float64(a[i]) + (float64(b[i])-float64(a[i]))*t))
68+ }
69+ return out
70+}
71+
72+// contrast is the WCAG contrast ratio of two colors, from 1 to 21.
73+func contrast(a, b RGB) float64 {
74+ la, lb := luminance(a), luminance(b)
75+ if la < lb {
76+ la, lb = lb, la
77+ }
78+ return (la + 0.05) / (lb + 0.05)
79+}
80+
81+func luminance(c RGB) float64 {
82+ lin := func(v uint8) float64 {
83+ x := float64(v) / 255
84+ if x <= 0.04045 {
85+ return x / 12.92
86+ }
87+ return math.Pow((x+0.055)/1.055, 2.4)
88+ }
89+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
90+}
@@ -0,0 +1,90 @@
1+package tray
2+
3+import "math"
4+
5+// histogram finds the dominant color of an icon. Colors are counted in 4096
6+// buckets (4 bits per channel), weighted by opacity and saturation so a logo's
7+// color wins over its grey outline. It is reused between icons: no
8+// allocation.
9+type histogram struct {
10+ weight [4096]uint32
11+ sum [4096][3]uint32
12+}
13+
14+// dominant returns the dominant color of argb, an SNI pixmap: big-endian
15+// ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
16+// transparent or grey.
17+func (h *histogram) dominant(argb []byte) (RGB, bool) {
18+ if len(argb) < 4 {
19+ return RGB{}, false
20+ }
21+ clear(h.weight[:])
22+ clear(h.sum[:])
23+ for i := 0; i+3 < len(argb); i += 4 {
24+ a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
25+ if a < 128 {
26+ continue
27+ }
28+ hi, lo := max(r, g, b), min(r, g, b)
29+ chroma := hi - lo
30+ if chroma < 40 {
31+ continue // grey, black or white: outline and shading
32+ }
33+ w := a * chroma >> 8
34+ k := (r>>4)<<8 | (g>>4)<<4 | b>>4
35+ h.weight[k] += w
36+ h.sum[k][0] += r * w
37+ h.sum[k][1] += g * w
38+ h.sum[k][2] += b * w
39+ }
40+ best := -1
41+ for k, w := range h.weight {
42+ if w > 0 && (best < 0 || w > h.weight[best]) {
43+ best = k
44+ }
45+ }
46+ if best < 0 {
47+ return RGB{}, false
48+ }
49+ w := h.weight[best]
50+ s := h.sum[best]
51+ return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true
52+}
53+
54+// readable moves c toward fg until it has enough contrast with bg, so a dark
55+// logo still shows on a dark bar.
56+func readable(c, bg, fg RGB) RGB {
57+ const minContrast = 3.0 // WCAG's minimum for large text and icons
58+ for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
59+ c = mix(c, fg, 0.25)
60+ }
61+ return c
62+}
63+
64+func mix(a, b RGB, t float64) RGB {
65+ var out RGB
66+ for i := range out {
67+ out[i] = uint8(math.Round(float64(a[i]) + (float64(b[i])-float64(a[i]))*t))
68+ }
69+ return out
70+}
71+
72+// contrast is the WCAG contrast ratio of two colors, from 1 to 21.
73+func contrast(a, b RGB) float64 {
74+ la, lb := luminance(a), luminance(b)
75+ if la < lb {
76+ la, lb = lb, la
77+ }
78+ return (la + 0.05) / (lb + 0.05)
79+}
80+
81+func luminance(c RGB) float64 {
82+ lin := func(v uint8) float64 {
83+ x := float64(v) / 255
84+ if x <= 0.04045 {
85+ return x / 12.92
86+ }
87+ return math.Pow((x+0.055)/1.055, 2.4)
88+ }
89+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
90+}
A internal/tray/color_test.go +50 -0
@@ -0,0 +1,50 @@
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 TestReadable(t *testing.T) {
40+ bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
41+ dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
42+ got := readable(dark, bg, fg)
43+ if contrast(got, bg) < 3 {
44+ t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg))
45+ }
46+ bright := RGB{0xf9, 0xe2, 0xaf}
47+ if readable(bright, bg, fg) != bright {
48+ t.Fatal("a readable color must not change")
49+ }
50+}
@@ -0,0 +1,50 @@
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 TestReadable(t *testing.T) {
40+ bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
41+ dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
42+ got := readable(dark, bg, fg)
43+ if contrast(got, bg) < 3 {
44+ t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg))
45+ }
46+ bright := RGB{0xf9, 0xe2, 0xaf}
47+ if readable(bright, bg, fg) != bright {
48+ t.Fatal("a readable color must not change")
49+ }
50+}
A internal/tray/resolve.go +336 -0
@@ -0,0 +1,336 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "unicode"
9+)
10+
11+// Resolver picks the icon of a tray item from what the system already knows,
12+// so a new application needs no table entry:
13+//
14+// 1. the user's [tray.icons] override;
15+// 2. a Nerd Font glyph named after the application, such as "fa-steam";
16+// 3. a glyph for a common status icon name, such as "audio-volume-muted";
17+// 4. a glyph for the category of its .desktop file, such as Game;
18+// 5. a letter in a circle.
19+type Resolver struct {
20+ lookup func(name string) (rune, bool)
21+ icons map[string]string // lowercased keys
22+ dataDirs []string
23+ apps map[string]*desktopApp // nil until the first lookup
24+}
25+
26+// desktopApp is what the resolver uses from a .desktop file.
27+type desktopApp struct {
28+ name string
29+ words []string // file name, Icon and StartupWMClass, lowercased
30+ categories []string
31+}
32+
33+// NewResolver returns a resolver that finds glyphs with lookup (a font's
34+// glyph names) and .desktop files under dataDirs/applications. Nil dataDirs
35+// uses the XDG data directories.
36+func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver {
37+ r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs}
38+ for k, v := range icons {
39+ r.icons[strings.ToLower(k)] = v
40+ }
41+ if r.dataDirs == nil {
42+ r.dataDirs = xdgDataDirs()
43+ }
44+ return r
45+}
46+
47+// prefixes are the Nerd Font sets, brand-rich ones first.
48+var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"}
49+
50+// stopwords are parts of item ids and names that say nothing about the
51+// application.
52+var stopwords = map[string]bool{
53+ "org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true,
54+ "desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true,
55+ "notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true,
56+ "gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true,
57+ "ayatana": true, "notification": true, "electron": true, "bin": true,
58+}
59+
60+// statusIcons maps words of themed icon names to glyphs, most specific first.
61+var statusIcons = [...][2]string{
62+ {"muted", "md-volume_off"}, {"volume", "md-volume_high"}, {"audio", "md-volume_high"},
63+ {"microphone", "md-microphone"}, {"mic", "md-microphone"},
64+ {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
65+ {"vpn", "md-vpn"}, {"wired", "md-lan"}, {"ethernet", "md-lan"},
66+ {"wireless", "md-wifi"}, {"wifi", "md-wifi"}, {"signal", "md-wifi"}, {"network", "md-lan"},
67+ {"update", "md-update"}, {"updates", "md-update"}, {"mail", "md-email"},
68+}
69+
70+// categories maps .desktop categories to glyphs, most specific first: an
71+// application lists broad categories too, such as "Network;WebBrowser".
72+var categories = [...][2]string{
73+ {"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"},
74+ {"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"},
75+ {"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"},
76+ {"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"},
77+ {"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"},
78+ {"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"},
79+ {"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"},
80+ {"System", "md-cog"}, {"Utility", "md-tools"},
81+}
82+
83+// Resolve returns the icon text of an item: one glyph, or the user's
84+// override. id, iconName and title are the item's properties; any may be
85+// empty.
86+func (r *Resolver) Resolve(id, iconName, title string) string {
87+ if strings.HasPrefix(iconName, "/") {
88+ iconName = "" // a file path names no application
89+ }
90+ keys := [...]string{id, iconName, title}
91+ for _, k := range keys {
92+ if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" {
93+ return r.glyphOr(v, v)
94+ }
95+ }
96+ app := r.app(keys[:])
97+ if app != nil {
98+ if v, ok := r.icons[strings.ToLower(app.name)]; ok {
99+ return r.glyphOr(v, v)
100+ }
101+ }
102+ // Status icon names first: "audio-volume-muted" is a state, not a brand.
103+ for _, w := range words(iconName) {
104+ for _, s := range statusIcons {
105+ if w == s[0] {
106+ if g, ok := r.glyph(s[1]); ok {
107+ return g
108+ }
109+ }
110+ }
111+ }
112+ var names []string
113+ names = append(names, keys[:]...)
114+ if app != nil {
115+ names = append(names, app.words...)
116+ names = append(names, app.name)
117+ }
118+ for _, n := range names {
119+ if g, ok := r.brand(n); ok {
120+ return g
121+ }
122+ }
123+ if app != nil {
124+ for _, c := range categories {
125+ for _, ac := range app.categories {
126+ if ac == c[0] {
127+ if g, ok := r.glyph(c[1]); ok {
128+ return g
129+ }
130+ }
131+ }
132+ }
133+ }
134+ for _, n := range [...]string{nameOf(app), title, id} {
135+ if l := firstLetter(n); l != 0 {
136+ return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l)))
137+ }
138+ }
139+ return "•"
140+}
141+
142+func nameOf(app *desktopApp) string {
143+ if app == nil {
144+ return ""
145+ }
146+ return app.name
147+}
148+
149+// glyph returns the glyph called name as a string.
150+func (r *Resolver) glyph(name string) (string, bool) {
151+ if rn, ok := r.lookup(name); ok {
152+ return string(rn), true
153+ }
154+ return "", false
155+}
156+
157+// glyphOr returns the glyph called name, or fallback when the font has none.
158+func (r *Resolver) glyphOr(name, fallback string) string {
159+ if g, ok := r.glyph(name); ok {
160+ return g
161+ }
162+ return fallback
163+}
164+
165+// brand finds a glyph named after s: its whole name, then each word.
166+func (r *Resolver) brand(s string) (string, bool) {
167+ s = strings.ToLower(s)
168+ whole := strings.Map(func(c rune) rune {
169+ if c == '-' || c == '.' || c == ' ' {
170+ return '_'
171+ }
172+ return c
173+ }, s)
174+ if !stopwords[whole] {
175+ if g, ok := r.prefixed(whole); ok {
176+ return g, true
177+ }
178+ }
179+ for _, w := range words(s) {
180+ if g, ok := r.prefixed(w); ok {
181+ return g, true
182+ }
183+ }
184+ return "", false
185+}
186+
187+func (r *Resolver) prefixed(w string) (string, bool) {
188+ if len(w) < 3 {
189+ return "", false
190+ }
191+ for _, p := range prefixes {
192+ if g, ok := r.glyph(p + "-" + w); ok {
193+ return g, true
194+ }
195+ }
196+ return "", false
197+}
198+
199+// words splits s into lowercase alphanumeric words of 3 or more letters,
200+// without stopwords or numbers.
201+func words(s string) []string {
202+ var out []string
203+ for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool {
204+ return !unicode.IsLetter(c) && !unicode.IsDigit(c)
205+ }) {
206+ if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) {
207+ out = append(out, f)
208+ }
209+ }
210+ return out
211+}
212+
213+func firstLetter(s string) rune {
214+ for _, c := range strings.ToLower(s) {
215+ if c >= 'a' && c <= 'z' {
216+ return c
217+ }
218+ }
219+ return 0
220+}
221+
222+// app finds the .desktop file of an item from its keys and their words.
223+func (r *Resolver) app(keys []string) *desktopApp {
224+ if r.apps == nil {
225+ r.apps = loadApps(r.dataDirs)
226+ }
227+ for _, k := range keys {
228+ if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
229+ return a
230+ }
231+ }
232+ for _, k := range keys {
233+ for _, w := range words(k) {
234+ if a := r.apps[w]; a != nil {
235+ return a
236+ }
237+ }
238+ }
239+ return nil
240+}
241+
242+func xdgDataDirs() []string {
243+ home := os.Getenv("XDG_DATA_HOME")
244+ if home == "" {
245+ if h, err := os.UserHomeDir(); err == nil {
246+ home = filepath.Join(h, ".local", "share")
247+ }
248+ }
249+ dirs := os.Getenv("XDG_DATA_DIRS")
250+ if dirs == "" {
251+ dirs = "/usr/local/share:/usr/share"
252+ }
253+ return append([]string{home}, filepath.SplitList(dirs)...)
254+}
255+
256+// loadApps indexes the .desktop files of dataDirs by lowercased file name,
257+// Icon and StartupWMClass, and by the words of those. Whole values win over
258+// words, and earlier directories over later ones, as in the XDG spec.
259+func loadApps(dataDirs []string) map[string]*desktopApp {
260+ apps := map[string]*desktopApp{}
261+ var all []*desktopApp
262+ for _, d := range dataDirs {
263+ if d == "" {
264+ continue
265+ }
266+ files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop"))
267+ for _, f := range files {
268+ if a := readDesktop(f); a != nil {
269+ all = append(all, a)
270+ }
271+ }
272+ }
273+ add := func(k string, a *desktopApp) {
274+ if _, taken := apps[k]; !taken {
275+ apps[k] = a
276+ }
277+ }
278+ for _, a := range all {
279+ for _, w := range a.words {
280+ add(w, a)
281+ }
282+ }
283+ for _, a := range all {
284+ for _, v := range a.words {
285+ for _, w := range words(v) {
286+ add(w, a)
287+ }
288+ }
289+ }
290+ return apps
291+}
292+
293+// readDesktop reads the keys the resolver needs from the [Desktop Entry]
294+// group of a .desktop file.
295+func readDesktop(path string) *desktopApp {
296+ f, err := os.Open(path)
297+ if err != nil {
298+ return nil
299+ }
300+ defer func() { _ = f.Close() }()
301+ a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}}
302+ in := false
303+ sc := bufio.NewScanner(f)
304+ for sc.Scan() {
305+ line := strings.TrimSpace(sc.Text())
306+ if strings.HasPrefix(line, "[") {
307+ if in {
308+ break // only the first group is the entry
309+ }
310+ in = line == "[Desktop Entry]"
311+ continue
312+ }
313+ k, v, ok := strings.Cut(line, "=")
314+ if !in || !ok {
315+ continue
316+ }
317+ switch strings.TrimSpace(k) {
318+ case "Name":
319+ a.name = strings.TrimSpace(v)
320+ case "Icon":
321+ if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") {
322+ a.words = append(a.words, strings.ToLower(v))
323+ }
324+ case "StartupWMClass":
325+ if v = strings.TrimSpace(v); v != "" {
326+ a.words = append(a.words, strings.ToLower(v))
327+ }
328+ case "Categories":
329+ a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
330+ }
331+ }
332+ if !in {
333+ return nil
334+ }
335+ return a
336+}
@@ -0,0 +1,336 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "unicode"
9+)
10+
11+// Resolver picks the icon of a tray item from what the system already knows,
12+// so a new application needs no table entry:
13+//
14+// 1. the user's [tray.icons] override;
15+// 2. a Nerd Font glyph named after the application, such as "fa-steam";
16+// 3. a glyph for a common status icon name, such as "audio-volume-muted";
17+// 4. a glyph for the category of its .desktop file, such as Game;
18+// 5. a letter in a circle.
19+type Resolver struct {
20+ lookup func(name string) (rune, bool)
21+ icons map[string]string // lowercased keys
22+ dataDirs []string
23+ apps map[string]*desktopApp // nil until the first lookup
24+}
25+
26+// desktopApp is what the resolver uses from a .desktop file.
27+type desktopApp struct {
28+ name string
29+ words []string // file name, Icon and StartupWMClass, lowercased
30+ categories []string
31+}
32+
33+// NewResolver returns a resolver that finds glyphs with lookup (a font's
34+// glyph names) and .desktop files under dataDirs/applications. Nil dataDirs
35+// uses the XDG data directories.
36+func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver {
37+ r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs}
38+ for k, v := range icons {
39+ r.icons[strings.ToLower(k)] = v
40+ }
41+ if r.dataDirs == nil {
42+ r.dataDirs = xdgDataDirs()
43+ }
44+ return r
45+}
46+
47+// prefixes are the Nerd Font sets, brand-rich ones first.
48+var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"}
49+
50+// stopwords are parts of item ids and names that say nothing about the
51+// application.
52+var stopwords = map[string]bool{
53+ "org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true,
54+ "desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true,
55+ "notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true,
56+ "gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true,
57+ "ayatana": true, "notification": true, "electron": true, "bin": true,
58+}
59+
60+// statusIcons maps words of themed icon names to glyphs, most specific first.
61+var statusIcons = [...][2]string{
62+ {"muted", "md-volume_off"}, {"volume", "md-volume_high"}, {"audio", "md-volume_high"},
63+ {"microphone", "md-microphone"}, {"mic", "md-microphone"},
64+ {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
65+ {"vpn", "md-vpn"}, {"wired", "md-lan"}, {"ethernet", "md-lan"},
66+ {"wireless", "md-wifi"}, {"wifi", "md-wifi"}, {"signal", "md-wifi"}, {"network", "md-lan"},
67+ {"update", "md-update"}, {"updates", "md-update"}, {"mail", "md-email"},
68+}
69+
70+// categories maps .desktop categories to glyphs, most specific first: an
71+// application lists broad categories too, such as "Network;WebBrowser".
72+var categories = [...][2]string{
73+ {"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"},
74+ {"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"},
75+ {"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"},
76+ {"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"},
77+ {"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"},
78+ {"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"},
79+ {"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"},
80+ {"System", "md-cog"}, {"Utility", "md-tools"},
81+}
82+
83+// Resolve returns the icon text of an item: one glyph, or the user's
84+// override. id, iconName and title are the item's properties; any may be
85+// empty.
86+func (r *Resolver) Resolve(id, iconName, title string) string {
87+ if strings.HasPrefix(iconName, "/") {
88+ iconName = "" // a file path names no application
89+ }
90+ keys := [...]string{id, iconName, title}
91+ for _, k := range keys {
92+ if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" {
93+ return r.glyphOr(v, v)
94+ }
95+ }
96+ app := r.app(keys[:])
97+ if app != nil {
98+ if v, ok := r.icons[strings.ToLower(app.name)]; ok {
99+ return r.glyphOr(v, v)
100+ }
101+ }
102+ // Status icon names first: "audio-volume-muted" is a state, not a brand.
103+ for _, w := range words(iconName) {
104+ for _, s := range statusIcons {
105+ if w == s[0] {
106+ if g, ok := r.glyph(s[1]); ok {
107+ return g
108+ }
109+ }
110+ }
111+ }
112+ var names []string
113+ names = append(names, keys[:]...)
114+ if app != nil {
115+ names = append(names, app.words...)
116+ names = append(names, app.name)
117+ }
118+ for _, n := range names {
119+ if g, ok := r.brand(n); ok {
120+ return g
121+ }
122+ }
123+ if app != nil {
124+ for _, c := range categories {
125+ for _, ac := range app.categories {
126+ if ac == c[0] {
127+ if g, ok := r.glyph(c[1]); ok {
128+ return g
129+ }
130+ }
131+ }
132+ }
133+ }
134+ for _, n := range [...]string{nameOf(app), title, id} {
135+ if l := firstLetter(n); l != 0 {
136+ return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l)))
137+ }
138+ }
139+ return "•"
140+}
141+
142+func nameOf(app *desktopApp) string {
143+ if app == nil {
144+ return ""
145+ }
146+ return app.name
147+}
148+
149+// glyph returns the glyph called name as a string.
150+func (r *Resolver) glyph(name string) (string, bool) {
151+ if rn, ok := r.lookup(name); ok {
152+ return string(rn), true
153+ }
154+ return "", false
155+}
156+
157+// glyphOr returns the glyph called name, or fallback when the font has none.
158+func (r *Resolver) glyphOr(name, fallback string) string {
159+ if g, ok := r.glyph(name); ok {
160+ return g
161+ }
162+ return fallback
163+}
164+
165+// brand finds a glyph named after s: its whole name, then each word.
166+func (r *Resolver) brand(s string) (string, bool) {
167+ s = strings.ToLower(s)
168+ whole := strings.Map(func(c rune) rune {
169+ if c == '-' || c == '.' || c == ' ' {
170+ return '_'
171+ }
172+ return c
173+ }, s)
174+ if !stopwords[whole] {
175+ if g, ok := r.prefixed(whole); ok {
176+ return g, true
177+ }
178+ }
179+ for _, w := range words(s) {
180+ if g, ok := r.prefixed(w); ok {
181+ return g, true
182+ }
183+ }
184+ return "", false
185+}
186+
187+func (r *Resolver) prefixed(w string) (string, bool) {
188+ if len(w) < 3 {
189+ return "", false
190+ }
191+ for _, p := range prefixes {
192+ if g, ok := r.glyph(p + "-" + w); ok {
193+ return g, true
194+ }
195+ }
196+ return "", false
197+}
198+
199+// words splits s into lowercase alphanumeric words of 3 or more letters,
200+// without stopwords or numbers.
201+func words(s string) []string {
202+ var out []string
203+ for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool {
204+ return !unicode.IsLetter(c) && !unicode.IsDigit(c)
205+ }) {
206+ if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) {
207+ out = append(out, f)
208+ }
209+ }
210+ return out
211+}
212+
213+func firstLetter(s string) rune {
214+ for _, c := range strings.ToLower(s) {
215+ if c >= 'a' && c <= 'z' {
216+ return c
217+ }
218+ }
219+ return 0
220+}
221+
222+// app finds the .desktop file of an item from its keys and their words.
223+func (r *Resolver) app(keys []string) *desktopApp {
224+ if r.apps == nil {
225+ r.apps = loadApps(r.dataDirs)
226+ }
227+ for _, k := range keys {
228+ if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
229+ return a
230+ }
231+ }
232+ for _, k := range keys {
233+ for _, w := range words(k) {
234+ if a := r.apps[w]; a != nil {
235+ return a
236+ }
237+ }
238+ }
239+ return nil
240+}
241+
242+func xdgDataDirs() []string {
243+ home := os.Getenv("XDG_DATA_HOME")
244+ if home == "" {
245+ if h, err := os.UserHomeDir(); err == nil {
246+ home = filepath.Join(h, ".local", "share")
247+ }
248+ }
249+ dirs := os.Getenv("XDG_DATA_DIRS")
250+ if dirs == "" {
251+ dirs = "/usr/local/share:/usr/share"
252+ }
253+ return append([]string{home}, filepath.SplitList(dirs)...)
254+}
255+
256+// loadApps indexes the .desktop files of dataDirs by lowercased file name,
257+// Icon and StartupWMClass, and by the words of those. Whole values win over
258+// words, and earlier directories over later ones, as in the XDG spec.
259+func loadApps(dataDirs []string) map[string]*desktopApp {
260+ apps := map[string]*desktopApp{}
261+ var all []*desktopApp
262+ for _, d := range dataDirs {
263+ if d == "" {
264+ continue
265+ }
266+ files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop"))
267+ for _, f := range files {
268+ if a := readDesktop(f); a != nil {
269+ all = append(all, a)
270+ }
271+ }
272+ }
273+ add := func(k string, a *desktopApp) {
274+ if _, taken := apps[k]; !taken {
275+ apps[k] = a
276+ }
277+ }
278+ for _, a := range all {
279+ for _, w := range a.words {
280+ add(w, a)
281+ }
282+ }
283+ for _, a := range all {
284+ for _, v := range a.words {
285+ for _, w := range words(v) {
286+ add(w, a)
287+ }
288+ }
289+ }
290+ return apps
291+}
292+
293+// readDesktop reads the keys the resolver needs from the [Desktop Entry]
294+// group of a .desktop file.
295+func readDesktop(path string) *desktopApp {
296+ f, err := os.Open(path)
297+ if err != nil {
298+ return nil
299+ }
300+ defer func() { _ = f.Close() }()
301+ a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}}
302+ in := false
303+ sc := bufio.NewScanner(f)
304+ for sc.Scan() {
305+ line := strings.TrimSpace(sc.Text())
306+ if strings.HasPrefix(line, "[") {
307+ if in {
308+ break // only the first group is the entry
309+ }
310+ in = line == "[Desktop Entry]"
311+ continue
312+ }
313+ k, v, ok := strings.Cut(line, "=")
314+ if !in || !ok {
315+ continue
316+ }
317+ switch strings.TrimSpace(k) {
318+ case "Name":
319+ a.name = strings.TrimSpace(v)
320+ case "Icon":
321+ if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") {
322+ a.words = append(a.words, strings.ToLower(v))
323+ }
324+ case "StartupWMClass":
325+ if v = strings.TrimSpace(v); v != "" {
326+ a.words = append(a.words, strings.ToLower(v))
327+ }
328+ case "Categories":
329+ a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
330+ }
331+ }
332+ if !in {
333+ return nil
334+ }
335+ return a
336+}
A internal/tray/resolve_test.go +74 -0
@@ -0,0 +1,74 @@
1+package tray
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+// fakeFont knows a few glyph names; each maps to a letter so results read
10+// easily.
11+var fakeFont = map[string]rune{
12+ "fa-steam": 'S',
13+ "custom-obsidian": 'O',
14+ "md-volume_off": 'M',
15+ "md-gamepad_variant": 'G',
16+ "md-web": 'W',
17+ "md-lan": 'N',
18+ "md-alpha_z_circle": 'Z',
19+ "md-google_chrome": 'C',
20+}
21+
22+func lookup(name string) (rune, bool) {
23+ r, ok := fakeFont[name]
24+ return r, ok
25+}
26+
27+func writeDesktop(t *testing.T, dir, name, body string) {
28+ t.Helper()
29+ apps := filepath.Join(dir, "applications")
30+ if err := os.MkdirAll(apps, 0o755); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil {
34+ t.Fatal(err)
35+ }
36+}
37+
38+func TestResolve(t *testing.T) {
39+ dir := t.TempDir()
40+ writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n")
41+ writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
42+ writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
43+ writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
44+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir})
45+
46+ for _, c := range []struct {
47+ name, id, icon, title, want string
48+ }{
49+ {"brand from the id", "steam", "", "", "S"},
50+ {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
51+ {"brand from the whole name", "google-chrome", "", "", "C"},
52+ {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
53+ {"category from the desktop file", "racer", "racer-game", "", "G"},
54+ {"most specific category wins", "chromium", "", "", "W"},
55+ {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
56+ {"literal override, any case", "sunshine", "", "", "☀"},
57+ {"glyph-name override", "", "", "mail", "N"},
58+ {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"},
59+ {"letter without the font glyph", "Quux", "", "", "Q"},
60+ {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"},
61+ {"nothing to go on", "", "", "", "•"},
62+ } {
63+ if got := r.Resolve(c.id, c.icon, c.title); got != c.want {
64+ t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want)
65+ }
66+ }
67+}
68+
69+func TestWords(t *testing.T) {
70+ got := words("org.telegram.desktop_2-Tray 42")
71+ if len(got) != 1 || got[0] != "telegram" {
72+ t.Fatalf("words = %q", got)
73+ }
74+}
@@ -0,0 +1,74 @@
1+package tray
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+// fakeFont knows a few glyph names; each maps to a letter so results read
10+// easily.
11+var fakeFont = map[string]rune{
12+ "fa-steam": 'S',
13+ "custom-obsidian": 'O',
14+ "md-volume_off": 'M',
15+ "md-gamepad_variant": 'G',
16+ "md-web": 'W',
17+ "md-lan": 'N',
18+ "md-alpha_z_circle": 'Z',
19+ "md-google_chrome": 'C',
20+}
21+
22+func lookup(name string) (rune, bool) {
23+ r, ok := fakeFont[name]
24+ return r, ok
25+}
26+
27+func writeDesktop(t *testing.T, dir, name, body string) {
28+ t.Helper()
29+ apps := filepath.Join(dir, "applications")
30+ if err := os.MkdirAll(apps, 0o755); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil {
34+ t.Fatal(err)
35+ }
36+}
37+
38+func TestResolve(t *testing.T) {
39+ dir := t.TempDir()
40+ writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n")
41+ writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
42+ writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
43+ writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
44+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir})
45+
46+ for _, c := range []struct {
47+ name, id, icon, title, want string
48+ }{
49+ {"brand from the id", "steam", "", "", "S"},
50+ {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
51+ {"brand from the whole name", "google-chrome", "", "", "C"},
52+ {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
53+ {"category from the desktop file", "racer", "racer-game", "", "G"},
54+ {"most specific category wins", "chromium", "", "", "W"},
55+ {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
56+ {"literal override, any case", "sunshine", "", "", "☀"},
57+ {"glyph-name override", "", "", "mail", "N"},
58+ {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"},
59+ {"letter without the font glyph", "Quux", "", "", "Q"},
60+ {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"},
61+ {"nothing to go on", "", "", "", "•"},
62+ } {
63+ if got := r.Resolve(c.id, c.icon, c.title); got != c.want {
64+ t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want)
65+ }
66+ }
67+}
68+
69+func TestWords(t *testing.T) {
70+ got := words("org.telegram.desktop_2-Tray 42")
71+ if len(got) != 1 || got[0] != "telegram" {
72+ t.Fatalf("words = %q", got)
73+ }
74+}
A internal/tray/tray.go +662 -0
@@ -0,0 +1,662 @@
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
68+ color RGB
69+ hasColor bool
70+ loaded bool // GetAll answered at least once
71+ pending uint32 // serial of a GetAll in flight
72+ stale bool // a change arrived while GetAll was in flight
73+}
74+
75+type pendKind uint8
76+
77+const (
78+ pendGetAll pendKind = iota + 1
79+ pendItems // the watcher's RegisteredStatusNotifierItems
80+ pendName // RequestName of the watcher name
81+ pendIgnore
82+)
83+
84+type pend struct {
85+ serial uint32
86+ kind pendKind
87+ it *item
88+}
89+
90+// Tray is one running tray.
91+type Tray struct {
92+ c *zerobus.Conn
93+ opt Options
94+ watcher bool // this process owns the watcher name
95+ host string
96+ items []*item
97+ pending []pend
98+ frame []byte
99+ last []byte
100+ hist histogram
101+ err error // first fatal error from dispatch
102+}
103+
104+// Run serves the tray on c until ctx ends or the bus connection fails. It
105+// closes c.
106+func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
107+ t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
108+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
109+ if t.opt.Log == nil {
110+ t.opt.Log = slog.New(slog.DiscardHandler)
111+ }
112+ stop := context.AfterFunc(ctx, func() { _ = c.Close() })
113+ defer stop()
114+ defer func() { _ = c.Close() }()
115+
116+ // Calls made from here on only go to the bus, which always answers.
117+ // Handle sees what arrives meanwhile.
118+ c.Handle = t.dispatch
119+ for _, rule := range [...]string{
120+ "type='signal',interface='" + itemIface + "'",
121+ "type='signal',interface='" + watcherIface + "'",
122+ "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
123+ } {
124+ if err := c.AddMatch(rule); err != nil {
125+ return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
126+ }
127+ }
128+ if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
129+ return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
130+ }
131+ t.claim()
132+ if err := t.render(true); err != nil {
133+ return err
134+ }
135+ for t.err == nil {
136+ m, err := c.ReadMessage()
137+ if err != nil {
138+ return t.exit(ctx, err)
139+ }
140+ t.dispatch(m)
141+ }
142+ return t.err
143+}
144+
145+// exit returns nil when the error comes from ctx ending.
146+func (t *Tray) exit(ctx context.Context, err error) error {
147+ if ctx.Err() != nil {
148+ return nil
149+ }
150+ return err
151+}
152+
153+func (t *Tray) send() uint32 {
154+ serial, err := t.c.Send()
155+ if err != nil {
156+ if errors.Is(err, zerobus.ErrInvalid) {
157+ // A name or path an application gave us that D-Bus refuses.
158+ t.opt.Log.Warn("tray: message not sent", "err", err)
159+ return 0
160+ }
161+ t.fatal(err)
162+ return 0
163+ }
164+ return serial
165+}
166+
167+func (t *Tray) fatal(err error) {
168+ if t.err == nil {
169+ t.err = err
170+ }
171+}
172+
173+func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
174+ if serial != 0 {
175+ t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
176+ }
177+}
178+
179+// claim asks for the watcher name; the reply decides the mode.
180+func (t *Tray) claim() {
181+ e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
182+ e.Str(watcherName)
183+ e.Uint32(zerobus.NameFlagDoNotQueue)
184+ t.expect(t.send(), pendName, nil)
185+}
186+
187+func (t *Tray) dispatch(m *zerobus.Message) {
188+ switch m.Type {
189+ case zerobus.TypeMethodReturn, zerobus.TypeError:
190+ i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
191+ if i < 0 {
192+ return
193+ }
194+ p := t.pending[i]
195+ t.pending = slices.Delete(t.pending, i, i+1)
196+ t.reply(p, m)
197+ case zerobus.TypeSignal:
198+ t.signal(m)
199+ case zerobus.TypeMethodCall:
200+ t.call(m)
201+ }
202+}
203+
204+func (t *Tray) reply(p pend, m *zerobus.Message) {
205+ failed := m.Type == zerobus.TypeError
206+ switch p.kind {
207+ case pendName:
208+ code := m.Body().Uint32()
209+ if !failed && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
210+ t.becomeWatcher()
211+ return
212+ }
213+ t.becomeHost()
214+ case pendItems:
215+ if failed {
216+ t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
217+ return
218+ }
219+ r := m.Body()
220+ if r.Variant() != "as" {
221+ return
222+ }
223+ end := r.Array('s')
224+ for r.More(end) {
225+ t.add(r.Str(), "", "")
226+ }
227+ case pendGetAll:
228+ it := p.it
229+ if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
230+ return // removed, or an older request
231+ }
232+ it.pending = 0
233+ if failed {
234+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName)
235+ if !it.loaded {
236+ t.remove(it)
237+ }
238+ return
239+ }
240+ if it.owner != m.Sender {
241+ it.owner = strings.Clone(m.Sender)
242+ }
243+ t.load(it, m.Body())
244+ if it.stale {
245+ t.refresh(it)
246+ }
247+ }
248+}
249+
250+func (t *Tray) becomeWatcher() {
251+ t.watcher = true
252+ t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
253+ // Items that waited for a host register now.
254+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
255+ t.send()
256+}
257+
258+func (t *Tray) becomeHost() {
259+ t.watcher = false
260+ t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
261+ t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
262+ t.expect(t.send(), pendIgnore, nil)
263+ e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
264+ e.Str(watcherIface)
265+ e.Str("RegisteredStatusNotifierItems")
266+ t.expect(t.send(), pendItems, nil)
267+}
268+
269+func (t *Tray) signal(m *zerobus.Message) {
270+ switch m.Interface {
271+ case busName:
272+ if m.Member != "NameOwnerChanged" || m.Sender != busName {
273+ return
274+ }
275+ r := m.Body()
276+ name, _, owner := r.Str(), r.Str(), r.Str()
277+ if r.Err() != nil || owner != "" {
278+ return
279+ }
280+ t.vanished(name)
281+ case watcherIface:
282+ if t.watcher || m.Signature != "s" {
283+ return // our own signals come back to us too
284+ }
285+ s := m.Body().Str()
286+ switch m.Member {
287+ case "StatusNotifierItemRegistered":
288+ t.add(s, "", "")
289+ case "StatusNotifierItemUnregistered":
290+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == s }); i >= 0 {
291+ t.remove(t.items[i])
292+ }
293+ }
294+ case itemIface:
295+ for _, it := range t.items {
296+ if it.owner == m.Sender && it.path == m.Path {
297+ t.refresh(it)
298+ return
299+ }
300+ }
301+ }
302+}
303+
304+// vanished drops the items of a bus name that left, and takes over the
305+// watcher name when its owner left.
306+func (t *Tray) vanished(name string) {
307+ changed := false
308+ for i := len(t.items) - 1; i >= 0; i-- {
309+ if it := t.items[i]; it.owner == name || it.dest == name {
310+ t.drop(i)
311+ changed = true
312+ }
313+ }
314+ if changed {
315+ t.renderOrFail()
316+ }
317+ if name == watcherName && !t.watcher {
318+ // Items register again with the new watcher.
319+ for i := len(t.items) - 1; i >= 0; i-- {
320+ t.drop(i)
321+ }
322+ t.renderOrFail()
323+ t.claim()
324+ }
325+}
326+
327+// add starts tracking an item. service is the watcher's form, "bus/path" or
328+// "bus"; owner is its unique name when known.
329+func (t *Tray) add(service, owner, path string) {
330+ if path == "" {
331+ bus, p, ok := strings.Cut(service, "/")
332+ if ok {
333+ path = "/" + p
334+ } else {
335+ path = itemPath
336+ }
337+ service = bus + path
338+ if owner == "" && strings.HasPrefix(bus, ":") {
339+ owner = bus
340+ }
341+ }
342+ dest, _, _ := strings.Cut(service, "/")
343+ for _, it := range t.items {
344+ if it.service == service || owner != "" && it.owner == owner && it.path == path {
345+ return
346+ }
347+ }
348+ it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
349+ t.items = append(t.items, it)
350+ t.refresh(it)
351+}
352+
353+func (t *Tray) remove(it *item) {
354+ if i := slices.Index(t.items, it); i >= 0 {
355+ t.drop(i)
356+ t.renderOrFail()
357+ }
358+}
359+
360+func (t *Tray) drop(i int) {
361+ it := t.items[i]
362+ t.items = slices.Delete(t.items, i, i+1)
363+ if t.watcher {
364+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
365+ t.send()
366+ }
367+}
368+
369+// refresh asks an item for its properties, unless a request is in flight.
370+func (t *Tray) refresh(it *item) {
371+ if it.pending != 0 {
372+ it.stale = true
373+ return
374+ }
375+ it.stale = false
376+ dest := it.dest
377+ if it.owner != "" {
378+ dest = it.owner
379+ }
380+ t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
381+ it.pending = t.send()
382+ t.expect(it.pending, pendGetAll, it)
383+}
384+
385+// load reads a GetAll reply into it and redraws when something shown
386+// changed. Strings are copied only when they change.
387+func (t *Tray) load(it *item, r *zerobus.Reader) {
388+ was := *it
389+ var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
390+ end := r.Array('{')
391+ for r.More(end) {
392+ r.Struct()
393+ key := r.Str()
394+ sig := r.Variant()
395+ switch {
396+ case sig == "s" && key == "Id":
397+ setStr(&it.id, r.Str())
398+ case sig == "s" && key == "Title":
399+ setStr(&it.title, r.Str())
400+ case sig == "s" && key == "IconName":
401+ setStr(&it.iconName, r.Str())
402+ case sig == "s" && key == "Status":
403+ it.status = parseStatus(r.Str())
404+ case sig == "a(iiay)" && key == "IconPixmap":
405+ pixmaps = pickPixmap(r)
406+ case sig == "a(iiay)" && key == "AttentionIconPixmap":
407+ attentionPixmaps = pickPixmap(r)
408+ default:
409+ r.Skip(sig)
410+ }
411+ }
412+ if r.Err() != nil {
413+ t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
414+ *it = was
415+ return
416+ }
417+ it.loaded = true
418+ if it.status == attention && attentionPixmaps != nil {
419+ pixmaps = attentionPixmaps
420+ }
421+ it.color, it.hasColor = t.hist.dominant(pixmaps)
422+ if it.hasColor {
423+ it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
424+ }
425+ if it.icon == "" || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
426+ it.icon = t.opt.Resolver.Resolve(it.id, it.iconName, it.title)
427+ }
428+ t.renderOrFail()
429+}
430+
431+// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
432+// best suited to find a color: the largest up to 64x64, else the smallest.
433+// The result points into the message buffer.
434+func pickPixmap(r *zerobus.Reader) []byte {
435+ var best []byte
436+ bestN := 0
437+ end := r.Array('(')
438+ for r.More(end) {
439+ r.Struct()
440+ w, h := int(r.Int32()), int(r.Int32())
441+ data := r.ByteArray()
442+ n := w * h
443+ if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
444+ continue
445+ }
446+ if best == nil || better(n, bestN) {
447+ best, bestN = data, n
448+ }
449+ }
450+ return best
451+}
452+
453+// better reports whether an icon of n pixels suits the color search better
454+// than one of cur pixels: the largest up to 64x64, else the smallest.
455+func better(n, cur int) bool {
456+ const enough = 64 * 64
457+ if cur > enough {
458+ return n < cur
459+ }
460+ return n > cur && n <= enough
461+}
462+
463+func setStr(dst *string, s string) {
464+ if *dst != s {
465+ *dst = strings.Clone(s)
466+ }
467+}
468+
469+func parseStatus(s string) status {
470+ switch s {
471+ case "Passive":
472+ return passive
473+ case "NeedsAttention":
474+ return attention
475+ }
476+ return active
477+}
478+
479+// call answers method calls when this process is the watcher.
480+func (t *Tray) call(m *zerobus.Message) {
481+ noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
482+ if !t.watcher || m.Path != watcherPath {
483+ if !noReply {
484+ t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
485+ t.send()
486+ }
487+ return
488+ }
489+ switch {
490+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
491+ s := m.Body().Str()
492+ if s == "" {
493+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
494+ return
495+ }
496+ if !noReply {
497+ t.c.NewReply(m, "")
498+ t.send()
499+ }
500+ var owner, path string
501+ if strings.HasPrefix(s, "/") {
502+ owner, path, s = m.Sender, s, m.Sender+s // registered by object path
503+ } else {
504+ owner, path, s = m.Sender, itemPath, s+itemPath
505+ }
506+ n := len(t.items)
507+ t.add(s, owner, path)
508+ if len(t.items) > n {
509+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[n].service)
510+ t.send()
511+ }
512+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
513+ if !noReply {
514+ t.c.NewReply(m, "")
515+ t.send()
516+ }
517+ case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
518+ r := m.Body()
519+ iface, prop := r.Str(), r.Str()
520+ if iface != watcherIface || !t.prop(m, prop, noReply) {
521+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
522+ }
523+ case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
524+ if noReply {
525+ return
526+ }
527+ e := t.c.NewReply(m, "a{sv}")
528+ a := e.BeginArray('{')
529+ if m.Body().Str() == watcherIface {
530+ for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
531+ e.Struct()
532+ e.Str(p)
533+ t.propValue(e, p)
534+ }
535+ }
536+ e.EndArray(a)
537+ t.send()
538+ case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
539+ if !noReply {
540+ t.c.NewReply(m, "s").Str(introspection)
541+ t.send()
542+ }
543+ case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
544+ if !noReply {
545+ t.c.NewReply(m, "")
546+ t.send()
547+ }
548+ default:
549+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
550+ }
551+}
552+
553+func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
554+ if !noReply {
555+ t.c.NewError(m, name, "s").Str(text)
556+ t.send()
557+ }
558+}
559+
560+// prop answers Properties.Get; it reports false for an unknown property.
561+func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
562+ switch name {
563+ case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
564+ default:
565+ return false
566+ }
567+ if !noReply {
568+ t.propValue(t.c.NewReply(m, "v"), name)
569+ t.send()
570+ }
571+ return true
572+}
573+
574+func (t *Tray) propValue(e *zerobus.Encoder, name string) {
575+ switch name {
576+ case "RegisteredStatusNotifierItems":
577+ e.Variant("as")
578+ a := e.BeginArray('s')
579+ for _, it := range t.items {
580+ e.Str(it.service)
581+ }
582+ e.EndArray(a)
583+ case "IsStatusNotifierHostRegistered":
584+ e.Variant("b")
585+ e.Bool(true) // this process is the host
586+ case "ProtocolVersion":
587+ e.Variant("i")
588+ e.Int32(0)
589+ }
590+}
591+
592+func (t *Tray) renderOrFail() {
593+ if err := t.render(false); err != nil {
594+ t.fatal(err)
595+ }
596+}
597+
598+// render writes the line of visible items when it differs from the last one.
599+func (t *Tray) render(force bool) error {
600+ f := t.frame[:0]
601+ for _, it := range t.items {
602+ if !it.loaded || it.status == passive {
603+ continue
604+ }
605+ if len(f) > 0 {
606+ f = append(f, ' ')
607+ }
608+ c, bold := t.opt.Foreground, false
609+ switch {
610+ case it.status == attention:
611+ c, bold = t.opt.Accent, true
612+ case it.hasColor:
613+ c = it.color
614+ }
615+ f = append(f, "\x1b["...)
616+ if bold {
617+ f = append(f, "1;"...)
618+ }
619+ f = append(f, "38;2;"...)
620+ f = strconv.AppendUint(f, uint64(c[0]), 10)
621+ f = append(f, ';')
622+ f = strconv.AppendUint(f, uint64(c[1]), 10)
623+ f = append(f, ';')
624+ f = strconv.AppendUint(f, uint64(c[2]), 10)
625+ f = append(f, 'm')
626+ f = append(f, it.icon...)
627+ f = append(f, "\x1b[0m"...)
628+ }
629+ f = append(f, '\n')
630+ t.frame = f
631+ if !force && bytes.Equal(f, t.last) {
632+ return nil
633+ }
634+ t.frame, t.last = t.last, t.frame
635+ if _, err := t.opt.Out.Write(t.last); err != nil {
636+ return fmt.Errorf("tray: %w", err)
637+ }
638+ return nil
639+}
640+
641+const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
642+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
643+<node>
644+ <interface name="org.kde.StatusNotifierWatcher">
645+ <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
646+ <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
647+ <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
648+ <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
649+ <property name="ProtocolVersion" type="i" access="read"/>
650+ <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
651+ <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
652+ <signal name="StatusNotifierHostRegistered"/>
653+ </interface>
654+ <interface name="org.freedesktop.DBus.Properties">
655+ <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
656+ <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
657+ </interface>
658+ <interface name="org.freedesktop.DBus.Introspectable">
659+ <method name="Introspect"><arg type="s" direction="out"/></method>
660+ </interface>
661+</node>
662+`
@@ -0,0 +1,662 @@
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
68+ color RGB
69+ hasColor bool
70+ loaded bool // GetAll answered at least once
71+ pending uint32 // serial of a GetAll in flight
72+ stale bool // a change arrived while GetAll was in flight
73+}
74+
75+type pendKind uint8
76+
77+const (
78+ pendGetAll pendKind = iota + 1
79+ pendItems // the watcher's RegisteredStatusNotifierItems
80+ pendName // RequestName of the watcher name
81+ pendIgnore
82+)
83+
84+type pend struct {
85+ serial uint32
86+ kind pendKind
87+ it *item
88+}
89+
90+// Tray is one running tray.
91+type Tray struct {
92+ c *zerobus.Conn
93+ opt Options
94+ watcher bool // this process owns the watcher name
95+ host string
96+ items []*item
97+ pending []pend
98+ frame []byte
99+ last []byte
100+ hist histogram
101+ err error // first fatal error from dispatch
102+}
103+
104+// Run serves the tray on c until ctx ends or the bus connection fails. It
105+// closes c.
106+func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
107+ t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
108+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
109+ if t.opt.Log == nil {
110+ t.opt.Log = slog.New(slog.DiscardHandler)
111+ }
112+ stop := context.AfterFunc(ctx, func() { _ = c.Close() })
113+ defer stop()
114+ defer func() { _ = c.Close() }()
115+
116+ // Calls made from here on only go to the bus, which always answers.
117+ // Handle sees what arrives meanwhile.
118+ c.Handle = t.dispatch
119+ for _, rule := range [...]string{
120+ "type='signal',interface='" + itemIface + "'",
121+ "type='signal',interface='" + watcherIface + "'",
122+ "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
123+ } {
124+ if err := c.AddMatch(rule); err != nil {
125+ return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
126+ }
127+ }
128+ if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
129+ return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
130+ }
131+ t.claim()
132+ if err := t.render(true); err != nil {
133+ return err
134+ }
135+ for t.err == nil {
136+ m, err := c.ReadMessage()
137+ if err != nil {
138+ return t.exit(ctx, err)
139+ }
140+ t.dispatch(m)
141+ }
142+ return t.err
143+}
144+
145+// exit returns nil when the error comes from ctx ending.
146+func (t *Tray) exit(ctx context.Context, err error) error {
147+ if ctx.Err() != nil {
148+ return nil
149+ }
150+ return err
151+}
152+
153+func (t *Tray) send() uint32 {
154+ serial, err := t.c.Send()
155+ if err != nil {
156+ if errors.Is(err, zerobus.ErrInvalid) {
157+ // A name or path an application gave us that D-Bus refuses.
158+ t.opt.Log.Warn("tray: message not sent", "err", err)
159+ return 0
160+ }
161+ t.fatal(err)
162+ return 0
163+ }
164+ return serial
165+}
166+
167+func (t *Tray) fatal(err error) {
168+ if t.err == nil {
169+ t.err = err
170+ }
171+}
172+
173+func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
174+ if serial != 0 {
175+ t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
176+ }
177+}
178+
179+// claim asks for the watcher name; the reply decides the mode.
180+func (t *Tray) claim() {
181+ e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
182+ e.Str(watcherName)
183+ e.Uint32(zerobus.NameFlagDoNotQueue)
184+ t.expect(t.send(), pendName, nil)
185+}
186+
187+func (t *Tray) dispatch(m *zerobus.Message) {
188+ switch m.Type {
189+ case zerobus.TypeMethodReturn, zerobus.TypeError:
190+ i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
191+ if i < 0 {
192+ return
193+ }
194+ p := t.pending[i]
195+ t.pending = slices.Delete(t.pending, i, i+1)
196+ t.reply(p, m)
197+ case zerobus.TypeSignal:
198+ t.signal(m)
199+ case zerobus.TypeMethodCall:
200+ t.call(m)
201+ }
202+}
203+
204+func (t *Tray) reply(p pend, m *zerobus.Message) {
205+ failed := m.Type == zerobus.TypeError
206+ switch p.kind {
207+ case pendName:
208+ code := m.Body().Uint32()
209+ if !failed && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
210+ t.becomeWatcher()
211+ return
212+ }
213+ t.becomeHost()
214+ case pendItems:
215+ if failed {
216+ t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
217+ return
218+ }
219+ r := m.Body()
220+ if r.Variant() != "as" {
221+ return
222+ }
223+ end := r.Array('s')
224+ for r.More(end) {
225+ t.add(r.Str(), "", "")
226+ }
227+ case pendGetAll:
228+ it := p.it
229+ if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
230+ return // removed, or an older request
231+ }
232+ it.pending = 0
233+ if failed {
234+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName)
235+ if !it.loaded {
236+ t.remove(it)
237+ }
238+ return
239+ }
240+ if it.owner != m.Sender {
241+ it.owner = strings.Clone(m.Sender)
242+ }
243+ t.load(it, m.Body())
244+ if it.stale {
245+ t.refresh(it)
246+ }
247+ }
248+}
249+
250+func (t *Tray) becomeWatcher() {
251+ t.watcher = true
252+ t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
253+ // Items that waited for a host register now.
254+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
255+ t.send()
256+}
257+
258+func (t *Tray) becomeHost() {
259+ t.watcher = false
260+ t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
261+ t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
262+ t.expect(t.send(), pendIgnore, nil)
263+ e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
264+ e.Str(watcherIface)
265+ e.Str("RegisteredStatusNotifierItems")
266+ t.expect(t.send(), pendItems, nil)
267+}
268+
269+func (t *Tray) signal(m *zerobus.Message) {
270+ switch m.Interface {
271+ case busName:
272+ if m.Member != "NameOwnerChanged" || m.Sender != busName {
273+ return
274+ }
275+ r := m.Body()
276+ name, _, owner := r.Str(), r.Str(), r.Str()
277+ if r.Err() != nil || owner != "" {
278+ return
279+ }
280+ t.vanished(name)
281+ case watcherIface:
282+ if t.watcher || m.Signature != "s" {
283+ return // our own signals come back to us too
284+ }
285+ s := m.Body().Str()
286+ switch m.Member {
287+ case "StatusNotifierItemRegistered":
288+ t.add(s, "", "")
289+ case "StatusNotifierItemUnregistered":
290+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == s }); i >= 0 {
291+ t.remove(t.items[i])
292+ }
293+ }
294+ case itemIface:
295+ for _, it := range t.items {
296+ if it.owner == m.Sender && it.path == m.Path {
297+ t.refresh(it)
298+ return
299+ }
300+ }
301+ }
302+}
303+
304+// vanished drops the items of a bus name that left, and takes over the
305+// watcher name when its owner left.
306+func (t *Tray) vanished(name string) {
307+ changed := false
308+ for i := len(t.items) - 1; i >= 0; i-- {
309+ if it := t.items[i]; it.owner == name || it.dest == name {
310+ t.drop(i)
311+ changed = true
312+ }
313+ }
314+ if changed {
315+ t.renderOrFail()
316+ }
317+ if name == watcherName && !t.watcher {
318+ // Items register again with the new watcher.
319+ for i := len(t.items) - 1; i >= 0; i-- {
320+ t.drop(i)
321+ }
322+ t.renderOrFail()
323+ t.claim()
324+ }
325+}
326+
327+// add starts tracking an item. service is the watcher's form, "bus/path" or
328+// "bus"; owner is its unique name when known.
329+func (t *Tray) add(service, owner, path string) {
330+ if path == "" {
331+ bus, p, ok := strings.Cut(service, "/")
332+ if ok {
333+ path = "/" + p
334+ } else {
335+ path = itemPath
336+ }
337+ service = bus + path
338+ if owner == "" && strings.HasPrefix(bus, ":") {
339+ owner = bus
340+ }
341+ }
342+ dest, _, _ := strings.Cut(service, "/")
343+ for _, it := range t.items {
344+ if it.service == service || owner != "" && it.owner == owner && it.path == path {
345+ return
346+ }
347+ }
348+ it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
349+ t.items = append(t.items, it)
350+ t.refresh(it)
351+}
352+
353+func (t *Tray) remove(it *item) {
354+ if i := slices.Index(t.items, it); i >= 0 {
355+ t.drop(i)
356+ t.renderOrFail()
357+ }
358+}
359+
360+func (t *Tray) drop(i int) {
361+ it := t.items[i]
362+ t.items = slices.Delete(t.items, i, i+1)
363+ if t.watcher {
364+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
365+ t.send()
366+ }
367+}
368+
369+// refresh asks an item for its properties, unless a request is in flight.
370+func (t *Tray) refresh(it *item) {
371+ if it.pending != 0 {
372+ it.stale = true
373+ return
374+ }
375+ it.stale = false
376+ dest := it.dest
377+ if it.owner != "" {
378+ dest = it.owner
379+ }
380+ t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
381+ it.pending = t.send()
382+ t.expect(it.pending, pendGetAll, it)
383+}
384+
385+// load reads a GetAll reply into it and redraws when something shown
386+// changed. Strings are copied only when they change.
387+func (t *Tray) load(it *item, r *zerobus.Reader) {
388+ was := *it
389+ var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
390+ end := r.Array('{')
391+ for r.More(end) {
392+ r.Struct()
393+ key := r.Str()
394+ sig := r.Variant()
395+ switch {
396+ case sig == "s" && key == "Id":
397+ setStr(&it.id, r.Str())
398+ case sig == "s" && key == "Title":
399+ setStr(&it.title, r.Str())
400+ case sig == "s" && key == "IconName":
401+ setStr(&it.iconName, r.Str())
402+ case sig == "s" && key == "Status":
403+ it.status = parseStatus(r.Str())
404+ case sig == "a(iiay)" && key == "IconPixmap":
405+ pixmaps = pickPixmap(r)
406+ case sig == "a(iiay)" && key == "AttentionIconPixmap":
407+ attentionPixmaps = pickPixmap(r)
408+ default:
409+ r.Skip(sig)
410+ }
411+ }
412+ if r.Err() != nil {
413+ t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
414+ *it = was
415+ return
416+ }
417+ it.loaded = true
418+ if it.status == attention && attentionPixmaps != nil {
419+ pixmaps = attentionPixmaps
420+ }
421+ it.color, it.hasColor = t.hist.dominant(pixmaps)
422+ if it.hasColor {
423+ it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
424+ }
425+ if it.icon == "" || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
426+ it.icon = t.opt.Resolver.Resolve(it.id, it.iconName, it.title)
427+ }
428+ t.renderOrFail()
429+}
430+
431+// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
432+// best suited to find a color: the largest up to 64x64, else the smallest.
433+// The result points into the message buffer.
434+func pickPixmap(r *zerobus.Reader) []byte {
435+ var best []byte
436+ bestN := 0
437+ end := r.Array('(')
438+ for r.More(end) {
439+ r.Struct()
440+ w, h := int(r.Int32()), int(r.Int32())
441+ data := r.ByteArray()
442+ n := w * h
443+ if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
444+ continue
445+ }
446+ if best == nil || better(n, bestN) {
447+ best, bestN = data, n
448+ }
449+ }
450+ return best
451+}
452+
453+// better reports whether an icon of n pixels suits the color search better
454+// than one of cur pixels: the largest up to 64x64, else the smallest.
455+func better(n, cur int) bool {
456+ const enough = 64 * 64
457+ if cur > enough {
458+ return n < cur
459+ }
460+ return n > cur && n <= enough
461+}
462+
463+func setStr(dst *string, s string) {
464+ if *dst != s {
465+ *dst = strings.Clone(s)
466+ }
467+}
468+
469+func parseStatus(s string) status {
470+ switch s {
471+ case "Passive":
472+ return passive
473+ case "NeedsAttention":
474+ return attention
475+ }
476+ return active
477+}
478+
479+// call answers method calls when this process is the watcher.
480+func (t *Tray) call(m *zerobus.Message) {
481+ noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
482+ if !t.watcher || m.Path != watcherPath {
483+ if !noReply {
484+ t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
485+ t.send()
486+ }
487+ return
488+ }
489+ switch {
490+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
491+ s := m.Body().Str()
492+ if s == "" {
493+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
494+ return
495+ }
496+ if !noReply {
497+ t.c.NewReply(m, "")
498+ t.send()
499+ }
500+ var owner, path string
501+ if strings.HasPrefix(s, "/") {
502+ owner, path, s = m.Sender, s, m.Sender+s // registered by object path
503+ } else {
504+ owner, path, s = m.Sender, itemPath, s+itemPath
505+ }
506+ n := len(t.items)
507+ t.add(s, owner, path)
508+ if len(t.items) > n {
509+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[n].service)
510+ t.send()
511+ }
512+ case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
513+ if !noReply {
514+ t.c.NewReply(m, "")
515+ t.send()
516+ }
517+ case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
518+ r := m.Body()
519+ iface, prop := r.Str(), r.Str()
520+ if iface != watcherIface || !t.prop(m, prop, noReply) {
521+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
522+ }
523+ case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
524+ if noReply {
525+ return
526+ }
527+ e := t.c.NewReply(m, "a{sv}")
528+ a := e.BeginArray('{')
529+ if m.Body().Str() == watcherIface {
530+ for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
531+ e.Struct()
532+ e.Str(p)
533+ t.propValue(e, p)
534+ }
535+ }
536+ e.EndArray(a)
537+ t.send()
538+ case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
539+ if !noReply {
540+ t.c.NewReply(m, "s").Str(introspection)
541+ t.send()
542+ }
543+ case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
544+ if !noReply {
545+ t.c.NewReply(m, "")
546+ t.send()
547+ }
548+ default:
549+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
550+ }
551+}
552+
553+func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
554+ if !noReply {
555+ t.c.NewError(m, name, "s").Str(text)
556+ t.send()
557+ }
558+}
559+
560+// prop answers Properties.Get; it reports false for an unknown property.
561+func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
562+ switch name {
563+ case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
564+ default:
565+ return false
566+ }
567+ if !noReply {
568+ t.propValue(t.c.NewReply(m, "v"), name)
569+ t.send()
570+ }
571+ return true
572+}
573+
574+func (t *Tray) propValue(e *zerobus.Encoder, name string) {
575+ switch name {
576+ case "RegisteredStatusNotifierItems":
577+ e.Variant("as")
578+ a := e.BeginArray('s')
579+ for _, it := range t.items {
580+ e.Str(it.service)
581+ }
582+ e.EndArray(a)
583+ case "IsStatusNotifierHostRegistered":
584+ e.Variant("b")
585+ e.Bool(true) // this process is the host
586+ case "ProtocolVersion":
587+ e.Variant("i")
588+ e.Int32(0)
589+ }
590+}
591+
592+func (t *Tray) renderOrFail() {
593+ if err := t.render(false); err != nil {
594+ t.fatal(err)
595+ }
596+}
597+
598+// render writes the line of visible items when it differs from the last one.
599+func (t *Tray) render(force bool) error {
600+ f := t.frame[:0]
601+ for _, it := range t.items {
602+ if !it.loaded || it.status == passive {
603+ continue
604+ }
605+ if len(f) > 0 {
606+ f = append(f, ' ')
607+ }
608+ c, bold := t.opt.Foreground, false
609+ switch {
610+ case it.status == attention:
611+ c, bold = t.opt.Accent, true
612+ case it.hasColor:
613+ c = it.color
614+ }
615+ f = append(f, "\x1b["...)
616+ if bold {
617+ f = append(f, "1;"...)
618+ }
619+ f = append(f, "38;2;"...)
620+ f = strconv.AppendUint(f, uint64(c[0]), 10)
621+ f = append(f, ';')
622+ f = strconv.AppendUint(f, uint64(c[1]), 10)
623+ f = append(f, ';')
624+ f = strconv.AppendUint(f, uint64(c[2]), 10)
625+ f = append(f, 'm')
626+ f = append(f, it.icon...)
627+ f = append(f, "\x1b[0m"...)
628+ }
629+ f = append(f, '\n')
630+ t.frame = f
631+ if !force && bytes.Equal(f, t.last) {
632+ return nil
633+ }
634+ t.frame, t.last = t.last, t.frame
635+ if _, err := t.opt.Out.Write(t.last); err != nil {
636+ return fmt.Errorf("tray: %w", err)
637+ }
638+ return nil
639+}
640+
641+const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
642+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
643+<node>
644+ <interface name="org.kde.StatusNotifierWatcher">
645+ <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
646+ <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
647+ <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
648+ <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
649+ <property name="ProtocolVersion" type="i" access="read"/>
650+ <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
651+ <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
652+ <signal name="StatusNotifierHostRegistered"/>
653+ </interface>
654+ <interface name="org.freedesktop.DBus.Properties">
655+ <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
656+ <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
657+ </interface>
658+ <interface name="org.freedesktop.DBus.Introspectable">
659+ <method name="Introspect"><arg type="s" direction="out"/></method>
660+ </interface>
661+</node>
662+`
A internal/tray/tray_test.go +387 -0
@@ -0,0 +1,387 @@
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 makes the item's goroutine run fn, and waits for it.
74+func (f *fakeItem) run(t *testing.T, fn func()) {
75+ t.Helper()
76+ f.cmds <- fn
77+ f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
78+ if _, err := f.driver.Call(); err != nil {
79+ t.Fatal(err)
80+ }
81+}
82+
83+func (f *fakeItem) serve() {
84+ defer close(f.closed)
85+ for {
86+ m, err := f.c.ReadMessage()
87+ if err != nil {
88+ return
89+ }
90+ if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
91+ f.c.NewReply(m, "")
92+ if _, err := f.c.Send(); err != nil {
93+ return
94+ }
95+ (<-f.cmds)()
96+ continue
97+ }
98+ if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
99+ continue
100+ }
101+ e := f.c.NewReply(m, "a{sv}")
102+ a := e.BeginArray('{')
103+ for i, k := range [...]string{"Id", "IconName", "Status"} {
104+ e.Struct()
105+ e.Str(k)
106+ e.Variant("s")
107+ e.Str(f.cur[i])
108+ }
109+ e.Struct()
110+ e.Str("IconPixmap")
111+ e.Variant("a(iiay)")
112+ px := e.BeginArray('(')
113+ e.Struct()
114+ e.Int32(1)
115+ e.Int32(1)
116+ e.ByteArray(f.pixel[:])
117+ e.EndArray(px)
118+ e.EndArray(a)
119+ if _, err := f.c.Send(); err != nil {
120+ return
121+ }
122+ }
123+}
124+
125+// register registers the item with the watcher, by bus name.
126+func (f *fakeItem) register(t *testing.T) {
127+ f.run(t, func() {
128+ f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
129+ _, _ = f.c.Send()
130+ })
131+}
132+
133+// setStatus changes the item's status and emits NewStatus.
134+func (f *fakeItem) setStatus(t *testing.T, status string) {
135+ f.run(t, func() {
136+ f.cur[2] = status
137+ f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
138+ _, _ = f.c.Send()
139+ })
140+}
141+
142+// quit closes the item's connection, as when the application exits.
143+func (f *fakeItem) quit(t *testing.T) {
144+ f.run(t, func() { _ = f.c.Close() })
145+ <-f.closed
146+}
147+
148+// lines reads the tray's output.
149+type lines struct {
150+ ch chan string
151+}
152+
153+func readLines(r io.Reader) *lines {
154+ l := &lines{ch: make(chan string, 64)}
155+ go func() {
156+ sc := bufio.NewScanner(r)
157+ for sc.Scan() {
158+ l.ch <- sc.Text()
159+ }
160+ close(l.ch)
161+ }()
162+ return l
163+}
164+
165+// waitFor returns the first line for which ok is true.
166+func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
167+ t.Helper()
168+ timeout := time.After(5 * time.Second)
169+ for {
170+ select {
171+ case s, open := <-l.ch:
172+ if !open {
173+ t.Fatalf("output ended while waiting for %s", what)
174+ }
175+ if ok(s) {
176+ return s
177+ }
178+ case <-timeout:
179+ t.Fatalf("timed out waiting for %s", what)
180+ }
181+ }
182+}
183+
184+// startTray runs a tray on the bus. Cancel it and receive from the channel
185+// to stop it; the test cleanup does both when the test did not.
186+func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
187+ pr, pw := io.Pipe()
188+ ctx, cancel := context.WithCancel(context.Background())
189+ done := make(chan error, 1)
190+ var result error
191+ stopped := make(chan struct{})
192+ opt := Options{
193+ Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
194+ Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
195+ Out: pw,
196+ }
197+ c := dial(t, addr)
198+ go func() {
199+ result = Run(ctx, c, opt)
200+ _ = pw.Close()
201+ close(stopped)
202+ done <- result
203+ }()
204+ t.Cleanup(func() {
205+ cancel()
206+ <-stopped
207+ })
208+ return readLines(pr), cancel, done
209+}
210+
211+func TestTrayAsWatcher(t *testing.T) {
212+ addr := privateBus(t)
213+ out, cancel, done := startTray(t, addr)
214+ if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
215+ t.Fatalf("first line %q, want empty", s)
216+ }
217+ // Wait until the tray owns the watcher name.
218+ probe := dial(t, addr)
219+ deadline := time.Now().Add(5 * time.Second)
220+ for {
221+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
222+ m, err := probe.Call()
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ if m.Body().Bool() {
227+ break
228+ }
229+ if time.Now().After(deadline) {
230+ t.Fatal("the tray did not become the watcher")
231+ }
232+ time.Sleep(10 * time.Millisecond)
233+ }
234+
235+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
236+ steam.register(t)
237+ line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
238+ if !strings.Contains(line, "38;2;") {
239+ t.Fatalf("the icon is not colored: %q", line)
240+ }
241+
242+ steam.setStatus(t, "NeedsAttention")
243+ out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
244+
245+ steam.setStatus(t, "Passive")
246+ out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
247+
248+ steam.setStatus(t, "Active")
249+ out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
250+
251+ // The application quits: its item goes away.
252+ steam.quit(t)
253+ out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
254+
255+ cancel()
256+ if err := <-done; err != nil {
257+ t.Fatalf("Run: %v", err)
258+ }
259+}
260+
261+func TestTrayAsHost(t *testing.T) {
262+ addr := privateBus(t)
263+ // Another program is the watcher. It lists one item and announces it.
264+ other := dial(t, addr)
265+ if code, err := other.RequestName(watcherName, zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
266+ t.Fatalf("RequestName = %d, %v", code, err)
267+ }
268+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
269+ // A Conn is used from one goroutine: the fake watcher announces new
270+ // items from its own loop, when the test pings it.
271+ announce := make(chan string, 1)
272+ go func() {
273+ for {
274+ m, err := other.ReadMessage()
275+ if err != nil {
276+ return
277+ }
278+ if m.Type != zerobus.TypeMethodCall {
279+ continue
280+ }
281+ switch m.Member {
282+ case "Ping":
283+ other.NewReply(m, "")
284+ if _, err := other.Send(); err != nil {
285+ return
286+ }
287+ other.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(<-announce)
288+ case "Get":
289+ e := other.NewReply(m, "v")
290+ e.Variant("as")
291+ a := e.BeginArray('s')
292+ e.Str(obsidian.name + itemPath)
293+ e.EndArray(a)
294+ default:
295+ other.NewReply(m, "")
296+ }
297+ if _, err := other.Send(); err != nil {
298+ return
299+ }
300+ }
301+ }()
302+
303+ out, _, _ := startTray(t, addr)
304+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
305+
306+ // A second item is announced by the other watcher.
307+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
308+ announce <- steam.name
309+ probe := dial(t, addr)
310+ probe.NewCall(other.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
311+ if _, err := probe.Call(); err != nil {
312+ t.Fatal(err)
313+ }
314+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
315+}
316+
317+// TestRefreshNoAllocs checks the steady state: an item signals a change, the
318+// tray asks for its properties and reads the reply. Nothing allocates once
319+// the buffers have grown.
320+func TestRefreshNoAllocs(t *testing.T) {
321+ addr := privateBus(t)
322+ app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
323+ var out strings.Builder
324+ tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
325+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
326+ tr.opt.Log = slog.New(slog.DiscardHandler)
327+ tr.add(app.name, "", "")
328+ cycle := func() {
329+ tr.refresh(tr.items[0])
330+ for len(tr.pending) > 0 {
331+ m, err := tr.c.ReadMessage()
332+ if err != nil {
333+ t.Fatal(err)
334+ }
335+ tr.dispatch(m)
336+ }
337+ }
338+ for range 3 {
339+ cycle() // resolve the icon and grow the buffers
340+ }
341+ if tr.err != nil || !tr.items[0].loaded {
342+ t.Fatalf("item not loaded: %v", tr.err)
343+ }
344+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
345+ t.Fatalf("%v allocations per refresh, want 0", allocs)
346+ }
347+}
348+
349+func TestRenderSkipsUnchangedFrames(t *testing.T) {
350+ var buf strings.Builder
351+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
352+ tr.items = []*item{{loaded: true, icon: "X"}}
353+ for range 3 {
354+ if err := tr.render(false); err != nil {
355+ t.Fatal(err)
356+ }
357+ }
358+ if got := strings.Count(buf.String(), "\n"); got != 1 {
359+ t.Fatalf("%d frames written, want 1", got)
360+ }
361+ if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
362+ t.Fatalf("render allocates %v times", allocs)
363+ }
364+}
365+
366+func TestBetterPixmapSize(t *testing.T) {
367+ // The largest icon up to 64x64 wins; above that, the smallest.
368+ for _, c := range []struct {
369+ sizes []int // sides, in the order the item lists them
370+ want int
371+ }{
372+ {[]int{16, 32, 128}, 32},
373+ {[]int{128, 32, 16}, 32},
374+ {[]int{256, 128}, 128},
375+ {[]int{22}, 22},
376+ } {
377+ best := 0
378+ for _, s := range c.sizes {
379+ if best == 0 || better(s*s, best*best) {
380+ best = s
381+ }
382+ }
383+ if best != c.want {
384+ t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
385+ }
386+ }
387+}
@@ -0,0 +1,387 @@
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 makes the item's goroutine run fn, and waits for it.
74+func (f *fakeItem) run(t *testing.T, fn func()) {
75+ t.Helper()
76+ f.cmds <- fn
77+ f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
78+ if _, err := f.driver.Call(); err != nil {
79+ t.Fatal(err)
80+ }
81+}
82+
83+func (f *fakeItem) serve() {
84+ defer close(f.closed)
85+ for {
86+ m, err := f.c.ReadMessage()
87+ if err != nil {
88+ return
89+ }
90+ if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
91+ f.c.NewReply(m, "")
92+ if _, err := f.c.Send(); err != nil {
93+ return
94+ }
95+ (<-f.cmds)()
96+ continue
97+ }
98+ if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
99+ continue
100+ }
101+ e := f.c.NewReply(m, "a{sv}")
102+ a := e.BeginArray('{')
103+ for i, k := range [...]string{"Id", "IconName", "Status"} {
104+ e.Struct()
105+ e.Str(k)
106+ e.Variant("s")
107+ e.Str(f.cur[i])
108+ }
109+ e.Struct()
110+ e.Str("IconPixmap")
111+ e.Variant("a(iiay)")
112+ px := e.BeginArray('(')
113+ e.Struct()
114+ e.Int32(1)
115+ e.Int32(1)
116+ e.ByteArray(f.pixel[:])
117+ e.EndArray(px)
118+ e.EndArray(a)
119+ if _, err := f.c.Send(); err != nil {
120+ return
121+ }
122+ }
123+}
124+
125+// register registers the item with the watcher, by bus name.
126+func (f *fakeItem) register(t *testing.T) {
127+ f.run(t, func() {
128+ f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
129+ _, _ = f.c.Send()
130+ })
131+}
132+
133+// setStatus changes the item's status and emits NewStatus.
134+func (f *fakeItem) setStatus(t *testing.T, status string) {
135+ f.run(t, func() {
136+ f.cur[2] = status
137+ f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
138+ _, _ = f.c.Send()
139+ })
140+}
141+
142+// quit closes the item's connection, as when the application exits.
143+func (f *fakeItem) quit(t *testing.T) {
144+ f.run(t, func() { _ = f.c.Close() })
145+ <-f.closed
146+}
147+
148+// lines reads the tray's output.
149+type lines struct {
150+ ch chan string
151+}
152+
153+func readLines(r io.Reader) *lines {
154+ l := &lines{ch: make(chan string, 64)}
155+ go func() {
156+ sc := bufio.NewScanner(r)
157+ for sc.Scan() {
158+ l.ch <- sc.Text()
159+ }
160+ close(l.ch)
161+ }()
162+ return l
163+}
164+
165+// waitFor returns the first line for which ok is true.
166+func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
167+ t.Helper()
168+ timeout := time.After(5 * time.Second)
169+ for {
170+ select {
171+ case s, open := <-l.ch:
172+ if !open {
173+ t.Fatalf("output ended while waiting for %s", what)
174+ }
175+ if ok(s) {
176+ return s
177+ }
178+ case <-timeout:
179+ t.Fatalf("timed out waiting for %s", what)
180+ }
181+ }
182+}
183+
184+// startTray runs a tray on the bus. Cancel it and receive from the channel
185+// to stop it; the test cleanup does both when the test did not.
186+func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
187+ pr, pw := io.Pipe()
188+ ctx, cancel := context.WithCancel(context.Background())
189+ done := make(chan error, 1)
190+ var result error
191+ stopped := make(chan struct{})
192+ opt := Options{
193+ Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
194+ Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
195+ Out: pw,
196+ }
197+ c := dial(t, addr)
198+ go func() {
199+ result = Run(ctx, c, opt)
200+ _ = pw.Close()
201+ close(stopped)
202+ done <- result
203+ }()
204+ t.Cleanup(func() {
205+ cancel()
206+ <-stopped
207+ })
208+ return readLines(pr), cancel, done
209+}
210+
211+func TestTrayAsWatcher(t *testing.T) {
212+ addr := privateBus(t)
213+ out, cancel, done := startTray(t, addr)
214+ if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
215+ t.Fatalf("first line %q, want empty", s)
216+ }
217+ // Wait until the tray owns the watcher name.
218+ probe := dial(t, addr)
219+ deadline := time.Now().Add(5 * time.Second)
220+ for {
221+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
222+ m, err := probe.Call()
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ if m.Body().Bool() {
227+ break
228+ }
229+ if time.Now().After(deadline) {
230+ t.Fatal("the tray did not become the watcher")
231+ }
232+ time.Sleep(10 * time.Millisecond)
233+ }
234+
235+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
236+ steam.register(t)
237+ line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
238+ if !strings.Contains(line, "38;2;") {
239+ t.Fatalf("the icon is not colored: %q", line)
240+ }
241+
242+ steam.setStatus(t, "NeedsAttention")
243+ out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
244+
245+ steam.setStatus(t, "Passive")
246+ out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
247+
248+ steam.setStatus(t, "Active")
249+ out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
250+
251+ // The application quits: its item goes away.
252+ steam.quit(t)
253+ out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
254+
255+ cancel()
256+ if err := <-done; err != nil {
257+ t.Fatalf("Run: %v", err)
258+ }
259+}
260+
261+func TestTrayAsHost(t *testing.T) {
262+ addr := privateBus(t)
263+ // Another program is the watcher. It lists one item and announces it.
264+ other := dial(t, addr)
265+ if code, err := other.RequestName(watcherName, zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
266+ t.Fatalf("RequestName = %d, %v", code, err)
267+ }
268+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
269+ // A Conn is used from one goroutine: the fake watcher announces new
270+ // items from its own loop, when the test pings it.
271+ announce := make(chan string, 1)
272+ go func() {
273+ for {
274+ m, err := other.ReadMessage()
275+ if err != nil {
276+ return
277+ }
278+ if m.Type != zerobus.TypeMethodCall {
279+ continue
280+ }
281+ switch m.Member {
282+ case "Ping":
283+ other.NewReply(m, "")
284+ if _, err := other.Send(); err != nil {
285+ return
286+ }
287+ other.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(<-announce)
288+ case "Get":
289+ e := other.NewReply(m, "v")
290+ e.Variant("as")
291+ a := e.BeginArray('s')
292+ e.Str(obsidian.name + itemPath)
293+ e.EndArray(a)
294+ default:
295+ other.NewReply(m, "")
296+ }
297+ if _, err := other.Send(); err != nil {
298+ return
299+ }
300+ }
301+ }()
302+
303+ out, _, _ := startTray(t, addr)
304+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
305+
306+ // A second item is announced by the other watcher.
307+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
308+ announce <- steam.name
309+ probe := dial(t, addr)
310+ probe.NewCall(other.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
311+ if _, err := probe.Call(); err != nil {
312+ t.Fatal(err)
313+ }
314+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
315+}
316+
317+// TestRefreshNoAllocs checks the steady state: an item signals a change, the
318+// tray asks for its properties and reads the reply. Nothing allocates once
319+// the buffers have grown.
320+func TestRefreshNoAllocs(t *testing.T) {
321+ addr := privateBus(t)
322+ app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
323+ var out strings.Builder
324+ tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
325+ frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
326+ tr.opt.Log = slog.New(slog.DiscardHandler)
327+ tr.add(app.name, "", "")
328+ cycle := func() {
329+ tr.refresh(tr.items[0])
330+ for len(tr.pending) > 0 {
331+ m, err := tr.c.ReadMessage()
332+ if err != nil {
333+ t.Fatal(err)
334+ }
335+ tr.dispatch(m)
336+ }
337+ }
338+ for range 3 {
339+ cycle() // resolve the icon and grow the buffers
340+ }
341+ if tr.err != nil || !tr.items[0].loaded {
342+ t.Fatalf("item not loaded: %v", tr.err)
343+ }
344+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
345+ t.Fatalf("%v allocations per refresh, want 0", allocs)
346+ }
347+}
348+
349+func TestRenderSkipsUnchangedFrames(t *testing.T) {
350+ var buf strings.Builder
351+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
352+ tr.items = []*item{{loaded: true, icon: "X"}}
353+ for range 3 {
354+ if err := tr.render(false); err != nil {
355+ t.Fatal(err)
356+ }
357+ }
358+ if got := strings.Count(buf.String(), "\n"); got != 1 {
359+ t.Fatalf("%d frames written, want 1", got)
360+ }
361+ if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
362+ t.Fatalf("render allocates %v times", allocs)
363+ }
364+}
365+
366+func TestBetterPixmapSize(t *testing.T) {
367+ // The largest icon up to 64x64 wins; above that, the smallest.
368+ for _, c := range []struct {
369+ sizes []int // sides, in the order the item lists them
370+ want int
371+ }{
372+ {[]int{16, 32, 128}, 32},
373+ {[]int{128, 32, 16}, 32},
374+ {[]int{256, 128}, 128},
375+ {[]int{22}, 22},
376+ } {
377+ best := 0
378+ for _, s := range c.sizes {
379+ if best == 0 || better(s*s, best*best) {
380+ best = s
381+ }
382+ }
383+ if best != c.want {
384+ t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
385+ }
386+ }
387+}

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