main 7a3b92c

control.go RawHistoryFolder

1package tray
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"errors"
9	"io"
10	"log/slog"
11	"slices"
12	"strconv"
13	"strings"
14	"sync"
15	"time"
16
17	"github.com/bnema/zerobus"
18)
19
20const (
21	// callTimeout is how long the control loop waits for an application. A
22	// frozen one must not keep the next click from working.
23	callTimeout = 2 * time.Second
24	// maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
25	maxInputLine = 256
26	// maxSteps bounds the steps of a scroll line.
27	maxSteps = 1 << 16
28	// tooltipDelay is how long the pointer rests on an icon before its
29	// tooltip is sent to the bar.
30	tooltipDelay = 500 * time.Millisecond
31)
32
33// Control lines to the bar: OSC 777 "neferbar", a JSON object, BEL, newline.
34const (
35	controlPrefix = "\x1b]777;neferbar;"
36	controlSuffix = "\x07\n"
37)
38
39// lockedWriter serializes writes from the main loop and the control loop to
40// the shared output.
41type lockedWriter struct {
42	mu sync.Mutex
43	w  io.Writer
44}
45
46func (l *lockedWriter) Write(p []byte) (int, error) {
47	l.mu.Lock()
48	defer l.mu.Unlock()
49	return l.w.Write(p)
50}
51
52// button is a mouse button named in a click line.
53type button uint8
54
55const (
56	btnLeft button = iota + 1
57	btnMiddle
58	btnRight
59)
60
61// command is one parsed line from the bar.
62type command struct {
63	verb   string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
64	button button // click
65	vert   bool   // scroll: vertical axis
66	delta  int32  // scroll: signed steps, down and right positive
67	col    int
68	token  uint32 // click: the press token
69}
70
71// parseCommand reads one line of the protocol. Every field must be present,
72// numbers must be plain non-negative decimals, and nothing may follow.
73func parseCommand(line string) (command, bool) {
74	f := strings.Fields(line)
75	if len(f) == 0 {
76		return command{}, false
77	}
78	// Plain decimal digits only: ParseUint takes no sign, and the bit size
79	// keeps the value in range on 32-bit platforms, where int is 32 bits.
80	num := func(s string, bits int, limit uint64) (uint64, bool) {
81		n, err := strconv.ParseUint(s, 10, bits)
82		return n, err == nil && n <= limit
83	}
84	// Columns are bounded by 1<<20; tokens use the full uint32.
85	col := func(s string) (int, bool) { n, ok := num(s, 31, 1<<20); return int(n), ok }
86	c := command{verb: f[0]}
87	switch f[0] {
88	case "click":
89		if len(f) != 4 {
90			return command{}, false
91		}
92		switch f[1] {
93		case "left":
94			c.button = btnLeft
95		case "middle":
96			c.button = btnMiddle
97		case "right":
98			c.button = btnRight
99		default:
100			return command{}, false
101		}
102		cl, ok1 := col(f[2])
103		tok, ok2 := num(f[3], 32, 1<<32-1)
104		if !ok1 || !ok2 {
105			return command{}, false
106		}
107		c.col, c.token = cl, uint32(tok)
108	case "scroll":
109		if len(f) != 4 {
110			return command{}, false
111		}
112		sign := int32(1)
113		switch f[1] {
114		case "down":
115			c.vert = true
116		case "up":
117			c.vert, sign = true, -1
118		case "right":
119		case "left":
120			sign = -1
121		default:
122			return command{}, false
123		}
124		steps, ok1 := num(f[2], 31, maxSteps)
125		cl, ok2 := col(f[3])
126		if !ok1 || !ok2 || steps == 0 {
127			return command{}, false
128		}
129		c.delta, c.col = sign*int32(steps), cl
130	case "hover":
131		cl, ok := col(f[len(f)-1])
132		if len(f) != 2 || !ok {
133			return command{}, false
134		}
135		c.col = cl
136	case "leave":
137		if len(f) != 1 {
138			return command{}, false
139		}
140	case "menu-activate":
141		if len(f) != 3 {
142			return command{}, false
143		}
144		tok, ok1 := num(f[1], 32, 1<<32-1)
145		id, ok2 := num(f[2], 31, 1<<31-1)
146		if !ok1 || !ok2 {
147			return command{}, false
148		}
149		c.token, c.col = uint32(tok), int(id) // col carries the menu item id
150	case "menu-closed":
151		tok, ok := num(f[len(f)-1], 32, 1<<32-1)
152		if len(f) != 2 || !ok {
153			return command{}, false
154		}
155		c.token = uint32(tok)
156	default:
157		return command{}, false
158	}
159	return c, true
160}
161
162// readCommands sends the lines of r to out until r ends or ctx is done. A
163// line longer than maxInputLine is dropped whole.
164func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
165	br := bufio.NewReaderSize(r, maxInputLine)
166	skipping := false
167	for {
168		b, err := br.ReadSlice('\n')
169		switch {
170		case errors.Is(err, bufio.ErrBufferFull):
171			skipping = true // the rest of this line is dropped too
172			continue
173		case err != nil && len(b) == 0:
174			return
175		}
176		if skipping {
177			skipping = false
178			log.Debug("tray: input line too long")
179		} else if err == nil {
180			select {
181			case out <- strings.TrimRight(string(b), "\r\n"):
182			case <-ctx.Done():
183				return
184			}
185		}
186		if err != nil {
187			return
188		}
189	}
190}
191
192// control performs the actions the bar asks for. It owns a second bus
193// connection, so a slow application never holds up the tray's own loop.
194type control struct {
195	t    *Tray
196	dial func() (*zerobus.Conn, error)
197	log  *slog.Logger
198
199	mu   sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
200	conn *zerobus.Conn
201	done bool
202
203	// The tooltip and menu state belong to the run goroutine.
204	tipTimer *time.Timer
205	hov      hover
206	menu     *lastMenu
207
208	// deadline, when set, ends every call of the sequence in progress
209	// (openMenu): a call waits for the smaller of callTimeout and what is left.
210	deadline time.Time
211}
212
213// hover is the icon the pointer rests on, and whether its tooltip is shown.
214type hover struct {
215	active     bool
216	dest, path string
217	col        int
218	shown      bool
219}
220
221// run serves lines until ctx ends.
222func (c *control) run(ctx context.Context, lines <-chan string) {
223	stop := context.AfterFunc(ctx, c.close)
224	defer stop()
225	defer c.close()
226	c.tipTimer = time.NewTimer(time.Hour)
227	c.tipTimer.Stop()
228	defer c.tipTimer.Stop()
229	for {
230		select {
231		case <-ctx.Done():
232			return
233		case <-c.tipTimer.C:
234			c.showTooltip()
235		case line := <-lines:
236			cmd, ok := parseCommand(line)
237			if !ok {
238				c.log.Debug("tray: ignoring input line", "line", line)
239				continue
240			}
241			c.handle(cmd)
242		}
243	}
244}
245
246func (c *control) close() {
247	c.mu.Lock()
248	defer c.mu.Unlock()
249	c.done = true
250	if c.conn != nil {
251		_ = c.conn.Close()
252		c.conn = nil
253	}
254}
255
256// conn2 returns the control connection, dialing it when needed. The dial runs
257// outside the lock, so shutdown never waits for it: if shutdown began meanwhile
258// the new connection is closed and ErrClosed is returned.
259func (c *control) conn2() (*zerobus.Conn, error) {
260	c.mu.Lock()
261	if c.done {
262		c.mu.Unlock()
263		return nil, zerobus.ErrClosed
264	}
265	if conn := c.conn; conn != nil {
266		c.mu.Unlock()
267		return conn, nil
268	}
269	c.mu.Unlock()
270
271	conn, err := c.dial()
272	if err != nil {
273		return nil, err
274	}
275	c.mu.Lock()
276	defer c.mu.Unlock()
277	if c.done {
278		_ = conn.Close()
279		return nil, zerobus.ErrClosed
280	}
281	c.conn = conn
282	return conn, nil
283}
284
285// drop forgets conn after a transport error; the next call dials again.
286func (c *control) drop(conn *zerobus.Conn) {
287	c.mu.Lock()
288	defer c.mu.Unlock()
289	_ = conn.Close()
290	if c.conn == conn {
291		c.conn = nil
292	}
293}
294
295func (c *control) handle(cmd command) {
296	switch cmd.verb {
297	case "hover":
298		tg, ok := c.t.target(cmd.col)
299		if !ok { // between icons
300			c.endHover()
301			return
302		}
303		if c.hov.active && c.hov.dest == tg.Dest && c.hov.path == tg.Path {
304			return // still on the same icon
305		}
306		c.endHover()
307		c.hov = hover{active: true, dest: tg.Dest, path: tg.Path, col: cmd.col}
308		c.tipTimer.Reset(tooltipDelay)
309	case "leave":
310		c.endHover()
311	case "click":
312		c.endHover()
313		tg, ok := c.t.target(cmd.col)
314		if !ok {
315			return
316		}
317		switch cmd.button {
318		case btnLeft:
319			if tg.IsMenu {
320				c.openMenu(tg, cmd.token)
321				return
322			}
323			err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
324			if isUnknownMethod(err) {
325				c.openMenu(tg, cmd.token)
326			}
327		case btnMiddle:
328			c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
329		case btnRight:
330			c.openMenu(tg, cmd.token)
331		}
332	case "menu-activate":
333		c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id
334	case "menu-closed":
335		c.menuClosed(cmd.token)
336	case "scroll":
337		tg, ok := c.t.target(cmd.col)
338		if !ok {
339			return
340		}
341		orientation := "horizontal"
342		if cmd.vert {
343			orientation = "vertical"
344		}
345		c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
346	}
347}
348
349// endHover stops the tooltip timer and, if the tooltip was sent, tells the bar
350// to close it.
351func (c *control) endHover() {
352	c.tipTimer.Stop()
353	if c.hov.shown {
354		c.emit(map[string]string{"type": "close"})
355	}
356	c.hov = hover{}
357}
358
359// showTooltip sends the tooltip of the hovered icon when the pointer is still
360// on it and the application has something to say.
361func (c *control) showTooltip() {
362	if !c.hov.active || c.hov.shown {
363		return
364	}
365	tg, ok := c.t.target(c.hov.col)
366	if !ok || tg.Dest != c.hov.dest || tg.Path != c.hov.path {
367		c.endHover()
368		return
369	}
370	title, body := plainText(tg.TipTitle), plainText(tg.TipBody)
371	if title == "" && body == "" {
372		return
373	}
374	c.hov.shown = true
375	c.emit(struct {
376		Type  string `json:"type"`
377		Col   int    `json:"col"`
378		Width int    `json:"width"`
379		Title string `json:"title"`
380		Body  string `json:"body"`
381	}{"tooltip", tg.Start, tg.Width, title, body})
382}
383
384// emit writes a control line for the bar.
385func (c *control) emit(v any) {
386	var buf bytes.Buffer
387	buf.WriteString(controlPrefix)
388	enc := json.NewEncoder(&buf)
389	enc.SetEscapeHTML(false) // "&" and "<" stay readable; the bar parses JSON either way
390	if err := enc.Encode(v); err != nil {
391		c.log.Warn("tray: cannot encode a control line", "err", err)
392		return
393	}
394	buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
395	buf.WriteString(controlSuffix)
396	c.write(buf.Bytes())
397}
398
399// writeLine writes the control line whose JSON is b.
400func (c *control) writeLine(b []byte) {
401	line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix))
402	line = append(append(append(line, controlPrefix...), b...), controlSuffix...)
403	c.write(line)
404}
405
406func (c *control) write(line []byte) {
407	if _, err := c.t.opt.Out.Write(line); err != nil {
408		c.log.Debug("tray: cannot write a control line", "err", err)
409	}
410}
411
412// contextMenu asks the application to show its own menu.
413func (c *control) contextMenu(tg Target) {
414	c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
415}
416
417func isUnknownMethod(err error) bool {
418	var e *zerobus.Error
419	return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
420}
421
422// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
423// reply is returned as is; a transport failure or a timeout drops the
424// connection and is logged.
425func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
426	return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil)
427}
428
429// callOn is call for any interface. reply, if set, reads the body of a
430// successful reply; it must not keep what it reads.
431func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error {
432	conn, err := c.conn2()
433	if err != nil {
434		if !errors.Is(err, zerobus.ErrClosed) {
435			c.log.Warn("tray: cannot reach the session bus", "err", err)
436		}
437		return err
438	}
439	wait := callTimeout
440	if !c.deadline.IsZero() {
441		if wait = min(wait, time.Until(c.deadline)); wait <= 0 {
442			return errBudget
443		}
444	}
445	args(conn.NewCall(dest, path, iface, member, sig))
446	timer := time.AfterFunc(wait, func() { _ = conn.Close() })
447	m, err := conn.Call()
448	if err == nil && reply != nil {
449		reply(m)
450	}
451	expired := !timer.Stop()
452	var refused *zerobus.Error
453	if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired {
454		// An error reply, or a name or path the library would not send:
455		// the connection is fine.
456		c.log.Debug("tray: the application refused", "method", member, "err", err)
457		return err
458	}
459	if err != nil || expired {
460		c.drop(conn)
461		if err == nil {
462			err = zerobus.ErrClosed
463		}
464		c.mu.Lock()
465		stopping := c.done
466		c.mu.Unlock()
467		if !stopping {
468			c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err)
469		}
470	}
471	return err
472}
473
474// errBudget is returned by a call that did not start because the time for its
475// sequence ran out.
476var errBudget = errors.New("tray: out of time")
477
478// menuBudget is how long opening a menu may take in all, whatever the number
479// of calls it makes. A variable for the tests.
480var menuBudget = 4 * time.Second
481
482// closeBudget is the time given to tell an application its menu will not be
483// shown after all.
484const closeBudget = time.Second
485
486// transportFailed reports whether err means the call did not complete, as
487// opposed to the application answering with an error.
488func transportFailed(err error) bool {
489	var refused *zerobus.Error
490	return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid)
491}
492
493// openMenu shows the application's menu for the press numbered token: its
494// dbusmenu when it has one, else it asks the application to show its own.
495// The calls share one deadline (menuBudget). When they fail after the
496// application was told "opened", it is told "closed" and the menu forgotten.
497func (c *control) openMenu(tg Target, token uint32) {
498	if tg.Menu == "" || tg.Menu == noMenuPath {
499		c.contextMenu(tg)
500		return
501	}
502	c.menu = nil // an older menu is superseded
503	c.deadline = time.Now().Add(menuBudget)
504	defer func() { c.deadline = time.Time{} }()
505
506	// Ignore refusals: many applications do not implement these.
507	err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
508	if err != nil && (transportFailed(err) || errors.Is(err, errBudget)) {
509		return
510	}
511	err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
512	if errors.Is(err, errBudget) {
513		return // never sent
514	}
515	// From here on the application may believe its menu is open, even if the
516	// call timed out: it may have been delivered.
517	if (err != nil && transportFailed(err)) || !c.sendMenu(tg, token) {
518		c.deadline = time.Now().Add(closeBudget)
519		c.menuEvent(tg.Dest, tg.Menu, 0, "closed")
520		c.menu = nil
521	}
522}
523
524// sendMenu reads the menu of tg and writes it for the bar. It reports false
525// when there is no menu to show.
526func (c *control) sendMenu(tg Target, token uint32) bool {
527	var items []menuItem
528	var decodeErr error
529	err := c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
530		e.Int32(0)
531		e.Int32(-1)
532		a := e.BeginArray('s')
533		e.EndArray(a)
534	}, func(m *zerobus.Message) {
535		if m.Signature != "u"+layoutSig {
536			decodeErr = errors.New("unexpected layout signature " + m.Signature)
537			return
538		}
539		items, _, decodeErr = decodeLayout(m.Body())
540	})
541	if err != nil {
542		return false
543	}
544	if decodeErr != nil {
545		c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
546		return false
547	}
548	if len(items) == 0 {
549		c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
550		return false
551	}
552	line, ids, err := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
553	if err != nil {
554		c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", err)
555		return false
556	}
557	c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
558	c.writeLine(line)
559	return true
560}
561
562// menuEvent sends a dbusmenu Event with no data.
563func (c *control) menuEvent(dest, path string, id int32, event string) error {
564	return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) {
565		e.Int32(id)
566		e.Str(event)
567		e.Variant("i")
568		e.Int32(0)
569		e.Uint32(0)
570	}, nil)
571}
572
573// menuActivate carries out a choice of the bar: only for the menu it was last
574// given, and only for an item that menu holds.
575func (c *control) menuActivate(token uint32, id int32) {
576	m := c.menu
577	if m == nil || m.token != token {
578		c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token)
579		return
580	}
581	if _, found := slices.BinarySearch(m.ids, id); !found {
582		c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id)
583		return
584	}
585	c.menu = nil
586	c.menuEvent(m.dest, m.path, id, "clicked")
587}
588
589// menuClosed tells the application its menu is closed.
590func (c *control) menuClosed(token uint32) {
591	m := c.menu
592	if m == nil || m.token != token {
593		return
594	}
595	c.menu = nil
596	c.menuEvent(m.dest, m.path, 0, "closed")
597}

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