detached b6d6950

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 @@
11 package html
22
33 import (
4- "encoding/json"
54 "fmt"
65 "math"
76 "unicode/utf8"
@@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
6564 if err != nil {
6665 return nil, err
6766 }
68- if err := r.validateFrame(frame); err != nil {
67+ scratch, err := r.validateFrame(frame)
68+ if err != nil {
6969 return nil, err
7070 }
7171 cursor = normalizeWrapPendingCursor(cursor, frame.Width)
@@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
7474 }
7575
7676 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)
7978 if err != nil {
8079 return nil, err
8180 }
@@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
9089 state: transactionPending,
9190 update: update,
9291 json: encoded,
93- frame: candidate,
92+ frame: frame,
9493 cursor: cursor,
9594 }
9695 r.pending = tx
9796 return &PreparedDraw{tx: tx}, nil
9897 }
9998
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) {
101102 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)
103104 }
104105 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)
106107 }
107108 cells := frame.Width * frame.Height
108109 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)
110111 }
111112 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)
113114 }
114115 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)
116117 }
118+ scratch := make([]core.Cell, frame.Width)
117119 for y := range frame.Height {
118- row := frame.Row(y)
120+ row := readRow(frame, y, scratch)
119121 for x := 0; x < frame.Width; x++ {
120122 cell := row[x]
121123 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)
123125 }
124126 if cell.Continuation {
125127 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)
127129 }
128130 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)
130132 }
131133 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)
133135 }
134136 continue
135137 }
@@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error {
137139 continue
138140 }
139141 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)
141143 }
142144 width := core.RuneWidth(cell.Rune)
143145 switch width {
144146 case 1:
145147 case 2:
146148 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)
148150 }
149151 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)
151153 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)
153155 }
154156 }
155157 }
156- return nil
158+ return scratch, nil
157159 }
158160
159161 // materializeCellSource reads a source into an owned frame. A core.Frame
@@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
229231 return false
230232 }
231233
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) {
233259 update := Update{
234260 SchemaVersion: UpdateSchemaVersion,
235261 Width: frame.Width,
@@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (
241267 }
242268 styleIDs := make(map[Style]int)
243269 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) {
245272 continue
246273 }
247274 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
248275 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
249276 }
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)
251278 if err != nil {
252279 return Update{}, err
253280 }
254- update.Rows = append(update.Rows, row)
281+ update.Rows = append(update.Rows, encoded)
255282 }
256283 return update, nil
257284 }
258285
259-// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on
286+// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
260287 // the exact encoded size. The previous conservative estimate could reject
261288 // compact updates well below the configured limit.
262289 func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
263- encoded, err := json.Marshal(update)
290+ encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update)
264291 if err != nil {
265292 return nil, fmt.Errorf("html: encode update: %w", err)
266293 }
@@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
270297 return encoded, nil
271298 }
272299
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"
284305
285306 func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) {
286307 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
300321 *styles = append(*styles, style)
301322 }
302323 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:
305327 width = 1
306328 text = " "
329+ case cell.Rune < utf8.RuneSelf:
330+ text = asciiText[cell.Rune : cell.Rune+1] // no allocation
331+ default:
332+ text = string(cell.Rune)
307333 }
308334 row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID})
309335 }
@@ -1,7 +1,6 @@
1 package html1 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, err65 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, err69 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, err79 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 = tx95 r.pending = tx
97 return &PreparedDraw{tx: tx}, nil96 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.Height108 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 continue136 continue
135 }137 }
@@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error {
137 continue139 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 nil158+ return scratch, nil
157 }159 }
158 160
159 // materializeCellSource reads a source into an owned frame. A core.Frame161 // 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 false231 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 continue272 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{}, err279 return Update{}, err
253 }280 }
254- update.Rows = append(update.Rows, row)281+ update.Rows = append(update.Rows, encoded)
255 }282 }
256 return update, nil283 return update, nil
257 }284 }
258 285
259-// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on286+// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
260 // the exact encoded size. The previous conservative estimate could reject287 // 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, nil297 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 false302+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 = 1327 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) {
228228 require.True(t, next.Update().Snapshot)
229229 require.NoError(t, next.Commit())
230230 }
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+}

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 2

Tags 13

↑↓ moveenter openesc close