1package module
2
3import (
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.
12const (
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 // MaxRows and MaxCells bound the table of a tooltip; a cell is cut like a
24 // label.
25 MaxRows = 32
26 MaxCells = 4
27 // maxColumn bounds the column and width of a control line.
28 maxColumn = 1 << 20
29)
30
31// Control types.
32const (
33 ControlTooltip = "tooltip"
34 ControlMenu = "menu"
35 ControlClose = "close"
36)
37
38// Menu item kinds.
39const (
40 KindNormal = "normal"
41 KindSeparator = "separator"
42 KindCheck = "check"
43 KindRadio = "radio"
44)
45
46// Control is one parsed control line of a script.
47type Control struct {
48 Type string
49 Col int // first cell of the anchor inside the module
50 Width int // cells of the anchor
51 // Title and Body are the text of a tooltip, Rows its table: one entry
52 // per row, one string per cell.
53 Title, Body string
54 Rows [][]string
55 // Click is the token of the press a menu answers.
56 Click uint32
57 Items []MenuItem
58}
59
60// MenuItem is one entry of a menu.
61type MenuItem struct {
62 ID int32
63 Label string
64 Kind string
65 Enabled bool
66 Checked bool
67 Items []MenuItem // a submenu
68}
69
70type wireControl struct {
71 Type string `json:"type"`
72 Col int `json:"col"`
73 Width int `json:"width"`
74 Title string `json:"title"`
75 Body string `json:"body"`
76 Rows [][]string `json:"rows"`
77 Click uint32 `json:"click"`
78 Items []wireItem `json:"items"`
79}
80
81type wireItem struct {
82 ID int32 `json:"id"`
83 Label string `json:"label"`
84 Kind string `json:"kind"`
85 Enabled *bool `json:"enabled"`
86 Checked bool `json:"checked"`
87 Items []wireItem `json:"items"`
88}
89
90// ParseControl reads the JSON of a control line (see TakeControl) and checks
91// every limit. Control characters are removed from the texts, labels are cut
92// to MaxLabel bytes, and an item without "enabled" is enabled.
93func ParseControl(b []byte) (Control, error) {
94 if len(b) > MaxControl {
95 return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl)
96 }
97 var w wireControl
98 if err := json.Unmarshal(b, &w); err != nil {
99 return Control{}, fmt.Errorf("control line: %w", err)
100 }
101 if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn {
102 return Control{}, errors.New("control line: column or width out of range")
103 }
104 c := Control{Type: w.Type, Col: w.Col, Width: w.Width}
105 switch w.Type {
106 case ControlClose:
107 return c, nil
108 case ControlTooltip:
109 if w.Width == 0 {
110 return Control{}, errors.New("tooltip: width must be positive")
111 }
112 c.Title = clean(w.Title, MaxLabel, false)
113 c.Body = clean(w.Body, MaxText, true)
114 if len(w.Rows) > MaxRows {
115 return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116 }
117 if len(w.Rows) > 0 {
118 c.Rows = make([][]string, 0, len(w.Rows))
119 }
120 text := c.Title != "" || c.Body != ""
121 for _, row := range w.Rows {
122 if len(row) > MaxCells {
123 return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
124 }
125 cells := make([]string, len(row))
126 for i, s := range row {
127 cells[i] = clean(s, MaxLabel, false)
128 text = text || cells[i] != ""
129 }
130 c.Rows = append(c.Rows, cells)
131 }
132 if !text {
133 return Control{}, errors.New("tooltip: no text")
134 }
135 return c, nil
136 case ControlMenu:
137 if w.Width == 0 {
138 return Control{}, errors.New("menu: width must be positive")
139 }
140 c.Click = w.Click
141 count := 0
142 items, err := convertItems(w.Items, 1, &count)
143 if err != nil {
144 return Control{}, fmt.Errorf("menu: %w", err)
145 }
146 if len(items) == 0 {
147 return Control{}, errors.New("menu: no items")
148 }
149 c.Items = items
150 return c, nil
151 }
152 return Control{}, fmt.Errorf("control line: unknown type %q", w.Type)
153}
154
155func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) {
156 if len(in) == 0 {
157 return nil, nil
158 }
159 if depth > MaxMenuDepth {
160 return nil, fmt.Errorf("more than %d levels", MaxMenuDepth)
161 }
162 out := make([]MenuItem, 0, len(in))
163 for i := range in {
164 if *count++; *count > MaxMenuItems {
165 return nil, fmt.Errorf("more than %d items", MaxMenuItems)
166 }
167 w := &in[i]
168 it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked}
169 if w.ID < 0 {
170 return nil, fmt.Errorf("item %d: negative id", w.ID)
171 }
172 switch w.Kind {
173 case "":
174 it.Kind = KindNormal
175 case KindNormal, KindSeparator, KindCheck, KindRadio:
176 default:
177 return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind)
178 }
179 sub, err := convertItems(w.Items, depth+1, count)
180 if err != nil {
181 return nil, err
182 }
183 it.Items = sub
184 out = append(out, it)
185 }
186 return out, nil
187}
188
189// clean drops control characters (and anything Unicode calls a format or
190// unassigned control, such as bidi overrides), keeps newlines when keepNL, and
191// cuts the result to max bytes at a rune boundary.
192func clean(s string, max int, keepNL bool) string {
193 needs := len(s) > max
194 for _, r := range s {
195 if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
196 needs = true
197 break
198 }
199 }
200 if !needs {
201 return s
202 }
203 out := make([]byte, 0, min(len(s), max))
204 for _, r := range s {
205 switch {
206 case r == '\n' && keepNL:
207 case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r):
208 continue
209 }
210 if len(out)+utf8.RuneLen(r) > max {
211 break
212 }
213 out = utf8.AppendRune(out, r)
214 }
215 return string(out)
216}