detached 3dcb344

feat(tray): tooltips

The tray reads ToolTip (and falls back to Title), follows NewToolTip, and after 500 ms of hover sends a tooltip control line with the markup stripped. Leaving the icon or clicking sends a close line.
commit
3dcb344c6c70fb02982c462347c56620a2486215
author
brice · · 2026-10-09 05:21 UTC
parent
ea6e2cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +389 -2
M internal/tray/control.go +95 -0
@@ -2,7 +2,9 @@ package tray
22
33 import (
44 "bufio"
5+ "bytes"
56 "context"
7+ "encoding/json"
68 "errors"
79 "io"
810 "log/slog"
@@ -22,6 +24,15 @@ const (
2224 maxInputLine = 256
2325 // maxSteps bounds the steps of a scroll line.
2426 maxSteps = 1 << 16
27+ // tooltipDelay is how long the pointer rests on an icon before its
28+ // tooltip is sent to the bar.
29+ tooltipDelay = 500 * time.Millisecond
30+)
31+
32+// Control lines to the bar: OSC 777 "neferbar", a JSON object, BEL, newline.
33+const (
34+ controlPrefix = "\x1b]777;neferbar;"
35+ controlSuffix = "\x07\n"
2536 )
2637
2738 // lockedWriter serializes writes from the main loop and the control loop to
@@ -187,6 +198,18 @@ type control struct {
187198 mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
188199 conn *zerobus.Conn
189200 done bool
201+
202+ // The tooltip state belongs to the run goroutine.
203+ tipTimer *time.Timer
204+ hov hover
205+}
206+
207+// hover is the icon the pointer rests on, and whether its tooltip is shown.
208+type hover struct {
209+ active bool
210+ dest, path string
211+ col int
212+ shown bool
190213 }
191214
192215 // run serves lines until ctx ends.
@@ -194,10 +217,15 @@ func (c *control) run(ctx context.Context, lines <-chan string) {
194217 stop := context.AfterFunc(ctx, c.close)
195218 defer stop()
196219 defer c.close()
220+ c.tipTimer = time.NewTimer(time.Hour)
221+ c.tipTimer.Stop()
222+ defer c.tipTimer.Stop()
197223 for {
198224 select {
199225 case <-ctx.Done():
200226 return
227+ case <-c.tipTimer.C:
228+ c.showTooltip()
201229 case line := <-lines:
202230 cmd, ok := parseCommand(line)
203231 if !ok {
@@ -260,7 +288,22 @@ func (c *control) drop(conn *zerobus.Conn) {
260288
261289 func (c *control) handle(cmd command) {
262290 switch cmd.verb {
291+ case "hover":
292+ tg, ok := c.t.target(cmd.col)
293+ if !ok { // between icons
294+ c.endHover()
295+ return
296+ }
297+ if c.hov.active && c.hov.dest == tg.Dest && c.hov.path == tg.Path {
298+ return // still on the same icon
299+ }
300+ c.endHover()
301+ c.hov = hover{active: true, dest: tg.Dest, path: tg.Path, col: cmd.col}
302+ c.tipTimer.Reset(tooltipDelay)
303+ case "leave":
304+ c.endHover()
263305 case "click":
306+ c.endHover()
264307 tg, ok := c.t.target(cmd.col)
265308 if !ok {
266309 return
@@ -293,6 +336,58 @@ func (c *control) handle(cmd command) {
293336 }
294337 }
295338
339+// endHover stops the tooltip timer and, if the tooltip was sent, tells the bar
340+// to close it.
341+func (c *control) endHover() {
342+ c.tipTimer.Stop()
343+ if c.hov.shown {
344+ c.emit(map[string]string{"type": "close"})
345+ }
346+ c.hov = hover{}
347+}
348+
349+// showTooltip sends the tooltip of the hovered icon when the pointer is still
350+// on it and the application has something to say.
351+func (c *control) showTooltip() {
352+ if !c.hov.active || c.hov.shown {
353+ return
354+ }
355+ tg, ok := c.t.target(c.hov.col)
356+ if !ok || tg.Dest != c.hov.dest || tg.Path != c.hov.path {
357+ c.endHover()
358+ return
359+ }
360+ title, body := plainText(tg.TipTitle), plainText(tg.TipBody)
361+ if title == "" && body == "" {
362+ return
363+ }
364+ c.hov.shown = true
365+ c.emit(struct {
366+ Type string `json:"type"`
367+ Col int `json:"col"`
368+ Width int `json:"width"`
369+ Title string `json:"title"`
370+ Body string `json:"body"`
371+ }{"tooltip", tg.Start, tg.Width, title, body})
372+}
373+
374+// emit writes a control line for the bar.
375+func (c *control) emit(v any) {
376+ var buf bytes.Buffer
377+ buf.WriteString(controlPrefix)
378+ enc := json.NewEncoder(&buf)
379+ enc.SetEscapeHTML(false) // "&" and "<" stay readable; the bar parses JSON either way
380+ if err := enc.Encode(v); err != nil {
381+ c.log.Warn("tray: cannot encode a control line", "err", err)
382+ return
383+ }
384+ buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
385+ buf.WriteString(controlSuffix)
386+ if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil {
387+ c.log.Debug("tray: cannot write a control line", "err", err)
388+ }
389+}
390+
296391 // contextMenu asks the application to show its own menu.
297392 func (c *control) contextMenu(tg Target) {
298393 c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
@@ -2,7 +2,9 @@ package tray
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "bytes"
5 "context"6 "context"
7+ "encoding/json"
6 "errors"8 "errors"
7 "io"9 "io"
8 "log/slog"10 "log/slog"
@@ -22,6 +24,15 @@ const (
22 maxInputLine = 25624 maxInputLine = 256
23 // maxSteps bounds the steps of a scroll line.25 // maxSteps bounds the steps of a scroll line.
24 maxSteps = 1 << 1626 maxSteps = 1 << 16
27+ // tooltipDelay is how long the pointer rests on an icon before its
28+ // tooltip is sent to the bar.
29+ tooltipDelay = 500 * time.Millisecond
30+)
31+
32+// Control lines to the bar: OSC 777 "neferbar", a JSON object, BEL, newline.
33+const (
34+ controlPrefix = "\x1b]777;neferbar;"
35+ controlSuffix = "\x07\n"
25 )36 )
26 37
27 // lockedWriter serializes writes from the main loop and the control loop to38 // lockedWriter serializes writes from the main loop and the control loop to
@@ -187,6 +198,18 @@ type control struct {
187 mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine198 mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
188 conn *zerobus.Conn199 conn *zerobus.Conn
189 done bool200 done bool
201+
202+ // The tooltip state belongs to the run goroutine.
203+ tipTimer *time.Timer
204+ hov hover
205+}
206+
207+// hover is the icon the pointer rests on, and whether its tooltip is shown.
208+type hover struct {
209+ active bool
210+ dest, path string
211+ col int
212+ shown bool
190 }213 }
191 214
192 // run serves lines until ctx ends.215 // run serves lines until ctx ends.
@@ -194,10 +217,15 @@ func (c *control) run(ctx context.Context, lines <-chan string) {
194 stop := context.AfterFunc(ctx, c.close)217 stop := context.AfterFunc(ctx, c.close)
195 defer stop()218 defer stop()
196 defer c.close()219 defer c.close()
220+ c.tipTimer = time.NewTimer(time.Hour)
221+ c.tipTimer.Stop()
222+ defer c.tipTimer.Stop()
197 for {223 for {
198 select {224 select {
199 case <-ctx.Done():225 case <-ctx.Done():
200 return226 return
227+ case <-c.tipTimer.C:
228+ c.showTooltip()
201 case line := <-lines:229 case line := <-lines:
202 cmd, ok := parseCommand(line)230 cmd, ok := parseCommand(line)
203 if !ok {231 if !ok {
@@ -260,7 +288,22 @@ func (c *control) drop(conn *zerobus.Conn) {
260 288
261 func (c *control) handle(cmd command) {289 func (c *control) handle(cmd command) {
262 switch cmd.verb {290 switch cmd.verb {
291+ case "hover":
292+ tg, ok := c.t.target(cmd.col)
293+ if !ok { // between icons
294+ c.endHover()
295+ return
296+ }
297+ if c.hov.active && c.hov.dest == tg.Dest && c.hov.path == tg.Path {
298+ return // still on the same icon
299+ }
300+ c.endHover()
301+ c.hov = hover{active: true, dest: tg.Dest, path: tg.Path, col: cmd.col}
302+ c.tipTimer.Reset(tooltipDelay)
303+ case "leave":
304+ c.endHover()
263 case "click":305 case "click":
306+ c.endHover()
264 tg, ok := c.t.target(cmd.col)307 tg, ok := c.t.target(cmd.col)
265 if !ok {308 if !ok {
266 return309 return
@@ -293,6 +336,58 @@ func (c *control) handle(cmd command) {
293 }336 }
294 }337 }
295 338
339+// endHover stops the tooltip timer and, if the tooltip was sent, tells the bar
340+// to close it.
341+func (c *control) endHover() {
342+ c.tipTimer.Stop()
343+ if c.hov.shown {
344+ c.emit(map[string]string{"type": "close"})
345+ }
346+ c.hov = hover{}
347+}
348+
349+// showTooltip sends the tooltip of the hovered icon when the pointer is still
350+// on it and the application has something to say.
351+func (c *control) showTooltip() {
352+ if !c.hov.active || c.hov.shown {
353+ return
354+ }
355+ tg, ok := c.t.target(c.hov.col)
356+ if !ok || tg.Dest != c.hov.dest || tg.Path != c.hov.path {
357+ c.endHover()
358+ return
359+ }
360+ title, body := plainText(tg.TipTitle), plainText(tg.TipBody)
361+ if title == "" && body == "" {
362+ return
363+ }
364+ c.hov.shown = true
365+ c.emit(struct {
366+ Type string `json:"type"`
367+ Col int `json:"col"`
368+ Width int `json:"width"`
369+ Title string `json:"title"`
370+ Body string `json:"body"`
371+ }{"tooltip", tg.Start, tg.Width, title, body})
372+}
373+
374+// emit writes a control line for the bar.
375+func (c *control) emit(v any) {
376+ var buf bytes.Buffer
377+ buf.WriteString(controlPrefix)
378+ enc := json.NewEncoder(&buf)
379+ enc.SetEscapeHTML(false) // "&" and "<" stay readable; the bar parses JSON either way
380+ if err := enc.Encode(v); err != nil {
381+ c.log.Warn("tray: cannot encode a control line", "err", err)
382+ return
383+ }
384+ buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
385+ buf.WriteString(controlSuffix)
386+ if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil {
387+ c.log.Debug("tray: cannot write a control line", "err", err)
388+ }
389+}
390+
296 // contextMenu asks the application to show its own menu.391 // contextMenu asks the application to show its own menu.
297 func (c *control) contextMenu(tg Target) {392 func (c *control) contextMenu(tg Target) {
298 c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })393 c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
A internal/tray/markup.go +95 -0
@@ -0,0 +1,95 @@
1+package tray
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "unicode/utf8"
7+)
8+
9+// maxTipBytes bounds a tooltip text sent to the bar.
10+const maxTipBytes = 512
11+
12+// plainText turns the markup an application puts in a tooltip into plain
13+// text: tags are dropped, <br> becomes a newline, and the common entities are
14+// resolved. The result is cut to maxTipBytes at a rune boundary. A "<" with no
15+// closing ">" is kept as text.
16+func plainText(s string) string {
17+ var b strings.Builder
18+ for i := 0; i < len(s) && b.Len() < maxTipBytes+utf8.UTFMax; {
19+ switch c := s[i]; {
20+ case c == '<':
21+ end := strings.IndexByte(s[i:], '>')
22+ if end < 0 {
23+ b.WriteByte(c)
24+ i++
25+ continue
26+ }
27+ if isBreak(s[i+1 : i+end]) {
28+ b.WriteByte('\n')
29+ }
30+ i += end + 1
31+ case c == '&':
32+ if r, n, ok := entity(s[i:]); ok {
33+ b.WriteRune(r)
34+ i += n
35+ continue
36+ }
37+ b.WriteByte(c)
38+ i++
39+ default:
40+ b.WriteByte(c)
41+ i++
42+ }
43+ }
44+ return cutRunes(b.String(), maxTipBytes)
45+}
46+
47+// isBreak reports whether the inside of a tag is <br>, <br/> or <br />.
48+func isBreak(tag string) bool {
49+ tag = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(tag), "/"))
50+ return strings.EqualFold(tag, "br")
51+}
52+
53+// entity decodes the entity at the start of s ("&amp;", "&#65;", "&#x41;").
54+func entity(s string) (r rune, n int, ok bool) {
55+ end := strings.IndexByte(s, ';')
56+ if end < 2 || end > 10 {
57+ return 0, 0, false
58+ }
59+ switch name := s[1:end]; name {
60+ case "amp":
61+ return '&', end + 1, true
62+ case "lt":
63+ return '<', end + 1, true
64+ case "gt":
65+ return '>', end + 1, true
66+ case "quot":
67+ return '"', end + 1, true
68+ case "apos":
69+ return '\'', end + 1, true
70+ default:
71+ if name[0] != '#' {
72+ return 0, 0, false
73+ }
74+ num, base := name[1:], 10
75+ if len(num) > 0 && (num[0] == 'x' || num[0] == 'X') {
76+ num, base = num[1:], 16
77+ }
78+ v, err := strconv.ParseUint(num, base, 32)
79+ if err != nil || !utf8.ValidRune(rune(v)) || v == 0 {
80+ return 0, 0, false
81+ }
82+ return rune(v), end + 1, true
83+ }
84+}
85+
86+// cutRunes cuts s to at most max bytes without splitting a rune.
87+func cutRunes(s string, max int) string {
88+ if len(s) <= max {
89+ return s
90+ }
91+ for max > 0 && !utf8.RuneStart(s[max]) {
92+ max--
93+ }
94+ return s[:max]
95+}
@@ -0,0 +1,95 @@
1+package tray
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "unicode/utf8"
7+)
8+
9+// maxTipBytes bounds a tooltip text sent to the bar.
10+const maxTipBytes = 512
11+
12+// plainText turns the markup an application puts in a tooltip into plain
13+// text: tags are dropped, <br> becomes a newline, and the common entities are
14+// resolved. The result is cut to maxTipBytes at a rune boundary. A "<" with no
15+// closing ">" is kept as text.
16+func plainText(s string) string {
17+ var b strings.Builder
18+ for i := 0; i < len(s) && b.Len() < maxTipBytes+utf8.UTFMax; {
19+ switch c := s[i]; {
20+ case c == '<':
21+ end := strings.IndexByte(s[i:], '>')
22+ if end < 0 {
23+ b.WriteByte(c)
24+ i++
25+ continue
26+ }
27+ if isBreak(s[i+1 : i+end]) {
28+ b.WriteByte('\n')
29+ }
30+ i += end + 1
31+ case c == '&':
32+ if r, n, ok := entity(s[i:]); ok {
33+ b.WriteRune(r)
34+ i += n
35+ continue
36+ }
37+ b.WriteByte(c)
38+ i++
39+ default:
40+ b.WriteByte(c)
41+ i++
42+ }
43+ }
44+ return cutRunes(b.String(), maxTipBytes)
45+}
46+
47+// isBreak reports whether the inside of a tag is <br>, <br/> or <br />.
48+func isBreak(tag string) bool {
49+ tag = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(tag), "/"))
50+ return strings.EqualFold(tag, "br")
51+}
52+
53+// entity decodes the entity at the start of s ("&amp;", "&#65;", "&#x41;").
54+func entity(s string) (r rune, n int, ok bool) {
55+ end := strings.IndexByte(s, ';')
56+ if end < 2 || end > 10 {
57+ return 0, 0, false
58+ }
59+ switch name := s[1:end]; name {
60+ case "amp":
61+ return '&', end + 1, true
62+ case "lt":
63+ return '<', end + 1, true
64+ case "gt":
65+ return '>', end + 1, true
66+ case "quot":
67+ return '"', end + 1, true
68+ case "apos":
69+ return '\'', end + 1, true
70+ default:
71+ if name[0] != '#' {
72+ return 0, 0, false
73+ }
74+ num, base := name[1:], 10
75+ if len(num) > 0 && (num[0] == 'x' || num[0] == 'X') {
76+ num, base = num[1:], 16
77+ }
78+ v, err := strconv.ParseUint(num, base, 32)
79+ if err != nil || !utf8.ValidRune(rune(v)) || v == 0 {
80+ return 0, 0, false
81+ }
82+ return rune(v), end + 1, true
83+ }
84+}
85+
86+// cutRunes cuts s to at most max bytes without splitting a rune.
87+func cutRunes(s string, max int) string {
88+ if len(s) <= max {
89+ return s
90+ }
91+ for max > 0 && !utf8.RuneStart(s[max]) {
92+ max--
93+ }
94+ return s[:max]
95+}
M internal/tray/snapshot.go +3 -0
@@ -11,6 +11,9 @@ type Target struct {
1111 IsMenu bool // the item only offers a menu: Activate means "open it"
1212 Start int // first column of the icon in the line
1313 Width int // cells the icon takes
14+ // TipTitle and TipBody are the item's tooltip as it sent it, markup
15+ // included; the title falls back to the item's Title.
16+ TipTitle, TipBody string
1417 }
1518
1619 // snapshot is the list of targets of the last line printed. The main loop
@@ -11,6 +11,9 @@ type Target struct {
11 IsMenu bool // the item only offers a menu: Activate means "open it"11 IsMenu bool // the item only offers a menu: Activate means "open it"
12 Start int // first column of the icon in the line12 Start int // first column of the icon in the line
13 Width int // cells the icon takes13 Width int // cells the icon takes
14+ // TipTitle and TipBody are the item's tooltip as it sent it, markup
15+ // included; the title falls back to the item's Title.
16+ TipTitle, TipBody string
14 }17 }
15 18
16 // snapshot is the list of targets of the last line printed. The main loop19 // snapshot is the list of targets of the last line printed. The main loop
A internal/tray/tooltip_test.go +149 -0
@@ -0,0 +1,149 @@
1+package tray
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+)
8+
9+func TestPlainText(t *testing.T) {
10+ for in, want := range map[string]string{
11+ "": "",
12+ "plain": "plain",
13+ "<b>bold</b> text": "bold text",
14+ "a<br>b": "a\nb",
15+ "a<br/>b<BR />c": "a\nb\nc",
16+ "<p style=\"x\">hi</p>": "hi",
17+ "Tom &amp; Jerry": "Tom & Jerry",
18+ "&lt;tag&gt; &quot;q&quot; &apos;s&apos;": "<tag> \"q\" 's'",
19+ "&#65;&#x42;&#X43;": "ABC",
20+ "&#0;&#xD800;&#99999999999;": "&#0;&#xD800;&#99999999999;",
21+ "&unknown; &amp": "&unknown; &amp",
22+ "1 < 2": "1 < 2",
23+ "<<b>x</b>": "x",
24+ "<img src='x>y'>z": "y'>z",
25+ "\u00e9&#233;": "éé",
26+ } {
27+ if got := plainText(in); got != want {
28+ t.Errorf("plainText(%q) = %q, want %q", in, got, want)
29+ }
30+ }
31+ long := plainText(strings.Repeat("é", 1000))
32+ if len(long) > maxTipBytes || len(long)%2 != 0 || strings.ContainsRune(long, '\uFFFD') {
33+ t.Errorf("long text: %d bytes", len(long))
34+ }
35+ if got := plainText(strings.Repeat("<b>", 100000) + "x"); got != "x" {
36+ t.Errorf("many tags = %q", got)
37+ }
38+}
39+
40+// controlLine waits for a control line with the given type and returns its JSON.
41+func controlLine(t *testing.T, out *lines, typ string, within time.Duration) string {
42+ t.Helper()
43+ timeout := time.After(within)
44+ for {
45+ select {
46+ case s, ok := <-out.ch:
47+ if !ok {
48+ t.Fatalf("output ended while waiting for a %s line", typ)
49+ }
50+ if j, found := strings.CutPrefix(s, controlPrefix); found && strings.HasSuffix(j, "\x07") &&
51+ strings.Contains(j, `"type":"`+typ+`"`) {
52+ return strings.TrimSuffix(j, "\x07")
53+ }
54+ case <-timeout:
55+ t.Fatalf("no %s control line within %v", typ, within)
56+ }
57+ }
58+}
59+
60+// noControlLine fails when a control line arrives within d.
61+func noControlLine(t *testing.T, out *lines, d time.Duration) {
62+ t.Helper()
63+ timeout := time.After(d)
64+ for {
65+ select {
66+ case s, ok := <-out.ch:
67+ if ok && strings.HasPrefix(s, controlPrefix) {
68+ t.Fatalf("unexpected control line %q", s)
69+ }
70+ case <-timeout:
71+ return
72+ }
73+ }
74+}
75+
76+func TestTooltipAfterHover(t *testing.T) {
77+ steam, _, in, out := setup(t)
78+ steam.setTip(t, "Steam", "<b>3</b> friends online<br>&amp; 1 game")
79+ // Wait for the refresh the NewToolTip signal triggers.
80+ deadline := time.Now().Add(5 * time.Second)
81+ var got string
82+ for time.Now().Before(deadline) {
83+ in.send(t, "hover 0")
84+ select {
85+ case s := <-out.ch:
86+ if strings.HasPrefix(s, controlPrefix) {
87+ got = s
88+ }
89+ case <-time.After(900 * time.Millisecond):
90+ }
91+ if got != "" {
92+ break
93+ }
94+ in.send(t, "leave")
95+ }
96+ want := controlPrefix + `{"type":"tooltip","col":0,"width":1,"title":"Steam","body":"3 friends online\n& 1 game"}` + "\x07"
97+ if got != want {
98+ t.Fatalf("tooltip line = %q\nwant %q", got, want)
99+ }
100+ // The pointer leaves: the tooltip closes.
101+ in.send(t, "leave")
102+ if j := controlLine(t, out, "close", time.Second); j != `{"type":"close"}` {
103+ t.Fatalf("close line = %q", j)
104+ }
105+}
106+
107+func TestTooltipNotSentWhenLeftEarlyOrOnClick(t *testing.T) {
108+ steam, obs, in, out := setup(t)
109+ steam.run(t, func() { steam.tip = &[2]string{"Steam", "x"} })
110+ obs.run(t, func() { obs.title = "Obsidian" })
111+ steam.setTip(t, "Steam", "x") // refresh both through the signal path
112+ obs.setTip(t, "", "")
113+ time.Sleep(200 * time.Millisecond)
114+
115+ in.send(t, "hover 0")
116+ time.Sleep(150 * time.Millisecond)
117+ in.send(t, "leave")
118+ noControlLine(t, out, 700*time.Millisecond)
119+
120+ // A click before the delay cancels the tooltip too, without a close line.
121+ in.send(t, "hover 0")
122+ time.Sleep(100 * time.Millisecond)
123+ in.send(t, "click left 0 1")
124+ expectCall(t, steam, "Activate(0,0)")
125+ noControlLine(t, out, 700*time.Millisecond)
126+
127+ // Moving between the gap and another icon restarts the delay.
128+ in.send(t, "hover 1", "hover 2")
129+ start := time.Now()
130+ j := controlLine(t, out, "tooltip", 2*time.Second)
131+ if d := time.Since(start); d < 400*time.Millisecond {
132+ t.Fatalf("the tooltip came after %v, want about 500ms", d)
133+ }
134+ // An item with no tooltip shows its Title.
135+ if !strings.Contains(j, `"title":"Obsidian"`) || !strings.Contains(j, `"col":2`) {
136+ t.Fatalf("tooltip = %q", j)
137+ }
138+ // A click after it was shown closes it.
139+ in.send(t, "click left 2 2")
140+ controlLine(t, out, "close", time.Second)
141+}
142+
143+func TestTooltipWithNothingToSayIsNotSent(t *testing.T) {
144+ _, _, in, out := setup(t) // fake items without ToolTip or Title
145+ in.send(t, "hover 0")
146+ noControlLine(t, out, 900*time.Millisecond)
147+ in.send(t, "leave")
148+ noControlLine(t, out, 100*time.Millisecond)
149+}
@@ -0,0 +1,149 @@
1+package tray
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+)
8+
9+func TestPlainText(t *testing.T) {
10+ for in, want := range map[string]string{
11+ "": "",
12+ "plain": "plain",
13+ "<b>bold</b> text": "bold text",
14+ "a<br>b": "a\nb",
15+ "a<br/>b<BR />c": "a\nb\nc",
16+ "<p style=\"x\">hi</p>": "hi",
17+ "Tom &amp; Jerry": "Tom & Jerry",
18+ "&lt;tag&gt; &quot;q&quot; &apos;s&apos;": "<tag> \"q\" 's'",
19+ "&#65;&#x42;&#X43;": "ABC",
20+ "&#0;&#xD800;&#99999999999;": "&#0;&#xD800;&#99999999999;",
21+ "&unknown; &amp": "&unknown; &amp",
22+ "1 < 2": "1 < 2",
23+ "<<b>x</b>": "x",
24+ "<img src='x>y'>z": "y'>z",
25+ "\u00e9&#233;": "éé",
26+ } {
27+ if got := plainText(in); got != want {
28+ t.Errorf("plainText(%q) = %q, want %q", in, got, want)
29+ }
30+ }
31+ long := plainText(strings.Repeat("é", 1000))
32+ if len(long) > maxTipBytes || len(long)%2 != 0 || strings.ContainsRune(long, '\uFFFD') {
33+ t.Errorf("long text: %d bytes", len(long))
34+ }
35+ if got := plainText(strings.Repeat("<b>", 100000) + "x"); got != "x" {
36+ t.Errorf("many tags = %q", got)
37+ }
38+}
39+
40+// controlLine waits for a control line with the given type and returns its JSON.
41+func controlLine(t *testing.T, out *lines, typ string, within time.Duration) string {
42+ t.Helper()
43+ timeout := time.After(within)
44+ for {
45+ select {
46+ case s, ok := <-out.ch:
47+ if !ok {
48+ t.Fatalf("output ended while waiting for a %s line", typ)
49+ }
50+ if j, found := strings.CutPrefix(s, controlPrefix); found && strings.HasSuffix(j, "\x07") &&
51+ strings.Contains(j, `"type":"`+typ+`"`) {
52+ return strings.TrimSuffix(j, "\x07")
53+ }
54+ case <-timeout:
55+ t.Fatalf("no %s control line within %v", typ, within)
56+ }
57+ }
58+}
59+
60+// noControlLine fails when a control line arrives within d.
61+func noControlLine(t *testing.T, out *lines, d time.Duration) {
62+ t.Helper()
63+ timeout := time.After(d)
64+ for {
65+ select {
66+ case s, ok := <-out.ch:
67+ if ok && strings.HasPrefix(s, controlPrefix) {
68+ t.Fatalf("unexpected control line %q", s)
69+ }
70+ case <-timeout:
71+ return
72+ }
73+ }
74+}
75+
76+func TestTooltipAfterHover(t *testing.T) {
77+ steam, _, in, out := setup(t)
78+ steam.setTip(t, "Steam", "<b>3</b> friends online<br>&amp; 1 game")
79+ // Wait for the refresh the NewToolTip signal triggers.
80+ deadline := time.Now().Add(5 * time.Second)
81+ var got string
82+ for time.Now().Before(deadline) {
83+ in.send(t, "hover 0")
84+ select {
85+ case s := <-out.ch:
86+ if strings.HasPrefix(s, controlPrefix) {
87+ got = s
88+ }
89+ case <-time.After(900 * time.Millisecond):
90+ }
91+ if got != "" {
92+ break
93+ }
94+ in.send(t, "leave")
95+ }
96+ want := controlPrefix + `{"type":"tooltip","col":0,"width":1,"title":"Steam","body":"3 friends online\n& 1 game"}` + "\x07"
97+ if got != want {
98+ t.Fatalf("tooltip line = %q\nwant %q", got, want)
99+ }
100+ // The pointer leaves: the tooltip closes.
101+ in.send(t, "leave")
102+ if j := controlLine(t, out, "close", time.Second); j != `{"type":"close"}` {
103+ t.Fatalf("close line = %q", j)
104+ }
105+}
106+
107+func TestTooltipNotSentWhenLeftEarlyOrOnClick(t *testing.T) {
108+ steam, obs, in, out := setup(t)
109+ steam.run(t, func() { steam.tip = &[2]string{"Steam", "x"} })
110+ obs.run(t, func() { obs.title = "Obsidian" })
111+ steam.setTip(t, "Steam", "x") // refresh both through the signal path
112+ obs.setTip(t, "", "")
113+ time.Sleep(200 * time.Millisecond)
114+
115+ in.send(t, "hover 0")
116+ time.Sleep(150 * time.Millisecond)
117+ in.send(t, "leave")
118+ noControlLine(t, out, 700*time.Millisecond)
119+
120+ // A click before the delay cancels the tooltip too, without a close line.
121+ in.send(t, "hover 0")
122+ time.Sleep(100 * time.Millisecond)
123+ in.send(t, "click left 0 1")
124+ expectCall(t, steam, "Activate(0,0)")
125+ noControlLine(t, out, 700*time.Millisecond)
126+
127+ // Moving between the gap and another icon restarts the delay.
128+ in.send(t, "hover 1", "hover 2")
129+ start := time.Now()
130+ j := controlLine(t, out, "tooltip", 2*time.Second)
131+ if d := time.Since(start); d < 400*time.Millisecond {
132+ t.Fatalf("the tooltip came after %v, want about 500ms", d)
133+ }
134+ // An item with no tooltip shows its Title.
135+ if !strings.Contains(j, `"title":"Obsidian"`) || !strings.Contains(j, `"col":2`) {
136+ t.Fatalf("tooltip = %q", j)
137+ }
138+ // A click after it was shown closes it.
139+ in.send(t, "click left 2 2")
140+ controlLine(t, out, "close", time.Second)
141+}
142+
143+func TestTooltipWithNothingToSayIsNotSent(t *testing.T) {
144+ _, _, in, out := setup(t) // fake items without ToolTip or Title
145+ in.send(t, "hover 0")
146+ noControlLine(t, out, 900*time.Millisecond)
147+ in.send(t, "leave")
148+ noControlLine(t, out, 100*time.Millisecond)
149+}
M internal/tray/tray.go +19 -2
@@ -76,6 +76,7 @@ type item struct {
7676 color RGB
7777 hasColor bool
7878 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
7980 isMenu bool // ItemIsMenu: Activate should open the menu
8081 loaded bool // GetAll answered at least once
8182 pending uint32 // serial of a GetAll in flight
@@ -334,7 +335,7 @@ func (t *Tray) signal(m *zerobus.Message) {
334335 }
335336 case itemIface:
336337 switch m.Member {
337- case "NewToolTip", "NewMenu", "NewOverlayIcon":
338+ case "NewMenu", "NewOverlayIcon":
338339 return // nothing the tray shows
339340 }
340341 for _, it := range t.items {
@@ -481,6 +482,7 @@ func (t *Tray) refresh(it *item) {
481482 func (t *Tray) load(it *item, r *zerobus.Reader) {
482483 was := *it
483484 var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
485+ tipSeen := false
484486 end := r.Array('{')
485487 for r.More(end) {
486488 r.Struct()
@@ -495,6 +497,13 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
495497 setStr(&it.iconName, r.Str())
496498 case sig == "o" && key == "Menu":
497499 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
498507 case sig == "b" && key == "ItemIsMenu":
499508 it.isMenu = r.Bool()
500509 case sig == "s" && key == "Status":
@@ -512,6 +521,9 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
512521 *it = was
513522 return
514523 }
524+ if !tipSeen {
525+ it.tipTitle, it.tipBody = "", ""
526+ }
515527 it.loaded = true
516528 if it.status == attention && attentionPixmaps != nil {
517529 pixmaps = attentionPixmaps
@@ -741,7 +753,12 @@ func (t *Tray) render(force bool) error {
741753 for _, r := range it.icon {
742754 w += vt.RuneWidth(r)
743755 }
744- ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
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})
745762 col += w
746763 }
747764 f = append(f, '\n')
@@ -76,6 +76,7 @@ type item struct {
76 color RGB76 color RGB
77 hasColor bool77 hasColor bool
78 menu string // object path of its dbusmenu, "" when none78 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
79 isMenu bool // ItemIsMenu: Activate should open the menu80 isMenu bool // ItemIsMenu: Activate should open the menu
80 loaded bool // GetAll answered at least once81 loaded bool // GetAll answered at least once
81 pending uint32 // serial of a GetAll in flight82 pending uint32 // serial of a GetAll in flight
@@ -334,7 +335,7 @@ func (t *Tray) signal(m *zerobus.Message) {
334 }335 }
335 case itemIface:336 case itemIface:
336 switch m.Member {337 switch m.Member {
337- case "NewToolTip", "NewMenu", "NewOverlayIcon":338+ case "NewMenu", "NewOverlayIcon":
338 return // nothing the tray shows339 return // nothing the tray shows
339 }340 }
340 for _, it := range t.items {341 for _, it := range t.items {
@@ -481,6 +482,7 @@ func (t *Tray) refresh(it *item) {
481 func (t *Tray) load(it *item, r *zerobus.Reader) {482 func (t *Tray) load(it *item, r *zerobus.Reader) {
482 was := *it483 was := *it
483 var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind484 var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
485+ tipSeen := false
484 end := r.Array('{')486 end := r.Array('{')
485 for r.More(end) {487 for r.More(end) {
486 r.Struct()488 r.Struct()
@@ -495,6 +497,13 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
495 setStr(&it.iconName, r.Str())497 setStr(&it.iconName, r.Str())
496 case sig == "o" && key == "Menu":498 case sig == "o" && key == "Menu":
497 setStr(&it.menu, r.ObjectPath())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
498 case sig == "b" && key == "ItemIsMenu":507 case sig == "b" && key == "ItemIsMenu":
499 it.isMenu = r.Bool()508 it.isMenu = r.Bool()
500 case sig == "s" && key == "Status":509 case sig == "s" && key == "Status":
@@ -512,6 +521,9 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
512 *it = was521 *it = was
513 return522 return
514 }523 }
524+ if !tipSeen {
525+ it.tipTitle, it.tipBody = "", ""
526+ }
515 it.loaded = true527 it.loaded = true
516 if it.status == attention && attentionPixmaps != nil {528 if it.status == attention && attentionPixmaps != nil {
517 pixmaps = attentionPixmaps529 pixmaps = attentionPixmaps
@@ -741,7 +753,12 @@ func (t *Tray) render(force bool) error {
741 for _, r := range it.icon {753 for _, r := range it.icon {
742 w += vt.RuneWidth(r)754 w += vt.RuneWidth(r)
743 }755 }
744- ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})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})
745 col += w762 col += w
746 }763 }
747 f = append(f, '\n')764 f = append(f, '\n')
M internal/tray/tray_test.go +28 -0
@@ -69,6 +69,8 @@ type fakeItem struct {
6969 unknown string // the method it answers with UnknownMethod
7070 slow string // the method it answers only after slowFor
7171 slowFor time.Duration // how long slow takes
72+ tip *[2]string // its ToolTip title and description, set through run
73+ title string // its Title property, set through run
7274 }
7375
7476 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
@@ -123,6 +125,23 @@ func (f *fakeItem) serve() {
123125 e.Variant("s")
124126 e.Str(f.cur[i])
125127 }
128+ if f.tip != nil {
129+ e.Struct()
130+ e.Str("ToolTip")
131+ e.Variant("(sa(iiay)ss)")
132+ e.Struct()
133+ e.Str("icon")
134+ tp := e.BeginArray('(')
135+ e.EndArray(tp)
136+ e.Str(f.tip[0])
137+ e.Str(f.tip[1])
138+ }
139+ if f.title != "" {
140+ e.Struct()
141+ e.Str("Title")
142+ e.Variant("s")
143+ e.Str(f.title)
144+ }
126145 e.Struct()
127146 e.Str("IconPixmap")
128147 e.Variant("a(iiay)")
@@ -175,6 +194,15 @@ func (f *fakeItem) register(t *testing.T) {
175194 })
176195 }
177196
197+// setTip changes the item's tooltip and emits NewToolTip.
198+func (f *fakeItem) setTip(t *testing.T, title, body string) {
199+ f.run(t, func() {
200+ f.tip = &[2]string{title, body}
201+ f.c.NewSignal(itemPath, itemIface, "NewToolTip", "")
202+ _, _ = f.c.Send()
203+ })
204+}
205+
178206 // setStatus changes the item's status and emits NewStatus.
179207 func (f *fakeItem) setStatus(t *testing.T, status string) {
180208 f.run(t, func() {
@@ -69,6 +69,8 @@ type fakeItem struct {
69 unknown string // the method it answers with UnknownMethod69 unknown string // the method it answers with UnknownMethod
70 slow string // the method it answers only after slowFor70 slow string // the method it answers only after slowFor
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 run
73+ title string // its Title property, set through run
72 }74 }
73 75
74 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {76 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
@@ -123,6 +125,23 @@ func (f *fakeItem) serve() {
123 e.Variant("s")125 e.Variant("s")
124 e.Str(f.cur[i])126 e.Str(f.cur[i])
125 }127 }
128+ if f.tip != nil {
129+ e.Struct()
130+ e.Str("ToolTip")
131+ e.Variant("(sa(iiay)ss)")
132+ e.Struct()
133+ e.Str("icon")
134+ tp := e.BeginArray('(')
135+ e.EndArray(tp)
136+ e.Str(f.tip[0])
137+ e.Str(f.tip[1])
138+ }
139+ if f.title != "" {
140+ e.Struct()
141+ e.Str("Title")
142+ e.Variant("s")
143+ e.Str(f.title)
144+ }
126 e.Struct()145 e.Struct()
127 e.Str("IconPixmap")146 e.Str("IconPixmap")
128 e.Variant("a(iiay)")147 e.Variant("a(iiay)")
@@ -175,6 +194,15 @@ func (f *fakeItem) register(t *testing.T) {
175 })194 })
176 }195 }
177 196
197+// setTip changes the item's tooltip and emits NewToolTip.
198+func (f *fakeItem) setTip(t *testing.T, title, body string) {
199+ f.run(t, func() {
200+ f.tip = &[2]string{title, body}
201+ f.c.NewSignal(itemPath, itemIface, "NewToolTip", "")
202+ _, _ = f.c.Send()
203+ })
204+}
205+
178 // setStatus changes the item's status and emits NewStatus.206 // setStatus changes the item's status and emits NewStatus.
179 func (f *fakeItem) setStatus(t *testing.T, status string) {207 func (f *fakeItem) setStatus(t *testing.T, status string) {
180 f.run(t, func() {208 f.run(t, func() {

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