detached eaec412

feat(tray): dbusmenu menus

A right click (and the left-click fallbacks) on an item with a dbusmenu calls AboutToShow, Event(opened) and GetLayout, decodes the tree (mnemonics stripped, invisible items dropped, depth and item limits, children with another signature skipped) and sends it as a menu control line. menu-activate sends Event(clicked) and menu-closed sends Event(closed), only for the last menu and for ids it holds. Items without a dbusmenu keep ContextMenu.
commit
eaec4121d749ec2a314882fff9cb8544750983e7
author
brice · · 2026-10-09 05:29 UTC
parent
a4dc00a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +694 -11
M internal/tray/control.go +133 -10
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "io"
1010 "log/slog"
11+ "slices"
1112 "strconv"
1213 "strings"
1314 "sync"
@@ -199,9 +200,10 @@ type control struct {
199200 conn *zerobus.Conn
200201 done bool
201202
202- // The tooltip state belongs to the run goroutine.
203+ // The tooltip and menu state belong to the run goroutine.
203204 tipTimer *time.Timer
204205 hov hover
206+ menu *lastMenu
205207 }
206208
207209 // hover is the icon the pointer rests on, and whether its tooltip is shown.
@@ -311,18 +313,22 @@ func (c *control) handle(cmd command) {
311313 switch cmd.button {
312314 case btnLeft:
313315 if tg.IsMenu {
314- c.contextMenu(tg)
316+ c.openMenu(tg, cmd.token)
315317 return
316318 }
317319 err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
318320 if isUnknownMethod(err) {
319- c.contextMenu(tg)
321+ c.openMenu(tg, cmd.token)
320322 }
321323 case btnMiddle:
322324 c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
323325 case btnRight:
324- c.contextMenu(tg)
326+ c.openMenu(tg, cmd.token)
325327 }
328+ case "menu-activate":
329+ c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id
330+ case "menu-closed":
331+ c.menuClosed(cmd.token)
326332 case "scroll":
327333 tg, ok := c.t.target(cmd.col)
328334 if !ok {
@@ -383,7 +389,18 @@ func (c *control) emit(v any) {
383389 }
384390 buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
385391 buf.WriteString(controlSuffix)
386- if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil {
392+ c.write(buf.Bytes())
393+}
394+
395+// writeLine writes the control line whose JSON is b.
396+func (c *control) writeLine(b []byte) {
397+ line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix))
398+ line = append(append(append(line, controlPrefix...), b...), controlSuffix...)
399+ c.write(line)
400+}
401+
402+func (c *control) write(line []byte) {
403+ if _, err := c.t.opt.Out.Write(line); err != nil {
387404 c.log.Debug("tray: cannot write a control line", "err", err)
388405 }
389406 }
@@ -402,6 +419,12 @@ func isUnknownMethod(err error) bool {
402419 // reply is returned as is; a transport failure or a timeout drops the
403420 // connection and is logged.
404421 func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
422+ return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil)
423+}
424+
425+// callOn is call for any interface. reply, if set, reads the body of a
426+// successful reply; it must not keep what it reads.
427+func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error {
405428 conn, err := c.conn2()
406429 if err != nil {
407430 if !errors.Is(err, zerobus.ErrClosed) {
@@ -409,12 +432,17 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
409432 }
410433 return err
411434 }
412- args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
435+ args(conn.NewCall(dest, path, iface, member, sig))
413436 timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
414- _, err = conn.Call()
437+ m, err := conn.Call()
438+ if err == nil && reply != nil {
439+ reply(m)
440+ }
415441 expired := !timer.Stop()
416- var reply *zerobus.Error
417- if errors.As(err, &reply) && !expired {
442+ var refused *zerobus.Error
443+ if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired {
444+ // An error reply, or a name or path the library would not send:
445+ // the connection is fine.
418446 c.log.Debug("tray: the application refused", "method", member, "err", err)
419447 return err
420448 }
@@ -427,8 +455,103 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
427455 stopping := c.done
428456 c.mu.Unlock()
429457 if !stopping {
430- c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
458+ c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err)
431459 }
432460 }
433461 return err
434462 }
463+
464+// transportFailed reports whether err means the call did not complete, as
465+// opposed to the application answering with an error.
466+func transportFailed(err error) bool {
467+ var refused *zerobus.Error
468+ return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid)
469+}
470+
471+// openMenu shows the application's menu for the press numbered token: its
472+// dbusmenu when it has one, else it asks the application to show its own.
473+func (c *control) openMenu(tg Target, token uint32) {
474+ if tg.Menu == "" || tg.Menu == noMenuPath {
475+ c.contextMenu(tg)
476+ return
477+ }
478+ // Ignore refusals: many applications do not implement these.
479+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
480+ if transportFailed(err) {
481+ return
482+ }
483+ err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
484+ if transportFailed(err) {
485+ return
486+ }
487+ var items []menuItem
488+ var ids []int32
489+ var decodeErr error
490+ err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
491+ e.Int32(0)
492+ e.Int32(-1)
493+ a := e.BeginArray('s')
494+ e.EndArray(a)
495+ }, func(m *zerobus.Message) {
496+ if m.Signature != "u"+layoutSig {
497+ decodeErr = errors.New("unexpected layout signature " + m.Signature)
498+ return
499+ }
500+ items, ids, decodeErr = decodeLayout(m.Body())
501+ })
502+ if err != nil {
503+ return
504+ }
505+ if decodeErr != nil {
506+ c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
507+ return
508+ }
509+ if len(items) == 0 {
510+ c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
511+ return
512+ }
513+ line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
514+ if encErr != nil {
515+ c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr)
516+ return
517+ }
518+ c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
519+ c.writeLine(line)
520+}
521+
522+// menuEvent sends a dbusmenu Event with no data.
523+func (c *control) menuEvent(dest, path string, id int32, event string) error {
524+ return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) {
525+ e.Int32(id)
526+ e.Str(event)
527+ e.Variant("i")
528+ e.Int32(0)
529+ e.Uint32(0)
530+ }, nil)
531+}
532+
533+// menuActivate carries out a choice of the bar: only for the menu it was last
534+// given, and only for an item that menu holds.
535+func (c *control) menuActivate(token uint32, id int32) {
536+ m := c.menu
537+ if m == nil || m.token != token {
538+ c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token)
539+ return
540+ }
541+ if _, found := slices.BinarySearch(m.ids, id); !found {
542+ c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id)
543+ return
544+ }
545+ c.menu = nil
546+ c.menuEvent(m.dest, m.path, id, "clicked")
547+}
548+
549+// menuClosed tells the application its menu is closed.
550+func (c *control) menuClosed(token uint32) {
551+ m := c.menu
552+ if m == nil || m.token != token {
553+ return
554+ }
555+ c.menu = nil
556+ c.menuEvent(m.dest, m.path, 0, "closed")
557+}
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "io"9 "io"
10 "log/slog"10 "log/slog"
11+ "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
13 "sync"14 "sync"
@@ -199,9 +200,10 @@ type control struct {
199 conn *zerobus.Conn200 conn *zerobus.Conn
200 done bool201 done bool
201 202
202- // The tooltip state belongs to the run goroutine.203+ // The tooltip and menu state belong to the run goroutine.
203 tipTimer *time.Timer204 tipTimer *time.Timer
204 hov hover205 hov hover
206+ menu *lastMenu
205 }207 }
206 208
207 // hover is the icon the pointer rests on, and whether its tooltip is shown.209 // hover is the icon the pointer rests on, and whether its tooltip is shown.
@@ -311,18 +313,22 @@ func (c *control) handle(cmd command) {
311 switch cmd.button {313 switch cmd.button {
312 case btnLeft:314 case btnLeft:
313 if tg.IsMenu {315 if tg.IsMenu {
314- c.contextMenu(tg)316+ c.openMenu(tg, cmd.token)
315 return317 return
316 }318 }
317 err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })319 err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
318 if isUnknownMethod(err) {320 if isUnknownMethod(err) {
319- c.contextMenu(tg)321+ c.openMenu(tg, cmd.token)
320 }322 }
321 case btnMiddle:323 case btnMiddle:
322 c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })324 c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
323 case btnRight:325 case btnRight:
324- c.contextMenu(tg)326+ c.openMenu(tg, cmd.token)
325 }327 }
328+ case "menu-activate":
329+ c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id
330+ case "menu-closed":
331+ c.menuClosed(cmd.token)
326 case "scroll":332 case "scroll":
327 tg, ok := c.t.target(cmd.col)333 tg, ok := c.t.target(cmd.col)
328 if !ok {334 if !ok {
@@ -383,7 +389,18 @@ func (c *control) emit(v any) {
383 }389 }
384 buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first390 buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
385 buf.WriteString(controlSuffix)391 buf.WriteString(controlSuffix)
386- if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil {392+ c.write(buf.Bytes())
393+}
394+
395+// writeLine writes the control line whose JSON is b.
396+func (c *control) writeLine(b []byte) {
397+ line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix))
398+ line = append(append(append(line, controlPrefix...), b...), controlSuffix...)
399+ c.write(line)
400+}
401+
402+func (c *control) write(line []byte) {
403+ if _, err := c.t.opt.Out.Write(line); err != nil {
387 c.log.Debug("tray: cannot write a control line", "err", err)404 c.log.Debug("tray: cannot write a control line", "err", err)
388 }405 }
389 }406 }
@@ -402,6 +419,12 @@ func isUnknownMethod(err error) bool {
402 // reply is returned as is; a transport failure or a timeout drops the419 // reply is returned as is; a transport failure or a timeout drops the
403 // connection and is logged.420 // connection and is logged.
404 func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {421 func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
422+ return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil)
423+}
424+
425+// callOn is call for any interface. reply, if set, reads the body of a
426+// successful reply; it must not keep what it reads.
427+func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error {
405 conn, err := c.conn2()428 conn, err := c.conn2()
406 if err != nil {429 if err != nil {
407 if !errors.Is(err, zerobus.ErrClosed) {430 if !errors.Is(err, zerobus.ErrClosed) {
@@ -409,12 +432,17 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
409 }432 }
410 return err433 return err
411 }434 }
412- args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))435+ args(conn.NewCall(dest, path, iface, member, sig))
413 timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })436 timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
414- _, err = conn.Call()437+ m, err := conn.Call()
438+ if err == nil && reply != nil {
439+ reply(m)
440+ }
415 expired := !timer.Stop()441 expired := !timer.Stop()
416- var reply *zerobus.Error442+ var refused *zerobus.Error
417- if errors.As(err, &reply) && !expired {443+ if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired {
444+ // An error reply, or a name or path the library would not send:
445+ // the connection is fine.
418 c.log.Debug("tray: the application refused", "method", member, "err", err)446 c.log.Debug("tray: the application refused", "method", member, "err", err)
419 return err447 return err
420 }448 }
@@ -427,8 +455,103 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
427 stopping := c.done455 stopping := c.done
428 c.mu.Unlock()456 c.mu.Unlock()
429 if !stopping {457 if !stopping {
430- c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)458+ c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err)
431 }459 }
432 }460 }
433 return err461 return err
434 }462 }
463+
464+// transportFailed reports whether err means the call did not complete, as
465+// opposed to the application answering with an error.
466+func transportFailed(err error) bool {
467+ var refused *zerobus.Error
468+ return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid)
469+}
470+
471+// openMenu shows the application's menu for the press numbered token: its
472+// dbusmenu when it has one, else it asks the application to show its own.
473+func (c *control) openMenu(tg Target, token uint32) {
474+ if tg.Menu == "" || tg.Menu == noMenuPath {
475+ c.contextMenu(tg)
476+ return
477+ }
478+ // Ignore refusals: many applications do not implement these.
479+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
480+ if transportFailed(err) {
481+ return
482+ }
483+ err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
484+ if transportFailed(err) {
485+ return
486+ }
487+ var items []menuItem
488+ var ids []int32
489+ var decodeErr error
490+ err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
491+ e.Int32(0)
492+ e.Int32(-1)
493+ a := e.BeginArray('s')
494+ e.EndArray(a)
495+ }, func(m *zerobus.Message) {
496+ if m.Signature != "u"+layoutSig {
497+ decodeErr = errors.New("unexpected layout signature " + m.Signature)
498+ return
499+ }
500+ items, ids, decodeErr = decodeLayout(m.Body())
501+ })
502+ if err != nil {
503+ return
504+ }
505+ if decodeErr != nil {
506+ c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
507+ return
508+ }
509+ if len(items) == 0 {
510+ c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
511+ return
512+ }
513+ line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
514+ if encErr != nil {
515+ c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr)
516+ return
517+ }
518+ c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
519+ c.writeLine(line)
520+}
521+
522+// menuEvent sends a dbusmenu Event with no data.
523+func (c *control) menuEvent(dest, path string, id int32, event string) error {
524+ return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) {
525+ e.Int32(id)
526+ e.Str(event)
527+ e.Variant("i")
528+ e.Int32(0)
529+ e.Uint32(0)
530+ }, nil)
531+}
532+
533+// menuActivate carries out a choice of the bar: only for the menu it was last
534+// given, and only for an item that menu holds.
535+func (c *control) menuActivate(token uint32, id int32) {
536+ m := c.menu
537+ if m == nil || m.token != token {
538+ c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token)
539+ return
540+ }
541+ if _, found := slices.BinarySearch(m.ids, id); !found {
542+ c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id)
543+ return
544+ }
545+ c.menu = nil
546+ c.menuEvent(m.dest, m.path, id, "clicked")
547+}
548+
549+// menuClosed tells the application its menu is closed.
550+func (c *control) menuClosed(token uint32) {
551+ m := c.menu
552+ if m == nil || m.token != token {
553+ return
554+ }
555+ c.menu = nil
556+ c.menuEvent(m.dest, m.path, 0, "closed")
557+}
A internal/tray/dbusmenu.go +217 -0
@@ -0,0 +1,217 @@
1+package tray
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/zerobus"
10+)
11+
12+const (
13+ menuIface = "com.canonical.dbusmenu"
14+
15+ // The limits of the menu the bar accepts (module.ParseControl).
16+ maxMenuDepth = 8
17+ maxMenuItems = 512
18+ maxMenuLabel = 256
19+ // maxMenuLine keeps the control line under the bar's 64 KiB, with room
20+ // for the fields around the items.
21+ maxMenuLine = 60 << 10
22+
23+ layoutSig = "(ia{sv}av)"
24+
25+ // noMenuPath is what some toolkits put in Menu to say "no menu".
26+ noMenuPath = "/NO_DBUSMENU"
27+)
28+
29+// menuItem is one entry of the menu sent to the bar.
30+type menuItem struct {
31+ ID int32 `json:"id"`
32+ Label string `json:"label"`
33+ Kind string `json:"kind"`
34+ Enabled bool `json:"enabled"`
35+ Checked bool `json:"checked"`
36+ Items []menuItem `json:"items,omitempty"`
37+}
38+
39+// menuLine is the control line that opens a menu.
40+type menuLine struct {
41+ Type string `json:"type"`
42+ Col int `json:"col"`
43+ Width int `json:"width"`
44+ Click uint32 `json:"click"`
45+ Items []menuItem `json:"items"`
46+}
47+
48+// lastMenu is the menu the bar was last given: what a menu-activate or
49+// menu-closed line may refer to.
50+type lastMenu struct {
51+ token uint32
52+ dest string
53+ path string
54+ ids []int32 // the ids the bar can choose from, sorted
55+}
56+
57+// layoutDecoder reads the reply of GetLayout.
58+type layoutDecoder struct {
59+ r *zerobus.Reader
60+ count int
61+ ids []int32
62+}
63+
64+// decodeLayout reads a GetLayout reply body, "u(ia{sv}av)": the top level
65+// items, and the ids among them. Invisible items are dropped; the tree is
66+// cut at maxMenuDepth levels and maxMenuItems items. Children whose signature
67+// is not (ia{sv}av) are skipped.
68+func decodeLayout(r *zerobus.Reader) (items []menuItem, ids []int32, err error) {
69+ r.Uint32() // revision
70+ d := &layoutDecoder{r: r}
71+ root, _ := d.node(0)
72+ if err = r.Err(); err != nil {
73+ return nil, nil, err
74+ }
75+ slices.Sort(d.ids)
76+ return root.Items, d.ids, nil
77+}
78+
79+// node reads one (ia{sv}av) struct at the given depth (the root is 0). It
80+// reports false for an item that is not kept: invisible, or over a limit.
81+func (d *layoutDecoder) node(depth int) (menuItem, bool) {
82+ r := d.r
83+ r.Struct()
84+ it := menuItem{ID: r.Int32(), Kind: "normal", Enabled: true}
85+ visible := true
86+ toggle := ""
87+ checked := false
88+ end := r.Array('{')
89+ for r.More(end) {
90+ r.Struct()
91+ key := r.Str()
92+ sig := r.Variant()
93+ switch {
94+ case key == "label" && sig == "s":
95+ it.Label = cleanLabel(r.Str())
96+ case key == "enabled" && sig == "b":
97+ it.Enabled = r.Bool()
98+ case key == "visible" && sig == "b":
99+ visible = r.Bool()
100+ case key == "type" && sig == "s":
101+ if r.Str() == "separator" {
102+ it.Kind = "separator"
103+ }
104+ case key == "toggle-type" && sig == "s":
105+ toggle = r.Str()
106+ case key == "toggle-state" && sig == "i":
107+ checked = r.Int32() == 1
108+ default:
109+ r.Skip(sig)
110+ }
111+ }
112+ if it.Kind != "separator" {
113+ switch toggle {
114+ case "checkmark":
115+ it.Kind = "check"
116+ case "radio":
117+ it.Kind = "radio"
118+ }
119+ it.Checked = checked && it.Kind != "normal"
120+ }
121+ kids := r.Array('v')
122+ keep := depth == 0 || (visible && it.ID >= 0 && d.count < maxMenuItems)
123+ if depth > 0 && keep {
124+ d.count++
125+ d.ids = append(d.ids, it.ID)
126+ }
127+ if !keep || depth >= maxMenuDepth {
128+ r.SkipTo(kids)
129+ return it, keep
130+ }
131+ for r.More(kids) {
132+ sig := r.Variant()
133+ if sig != layoutSig {
134+ r.Skip(sig)
135+ continue
136+ }
137+ if child, ok := d.node(depth + 1); ok {
138+ it.Items = append(it.Items, child)
139+ }
140+ }
141+ return it, true
142+}
143+
144+// cleanLabel strips the mnemonic markers of a dbusmenu label: "_x" is "x" and
145+// "__" is "_". The control characters are the bar's to remove.
146+func cleanLabel(s string) string {
147+ if !strings.Contains(s, "_") {
148+ return cutRunes(s, maxMenuLabel)
149+ }
150+ var b strings.Builder
151+ for i := 0; i < len(s); i++ {
152+ if s[i] != '_' {
153+ b.WriteByte(s[i])
154+ continue
155+ }
156+ if i+1 < len(s) {
157+ i++
158+ b.WriteByte(s[i]) // "_x" and "__" both keep the second byte
159+ }
160+ }
161+ return cutRunes(b.String(), maxMenuLabel)
162+}
163+
164+// encodeMenu builds the control line for a menu. When the JSON would exceed
165+// the bar's limit, items are dropped from the end of the menu (in reading
166+// order) until it fits.
167+func encodeMenu(l menuLine) ([]byte, []int32, error) {
168+ total := countItems(l.Items)
169+ for {
170+ b, err := json.Marshal(l)
171+ if err != nil {
172+ return nil, nil, err
173+ }
174+ if len(b) <= maxMenuLine {
175+ var ids []int32
176+ collectIDs(l.Items, &ids)
177+ slices.Sort(ids)
178+ return b, ids, nil
179+ }
180+ if total <= 1 {
181+ return nil, nil, errors.New("tray: the menu is too large")
182+ }
183+ total = total * 3 / 4
184+ keep := total
185+ l.Items = keepFirst(l.Items, &keep)
186+ }
187+}
188+
189+func countItems(items []menuItem) int {
190+ n := len(items)
191+ for i := range items {
192+ n += countItems(items[i].Items)
193+ }
194+ return n
195+}
196+
197+func collectIDs(items []menuItem, ids *[]int32) {
198+ for i := range items {
199+ *ids = append(*ids, items[i].ID)
200+ collectIDs(items[i].Items, ids)
201+ }
202+}
203+
204+// keepFirst keeps the first *n items in reading order, parents before their
205+// children, and returns the trimmed list.
206+func keepFirst(items []menuItem, n *int) []menuItem {
207+ var out []menuItem
208+ for _, it := range items {
209+ if *n <= 0 {
210+ break
211+ }
212+ *n--
213+ it.Items = keepFirst(it.Items, n)
214+ out = append(out, it)
215+ }
216+ return out
217+}
@@ -0,0 +1,217 @@
1+package tray
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/zerobus"
10+)
11+
12+const (
13+ menuIface = "com.canonical.dbusmenu"
14+
15+ // The limits of the menu the bar accepts (module.ParseControl).
16+ maxMenuDepth = 8
17+ maxMenuItems = 512
18+ maxMenuLabel = 256
19+ // maxMenuLine keeps the control line under the bar's 64 KiB, with room
20+ // for the fields around the items.
21+ maxMenuLine = 60 << 10
22+
23+ layoutSig = "(ia{sv}av)"
24+
25+ // noMenuPath is what some toolkits put in Menu to say "no menu".
26+ noMenuPath = "/NO_DBUSMENU"
27+)
28+
29+// menuItem is one entry of the menu sent to the bar.
30+type menuItem struct {
31+ ID int32 `json:"id"`
32+ Label string `json:"label"`
33+ Kind string `json:"kind"`
34+ Enabled bool `json:"enabled"`
35+ Checked bool `json:"checked"`
36+ Items []menuItem `json:"items,omitempty"`
37+}
38+
39+// menuLine is the control line that opens a menu.
40+type menuLine struct {
41+ Type string `json:"type"`
42+ Col int `json:"col"`
43+ Width int `json:"width"`
44+ Click uint32 `json:"click"`
45+ Items []menuItem `json:"items"`
46+}
47+
48+// lastMenu is the menu the bar was last given: what a menu-activate or
49+// menu-closed line may refer to.
50+type lastMenu struct {
51+ token uint32
52+ dest string
53+ path string
54+ ids []int32 // the ids the bar can choose from, sorted
55+}
56+
57+// layoutDecoder reads the reply of GetLayout.
58+type layoutDecoder struct {
59+ r *zerobus.Reader
60+ count int
61+ ids []int32
62+}
63+
64+// decodeLayout reads a GetLayout reply body, "u(ia{sv}av)": the top level
65+// items, and the ids among them. Invisible items are dropped; the tree is
66+// cut at maxMenuDepth levels and maxMenuItems items. Children whose signature
67+// is not (ia{sv}av) are skipped.
68+func decodeLayout(r *zerobus.Reader) (items []menuItem, ids []int32, err error) {
69+ r.Uint32() // revision
70+ d := &layoutDecoder{r: r}
71+ root, _ := d.node(0)
72+ if err = r.Err(); err != nil {
73+ return nil, nil, err
74+ }
75+ slices.Sort(d.ids)
76+ return root.Items, d.ids, nil
77+}
78+
79+// node reads one (ia{sv}av) struct at the given depth (the root is 0). It
80+// reports false for an item that is not kept: invisible, or over a limit.
81+func (d *layoutDecoder) node(depth int) (menuItem, bool) {
82+ r := d.r
83+ r.Struct()
84+ it := menuItem{ID: r.Int32(), Kind: "normal", Enabled: true}
85+ visible := true
86+ toggle := ""
87+ checked := false
88+ end := r.Array('{')
89+ for r.More(end) {
90+ r.Struct()
91+ key := r.Str()
92+ sig := r.Variant()
93+ switch {
94+ case key == "label" && sig == "s":
95+ it.Label = cleanLabel(r.Str())
96+ case key == "enabled" && sig == "b":
97+ it.Enabled = r.Bool()
98+ case key == "visible" && sig == "b":
99+ visible = r.Bool()
100+ case key == "type" && sig == "s":
101+ if r.Str() == "separator" {
102+ it.Kind = "separator"
103+ }
104+ case key == "toggle-type" && sig == "s":
105+ toggle = r.Str()
106+ case key == "toggle-state" && sig == "i":
107+ checked = r.Int32() == 1
108+ default:
109+ r.Skip(sig)
110+ }
111+ }
112+ if it.Kind != "separator" {
113+ switch toggle {
114+ case "checkmark":
115+ it.Kind = "check"
116+ case "radio":
117+ it.Kind = "radio"
118+ }
119+ it.Checked = checked && it.Kind != "normal"
120+ }
121+ kids := r.Array('v')
122+ keep := depth == 0 || (visible && it.ID >= 0 && d.count < maxMenuItems)
123+ if depth > 0 && keep {
124+ d.count++
125+ d.ids = append(d.ids, it.ID)
126+ }
127+ if !keep || depth >= maxMenuDepth {
128+ r.SkipTo(kids)
129+ return it, keep
130+ }
131+ for r.More(kids) {
132+ sig := r.Variant()
133+ if sig != layoutSig {
134+ r.Skip(sig)
135+ continue
136+ }
137+ if child, ok := d.node(depth + 1); ok {
138+ it.Items = append(it.Items, child)
139+ }
140+ }
141+ return it, true
142+}
143+
144+// cleanLabel strips the mnemonic markers of a dbusmenu label: "_x" is "x" and
145+// "__" is "_". The control characters are the bar's to remove.
146+func cleanLabel(s string) string {
147+ if !strings.Contains(s, "_") {
148+ return cutRunes(s, maxMenuLabel)
149+ }
150+ var b strings.Builder
151+ for i := 0; i < len(s); i++ {
152+ if s[i] != '_' {
153+ b.WriteByte(s[i])
154+ continue
155+ }
156+ if i+1 < len(s) {
157+ i++
158+ b.WriteByte(s[i]) // "_x" and "__" both keep the second byte
159+ }
160+ }
161+ return cutRunes(b.String(), maxMenuLabel)
162+}
163+
164+// encodeMenu builds the control line for a menu. When the JSON would exceed
165+// the bar's limit, items are dropped from the end of the menu (in reading
166+// order) until it fits.
167+func encodeMenu(l menuLine) ([]byte, []int32, error) {
168+ total := countItems(l.Items)
169+ for {
170+ b, err := json.Marshal(l)
171+ if err != nil {
172+ return nil, nil, err
173+ }
174+ if len(b) <= maxMenuLine {
175+ var ids []int32
176+ collectIDs(l.Items, &ids)
177+ slices.Sort(ids)
178+ return b, ids, nil
179+ }
180+ if total <= 1 {
181+ return nil, nil, errors.New("tray: the menu is too large")
182+ }
183+ total = total * 3 / 4
184+ keep := total
185+ l.Items = keepFirst(l.Items, &keep)
186+ }
187+}
188+
189+func countItems(items []menuItem) int {
190+ n := len(items)
191+ for i := range items {
192+ n += countItems(items[i].Items)
193+ }
194+ return n
195+}
196+
197+func collectIDs(items []menuItem, ids *[]int32) {
198+ for i := range items {
199+ *ids = append(*ids, items[i].ID)
200+ collectIDs(items[i].Items, ids)
201+ }
202+}
203+
204+// keepFirst keeps the first *n items in reading order, parents before their
205+// children, and returns the trimmed list.
206+func keepFirst(items []menuItem, n *int) []menuItem {
207+ var out []menuItem
208+ for _, it := range items {
209+ if *n <= 0 {
210+ break
211+ }
212+ *n--
213+ it.Items = keepFirst(it.Items, n)
214+ out = append(out, it)
215+ }
216+ return out
217+}
A internal/tray/dbusmenu_test.go +327 -0
@@ -0,0 +1,327 @@
1+package tray
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerobus"
10+
11+ "github.com/bnema/neferbar/internal/module"
12+)
13+
14+// tprop is one property of a test menu node.
15+type tprop struct {
16+ k string
17+ v any // string, bool or int32
18+}
19+
20+// tnode is a node of the layout a fake application serves.
21+type tnode struct {
22+ id int32
23+ props []tprop
24+ kids []tnode
25+ // junk is written as an extra child with a signature that is not a layout node.
26+ junk bool
27+}
28+
29+func (n *tnode) encode(e *zerobus.Encoder) {
30+ e.Struct()
31+ e.Int32(n.id)
32+ a := e.BeginArray('{')
33+ for _, p := range n.props {
34+ e.Struct()
35+ e.Str(p.k)
36+ switch v := p.v.(type) {
37+ case string:
38+ e.Variant("s")
39+ e.Str(v)
40+ case bool:
41+ e.Variant("b")
42+ e.Bool(v)
43+ case int32:
44+ e.Variant("i")
45+ e.Int32(v)
46+ }
47+ }
48+ e.EndArray(a)
49+ kids := e.BeginArray('v')
50+ if n.junk {
51+ e.Variant("s")
52+ e.Str("not a node")
53+ }
54+ for i := range n.kids {
55+ e.Variant(layoutSig)
56+ n.kids[i].encode(e)
57+ }
58+ e.EndArray(kids)
59+}
60+
61+// menuCall answers a dbusmenu call and records it on mcalls.
62+func (f *fakeItem) menuCall(m *zerobus.Message) bool {
63+ r := m.Body()
64+ switch m.Member {
65+ case "AboutToShow":
66+ f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32())
67+ f.c.NewReply(m, "b").Bool(false)
68+ case "Event":
69+ id, name := r.Int32(), r.Str()
70+ r.Skip(r.Variant())
71+ f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name)
72+ f.c.NewReply(m, "")
73+ case "GetLayout":
74+ f.mcalls <- "GetLayout"
75+ if f.menu == nil {
76+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
77+ break
78+ }
79+ e := f.c.NewReply(m, "u"+layoutSig)
80+ e.Uint32(1)
81+ f.menu.encode(e)
82+ default:
83+ return true
84+ }
85+ _, err := f.c.Send()
86+ return err == nil
87+}
88+
89+func expectMenuCall(t *testing.T, f *fakeItem, want string) {
90+ t.Helper()
91+ select {
92+ case got := <-f.mcalls:
93+ if got != want {
94+ t.Fatalf("menu got %s, want %s", got, want)
95+ }
96+ case <-time.After(5 * time.Second):
97+ t.Fatalf("menu never got %s", want)
98+ }
99+}
100+
101+func expectNoMenuCall(t *testing.T, f *fakeItem) {
102+ t.Helper()
103+ select {
104+ case got := <-f.mcalls:
105+ t.Fatalf("unexpected menu call %s", got)
106+ case <-time.After(150 * time.Millisecond):
107+ }
108+}
109+
110+func sampleLayout() *tnode {
111+ return &tnode{id: 0, kids: []tnode{
112+ {id: 1, props: []tprop{{"label", "_Open"}}},
113+ {id: 2, props: []tprop{{"type", "separator"}}},
114+ {id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}},
115+ {id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{
116+ {id: 5, props: []tprop{{"label", "Advanced"}}},
117+ }},
118+ {id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}},
119+ {id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}},
120+ {id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}},
121+ }, junk: true}
122+}
123+
124+// menuReady gives steam a dbusmenu and waits until the tray knows it: right
125+// clicks fall back to ContextMenu until then, and the first menu line proves it.
126+func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control {
127+ t.Helper()
128+ steam.run(t, func() {
129+ steam.menu = layout
130+ steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active")
131+ _, _ = steam.c.Send()
132+ })
133+ deadline := time.Now().Add(5 * time.Second)
134+ for time.Now().Before(deadline) {
135+ in.send(t, "click right 0 9")
136+ wait := time.After(600 * time.Millisecond)
137+ poll:
138+ for {
139+ select {
140+ case s := <-out.ch:
141+ if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) {
142+ c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07")))
143+ if err != nil {
144+ t.Fatalf("the bar would refuse the menu line %q: %v", j, err)
145+ }
146+ // Drain what the successful open recorded.
147+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
148+ expectMenuCall(t, steam, want)
149+ }
150+ return c
151+ }
152+ case <-steam.calls: // ContextMenu: the tray has not seen the menu yet
153+ break poll
154+ case <-wait:
155+ break poll
156+ }
157+ }
158+ for len(steam.mcalls) > 0 {
159+ <-steam.mcalls
160+ }
161+ }
162+ t.Fatal("the tray never produced a menu")
163+ return module.Control{}
164+}
165+
166+func TestRightClickOpensTheDbusmenu(t *testing.T) {
167+ steam, _, in, out := setup(t)
168+ c := menuReady(t, steam, in, out, sampleLayout())
169+
170+ if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 {
171+ t.Fatalf("control = %+v", c)
172+ }
173+ type flat struct {
174+ id int32
175+ label, kind string
176+ enabled, chk bool
177+ kids int
178+ }
179+ var got []flat
180+ for _, it := range c.Items {
181+ got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)})
182+ }
183+ want := []flat{
184+ {1, "Open", "normal", true, false, 0},
185+ {2, "", "separator", true, false, 0},
186+ {3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te"
187+ {4, "Settings", "normal", true, false, 1},
188+ {6, "Quit", "normal", false, false, 0},
189+ {8, "Radio", "radio", true, false, 0},
190+ }
191+ want[2].label = "Mu_te"
192+ if fmt.Sprint(got) != fmt.Sprint(want) {
193+ t.Fatalf("items = %+v\nwant %+v", got, want)
194+ }
195+ if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" {
196+ t.Fatalf("submenu = %+v", c.Items[3].Items)
197+ }
198+ expectNoCall(t, steam) // no ContextMenu: the bar draws the menu
199+
200+ // A choice reaches the application, once.
201+ in.send(t, "menu-activate 9 5")
202+ expectMenuCall(t, steam, "Event(5,clicked)")
203+ in.send(t, "menu-activate 9 5", "menu-closed 9")
204+ expectNoMenuCall(t, steam)
205+}
206+
207+func TestMenuClosedAndStaleTokens(t *testing.T) {
208+ steam, _, in, out := setup(t)
209+ menuReady(t, steam, in, out, sampleLayout())
210+
211+ in.send(t, "menu-activate 8 1") // another token
212+ in.send(t, "menu-closed 8")
213+ in.send(t, "menu-activate 9 99") // an id the menu does not hold
214+ in.send(t, "menu-activate 9 7") // an invisible item
215+ in.send(t, "menu-activate 9 70") // a child of an invisible item
216+ expectNoMenuCall(t, steam)
217+
218+ in.send(t, "menu-closed 9")
219+ expectMenuCall(t, steam, "Event(0,closed)")
220+ in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing
221+ expectNoMenuCall(t, steam)
222+}
223+
224+func TestNewerMenuReplacesTheOlder(t *testing.T) {
225+ steam, _, in, out := setup(t)
226+ menuReady(t, steam, in, out, sampleLayout())
227+ in.send(t, "click right 0 10")
228+ out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) })
229+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
230+ expectMenuCall(t, steam, want)
231+ }
232+ in.send(t, "menu-activate 9 1") // the first menu is gone
233+ expectNoMenuCall(t, steam)
234+ in.send(t, "menu-activate 10 1")
235+ expectMenuCall(t, steam, "Event(1,clicked)")
236+}
237+
238+func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) {
239+ steam, _, in, out := setup(t)
240+ menuReady(t, steam, in, out, sampleLayout())
241+ // ItemIsMenu is not served by the fake, so the left click calls Activate,
242+ // which it refuses: the fallback opens the menu.
243+ steam.run(t, func() { steam.unknown = "Activate" })
244+ in.send(t, "click left 0 12")
245+ expectCall(t, steam, "Activate(0,0)")
246+ out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) })
247+}
248+
249+func TestMenuLayoutLimitsAndDecoding(t *testing.T) {
250+ // Build a deep and wide layout and decode it through the real reply path.
251+ deep := tnode{id: 100, props: []tprop{{"label", "d"}}}
252+ for i := int32(1); i < 12; i++ {
253+ deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}}
254+ }
255+ root := tnode{id: 0, kids: []tnode{deep}}
256+ for i := int32(0); i < 600; i++ {
257+ root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}})
258+ }
259+ items, ids := decodeTestLayout(t, &root)
260+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
261+ t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems)
262+ }
263+ depth := 0
264+ for l := items; len(l) > 0; l = l[0].Items {
265+ depth++
266+ }
267+ if depth != maxMenuDepth {
268+ t.Fatalf("depth %d, want %d", depth, maxMenuDepth)
269+ }
270+ if got := len(items[len(items)-1].Label); got != 256 {
271+ t.Fatalf("label of %d bytes, want 256", got)
272+ }
273+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
274+ if err != nil || len(line) > maxMenuLine {
275+ t.Fatalf("encodeMenu: %d bytes, %v", len(line), err)
276+ }
277+ if _, err = module.ParseControl(line); err != nil {
278+ t.Fatalf("the bar refuses the trimmed menu: %v", err)
279+ }
280+}
281+
282+func TestMenuLabelMnemonics(t *testing.T) {
283+ for in, want := range map[string]string{
284+ "_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè",
285+ } {
286+ if got := cleanLabel(in); got != want {
287+ t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
288+ }
289+ }
290+}
291+
292+// decodeTestLayout encodes root as a GetLayout reply and decodes it again.
293+func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
294+ t.Helper()
295+ addr := privateBus(t)
296+ srv, cli := dial(t, addr), dial(t, addr)
297+ done := make(chan struct{})
298+ go func() {
299+ defer close(done)
300+ var m *zerobus.Message
301+ for { // the bus first sends signals such as NameAcquired
302+ var err error
303+ if m, err = srv.ReadMessage(); err != nil {
304+ return
305+ }
306+ if m.Type == zerobus.TypeMethodCall {
307+ break
308+ }
309+ }
310+ e := srv.NewReply(m, "u"+layoutSig)
311+ e.Uint32(1)
312+ root.encode(e)
313+ _, _ = srv.Send()
314+ }()
315+ cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "")
316+ m, err := cli.Call()
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ items, ids, err := decodeLayout(m.Body())
321+ if err != nil {
322+ t.Fatal(err)
323+ }
324+ _ = srv.Close()
325+ <-done
326+ return items, ids
327+}
@@ -0,0 +1,327 @@
1+package tray
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerobus"
10+
11+ "github.com/bnema/neferbar/internal/module"
12+)
13+
14+// tprop is one property of a test menu node.
15+type tprop struct {
16+ k string
17+ v any // string, bool or int32
18+}
19+
20+// tnode is a node of the layout a fake application serves.
21+type tnode struct {
22+ id int32
23+ props []tprop
24+ kids []tnode
25+ // junk is written as an extra child with a signature that is not a layout node.
26+ junk bool
27+}
28+
29+func (n *tnode) encode(e *zerobus.Encoder) {
30+ e.Struct()
31+ e.Int32(n.id)
32+ a := e.BeginArray('{')
33+ for _, p := range n.props {
34+ e.Struct()
35+ e.Str(p.k)
36+ switch v := p.v.(type) {
37+ case string:
38+ e.Variant("s")
39+ e.Str(v)
40+ case bool:
41+ e.Variant("b")
42+ e.Bool(v)
43+ case int32:
44+ e.Variant("i")
45+ e.Int32(v)
46+ }
47+ }
48+ e.EndArray(a)
49+ kids := e.BeginArray('v')
50+ if n.junk {
51+ e.Variant("s")
52+ e.Str("not a node")
53+ }
54+ for i := range n.kids {
55+ e.Variant(layoutSig)
56+ n.kids[i].encode(e)
57+ }
58+ e.EndArray(kids)
59+}
60+
61+// menuCall answers a dbusmenu call and records it on mcalls.
62+func (f *fakeItem) menuCall(m *zerobus.Message) bool {
63+ r := m.Body()
64+ switch m.Member {
65+ case "AboutToShow":
66+ f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32())
67+ f.c.NewReply(m, "b").Bool(false)
68+ case "Event":
69+ id, name := r.Int32(), r.Str()
70+ r.Skip(r.Variant())
71+ f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name)
72+ f.c.NewReply(m, "")
73+ case "GetLayout":
74+ f.mcalls <- "GetLayout"
75+ if f.menu == nil {
76+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
77+ break
78+ }
79+ e := f.c.NewReply(m, "u"+layoutSig)
80+ e.Uint32(1)
81+ f.menu.encode(e)
82+ default:
83+ return true
84+ }
85+ _, err := f.c.Send()
86+ return err == nil
87+}
88+
89+func expectMenuCall(t *testing.T, f *fakeItem, want string) {
90+ t.Helper()
91+ select {
92+ case got := <-f.mcalls:
93+ if got != want {
94+ t.Fatalf("menu got %s, want %s", got, want)
95+ }
96+ case <-time.After(5 * time.Second):
97+ t.Fatalf("menu never got %s", want)
98+ }
99+}
100+
101+func expectNoMenuCall(t *testing.T, f *fakeItem) {
102+ t.Helper()
103+ select {
104+ case got := <-f.mcalls:
105+ t.Fatalf("unexpected menu call %s", got)
106+ case <-time.After(150 * time.Millisecond):
107+ }
108+}
109+
110+func sampleLayout() *tnode {
111+ return &tnode{id: 0, kids: []tnode{
112+ {id: 1, props: []tprop{{"label", "_Open"}}},
113+ {id: 2, props: []tprop{{"type", "separator"}}},
114+ {id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}},
115+ {id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{
116+ {id: 5, props: []tprop{{"label", "Advanced"}}},
117+ }},
118+ {id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}},
119+ {id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}},
120+ {id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}},
121+ }, junk: true}
122+}
123+
124+// menuReady gives steam a dbusmenu and waits until the tray knows it: right
125+// clicks fall back to ContextMenu until then, and the first menu line proves it.
126+func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control {
127+ t.Helper()
128+ steam.run(t, func() {
129+ steam.menu = layout
130+ steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active")
131+ _, _ = steam.c.Send()
132+ })
133+ deadline := time.Now().Add(5 * time.Second)
134+ for time.Now().Before(deadline) {
135+ in.send(t, "click right 0 9")
136+ wait := time.After(600 * time.Millisecond)
137+ poll:
138+ for {
139+ select {
140+ case s := <-out.ch:
141+ if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) {
142+ c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07")))
143+ if err != nil {
144+ t.Fatalf("the bar would refuse the menu line %q: %v", j, err)
145+ }
146+ // Drain what the successful open recorded.
147+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
148+ expectMenuCall(t, steam, want)
149+ }
150+ return c
151+ }
152+ case <-steam.calls: // ContextMenu: the tray has not seen the menu yet
153+ break poll
154+ case <-wait:
155+ break poll
156+ }
157+ }
158+ for len(steam.mcalls) > 0 {
159+ <-steam.mcalls
160+ }
161+ }
162+ t.Fatal("the tray never produced a menu")
163+ return module.Control{}
164+}
165+
166+func TestRightClickOpensTheDbusmenu(t *testing.T) {
167+ steam, _, in, out := setup(t)
168+ c := menuReady(t, steam, in, out, sampleLayout())
169+
170+ if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 {
171+ t.Fatalf("control = %+v", c)
172+ }
173+ type flat struct {
174+ id int32
175+ label, kind string
176+ enabled, chk bool
177+ kids int
178+ }
179+ var got []flat
180+ for _, it := range c.Items {
181+ got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)})
182+ }
183+ want := []flat{
184+ {1, "Open", "normal", true, false, 0},
185+ {2, "", "separator", true, false, 0},
186+ {3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te"
187+ {4, "Settings", "normal", true, false, 1},
188+ {6, "Quit", "normal", false, false, 0},
189+ {8, "Radio", "radio", true, false, 0},
190+ }
191+ want[2].label = "Mu_te"
192+ if fmt.Sprint(got) != fmt.Sprint(want) {
193+ t.Fatalf("items = %+v\nwant %+v", got, want)
194+ }
195+ if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" {
196+ t.Fatalf("submenu = %+v", c.Items[3].Items)
197+ }
198+ expectNoCall(t, steam) // no ContextMenu: the bar draws the menu
199+
200+ // A choice reaches the application, once.
201+ in.send(t, "menu-activate 9 5")
202+ expectMenuCall(t, steam, "Event(5,clicked)")
203+ in.send(t, "menu-activate 9 5", "menu-closed 9")
204+ expectNoMenuCall(t, steam)
205+}
206+
207+func TestMenuClosedAndStaleTokens(t *testing.T) {
208+ steam, _, in, out := setup(t)
209+ menuReady(t, steam, in, out, sampleLayout())
210+
211+ in.send(t, "menu-activate 8 1") // another token
212+ in.send(t, "menu-closed 8")
213+ in.send(t, "menu-activate 9 99") // an id the menu does not hold
214+ in.send(t, "menu-activate 9 7") // an invisible item
215+ in.send(t, "menu-activate 9 70") // a child of an invisible item
216+ expectNoMenuCall(t, steam)
217+
218+ in.send(t, "menu-closed 9")
219+ expectMenuCall(t, steam, "Event(0,closed)")
220+ in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing
221+ expectNoMenuCall(t, steam)
222+}
223+
224+func TestNewerMenuReplacesTheOlder(t *testing.T) {
225+ steam, _, in, out := setup(t)
226+ menuReady(t, steam, in, out, sampleLayout())
227+ in.send(t, "click right 0 10")
228+ out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) })
229+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
230+ expectMenuCall(t, steam, want)
231+ }
232+ in.send(t, "menu-activate 9 1") // the first menu is gone
233+ expectNoMenuCall(t, steam)
234+ in.send(t, "menu-activate 10 1")
235+ expectMenuCall(t, steam, "Event(1,clicked)")
236+}
237+
238+func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) {
239+ steam, _, in, out := setup(t)
240+ menuReady(t, steam, in, out, sampleLayout())
241+ // ItemIsMenu is not served by the fake, so the left click calls Activate,
242+ // which it refuses: the fallback opens the menu.
243+ steam.run(t, func() { steam.unknown = "Activate" })
244+ in.send(t, "click left 0 12")
245+ expectCall(t, steam, "Activate(0,0)")
246+ out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) })
247+}
248+
249+func TestMenuLayoutLimitsAndDecoding(t *testing.T) {
250+ // Build a deep and wide layout and decode it through the real reply path.
251+ deep := tnode{id: 100, props: []tprop{{"label", "d"}}}
252+ for i := int32(1); i < 12; i++ {
253+ deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}}
254+ }
255+ root := tnode{id: 0, kids: []tnode{deep}}
256+ for i := int32(0); i < 600; i++ {
257+ root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}})
258+ }
259+ items, ids := decodeTestLayout(t, &root)
260+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
261+ t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems)
262+ }
263+ depth := 0
264+ for l := items; len(l) > 0; l = l[0].Items {
265+ depth++
266+ }
267+ if depth != maxMenuDepth {
268+ t.Fatalf("depth %d, want %d", depth, maxMenuDepth)
269+ }
270+ if got := len(items[len(items)-1].Label); got != 256 {
271+ t.Fatalf("label of %d bytes, want 256", got)
272+ }
273+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
274+ if err != nil || len(line) > maxMenuLine {
275+ t.Fatalf("encodeMenu: %d bytes, %v", len(line), err)
276+ }
277+ if _, err = module.ParseControl(line); err != nil {
278+ t.Fatalf("the bar refuses the trimmed menu: %v", err)
279+ }
280+}
281+
282+func TestMenuLabelMnemonics(t *testing.T) {
283+ for in, want := range map[string]string{
284+ "_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè",
285+ } {
286+ if got := cleanLabel(in); got != want {
287+ t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
288+ }
289+ }
290+}
291+
292+// decodeTestLayout encodes root as a GetLayout reply and decodes it again.
293+func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
294+ t.Helper()
295+ addr := privateBus(t)
296+ srv, cli := dial(t, addr), dial(t, addr)
297+ done := make(chan struct{})
298+ go func() {
299+ defer close(done)
300+ var m *zerobus.Message
301+ for { // the bus first sends signals such as NameAcquired
302+ var err error
303+ if m, err = srv.ReadMessage(); err != nil {
304+ return
305+ }
306+ if m.Type == zerobus.TypeMethodCall {
307+ break
308+ }
309+ }
310+ e := srv.NewReply(m, "u"+layoutSig)
311+ e.Uint32(1)
312+ root.encode(e)
313+ _, _ = srv.Send()
314+ }()
315+ cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "")
316+ m, err := cli.Call()
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ items, ids, err := decodeLayout(m.Body())
321+ if err != nil {
322+ t.Fatal(err)
323+ }
324+ _ = srv.Close()
325+ <-done
326+ return items, ids
327+}
M internal/tray/tray_test.go +17 -1
@@ -71,13 +71,17 @@ type fakeItem struct {
7171 slowFor time.Duration // how long slow takes
7272 tip *[2]string // its ToolTip title and description, set through run
7373 title string // its Title property, set through run
74+
75+ // A dbusmenu at /MenuBar, set through run.
76+ menu *tnode
77+ mcalls chan string // the dbusmenu calls it received
7478 }
7579
7680 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
7781 c := dial(t, addr)
7882 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
7983 cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
80- calls: make(chan string, 32)}
84+ calls: make(chan string, 32), mcalls: make(chan string, 32)}
8185 go f.serve()
8286 return f
8387 }
@@ -108,6 +112,12 @@ func (f *fakeItem) serve() {
108112 (<-f.cmds)()
109113 continue
110114 }
115+ if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface {
116+ if !f.menuCall(m) {
117+ return
118+ }
119+ continue
120+ }
111121 if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
112122 if !f.action(m) {
113123 return
@@ -136,6 +146,12 @@ func (f *fakeItem) serve() {
136146 e.Str(f.tip[0])
137147 e.Str(f.tip[1])
138148 }
149+ if f.menu != nil {
150+ e.Struct()
151+ e.Str("Menu")
152+ e.Variant("o")
153+ e.ObjectPath("/MenuBar")
154+ }
139155 if f.title != "" {
140156 e.Struct()
141157 e.Str("Title")
@@ -71,13 +71,17 @@ type fakeItem struct {
71 slowFor time.Duration // how long slow takes71 slowFor time.Duration // how long slow takes
72 tip *[2]string // its ToolTip title and description, set through run72 tip *[2]string // its ToolTip title and description, set through run
73 title string // its Title property, set through run73 title string // its Title property, set through run
74+
75+ // A dbusmenu at /MenuBar, set through run.
76+ menu *tnode
77+ mcalls chan string // the dbusmenu calls it received
74 }78 }
75 79
76 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {80 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
77 c := dial(t, addr)81 c := dial(t, addr)
78 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),82 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
79 cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),83 cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
80- calls: make(chan string, 32)}84+ calls: make(chan string, 32), mcalls: make(chan string, 32)}
81 go f.serve()85 go f.serve()
82 return f86 return f
83 }87 }
@@ -108,6 +112,12 @@ func (f *fakeItem) serve() {
108 (<-f.cmds)()112 (<-f.cmds)()
109 continue113 continue
110 }114 }
115+ if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface {
116+ if !f.menuCall(m) {
117+ return
118+ }
119+ continue
120+ }
111 if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {121 if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
112 if !f.action(m) {122 if !f.action(m) {
113 return123 return
@@ -136,6 +146,12 @@ func (f *fakeItem) serve() {
136 e.Str(f.tip[0])146 e.Str(f.tip[0])
137 e.Str(f.tip[1])147 e.Str(f.tip[1])
138 }148 }
149+ if f.menu != nil {
150+ e.Struct()
151+ e.Str("Menu")
152+ e.Variant("o")
153+ e.ObjectPath("/MenuBar")
154+ }
139 if f.title != "" {155 if f.title != "" {
140 e.Struct()156 e.Struct()
141 e.Str("Title")157 e.Str("Title")

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