perf(html): remove per-row allocations and reflective JSON encoding in Prepare
- commit
- b6d6950dc11e809f1119d81b0422f6754fa10325
- author
- brice · · 2026-10-02 09:20 UTC
- parent
- dca8245
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +507 -40
A html/encode.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strconv" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // appendUpdateJSON appends the canonical JSON encoding of update to dst. The | |
| 10 | + | // bytes are identical to encoding/json.Marshal(update) (including HTML | |
| 11 | + | // escaping of text), but it writes into one caller-sized buffer instead of | |
| 12 | + | // going through reflection and an intermediate copy. | |
| 13 | + | func appendUpdateJSON(dst []byte, update Update) ([]byte, error) { | |
| 14 | + | dst = append(dst, `{"schemaVersion":`...) | |
| 15 | + | dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10) | |
| 16 | + | dst = append(dst, `,"width":`...) | |
| 17 | + | dst = strconv.AppendInt(dst, int64(update.Width), 10) | |
| 18 | + | dst = append(dst, `,"height":`...) | |
| 19 | + | dst = strconv.AppendInt(dst, int64(update.Height), 10) | |
| 20 | + | dst = append(dst, `,"snapshot":`...) | |
| 21 | + | dst = strconv.AppendBool(dst, update.Snapshot) | |
| 22 | + | dst = append(dst, `,"rows":`...) | |
| 23 | + | if update.Rows == nil { | |
| 24 | + | dst = append(dst, "null"...) | |
| 25 | + | } else { | |
| 26 | + | dst = append(dst, '[') | |
| 27 | + | for i := range update.Rows { | |
| 28 | + | if i > 0 { | |
| 29 | + | dst = append(dst, ',') | |
| 30 | + | } | |
| 31 | + | dst = appendRowJSON(dst, &update.Rows[i]) | |
| 32 | + | } | |
| 33 | + | dst = append(dst, ']') | |
| 34 | + | } | |
| 35 | + | dst = append(dst, `,"styles":`...) | |
| 36 | + | if update.Styles == nil { | |
| 37 | + | dst = append(dst, "null"...) | |
| 38 | + | } else { | |
| 39 | + | dst = append(dst, '[') | |
| 40 | + | for i := range update.Styles { | |
| 41 | + | if i > 0 { | |
| 42 | + | dst = append(dst, ',') | |
| 43 | + | } | |
| 44 | + | var err error | |
| 45 | + | if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | dst = append(dst, ']') | |
| 50 | + | } | |
| 51 | + | dst = append(dst, `,"cursor":{"row":`...) | |
| 52 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10) | |
| 53 | + | dst = append(dst, `,"column":`...) | |
| 54 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10) | |
| 55 | + | dst = append(dst, `,"visible":`...) | |
| 56 | + | dst = strconv.AppendBool(dst, update.Cursor.Visible) | |
| 57 | + | dst = append(dst, `,"style":`...) | |
| 58 | + | dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10) | |
| 59 | + | dst = append(dst, `,"styleSet":`...) | |
| 60 | + | dst = strconv.AppendBool(dst, update.Cursor.StyleSet) | |
| 61 | + | dst = append(dst, "}}"...) | |
| 62 | + | return dst, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func appendRowJSON(dst []byte, row *RowUpdate) []byte { | |
| 66 | + | dst = append(dst, `{"row":`...) | |
| 67 | + | dst = strconv.AppendInt(dst, int64(row.Row), 10) | |
| 68 | + | dst = append(dst, `,"cells":`...) | |
| 69 | + | if row.Cells == nil { | |
| 70 | + | return append(dst, "null}"...) | |
| 71 | + | } | |
| 72 | + | dst = append(dst, '[') | |
| 73 | + | for i := range row.Cells { | |
| 74 | + | cell := &row.Cells[i] | |
| 75 | + | if i > 0 { | |
| 76 | + | dst = append(dst, ',') | |
| 77 | + | } | |
| 78 | + | dst = append(dst, `{"column":`...) | |
| 79 | + | dst = strconv.AppendInt(dst, int64(cell.Column), 10) | |
| 80 | + | dst = append(dst, `,"width":`...) | |
| 81 | + | dst = strconv.AppendInt(dst, int64(cell.Width), 10) | |
| 82 | + | dst = append(dst, `,"text":`...) | |
| 83 | + | dst = appendJSONString(dst, cell.Text) | |
| 84 | + | dst = append(dst, `,"style":`...) | |
| 85 | + | dst = strconv.AppendInt(dst, int64(cell.Style), 10) | |
| 86 | + | dst = append(dst, '}') | |
| 87 | + | } | |
| 88 | + | return append(dst, "]}"...) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func appendStyleJSON(dst []byte, style *Style) ([]byte, error) { | |
| 92 | + | dst = append(dst, '{') | |
| 93 | + | first := true | |
| 94 | + | flag := func(name string, set bool) { | |
| 95 | + | if !set { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | if !first { | |
| 99 | + | dst = append(dst, ',') | |
| 100 | + | } | |
| 101 | + | first = false | |
| 102 | + | dst = append(dst, '"') | |
| 103 | + | dst = append(dst, name...) | |
| 104 | + | dst = append(dst, `":true`...) | |
| 105 | + | } | |
| 106 | + | flag("bold", style.Bold) | |
| 107 | + | flag("italic", style.Italic) | |
| 108 | + | flag("inverse", style.Inverse) | |
| 109 | + | flag("dim", style.Dim) | |
| 110 | + | flag("blink", style.Blink) | |
| 111 | + | flag("strikethrough", style.Strikethrough) | |
| 112 | + | flag("underline", style.Underline) | |
| 113 | + | if style.UnderlineStyle != 0 { | |
| 114 | + | if !first { | |
| 115 | + | dst = append(dst, ',') | |
| 116 | + | } | |
| 117 | + | first = false | |
| 118 | + | dst = append(dst, `"underlineStyle":`...) | |
| 119 | + | dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10) | |
| 120 | + | } | |
| 121 | + | if !first { | |
| 122 | + | dst = append(dst, ',') | |
| 123 | + | } | |
| 124 | + | var err error | |
| 125 | + | dst = append(dst, `"foreground":`...) | |
| 126 | + | if dst, err = appendColorJSON(dst, style.Foreground); err != nil { | |
| 127 | + | return nil, err | |
| 128 | + | } | |
| 129 | + | dst = append(dst, `,"background":`...) | |
| 130 | + | if dst, err = appendColorJSON(dst, style.Background); err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | dst = append(dst, `,"underlineColor":`...) | |
| 134 | + | if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | return append(dst, '}'), nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // appendColorJSON mirrors Color.MarshalJSON. | |
| 141 | + | func appendColorJSON(dst []byte, color Color) ([]byte, error) { | |
| 142 | + | switch color.Kind { | |
| 143 | + | case ColorDefault: | |
| 144 | + | return append(dst, `{"kind":0}`...), nil | |
| 145 | + | case ColorIndexed: | |
| 146 | + | dst = append(dst, `{"kind":1,"index":`...) | |
| 147 | + | dst = strconv.AppendUint(dst, uint64(color.Index), 10) | |
| 148 | + | return append(dst, '}'), nil | |
| 149 | + | case ColorRGB: | |
| 150 | + | dst = append(dst, `{"kind":2,"rgb":{"r":`...) | |
| 151 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10) | |
| 152 | + | dst = append(dst, `,"g":`...) | |
| 153 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10) | |
| 154 | + | dst = append(dst, `,"b":`...) | |
| 155 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10) | |
| 156 | + | return append(dst, "}}"...), nil | |
| 157 | + | default: | |
| 158 | + | return nil, fmt.Errorf("html: invalid color kind %d", color.Kind) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | const hexDigits = "0123456789abcdef" | |
| 163 | + | ||
| 164 | + | // appendJSONString appends s as a JSON string using encoding/json's default | |
| 165 | + | // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029 | |
| 166 | + | // are escaped, and invalid UTF-8 becomes a literal U+FFFD (as encoding/json | |
| 167 | + | // does on this toolchain). Cell text is always valid UTF-8 here. | |
| 168 | + | func appendJSONString(dst []byte, s string) []byte { | |
| 169 | + | dst = append(dst, '"') | |
| 170 | + | start := 0 | |
| 171 | + | for i := 0; i < len(s); { | |
| 172 | + | b := s[i] | |
| 173 | + | if b < utf8.RuneSelf { | |
| 174 | + | if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' { | |
| 175 | + | i++ | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | dst = append(dst, s[start:i]...) | |
| 179 | + | switch b { | |
| 180 | + | case '"', '\\': | |
| 181 | + | dst = append(dst, '\\', b) | |
| 182 | + | case '\b': | |
| 183 | + | dst = append(dst, '\\', 'b') | |
| 184 | + | case '\f': | |
| 185 | + | dst = append(dst, '\\', 'f') | |
| 186 | + | case '\n': | |
| 187 | + | dst = append(dst, '\\', 'n') | |
| 188 | + | case '\r': | |
| 189 | + | dst = append(dst, '\\', 'r') | |
| 190 | + | case '\t': | |
| 191 | + | dst = append(dst, '\\', 't') | |
| 192 | + | default: | |
| 193 | + | dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF]) | |
| 194 | + | } | |
| 195 | + | i++ | |
| 196 | + | start = i | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 200 | + | if r == utf8.RuneError && size == 1 { | |
| 201 | + | dst = append(dst, s[start:i]...) | |
| 202 | + | dst = append(dst, "\uFFFD"...) | |
| 203 | + | i += size | |
| 204 | + | start = i | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | if r == '\u2028' || r == '\u2029' { | |
| 208 | + | dst = append(dst, s[start:i]...) | |
| 209 | + | dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF]) | |
| 210 | + | i += size | |
| 211 | + | start = i | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | i += size | |
| 215 | + | } | |
| 216 | + | dst = append(dst, s[start:]...) | |
| 217 | + | return append(dst, '"') | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // estimateUpdateJSONSize returns a capacity hint close to the encoded size so | |
| 221 | + | // the output buffer normally grows zero times. | |
| 222 | + | func estimateUpdateJSONSize(update Update) int { | |
| 223 | + | size := 256 + 160*len(update.Styles) | |
| 224 | + | for i := range update.Rows { | |
| 225 | + | size += 32 + 48*len(update.Rows[i].Cells) | |
| 226 | + | } | |
| 227 | + | return size | |
| 228 | + | } | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strconv" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // appendUpdateJSON appends the canonical JSON encoding of update to dst. The | |||
| 10 | + | // bytes are identical to encoding/json.Marshal(update) (including HTML | |||
| 11 | + | // escaping of text), but it writes into one caller-sized buffer instead of | |||
| 12 | + | // going through reflection and an intermediate copy. | |||
| 13 | + | func appendUpdateJSON(dst []byte, update Update) ([]byte, error) { | |||
| 14 | + | dst = append(dst, `{"schemaVersion":`...) | |||
| 15 | + | dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10) | |||
| 16 | + | dst = append(dst, `,"width":`...) | |||
| 17 | + | dst = strconv.AppendInt(dst, int64(update.Width), 10) | |||
| 18 | + | dst = append(dst, `,"height":`...) | |||
| 19 | + | dst = strconv.AppendInt(dst, int64(update.Height), 10) | |||
| 20 | + | dst = append(dst, `,"snapshot":`...) | |||
| 21 | + | dst = strconv.AppendBool(dst, update.Snapshot) | |||
| 22 | + | dst = append(dst, `,"rows":`...) | |||
| 23 | + | if update.Rows == nil { | |||
| 24 | + | dst = append(dst, "null"...) | |||
| 25 | + | } else { | |||
| 26 | + | dst = append(dst, '[') | |||
| 27 | + | for i := range update.Rows { | |||
| 28 | + | if i > 0 { | |||
| 29 | + | dst = append(dst, ',') | |||
| 30 | + | } | |||
| 31 | + | dst = appendRowJSON(dst, &update.Rows[i]) | |||
| 32 | + | } | |||
| 33 | + | dst = append(dst, ']') | |||
| 34 | + | } | |||
| 35 | + | dst = append(dst, `,"styles":`...) | |||
| 36 | + | if update.Styles == nil { | |||
| 37 | + | dst = append(dst, "null"...) | |||
| 38 | + | } else { | |||
| 39 | + | dst = append(dst, '[') | |||
| 40 | + | for i := range update.Styles { | |||
| 41 | + | if i > 0 { | |||
| 42 | + | dst = append(dst, ',') | |||
| 43 | + | } | |||
| 44 | + | var err error | |||
| 45 | + | if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil { | |||
| 46 | + | return nil, err | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | dst = append(dst, ']') | |||
| 50 | + | } | |||
| 51 | + | dst = append(dst, `,"cursor":{"row":`...) | |||
| 52 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10) | |||
| 53 | + | dst = append(dst, `,"column":`...) | |||
| 54 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10) | |||
| 55 | + | dst = append(dst, `,"visible":`...) | |||
| 56 | + | dst = strconv.AppendBool(dst, update.Cursor.Visible) | |||
| 57 | + | dst = append(dst, `,"style":`...) | |||
| 58 | + | dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10) | |||
| 59 | + | dst = append(dst, `,"styleSet":`...) | |||
| 60 | + | dst = strconv.AppendBool(dst, update.Cursor.StyleSet) | |||
| 61 | + | dst = append(dst, "}}"...) | |||
| 62 | + | return dst, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func appendRowJSON(dst []byte, row *RowUpdate) []byte { | |||
| 66 | + | dst = append(dst, `{"row":`...) | |||
| 67 | + | dst = strconv.AppendInt(dst, int64(row.Row), 10) | |||
| 68 | + | dst = append(dst, `,"cells":`...) | |||
| 69 | + | if row.Cells == nil { | |||
| 70 | + | return append(dst, "null}"...) | |||
| 71 | + | } | |||
| 72 | + | dst = append(dst, '[') | |||
| 73 | + | for i := range row.Cells { | |||
| 74 | + | cell := &row.Cells[i] | |||
| 75 | + | if i > 0 { | |||
| 76 | + | dst = append(dst, ',') | |||
| 77 | + | } | |||
| 78 | + | dst = append(dst, `{"column":`...) | |||
| 79 | + | dst = strconv.AppendInt(dst, int64(cell.Column), 10) | |||
| 80 | + | dst = append(dst, `,"width":`...) | |||
| 81 | + | dst = strconv.AppendInt(dst, int64(cell.Width), 10) | |||
| 82 | + | dst = append(dst, `,"text":`...) | |||
| 83 | + | dst = appendJSONString(dst, cell.Text) | |||
| 84 | + | dst = append(dst, `,"style":`...) | |||
| 85 | + | dst = strconv.AppendInt(dst, int64(cell.Style), 10) | |||
| 86 | + | dst = append(dst, '}') | |||
| 87 | + | } | |||
| 88 | + | return append(dst, "]}"...) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func appendStyleJSON(dst []byte, style *Style) ([]byte, error) { | |||
| 92 | + | dst = append(dst, '{') | |||
| 93 | + | first := true | |||
| 94 | + | flag := func(name string, set bool) { | |||
| 95 | + | if !set { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | if !first { | |||
| 99 | + | dst = append(dst, ',') | |||
| 100 | + | } | |||
| 101 | + | first = false | |||
| 102 | + | dst = append(dst, '"') | |||
| 103 | + | dst = append(dst, name...) | |||
| 104 | + | dst = append(dst, `":true`...) | |||
| 105 | + | } | |||
| 106 | + | flag("bold", style.Bold) | |||
| 107 | + | flag("italic", style.Italic) | |||
| 108 | + | flag("inverse", style.Inverse) | |||
| 109 | + | flag("dim", style.Dim) | |||
| 110 | + | flag("blink", style.Blink) | |||
| 111 | + | flag("strikethrough", style.Strikethrough) | |||
| 112 | + | flag("underline", style.Underline) | |||
| 113 | + | if style.UnderlineStyle != 0 { | |||
| 114 | + | if !first { | |||
| 115 | + | dst = append(dst, ',') | |||
| 116 | + | } | |||
| 117 | + | first = false | |||
| 118 | + | dst = append(dst, `"underlineStyle":`...) | |||
| 119 | + | dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10) | |||
| 120 | + | } | |||
| 121 | + | if !first { | |||
| 122 | + | dst = append(dst, ',') | |||
| 123 | + | } | |||
| 124 | + | var err error | |||
| 125 | + | dst = append(dst, `"foreground":`...) | |||
| 126 | + | if dst, err = appendColorJSON(dst, style.Foreground); err != nil { | |||
| 127 | + | return nil, err | |||
| 128 | + | } | |||
| 129 | + | dst = append(dst, `,"background":`...) | |||
| 130 | + | if dst, err = appendColorJSON(dst, style.Background); err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | dst = append(dst, `,"underlineColor":`...) | |||
| 134 | + | if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil { | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | return append(dst, '}'), nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // appendColorJSON mirrors Color.MarshalJSON. | |||
| 141 | + | func appendColorJSON(dst []byte, color Color) ([]byte, error) { | |||
| 142 | + | switch color.Kind { | |||
| 143 | + | case ColorDefault: | |||
| 144 | + | return append(dst, `{"kind":0}`...), nil | |||
| 145 | + | case ColorIndexed: | |||
| 146 | + | dst = append(dst, `{"kind":1,"index":`...) | |||
| 147 | + | dst = strconv.AppendUint(dst, uint64(color.Index), 10) | |||
| 148 | + | return append(dst, '}'), nil | |||
| 149 | + | case ColorRGB: | |||
| 150 | + | dst = append(dst, `{"kind":2,"rgb":{"r":`...) | |||
| 151 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10) | |||
| 152 | + | dst = append(dst, `,"g":`...) | |||
| 153 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10) | |||
| 154 | + | dst = append(dst, `,"b":`...) | |||
| 155 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10) | |||
| 156 | + | return append(dst, "}}"...), nil | |||
| 157 | + | default: | |||
| 158 | + | return nil, fmt.Errorf("html: invalid color kind %d", color.Kind) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | const hexDigits = "0123456789abcdef" | |||
| 163 | + | ||||
| 164 | + | // appendJSONString appends s as a JSON string using encoding/json's default | |||
| 165 | + | // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029 | |||
| 166 | + | // are escaped, and invalid UTF-8 becomes a literal U+FFFD (as encoding/json | |||
| 167 | + | // does on this toolchain). Cell text is always valid UTF-8 here. | |||
| 168 | + | func appendJSONString(dst []byte, s string) []byte { | |||
| 169 | + | dst = append(dst, '"') | |||
| 170 | + | start := 0 | |||
| 171 | + | for i := 0; i < len(s); { | |||
| 172 | + | b := s[i] | |||
| 173 | + | if b < utf8.RuneSelf { | |||
| 174 | + | if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' { | |||
| 175 | + | i++ | |||
| 176 | + | continue | |||
| 177 | + | } | |||
| 178 | + | dst = append(dst, s[start:i]...) | |||
| 179 | + | switch b { | |||
| 180 | + | case '"', '\\': | |||
| 181 | + | dst = append(dst, '\\', b) | |||
| 182 | + | case '\b': | |||
| 183 | + | dst = append(dst, '\\', 'b') | |||
| 184 | + | case '\f': | |||
| 185 | + | dst = append(dst, '\\', 'f') | |||
| 186 | + | case '\n': | |||
| 187 | + | dst = append(dst, '\\', 'n') | |||
| 188 | + | case '\r': | |||
| 189 | + | dst = append(dst, '\\', 'r') | |||
| 190 | + | case '\t': | |||
| 191 | + | dst = append(dst, '\\', 't') | |||
| 192 | + | default: | |||
| 193 | + | dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF]) | |||
| 194 | + | } | |||
| 195 | + | i++ | |||
| 196 | + | start = i | |||
| 197 | + | continue | |||
| 198 | + | } | |||
| 199 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 200 | + | if r == utf8.RuneError && size == 1 { | |||
| 201 | + | dst = append(dst, s[start:i]...) | |||
| 202 | + | dst = append(dst, "\uFFFD"...) | |||
| 203 | + | i += size | |||
| 204 | + | start = i | |||
| 205 | + | continue | |||
| 206 | + | } | |||
| 207 | + | if r == '\u2028' || r == '\u2029' { | |||
| 208 | + | dst = append(dst, s[start:i]...) | |||
| 209 | + | dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF]) | |||
| 210 | + | i += size | |||
| 211 | + | start = i | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | i += size | |||
| 215 | + | } | |||
| 216 | + | dst = append(dst, s[start:]...) | |||
| 217 | + | return append(dst, '"') | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // estimateUpdateJSONSize returns a capacity hint close to the encoded size so | |||
| 221 | + | // the output buffer normally grows zero times. | |||
| 222 | + | func estimateUpdateJSONSize(update Update) int { | |||
| 223 | + | size := 256 + 160*len(update.Styles) | |||
| 224 | + | for i := range update.Rows { | |||
| 225 | + | size += 32 + 48*len(update.Rows[i].Cells) | |||
| 226 | + | } | |||
| 227 | + | return size | |||
| 228 | + | } | |||
A html/encode_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "math/rand" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev-vt/core" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) { | |
| 13 | + | cases := []string{ | |
| 14 | + | "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t", | |
| 15 | + | "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f", | |
| 16 | + | } | |
| 17 | + | for r := rune(0); r < 0x300; r++ { | |
| 18 | + | cases = append(cases, string(r)) | |
| 19 | + | } | |
| 20 | + | for _, text := range cases { | |
| 21 | + | want, err := json.Marshal(text) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Invalid UTF-8 never reaches the encoder (cells are validated), and the | |
| 28 | + | // escaped form of U+FFFD differs between encoding/json implementations, so | |
| 29 | + | // compare decoded values only. | |
| 30 | + | func TestAppendJSONStringInvalidUTF8DecodesLikeEncodingJSON(t *testing.T) { | |
| 31 | + | for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80"} { | |
| 32 | + | want, err := json.Marshal(text) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | var wantDecoded, gotDecoded string | |
| 35 | + | require.NoError(t, json.Unmarshal(want, &wantDecoded)) | |
| 36 | + | require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotDecoded)) | |
| 37 | + | require.Equal(t, wantDecoded, gotDecoded) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestAsciiTextIndexesEveryByte(t *testing.T) { | |
| 42 | + | require.Len(t, asciiText, 128) | |
| 43 | + | for i := 0; i < 128; i++ { | |
| 44 | + | require.Equal(t, byte(i), asciiText[i]) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func randomColor(rng *rand.Rand) Color { | |
| 49 | + | switch rng.Intn(3) { | |
| 50 | + | case 0: | |
| 51 | + | return Color{Kind: ColorDefault} | |
| 52 | + | case 1: | |
| 53 | + | return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))} | |
| 54 | + | } | |
| 55 | + | return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) { | |
| 59 | + | rng := rand.New(rand.NewSource(1)) | |
| 60 | + | texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"} | |
| 61 | + | for range 300 { | |
| 62 | + | update := Update{ | |
| 63 | + | SchemaVersion: UpdateSchemaVersion, | |
| 64 | + | Width: rng.Intn(500), | |
| 65 | + | Height: rng.Intn(500), | |
| 66 | + | Snapshot: rng.Intn(2) == 0, | |
| 67 | + | Cursor: Cursor{ | |
| 68 | + | Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0, | |
| 69 | + | Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0, | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | if rng.Intn(5) != 0 { | |
| 73 | + | update.Rows = make([]RowUpdate, rng.Intn(4)) | |
| 74 | + | for i := range update.Rows { | |
| 75 | + | update.Rows[i].Row = rng.Intn(1000) | |
| 76 | + | if rng.Intn(5) != 0 { | |
| 77 | + | update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5)) | |
| 78 | + | for j := range update.Rows[i].Cells { | |
| 79 | + | update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)} | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if rng.Intn(5) != 0 { | |
| 85 | + | update.Styles = make([]Style, rng.Intn(4)) | |
| 86 | + | for i := range update.Styles { | |
| 87 | + | update.Styles[i] = Style{ | |
| 88 | + | Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0, | |
| 89 | + | Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0, | |
| 90 | + | Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)), | |
| 91 | + | Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng), | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | want, err := json.Marshal(update) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | got, err := appendUpdateJSON(nil, update) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.Equal(t, string(want), string(got)) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) { | |
| 104 | + | update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}} | |
| 105 | + | _, err := appendUpdateJSON(nil, update) | |
| 106 | + | require.ErrorContains(t, err, "invalid color kind") | |
| 107 | + | _, err = json.Marshal(update) | |
| 108 | + | require.Error(t, err) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) { | |
| 112 | + | frame := core.NewFrame(6, 2) | |
| 113 | + | style := core.DefaultStyle() | |
| 114 | + | style.Bold = true | |
| 115 | + | style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3} | |
| 116 | + | style.Background = 200 | |
| 117 | + | frame.Set(0, 0, core.Cell{Rune: '<', Style: style}) | |
| 118 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |
| 119 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 120 | + | frame.Set(4, 1, core.Cell{Rune: '"', Style: style}) | |
| 121 | + | frame.Set(5, 1, core.Cell{Rune: '😀', Style: core.DefaultStyle()}) | |
| 122 | + | // width-2 rune at the last column is invalid; keep it narrow. | |
| 123 | + | frame.Set(5, 1, core.Cell{Rune: '&', Style: core.DefaultStyle()}) | |
| 124 | + | ||
| 125 | + | renderer, err := New(Options{}) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true}) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | want, err := json.Marshal(prepared.tx.update) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | require.Equal(t, string(want), string(prepared.JSON())) | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "math/rand" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev-vt/core" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) { | |||
| 13 | + | cases := []string{ | |||
| 14 | + | "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t", | |||
| 15 | + | "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f", | |||
| 16 | + | } | |||
| 17 | + | for r := rune(0); r < 0x300; r++ { | |||
| 18 | + | cases = append(cases, string(r)) | |||
| 19 | + | } | |||
| 20 | + | for _, text := range cases { | |||
| 21 | + | want, err := json.Marshal(text) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Invalid UTF-8 never reaches the encoder (cells are validated), and the | |||
| 28 | + | // escaped form of U+FFFD differs between encoding/json implementations, so | |||
| 29 | + | // compare decoded values only. | |||
| 30 | + | func TestAppendJSONStringInvalidUTF8DecodesLikeEncodingJSON(t *testing.T) { | |||
| 31 | + | for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80"} { | |||
| 32 | + | want, err := json.Marshal(text) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | var wantDecoded, gotDecoded string | |||
| 35 | + | require.NoError(t, json.Unmarshal(want, &wantDecoded)) | |||
| 36 | + | require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotDecoded)) | |||
| 37 | + | require.Equal(t, wantDecoded, gotDecoded) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestAsciiTextIndexesEveryByte(t *testing.T) { | |||
| 42 | + | require.Len(t, asciiText, 128) | |||
| 43 | + | for i := 0; i < 128; i++ { | |||
| 44 | + | require.Equal(t, byte(i), asciiText[i]) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func randomColor(rng *rand.Rand) Color { | |||
| 49 | + | switch rng.Intn(3) { | |||
| 50 | + | case 0: | |||
| 51 | + | return Color{Kind: ColorDefault} | |||
| 52 | + | case 1: | |||
| 53 | + | return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))} | |||
| 54 | + | } | |||
| 55 | + | return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}} | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) { | |||
| 59 | + | rng := rand.New(rand.NewSource(1)) | |||
| 60 | + | texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"} | |||
| 61 | + | for range 300 { | |||
| 62 | + | update := Update{ | |||
| 63 | + | SchemaVersion: UpdateSchemaVersion, | |||
| 64 | + | Width: rng.Intn(500), | |||
| 65 | + | Height: rng.Intn(500), | |||
| 66 | + | Snapshot: rng.Intn(2) == 0, | |||
| 67 | + | Cursor: Cursor{ | |||
| 68 | + | Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0, | |||
| 69 | + | Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0, | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | if rng.Intn(5) != 0 { | |||
| 73 | + | update.Rows = make([]RowUpdate, rng.Intn(4)) | |||
| 74 | + | for i := range update.Rows { | |||
| 75 | + | update.Rows[i].Row = rng.Intn(1000) | |||
| 76 | + | if rng.Intn(5) != 0 { | |||
| 77 | + | update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5)) | |||
| 78 | + | for j := range update.Rows[i].Cells { | |||
| 79 | + | update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)} | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if rng.Intn(5) != 0 { | |||
| 85 | + | update.Styles = make([]Style, rng.Intn(4)) | |||
| 86 | + | for i := range update.Styles { | |||
| 87 | + | update.Styles[i] = Style{ | |||
| 88 | + | Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0, | |||
| 89 | + | Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0, | |||
| 90 | + | Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)), | |||
| 91 | + | Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng), | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | want, err := json.Marshal(update) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | got, err := appendUpdateJSON(nil, update) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.Equal(t, string(want), string(got)) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) { | |||
| 104 | + | update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}} | |||
| 105 | + | _, err := appendUpdateJSON(nil, update) | |||
| 106 | + | require.ErrorContains(t, err, "invalid color kind") | |||
| 107 | + | _, err = json.Marshal(update) | |||
| 108 | + | require.Error(t, err) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) { | |||
| 112 | + | frame := core.NewFrame(6, 2) | |||
| 113 | + | style := core.DefaultStyle() | |||
| 114 | + | style.Bold = true | |||
| 115 | + | style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3} | |||
| 116 | + | style.Background = 200 | |||
| 117 | + | frame.Set(0, 0, core.Cell{Rune: '<', Style: style}) | |||
| 118 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |||
| 119 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 120 | + | frame.Set(4, 1, core.Cell{Rune: '"', Style: style}) | |||
| 121 | + | frame.Set(5, 1, core.Cell{Rune: '😀', Style: core.DefaultStyle()}) | |||
| 122 | + | // width-2 rune at the last column is invalid; keep it narrow. | |||
| 123 | + | frame.Set(5, 1, core.Cell{Rune: '&', Style: core.DefaultStyle()}) | |||
| 124 | + | ||||
| 125 | + | renderer, err := New(Options{}) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true}) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | want, err := json.Marshal(prepared.tx.update) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | require.Equal(t, string(want), string(prepared.JSON())) | |||
| 132 | + | } | |||
M html/renderer.go +66 -40
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package html | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "math" | |
| 7 | 6 | "unicode/utf8" | |
| @@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 65 | 64 | if err != nil { | |
| 66 | 65 | return nil, err | |
| 67 | 66 | } | |
| 68 | - | if err := r.validateFrame(frame); err != nil { | |
| 67 | + | scratch, err := r.validateFrame(frame) | |
| 68 | + | if err != nil { | |
| 69 | 69 | return nil, err | |
| 70 | 70 | } | |
| 71 | 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | |
| @@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | |
| 77 | - | candidate := frame.Clone() | |
| 78 | - | update, err := r.buildUpdate(candidate, snapshot, cursor) | |
| 77 | + | update, err := r.buildUpdate(frame, scratch, snapshot, cursor) | |
| 79 | 78 | if err != nil { | |
| 80 | 79 | return nil, err | |
| 81 | 80 | } | |
| @@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 90 | 89 | state: transactionPending, | |
| 91 | 90 | update: update, | |
| 92 | 91 | json: encoded, | |
| 93 | - | frame: candidate, | |
| 92 | + | frame: frame, | |
| 94 | 93 | cursor: cursor, | |
| 95 | 94 | } | |
| 96 | 95 | r.pending = tx | |
| 97 | 96 | return &PreparedDraw{tx: tx}, nil | |
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | - | func (r *Renderer) validateFrame(frame core.Frame) error { | |
| 99 | + | // validateFrame checks limits and cell invariants. It returns a one-row | |
| 100 | + | // scratch buffer that the caller may reuse for the rest of this Prepare. | |
| 101 | + | func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) { | |
| 101 | 102 | if frame.Width <= 0 || frame.Height <= 0 { | |
| 102 | - | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |
| 103 | + | return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |
| 103 | 104 | } | |
| 104 | 105 | if frame.Width > math.MaxInt/frame.Height { | |
| 105 | - | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |
| 106 | + | return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |
| 106 | 107 | } | |
| 107 | 108 | cells := frame.Width * frame.Height | |
| 108 | 109 | if cells > r.limits.MaxCells { | |
| 109 | - | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |
| 110 | + | return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |
| 110 | 111 | } | |
| 111 | 112 | if frame.Height > r.limits.MaxRowsPerUpdate { | |
| 112 | - | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |
| 113 | + | return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |
| 113 | 114 | } | |
| 114 | 115 | if err := frame.Validate(); err != nil { | |
| 115 | - | return fmt.Errorf("html: validate frame: %w", err) | |
| 116 | + | return nil, fmt.Errorf("html: validate frame: %w", err) | |
| 116 | 117 | } | |
| 118 | + | scratch := make([]core.Cell, frame.Width) | |
| 117 | 119 | for y := range frame.Height { | |
| 118 | - | row := frame.Row(y) | |
| 120 | + | row := readRow(frame, y, scratch) | |
| 119 | 121 | for x := 0; x < frame.Width; x++ { | |
| 120 | 122 | cell := row[x] | |
| 121 | 123 | if err := validateCoreStyle(cell.Style); err != nil { | |
| 122 | - | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |
| 124 | + | return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |
| 123 | 125 | } | |
| 124 | 126 | if cell.Continuation { | |
| 125 | 127 | if cell.Rune != 0 { | |
| 126 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |
| 128 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |
| 127 | 129 | } | |
| 128 | 130 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | |
| 129 | - | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |
| 131 | + | return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |
| 130 | 132 | } | |
| 131 | 133 | if !cell.Style.Equal(row[x-1].Style) { | |
| 132 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |
| 134 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |
| 133 | 135 | } | |
| 134 | 136 | continue | |
| 135 | 137 | } | |
| @@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error { | |||
| 137 | 139 | continue | |
| 138 | 140 | } | |
| 139 | 141 | if !utf8.ValidRune(cell.Rune) { | |
| 140 | - | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |
| 142 | + | return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |
| 141 | 143 | } | |
| 142 | 144 | width := core.RuneWidth(cell.Rune) | |
| 143 | 145 | switch width { | |
| 144 | 146 | case 1: | |
| 145 | 147 | case 2: | |
| 146 | 148 | if x+1 >= frame.Width || !row[x+1].Continuation { | |
| 147 | - | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |
| 149 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |
| 148 | 150 | } | |
| 149 | 151 | case 0: | |
| 150 | - | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |
| 152 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |
| 151 | 153 | default: | |
| 152 | - | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |
| 154 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |
| 153 | 155 | } | |
| 154 | 156 | } | |
| 155 | 157 | } | |
| 156 | - | return nil | |
| 158 | + | return scratch, nil | |
| 157 | 159 | } | |
| 158 | 160 | ||
| 159 | 161 | // materializeCellSource reads a source into an owned frame. A core.Frame | |
| @@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |||
| 229 | 231 | return false | |
| 230 | 232 | } | |
| 231 | 233 | ||
| 232 | - | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | |
| 234 | + | // readRow copies logical row y into buf and returns the filled prefix. It is | |
| 235 | + | // the allocation-free equivalent of core.Frame.Row. | |
| 236 | + | func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell { | |
| 237 | + | row := buf[:frame.Width] | |
| 238 | + | for x := range row { | |
| 239 | + | row[x] = frame.Cell(x, y) | |
| 240 | + | } | |
| 241 | + | return row | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // rowEqualsFrame reports whether row matches logical row y of frame, stopping | |
| 245 | + | // at the first difference without materializing the frame row. | |
| 246 | + | func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool { | |
| 247 | + | if len(row) != frame.Width { | |
| 248 | + | return false | |
| 249 | + | } | |
| 250 | + | for x := range row { | |
| 251 | + | if !row[x].Equal(frame.Cell(x, y)) { | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | return true | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) { | |
| 233 | 259 | update := Update{ | |
| 234 | 260 | SchemaVersion: UpdateSchemaVersion, | |
| 235 | 261 | Width: frame.Width, | |
| @@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) ( | |||
| 241 | 267 | } | |
| 242 | 268 | styleIDs := make(map[Style]int) | |
| 243 | 269 | for y := range frame.Height { | |
| 244 | - | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | |
| 270 | + | row := readRow(frame, y, scratch) | |
| 271 | + | if !snapshot && rowEqualsFrame(row, r.committed, y) { | |
| 245 | 272 | continue | |
| 246 | 273 | } | |
| 247 | 274 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | |
| 248 | 275 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | |
| 249 | 276 | } | |
| 250 | - | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | |
| 277 | + | encoded, err := encodeRow(y, row, &update.Styles, styleIDs, r.limits.MaxStyles) | |
| 251 | 278 | if err != nil { | |
| 252 | 279 | return Update{}, err | |
| 253 | 280 | } | |
| 254 | - | update.Rows = append(update.Rows, row) | |
| 281 | + | update.Rows = append(update.Rows, encoded) | |
| 255 | 282 | } | |
| 256 | 283 | return update, nil | |
| 257 | 284 | } | |
| 258 | 285 | ||
| 259 | - | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | |
| 286 | + | // encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on | |
| 260 | 287 | // the exact encoded size. The previous conservative estimate could reject | |
| 261 | 288 | // compact updates well below the configured limit. | |
| 262 | 289 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |
| 263 | - | encoded, err := json.Marshal(update) | |
| 290 | + | encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update) | |
| 264 | 291 | if err != nil { | |
| 265 | 292 | return nil, fmt.Errorf("html: encode update: %w", err) | |
| 266 | 293 | } | |
| @@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |||
| 270 | 297 | return encoded, nil | |
| 271 | 298 | } | |
| 272 | 299 | ||
| 273 | - | func rowsEqual(left, right []core.Cell) bool { | |
| 274 | - | if len(left) != len(right) { | |
| 275 | - | return false | |
| 276 | - | } | |
| 277 | - | for i := range left { | |
| 278 | - | if !left[i].Equal(right[i]) { | |
| 279 | - | return false | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | return true | |
| 283 | - | } | |
| 300 | + | // asciiText holds every ASCII byte so single-byte cell text can be sliced out | |
| 301 | + | // of it instead of allocating a new string per cell. | |
| 302 | + | const asciiText = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" + | |
| 303 | + | "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + | |
| 304 | + | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f" | |
| 284 | 305 | ||
| 285 | 306 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | |
| 286 | 307 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | |
| @@ -300,10 +321,15 @@ func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int | |||
| 300 | 321 | *styles = append(*styles, style) | |
| 301 | 322 | } | |
| 302 | 323 | width := core.RuneWidth(cell.Rune) | |
| 303 | - | text := string(cell.Rune) | |
| 304 | - | if cell.Rune == 0 { | |
| 324 | + | var text string | |
| 325 | + | switch { | |
| 326 | + | case cell.Rune == 0: | |
| 305 | 327 | width = 1 | |
| 306 | 328 | text = " " | |
| 329 | + | case cell.Rune < utf8.RuneSelf: | |
| 330 | + | text = asciiText[cell.Rune : cell.Rune+1] // no allocation | |
| 331 | + | default: | |
| 332 | + | text = string(cell.Rune) | |
| 307 | 333 | } | |
| 308 | 334 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | |
| 309 | 335 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package html | 1 | package html | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/json" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | "math" | 5 | "math" | ||
| 7 | "unicode/utf8" | 6 | "unicode/utf8" | ||
| @@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 65 | if err != nil { | 64 | if err != nil { | ||
| 66 | return nil, err | 65 | return nil, err | ||
| 67 | } | 66 | } | ||
| 68 | - | if err := r.validateFrame(frame); err != nil { | 67 | + | scratch, err := r.validateFrame(frame) |
| 68 | + | if err != nil { | |||
| 69 | return nil, err | 69 | return nil, err | ||
| 70 | } | 70 | } | ||
| 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | ||
| @@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | ||
| 77 | - | candidate := frame.Clone() | 77 | + | update, err := r.buildUpdate(frame, scratch, snapshot, cursor) |
| 78 | - | update, err := r.buildUpdate(candidate, snapshot, cursor) | |||
| 79 | if err != nil { | 78 | if err != nil { | ||
| 80 | return nil, err | 79 | return nil, err | ||
| 81 | } | 80 | } | ||
| @@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 90 | state: transactionPending, | 89 | state: transactionPending, | ||
| 91 | update: update, | 90 | update: update, | ||
| 92 | json: encoded, | 91 | json: encoded, | ||
| 93 | - | frame: candidate, | 92 | + | frame: frame, |
| 94 | cursor: cursor, | 93 | cursor: cursor, | ||
| 95 | } | 94 | } | ||
| 96 | r.pending = tx | 95 | r.pending = tx | ||
| 97 | return &PreparedDraw{tx: tx}, nil | 96 | return &PreparedDraw{tx: tx}, nil | ||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| 100 | - | func (r *Renderer) validateFrame(frame core.Frame) error { | 99 | + | // validateFrame checks limits and cell invariants. It returns a one-row |
| 100 | + | // scratch buffer that the caller may reuse for the rest of this Prepare. | |||
| 101 | + | func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) { | |||
| 101 | if frame.Width <= 0 || frame.Height <= 0 { | 102 | if frame.Width <= 0 || frame.Height <= 0 { | ||
| 102 | - | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | 103 | + | return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) |
| 103 | } | 104 | } | ||
| 104 | if frame.Width > math.MaxInt/frame.Height { | 105 | if frame.Width > math.MaxInt/frame.Height { | ||
| 105 | - | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | 106 | + | return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) |
| 106 | } | 107 | } | ||
| 107 | cells := frame.Width * frame.Height | 108 | cells := frame.Width * frame.Height | ||
| 108 | if cells > r.limits.MaxCells { | 109 | if cells > r.limits.MaxCells { | ||
| 109 | - | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | 110 | + | return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) |
| 110 | } | 111 | } | ||
| 111 | if frame.Height > r.limits.MaxRowsPerUpdate { | 112 | if frame.Height > r.limits.MaxRowsPerUpdate { | ||
| 112 | - | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | 113 | + | return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) |
| 113 | } | 114 | } | ||
| 114 | if err := frame.Validate(); err != nil { | 115 | if err := frame.Validate(); err != nil { | ||
| 115 | - | return fmt.Errorf("html: validate frame: %w", err) | 116 | + | return nil, fmt.Errorf("html: validate frame: %w", err) |
| 116 | } | 117 | } | ||
| 118 | + | scratch := make([]core.Cell, frame.Width) | |||
| 117 | for y := range frame.Height { | 119 | for y := range frame.Height { | ||
| 118 | - | row := frame.Row(y) | 120 | + | row := readRow(frame, y, scratch) |
| 119 | for x := 0; x < frame.Width; x++ { | 121 | for x := 0; x < frame.Width; x++ { | ||
| 120 | cell := row[x] | 122 | cell := row[x] | ||
| 121 | if err := validateCoreStyle(cell.Style); err != nil { | 123 | if err := validateCoreStyle(cell.Style); err != nil { | ||
| 122 | - | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | 124 | + | return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err) |
| 123 | } | 125 | } | ||
| 124 | if cell.Continuation { | 126 | if cell.Continuation { | ||
| 125 | if cell.Rune != 0 { | 127 | if cell.Rune != 0 { | ||
| 126 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | 128 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) |
| 127 | } | 129 | } | ||
| 128 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | 130 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | ||
| 129 | - | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | 131 | + | return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) |
| 130 | } | 132 | } | ||
| 131 | if !cell.Style.Equal(row[x-1].Style) { | 133 | if !cell.Style.Equal(row[x-1].Style) { | ||
| 132 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | 134 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) |
| 133 | } | 135 | } | ||
| 134 | continue | 136 | continue | ||
| 135 | } | 137 | } | ||
| @@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error { | |||||
| 137 | continue | 139 | continue | ||
| 138 | } | 140 | } | ||
| 139 | if !utf8.ValidRune(cell.Rune) { | 141 | if !utf8.ValidRune(cell.Rune) { | ||
| 140 | - | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | 142 | + | return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) |
| 141 | } | 143 | } | ||
| 142 | width := core.RuneWidth(cell.Rune) | 144 | width := core.RuneWidth(cell.Rune) | ||
| 143 | switch width { | 145 | switch width { | ||
| 144 | case 1: | 146 | case 1: | ||
| 145 | case 2: | 147 | case 2: | ||
| 146 | if x+1 >= frame.Width || !row[x+1].Continuation { | 148 | if x+1 >= frame.Width || !row[x+1].Continuation { | ||
| 147 | - | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | 149 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) |
| 148 | } | 150 | } | ||
| 149 | case 0: | 151 | case 0: | ||
| 150 | - | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | 152 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) |
| 151 | default: | 153 | default: | ||
| 152 | - | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | 154 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) |
| 153 | } | 155 | } | ||
| 154 | } | 156 | } | ||
| 155 | } | 157 | } | ||
| 156 | - | return nil | 158 | + | return scratch, nil |
| 157 | } | 159 | } | ||
| 158 | 160 | ||||
| 159 | // materializeCellSource reads a source into an owned frame. A core.Frame | 161 | // materializeCellSource reads a source into an owned frame. A core.Frame | ||
| @@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |||||
| 229 | return false | 231 | return false | ||
| 230 | } | 232 | } | ||
| 231 | 233 | ||||
| 232 | - | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | 234 | + | // readRow copies logical row y into buf and returns the filled prefix. It is |
| 235 | + | // the allocation-free equivalent of core.Frame.Row. | |||
| 236 | + | func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell { | |||
| 237 | + | row := buf[:frame.Width] | |||
| 238 | + | for x := range row { | |||
| 239 | + | row[x] = frame.Cell(x, y) | |||
| 240 | + | } | |||
| 241 | + | return row | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // rowEqualsFrame reports whether row matches logical row y of frame, stopping | |||
| 245 | + | // at the first difference without materializing the frame row. | |||
| 246 | + | func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool { | |||
| 247 | + | if len(row) != frame.Width { | |||
| 248 | + | return false | |||
| 249 | + | } | |||
| 250 | + | for x := range row { | |||
| 251 | + | if !row[x].Equal(frame.Cell(x, y)) { | |||
| 252 | + | return false | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | return true | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) { | |||
| 233 | update := Update{ | 259 | update := Update{ | ||
| 234 | SchemaVersion: UpdateSchemaVersion, | 260 | SchemaVersion: UpdateSchemaVersion, | ||
| 235 | Width: frame.Width, | 261 | Width: frame.Width, | ||
| @@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) ( | |||||
| 241 | } | 267 | } | ||
| 242 | styleIDs := make(map[Style]int) | 268 | styleIDs := make(map[Style]int) | ||
| 243 | for y := range frame.Height { | 269 | for y := range frame.Height { | ||
| 244 | - | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | 270 | + | row := readRow(frame, y, scratch) |
| 271 | + | if !snapshot && rowEqualsFrame(row, r.committed, y) { | |||
| 245 | continue | 272 | continue | ||
| 246 | } | 273 | } | ||
| 247 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | 274 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | ||
| 248 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | 275 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | ||
| 249 | } | 276 | } | ||
| 250 | - | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | 277 | + | encoded, err := encodeRow(y, row, &update.Styles, styleIDs, r.limits.MaxStyles) |
| 251 | if err != nil { | 278 | if err != nil { | ||
| 252 | return Update{}, err | 279 | return Update{}, err | ||
| 253 | } | 280 | } | ||
| 254 | - | update.Rows = append(update.Rows, row) | 281 | + | update.Rows = append(update.Rows, encoded) |
| 255 | } | 282 | } | ||
| 256 | return update, nil | 283 | return update, nil | ||
| 257 | } | 284 | } | ||
| 258 | 285 | ||||
| 259 | - | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | 286 | + | // encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on |
| 260 | // the exact encoded size. The previous conservative estimate could reject | 287 | // the exact encoded size. The previous conservative estimate could reject | ||
| 261 | // compact updates well below the configured limit. | 288 | // compact updates well below the configured limit. | ||
| 262 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | 289 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | ||
| 263 | - | encoded, err := json.Marshal(update) | 290 | + | encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update) |
| 264 | if err != nil { | 291 | if err != nil { | ||
| 265 | return nil, fmt.Errorf("html: encode update: %w", err) | 292 | return nil, fmt.Errorf("html: encode update: %w", err) | ||
| 266 | } | 293 | } | ||
| @@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |||||
| 270 | return encoded, nil | 297 | return encoded, nil | ||
| 271 | } | 298 | } | ||
| 272 | 299 | ||||
| 273 | - | func rowsEqual(left, right []core.Cell) bool { | 300 | + | // asciiText holds every ASCII byte so single-byte cell text can be sliced out |
| 274 | - | if len(left) != len(right) { | 301 | + | // of it instead of allocating a new string per cell. |
| 275 | - | return false | 302 | + | const asciiText = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" + |
| 276 | - | } | 303 | + | "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + |
| 277 | - | for i := range left { | 304 | + | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f" |
| 278 | - | if !left[i].Equal(right[i]) { | |||
| 279 | - | return false | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | return true | |||
| 283 | - | } | |||
| 284 | 305 | ||||
| 285 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | 306 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | ||
| 286 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | 307 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | ||
| @@ -300,10 +321,15 @@ func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int | |||||
| 300 | *styles = append(*styles, style) | 321 | *styles = append(*styles, style) | ||
| 301 | } | 322 | } | ||
| 302 | width := core.RuneWidth(cell.Rune) | 323 | width := core.RuneWidth(cell.Rune) | ||
| 303 | - | text := string(cell.Rune) | 324 | + | var text string |
| 304 | - | if cell.Rune == 0 { | 325 | + | switch { |
| 326 | + | case cell.Rune == 0: | |||
| 305 | width = 1 | 327 | width = 1 | ||
| 306 | text = " " | 328 | text = " " | ||
| 329 | + | case cell.Rune < utf8.RuneSelf: | |||
| 330 | + | text = asciiText[cell.Rune : cell.Rune+1] // no allocation | |||
| 331 | + | default: | |||
| 332 | + | text = string(cell.Rune) | |||
| 307 | } | 333 | } | ||
| 308 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | 334 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | ||
| 309 | } | 335 | } | ||
M html/renderer_test.go +81 -0
| @@ -228,3 +228,84 @@ func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |||
| 228 | 228 | require.True(t, next.Update().Snapshot) | |
| 229 | 229 | require.NoError(t, next.Commit()) | |
| 230 | 230 | } | |
| 231 | + | ||
| 232 | + | func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) { | |
| 233 | + | frame := core.NewFrame(4, 3) | |
| 234 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |
| 235 | + | renderer, err := New(Options{}) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | ||
| 238 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | firstUpdate, firstJSON := first.Update(), first.JSON() | |
| 241 | + | require.NoError(t, first.Commit()) | |
| 242 | + | ||
| 243 | + | // The caller mutates its frame in place; neither the earlier draw nor the | |
| 244 | + | // committed shadow may observe it. | |
| 245 | + | frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()}) | |
| 246 | + | second, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | require.Equal(t, []int{1}, rowIndexes(second.Update())) | |
| 249 | + | secondUpdate, secondJSON := second.Update(), second.JSON() | |
| 250 | + | require.NoError(t, second.Commit()) | |
| 251 | + | ||
| 252 | + | frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()}) | |
| 253 | + | third, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | require.Equal(t, []int{2}, rowIndexes(third.Update())) | |
| 256 | + | require.NoError(t, third.Commit()) | |
| 257 | + | ||
| 258 | + | require.Equal(t, firstUpdate, first.Update()) | |
| 259 | + | require.Equal(t, firstJSON, first.JSON()) | |
| 260 | + | require.Equal(t, secondUpdate, second.Update()) | |
| 261 | + | require.Equal(t, secondJSON, second.JSON()) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |
| 265 | + | frame := core.NewFrame(3, 2) | |
| 266 | + | renderer, err := New(Options{}) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | require.NoError(t, first.Commit()) | |
| 271 | + | ||
| 272 | + | frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |
| 273 | + | aborted, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | abortedJSON := aborted.JSON() | |
| 276 | + | require.NoError(t, aborted.Abort()) | |
| 277 | + | ||
| 278 | + | retry, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | require.False(t, retry.Update().Snapshot) | |
| 281 | + | require.Equal(t, []int{1}, rowIndexes(retry.Update())) | |
| 282 | + | require.Equal(t, abortedJSON, retry.JSON()) | |
| 283 | + | require.NoError(t, retry.Commit()) | |
| 284 | + | ||
| 285 | + | steady, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | require.Empty(t, steady.Update().Rows) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestPrepareDoesNotAliasCallerFrame(t *testing.T) { | |
| 291 | + | frame := core.NewFrame(2, 1) | |
| 292 | + | renderer, err := New(Options{}) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | require.NoError(t, prepared.Commit()) | |
| 297 | + | ||
| 298 | + | // Mutating the caller frame after Commit must be seen as a change. | |
| 299 | + | frame.Set(0, 0, core.Cell{Rune: 'Q', Style: core.DefaultStyle()}) | |
| 300 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | require.Equal(t, []int{0}, rowIndexes(next.Update())) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func rowIndexes(update Update) []int { | |
| 306 | + | rows := make([]int, 0, len(update.Rows)) | |
| 307 | + | for _, row := range update.Rows { | |
| 308 | + | rows = append(rows, row.Row) | |
| 309 | + | } | |
| 310 | + | return rows | |
| 311 | + | } | |
| @@ -228,3 +228,84 @@ func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |||||
| 228 | require.True(t, next.Update().Snapshot) | 228 | require.True(t, next.Update().Snapshot) | ||
| 229 | require.NoError(t, next.Commit()) | 229 | require.NoError(t, next.Commit()) | ||
| 230 | } | 230 | } | ||
| 231 | + | ||||
| 232 | + | func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) { | |||
| 233 | + | frame := core.NewFrame(4, 3) | |||
| 234 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |||
| 235 | + | renderer, err := New(Options{}) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | ||||
| 238 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | firstUpdate, firstJSON := first.Update(), first.JSON() | |||
| 241 | + | require.NoError(t, first.Commit()) | |||
| 242 | + | ||||
| 243 | + | // The caller mutates its frame in place; neither the earlier draw nor the | |||
| 244 | + | // committed shadow may observe it. | |||
| 245 | + | frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()}) | |||
| 246 | + | second, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | require.Equal(t, []int{1}, rowIndexes(second.Update())) | |||
| 249 | + | secondUpdate, secondJSON := second.Update(), second.JSON() | |||
| 250 | + | require.NoError(t, second.Commit()) | |||
| 251 | + | ||||
| 252 | + | frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()}) | |||
| 253 | + | third, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | require.Equal(t, []int{2}, rowIndexes(third.Update())) | |||
| 256 | + | require.NoError(t, third.Commit()) | |||
| 257 | + | ||||
| 258 | + | require.Equal(t, firstUpdate, first.Update()) | |||
| 259 | + | require.Equal(t, firstJSON, first.JSON()) | |||
| 260 | + | require.Equal(t, secondUpdate, second.Update()) | |||
| 261 | + | require.Equal(t, secondJSON, second.JSON()) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |||
| 265 | + | frame := core.NewFrame(3, 2) | |||
| 266 | + | renderer, err := New(Options{}) | |||
| 267 | + | require.NoError(t, err) | |||
| 268 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 269 | + | require.NoError(t, err) | |||
| 270 | + | require.NoError(t, first.Commit()) | |||
| 271 | + | ||||
| 272 | + | frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |||
| 273 | + | aborted, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | abortedJSON := aborted.JSON() | |||
| 276 | + | require.NoError(t, aborted.Abort()) | |||
| 277 | + | ||||
| 278 | + | retry, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | require.False(t, retry.Update().Snapshot) | |||
| 281 | + | require.Equal(t, []int{1}, rowIndexes(retry.Update())) | |||
| 282 | + | require.Equal(t, abortedJSON, retry.JSON()) | |||
| 283 | + | require.NoError(t, retry.Commit()) | |||
| 284 | + | ||||
| 285 | + | steady, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | require.Empty(t, steady.Update().Rows) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func TestPrepareDoesNotAliasCallerFrame(t *testing.T) { | |||
| 291 | + | frame := core.NewFrame(2, 1) | |||
| 292 | + | renderer, err := New(Options{}) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | require.NoError(t, prepared.Commit()) | |||
| 297 | + | ||||
| 298 | + | // Mutating the caller frame after Commit must be seen as a change. | |||
| 299 | + | frame.Set(0, 0, core.Cell{Rune: 'Q', Style: core.DefaultStyle()}) | |||
| 300 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 301 | + | require.NoError(t, err) | |||
| 302 | + | require.Equal(t, []int{0}, rowIndexes(next.Update())) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func rowIndexes(update Update) []int { | |||
| 306 | + | rows := make([]int, 0, len(update.Rows)) | |||
| 307 | + | for _, row := range update.Rows { | |||
| 308 | + | rows = append(rows, row.Row) | |||
| 309 | + | } | |||
| 310 | + | return rows | |||
| 311 | + | } | |||