detached debad97

feat(tray): click and scroll icons through a control connection

commit
debad97851ced135abe81bcf006ce5fbddbf54c2
author
brice · · 2026-10-09 03:40 UTC
parent
cdd88db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +724 -5
M cmd/neferbar/tray.go +2 -0
@@ -73,6 +73,8 @@ func runTray(ctx context.Context, args []string) error {
7373 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
7474 Out: os.Stdout,
7575 Log: log,
76+ In: os.Stdin,
77+ Dial: zerobus.SessionBus,
7678 }
7779 opt.Foreground, opt.Background, opt.Accent = trayColors(log)
7880
@@ -73,6 +73,8 @@ func runTray(ctx context.Context, args []string) error {
73 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),73 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
74 Out: os.Stdout,74 Out: os.Stdout,
75 Log: log,75 Log: log,
76+ In: os.Stdin,
77+ Dial: zerobus.SessionBus,
76 }78 }
77 opt.Foreground, opt.Background, opt.Accent = trayColors(log)79 opt.Foreground, opt.Background, opt.Accent = trayColors(log)
78 80
A internal/tray/control.go +323 -0
@@ -0,0 +1,323 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerobus"
15+)
16+
17+const (
18+ // callTimeout is how long the control loop waits for an application. A
19+ // frozen one must not keep the next click from working.
20+ callTimeout = 2 * time.Second
21+ // maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
22+ maxInputLine = 256
23+ // maxSteps bounds the steps of a scroll line.
24+ maxSteps = 1 << 16
25+)
26+
27+// lockedWriter serializes writes from the main loop and the control loop to
28+// the shared output.
29+type lockedWriter struct {
30+ mu sync.Mutex
31+ w io.Writer
32+}
33+
34+func (l *lockedWriter) Write(p []byte) (int, error) {
35+ l.mu.Lock()
36+ defer l.mu.Unlock()
37+ return l.w.Write(p)
38+}
39+
40+// button is a mouse button named in a click line.
41+type button uint8
42+
43+const (
44+ btnLeft button = iota + 1
45+ btnMiddle
46+ btnRight
47+)
48+
49+// command is one parsed line from the bar.
50+type command struct {
51+ verb string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
52+ button button // click
53+ vert bool // scroll: vertical axis
54+ delta int32 // scroll: signed steps, down and right positive
55+ col int
56+ token uint32 // click: the press token
57+}
58+
59+// parseCommand reads one line of the protocol. Every field must be present,
60+// numbers must be plain non-negative decimals, and nothing may follow.
61+func parseCommand(line string) (command, bool) {
62+ f := strings.Fields(line)
63+ if len(f) == 0 {
64+ return command{}, false
65+ }
66+ num := func(s string, limit int) (int, bool) {
67+ n, err := strconv.Atoi(s)
68+ return n, err == nil && n >= 0 && n <= limit && s[0] != '+'
69+ }
70+ c := command{verb: f[0]}
71+ switch f[0] {
72+ case "click":
73+ if len(f) != 4 {
74+ return command{}, false
75+ }
76+ switch f[1] {
77+ case "left":
78+ c.button = btnLeft
79+ case "middle":
80+ c.button = btnMiddle
81+ case "right":
82+ c.button = btnRight
83+ default:
84+ return command{}, false
85+ }
86+ col, ok1 := num(f[2], 1<<20)
87+ tok, ok2 := num(f[3], 1<<32-1)
88+ if !ok1 || !ok2 {
89+ return command{}, false
90+ }
91+ c.col, c.token = col, uint32(tok)
92+ case "scroll":
93+ if len(f) != 4 {
94+ return command{}, false
95+ }
96+ sign := int32(1)
97+ switch f[1] {
98+ case "down":
99+ c.vert = true
100+ case "up":
101+ c.vert, sign = true, -1
102+ case "right":
103+ case "left":
104+ sign = -1
105+ default:
106+ return command{}, false
107+ }
108+ steps, ok1 := num(f[2], maxSteps)
109+ col, ok2 := num(f[3], 1<<20)
110+ if !ok1 || !ok2 || steps == 0 {
111+ return command{}, false
112+ }
113+ c.delta, c.col = sign*int32(steps), col
114+ case "hover":
115+ col, ok := num(f[len(f)-1], 1<<20)
116+ if len(f) != 2 || !ok {
117+ return command{}, false
118+ }
119+ c.col = col
120+ case "leave":
121+ if len(f) != 1 {
122+ return command{}, false
123+ }
124+ case "menu-activate":
125+ if len(f) != 3 {
126+ return command{}, false
127+ }
128+ tok, ok1 := num(f[1], 1<<32-1)
129+ id, ok2 := num(f[2], 1<<31-1)
130+ if !ok1 || !ok2 {
131+ return command{}, false
132+ }
133+ c.token, c.col = uint32(tok), id // col carries the menu item id
134+ case "menu-closed":
135+ tok, ok := num(f[len(f)-1], 1<<32-1)
136+ if len(f) != 2 || !ok {
137+ return command{}, false
138+ }
139+ c.token = uint32(tok)
140+ default:
141+ return command{}, false
142+ }
143+ return c, true
144+}
145+
146+// readCommands sends the lines of r to out until r ends or ctx is done. A
147+// line longer than maxInputLine is dropped whole.
148+func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
149+ br := bufio.NewReaderSize(r, maxInputLine)
150+ skipping := false
151+ for {
152+ b, err := br.ReadSlice('\n')
153+ switch {
154+ case errors.Is(err, bufio.ErrBufferFull):
155+ skipping = true // the rest of this line is dropped too
156+ continue
157+ case err != nil && len(b) == 0:
158+ return
159+ }
160+ if skipping {
161+ skipping = false
162+ log.Debug("tray: input line too long")
163+ } else if err == nil {
164+ select {
165+ case out <- strings.TrimRight(string(b), "\r\n"):
166+ case <-ctx.Done():
167+ return
168+ }
169+ }
170+ if err != nil {
171+ return
172+ }
173+ }
174+}
175+
176+// control performs the actions the bar asks for. It owns a second bus
177+// connection, so a slow application never holds up the tray's own loop.
178+type control struct {
179+ t *Tray
180+ dial func() (*zerobus.Conn, error)
181+ log *slog.Logger
182+
183+ mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
184+ conn *zerobus.Conn
185+ done bool
186+}
187+
188+// run serves lines until ctx ends.
189+func (c *control) run(ctx context.Context, lines <-chan string) {
190+ stop := context.AfterFunc(ctx, c.close)
191+ defer stop()
192+ defer c.close()
193+ for {
194+ select {
195+ case <-ctx.Done():
196+ return
197+ case line := <-lines:
198+ cmd, ok := parseCommand(line)
199+ if !ok {
200+ c.log.Debug("tray: ignoring input line", "line", line)
201+ continue
202+ }
203+ c.handle(cmd)
204+ }
205+ }
206+}
207+
208+func (c *control) close() {
209+ c.mu.Lock()
210+ defer c.mu.Unlock()
211+ c.done = true
212+ if c.conn != nil {
213+ _ = c.conn.Close()
214+ c.conn = nil
215+ }
216+}
217+
218+// conn2 returns the control connection, dialing it when needed.
219+func (c *control) conn2() (*zerobus.Conn, error) {
220+ c.mu.Lock()
221+ defer c.mu.Unlock()
222+ if c.done {
223+ return nil, zerobus.ErrClosed
224+ }
225+ if c.conn == nil {
226+ conn, err := c.dial()
227+ if err != nil {
228+ return nil, err
229+ }
230+ c.conn = conn
231+ }
232+ return c.conn, nil
233+}
234+
235+// drop forgets conn after a transport error; the next call dials again.
236+func (c *control) drop(conn *zerobus.Conn) {
237+ c.mu.Lock()
238+ defer c.mu.Unlock()
239+ _ = conn.Close()
240+ if c.conn == conn {
241+ c.conn = nil
242+ }
243+}
244+
245+func (c *control) handle(cmd command) {
246+ switch cmd.verb {
247+ case "click":
248+ tg, ok := c.t.target(cmd.col)
249+ if !ok {
250+ return
251+ }
252+ switch cmd.button {
253+ case btnLeft:
254+ if tg.IsMenu {
255+ c.contextMenu(tg)
256+ return
257+ }
258+ err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
259+ if isUnknownMethod(err) {
260+ c.contextMenu(tg)
261+ }
262+ case btnMiddle:
263+ c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
264+ case btnRight:
265+ c.contextMenu(tg)
266+ }
267+ case "scroll":
268+ tg, ok := c.t.target(cmd.col)
269+ if !ok {
270+ return
271+ }
272+ orientation := "horizontal"
273+ if cmd.vert {
274+ orientation = "vertical"
275+ }
276+ c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
277+ }
278+}
279+
280+// contextMenu asks the application to show its own menu.
281+func (c *control) contextMenu(tg Target) {
282+ c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
283+}
284+
285+func isUnknownMethod(err error) bool {
286+ var e *zerobus.Error
287+ return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
288+}
289+
290+// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
291+// reply is returned as is; a transport failure or a timeout drops the
292+// connection and is logged.
293+func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
294+ conn, err := c.conn2()
295+ if err != nil {
296+ if !errors.Is(err, zerobus.ErrClosed) {
297+ c.log.Warn("tray: cannot reach the session bus", "err", err)
298+ }
299+ return err
300+ }
301+ args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
302+ timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
303+ _, err = conn.Call()
304+ expired := !timer.Stop()
305+ var reply *zerobus.Error
306+ if errors.As(err, &reply) && !expired {
307+ c.log.Debug("tray: the application refused", "method", member, "err", err)
308+ return err
309+ }
310+ if err != nil || expired {
311+ c.drop(conn)
312+ if err == nil {
313+ err = zerobus.ErrClosed
314+ }
315+ c.mu.Lock()
316+ stopping := c.done
317+ c.mu.Unlock()
318+ if !stopping {
319+ c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
320+ }
321+ }
322+ return err
323+}
@@ -0,0 +1,323 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerobus"
15+)
16+
17+const (
18+ // callTimeout is how long the control loop waits for an application. A
19+ // frozen one must not keep the next click from working.
20+ callTimeout = 2 * time.Second
21+ // maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
22+ maxInputLine = 256
23+ // maxSteps bounds the steps of a scroll line.
24+ maxSteps = 1 << 16
25+)
26+
27+// lockedWriter serializes writes from the main loop and the control loop to
28+// the shared output.
29+type lockedWriter struct {
30+ mu sync.Mutex
31+ w io.Writer
32+}
33+
34+func (l *lockedWriter) Write(p []byte) (int, error) {
35+ l.mu.Lock()
36+ defer l.mu.Unlock()
37+ return l.w.Write(p)
38+}
39+
40+// button is a mouse button named in a click line.
41+type button uint8
42+
43+const (
44+ btnLeft button = iota + 1
45+ btnMiddle
46+ btnRight
47+)
48+
49+// command is one parsed line from the bar.
50+type command struct {
51+ verb string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
52+ button button // click
53+ vert bool // scroll: vertical axis
54+ delta int32 // scroll: signed steps, down and right positive
55+ col int
56+ token uint32 // click: the press token
57+}
58+
59+// parseCommand reads one line of the protocol. Every field must be present,
60+// numbers must be plain non-negative decimals, and nothing may follow.
61+func parseCommand(line string) (command, bool) {
62+ f := strings.Fields(line)
63+ if len(f) == 0 {
64+ return command{}, false
65+ }
66+ num := func(s string, limit int) (int, bool) {
67+ n, err := strconv.Atoi(s)
68+ return n, err == nil && n >= 0 && n <= limit && s[0] != '+'
69+ }
70+ c := command{verb: f[0]}
71+ switch f[0] {
72+ case "click":
73+ if len(f) != 4 {
74+ return command{}, false
75+ }
76+ switch f[1] {
77+ case "left":
78+ c.button = btnLeft
79+ case "middle":
80+ c.button = btnMiddle
81+ case "right":
82+ c.button = btnRight
83+ default:
84+ return command{}, false
85+ }
86+ col, ok1 := num(f[2], 1<<20)
87+ tok, ok2 := num(f[3], 1<<32-1)
88+ if !ok1 || !ok2 {
89+ return command{}, false
90+ }
91+ c.col, c.token = col, uint32(tok)
92+ case "scroll":
93+ if len(f) != 4 {
94+ return command{}, false
95+ }
96+ sign := int32(1)
97+ switch f[1] {
98+ case "down":
99+ c.vert = true
100+ case "up":
101+ c.vert, sign = true, -1
102+ case "right":
103+ case "left":
104+ sign = -1
105+ default:
106+ return command{}, false
107+ }
108+ steps, ok1 := num(f[2], maxSteps)
109+ col, ok2 := num(f[3], 1<<20)
110+ if !ok1 || !ok2 || steps == 0 {
111+ return command{}, false
112+ }
113+ c.delta, c.col = sign*int32(steps), col
114+ case "hover":
115+ col, ok := num(f[len(f)-1], 1<<20)
116+ if len(f) != 2 || !ok {
117+ return command{}, false
118+ }
119+ c.col = col
120+ case "leave":
121+ if len(f) != 1 {
122+ return command{}, false
123+ }
124+ case "menu-activate":
125+ if len(f) != 3 {
126+ return command{}, false
127+ }
128+ tok, ok1 := num(f[1], 1<<32-1)
129+ id, ok2 := num(f[2], 1<<31-1)
130+ if !ok1 || !ok2 {
131+ return command{}, false
132+ }
133+ c.token, c.col = uint32(tok), id // col carries the menu item id
134+ case "menu-closed":
135+ tok, ok := num(f[len(f)-1], 1<<32-1)
136+ if len(f) != 2 || !ok {
137+ return command{}, false
138+ }
139+ c.token = uint32(tok)
140+ default:
141+ return command{}, false
142+ }
143+ return c, true
144+}
145+
146+// readCommands sends the lines of r to out until r ends or ctx is done. A
147+// line longer than maxInputLine is dropped whole.
148+func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
149+ br := bufio.NewReaderSize(r, maxInputLine)
150+ skipping := false
151+ for {
152+ b, err := br.ReadSlice('\n')
153+ switch {
154+ case errors.Is(err, bufio.ErrBufferFull):
155+ skipping = true // the rest of this line is dropped too
156+ continue
157+ case err != nil && len(b) == 0:
158+ return
159+ }
160+ if skipping {
161+ skipping = false
162+ log.Debug("tray: input line too long")
163+ } else if err == nil {
164+ select {
165+ case out <- strings.TrimRight(string(b), "\r\n"):
166+ case <-ctx.Done():
167+ return
168+ }
169+ }
170+ if err != nil {
171+ return
172+ }
173+ }
174+}
175+
176+// control performs the actions the bar asks for. It owns a second bus
177+// connection, so a slow application never holds up the tray's own loop.
178+type control struct {
179+ t *Tray
180+ dial func() (*zerobus.Conn, error)
181+ log *slog.Logger
182+
183+ mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
184+ conn *zerobus.Conn
185+ done bool
186+}
187+
188+// run serves lines until ctx ends.
189+func (c *control) run(ctx context.Context, lines <-chan string) {
190+ stop := context.AfterFunc(ctx, c.close)
191+ defer stop()
192+ defer c.close()
193+ for {
194+ select {
195+ case <-ctx.Done():
196+ return
197+ case line := <-lines:
198+ cmd, ok := parseCommand(line)
199+ if !ok {
200+ c.log.Debug("tray: ignoring input line", "line", line)
201+ continue
202+ }
203+ c.handle(cmd)
204+ }
205+ }
206+}
207+
208+func (c *control) close() {
209+ c.mu.Lock()
210+ defer c.mu.Unlock()
211+ c.done = true
212+ if c.conn != nil {
213+ _ = c.conn.Close()
214+ c.conn = nil
215+ }
216+}
217+
218+// conn2 returns the control connection, dialing it when needed.
219+func (c *control) conn2() (*zerobus.Conn, error) {
220+ c.mu.Lock()
221+ defer c.mu.Unlock()
222+ if c.done {
223+ return nil, zerobus.ErrClosed
224+ }
225+ if c.conn == nil {
226+ conn, err := c.dial()
227+ if err != nil {
228+ return nil, err
229+ }
230+ c.conn = conn
231+ }
232+ return c.conn, nil
233+}
234+
235+// drop forgets conn after a transport error; the next call dials again.
236+func (c *control) drop(conn *zerobus.Conn) {
237+ c.mu.Lock()
238+ defer c.mu.Unlock()
239+ _ = conn.Close()
240+ if c.conn == conn {
241+ c.conn = nil
242+ }
243+}
244+
245+func (c *control) handle(cmd command) {
246+ switch cmd.verb {
247+ case "click":
248+ tg, ok := c.t.target(cmd.col)
249+ if !ok {
250+ return
251+ }
252+ switch cmd.button {
253+ case btnLeft:
254+ if tg.IsMenu {
255+ c.contextMenu(tg)
256+ return
257+ }
258+ err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
259+ if isUnknownMethod(err) {
260+ c.contextMenu(tg)
261+ }
262+ case btnMiddle:
263+ c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
264+ case btnRight:
265+ c.contextMenu(tg)
266+ }
267+ case "scroll":
268+ tg, ok := c.t.target(cmd.col)
269+ if !ok {
270+ return
271+ }
272+ orientation := "horizontal"
273+ if cmd.vert {
274+ orientation = "vertical"
275+ }
276+ c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
277+ }
278+}
279+
280+// contextMenu asks the application to show its own menu.
281+func (c *control) contextMenu(tg Target) {
282+ c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
283+}
284+
285+func isUnknownMethod(err error) bool {
286+ var e *zerobus.Error
287+ return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
288+}
289+
290+// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
291+// reply is returned as is; a transport failure or a timeout drops the
292+// connection and is logged.
293+func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
294+ conn, err := c.conn2()
295+ if err != nil {
296+ if !errors.Is(err, zerobus.ErrClosed) {
297+ c.log.Warn("tray: cannot reach the session bus", "err", err)
298+ }
299+ return err
300+ }
301+ args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
302+ timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
303+ _, err = conn.Call()
304+ expired := !timer.Stop()
305+ var reply *zerobus.Error
306+ if errors.As(err, &reply) && !expired {
307+ c.log.Debug("tray: the application refused", "method", member, "err", err)
308+ return err
309+ }
310+ if err != nil || expired {
311+ c.drop(conn)
312+ if err == nil {
313+ err = zerobus.ErrClosed
314+ }
315+ c.mu.Lock()
316+ stopping := c.done
317+ c.mu.Unlock()
318+ if !stopping {
319+ c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
320+ }
321+ }
322+ return err
323+}
A internal/tray/control_test.go +181 -0
@@ -0,0 +1,181 @@
1+package tray
2+
3+import (
4+ "io"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerobus"
10+)
11+
12+func TestParseCommand(t *testing.T) {
13+ good := map[string]command{
14+ "click left 0 1": {verb: "click", button: btnLeft, col: 0, token: 1},
15+ "click middle 3 42": {verb: "click", button: btnMiddle, col: 3, token: 42},
16+ "click right 12 7": {verb: "click", button: btnRight, col: 12, token: 7},
17+ "scroll up 2 0": {verb: "scroll", vert: true, delta: -2},
18+ "scroll down 1 4": {verb: "scroll", vert: true, delta: 1, col: 4},
19+ "scroll left 3 1": {verb: "scroll", delta: -3, col: 1},
20+ "scroll right 5 2": {verb: "scroll", delta: 5, col: 2},
21+ "hover 4": {verb: "hover", col: 4},
22+ "leave": {verb: "leave"},
23+ "menu-activate 9 17": {verb: "menu-activate", token: 9, col: 17},
24+ "menu-closed 9": {verb: "menu-closed", token: 9},
25+ " click left 1 2 ": {verb: "click", button: btnLeft, col: 1, token: 2},
26+ }
27+ for line, want := range good {
28+ got, ok := parseCommand(line)
29+ if !ok || got != want {
30+ t.Errorf("parseCommand(%q) = %+v, %v; want %+v", line, got, ok, want)
31+ }
32+ }
33+ for _, line := range []string{
34+ "", " ", "garbage", "click", "click left", "click left 0", "click left 0 1 2", "click up 0 1",
35+ "click left -1 1", "click left x 1", "click left 0 -3", "click left +1 1", "click left 0 4294967296",
36+ "scroll up", "scroll up 0 0", "scroll up -1 0", "scroll up 2", "scroll diagonal 1 0", "scroll up 1000000 0",
37+ "hover", "hover x", "hover -1", "hover 1 2", "leave now", "menu-activate 1", "menu-activate 1 -2",
38+ "menu-closed", "menu-closed x", "menu-closed 1 2", "CLICK left 0 1",
39+ } {
40+ if got, ok := parseCommand(line); ok {
41+ t.Errorf("parseCommand(%q) = %+v, want it refused", line, got)
42+ }
43+ }
44+}
45+
46+// barInput is the pipe the test writes the bar's lines to.
47+type barInput struct{ w *io.PipeWriter }
48+
49+func (b barInput) send(t *testing.T, lines ...string) {
50+ t.Helper()
51+ for _, l := range lines {
52+ if _, err := io.WriteString(b.w, l+"\n"); err != nil {
53+ t.Fatal(err)
54+ }
55+ }
56+}
57+
58+// setup runs a tray with input on a private bus, with two items: steam ("S",
59+// column 0) and obsidian ("O", column 2).
60+func setup(t *testing.T) (steam, obs *fakeItem, in barInput, out *lines) {
61+ t.Helper()
62+ addr := privateBus(t)
63+ pr, pw := io.Pipe()
64+ t.Cleanup(func() { _ = pw.Close() })
65+ in = barInput{pw}
66+ out, _, _ = startTrayWith(t, addr, pr, func() (*zerobus.Conn, error) { return zerobus.Dial(addr) })
67+ waitWatcher(t, addr)
68+ steam = newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
69+ obs = newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
70+ steam.register(t)
71+ out.waitFor(t, "steam", func(s string) bool { return strings.Contains(s, "S") })
72+ obs.register(t)
73+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "S") && strings.Contains(s, "O") })
74+ return steam, obs, in, out
75+}
76+
77+func expectCall(t *testing.T, f *fakeItem, want string) {
78+ t.Helper()
79+ select {
80+ case got := <-f.calls:
81+ if got != want {
82+ t.Fatalf("item got %s, want %s", got, want)
83+ }
84+ case <-time.After(5 * time.Second):
85+ t.Fatalf("item never got %s", want)
86+ }
87+}
88+
89+func expectNoCall(t *testing.T, f *fakeItem) {
90+ t.Helper()
91+ select {
92+ case got := <-f.calls:
93+ t.Fatalf("unexpected call %s", got)
94+ case <-time.After(150 * time.Millisecond):
95+ }
96+}
97+
98+func TestClicksAndScroll(t *testing.T) {
99+ steam, obs, in, _ := setup(t)
100+
101+ in.send(t, "click left 0 1")
102+ expectCall(t, steam, "Activate(0,0)")
103+ in.send(t, "click middle 0 2")
104+ expectCall(t, steam, "SecondaryActivate(0,0)")
105+ in.send(t, "click right 0 3")
106+ expectCall(t, steam, "ContextMenu(0,0)")
107+
108+ // The second icon starts at column 2, after one space.
109+ in.send(t, "click left 2 4")
110+ expectCall(t, obs, "Activate(0,0)")
111+ expectNoCall(t, steam)
112+
113+ in.send(t, "scroll up 2 0")
114+ expectCall(t, steam, "Scroll(-2,vertical)")
115+ in.send(t, "scroll down 3 0")
116+ expectCall(t, steam, "Scroll(3,vertical)")
117+ in.send(t, "scroll right 1 2")
118+ expectCall(t, obs, "Scroll(1,horizontal)")
119+ in.send(t, "scroll left 4 2")
120+ expectCall(t, obs, "Scroll(-4,horizontal)")
121+}
122+
123+func TestClickOutsideIconsAndGarbageAreIgnored(t *testing.T) {
124+ steam, obs, in, _ := setup(t)
125+ in.send(t,
126+ "click left 1 1", // the space between the icons
127+ "click left 9 2", // past the last icon
128+ "garbage",
129+ "click left x 3",
130+ "click left 0",
131+ "click left 0 4 extra",
132+ "click left -1 5",
133+ "scroll up 0 0",
134+ "\x1b[31m not a command",
135+ strings.Repeat("click left 0 1 ", 40), // longer than the line limit
136+ "hover 0", "leave", // accepted by the parser, nothing to do yet
137+ )
138+ expectNoCall(t, steam)
139+ expectNoCall(t, obs)
140+ // The loop still works afterwards.
141+ in.send(t, "click left 0 9")
142+ expectCall(t, steam, "Activate(0,0)")
143+}
144+
145+func TestActivateUnknownMethodFallsBackToContextMenu(t *testing.T) {
146+ steam, _, in, _ := setup(t)
147+ steam.run(t, func() { steam.unknown = "Activate" })
148+ in.send(t, "click left 0 1")
149+ expectCall(t, steam, "Activate(0,0)")
150+ expectCall(t, steam, "ContextMenu(0,0)")
151+}
152+
153+func TestItemIsMenuLeftClickOpensTheMenu(t *testing.T) {
154+ var buf strings.Builder
155+ tr := &Tray{opt: Options{Out: &buf}}
156+ tr.items = []*item{{loaded: true, icon: "X", dest: ":1.1", path: "/p", isMenu: true, menu: "/m"}}
157+ if err := tr.render(true); err != nil {
158+ t.Fatal(err)
159+ }
160+ tg, ok := tr.target(0)
161+ if !ok || !tg.IsMenu || tg.Menu != "/m" {
162+ t.Fatalf("target = %+v, %v", tg, ok)
163+ }
164+}
165+
166+// A frozen application must not block the tray: the call is abandoned after
167+// two seconds and the next click, on another item, still goes through.
168+func TestFrozenApplicationDoesNotBlockNextClick(t *testing.T) {
169+ steam, obs, in, _ := setup(t)
170+ steam.run(t, func() { steam.slow, steam.slowFor = "Activate", 4*time.Second })
171+ start := time.Now()
172+ in.send(t, "click left 0 1", "click left 2 2")
173+ expectCall(t, steam, "Activate(0,0)")
174+ expectCall(t, obs, "Activate(0,0)")
175+ if d := time.Since(start); d < 1500*time.Millisecond || d > 2500*time.Millisecond {
176+ t.Fatalf("the second click arrived after %v, want about 2s (the watchdog)", d)
177+ }
178+ // And again on the dropped item: the connection was redialed.
179+ in.send(t, "click middle 2 3")
180+ expectCall(t, obs, "SecondaryActivate(0,0)")
181+}
@@ -0,0 +1,181 @@
1+package tray
2+
3+import (
4+ "io"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerobus"
10+)
11+
12+func TestParseCommand(t *testing.T) {
13+ good := map[string]command{
14+ "click left 0 1": {verb: "click", button: btnLeft, col: 0, token: 1},
15+ "click middle 3 42": {verb: "click", button: btnMiddle, col: 3, token: 42},
16+ "click right 12 7": {verb: "click", button: btnRight, col: 12, token: 7},
17+ "scroll up 2 0": {verb: "scroll", vert: true, delta: -2},
18+ "scroll down 1 4": {verb: "scroll", vert: true, delta: 1, col: 4},
19+ "scroll left 3 1": {verb: "scroll", delta: -3, col: 1},
20+ "scroll right 5 2": {verb: "scroll", delta: 5, col: 2},
21+ "hover 4": {verb: "hover", col: 4},
22+ "leave": {verb: "leave"},
23+ "menu-activate 9 17": {verb: "menu-activate", token: 9, col: 17},
24+ "menu-closed 9": {verb: "menu-closed", token: 9},
25+ " click left 1 2 ": {verb: "click", button: btnLeft, col: 1, token: 2},
26+ }
27+ for line, want := range good {
28+ got, ok := parseCommand(line)
29+ if !ok || got != want {
30+ t.Errorf("parseCommand(%q) = %+v, %v; want %+v", line, got, ok, want)
31+ }
32+ }
33+ for _, line := range []string{
34+ "", " ", "garbage", "click", "click left", "click left 0", "click left 0 1 2", "click up 0 1",
35+ "click left -1 1", "click left x 1", "click left 0 -3", "click left +1 1", "click left 0 4294967296",
36+ "scroll up", "scroll up 0 0", "scroll up -1 0", "scroll up 2", "scroll diagonal 1 0", "scroll up 1000000 0",
37+ "hover", "hover x", "hover -1", "hover 1 2", "leave now", "menu-activate 1", "menu-activate 1 -2",
38+ "menu-closed", "menu-closed x", "menu-closed 1 2", "CLICK left 0 1",
39+ } {
40+ if got, ok := parseCommand(line); ok {
41+ t.Errorf("parseCommand(%q) = %+v, want it refused", line, got)
42+ }
43+ }
44+}
45+
46+// barInput is the pipe the test writes the bar's lines to.
47+type barInput struct{ w *io.PipeWriter }
48+
49+func (b barInput) send(t *testing.T, lines ...string) {
50+ t.Helper()
51+ for _, l := range lines {
52+ if _, err := io.WriteString(b.w, l+"\n"); err != nil {
53+ t.Fatal(err)
54+ }
55+ }
56+}
57+
58+// setup runs a tray with input on a private bus, with two items: steam ("S",
59+// column 0) and obsidian ("O", column 2).
60+func setup(t *testing.T) (steam, obs *fakeItem, in barInput, out *lines) {
61+ t.Helper()
62+ addr := privateBus(t)
63+ pr, pw := io.Pipe()
64+ t.Cleanup(func() { _ = pw.Close() })
65+ in = barInput{pw}
66+ out, _, _ = startTrayWith(t, addr, pr, func() (*zerobus.Conn, error) { return zerobus.Dial(addr) })
67+ waitWatcher(t, addr)
68+ steam = newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
69+ obs = newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
70+ steam.register(t)
71+ out.waitFor(t, "steam", func(s string) bool { return strings.Contains(s, "S") })
72+ obs.register(t)
73+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "S") && strings.Contains(s, "O") })
74+ return steam, obs, in, out
75+}
76+
77+func expectCall(t *testing.T, f *fakeItem, want string) {
78+ t.Helper()
79+ select {
80+ case got := <-f.calls:
81+ if got != want {
82+ t.Fatalf("item got %s, want %s", got, want)
83+ }
84+ case <-time.After(5 * time.Second):
85+ t.Fatalf("item never got %s", want)
86+ }
87+}
88+
89+func expectNoCall(t *testing.T, f *fakeItem) {
90+ t.Helper()
91+ select {
92+ case got := <-f.calls:
93+ t.Fatalf("unexpected call %s", got)
94+ case <-time.After(150 * time.Millisecond):
95+ }
96+}
97+
98+func TestClicksAndScroll(t *testing.T) {
99+ steam, obs, in, _ := setup(t)
100+
101+ in.send(t, "click left 0 1")
102+ expectCall(t, steam, "Activate(0,0)")
103+ in.send(t, "click middle 0 2")
104+ expectCall(t, steam, "SecondaryActivate(0,0)")
105+ in.send(t, "click right 0 3")
106+ expectCall(t, steam, "ContextMenu(0,0)")
107+
108+ // The second icon starts at column 2, after one space.
109+ in.send(t, "click left 2 4")
110+ expectCall(t, obs, "Activate(0,0)")
111+ expectNoCall(t, steam)
112+
113+ in.send(t, "scroll up 2 0")
114+ expectCall(t, steam, "Scroll(-2,vertical)")
115+ in.send(t, "scroll down 3 0")
116+ expectCall(t, steam, "Scroll(3,vertical)")
117+ in.send(t, "scroll right 1 2")
118+ expectCall(t, obs, "Scroll(1,horizontal)")
119+ in.send(t, "scroll left 4 2")
120+ expectCall(t, obs, "Scroll(-4,horizontal)")
121+}
122+
123+func TestClickOutsideIconsAndGarbageAreIgnored(t *testing.T) {
124+ steam, obs, in, _ := setup(t)
125+ in.send(t,
126+ "click left 1 1", // the space between the icons
127+ "click left 9 2", // past the last icon
128+ "garbage",
129+ "click left x 3",
130+ "click left 0",
131+ "click left 0 4 extra",
132+ "click left -1 5",
133+ "scroll up 0 0",
134+ "\x1b[31m not a command",
135+ strings.Repeat("click left 0 1 ", 40), // longer than the line limit
136+ "hover 0", "leave", // accepted by the parser, nothing to do yet
137+ )
138+ expectNoCall(t, steam)
139+ expectNoCall(t, obs)
140+ // The loop still works afterwards.
141+ in.send(t, "click left 0 9")
142+ expectCall(t, steam, "Activate(0,0)")
143+}
144+
145+func TestActivateUnknownMethodFallsBackToContextMenu(t *testing.T) {
146+ steam, _, in, _ := setup(t)
147+ steam.run(t, func() { steam.unknown = "Activate" })
148+ in.send(t, "click left 0 1")
149+ expectCall(t, steam, "Activate(0,0)")
150+ expectCall(t, steam, "ContextMenu(0,0)")
151+}
152+
153+func TestItemIsMenuLeftClickOpensTheMenu(t *testing.T) {
154+ var buf strings.Builder
155+ tr := &Tray{opt: Options{Out: &buf}}
156+ tr.items = []*item{{loaded: true, icon: "X", dest: ":1.1", path: "/p", isMenu: true, menu: "/m"}}
157+ if err := tr.render(true); err != nil {
158+ t.Fatal(err)
159+ }
160+ tg, ok := tr.target(0)
161+ if !ok || !tg.IsMenu || tg.Menu != "/m" {
162+ t.Fatalf("target = %+v, %v", tg, ok)
163+ }
164+}
165+
166+// A frozen application must not block the tray: the call is abandoned after
167+// two seconds and the next click, on another item, still goes through.
168+func TestFrozenApplicationDoesNotBlockNextClick(t *testing.T) {
169+ steam, obs, in, _ := setup(t)
170+ steam.run(t, func() { steam.slow, steam.slowFor = "Activate", 4*time.Second })
171+ start := time.Now()
172+ in.send(t, "click left 0 1", "click left 2 2")
173+ expectCall(t, steam, "Activate(0,0)")
174+ expectCall(t, obs, "Activate(0,0)")
175+ if d := time.Since(start); d < 1500*time.Millisecond || d > 2500*time.Millisecond {
176+ t.Fatalf("the second click arrived after %v, want about 2s (the watchdog)", d)
177+ }
178+ // And again on the dropped item: the connection was redialed.
179+ in.send(t, "click middle 2 3")
180+ expectCall(t, obs, "SecondaryActivate(0,0)")
181+}
A internal/tray/snapshot.go +43 -0
@@ -0,0 +1,43 @@
1+package tray
2+
3+import "sync"
4+
5+// Target is what a click on the tray line reaches: one visible item and the
6+// columns of its icon in the printed line.
7+type Target struct {
8+ Dest string // where to send calls: the owner's unique name, else the registered name
9+ Path string // the object path of the StatusNotifierItem
10+ Menu string // the object path of its dbusmenu, "" when it has none
11+ IsMenu bool // the item only offers a menu: Activate means "open it"
12+ Start int // first column of the icon in the line
13+ Width int // cells the icon takes
14+}
15+
16+// snapshot is the list of targets of the last line printed. The main loop
17+// writes it; the control goroutine reads it.
18+type snapshot struct {
19+ mu sync.Mutex
20+ targets []Target
21+}
22+
23+// publish replaces the targets with a copy of ts.
24+func (s *snapshot) publish(ts []Target) {
25+ s.mu.Lock()
26+ s.targets = append(s.targets[:0], ts...)
27+ s.mu.Unlock()
28+}
29+
30+// at returns the target whose icon covers col.
31+func (s *snapshot) at(col int) (Target, bool) {
32+ s.mu.Lock()
33+ defer s.mu.Unlock()
34+ for _, t := range s.targets {
35+ if col >= t.Start && col < t.Start+t.Width {
36+ return t, true
37+ }
38+ }
39+ return Target{}, false
40+}
41+
42+// target returns the visible item whose icon covers column col of the line.
43+func (t *Tray) target(col int) (Target, bool) { return t.snap.at(col) }
@@ -0,0 +1,43 @@
1+package tray
2+
3+import "sync"
4+
5+// Target is what a click on the tray line reaches: one visible item and the
6+// columns of its icon in the printed line.
7+type Target struct {
8+ Dest string // where to send calls: the owner's unique name, else the registered name
9+ Path string // the object path of the StatusNotifierItem
10+ Menu string // the object path of its dbusmenu, "" when it has none
11+ IsMenu bool // the item only offers a menu: Activate means "open it"
12+ Start int // first column of the icon in the line
13+ Width int // cells the icon takes
14+}
15+
16+// snapshot is the list of targets of the last line printed. The main loop
17+// writes it; the control goroutine reads it.
18+type snapshot struct {
19+ mu sync.Mutex
20+ targets []Target
21+}
22+
23+// publish replaces the targets with a copy of ts.
24+func (s *snapshot) publish(ts []Target) {
25+ s.mu.Lock()
26+ s.targets = append(s.targets[:0], ts...)
27+ s.mu.Unlock()
28+}
29+
30+// at returns the target whose icon covers col.
31+func (s *snapshot) at(col int) (Target, bool) {
32+ s.mu.Lock()
33+ defer s.mu.Unlock()
34+ for _, t := range s.targets {
35+ if col >= t.Start && col < t.Start+t.Width {
36+ return t, true
37+ }
38+ }
39+ return Target{}, false
40+}
41+
42+// target returns the visible item whose icon covers column col of the line.
43+func (t *Tray) target(col int) (Target, bool) { return t.snap.at(col) }
M internal/tray/tray.go +63 -3
@@ -19,7 +19,9 @@ import (
1919 "slices"
2020 "strconv"
2121 "strings"
22+ "sync"
2223
24+ vt "github.com/bnema/vev-vt"
2325 "github.com/bnema/zerobus"
2426 )
2527
@@ -46,6 +48,11 @@ type Options struct {
4648 Foreground, Background, Accent RGB
4749 Out io.Writer // one line per change
4850 Log *slog.Logger
51+ // In carries the bar's pointer lines (see the README); nil ignores input.
52+ In io.Reader
53+ // Dial opens the connection that runs the actions In asks for, apart from
54+ // the one that serves the tray. Required with In.
55+ Dial func() (*zerobus.Conn, error)
4956 }
5057
5158 type status uint8
@@ -68,6 +75,8 @@ type item struct {
6875 resolved bool
6976 color RGB
7077 hasColor bool
78+ menu string // object path of its dbusmenu, "" when none
79+ isMenu bool // ItemIsMenu: Activate should open the menu
7180 loaded bool // GetAll answered at least once
7281 pending uint32 // serial of a GetAll in flight
7382 stale bool // a change arrived while GetAll was in flight
@@ -100,6 +109,9 @@ type Tray struct {
100109 last []byte
101110 hist histogram
102111 err error // first fatal error from dispatch
112+
113+ snap snapshot // what the last line printed holds, for the control loop
114+ targets []Target // scratch for the next snapshot
103115 }
104116
105117 // Run serves the tray on c until ctx ends or the bus connection fails. It
@@ -114,6 +126,23 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
114126 defer stop()
115127 defer func() { _ = c.Close() }()
116128
129+ if opt.In != nil {
130+ if opt.Dial == nil {
131+ return fmt.Errorf("tray: Options.In needs Options.Dial")
132+ }
133+ t.opt.Out = &lockedWriter{w: opt.Out}
134+ cctx, cancel := context.WithCancel(ctx)
135+ lines := make(chan string, 16)
136+ ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
137+ var wg sync.WaitGroup
138+ wg.Add(1)
139+ go func() { defer wg.Done(); ctl.run(cctx, lines) }()
140+ // The reader may sit in a blocking Read of stdin that nothing can
141+ // interrupt; it is not waited for, and ends with the process.
142+ go readCommands(cctx, opt.In, lines, t.opt.Log)
143+ defer func() { cancel(); wg.Wait() }()
144+ }
145+
117146 // Calls made from here on only go to the bus, which always answers.
118147 // Handle sees what arrives meanwhile.
119148 c.Handle = t.dispatch
@@ -464,6 +493,10 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
464493 setStr(&it.title, r.Str())
465494 case sig == "s" && key == "IconName":
466495 setStr(&it.iconName, r.Str())
496+ case sig == "o" && key == "Menu":
497+ setStr(&it.menu, r.ObjectPath())
498+ case sig == "b" && key == "ItemIsMenu":
499+ it.isMenu = r.Bool()
467500 case sig == "s" && key == "Status":
468501 it.status = parseStatus(r.Str())
469502 case sig == "a(iiay)" && key == "IconPixmap":
@@ -665,15 +698,20 @@ func (t *Tray) renderOrFail() {
665698 }
666699 }
667700
668-// render writes the line of visible items when it differs from the last one.
701+// render writes the line of visible items when it differs from the last one,
702+// and publishes where each icon sits for the control loop. The targets are
703+// compared too: an item may change its menu without changing its icon.
669704 func (t *Tray) render(force bool) error {
670705 f := t.frame[:0]
706+ ts := t.targets[:0]
707+ col := 0
671708 for _, it := range t.items {
672709 if !it.loaded || it.status == passive || it.icon == "" {
673710 continue
674711 }
675712 if len(f) > 0 {
676713 f = append(f, ' ')
714+ col++
677715 }
678716 c, bold := t.opt.Foreground, false
679717 switch {
@@ -695,10 +733,25 @@ func (t *Tray) render(force bool) error {
695733 f = append(f, 'm')
696734 f = append(f, it.icon...)
697735 f = append(f, "\x1b[0m"...)
736+ dest := it.dest
737+ if it.owner != "" {
738+ dest = it.owner
739+ }
740+ w := 0
741+ for _, r := range it.icon {
742+ w += vt.RuneWidth(r)
743+ }
744+ ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
745+ col += w
698746 }
699747 f = append(f, '\n')
700- t.frame = f
701- if !force && bytes.Equal(f, t.last) {
748+ t.frame, t.targets = f, ts
749+ sameFrame := bytes.Equal(f, t.last)
750+ if !force && sameFrame && t.sameTargets(ts) {
751+ return nil
752+ }
753+ t.snap.publish(ts) // before the line: whoever sees the line finds its targets
754+ if !force && sameFrame {
702755 return nil
703756 }
704757 t.frame, t.last = t.last, t.frame
@@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error {
708761 return nil
709762 }
710763
764+// sameTargets reports whether the published snapshot equals ts.
765+func (t *Tray) sameTargets(ts []Target) bool {
766+ t.snap.mu.Lock()
767+ defer t.snap.mu.Unlock()
768+ return slices.Equal(t.snap.targets, ts)
769+}
770+
711771 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712772 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713773 <node>
@@ -19,7 +19,9 @@ import (
19 "slices"19 "slices"
20 "strconv"20 "strconv"
21 "strings"21 "strings"
22+ "sync"
22 23
24+ vt "github.com/bnema/vev-vt"
23 "github.com/bnema/zerobus"25 "github.com/bnema/zerobus"
24 )26 )
25 27
@@ -46,6 +48,11 @@ type Options struct {
46 Foreground, Background, Accent RGB48 Foreground, Background, Accent RGB
47 Out io.Writer // one line per change49 Out io.Writer // one line per change
48 Log *slog.Logger50 Log *slog.Logger
51+ // In carries the bar's pointer lines (see the README); nil ignores input.
52+ In io.Reader
53+ // Dial opens the connection that runs the actions In asks for, apart from
54+ // the one that serves the tray. Required with In.
55+ Dial func() (*zerobus.Conn, error)
49 }56 }
50 57
51 type status uint858 type status uint8
@@ -68,6 +75,8 @@ type item struct {
68 resolved bool75 resolved bool
69 color RGB76 color RGB
70 hasColor bool77 hasColor bool
78+ menu string // object path of its dbusmenu, "" when none
79+ isMenu bool // ItemIsMenu: Activate should open the menu
71 loaded bool // GetAll answered at least once80 loaded bool // GetAll answered at least once
72 pending uint32 // serial of a GetAll in flight81 pending uint32 // serial of a GetAll in flight
73 stale bool // a change arrived while GetAll was in flight82 stale bool // a change arrived while GetAll was in flight
@@ -100,6 +109,9 @@ type Tray struct {
100 last []byte109 last []byte
101 hist histogram110 hist histogram
102 err error // first fatal error from dispatch111 err error // first fatal error from dispatch
112+
113+ snap snapshot // what the last line printed holds, for the control loop
114+ targets []Target // scratch for the next snapshot
103 }115 }
104 116
105 // Run serves the tray on c until ctx ends or the bus connection fails. It117 // Run serves the tray on c until ctx ends or the bus connection fails. It
@@ -114,6 +126,23 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
114 defer stop()126 defer stop()
115 defer func() { _ = c.Close() }()127 defer func() { _ = c.Close() }()
116 128
129+ if opt.In != nil {
130+ if opt.Dial == nil {
131+ return fmt.Errorf("tray: Options.In needs Options.Dial")
132+ }
133+ t.opt.Out = &lockedWriter{w: opt.Out}
134+ cctx, cancel := context.WithCancel(ctx)
135+ lines := make(chan string, 16)
136+ ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
137+ var wg sync.WaitGroup
138+ wg.Add(1)
139+ go func() { defer wg.Done(); ctl.run(cctx, lines) }()
140+ // The reader may sit in a blocking Read of stdin that nothing can
141+ // interrupt; it is not waited for, and ends with the process.
142+ go readCommands(cctx, opt.In, lines, t.opt.Log)
143+ defer func() { cancel(); wg.Wait() }()
144+ }
145+
117 // Calls made from here on only go to the bus, which always answers.146 // Calls made from here on only go to the bus, which always answers.
118 // Handle sees what arrives meanwhile.147 // Handle sees what arrives meanwhile.
119 c.Handle = t.dispatch148 c.Handle = t.dispatch
@@ -464,6 +493,10 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
464 setStr(&it.title, r.Str())493 setStr(&it.title, r.Str())
465 case sig == "s" && key == "IconName":494 case sig == "s" && key == "IconName":
466 setStr(&it.iconName, r.Str())495 setStr(&it.iconName, r.Str())
496+ case sig == "o" && key == "Menu":
497+ setStr(&it.menu, r.ObjectPath())
498+ case sig == "b" && key == "ItemIsMenu":
499+ it.isMenu = r.Bool()
467 case sig == "s" && key == "Status":500 case sig == "s" && key == "Status":
468 it.status = parseStatus(r.Str())501 it.status = parseStatus(r.Str())
469 case sig == "a(iiay)" && key == "IconPixmap":502 case sig == "a(iiay)" && key == "IconPixmap":
@@ -665,15 +698,20 @@ func (t *Tray) renderOrFail() {
665 }698 }
666 }699 }
667 700
668-// render writes the line of visible items when it differs from the last one.701+// render writes the line of visible items when it differs from the last one,
702+// and publishes where each icon sits for the control loop. The targets are
703+// compared too: an item may change its menu without changing its icon.
669 func (t *Tray) render(force bool) error {704 func (t *Tray) render(force bool) error {
670 f := t.frame[:0]705 f := t.frame[:0]
706+ ts := t.targets[:0]
707+ col := 0
671 for _, it := range t.items {708 for _, it := range t.items {
672 if !it.loaded || it.status == passive || it.icon == "" {709 if !it.loaded || it.status == passive || it.icon == "" {
673 continue710 continue
674 }711 }
675 if len(f) > 0 {712 if len(f) > 0 {
676 f = append(f, ' ')713 f = append(f, ' ')
714+ col++
677 }715 }
678 c, bold := t.opt.Foreground, false716 c, bold := t.opt.Foreground, false
679 switch {717 switch {
@@ -695,10 +733,25 @@ func (t *Tray) render(force bool) error {
695 f = append(f, 'm')733 f = append(f, 'm')
696 f = append(f, it.icon...)734 f = append(f, it.icon...)
697 f = append(f, "\x1b[0m"...)735 f = append(f, "\x1b[0m"...)
736+ dest := it.dest
737+ if it.owner != "" {
738+ dest = it.owner
739+ }
740+ w := 0
741+ for _, r := range it.icon {
742+ w += vt.RuneWidth(r)
743+ }
744+ ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
745+ col += w
698 }746 }
699 f = append(f, '\n')747 f = append(f, '\n')
700- t.frame = f748+ t.frame, t.targets = f, ts
701- if !force && bytes.Equal(f, t.last) {749+ sameFrame := bytes.Equal(f, t.last)
750+ if !force && sameFrame && t.sameTargets(ts) {
751+ return nil
752+ }
753+ t.snap.publish(ts) // before the line: whoever sees the line finds its targets
754+ if !force && sameFrame {
702 return nil755 return nil
703 }756 }
704 t.frame, t.last = t.last, t.frame757 t.frame, t.last = t.last, t.frame
@@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error {
708 return nil761 return nil
709 }762 }
710 763
764+// sameTargets reports whether the published snapshot equals ts.
765+func (t *Tray) sameTargets(ts []Target) bool {
766+ t.snap.mu.Lock()
767+ defer t.snap.mu.Unlock()
768+ return slices.Equal(t.snap.targets, ts)
769+}
770+
711 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"771 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">772 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713 <node>773 <node>
M internal/tray/tray_test.go +112 -2
@@ -3,9 +3,11 @@ package tray
33 import (
44 "bufio"
55 "context"
6+ "fmt"
67 "io"
78 "log/slog"
89 "os/exec"
10+ "slices"
911 "strings"
1012 "testing"
1113 "time"
@@ -60,12 +62,20 @@ type fakeItem struct {
6062 cur [3]string // id, icon name, status
6163 pixel [4]byte // the one ARGB pixel of its icon
6264 closed chan struct{}
65+
66+ // calls records the StatusNotifierItem actions it received, as
67+ // "Method(args)". The fields below are set through run.
68+ calls chan string
69+ unknown string // the method it answers with UnknownMethod
70+ slow string // the method it answers only after slowFor
71+ slowFor time.Duration // how long slow takes
6372 }
6473
6574 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
6675 c := dial(t, addr)
6776 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{})}
77+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
78+ calls: make(chan string, 32)}
6979 go f.serve()
7080 return f
7181 }
@@ -96,6 +106,12 @@ func (f *fakeItem) serve() {
96106 (<-f.cmds)()
97107 continue
98108 }
109+ if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
110+ if !f.action(m) {
111+ return
112+ }
113+ continue
114+ }
99115 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100116 continue
101117 }
@@ -123,6 +139,34 @@ func (f *fakeItem) serve() {
123139 }
124140 }
125141
142+// action records and answers one StatusNotifierItem method call. It reports
143+// false when the connection is gone.
144+func (f *fakeItem) action(m *zerobus.Message) bool {
145+ r := m.Body()
146+ var rec string
147+ switch m.Member {
148+ case "Activate", "SecondaryActivate", "ContextMenu":
149+ x, y := r.Int32(), r.Int32()
150+ rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
151+ case "Scroll":
152+ delta, orientation := r.Int32(), r.Str()
153+ rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
154+ default:
155+ return true
156+ }
157+ f.calls <- rec
158+ if m.Member == f.slow {
159+ time.Sleep(f.slowFor)
160+ }
161+ if m.Member == f.unknown {
162+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
163+ } else {
164+ f.c.NewReply(m, "")
165+ }
166+ _, err := f.c.Send()
167+ return err == nil
168+}
169+
126170 // register registers the item with the watcher, by bus name.
127171 func (f *fakeItem) register(t *testing.T) {
128172 f.run(t, func() {
@@ -185,6 +229,12 @@ func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string
185229 // startTray runs a tray on the bus. Cancel it and receive from the channel
186230 // to stop it; the test cleanup does both when the test did not.
187231 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
232+ return startTrayWith(t, addr, nil, nil)
233+}
234+
235+// startTrayWith is startTray with the bar's input and a dialer for the
236+// control connection.
237+func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
188238 pr, pw := io.Pipe()
189239 ctx, cancel := context.WithCancel(context.Background())
190240 done := make(chan error, 1)
@@ -193,7 +243,7 @@ func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan er
193243 opt := Options{
194244 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195245 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196- Out: pw,
246+ Out: pw, In: in, Dial: dialer,
197247 }
198248 c := dial(t, addr)
199249 go func() {
@@ -540,3 +590,63 @@ func TestBetterPixmapSize(t *testing.T) {
540590 }
541591 }
542592 }
593+
594+func TestRenderPublishesTargets(t *testing.T) {
595+ var buf strings.Builder
596+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
597+ tr.items = []*item{
598+ {loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
599+ {loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
600+ {loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
601+ }
602+ if err := tr.render(false); err != nil {
603+ t.Fatal(err)
604+ }
605+ want := []Target{
606+ {Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
607+ {Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
608+ }
609+ if got := tr.snap.targets; !slices.Equal(got, want) {
610+ t.Fatalf("targets = %+v, want %+v", got, want)
611+ }
612+ for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
613+ if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
614+ t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
615+ }
616+ }
617+ for _, col := range []int{-1, 1, 3} {
618+ if _, ok := tr.target(col); ok {
619+ t.Errorf("target(%d) found an icon in the gap or past the line", col)
620+ }
621+ }
622+ // A hidden item leaves no target, and a wide icon moves the next one.
623+ tr.items[0].icon = "界"
624+ if err := tr.render(false); err != nil {
625+ t.Fatal(err)
626+ }
627+ if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
628+ t.Fatalf("targets after a wide icon = %+v", got)
629+ }
630+}
631+
632+// A change of menu path alone republishes the targets without a new line.
633+func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
634+ var buf strings.Builder
635+ tr := &Tray{opt: Options{Out: &buf}}
636+ tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
637+ for range 2 {
638+ if err := tr.render(false); err != nil {
639+ t.Fatal(err)
640+ }
641+ }
642+ tr.items[0].menu = "/menu"
643+ if err := tr.render(false); err != nil {
644+ t.Fatal(err)
645+ }
646+ if n := strings.Count(buf.String(), "\n"); n != 1 {
647+ t.Fatalf("%d lines written, want 1", n)
648+ }
649+ if tg, _ := tr.target(0); tg.Menu != "/menu" {
650+ t.Fatalf("target = %+v", tg)
651+ }
652+}
@@ -3,9 +3,11 @@ package tray
3 import (3 import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6+ "fmt"
6 "io"7 "io"
7 "log/slog"8 "log/slog"
8 "os/exec"9 "os/exec"
10+ "slices"
9 "strings"11 "strings"
10 "testing"12 "testing"
11 "time"13 "time"
@@ -60,12 +62,20 @@ type fakeItem struct {
60 cur [3]string // id, icon name, status62 cur [3]string // id, icon name, status
61 pixel [4]byte // the one ARGB pixel of its icon63 pixel [4]byte // the one ARGB pixel of its icon
62 closed chan struct{}64 closed chan struct{}
65+
66+ // calls records the StatusNotifierItem actions it received, as
67+ // "Method(args)". The fields below are set through run.
68+ calls chan string
69+ unknown string // the method it answers with UnknownMethod
70+ slow string // the method it answers only after slowFor
71+ slowFor time.Duration // how long slow takes
63 }72 }
64 73
65 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {74 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
66 c := dial(t, addr)75 c := dial(t, addr)
67 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),76 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{})}77+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
78+ calls: make(chan string, 32)}
69 go f.serve()79 go f.serve()
70 return f80 return f
71 }81 }
@@ -96,6 +106,12 @@ func (f *fakeItem) serve() {
96 (<-f.cmds)()106 (<-f.cmds)()
97 continue107 continue
98 }108 }
109+ if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
110+ if !f.action(m) {
111+ return
112+ }
113+ continue
114+ }
99 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {115 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100 continue116 continue
101 }117 }
@@ -123,6 +139,34 @@ func (f *fakeItem) serve() {
123 }139 }
124 }140 }
125 141
142+// action records and answers one StatusNotifierItem method call. It reports
143+// false when the connection is gone.
144+func (f *fakeItem) action(m *zerobus.Message) bool {
145+ r := m.Body()
146+ var rec string
147+ switch m.Member {
148+ case "Activate", "SecondaryActivate", "ContextMenu":
149+ x, y := r.Int32(), r.Int32()
150+ rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
151+ case "Scroll":
152+ delta, orientation := r.Int32(), r.Str()
153+ rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
154+ default:
155+ return true
156+ }
157+ f.calls <- rec
158+ if m.Member == f.slow {
159+ time.Sleep(f.slowFor)
160+ }
161+ if m.Member == f.unknown {
162+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
163+ } else {
164+ f.c.NewReply(m, "")
165+ }
166+ _, err := f.c.Send()
167+ return err == nil
168+}
169+
126 // register registers the item with the watcher, by bus name.170 // register registers the item with the watcher, by bus name.
127 func (f *fakeItem) register(t *testing.T) {171 func (f *fakeItem) register(t *testing.T) {
128 f.run(t, func() {172 f.run(t, func() {
@@ -185,6 +229,12 @@ func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string
185 // startTray runs a tray on the bus. Cancel it and receive from the channel229 // startTray runs a tray on the bus. Cancel it and receive from the channel
186 // to stop it; the test cleanup does both when the test did not.230 // to stop it; the test cleanup does both when the test did not.
187 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {231 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
232+ return startTrayWith(t, addr, nil, nil)
233+}
234+
235+// startTrayWith is startTray with the bar's input and a dialer for the
236+// control connection.
237+func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
188 pr, pw := io.Pipe()238 pr, pw := io.Pipe()
189 ctx, cancel := context.WithCancel(context.Background())239 ctx, cancel := context.WithCancel(context.Background())
190 done := make(chan error, 1)240 done := make(chan error, 1)
@@ -193,7 +243,7 @@ func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan er
193 opt := Options{243 opt := Options{
194 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),244 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},245 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196- Out: pw,246+ Out: pw, In: in, Dial: dialer,
197 }247 }
198 c := dial(t, addr)248 c := dial(t, addr)
199 go func() {249 go func() {
@@ -540,3 +590,63 @@ func TestBetterPixmapSize(t *testing.T) {
540 }590 }
541 }591 }
542 }592 }
593+
594+func TestRenderPublishesTargets(t *testing.T) {
595+ var buf strings.Builder
596+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
597+ tr.items = []*item{
598+ {loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
599+ {loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
600+ {loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
601+ }
602+ if err := tr.render(false); err != nil {
603+ t.Fatal(err)
604+ }
605+ want := []Target{
606+ {Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
607+ {Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
608+ }
609+ if got := tr.snap.targets; !slices.Equal(got, want) {
610+ t.Fatalf("targets = %+v, want %+v", got, want)
611+ }
612+ for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
613+ if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
614+ t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
615+ }
616+ }
617+ for _, col := range []int{-1, 1, 3} {
618+ if _, ok := tr.target(col); ok {
619+ t.Errorf("target(%d) found an icon in the gap or past the line", col)
620+ }
621+ }
622+ // A hidden item leaves no target, and a wide icon moves the next one.
623+ tr.items[0].icon = "界"
624+ if err := tr.render(false); err != nil {
625+ t.Fatal(err)
626+ }
627+ if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
628+ t.Fatalf("targets after a wide icon = %+v", got)
629+ }
630+}
631+
632+// A change of menu path alone republishes the targets without a new line.
633+func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
634+ var buf strings.Builder
635+ tr := &Tray{opt: Options{Out: &buf}}
636+ tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
637+ for range 2 {
638+ if err := tr.render(false); err != nil {
639+ t.Fatal(err)
640+ }
641+ }
642+ tr.items[0].menu = "/menu"
643+ if err := tr.render(false); err != nil {
644+ t.Fatal(err)
645+ }
646+ if n := strings.Count(buf.String(), "\n"); n != 1 {
647+ t.Fatalf("%d lines written, want 1", n)
648+ }
649+ if tg, _ := tr.target(0); tg.Menu != "/menu" {
650+ t.Fatalf("target = %+v", tg)
651+ }
652+}

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