1package tray
2
3import (
4 "encoding/json"
5 "errors"
6 "slices"
7 "strings"
8
9 "github.com/bnema/zerobus"
10)
11
12const (
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.
30type 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.
40type 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.
50type 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.
58type 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.
68func 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.
81func (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.
146func 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.
167func 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
189func 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
197func 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.
206func 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}