detached 0c98ac0

feat(popup): tooltip tables and blank lines; status example uses them

commit
0c98ac0a631dec09cdace8de42b10cd8de3991d1
author
brice · · 2026-10-09 07:10 UTC
parent
dd9c933
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +146 -40
M README.md +2 -2
@@ -260,7 +260,7 @@ for line in sys.stdin:
260260
261261 | `type` | Fields | Effect |
262262 |---|---|---|
263-| `tooltip` | `col`, `width`, `title`, `body` | shown only while the pointer is on the module; `body` may hold newlines |
263+| `tooltip` | `col`, `width`, `title`, `body`, `rows` | shown only while the pointer is on the module: `title` in bold, then `body` (`\n` starts a new line, an empty line is kept), then `rows`, a table such as `[["Signal", "62%"], ["Band", "5 GHz"]]` whose columns line up |
264264 | `menu` | `col`, `width`, `click`, `items` | opens a menu; `click` is the token of the `click` line it answers |
265265 | `close` | none | closes the module's tooltip |
266266
@@ -268,7 +268,7 @@ A menu item is `{"id": 3, "label": "Mute", "kind": "check", "enabled": true, "ch
268268
269269 The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed.
270270
271-Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels and titles are cut at 256 bytes, a tooltip body at 1024 bytes. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
271+Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
272272
273273 ### The bundled bar
274274
@@ -260,7 +260,7 @@ for line in sys.stdin:
260 260
261 | `type` | Fields | Effect |261 | `type` | Fields | Effect |
262 |---|---|---|262 |---|---|---|
263-| `tooltip` | `col`, `width`, `title`, `body` | shown only while the pointer is on the module; `body` may hold newlines |263+| `tooltip` | `col`, `width`, `title`, `body`, `rows` | shown only while the pointer is on the module: `title` in bold, then `body` (`\n` starts a new line, an empty line is kept), then `rows`, a table such as `[["Signal", "62%"], ["Band", "5 GHz"]]` whose columns line up |
264 | `menu` | `col`, `width`, `click`, `items` | opens a menu; `click` is the token of the `click` line it answers |264 | `menu` | `col`, `width`, `click`, `items` | opens a menu; `click` is the token of the `click` line it answers |
265 | `close` | none | closes the module's tooltip |265 | `close` | none | closes the module's tooltip |
266 266
@@ -268,7 +268,7 @@ A menu item is `{"id": 3, "label": "Mute", "kind": "check", "enabled": true, "ch
268 268
269 The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed.269 The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed.
270 270
271-Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels and titles are cut at 256 bytes, a tooltip body at 1024 bytes. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.271+Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
272 272
273 ### The bundled bar273 ### The bundled bar
274 274
M examples/modules/status.py +28 -17
@@ -107,8 +107,11 @@ class Volume:
107107
108108 def tooltip(self):
109109 device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))
110- state = "muted" if self.muted else f"{self.percent}%"
111- return f"Volume {state}", device.group(1) if device else ""
110+ return "Volume", [
111+ ("Level", f"{self.percent}%"),
112+ ("Muted", "yes" if self.muted else "no"),
113+ ("Output", device.group(1) if device else "?"),
114+ ]
112115
113116 def menu(self):
114117 items = [("Mute", "check", self.muted, lambda: self.mute())]
@@ -168,12 +171,16 @@ class Wifi:
168171
169172 def tooltip(self):
170173 if self.ethernet:
171- return "Ethernet", "connected"
174+ return "Ethernet", [("State", "connected")]
172175 if not self.enabled:
173- return "Wi-Fi off", ""
176+ return "Wi-Fi", [("State", "off")]
174177 if not self.ssid:
175- return "Wi-Fi", "not connected"
176- return self.ssid, f"Signal {self.signal}% · {self.freq}"
178+ return "Wi-Fi", [("State", "not connected")]
179+ return "Wi-Fi", [
180+ ("Network", self.ssid),
181+ ("Signal", f"{self.signal}%"),
182+ ("Frequency", self.freq),
183+ ]
177184
178185 def menu(self):
179186 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
@@ -245,16 +252,17 @@ class Battery:
245252 return icon
246253
247254 def tooltip(self):
248- title = f"Battery {self.percent}%"
249- body = self.status
250- if self.watts and self.status == "Discharging":
251- body += f" · {self.watts:.1f} W · {hours(self.now / (self.watts * 1e6))} left"
252- elif self.watts and self.status == "Charging":
253- body += f" · {self.watts:.1f} W · full in {hours((self.full - self.now) / (self.watts * 1e6))}"
255+ rows = [("Charge", f"{self.percent}%"), ("State", self.status)]
256+ if self.watts:
257+ rows.append(("Power", f"{self.watts:.1f} W"))
258+ if self.status == "Discharging":
259+ rows.append(("Time left", hours(self.now / (self.watts * 1e6))))
260+ elif self.status == "Charging":
261+ rows.append(("Full in", hours((self.full - self.now) / (self.watts * 1e6))))
254262 profile = run("powerprofilesctl", "get").strip()
255263 if profile:
256- body += f"\nProfile: {profile}"
257- return title, body
264+ rows.append(("Profile", profile))
265+ return "Battery", rows
258266
259267 def menu(self):
260268 current = run("powerprofilesctl", "get").strip()
@@ -294,7 +302,8 @@ class Module:
294302 def draw(self):
295303 # One icon per part, separated by a space: part i is at column 2*i.
296304 self.cols = {2 * i: part for i, part in enumerate(self.parts)}
297- frame = " ".join(part.icon() for part in self.parts)
305+ # A trailing space keeps the last icon off the next module.
306+ frame = " ".join(part.icon() for part in self.parts) + " "
298307 if frame != self.last_frame:
299308 self.last_frame = frame
300309 print(frame, flush=True)
@@ -308,9 +317,11 @@ class Module:
308317 part = self.hovered
309318 if part is None:
310319 return
311- title, body = part.tooltip()
320+ # A tooltip has a bold title, then a "body" (text, "\n" for a new
321+ # line) and/or "rows", a table whose columns line up.
322+ title, rows = part.tooltip()
312323 control({"type": "tooltip", "col": self.col_of(part), "width": 1,
313- "title": title, "body": body})
324+ "title": title, "rows": rows})
314325 self.tooltip_shown = True
315326
316327 def hide_tooltip(self):
@@ -107,8 +107,11 @@ class Volume:
107 107
108 def tooltip(self):108 def tooltip(self):
109 device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))109 device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))
110- state = "muted" if self.muted else f"{self.percent}%"110+ return "Volume", [
111- return f"Volume {state}", device.group(1) if device else ""111+ ("Level", f"{self.percent}%"),
112+ ("Muted", "yes" if self.muted else "no"),
113+ ("Output", device.group(1) if device else "?"),
114+ ]
112 115
113 def menu(self):116 def menu(self):
114 items = [("Mute", "check", self.muted, lambda: self.mute())]117 items = [("Mute", "check", self.muted, lambda: self.mute())]
@@ -168,12 +171,16 @@ class Wifi:
168 171
169 def tooltip(self):172 def tooltip(self):
170 if self.ethernet:173 if self.ethernet:
171- return "Ethernet", "connected"174+ return "Ethernet", [("State", "connected")]
172 if not self.enabled:175 if not self.enabled:
173- return "Wi-Fi off", ""176+ return "Wi-Fi", [("State", "off")]
174 if not self.ssid:177 if not self.ssid:
175- return "Wi-Fi", "not connected"178+ return "Wi-Fi", [("State", "not connected")]
176- return self.ssid, f"Signal {self.signal}% · {self.freq}"179+ return "Wi-Fi", [
180+ ("Network", self.ssid),
181+ ("Signal", f"{self.signal}%"),
182+ ("Frequency", self.freq),
183+ ]
177 184
178 def menu(self):185 def menu(self):
179 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,186 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
@@ -245,16 +252,17 @@ class Battery:
245 return icon252 return icon
246 253
247 def tooltip(self):254 def tooltip(self):
248- title = f"Battery {self.percent}%"255+ rows = [("Charge", f"{self.percent}%"), ("State", self.status)]
249- body = self.status256+ if self.watts:
250- if self.watts and self.status == "Discharging":257+ rows.append(("Power", f"{self.watts:.1f} W"))
251- body += f" · {self.watts:.1f} W · {hours(self.now / (self.watts * 1e6))} left"258+ if self.status == "Discharging":
252- elif self.watts and self.status == "Charging":259+ rows.append(("Time left", hours(self.now / (self.watts * 1e6))))
253- body += f" · {self.watts:.1f} W · full in {hours((self.full - self.now) / (self.watts * 1e6))}"260+ elif self.status == "Charging":
261+ rows.append(("Full in", hours((self.full - self.now) / (self.watts * 1e6))))
254 profile = run("powerprofilesctl", "get").strip()262 profile = run("powerprofilesctl", "get").strip()
255 if profile:263 if profile:
256- body += f"\nProfile: {profile}"264+ rows.append(("Profile", profile))
257- return title, body265+ return "Battery", rows
258 266
259 def menu(self):267 def menu(self):
260 current = run("powerprofilesctl", "get").strip()268 current = run("powerprofilesctl", "get").strip()
@@ -294,7 +302,8 @@ class Module:
294 def draw(self):302 def draw(self):
295 # One icon per part, separated by a space: part i is at column 2*i.303 # One icon per part, separated by a space: part i is at column 2*i.
296 self.cols = {2 * i: part for i, part in enumerate(self.parts)}304 self.cols = {2 * i: part for i, part in enumerate(self.parts)}
297- frame = " ".join(part.icon() for part in self.parts)305+ # A trailing space keeps the last icon off the next module.
306+ frame = " ".join(part.icon() for part in self.parts) + " "
298 if frame != self.last_frame:307 if frame != self.last_frame:
299 self.last_frame = frame308 self.last_frame = frame
300 print(frame, flush=True)309 print(frame, flush=True)
@@ -308,9 +317,11 @@ class Module:
308 part = self.hovered317 part = self.hovered
309 if part is None:318 if part is None:
310 return319 return
311- title, body = part.tooltip()320+ # A tooltip has a bold title, then a "body" (text, "\n" for a new
321+ # line) and/or "rows", a table whose columns line up.
322+ title, rows = part.tooltip()
312 control({"type": "tooltip", "col": self.col_of(part), "width": 1,323 control({"type": "tooltip", "col": self.col_of(part), "width": 1,
313- "title": title, "body": body})324+ "title": title, "rows": rows})
314 self.tooltip_shown = True325 self.tooltip_shown = True
315 326
316 def hide_tooltip(self):327 def hide_tooltip(self):
M internal/module/control.go +22 -2
@@ -20,6 +20,10 @@ const (
2020 // MaxText is the longest tooltip body, in bytes; longer ones are cut at a
2121 // rune boundary.
2222 MaxText = 1024
23+ // MaxRows and MaxCells bound the table of a tooltip; a cell is cut like a
24+ // label.
25+ MaxRows = 32
26+ MaxCells = 4
2327 // maxColumn bounds the column and width of a control line.
2428 maxColumn = 1 << 20
2529 )
@@ -44,8 +48,10 @@ type Control struct {
4448 Type string
4549 Col int // first cell of the anchor inside the module
4650 Width int // cells of the anchor
47- // Title and Body are the text of a tooltip.
51+ // Title and Body are the text of a tooltip, Rows its table: one entry
52+ // per row, one string per cell.
4853 Title, Body string
54+ Rows [][]string
4955 // Click is the token of the press a menu answers.
5056 Click uint32
5157 Items []MenuItem
@@ -67,6 +73,7 @@ type wireControl struct {
6773 Width int `json:"width"`
6874 Title string `json:"title"`
6975 Body string `json:"body"`
76+ Rows [][]string `json:"rows"`
7077 Click uint32 `json:"click"`
7178 Items []wireItem `json:"items"`
7279 }
@@ -104,7 +111,20 @@ func ParseControl(b []byte) (Control, error) {
104111 }
105112 c.Title = clean(w.Title, MaxLabel, false)
106113 c.Body = clean(w.Body, MaxText, true)
107- if c.Title == "" && c.Body == "" {
114+ if len(w.Rows) > MaxRows {
115+ return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116+ }
117+ for _, row := range w.Rows {
118+ if len(row) > MaxCells {
119+ return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
120+ }
121+ cells := make([]string, len(row))
122+ for i, s := range row {
123+ cells[i] = clean(s, MaxLabel, false)
124+ }
125+ c.Rows = append(c.Rows, cells)
126+ }
127+ if c.Title == "" && c.Body == "" && len(c.Rows) == 0 {
108128 return Control{}, errors.New("tooltip: no text")
109129 }
110130 return c, nil
@@ -20,6 +20,10 @@ const (
20 // MaxText is the longest tooltip body, in bytes; longer ones are cut at a20 // MaxText is the longest tooltip body, in bytes; longer ones are cut at a
21 // rune boundary.21 // rune boundary.
22 MaxText = 102422 MaxText = 1024
23+ // MaxRows and MaxCells bound the table of a tooltip; a cell is cut like a
24+ // label.
25+ MaxRows = 32
26+ MaxCells = 4
23 // maxColumn bounds the column and width of a control line.27 // maxColumn bounds the column and width of a control line.
24 maxColumn = 1 << 2028 maxColumn = 1 << 20
25 )29 )
@@ -44,8 +48,10 @@ type Control struct {
44 Type string48 Type string
45 Col int // first cell of the anchor inside the module49 Col int // first cell of the anchor inside the module
46 Width int // cells of the anchor50 Width int // cells of the anchor
47- // Title and Body are the text of a tooltip.51+ // Title and Body are the text of a tooltip, Rows its table: one entry
52+ // per row, one string per cell.
48 Title, Body string53 Title, Body string
54+ Rows [][]string
49 // Click is the token of the press a menu answers.55 // Click is the token of the press a menu answers.
50 Click uint3256 Click uint32
51 Items []MenuItem57 Items []MenuItem
@@ -67,6 +73,7 @@ type wireControl struct {
67 Width int `json:"width"`73 Width int `json:"width"`
68 Title string `json:"title"`74 Title string `json:"title"`
69 Body string `json:"body"`75 Body string `json:"body"`
76+ Rows [][]string `json:"rows"`
70 Click uint32 `json:"click"`77 Click uint32 `json:"click"`
71 Items []wireItem `json:"items"`78 Items []wireItem `json:"items"`
72 }79 }
@@ -104,7 +111,20 @@ func ParseControl(b []byte) (Control, error) {
104 }111 }
105 c.Title = clean(w.Title, MaxLabel, false)112 c.Title = clean(w.Title, MaxLabel, false)
106 c.Body = clean(w.Body, MaxText, true)113 c.Body = clean(w.Body, MaxText, true)
107- if c.Title == "" && c.Body == "" {114+ if len(w.Rows) > MaxRows {
115+ return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116+ }
117+ for _, row := range w.Rows {
118+ if len(row) > MaxCells {
119+ return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
120+ }
121+ cells := make([]string, len(row))
122+ for i, s := range row {
123+ cells[i] = clean(s, MaxLabel, false)
124+ }
125+ c.Rows = append(c.Rows, cells)
126+ }
127+ if c.Title == "" && c.Body == "" && len(c.Rows) == 0 {
108 return Control{}, errors.New("tooltip: no text")128 return Control{}, errors.New("tooltip: no text")
109 }129 }
110 return c, nil130 return c, nil
M internal/module/control_test.go +20 -0
@@ -107,6 +107,26 @@ func TestParseControlCleansText(t *testing.T) {
107107 if err != nil || c.Title != "T" || c.Body != "a\nbc" {
108108 t.Fatalf("tooltip text: %+v, %v", c, err)
109109 }
110+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"rows":[["Sig\nnal","62\u001b%"],["Band"]]}`))
111+ if err != nil || len(c.Rows) != 2 || c.Rows[0][0] != "Signal" || c.Rows[0][1] != "62%" || c.Rows[1][0] != "Band" {
112+ t.Fatalf("tooltip rows: %+v, %v", c, err)
113+ }
114+}
115+
116+func TestParseControlTableLimits(t *testing.T) {
117+ row := `["a"]`
118+ tooMany := `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(row+",", MaxRows+1), ",") + `]}`
119+ if _, err := ParseControl([]byte(tooMany)); err == nil {
120+ t.Fatal("too many rows must fail")
121+ }
122+ wide := `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, MaxCells+1), ",") + `]]}`
123+ if _, err := ParseControl([]byte(wide)); err == nil {
124+ t.Fatal("too many cells must fail")
125+ }
126+ long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("a", 2*MaxLabel) + `"]]}`
127+ if c, err := ParseControl([]byte(long)); err != nil || len(c.Rows[0][0]) != MaxLabel {
128+ t.Fatalf("long cell: %v", err)
129+ }
110130 }
111131
112132 func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
@@ -107,6 +107,26 @@ func TestParseControlCleansText(t *testing.T) {
107 if err != nil || c.Title != "T" || c.Body != "a\nbc" {107 if err != nil || c.Title != "T" || c.Body != "a\nbc" {
108 t.Fatalf("tooltip text: %+v, %v", c, err)108 t.Fatalf("tooltip text: %+v, %v", c, err)
109 }109 }
110+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"rows":[["Sig\nnal","62\u001b%"],["Band"]]}`))
111+ if err != nil || len(c.Rows) != 2 || c.Rows[0][0] != "Signal" || c.Rows[0][1] != "62%" || c.Rows[1][0] != "Band" {
112+ t.Fatalf("tooltip rows: %+v, %v", c, err)
113+ }
114+}
115+
116+func TestParseControlTableLimits(t *testing.T) {
117+ row := `["a"]`
118+ tooMany := `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(row+",", MaxRows+1), ",") + `]}`
119+ if _, err := ParseControl([]byte(tooMany)); err == nil {
120+ t.Fatal("too many rows must fail")
121+ }
122+ wide := `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, MaxCells+1), ",") + `]]}`
123+ if _, err := ParseControl([]byte(wide)); err == nil {
124+ t.Fatal("too many cells must fail")
125+ }
126+ long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("a", 2*MaxLabel) + `"]]}`
127+ if c, err := ParseControl([]byte(long)); err != nil || len(c.Rows[0][0]) != MaxLabel {
128+ t.Fatalf("long cell: %v", err)
129+ }
110 }130 }
111 131
112 func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {132 func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
M internal/popup/css.go +2 -0
@@ -44,6 +44,8 @@ func CSS(s Style) string {
4444 bg, fg, cssString(s.Font), size, ac)
4545 b.WriteString(".title { font-weight: bold; }\n")
4646 b.WriteString(".body { opacity: 0.85; }\n")
47+ b.WriteString(".table { column-gap: 1.5em; }\n")
48+ b.WriteString(".key { opacity: 0.7; }\n")
4749 b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")
4850 // nefergui paints the 1em check box centered in the left padding: leave
4951 // room on both sides of it so the label does not touch the box.
@@ -44,6 +44,8 @@ func CSS(s Style) string {
44 bg, fg, cssString(s.Font), size, ac)44 bg, fg, cssString(s.Font), size, ac)
45 b.WriteString(".title { font-weight: bold; }\n")45 b.WriteString(".title { font-weight: bold; }\n")
46 b.WriteString(".body { opacity: 0.85; }\n")46 b.WriteString(".body { opacity: 0.85; }\n")
47+ b.WriteString(".table { column-gap: 1.5em; }\n")
48+ b.WriteString(".key { opacity: 0.7; }\n")
47 b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")49 b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")
48 // nefergui paints the 1em check box centered in the left padding: leave50 // nefergui paints the 1em check box centered in the left padding: leave
49 // room on both sides of it so the label does not touch the box.51 // room on both sides of it so the label does not touch the box.
M internal/popup/host.go +13 -4
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "log/slog"
77 "strconv"
8+ "strings"
89 "time"
910
1011 "github.com/bnema/neferclient"
@@ -168,12 +169,20 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) {
168169 // tooltipKey is what identifies a tooltip: showing the same one again is
169170 // pointless.
170171 type tooltipKey struct {
171- title, body string
172- col, width int
172+ title, body, rows string
173+ col, width int
173174 }
174175
175176 func keyOf(c module.Control) tooltipKey {
176- return tooltipKey{c.Title, c.Body, c.Col, c.Width}
177+ var rows strings.Builder
178+ for _, r := range c.Rows {
179+ for _, s := range r {
180+ rows.WriteString(s)
181+ rows.WriteByte(0) // cells never hold control characters
182+ }
183+ rows.WriteByte('\n')
184+ }
185+ return tooltipKey{c.Title, c.Body, rows.String(), c.Col, c.Width}
177186 }
178187
179188 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows
@@ -270,7 +279,7 @@ func (h *Host) Open(req Request) error {
270279 h.tipKey = keyOf(req.Ctrl)
271280 switch req.Kind {
272281 case KindTooltip:
273- h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)
282+ h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
274283 h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
275284 default:
276285 h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "log/slog"6 "log/slog"
7 "strconv"7 "strconv"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/bnema/neferclient"11 "github.com/bnema/neferclient"
@@ -168,12 +169,20 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) {
168 // tooltipKey is what identifies a tooltip: showing the same one again is169 // tooltipKey is what identifies a tooltip: showing the same one again is
169 // pointless.170 // pointless.
170 type tooltipKey struct {171 type tooltipKey struct {
171- title, body string172+ title, body, rows string
172- col, width int173+ col, width int
173 }174 }
174 175
175 func keyOf(c module.Control) tooltipKey {176 func keyOf(c module.Control) tooltipKey {
176- return tooltipKey{c.Title, c.Body, c.Col, c.Width}177+ var rows strings.Builder
178+ for _, r := range c.Rows {
179+ for _, s := range r {
180+ rows.WriteString(s)
181+ rows.WriteByte(0) // cells never hold control characters
182+ }
183+ rows.WriteByte('\n')
184+ }
185+ return tooltipKey{c.Title, c.Body, rows.String(), c.Col, c.Width}
177 }186 }
178 187
179 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows188 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows
@@ -270,7 +279,7 @@ func (h *Host) Open(req Request) error {
270 h.tipKey = keyOf(req.Ctrl)279 h.tipKey = keyOf(req.Ctrl)
271 switch req.Kind {280 switch req.Kind {
272 case KindTooltip:281 case KindTooltip:
273- h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)282+ h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
274 h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }283 h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
275 default:284 default:
276 h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)285 h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
M internal/popup/popup_test.go +24 -3
@@ -88,6 +88,8 @@ func TestCSS(t *testing.T) {
8888 want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
8989 .title { font-weight: bold; }
9090 .body { opacity: 0.85; }
91+.table { column-gap: 1.5em; }
92+.key { opacity: 0.7; }
9193 button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
9294 checkbox.item { padding-left: 2em; }
9395 button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
@@ -159,15 +161,34 @@ func TestWriteCSSPermissions(t *testing.T) {
159161 }
160162
161163 func TestTooltipLines(t *testing.T) {
162- m := newTooltip("Title", "a\n\nb\n")
163- if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "b"}) {
164+ // A blank line in the body is kept; blank lines around it are not.
165+ m := newTooltip("Title", "\na\n\nb\n", nil)
166+ if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "", "b"}) {
164167 t.Fatalf("tooltip = %+v", m)
165168 }
166- if m = newTooltip("", ""); len(m.lines) != 0 {
169+ if m = newTooltip("", "", nil); len(m.lines) != 0 || m.cols != 0 {
167170 t.Fatalf("empty tooltip = %+v", m)
168171 }
169172 }
170173
174+func TestTooltipTable(t *testing.T) {
175+ m := newTooltip("", "", [][]string{{"Signal", "62%"}, {"Band", "5 GHz", "ch 100"}, {"x"}})
176+ if m.cols != 3 || len(m.rows) != 3 {
177+ t.Fatalf("table = %+v", m)
178+ }
179+}
180+
181+func TestTooltipKeyIncludesRows(t *testing.T) {
182+ a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183+ b := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"ab"}}}
184+ if keyOf(a) == keyOf(b) {
185+ t.Fatal("different tables must not share a key")
186+ }
187+ if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
188+ t.Fatal("same table, same key")
189+ }
190+}
191+
171192 func sampleMenu() *menuModel {
172193 return newMenu([]module.MenuItem{
173194 {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
@@ -88,6 +88,8 @@ func TestCSS(t *testing.T) {
88 want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }88 want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
89 .title { font-weight: bold; }89 .title { font-weight: bold; }
90 .body { opacity: 0.85; }90 .body { opacity: 0.85; }
91+.table { column-gap: 1.5em; }
92+.key { opacity: 0.7; }
91 button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }93 button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
92 checkbox.item { padding-left: 2em; }94 checkbox.item { padding-left: 2em; }
93 button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }95 button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
@@ -159,15 +161,34 @@ func TestWriteCSSPermissions(t *testing.T) {
159 }161 }
160 162
161 func TestTooltipLines(t *testing.T) {163 func TestTooltipLines(t *testing.T) {
162- m := newTooltip("Title", "a\n\nb\n")164+ // A blank line in the body is kept; blank lines around it are not.
163- if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "b"}) {165+ m := newTooltip("Title", "\na\n\nb\n", nil)
166+ if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "", "b"}) {
164 t.Fatalf("tooltip = %+v", m)167 t.Fatalf("tooltip = %+v", m)
165 }168 }
166- if m = newTooltip("", ""); len(m.lines) != 0 {169+ if m = newTooltip("", "", nil); len(m.lines) != 0 || m.cols != 0 {
167 t.Fatalf("empty tooltip = %+v", m)170 t.Fatalf("empty tooltip = %+v", m)
168 }171 }
169 }172 }
170 173
174+func TestTooltipTable(t *testing.T) {
175+ m := newTooltip("", "", [][]string{{"Signal", "62%"}, {"Band", "5 GHz", "ch 100"}, {"x"}})
176+ if m.cols != 3 || len(m.rows) != 3 {
177+ t.Fatalf("table = %+v", m)
178+ }
179+}
180+
181+func TestTooltipKeyIncludesRows(t *testing.T) {
182+ a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183+ b := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"ab"}}}
184+ if keyOf(a) == keyOf(b) {
185+ t.Fatal("different tables must not share a key")
186+ }
187+ if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
188+ t.Fatal("same table, same key")
189+ }
190+}
191+
171 func sampleMenu() *menuModel {192 func sampleMenu() *menuModel {
172 return newMenu([]module.MenuItem{193 return newMenu([]module.MenuItem{
173 {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},194 {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
M internal/popup/view.go +35 -12
@@ -2,6 +2,7 @@ package popup
22
33 import (
44 "strconv"
5+ "strings"
56
67 "github.com/bnema/nefergui"
78
@@ -11,32 +12,54 @@ import (
1112 // tooltipModel is what a tooltip shows.
1213 type tooltipModel struct {
1314 title string
14- lines []string // the body, one entry per non-empty line
15+ lines []string // the body, one entry per line; an empty one is a blank line
16+ rows [][]string // the table
17+ cols int // cells of the longest row
1518 }
1619
17-func newTooltip(title, body string) *tooltipModel {
18- m := &tooltipModel{title: title}
19- start := 0
20- for i := 0; i <= len(body); i++ {
21- if i == len(body) || body[i] == '\n' {
22- if i > start {
23- m.lines = append(m.lines, body[start:i])
24- }
25- start = i + 1
26- }
20+func newTooltip(title, body string, rows [][]string) *tooltipModel {
21+ m := &tooltipModel{title: title, rows: rows}
22+ if body = strings.Trim(body, "\n"); body != "" {
23+ m.lines = strings.Split(body, "\n")
24+ }
25+ for _, r := range rows {
26+ m.cols = max(m.cols, len(r))
2727 }
2828 return m
2929 }
3030
31-// tooltipView draws the title in bold, then the body.
31+// tooltipView draws the title in bold, the body, then the table. The table is
32+// laid out column by column, so every column is as wide as its widest cell
33+// and the cells of a row line up.
3234 func tooltipView(f *nefergui.Frame, m *tooltipModel) {
3335 col := f.Root().Column()
3436 if m.title != "" {
3537 col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))
3638 }
3739 for i, l := range m.lines {
40+ if l == "" {
41+ l = " " // keeps the height of a line
42+ }
3843 col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))
3944 }
45+ if m.cols == 0 {
46+ return
47+ }
48+ table := col.Row(nefergui.Class("table"), nefergui.Key("table"))
49+ for c := 0; c < m.cols; c++ {
50+ class := "value"
51+ if c == 0 && m.cols > 1 {
52+ class = "key"
53+ }
54+ cells := table.Column(nefergui.Class(class), nefergui.Key("c"+strconv.Itoa(c)))
55+ for r, row := range m.rows {
56+ s := " "
57+ if c < len(row) && row[c] != "" {
58+ s = row[c]
59+ }
60+ cells.Text(s, nefergui.Key("r"+strconv.Itoa(r)))
61+ }
62+ }
4063 }
4164
4265 // menuModel is a menu and where the user is in it. Submenus open in place:
@@ -2,6 +2,7 @@ package popup
2 2
3 import (3 import (
4 "strconv"4 "strconv"
5+ "strings"
5 6
6 "github.com/bnema/nefergui"7 "github.com/bnema/nefergui"
7 8
@@ -11,32 +12,54 @@ import (
11 // tooltipModel is what a tooltip shows.12 // tooltipModel is what a tooltip shows.
12 type tooltipModel struct {13 type tooltipModel struct {
13 title string14 title string
14- lines []string // the body, one entry per non-empty line15+ lines []string // the body, one entry per line; an empty one is a blank line
16+ rows [][]string // the table
17+ cols int // cells of the longest row
15 }18 }
16 19
17-func newTooltip(title, body string) *tooltipModel {20+func newTooltip(title, body string, rows [][]string) *tooltipModel {
18- m := &tooltipModel{title: title}21+ m := &tooltipModel{title: title, rows: rows}
19- start := 022+ if body = strings.Trim(body, "\n"); body != "" {
20- for i := 0; i <= len(body); i++ {23+ m.lines = strings.Split(body, "\n")
21- if i == len(body) || body[i] == '\n' {24+ }
22- if i > start {25+ for _, r := range rows {
23- m.lines = append(m.lines, body[start:i])26+ m.cols = max(m.cols, len(r))
24- }
25- start = i + 1
26- }
27 }27 }
28 return m28 return m
29 }29 }
30 30
31-// tooltipView draws the title in bold, then the body.31+// tooltipView draws the title in bold, the body, then the table. The table is
32+// laid out column by column, so every column is as wide as its widest cell
33+// and the cells of a row line up.
32 func tooltipView(f *nefergui.Frame, m *tooltipModel) {34 func tooltipView(f *nefergui.Frame, m *tooltipModel) {
33 col := f.Root().Column()35 col := f.Root().Column()
34 if m.title != "" {36 if m.title != "" {
35 col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))37 col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))
36 }38 }
37 for i, l := range m.lines {39 for i, l := range m.lines {
40+ if l == "" {
41+ l = " " // keeps the height of a line
42+ }
38 col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))43 col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))
39 }44 }
45+ if m.cols == 0 {
46+ return
47+ }
48+ table := col.Row(nefergui.Class("table"), nefergui.Key("table"))
49+ for c := 0; c < m.cols; c++ {
50+ class := "value"
51+ if c == 0 && m.cols > 1 {
52+ class = "key"
53+ }
54+ cells := table.Column(nefergui.Class(class), nefergui.Key("c"+strconv.Itoa(c)))
55+ for r, row := range m.rows {
56+ s := " "
57+ if c < len(row) && row[c] != "" {
58+ s = row[c]
59+ }
60+ cells.Text(s, nefergui.Key("r"+strconv.Itoa(r)))
61+ }
62+ }
40 }63 }
41 64
42 // menuModel is a menu and where the user is in it. Submenus open in place:65 // menuModel is a menu and where the user is in it. Submenus open in place:

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