v0.3.0 da0887c

dbusmenu.go RawHistoryFolder

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}

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