detached 476c4a3

feat(module): control lines

A script can print an OSC 777 neferbar line with a JSON tooltip, menu or close request. The line never becomes a frame; ParseControl enforces the size, depth, item and label limits.
commit
476c4a39929a021c7b33cc662b0ee8b33a8190d1
author
brice · · 2026-10-09 04:40 UTC
parent
77cf70f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +424 -14
A internal/module/control.go +191 -0
@@ -0,0 +1,191 @@
1+package module
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "unicode"
8+ "unicode/utf8"
9+)
10+
11+// Limits of a control line. ParseControl refuses what exceeds them.
12+const (
13+ // MaxMenuDepth is how many levels of submenus a menu may nest.
14+ MaxMenuDepth = 8
15+ // MaxMenuItems is how many items a whole menu may hold, submenus included.
16+ MaxMenuItems = 512
17+ // MaxLabel is the longest item label or tooltip title, in bytes; longer
18+ // ones are cut at a rune boundary.
19+ MaxLabel = 256
20+ // MaxText is the longest tooltip body, in bytes; longer ones are cut at a
21+ // rune boundary.
22+ MaxText = 1024
23+ // maxColumn bounds the column and width of a control line.
24+ maxColumn = 1 << 20
25+)
26+
27+// Control types.
28+const (
29+ ControlTooltip = "tooltip"
30+ ControlMenu = "menu"
31+ ControlClose = "close"
32+)
33+
34+// Menu item kinds.
35+const (
36+ KindNormal = "normal"
37+ KindSeparator = "separator"
38+ KindCheck = "check"
39+ KindRadio = "radio"
40+)
41+
42+// Control is one parsed control line of a script.
43+type Control struct {
44+ Type string
45+ Col int // first cell of the anchor inside the module
46+ Width int // cells of the anchor
47+ // Title and Body are the text of a tooltip.
48+ Title, Body string
49+ // Click is the token of the press a menu answers.
50+ Click uint32
51+ Items []MenuItem
52+}
53+
54+// MenuItem is one entry of a menu.
55+type MenuItem struct {
56+ ID int32
57+ Label string
58+ Kind string
59+ Enabled bool
60+ Checked bool
61+ Items []MenuItem // a submenu
62+}
63+
64+type wireControl struct {
65+ Type string `json:"type"`
66+ Col int `json:"col"`
67+ Width int `json:"width"`
68+ Title string `json:"title"`
69+ Body string `json:"body"`
70+ Click uint32 `json:"click"`
71+ Items []wireItem `json:"items"`
72+}
73+
74+type wireItem struct {
75+ ID int32 `json:"id"`
76+ Label string `json:"label"`
77+ Kind string `json:"kind"`
78+ Enabled *bool `json:"enabled"`
79+ Checked bool `json:"checked"`
80+ Items []wireItem `json:"items"`
81+}
82+
83+// ParseControl reads the JSON of a control line (see TakeControl) and checks
84+// every limit. Control characters are removed from the texts, labels are cut
85+// to MaxLabel bytes, and an item without "enabled" is enabled.
86+func ParseControl(b []byte) (Control, error) {
87+ if len(b) > MaxControl {
88+ return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl)
89+ }
90+ var w wireControl
91+ if err := json.Unmarshal(b, &w); err != nil {
92+ return Control{}, fmt.Errorf("control line: %w", err)
93+ }
94+ if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn {
95+ return Control{}, errors.New("control line: column or width out of range")
96+ }
97+ c := Control{Type: w.Type, Col: w.Col, Width: w.Width}
98+ switch w.Type {
99+ case ControlClose:
100+ return c, nil
101+ case ControlTooltip:
102+ if w.Width == 0 {
103+ return Control{}, errors.New("tooltip: width must be positive")
104+ }
105+ c.Title = clean(w.Title, MaxLabel, false)
106+ c.Body = clean(w.Body, MaxText, true)
107+ if c.Title == "" && c.Body == "" {
108+ return Control{}, errors.New("tooltip: no text")
109+ }
110+ return c, nil
111+ case ControlMenu:
112+ if w.Width == 0 {
113+ return Control{}, errors.New("menu: width must be positive")
114+ }
115+ c.Click = w.Click
116+ count := 0
117+ items, err := convertItems(w.Items, 1, &count)
118+ if err != nil {
119+ return Control{}, fmt.Errorf("menu: %w", err)
120+ }
121+ if len(items) == 0 {
122+ return Control{}, errors.New("menu: no items")
123+ }
124+ c.Items = items
125+ return c, nil
126+ }
127+ return Control{}, fmt.Errorf("control line: unknown type %q", w.Type)
128+}
129+
130+func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) {
131+ if len(in) == 0 {
132+ return nil, nil
133+ }
134+ if depth > MaxMenuDepth {
135+ return nil, fmt.Errorf("more than %d levels", MaxMenuDepth)
136+ }
137+ out := make([]MenuItem, 0, len(in))
138+ for i := range in {
139+ if *count++; *count > MaxMenuItems {
140+ return nil, fmt.Errorf("more than %d items", MaxMenuItems)
141+ }
142+ w := &in[i]
143+ it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked}
144+ if w.ID < 0 {
145+ return nil, fmt.Errorf("item %d: negative id", w.ID)
146+ }
147+ switch w.Kind {
148+ case "":
149+ it.Kind = KindNormal
150+ case KindNormal, KindSeparator, KindCheck, KindRadio:
151+ default:
152+ return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind)
153+ }
154+ sub, err := convertItems(w.Items, depth+1, count)
155+ if err != nil {
156+ return nil, err
157+ }
158+ it.Items = sub
159+ out = append(out, it)
160+ }
161+ return out, nil
162+}
163+
164+// clean drops control characters (and anything Unicode calls a format or
165+// unassigned control, such as bidi overrides), keeps newlines when keepNL, and
166+// cuts the result to max bytes at a rune boundary.
167+func clean(s string, max int, keepNL bool) string {
168+ needs := len(s) > max
169+ for _, r := range s {
170+ if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
171+ needs = true
172+ break
173+ }
174+ }
175+ if !needs {
176+ return s
177+ }
178+ out := make([]byte, 0, min(len(s), max))
179+ for _, r := range s {
180+ switch {
181+ case r == '\n' && keepNL:
182+ case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r):
183+ continue
184+ }
185+ if len(out)+utf8.RuneLen(r) > max {
186+ break
187+ }
188+ out = utf8.AppendRune(out, r)
189+ }
190+ return string(out)
191+}
@@ -0,0 +1,191 @@
1+package module
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "unicode"
8+ "unicode/utf8"
9+)
10+
11+// Limits of a control line. ParseControl refuses what exceeds them.
12+const (
13+ // MaxMenuDepth is how many levels of submenus a menu may nest.
14+ MaxMenuDepth = 8
15+ // MaxMenuItems is how many items a whole menu may hold, submenus included.
16+ MaxMenuItems = 512
17+ // MaxLabel is the longest item label or tooltip title, in bytes; longer
18+ // ones are cut at a rune boundary.
19+ MaxLabel = 256
20+ // MaxText is the longest tooltip body, in bytes; longer ones are cut at a
21+ // rune boundary.
22+ MaxText = 1024
23+ // maxColumn bounds the column and width of a control line.
24+ maxColumn = 1 << 20
25+)
26+
27+// Control types.
28+const (
29+ ControlTooltip = "tooltip"
30+ ControlMenu = "menu"
31+ ControlClose = "close"
32+)
33+
34+// Menu item kinds.
35+const (
36+ KindNormal = "normal"
37+ KindSeparator = "separator"
38+ KindCheck = "check"
39+ KindRadio = "radio"
40+)
41+
42+// Control is one parsed control line of a script.
43+type Control struct {
44+ Type string
45+ Col int // first cell of the anchor inside the module
46+ Width int // cells of the anchor
47+ // Title and Body are the text of a tooltip.
48+ Title, Body string
49+ // Click is the token of the press a menu answers.
50+ Click uint32
51+ Items []MenuItem
52+}
53+
54+// MenuItem is one entry of a menu.
55+type MenuItem struct {
56+ ID int32
57+ Label string
58+ Kind string
59+ Enabled bool
60+ Checked bool
61+ Items []MenuItem // a submenu
62+}
63+
64+type wireControl struct {
65+ Type string `json:"type"`
66+ Col int `json:"col"`
67+ Width int `json:"width"`
68+ Title string `json:"title"`
69+ Body string `json:"body"`
70+ Click uint32 `json:"click"`
71+ Items []wireItem `json:"items"`
72+}
73+
74+type wireItem struct {
75+ ID int32 `json:"id"`
76+ Label string `json:"label"`
77+ Kind string `json:"kind"`
78+ Enabled *bool `json:"enabled"`
79+ Checked bool `json:"checked"`
80+ Items []wireItem `json:"items"`
81+}
82+
83+// ParseControl reads the JSON of a control line (see TakeControl) and checks
84+// every limit. Control characters are removed from the texts, labels are cut
85+// to MaxLabel bytes, and an item without "enabled" is enabled.
86+func ParseControl(b []byte) (Control, error) {
87+ if len(b) > MaxControl {
88+ return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl)
89+ }
90+ var w wireControl
91+ if err := json.Unmarshal(b, &w); err != nil {
92+ return Control{}, fmt.Errorf("control line: %w", err)
93+ }
94+ if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn {
95+ return Control{}, errors.New("control line: column or width out of range")
96+ }
97+ c := Control{Type: w.Type, Col: w.Col, Width: w.Width}
98+ switch w.Type {
99+ case ControlClose:
100+ return c, nil
101+ case ControlTooltip:
102+ if w.Width == 0 {
103+ return Control{}, errors.New("tooltip: width must be positive")
104+ }
105+ c.Title = clean(w.Title, MaxLabel, false)
106+ c.Body = clean(w.Body, MaxText, true)
107+ if c.Title == "" && c.Body == "" {
108+ return Control{}, errors.New("tooltip: no text")
109+ }
110+ return c, nil
111+ case ControlMenu:
112+ if w.Width == 0 {
113+ return Control{}, errors.New("menu: width must be positive")
114+ }
115+ c.Click = w.Click
116+ count := 0
117+ items, err := convertItems(w.Items, 1, &count)
118+ if err != nil {
119+ return Control{}, fmt.Errorf("menu: %w", err)
120+ }
121+ if len(items) == 0 {
122+ return Control{}, errors.New("menu: no items")
123+ }
124+ c.Items = items
125+ return c, nil
126+ }
127+ return Control{}, fmt.Errorf("control line: unknown type %q", w.Type)
128+}
129+
130+func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) {
131+ if len(in) == 0 {
132+ return nil, nil
133+ }
134+ if depth > MaxMenuDepth {
135+ return nil, fmt.Errorf("more than %d levels", MaxMenuDepth)
136+ }
137+ out := make([]MenuItem, 0, len(in))
138+ for i := range in {
139+ if *count++; *count > MaxMenuItems {
140+ return nil, fmt.Errorf("more than %d items", MaxMenuItems)
141+ }
142+ w := &in[i]
143+ it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked}
144+ if w.ID < 0 {
145+ return nil, fmt.Errorf("item %d: negative id", w.ID)
146+ }
147+ switch w.Kind {
148+ case "":
149+ it.Kind = KindNormal
150+ case KindNormal, KindSeparator, KindCheck, KindRadio:
151+ default:
152+ return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind)
153+ }
154+ sub, err := convertItems(w.Items, depth+1, count)
155+ if err != nil {
156+ return nil, err
157+ }
158+ it.Items = sub
159+ out = append(out, it)
160+ }
161+ return out, nil
162+}
163+
164+// clean drops control characters (and anything Unicode calls a format or
165+// unassigned control, such as bidi overrides), keeps newlines when keepNL, and
166+// cuts the result to max bytes at a rune boundary.
167+func clean(s string, max int, keepNL bool) string {
168+ needs := len(s) > max
169+ for _, r := range s {
170+ if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
171+ needs = true
172+ break
173+ }
174+ }
175+ if !needs {
176+ return s
177+ }
178+ out := make([]byte, 0, min(len(s), max))
179+ for _, r := range s {
180+ switch {
181+ case r == '\n' && keepNL:
182+ case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r):
183+ continue
184+ }
185+ if len(out)+utf8.RuneLen(r) > max {
186+ break
187+ }
188+ out = utf8.AppendRune(out, r)
189+ }
190+ return string(out)
191+}
A internal/module/control_test.go +166 -0
@@ -0,0 +1,166 @@
1+package module
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestParseControlValid(t *testing.T) {
10+ c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`))
11+ if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" {
12+ t.Fatalf("tooltip: %+v, %v", c, err)
13+ }
14+ c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[
15+ {"id":1,"label":"Open"},{"id":2,"kind":"separator"},
16+ {"id":3,"label":"Mute","kind":"check","checked":true},
17+ {"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]},
18+ {"id":6,"label":"Quit","enabled":false}]}`))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if c.Click != 9 || len(c.Items) != 5 {
23+ t.Fatalf("menu: %+v", c)
24+ }
25+ if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal {
26+ t.Fatalf("defaults: %+v", c.Items[0])
27+ }
28+ if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked {
29+ t.Fatalf("kinds: %+v", c.Items)
30+ }
31+ if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled {
32+ t.Fatalf("submenu/disabled: %+v", c.Items)
33+ }
34+ if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose {
35+ t.Fatalf("close: %+v, %v", c, err)
36+ }
37+}
38+
39+// nested builds a menu with depth levels of submenus.
40+func nested(depth int) string {
41+ s := `{"id":1,"label":"x"}`
42+ for i := 1; i < depth; i++ {
43+ s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s)
44+ }
45+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}`
46+}
47+
48+func TestParseControlLimits(t *testing.T) {
49+ if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil {
50+ t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err)
51+ }
52+ many := func(n int) string {
53+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}`
54+ }
55+ if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil {
56+ t.Fatalf("%d items must pass: %v", MaxMenuItems, err)
57+ }
58+ for name, in := range map[string]string{
59+ "too deep": nested(MaxMenuDepth + 1),
60+ "too many": many(MaxMenuItems + 1),
61+ "oversize": `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`,
62+ "unknown type": `{"type":"explode"}`,
63+ "no type": `{}`,
64+ "bad json": `{"type":`,
65+ "not an object": `[1]`,
66+ "neg col": `{"type":"tooltip","col":-1,"width":1,"title":"a"}`,
67+ "huge width": `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`,
68+ "zero width": `{"type":"tooltip","col":0,"width":0,"title":"a"}`,
69+ "empty tip": `{"type":"tooltip","col":0,"width":1}`,
70+ "bad kind": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`,
71+ "neg id": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`,
72+ "id overflow": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`,
73+ "no items": `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`,
74+ "neg click": `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`,
75+ } {
76+ if _, err := ParseControl([]byte(in)); err == nil {
77+ t.Errorf("%s: want an error", name)
78+ }
79+ }
80+}
81+
82+func TestParseControlCleansText(t *testing.T) {
83+ long := strings.Repeat("é", MaxLabel) // 2 bytes each
84+ c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`))
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ got := c.Items[0].Label
89+ if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") {
90+ t.Fatalf("label = %q (%d bytes)", got, len(got))
91+ }
92+ if strings.ContainsRune(got, '\uFFFD') {
93+ t.Fatalf("cut inside a rune: %q", got)
94+ }
95+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`))
96+ if err != nil || c.Title != "T" || c.Body != "a\nbc" {
97+ t.Fatalf("tooltip text: %+v, %v", c, err)
98+ }
99+}
100+
101+func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
102+ m, _ := newTest()
103+ m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
104+ got, changed, _ := m.Take(nil)
105+ if !changed || string(got) != "two" {
106+ t.Fatalf("frame = %q changed=%v, want two", got, changed)
107+ }
108+ b, ok := m.TakeControl()
109+ if !ok || string(b) != `{"type":"close"}` {
110+ t.Fatalf("control = %q %v", b, ok)
111+ }
112+ if _, ok = m.TakeControl(); ok {
113+ t.Fatal("one control line expected")
114+ }
115+
116+ // The control line alone never becomes a frame.
117+ m, _ = newTest()
118+ m.readFrames(strings.NewReader("one\n"))
119+ m.Take(nil)
120+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n"))
121+ if _, changed, _ = m.Take(nil); changed {
122+ t.Fatal("a control line must not be published as a frame")
123+ }
124+}
125+
126+func TestUnterminatedControlIsAFrame(t *testing.T) {
127+ m, _ := newTest()
128+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n"))
129+ if _, ok := m.TakeControl(); ok {
130+ t.Fatal("a line without BEL is not a control line")
131+ }
132+ got, changed, _ := m.Take(nil)
133+ if !changed || string(got) != "\x1b]777;neferbar;{bad" {
134+ t.Fatalf("frame = %q changed=%v", got, changed)
135+ }
136+}
137+
138+func TestOversizeControlIsDropped(t *testing.T) {
139+ m, _ := newTest()
140+ m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n"))
141+ if _, ok := m.TakeControl(); ok {
142+ t.Fatal("an oversize control line must be dropped")
143+ }
144+ if got, _, _ := m.Take(nil); string(got) != "b" {
145+ t.Fatalf("frame = %q, want b", got)
146+ }
147+}
148+
149+func TestControlQueueDropsWhenFull(t *testing.T) {
150+ m, wake := newTest()
151+ var in strings.Builder
152+ for range 20 {
153+ in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")
154+ }
155+ m.readFrames(strings.NewReader(in.String()))
156+ n := 0
157+ for {
158+ if _, ok := m.TakeControl(); !ok {
159+ break
160+ }
161+ n++
162+ }
163+ if n != 8 || len(wake) != 1 {
164+ t.Fatalf("queued %d (want 8), wake %d", n, len(wake))
165+ }
166+}
@@ -0,0 +1,166 @@
1+package module
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestParseControlValid(t *testing.T) {
10+ c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`))
11+ if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" {
12+ t.Fatalf("tooltip: %+v, %v", c, err)
13+ }
14+ c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[
15+ {"id":1,"label":"Open"},{"id":2,"kind":"separator"},
16+ {"id":3,"label":"Mute","kind":"check","checked":true},
17+ {"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]},
18+ {"id":6,"label":"Quit","enabled":false}]}`))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if c.Click != 9 || len(c.Items) != 5 {
23+ t.Fatalf("menu: %+v", c)
24+ }
25+ if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal {
26+ t.Fatalf("defaults: %+v", c.Items[0])
27+ }
28+ if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked {
29+ t.Fatalf("kinds: %+v", c.Items)
30+ }
31+ if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled {
32+ t.Fatalf("submenu/disabled: %+v", c.Items)
33+ }
34+ if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose {
35+ t.Fatalf("close: %+v, %v", c, err)
36+ }
37+}
38+
39+// nested builds a menu with depth levels of submenus.
40+func nested(depth int) string {
41+ s := `{"id":1,"label":"x"}`
42+ for i := 1; i < depth; i++ {
43+ s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s)
44+ }
45+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}`
46+}
47+
48+func TestParseControlLimits(t *testing.T) {
49+ if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil {
50+ t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err)
51+ }
52+ many := func(n int) string {
53+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}`
54+ }
55+ if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil {
56+ t.Fatalf("%d items must pass: %v", MaxMenuItems, err)
57+ }
58+ for name, in := range map[string]string{
59+ "too deep": nested(MaxMenuDepth + 1),
60+ "too many": many(MaxMenuItems + 1),
61+ "oversize": `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`,
62+ "unknown type": `{"type":"explode"}`,
63+ "no type": `{}`,
64+ "bad json": `{"type":`,
65+ "not an object": `[1]`,
66+ "neg col": `{"type":"tooltip","col":-1,"width":1,"title":"a"}`,
67+ "huge width": `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`,
68+ "zero width": `{"type":"tooltip","col":0,"width":0,"title":"a"}`,
69+ "empty tip": `{"type":"tooltip","col":0,"width":1}`,
70+ "bad kind": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`,
71+ "neg id": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`,
72+ "id overflow": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`,
73+ "no items": `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`,
74+ "neg click": `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`,
75+ } {
76+ if _, err := ParseControl([]byte(in)); err == nil {
77+ t.Errorf("%s: want an error", name)
78+ }
79+ }
80+}
81+
82+func TestParseControlCleansText(t *testing.T) {
83+ long := strings.Repeat("é", MaxLabel) // 2 bytes each
84+ c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`))
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ got := c.Items[0].Label
89+ if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") {
90+ t.Fatalf("label = %q (%d bytes)", got, len(got))
91+ }
92+ if strings.ContainsRune(got, '\uFFFD') {
93+ t.Fatalf("cut inside a rune: %q", got)
94+ }
95+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`))
96+ if err != nil || c.Title != "T" || c.Body != "a\nbc" {
97+ t.Fatalf("tooltip text: %+v, %v", c, err)
98+ }
99+}
100+
101+func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
102+ m, _ := newTest()
103+ m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
104+ got, changed, _ := m.Take(nil)
105+ if !changed || string(got) != "two" {
106+ t.Fatalf("frame = %q changed=%v, want two", got, changed)
107+ }
108+ b, ok := m.TakeControl()
109+ if !ok || string(b) != `{"type":"close"}` {
110+ t.Fatalf("control = %q %v", b, ok)
111+ }
112+ if _, ok = m.TakeControl(); ok {
113+ t.Fatal("one control line expected")
114+ }
115+
116+ // The control line alone never becomes a frame.
117+ m, _ = newTest()
118+ m.readFrames(strings.NewReader("one\n"))
119+ m.Take(nil)
120+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n"))
121+ if _, changed, _ = m.Take(nil); changed {
122+ t.Fatal("a control line must not be published as a frame")
123+ }
124+}
125+
126+func TestUnterminatedControlIsAFrame(t *testing.T) {
127+ m, _ := newTest()
128+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n"))
129+ if _, ok := m.TakeControl(); ok {
130+ t.Fatal("a line without BEL is not a control line")
131+ }
132+ got, changed, _ := m.Take(nil)
133+ if !changed || string(got) != "\x1b]777;neferbar;{bad" {
134+ t.Fatalf("frame = %q changed=%v", got, changed)
135+ }
136+}
137+
138+func TestOversizeControlIsDropped(t *testing.T) {
139+ m, _ := newTest()
140+ m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n"))
141+ if _, ok := m.TakeControl(); ok {
142+ t.Fatal("an oversize control line must be dropped")
143+ }
144+ if got, _, _ := m.Take(nil); string(got) != "b" {
145+ t.Fatalf("frame = %q, want b", got)
146+ }
147+}
148+
149+func TestControlQueueDropsWhenFull(t *testing.T) {
150+ m, wake := newTest()
151+ var in strings.Builder
152+ for range 20 {
153+ in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")
154+ }
155+ m.readFrames(strings.NewReader(in.String()))
156+ n := 0
157+ for {
158+ if _, ok := m.TakeControl(); !ok {
159+ break
160+ }
161+ n++
162+ }
163+ if n != 8 || len(wake) != 1 {
164+ t.Fatalf("queued %d (want 8), wake %d", n, len(wake))
165+ }
166+}
M internal/module/module.go +67 -14
@@ -23,7 +23,10 @@ import (
2323 const (
2424 // MaxFrame bounds one frame; longer lines are truncated.
2525 MaxFrame = 16 << 10
26- readSize = 32 << 10
26+ // MaxControl bounds one control line (see ParseControl); longer ones are
27+ // dropped.
28+ MaxControl = 64 << 10
29+ readSize = 32 << 10
2730
2831 minBackoff = 500 * time.Millisecond
2932 maxBackoff = 30 * time.Second
@@ -56,8 +59,9 @@ type Module struct {
5659 dirty bool
5760 failed bool // the script is not running; show an error marker
5861
59- input chan event // lines for the script's stdin
60- gen atomic.Uint64 // counts script starts; see Gen
62+ input chan event // lines for the script's stdin
63+ control chan []byte // control lines of the script, see TakeControl
64+ gen atomic.Uint64 // counts script starts; see Gen
6165
6266 wake chan<- struct{}
6367 log *slog.Logger
@@ -77,7 +81,7 @@ type event struct {
7781 // New creates a module. wake receives a non-blocking signal after each frame.
7882 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
7983 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
80- pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)}
84+ pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)}
8185 }
8286
8387 // Gen counts the times the script was started. A value that changed means a
@@ -262,22 +266,69 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
262266 }
263267 }
264268
265-// readFrames splits r into frames and publishes each one.
269+// controlPrefix starts a control line: an OSC 777 sequence of the "neferbar"
270+// application, ended by controlEnd (BEL) before the newline.
271+const (
272+ controlPrefix = "\x1b]777;neferbar;"
273+ controlEnd = '\x07'
274+)
275+
276+// TakeControl returns the oldest control line the script printed, the JSON
277+// between the prefix and the BEL, or false when there is none. It never blocks.
278+func (m *Module) TakeControl() ([]byte, bool) {
279+ select {
280+ case b := <-m.control:
281+ return b, true
282+ default:
283+ return nil, false
284+ }
285+}
286+
287+// queueControl keeps a copy of the JSON of a control line. A full queue drops
288+// the line: the bar is not draining it.
289+func (m *Module) queueControl(json []byte) {
290+ select {
291+ case m.control <- append([]byte(nil), json...):
292+ m.signal()
293+ default:
294+ m.log.Debug("module control queue full; line dropped", "module", m.Name)
295+ }
296+}
297+
298+// line handles one complete line: a control line, or else a frame.
299+func (m *Module) line(line []byte, over bool) {
300+ if bytes.HasPrefix(line, []byte(controlPrefix)) {
301+ switch {
302+ case over:
303+ m.log.Warn("module control line too long; dropped", "module", m.Name, "max", MaxControl)
304+ return
305+ case len(line) > 0 && line[len(line)-1] == controlEnd:
306+ m.queueControl(line[len(controlPrefix) : len(line)-1])
307+ return
308+ }
309+ // No BEL: not a control line; it is shown like any text.
310+ }
311+ m.publish(line[:min(len(line), MaxFrame)])
312+}
313+
314+// readFrames splits r into frames and publishes each one. Control lines go to
315+// the control queue instead.
266316 func (m *Module) readFrames(r io.Reader) {
267317 buf := make([]byte, readSize)
268- frame := make([]byte, 0, MaxFrame)
318+ line := make([]byte, 0, MaxFrame)
319+ over := false // the current line is longer than MaxControl
269320 for {
270321 n, err := r.Read(buf)
271322 chunk := buf[:n]
272323 for len(chunk) > 0 {
273324 i := bytes.IndexAny(chunk, "\n\f")
274325 if i < 0 {
275- frame = appendCapped(frame, chunk)
326+ line, over = appendCapped(line, chunk, over)
276327 break
277328 }
278- frame = appendCapped(frame, chunk[:i])
279- m.publish(frame)
280- frame = frame[:0]
329+ line, over = appendCapped(line, chunk[:i], over)
330+ m.line(line, over)
331+ line, over = line[:0], false
281332 chunk = chunk[i+1:]
282333 }
283334 if err != nil {
@@ -286,11 +337,13 @@ func (m *Module) readFrames(r io.Reader) {
286337 }
287338 }
288339
289-func appendCapped(dst, src []byte) []byte {
290- if room := MaxFrame - len(dst); len(src) > room {
291- src = src[:max(room, 0)]
340+// appendCapped appends src to dst up to MaxControl bytes and reports whether
341+// anything was left out.
342+func appendCapped(dst, src []byte, over bool) ([]byte, bool) {
343+ if room := MaxControl - len(dst); len(src) > room {
344+ src, over = src[:max(room, 0)], true
292345 }
293- return append(dst, src...)
346+ return append(dst, src...), over
294347 }
295348
296349 // logWriter forwards a script's stderr lines to the bar's log.
@@ -23,7 +23,10 @@ import (
23 const (23 const (
24 // MaxFrame bounds one frame; longer lines are truncated.24 // MaxFrame bounds one frame; longer lines are truncated.
25 MaxFrame = 16 << 1025 MaxFrame = 16 << 10
26- readSize = 32 << 1026+ // MaxControl bounds one control line (see ParseControl); longer ones are
27+ // dropped.
28+ MaxControl = 64 << 10
29+ readSize = 32 << 10
27 30
28 minBackoff = 500 * time.Millisecond31 minBackoff = 500 * time.Millisecond
29 maxBackoff = 30 * time.Second32 maxBackoff = 30 * time.Second
@@ -56,8 +59,9 @@ type Module struct {
56 dirty bool59 dirty bool
57 failed bool // the script is not running; show an error marker60 failed bool // the script is not running; show an error marker
58 61
59- input chan event // lines for the script's stdin62+ input chan event // lines for the script's stdin
60- gen atomic.Uint64 // counts script starts; see Gen63+ control chan []byte // control lines of the script, see TakeControl
64+ gen atomic.Uint64 // counts script starts; see Gen
61 65
62 wake chan<- struct{}66 wake chan<- struct{}
63 log *slog.Logger67 log *slog.Logger
@@ -77,7 +81,7 @@ type event struct {
77 // New creates a module. wake receives a non-blocking signal after each frame.81 // New creates a module. wake receives a non-blocking signal after each frame.
78 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {82 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
79 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,83 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
80- pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)}84+ pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)}
81 }85 }
82 86
83 // Gen counts the times the script was started. A value that changed means a87 // Gen counts the times the script was started. A value that changed means a
@@ -262,22 +266,69 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
262 }266 }
263 }267 }
264 268
265-// readFrames splits r into frames and publishes each one.269+// controlPrefix starts a control line: an OSC 777 sequence of the "neferbar"
270+// application, ended by controlEnd (BEL) before the newline.
271+const (
272+ controlPrefix = "\x1b]777;neferbar;"
273+ controlEnd = '\x07'
274+)
275+
276+// TakeControl returns the oldest control line the script printed, the JSON
277+// between the prefix and the BEL, or false when there is none. It never blocks.
278+func (m *Module) TakeControl() ([]byte, bool) {
279+ select {
280+ case b := <-m.control:
281+ return b, true
282+ default:
283+ return nil, false
284+ }
285+}
286+
287+// queueControl keeps a copy of the JSON of a control line. A full queue drops
288+// the line: the bar is not draining it.
289+func (m *Module) queueControl(json []byte) {
290+ select {
291+ case m.control <- append([]byte(nil), json...):
292+ m.signal()
293+ default:
294+ m.log.Debug("module control queue full; line dropped", "module", m.Name)
295+ }
296+}
297+
298+// line handles one complete line: a control line, or else a frame.
299+func (m *Module) line(line []byte, over bool) {
300+ if bytes.HasPrefix(line, []byte(controlPrefix)) {
301+ switch {
302+ case over:
303+ m.log.Warn("module control line too long; dropped", "module", m.Name, "max", MaxControl)
304+ return
305+ case len(line) > 0 && line[len(line)-1] == controlEnd:
306+ m.queueControl(line[len(controlPrefix) : len(line)-1])
307+ return
308+ }
309+ // No BEL: not a control line; it is shown like any text.
310+ }
311+ m.publish(line[:min(len(line), MaxFrame)])
312+}
313+
314+// readFrames splits r into frames and publishes each one. Control lines go to
315+// the control queue instead.
266 func (m *Module) readFrames(r io.Reader) {316 func (m *Module) readFrames(r io.Reader) {
267 buf := make([]byte, readSize)317 buf := make([]byte, readSize)
268- frame := make([]byte, 0, MaxFrame)318+ line := make([]byte, 0, MaxFrame)
319+ over := false // the current line is longer than MaxControl
269 for {320 for {
270 n, err := r.Read(buf)321 n, err := r.Read(buf)
271 chunk := buf[:n]322 chunk := buf[:n]
272 for len(chunk) > 0 {323 for len(chunk) > 0 {
273 i := bytes.IndexAny(chunk, "\n\f")324 i := bytes.IndexAny(chunk, "\n\f")
274 if i < 0 {325 if i < 0 {
275- frame = appendCapped(frame, chunk)326+ line, over = appendCapped(line, chunk, over)
276 break327 break
277 }328 }
278- frame = appendCapped(frame, chunk[:i])329+ line, over = appendCapped(line, chunk[:i], over)
279- m.publish(frame)330+ m.line(line, over)
280- frame = frame[:0]331+ line, over = line[:0], false
281 chunk = chunk[i+1:]332 chunk = chunk[i+1:]
282 }333 }
283 if err != nil {334 if err != nil {
@@ -286,11 +337,13 @@ func (m *Module) readFrames(r io.Reader) {
286 }337 }
287 }338 }
288 339
289-func appendCapped(dst, src []byte) []byte {340+// appendCapped appends src to dst up to MaxControl bytes and reports whether
290- if room := MaxFrame - len(dst); len(src) > room {341+// anything was left out.
291- src = src[:max(room, 0)]342+func appendCapped(dst, src []byte, over bool) ([]byte, bool) {
343+ if room := MaxControl - len(dst); len(src) > room {
344+ src, over = src[:max(room, 0)], true
292 }345 }
293- return append(dst, src...)346+ return append(dst, src...), over
294 }347 }
295 348
296 // logWriter forwards a script's stderr lines to the bar's log.349 // logWriter forwards a script's stderr lines to the bar's log.

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