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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 260 | 260 | ||
| 261 | 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 | 264 | | `menu` | `col`, `width`, `click`, `items` | opens a menu; `click` is the token of the `click` line it answers | | |
| 265 | 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 | 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 | 273 | ### The bundled bar | |
| 274 | 274 | ||
| @@ -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 bar | 273 | ### The bundled bar | ||
| 274 | 274 | ||||
M examples/modules/status.py +28 -17
| @@ -107,8 +107,11 @@ class Volume: | |||
| 107 | 107 | ||
| 108 | 108 | def tooltip(self): | |
| 109 | 109 | 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 | + | ] | |
| 112 | 115 | ||
| 113 | 116 | def menu(self): | |
| 114 | 117 | items = [("Mute", "check", self.muted, lambda: self.mute())] | |
| @@ -168,12 +171,16 @@ class Wifi: | |||
| 168 | 171 | ||
| 169 | 172 | def tooltip(self): | |
| 170 | 173 | if self.ethernet: | |
| 171 | - | return "Ethernet", "connected" | |
| 174 | + | return "Ethernet", [("State", "connected")] | |
| 172 | 175 | if not self.enabled: | |
| 173 | - | return "Wi-Fi off", "" | |
| 176 | + | return "Wi-Fi", [("State", "off")] | |
| 174 | 177 | 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 | + | ] | |
| 177 | 184 | ||
| 178 | 185 | def menu(self): | |
| 179 | 186 | networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid, | |
| @@ -245,16 +252,17 @@ class Battery: | |||
| 245 | 252 | return icon | |
| 246 | 253 | ||
| 247 | 254 | 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)))) | |
| 254 | 262 | profile = run("powerprofilesctl", "get").strip() | |
| 255 | 263 | if profile: | |
| 256 | - | body += f"\nProfile: {profile}" | |
| 257 | - | return title, body | |
| 264 | + | rows.append(("Profile", profile)) | |
| 265 | + | return "Battery", rows | |
| 258 | 266 | ||
| 259 | 267 | def menu(self): | |
| 260 | 268 | current = run("powerprofilesctl", "get").strip() | |
| @@ -294,7 +302,8 @@ class Module: | |||
| 294 | 302 | def draw(self): | |
| 295 | 303 | # One icon per part, separated by a space: part i is at column 2*i. | |
| 296 | 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 | 307 | if frame != self.last_frame: | |
| 299 | 308 | self.last_frame = frame | |
| 300 | 309 | print(frame, flush=True) | |
| @@ -308,9 +317,11 @@ class Module: | |||
| 308 | 317 | part = self.hovered | |
| 309 | 318 | if part is None: | |
| 310 | 319 | 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 | 323 | control({"type": "tooltip", "col": self.col_of(part), "width": 1, | |
| 313 | - | "title": title, "body": body}) | |
| 324 | + | "title": title, "rows": rows}) | |
| 314 | 325 | self.tooltip_shown = True | |
| 315 | 326 | ||
| 316 | 327 | 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 icon | 252 | 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.status | 256 | + | 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, body | 265 | + | 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 = frame | 308 | self.last_frame = frame | ||
| 300 | print(frame, flush=True) | 309 | print(frame, flush=True) | ||
| @@ -308,9 +317,11 @@ class Module: | |||||
| 308 | part = self.hovered | 317 | part = self.hovered | ||
| 309 | if part is None: | 318 | if part is None: | ||
| 310 | return | 319 | 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 = True | 325 | 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 ( | |||
| 20 | 20 | // MaxText is the longest tooltip body, in bytes; longer ones are cut at a | |
| 21 | 21 | // rune boundary. | |
| 22 | 22 | 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 | 27 | // maxColumn bounds the column and width of a control line. | |
| 24 | 28 | maxColumn = 1 << 20 | |
| 25 | 29 | ) | |
| @@ -44,8 +48,10 @@ type Control struct { | |||
| 44 | 48 | Type string | |
| 45 | 49 | Col int // first cell of the anchor inside the module | |
| 46 | 50 | 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 | 53 | Title, Body string | |
| 54 | + | Rows [][]string | |
| 49 | 55 | // Click is the token of the press a menu answers. | |
| 50 | 56 | Click uint32 | |
| 51 | 57 | Items []MenuItem | |
| @@ -67,6 +73,7 @@ type wireControl struct { | |||
| 67 | 73 | Width int `json:"width"` | |
| 68 | 74 | Title string `json:"title"` | |
| 69 | 75 | Body string `json:"body"` | |
| 76 | + | Rows [][]string `json:"rows"` | |
| 70 | 77 | Click uint32 `json:"click"` | |
| 71 | 78 | Items []wireItem `json:"items"` | |
| 72 | 79 | } | |
| @@ -104,7 +111,20 @@ func ParseControl(b []byte) (Control, error) { | |||
| 104 | 111 | } | |
| 105 | 112 | c.Title = clean(w.Title, MaxLabel, false) | |
| 106 | 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 | 128 | return Control{}, errors.New("tooltip: no text") | |
| 109 | 129 | } | |
| 110 | 130 | return c, nil | |
| @@ -20,6 +20,10 @@ const ( | |||||
| 20 | // MaxText is the longest tooltip body, in bytes; longer ones are cut at a | 20 | // MaxText is the longest tooltip body, in bytes; longer ones are cut at a | ||
| 21 | // rune boundary. | 21 | // rune boundary. | ||
| 22 | MaxText = 1024 | 22 | 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 << 20 | 28 | maxColumn = 1 << 20 | ||
| 25 | ) | 29 | ) | ||
| @@ -44,8 +48,10 @@ type Control struct { | |||||
| 44 | Type string | 48 | Type string | ||
| 45 | Col int // first cell of the anchor inside the module | 49 | Col int // first cell of the anchor inside the module | ||
| 46 | Width int // cells of the anchor | 50 | 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 string | 53 | 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 uint32 | 56 | Click uint32 | ||
| 51 | Items []MenuItem | 57 | 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, nil | 130 | return c, nil | ||
M internal/module/control_test.go +20 -0
| @@ -107,6 +107,26 @@ func TestParseControlCleansText(t *testing.T) { | |||
| 107 | 107 | if err != nil || c.Title != "T" || c.Body != "a\nbc" { | |
| 108 | 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 | 132 | 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 { | |||
| 44 | 44 | bg, fg, cssString(s.Font), size, ac) | |
| 45 | 45 | b.WriteString(".title { font-weight: bold; }\n") | |
| 46 | 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 | 49 | b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n") | |
| 48 | 50 | // nefergui paints the 1em check box centered in the left padding: leave | |
| 49 | 51 | // 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: leave | 50 | // 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "log/slog" | |
| 7 | 7 | "strconv" | |
| 8 | + | "strings" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/neferclient" | |
| @@ -168,12 +169,20 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) { | |||
| 168 | 169 | // tooltipKey is what identifies a tooltip: showing the same one again is | |
| 169 | 170 | // pointless. | |
| 170 | 171 | type tooltipKey struct { | |
| 171 | - | title, body string | |
| 172 | - | col, width int | |
| 172 | + | title, body, rows string | |
| 173 | + | col, width int | |
| 173 | 174 | } | |
| 174 | 175 | ||
| 175 | 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 | 188 | // 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 | 279 | h.tipKey = keyOf(req.Ctrl) | |
| 271 | 280 | switch req.Kind { | |
| 272 | 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 | 283 | h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) } | |
| 275 | 284 | default: | |
| 276 | 285 | 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 is | 169 | // 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 string | 172 | + | title, body, rows string |
| 172 | - | col, width int | 173 | + | 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 shows | 188 | // 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) { | |||
| 88 | 88 | want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; } | |
| 89 | 89 | .title { font-weight: bold; } | |
| 90 | 90 | .body { opacity: 0.85; } | |
| 91 | + | .table { column-gap: 1.5em; } | |
| 92 | + | .key { opacity: 0.7; } | |
| 91 | 93 | button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; } | |
| 92 | 94 | checkbox.item { padding-left: 2em; } | |
| 93 | 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 | 163 | 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"}) { | |
| 164 | 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 | 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 | 192 | func sampleMenu() *menuModel { | |
| 172 | 193 | return newMenu([]module.MenuItem{ | |
| 173 | 194 | {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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | + | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/nefergui" | |
| 7 | 8 | ||
| @@ -11,32 +12,54 @@ import ( | |||
| 11 | 12 | // tooltipModel is what a tooltip shows. | |
| 12 | 13 | type tooltipModel struct { | |
| 13 | 14 | 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 | |
| 15 | 18 | } | |
| 16 | 19 | ||
| 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)) | |
| 27 | 27 | } | |
| 28 | 28 | 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 | 34 | func tooltipView(f *nefergui.Frame, m *tooltipModel) { | |
| 33 | 35 | col := f.Root().Column() | |
| 34 | 36 | if m.title != "" { | |
| 35 | 37 | col.Text(m.title, nefergui.Class("title"), nefergui.Key("title")) | |
| 36 | 38 | } | |
| 37 | 39 | for i, l := range m.lines { | |
| 40 | + | if l == "" { | |
| 41 | + | l = " " // keeps the height of a line | |
| 42 | + | } | |
| 38 | 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 | 65 | // 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 string | 14 | 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 | |||
| 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 := 0 | 22 | + | 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 m | 28 | 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: | ||