main 7a3b92c

tray.go RawHistoryFolder

1// Package tray shows the system tray (StatusNotifierItem) as one line of
2// Nerd Font glyphs, for "neferbar tray".
3//
4// It acts as the StatusNotifierWatcher when no other program does, and as a
5// plain host of the existing watcher otherwise, so it can run next to another
6// panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage;
7// calls to applications never block it, as their replies are matched by
8// serial when they arrive.
9package tray
10
11import (
12	"bytes"
13	"context"
14	"errors"
15	"fmt"
16	"io"
17	"log/slog"
18	"os"
19	"slices"
20	"strconv"
21	"strings"
22	"sync"
23
24	vt "github.com/bnema/vev-vt"
25	"github.com/bnema/zerobus"
26)
27
28const (
29	watcherName  = "org.kde.StatusNotifierWatcher"
30	watcherPath  = "/StatusNotifierWatcher"
31	watcherIface = "org.kde.StatusNotifierWatcher"
32	itemIface    = "org.kde.StatusNotifierItem"
33	itemPath     = "/StatusNotifierItem"
34	propsIface   = "org.freedesktop.DBus.Properties"
35	busName      = "org.freedesktop.DBus"
36	busPath      = "/org/freedesktop/DBus"
37)
38
39// RGB is a color.
40type RGB = [3]uint8
41
42// Options configure a Tray.
43type Options struct {
44	Resolver *Resolver
45	// Foreground colors items without an icon to take a color from;
46	// Background is what tinted icons must stand out against; Accent marks
47	// items that need attention.
48	Foreground, Background, Accent RGB
49	Out                            io.Writer // one line per change
50	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)
56}
57
58type status uint8
59
60const (
61	active status = iota
62	passive
63	attention
64)
65
66type item struct {
67	service string // as listed by the watcher: "bus/path" or "bus"
68	dest    string // where to send calls: the owner, or the registered bus name
69	owner   string // the unique name; signals come from it
70	path    string
71
72	id, iconName, title string
73	status              status
74	icon                string // resolved glyph; "" hides the item
75	resolved            bool
76	color               RGB
77	hasColor            bool
78	menu                string // object path of its dbusmenu, "" when none
79	tipTitle, tipBody   string // ToolTip, raw (may hold markup); the title if the item has no tooltip
80	isMenu              bool   // ItemIsMenu: Activate should open the menu
81	loaded              bool   // GetAll answered at least once
82	pending             uint32 // serial of a GetAll in flight
83	stale               bool   // a change arrived while GetAll was in flight
84}
85
86type pendKind uint8
87
88const (
89	pendGetAll pendKind = iota + 1
90	pendItems           // the watcher's RegisteredStatusNotifierItems
91	pendName            // RequestName of the watcher name
92	pendIgnore
93)
94
95type pend struct {
96	serial uint32
97	kind   pendKind
98	it     *item
99}
100
101// Tray is one running tray.
102type Tray struct {
103	c       *zerobus.Conn
104	opt     Options
105	watcher bool // this process owns the watcher name
106	host    string
107	items   []*item
108	pending []pend
109	frame   []byte
110	last    []byte
111	hist    histogram
112	err     error // first fatal error from dispatch
113
114	snap    snapshot // what the last line printed holds, for the control loop
115	targets []Target // scratch for the next snapshot
116}
117
118// Run serves the tray on c until ctx ends or the bus connection fails. It
119// closes c.
120func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
121	t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
122		frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
123	if t.opt.Log == nil {
124		t.opt.Log = slog.New(slog.DiscardHandler)
125	}
126	stop := context.AfterFunc(ctx, func() { _ = c.Close() })
127	defer stop()
128	defer func() { _ = c.Close() }()
129
130	if opt.In != nil {
131		if opt.Dial == nil {
132			return fmt.Errorf("tray: Options.In needs Options.Dial")
133		}
134		t.opt.Out = &lockedWriter{w: opt.Out}
135		cctx, cancel := context.WithCancel(ctx)
136		lines := make(chan string, 16)
137		ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
138		var wg sync.WaitGroup
139		wg.Add(1)
140		go func() { defer wg.Done(); ctl.run(cctx, lines) }()
141		// The reader may sit in a blocking Read of stdin that nothing can
142		// interrupt; it is not waited for, and ends with the process.
143		go readCommands(cctx, opt.In, lines, t.opt.Log)
144		defer func() { cancel(); wg.Wait() }()
145	}
146
147	// Calls made from here on only go to the bus, which always answers.
148	// Handle sees what arrives meanwhile.
149	c.Handle = t.dispatch
150	for _, rule := range [...]string{
151		"type='signal',interface='" + itemIface + "'",
152		// The bus matches a well-known sender by its current owner.
153		"type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
154		"type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
155	} {
156		if err := c.AddMatch(rule); err != nil {
157			return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
158		}
159	}
160	if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
161		return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
162	}
163	t.claim()
164	if err := t.render(true); err != nil {
165		return t.exit(ctx, err)
166	}
167	for t.err == nil {
168		m, err := c.ReadMessage()
169		if err != nil {
170			return t.exit(ctx, err)
171		}
172		t.dispatch(m)
173	}
174	// A send fails too when ctx ends during dispatch and closes c.
175	return t.exit(ctx, t.err)
176}
177
178// exit returns nil when the error comes from ctx ending.
179func (t *Tray) exit(ctx context.Context, err error) error {
180	if ctx.Err() != nil {
181		return nil
182	}
183	return err
184}
185
186func (t *Tray) send() uint32 {
187	serial, err := t.c.Send()
188	if err != nil {
189		if errors.Is(err, zerobus.ErrInvalid) {
190			// A name or path an application gave us that D-Bus refuses.
191			t.opt.Log.Warn("tray: message not sent", "err", err)
192			return 0
193		}
194		t.fatal(err)
195		return 0
196	}
197	return serial
198}
199
200func (t *Tray) fatal(err error) {
201	if t.err == nil {
202		t.err = err
203	}
204}
205
206func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
207	if serial != 0 {
208		t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
209	}
210}
211
212// claim asks for the watcher name; the reply decides the mode.
213func (t *Tray) claim() {
214	e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
215	e.Str(watcherName)
216	e.Uint32(zerobus.NameFlagDoNotQueue)
217	t.expect(t.send(), pendName, nil)
218}
219
220func (t *Tray) dispatch(m *zerobus.Message) {
221	switch m.Type {
222	case zerobus.TypeMethodReturn, zerobus.TypeError:
223		i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
224		if i < 0 {
225			return
226		}
227		p := t.pending[i]
228		t.pending = slices.Delete(t.pending, i, i+1)
229		t.reply(p, m)
230	case zerobus.TypeSignal:
231		t.signal(m)
232	case zerobus.TypeMethodCall:
233		t.call(m)
234	}
235}
236
237func (t *Tray) reply(p pend, m *zerobus.Message) {
238	failed := m.Type == zerobus.TypeError
239	switch p.kind {
240	case pendName:
241		code := m.Body().Uint32()
242		if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
243			t.becomeWatcher()
244			return
245		}
246		t.becomeHost()
247	case pendItems:
248		if failed {
249			t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
250			return
251		}
252		r := m.Body()
253		if m.Signature != "v" || r.Variant() != "as" {
254			t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
255			return
256		}
257		end := r.Array('s')
258		for r.More(end) {
259			t.add(r.Str(), "")
260		}
261	case pendGetAll:
262		it := p.it
263		if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
264			return // removed, or an older request
265		}
266		it.pending = 0
267		if failed || m.Signature != "a{sv}" {
268			t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
269			if !it.loaded {
270				t.remove(it)
271			}
272			return
273		}
274		if it.owner != m.Sender {
275			it.owner = strings.Clone(m.Sender)
276		}
277		t.load(it, m.Body())
278		if it.stale {
279			t.refresh(it)
280		}
281	}
282}
283
284func (t *Tray) becomeWatcher() {
285	t.watcher = true
286	t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
287	// Items that waited for a host register now.
288	t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
289	t.send()
290}
291
292func (t *Tray) becomeHost() {
293	t.watcher = false
294	t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
295	t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
296	t.expect(t.send(), pendIgnore, nil)
297	e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
298	e.Str(watcherIface)
299	e.Str("RegisteredStatusNotifierItems")
300	t.expect(t.send(), pendItems, nil)
301}
302
303func (t *Tray) signal(m *zerobus.Message) {
304	switch m.Interface {
305	case busName:
306		if m.Member != "NameOwnerChanged" || m.Sender != busName {
307			return
308		}
309		r := m.Body()
310		name, old, owner := r.Str(), r.Str(), r.Str()
311		switch {
312		case r.Err() != nil:
313		case owner == "":
314			t.vanished(name)
315		case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
316			// Another watcher replaced the one we used: its items are
317			// the ones to show now, not a late list from the old one.
318			t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
319			t.dropAll()
320			t.becomeHost()
321		}
322	case watcherIface:
323		if t.watcher || m.Signature != "s" {
324			return // our own signals come back to us too
325		}
326		s := m.Body().Str()
327		switch m.Member {
328		case "StatusNotifierItemRegistered":
329			t.add(s, "")
330		case "StatusNotifierItemUnregistered":
331			svc, _, _ := normService(s)
332			if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
333				t.remove(t.items[i])
334			}
335		}
336	case itemIface:
337		switch m.Member {
338		case "NewMenu", "NewOverlayIcon":
339			return // nothing the tray shows
340		}
341		for _, it := range t.items {
342			if it.owner == m.Sender && it.path == m.Path {
343				t.refresh(it)
344				return
345			}
346		}
347	}
348}
349
350// vanished drops the items of a bus name that left, and takes over the
351// watcher name when its owner left.
352func (t *Tray) vanished(name string) {
353	changed := false
354	for i := len(t.items) - 1; i >= 0; i-- {
355		if it := t.items[i]; it.owner == name || it.dest == name {
356			t.drop(i)
357			changed = true
358		}
359	}
360	if changed {
361		t.renderOrFail()
362	}
363	if name == watcherName && !t.watcher {
364		// Items register again with the new watcher.
365		t.dropAll()
366		t.claim()
367	}
368}
369
370func (t *Tray) dropAll() {
371	if len(t.items) == 0 {
372		return
373	}
374	for i := len(t.items) - 1; i >= 0; i-- {
375		t.drop(i)
376	}
377	t.renderOrFail()
378}
379
380// normService turns the watcher's form of an item, "bus/path" or "bus", into
381// "bus/path", its bus and its path.
382func normService(s string) (service, bus, path string) {
383	bus, p, ok := strings.Cut(s, "/")
384	if !ok {
385		return bus + itemPath, bus, itemPath
386	}
387	return s, bus, "/" + p
388}
389
390// validBusName reports whether s is a unique (":1.42") or well-known
391// ("org.example.App") bus name, as D-Bus defines them.
392func validBusName(s string) bool {
393	if s == "" || len(s) > 255 {
394		return false
395	}
396	unique := s[0] == ':'
397	if unique {
398		s = s[1:]
399	}
400	n := 0
401	for more := true; more; n++ {
402		var e string
403		e, s, more = strings.Cut(s, ".")
404		if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
405			return false
406		}
407		for _, c := range []byte(e) {
408			if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
409				return false
410			}
411		}
412	}
413	return n >= 2
414}
415
416// addResult says what add did.
417type addResult uint8
418
419const (
420	addNew addResult = iota
421	addKnown
422	addInvalid // D-Bus refuses its bus name or path
423)
424
425// add starts tracking an item. service is the watcher's form, "bus/path" or
426// "bus"; owner is its unique name when known.
427func (t *Tray) add(service, owner string) addResult {
428	service, dest, path := normService(service)
429	if owner == "" && strings.HasPrefix(dest, ":") {
430		owner = dest
431	}
432	for _, it := range t.items {
433		if it.service == service || owner != "" && it.owner == owner && it.path == path {
434			return addKnown
435		}
436	}
437	it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
438	t.items = append(t.items, it)
439	t.refresh(it)
440	if it.pending == 0 {
441		// Never shown nor announced: forget it quietly.
442		t.items = t.items[:len(t.items)-1]
443		return addInvalid
444	}
445	return addNew
446}
447
448func (t *Tray) remove(it *item) {
449	if i := slices.Index(t.items, it); i >= 0 {
450		t.drop(i)
451		t.renderOrFail()
452	}
453}
454
455func (t *Tray) drop(i int) {
456	it := t.items[i]
457	t.items = slices.Delete(t.items, i, i+1)
458	if t.watcher {
459		t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
460		t.send()
461	}
462}
463
464// refresh asks an item for its properties, unless a request is in flight.
465func (t *Tray) refresh(it *item) {
466	if it.pending != 0 {
467		it.stale = true
468		return
469	}
470	it.stale = false
471	dest := it.dest
472	if it.owner != "" {
473		dest = it.owner
474	}
475	t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
476	it.pending = t.send()
477	t.expect(it.pending, pendGetAll, it)
478}
479
480// load reads a GetAll reply into it and redraws when something shown
481// changed. Strings are copied only when they change.
482func (t *Tray) load(it *item, r *zerobus.Reader) {
483	was := *it
484	var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
485	tipSeen := false
486	end := r.Array('{')
487	for r.More(end) {
488		r.Struct()
489		key := r.Str()
490		sig := r.Variant()
491		switch {
492		case sig == "s" && key == "Id":
493			setStr(&it.id, r.Str())
494		case sig == "s" && key == "Title":
495			setStr(&it.title, r.Str())
496		case sig == "s" && key == "IconName":
497			setStr(&it.iconName, r.Str())
498		case sig == "o" && key == "Menu":
499			setStr(&it.menu, r.ObjectPath())
500		case sig == "(sa(iiay)ss)" && key == "ToolTip":
501			r.Struct()
502			r.Str()           // icon name
503			r.Skip("a(iiay)") // pixmaps
504			setStr(&it.tipTitle, r.Str())
505			setStr(&it.tipBody, r.Str())
506			tipSeen = true
507		case sig == "b" && key == "ItemIsMenu":
508			it.isMenu = r.Bool()
509		case sig == "s" && key == "Status":
510			it.status = parseStatus(r.Str())
511		case sig == "a(iiay)" && key == "IconPixmap":
512			pixmaps = pickPixmap(r)
513		case sig == "a(iiay)" && key == "AttentionIconPixmap":
514			attentionPixmaps = pickPixmap(r)
515		default:
516			r.Skip(sig)
517		}
518	}
519	if r.Err() != nil {
520		t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
521		*it = was
522		return
523	}
524	if !tipSeen {
525		it.tipTitle, it.tipBody = "", ""
526	}
527	it.loaded = true
528	if it.status == attention && attentionPixmaps != nil {
529		pixmaps = attentionPixmaps
530	}
531	it.color, it.hasColor = t.hist.dominant(pixmaps)
532	if it.hasColor {
533		it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
534	}
535	if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
536		it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
537	}
538	t.renderOrFail()
539}
540
541// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
542// best suited to find a color: the largest up to 64x64, else the smallest.
543// The result points into the message buffer.
544func pickPixmap(r *zerobus.Reader) []byte {
545	var best []byte
546	bestN := 0
547	end := r.Array('(')
548	for r.More(end) {
549		r.Struct()
550		w, h := int(r.Int32()), int(r.Int32())
551		data := r.ByteArray()
552		n := w * h
553		if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
554			continue
555		}
556		if best == nil || better(n, bestN) {
557			best, bestN = data, n
558		}
559	}
560	return best
561}
562
563// better reports whether an icon of n pixels suits the color search better
564// than one of cur pixels: the largest up to 64x64, else the smallest.
565func better(n, cur int) bool {
566	const enough = 64 * 64
567	if cur > enough {
568		return n < cur
569	}
570	return n > cur && n <= enough
571}
572
573func setStr(dst *string, s string) {
574	if *dst != s {
575		*dst = strings.Clone(s)
576	}
577}
578
579func parseStatus(s string) status {
580	switch s {
581	case "Passive":
582		return passive
583	case "NeedsAttention":
584		return attention
585	}
586	return active
587}
588
589// call answers method calls when this process is the watcher.
590func (t *Tray) call(m *zerobus.Message) {
591	noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
592	if !t.watcher || m.Path != watcherPath {
593		if !noReply {
594			t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
595			t.send()
596		}
597		return
598	}
599	switch {
600	case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
601		s := m.Body().Str()
602		if s == "" {
603			t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
604			return
605		}
606		if strings.HasPrefix(s, "/") {
607			s = m.Sender + s // registered by object path
608		} else if validBusName(s) {
609			s += itemPath
610		} else {
611			t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
612			return
613		}
614		// add copies s before the next read, and sends only a GetAll.
615		switch t.add(s, m.Sender) {
616		case addInvalid:
617			t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
618			return
619		case addNew:
620			t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
621			t.send()
622		}
623		if !noReply {
624			t.c.NewReply(m, "")
625			t.send()
626		}
627	case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
628		if !noReply {
629			t.c.NewReply(m, "")
630			t.send()
631		}
632	case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
633		r := m.Body()
634		iface, prop := r.Str(), r.Str()
635		if iface != watcherIface || !t.prop(m, prop, noReply) {
636			t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
637		}
638	case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
639		if noReply {
640			return
641		}
642		e := t.c.NewReply(m, "a{sv}")
643		a := e.BeginArray('{')
644		if m.Body().Str() == watcherIface {
645			for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
646				e.Struct()
647				e.Str(p)
648				t.propValue(e, p)
649			}
650		}
651		e.EndArray(a)
652		t.send()
653	case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
654		if !noReply {
655			t.c.NewReply(m, "s").Str(introspection)
656			t.send()
657		}
658	case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
659		if !noReply {
660			t.c.NewReply(m, "")
661			t.send()
662		}
663	default:
664		t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
665	}
666}
667
668func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
669	if !noReply {
670		t.c.NewError(m, name, "s").Str(text)
671		t.send()
672	}
673}
674
675// prop answers Properties.Get; it reports false for an unknown property.
676func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
677	switch name {
678	case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
679	default:
680		return false
681	}
682	if !noReply {
683		t.propValue(t.c.NewReply(m, "v"), name)
684		t.send()
685	}
686	return true
687}
688
689func (t *Tray) propValue(e *zerobus.Encoder, name string) {
690	switch name {
691	case "RegisteredStatusNotifierItems":
692		e.Variant("as")
693		a := e.BeginArray('s')
694		for _, it := range t.items {
695			e.Str(it.service)
696		}
697		e.EndArray(a)
698	case "IsStatusNotifierHostRegistered":
699		e.Variant("b")
700		e.Bool(true) // this process is the host
701	case "ProtocolVersion":
702		e.Variant("i")
703		e.Int32(0)
704	}
705}
706
707func (t *Tray) renderOrFail() {
708	if err := t.render(false); err != nil {
709		t.fatal(err)
710	}
711}
712
713// render writes the line of visible items when it differs from the last one,
714// and publishes where each icon sits for the control loop. The targets are
715// compared too: an item may change its menu without changing its icon.
716func (t *Tray) render(force bool) error {
717	f := t.frame[:0]
718	ts := t.targets[:0]
719	col := 0
720	for _, it := range t.items {
721		if !it.loaded || it.status == passive || it.icon == "" {
722			continue
723		}
724		if len(f) > 0 {
725			f = append(f, ' ')
726			col++
727		}
728		c, bold := t.opt.Foreground, false
729		switch {
730		case it.status == attention:
731			c, bold = t.opt.Accent, true
732		case it.hasColor:
733			c = it.color
734		}
735		f = append(f, "\x1b["...)
736		if bold {
737			f = append(f, "1;"...)
738		}
739		f = append(f, "38;2;"...)
740		f = strconv.AppendUint(f, uint64(c[0]), 10)
741		f = append(f, ';')
742		f = strconv.AppendUint(f, uint64(c[1]), 10)
743		f = append(f, ';')
744		f = strconv.AppendUint(f, uint64(c[2]), 10)
745		f = append(f, 'm')
746		f = append(f, it.icon...)
747		f = append(f, "\x1b[0m"...)
748		dest := it.dest
749		if it.owner != "" {
750			dest = it.owner
751		}
752		w := 0
753		for _, r := range it.icon {
754			w += vt.RuneWidth(r)
755		}
756		title, body := it.tipTitle, it.tipBody
757		if title == "" && body == "" {
758			title = it.title
759		}
760		ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w,
761			TipTitle: title, TipBody: body})
762		col += w
763	}
764	f = append(f, '\n')
765	t.frame, t.targets = f, ts
766	sameFrame := bytes.Equal(f, t.last)
767	if !force && sameFrame && t.sameTargets(ts) {
768		return nil
769	}
770	t.snap.publish(ts) // before the line: whoever sees the line finds its targets
771	if !force && sameFrame {
772		return nil
773	}
774	t.frame, t.last = t.last, t.frame
775	if _, err := t.opt.Out.Write(t.last); err != nil {
776		return fmt.Errorf("tray: %w", err)
777	}
778	return nil
779}
780
781// sameTargets reports whether the published snapshot equals ts.
782func (t *Tray) sameTargets(ts []Target) bool {
783	t.snap.mu.Lock()
784	defer t.snap.mu.Unlock()
785	return slices.Equal(t.snap.targets, ts)
786}
787
788const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
789 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
790<node>
791 <interface name="org.kde.StatusNotifierWatcher">
792  <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
793  <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
794  <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
795  <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
796  <property name="ProtocolVersion" type="i" access="read"/>
797  <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
798  <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
799  <signal name="StatusNotifierHostRegistered"/>
800 </interface>
801 <interface name="org.freedesktop.DBus.Properties">
802  <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
803  <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
804 </interface>
805 <interface name="org.freedesktop.DBus.Introspectable">
806  <method name="Introspect"><arg type="s" direction="out"/></method>
807 </interface>
808</node>
809`

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