fix(popup): reject blank tooltip tables, compare tooltips without a key string
- commit
- 43af492b857cae81a37caa3b8e0bf8005268a393
- author
- brice · · 2026-10-09 08:07 UTC
- parent
- 907150d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +67 -40
M internal/bar/popup.go +1 -0
| @@ -105,6 +105,7 @@ func (b *Bar) drainControl(m *module.Module) { | |||
| 105 | 105 | for i := range b.ctlBuf { | |
| 106 | 106 | b.control(m, b.ctlBuf[i]) | |
| 107 | 107 | } | |
| 108 | + | clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | // badControl logs an invalid control line, once per module. | |
| @@ -105,6 +105,7 @@ func (b *Bar) drainControl(m *module.Module) { | |||||
| 105 | for i := range b.ctlBuf { | 105 | for i := range b.ctlBuf { | ||
| 106 | b.control(m, b.ctlBuf[i]) | 106 | b.control(m, b.ctlBuf[i]) | ||
| 107 | } | 107 | } | ||
| 108 | + | clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up | |||
| 108 | } | 109 | } | ||
| 109 | 110 | ||||
| 110 | // badControl logs an invalid control line, once per module. | 111 | // badControl logs an invalid control line, once per module. | ||
M internal/module/control.go +6 -1
| @@ -114,6 +114,10 @@ func ParseControl(b []byte) (Control, error) { | |||
| 114 | 114 | if len(w.Rows) > MaxRows { | |
| 115 | 115 | return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows) | |
| 116 | 116 | } | |
| 117 | + | if len(w.Rows) > 0 { | |
| 118 | + | c.Rows = make([][]string, 0, len(w.Rows)) | |
| 119 | + | } | |
| 120 | + | text := c.Title != "" || c.Body != "" | |
| 117 | 121 | for _, row := range w.Rows { | |
| 118 | 122 | if len(row) > MaxCells { | |
| 119 | 123 | return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells) | |
| @@ -121,10 +125,11 @@ func ParseControl(b []byte) (Control, error) { | |||
| 121 | 125 | cells := make([]string, len(row)) | |
| 122 | 126 | for i, s := range row { | |
| 123 | 127 | cells[i] = clean(s, MaxLabel, false) | |
| 128 | + | text = text || cells[i] != "" | |
| 124 | 129 | } | |
| 125 | 130 | c.Rows = append(c.Rows, cells) | |
| 126 | 131 | } | |
| 127 | - | if c.Title == "" && c.Body == "" && len(c.Rows) == 0 { | |
| 132 | + | if !text { | |
| 128 | 133 | return Control{}, errors.New("tooltip: no text") | |
| 129 | 134 | } | |
| 130 | 135 | return c, nil | |
| @@ -114,6 +114,10 @@ func ParseControl(b []byte) (Control, error) { | |||||
| 114 | if len(w.Rows) > MaxRows { | 114 | if len(w.Rows) > MaxRows { | ||
| 115 | return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows) | 115 | return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows) | ||
| 116 | } | 116 | } | ||
| 117 | + | if len(w.Rows) > 0 { | |||
| 118 | + | c.Rows = make([][]string, 0, len(w.Rows)) | |||
| 119 | + | } | |||
| 120 | + | text := c.Title != "" || c.Body != "" | |||
| 117 | for _, row := range w.Rows { | 121 | for _, row := range w.Rows { | ||
| 118 | if len(row) > MaxCells { | 122 | if len(row) > MaxCells { | ||
| 119 | return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells) | 123 | return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells) | ||
| @@ -121,10 +125,11 @@ func ParseControl(b []byte) (Control, error) { | |||||
| 121 | cells := make([]string, len(row)) | 125 | cells := make([]string, len(row)) | ||
| 122 | for i, s := range row { | 126 | for i, s := range row { | ||
| 123 | cells[i] = clean(s, MaxLabel, false) | 127 | cells[i] = clean(s, MaxLabel, false) | ||
| 128 | + | text = text || cells[i] != "" | |||
| 124 | } | 129 | } | ||
| 125 | c.Rows = append(c.Rows, cells) | 130 | c.Rows = append(c.Rows, cells) | ||
| 126 | } | 131 | } | ||
| 127 | - | if c.Title == "" && c.Body == "" && len(c.Rows) == 0 { | 132 | + | if !text { |
| 128 | return Control{}, errors.New("tooltip: no text") | 133 | return Control{}, errors.New("tooltip: no text") | ||
| 129 | } | 134 | } | ||
| 130 | return c, nil | 135 | return c, nil | ||
M internal/module/control_test.go +41 -10
| @@ -114,18 +114,34 @@ func TestParseControlCleansText(t *testing.T) { | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 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") | |
| 117 | + | rows := func(n int) string { | |
| 118 | + | return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}` | |
| 119 | + | } | |
| 120 | + | cells := func(n int) string { | |
| 121 | + | return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}` | |
| 122 | + | } | |
| 123 | + | for name, tc := range map[string]struct { | |
| 124 | + | in string | |
| 125 | + | ok bool | |
| 126 | + | }{ | |
| 127 | + | "max rows": {rows(MaxRows), true}, | |
| 128 | + | "too many rows": {rows(MaxRows + 1), false}, | |
| 129 | + | "max cells": {cells(MaxCells), true}, | |
| 130 | + | "too many cells": {cells(MaxCells + 1), false}, | |
| 131 | + | "empty rows": {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false}, | |
| 132 | + | "blank cells": {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false}, | |
| 133 | + | } { | |
| 134 | + | if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok { | |
| 135 | + | t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok) | |
| 136 | + | } | |
| 121 | 137 | } | |
| 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") | |
| 138 | + | long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each | |
| 139 | + | c, err := ParseControl([]byte(long)) | |
| 140 | + | if err != nil { | |
| 141 | + | t.Fatal(err) | |
| 125 | 142 | } | |
| 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) | |
| 143 | + | if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) { | |
| 144 | + | t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell)) | |
| 129 | 145 | } | |
| 130 | 146 | } | |
| 131 | 147 | ||
| @@ -236,6 +252,7 @@ func FuzzParseControl(f *testing.F) { | |||
| 236 | 252 | nested(MaxMenuDepth), nested(MaxMenuDepth + 1), | |
| 237 | 253 | `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`, | |
| 238 | 254 | `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`, | |
| 255 | + | `{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`, | |
| 239 | 256 | `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`, | |
| 240 | 257 | } { | |
| 241 | 258 | f.Add([]byte(s)) | |
| @@ -256,6 +273,20 @@ func FuzzParseControl(f *testing.F) { | |||
| 256 | 273 | } | |
| 257 | 274 | bad(c.Title, MaxLabel) | |
| 258 | 275 | bad(c.Body, MaxText) | |
| 276 | + | if len(c.Rows) > MaxRows { | |
| 277 | + | t.Fatalf("%d rows", len(c.Rows)) | |
| 278 | + | } | |
| 279 | + | for _, row := range c.Rows { | |
| 280 | + | if len(row) > MaxCells { | |
| 281 | + | t.Fatalf("%d cells", len(row)) | |
| 282 | + | } | |
| 283 | + | for _, cell := range row { | |
| 284 | + | bad(cell, MaxLabel) | |
| 285 | + | if strings.ContainsRune(cell, '\n') { | |
| 286 | + | t.Fatalf("cell %q", cell) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 259 | 290 | count := 0 | |
| 260 | 291 | var walk func(items []MenuItem, depth int) | |
| 261 | 292 | walk = func(items []MenuItem, depth int) { | |
| @@ -114,18 +114,34 @@ func TestParseControlCleansText(t *testing.T) { | |||||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | func TestParseControlTableLimits(t *testing.T) { | 116 | func TestParseControlTableLimits(t *testing.T) { | ||
| 117 | - | row := `["a"]` | 117 | + | rows := func(n int) string { |
| 118 | - | tooMany := `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(row+",", MaxRows+1), ",") + `]}` | 118 | + | return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}` |
| 119 | - | if _, err := ParseControl([]byte(tooMany)); err == nil { | 119 | + | } |
| 120 | - | t.Fatal("too many rows must fail") | 120 | + | cells := func(n int) string { |
| 121 | + | return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}` | |||
| 122 | + | } | |||
| 123 | + | for name, tc := range map[string]struct { | |||
| 124 | + | in string | |||
| 125 | + | ok bool | |||
| 126 | + | }{ | |||
| 127 | + | "max rows": {rows(MaxRows), true}, | |||
| 128 | + | "too many rows": {rows(MaxRows + 1), false}, | |||
| 129 | + | "max cells": {cells(MaxCells), true}, | |||
| 130 | + | "too many cells": {cells(MaxCells + 1), false}, | |||
| 131 | + | "empty rows": {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false}, | |||
| 132 | + | "blank cells": {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false}, | |||
| 133 | + | } { | |||
| 134 | + | if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok { | |||
| 135 | + | t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok) | |||
| 136 | + | } | |||
| 121 | } | 137 | } | ||
| 122 | - | wide := `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, MaxCells+1), ",") + `]]}` | 138 | + | long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each |
| 123 | - | if _, err := ParseControl([]byte(wide)); err == nil { | 139 | + | c, err := ParseControl([]byte(long)) |
| 124 | - | t.Fatal("too many cells must fail") | 140 | + | if err != nil { |
| 141 | + | t.Fatal(err) | |||
| 125 | } | 142 | } | ||
| 126 | - | long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("a", 2*MaxLabel) + `"]]}` | 143 | + | if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) { |
| 127 | - | if c, err := ParseControl([]byte(long)); err != nil || len(c.Rows[0][0]) != MaxLabel { | 144 | + | t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell)) |
| 128 | - | t.Fatalf("long cell: %v", err) | |||
| 129 | } | 145 | } | ||
| 130 | } | 146 | } | ||
| 131 | 147 | ||||
| @@ -236,6 +252,7 @@ func FuzzParseControl(f *testing.F) { | |||||
| 236 | nested(MaxMenuDepth), nested(MaxMenuDepth + 1), | 252 | nested(MaxMenuDepth), nested(MaxMenuDepth + 1), | ||
| 237 | `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`, | 253 | `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`, | ||
| 238 | `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`, | 254 | `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`, | ||
| 255 | + | `{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`, | |||
| 239 | `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`, | 256 | `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`, | ||
| 240 | } { | 257 | } { | ||
| 241 | f.Add([]byte(s)) | 258 | f.Add([]byte(s)) | ||
| @@ -256,6 +273,20 @@ func FuzzParseControl(f *testing.F) { | |||||
| 256 | } | 273 | } | ||
| 257 | bad(c.Title, MaxLabel) | 274 | bad(c.Title, MaxLabel) | ||
| 258 | bad(c.Body, MaxText) | 275 | bad(c.Body, MaxText) | ||
| 276 | + | if len(c.Rows) > MaxRows { | |||
| 277 | + | t.Fatalf("%d rows", len(c.Rows)) | |||
| 278 | + | } | |||
| 279 | + | for _, row := range c.Rows { | |||
| 280 | + | if len(row) > MaxCells { | |||
| 281 | + | t.Fatalf("%d cells", len(row)) | |||
| 282 | + | } | |||
| 283 | + | for _, cell := range row { | |||
| 284 | + | bad(cell, MaxLabel) | |||
| 285 | + | if strings.ContainsRune(cell, '\n') { | |||
| 286 | + | t.Fatalf("cell %q", cell) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 259 | count := 0 | 290 | count := 0 | ||
| 260 | var walk func(items []MenuItem, depth int) | 291 | var walk func(items []MenuItem, depth int) | ||
| 261 | walk = func(items []MenuItem, depth int) { | 292 | walk = func(items []MenuItem, depth int) { | ||
M internal/popup/host.go +9 -21
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "slices" | |
| 7 | 8 | "strconv" | |
| 8 | - | "strings" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/neferclient" | |
| @@ -76,7 +76,7 @@ type Host struct { | |||
| 76 | 76 | menu *menuModel | |
| 77 | 77 | ||
| 78 | 78 | kind Kind | |
| 79 | - | tipKey tooltipKey // what the open tooltip shows | |
| 79 | + | tipCtl module.Control // what the open tooltip shows | |
| 80 | 80 | owner *module.Module | |
| 81 | 81 | ownerGen uint64 | |
| 82 | 82 | anchor neferclient.Rect | |
| @@ -166,23 +166,11 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) { | |||
| 166 | 166 | return true | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | - | // tooltipKey is what identifies a tooltip: showing the same one again is | |
| 170 | - | // pointless. | |
| 171 | - | type tooltipKey struct { | |
| 172 | - | title, body, rows string | |
| 173 | - | col, width int | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | func keyOf(c module.Control) tooltipKey { | |
| 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} | |
| 169 | + | // sameTooltip reports whether two tooltip controls show the same thing: | |
| 170 | + | // showing the same one again is pointless. | |
| 171 | + | func sameTooltip(a, b module.Control) bool { | |
| 172 | + | return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width && | |
| 173 | + | slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string]) | |
| 186 | 174 | } | |
| 187 | 175 | ||
| 188 | 176 | // ShowsTooltip reports whether the open popup is a tooltip of owner that shows | |
| @@ -192,7 +180,7 @@ func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool { | |||
| 192 | 180 | } | |
| 193 | 181 | ||
| 194 | 182 | func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool { | |
| 195 | - | return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c) | |
| 183 | + | return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c) | |
| 196 | 184 | } | |
| 197 | 185 | ||
| 198 | 186 | // Active reports whether a popup is open. | |
| @@ -276,7 +264,7 @@ func (h *Host) Open(req Request) error { | |||
| 276 | 264 | return fmt.Errorf("popup: %w", err) | |
| 277 | 265 | } | |
| 278 | 266 | h.r, h.kind = r, req.Kind | |
| 279 | - | h.tipKey = keyOf(req.Ctrl) | |
| 267 | + | h.tipCtl = req.Ctrl | |
| 280 | 268 | switch req.Kind { | |
| 281 | 269 | case KindTooltip: | |
| 282 | 270 | h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows) | |
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "slices" | |||
| 7 | "strconv" | 8 | "strconv" | ||
| 8 | - | "strings" | |||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/neferclient" | 11 | "github.com/bnema/neferclient" | ||
| @@ -76,7 +76,7 @@ type Host struct { | |||||
| 76 | menu *menuModel | 76 | menu *menuModel | ||
| 77 | 77 | ||||
| 78 | kind Kind | 78 | kind Kind | ||
| 79 | - | tipKey tooltipKey // what the open tooltip shows | 79 | + | tipCtl module.Control // what the open tooltip shows |
| 80 | owner *module.Module | 80 | owner *module.Module | ||
| 81 | ownerGen uint64 | 81 | ownerGen uint64 | ||
| 82 | anchor neferclient.Rect | 82 | anchor neferclient.Rect | ||
| @@ -166,23 +166,11 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) { | |||||
| 166 | return true | 166 | return true | ||
| 167 | } | 167 | } | ||
| 168 | 168 | ||||
| 169 | - | // tooltipKey is what identifies a tooltip: showing the same one again is | 169 | + | // sameTooltip reports whether two tooltip controls show the same thing: |
| 170 | - | // pointless. | 170 | + | // showing the same one again is pointless. |
| 171 | - | type tooltipKey struct { | 171 | + | func sameTooltip(a, b module.Control) bool { |
| 172 | - | title, body, rows string | 172 | + | return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width && |
| 173 | - | col, width int | 173 | + | slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string]) |
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | func keyOf(c module.Control) tooltipKey { | |||
| 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} | |||
| 186 | } | 174 | } | ||
| 187 | 175 | ||||
| 188 | // ShowsTooltip reports whether the open popup is a tooltip of owner that shows | 176 | // ShowsTooltip reports whether the open popup is a tooltip of owner that shows | ||
| @@ -192,7 +180,7 @@ func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool { | |||||
| 192 | } | 180 | } | ||
| 193 | 181 | ||||
| 194 | func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool { | 182 | func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool { | ||
| 195 | - | return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c) | 183 | + | return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c) |
| 196 | } | 184 | } | ||
| 197 | 185 | ||||
| 198 | // Active reports whether a popup is open. | 186 | // Active reports whether a popup is open. | ||
| @@ -276,7 +264,7 @@ func (h *Host) Open(req Request) error { | |||||
| 276 | return fmt.Errorf("popup: %w", err) | 264 | return fmt.Errorf("popup: %w", err) | ||
| 277 | } | 265 | } | ||
| 278 | h.r, h.kind = r, req.Kind | 266 | h.r, h.kind = r, req.Kind | ||
| 279 | - | h.tipKey = keyOf(req.Ctrl) | 267 | + | h.tipCtl = req.Ctrl |
| 280 | switch req.Kind { | 268 | switch req.Kind { | ||
| 281 | case KindTooltip: | 269 | case KindTooltip: | ||
| 282 | h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows) | 270 | h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows) | ||
M internal/popup/popup_test.go +8 -7
| @@ -178,14 +178,15 @@ func TestTooltipTable(t *testing.T) { | |||
| 178 | 178 | } | |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | - | func TestTooltipKeyIncludesRows(t *testing.T) { | |
| 181 | + | func TestSameTooltipComparesRows(t *testing.T) { | |
| 182 | 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") | |
| 183 | + | for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} { | |
| 184 | + | if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) { | |
| 185 | + | t.Fatalf("%q is a different table", rows) | |
| 186 | + | } | |
| 186 | 187 | } | |
| 187 | - | if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) { | |
| 188 | - | t.Fatal("same table, same key") | |
| 188 | + | if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) { | |
| 189 | + | t.Fatal("same table, same tooltip") | |
| 189 | 190 | } | |
| 190 | 191 | } | |
| 191 | 192 | ||
| @@ -303,7 +304,7 @@ func TestShowsTooltip(t *testing.T) { | |||
| 303 | 304 | m := &module.Module{} | |
| 304 | 305 | other := &module.Module{} | |
| 305 | 306 | c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"} | |
| 306 | - | h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)} | |
| 307 | + | h := &Host{kind: KindTooltip, owner: m, tipCtl: c} | |
| 307 | 308 | if !h.showsTooltip(m, c) { | |
| 308 | 309 | t.Fatal("the same tooltip of the same owner is shown") | |
| 309 | 310 | } | |
| @@ -178,14 +178,15 @@ func TestTooltipTable(t *testing.T) { | |||||
| 178 | } | 178 | } | ||
| 179 | } | 179 | } | ||
| 180 | 180 | ||||
| 181 | - | func TestTooltipKeyIncludesRows(t *testing.T) { | 181 | + | func TestSameTooltipComparesRows(t *testing.T) { |
| 182 | a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}} | 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"}}} | 183 | + | for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} { |
| 184 | - | if keyOf(a) == keyOf(b) { | 184 | + | if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) { |
| 185 | - | t.Fatal("different tables must not share a key") | 185 | + | t.Fatalf("%q is a different table", rows) |
| 186 | + | } | |||
| 186 | } | 187 | } | ||
| 187 | - | if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) { | 188 | + | if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) { |
| 188 | - | t.Fatal("same table, same key") | 189 | + | t.Fatal("same table, same tooltip") |
| 189 | } | 190 | } | ||
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| @@ -303,7 +304,7 @@ func TestShowsTooltip(t *testing.T) { | |||||
| 303 | m := &module.Module{} | 304 | m := &module.Module{} | ||
| 304 | other := &module.Module{} | 305 | other := &module.Module{} | ||
| 305 | c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"} | 306 | c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"} | ||
| 306 | - | h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)} | 307 | + | h := &Host{kind: KindTooltip, owner: m, tipCtl: c} |
| 307 | if !h.showsTooltip(m, c) { | 308 | if !h.showsTooltip(m, c) { | ||
| 308 | t.Fatal("the same tooltip of the same owner is shown") | 309 | t.Fatal("the same tooltip of the same owner is shown") | ||
| 309 | } | 310 | } | ||
M internal/popup/view.go +2 -1
| @@ -30,7 +30,8 @@ func newTooltip(title, body string, rows [][]string) *tooltipModel { | |||
| 30 | 30 | ||
| 31 | 31 | // tooltipView draws the title in bold, the body, then the table. The table is | |
| 32 | 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. | |
| 33 | + | // and the cells of a row line up, as long as no cell wraps: a cell wider than | |
| 34 | + | // the tooltip can be takes two lines and shifts the rest of its column. | |
| 34 | 35 | func tooltipView(f *nefergui.Frame, m *tooltipModel) { | |
| 35 | 36 | col := f.Root().Column() | |
| 36 | 37 | if m.title != "" { | |
| @@ -30,7 +30,8 @@ func newTooltip(title, body string, rows [][]string) *tooltipModel { | |||||
| 30 | 30 | ||||
| 31 | // tooltipView draws the title in bold, the body, then the table. The table is | 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 | 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. | 33 | + | // and the cells of a row line up, as long as no cell wraps: a cell wider than |
| 34 | + | // the tooltip can be takes two lines and shifts the rest of its column. | |||
| 34 | func tooltipView(f *nefergui.Frame, m *tooltipModel) { | 35 | func tooltipView(f *nefergui.Frame, m *tooltipModel) { | ||
| 35 | col := f.Root().Column() | 36 | col := f.Root().Column() | ||
| 36 | if m.title != "" { | 37 | if m.title != "" { | ||