detached bc3f988

merge(perf): html renderer row and JSON allocations

commit
bc3f9880e9088964eb83e6eb1a9a9638e2f0c68a
author
brice · · 2026-10-02 09:25 UTC
merge of
dca8245 + 6bd019c · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +596 -40
A html/encode.go +234 -0
@@ -0,0 +1,234 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "reflect"
7+ "strconv"
8+ "unicode/utf8"
9+)
10+
11+// appendUpdateJSON appends the canonical JSON encoding of update to dst. The
12+// bytes are identical to encoding/json.Marshal(update) (including HTML
13+// escaping of text), but it writes into one caller-sized buffer instead of
14+// going through reflection and an intermediate copy.
15+func appendUpdateJSON(dst []byte, update Update) ([]byte, error) {
16+ dst = append(dst, `{"schemaVersion":`...)
17+ dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10)
18+ dst = append(dst, `,"width":`...)
19+ dst = strconv.AppendInt(dst, int64(update.Width), 10)
20+ dst = append(dst, `,"height":`...)
21+ dst = strconv.AppendInt(dst, int64(update.Height), 10)
22+ dst = append(dst, `,"snapshot":`...)
23+ dst = strconv.AppendBool(dst, update.Snapshot)
24+ dst = append(dst, `,"rows":`...)
25+ if update.Rows == nil {
26+ dst = append(dst, "null"...)
27+ } else {
28+ dst = append(dst, '[')
29+ for i := range update.Rows {
30+ if i > 0 {
31+ dst = append(dst, ',')
32+ }
33+ dst = appendRowJSON(dst, &update.Rows[i])
34+ }
35+ dst = append(dst, ']')
36+ }
37+ dst = append(dst, `,"styles":`...)
38+ if update.Styles == nil {
39+ dst = append(dst, "null"...)
40+ } else {
41+ dst = append(dst, '[')
42+ for i := range update.Styles {
43+ if i > 0 {
44+ dst = append(dst, ',')
45+ }
46+ var err error
47+ if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil {
48+ return nil, err
49+ }
50+ }
51+ dst = append(dst, ']')
52+ }
53+ dst = append(dst, `,"cursor":{"row":`...)
54+ dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10)
55+ dst = append(dst, `,"column":`...)
56+ dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10)
57+ dst = append(dst, `,"visible":`...)
58+ dst = strconv.AppendBool(dst, update.Cursor.Visible)
59+ dst = append(dst, `,"style":`...)
60+ dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10)
61+ dst = append(dst, `,"styleSet":`...)
62+ dst = strconv.AppendBool(dst, update.Cursor.StyleSet)
63+ dst = append(dst, "}}"...)
64+ return dst, nil
65+}
66+
67+func appendRowJSON(dst []byte, row *RowUpdate) []byte {
68+ dst = append(dst, `{"row":`...)
69+ dst = strconv.AppendInt(dst, int64(row.Row), 10)
70+ dst = append(dst, `,"cells":`...)
71+ if row.Cells == nil {
72+ return append(dst, "null}"...)
73+ }
74+ dst = append(dst, '[')
75+ for i := range row.Cells {
76+ cell := &row.Cells[i]
77+ if i > 0 {
78+ dst = append(dst, ',')
79+ }
80+ dst = append(dst, `{"column":`...)
81+ dst = strconv.AppendInt(dst, int64(cell.Column), 10)
82+ dst = append(dst, `,"width":`...)
83+ dst = strconv.AppendInt(dst, int64(cell.Width), 10)
84+ dst = append(dst, `,"text":`...)
85+ dst = appendJSONString(dst, cell.Text)
86+ dst = append(dst, `,"style":`...)
87+ dst = strconv.AppendInt(dst, int64(cell.Style), 10)
88+ dst = append(dst, '}')
89+ }
90+ return append(dst, "]}"...)
91+}
92+
93+func appendStyleJSON(dst []byte, style *Style) ([]byte, error) {
94+ dst = append(dst, '{')
95+ first := true
96+ flag := func(name string, set bool) {
97+ if !set {
98+ return
99+ }
100+ if !first {
101+ dst = append(dst, ',')
102+ }
103+ first = false
104+ dst = append(dst, '"')
105+ dst = append(dst, name...)
106+ dst = append(dst, `":true`...)
107+ }
108+ flag("bold", style.Bold)
109+ flag("italic", style.Italic)
110+ flag("inverse", style.Inverse)
111+ flag("dim", style.Dim)
112+ flag("blink", style.Blink)
113+ flag("strikethrough", style.Strikethrough)
114+ flag("underline", style.Underline)
115+ if style.UnderlineStyle != 0 {
116+ if !first {
117+ dst = append(dst, ',')
118+ }
119+ first = false
120+ dst = append(dst, `"underlineStyle":`...)
121+ dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10)
122+ }
123+ if !first {
124+ dst = append(dst, ',')
125+ }
126+ var err error
127+ dst = append(dst, `"foreground":`...)
128+ if dst, err = appendColorJSON(dst, style.Foreground); err != nil {
129+ return nil, err
130+ }
131+ dst = append(dst, `,"background":`...)
132+ if dst, err = appendColorJSON(dst, style.Background); err != nil {
133+ return nil, err
134+ }
135+ dst = append(dst, `,"underlineColor":`...)
136+ if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil {
137+ return nil, err
138+ }
139+ return append(dst, '}'), nil
140+}
141+
142+// appendColorJSON mirrors Color.MarshalJSON.
143+func appendColorJSON(dst []byte, color Color) ([]byte, error) {
144+ switch color.Kind {
145+ case ColorDefault:
146+ return append(dst, `{"kind":0}`...), nil
147+ case ColorIndexed:
148+ dst = append(dst, `{"kind":1,"index":`...)
149+ dst = strconv.AppendUint(dst, uint64(color.Index), 10)
150+ return append(dst, '}'), nil
151+ case ColorRGB:
152+ dst = append(dst, `{"kind":2,"rgb":{"r":`...)
153+ dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10)
154+ dst = append(dst, `,"g":`...)
155+ dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10)
156+ dst = append(dst, `,"b":`...)
157+ dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)
158+ return append(dst, "}}"...), nil
159+ default:
160+ // Same error value encoding/json produced via Color.MarshalJSON.
161+ return nil, &json.MarshalerError{
162+ Type: reflect.TypeFor[*Color](),
163+ Err: fmt.Errorf("html: invalid color kind %d", color.Kind),
164+ }
165+ }
166+}
167+
168+const hexDigits = "0123456789abcdef"
169+
170+// appendJSONString appends s as a JSON string using encoding/json's default
171+// escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029
172+// are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the
173+// literal character, byte-identical to encoding/json on this toolchain.
174+func appendJSONString(dst []byte, s string) []byte {
175+ dst = append(dst, '"')
176+ start := 0
177+ for i := 0; i < len(s); {
178+ b := s[i]
179+ if b < utf8.RuneSelf {
180+ if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' {
181+ i++
182+ continue
183+ }
184+ dst = append(dst, s[start:i]...)
185+ switch b {
186+ case '"', '\\':
187+ dst = append(dst, '\\', b)
188+ case '\b':
189+ dst = append(dst, '\\', 'b')
190+ case '\f':
191+ dst = append(dst, '\\', 'f')
192+ case '\n':
193+ dst = append(dst, '\\', 'n')
194+ case '\r':
195+ dst = append(dst, '\\', 'r')
196+ case '\t':
197+ dst = append(dst, '\\', 't')
198+ default:
199+ dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF])
200+ }
201+ i++
202+ start = i
203+ continue
204+ }
205+ r, size := utf8.DecodeRuneInString(s[i:])
206+ if r == utf8.RuneError && size == 1 {
207+ dst = append(dst, s[start:i]...)
208+ dst = append(dst, "\uFFFD"...)
209+ i += size
210+ start = i
211+ continue
212+ }
213+ if r == '\u2028' || r == '\u2029' {
214+ dst = append(dst, s[start:i]...)
215+ dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF])
216+ i += size
217+ start = i
218+ continue
219+ }
220+ i += size
221+ }
222+ dst = append(dst, s[start:]...)
223+ return append(dst, '"')
224+}
225+
226+// estimateUpdateJSONSize returns a capacity hint close to the encoded size so
227+// the output buffer normally grows zero times.
228+func estimateUpdateJSONSize(update Update) int {
229+ size := 256 + 160*len(update.Styles)
230+ for i := range update.Rows {
231+ size += 32 + 48*len(update.Rows[i].Cells)
232+ }
233+ return size
234+}
@@ -0,0 +1,234 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "reflect"
7+ "strconv"
8+ "unicode/utf8"
9+)
10+
11+// appendUpdateJSON appends the canonical JSON encoding of update to dst. The
12+// bytes are identical to encoding/json.Marshal(update) (including HTML
13+// escaping of text), but it writes into one caller-sized buffer instead of
14+// going through reflection and an intermediate copy.
15+func appendUpdateJSON(dst []byte, update Update) ([]byte, error) {
16+ dst = append(dst, `{"schemaVersion":`...)
17+ dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10)
18+ dst = append(dst, `,"width":`...)
19+ dst = strconv.AppendInt(dst, int64(update.Width), 10)
20+ dst = append(dst, `,"height":`...)
21+ dst = strconv.AppendInt(dst, int64(update.Height), 10)
22+ dst = append(dst, `,"snapshot":`...)
23+ dst = strconv.AppendBool(dst, update.Snapshot)
24+ dst = append(dst, `,"rows":`...)
25+ if update.Rows == nil {
26+ dst = append(dst, "null"...)
27+ } else {
28+ dst = append(dst, '[')
29+ for i := range update.Rows {
30+ if i > 0 {
31+ dst = append(dst, ',')
32+ }
33+ dst = appendRowJSON(dst, &update.Rows[i])
34+ }
35+ dst = append(dst, ']')
36+ }
37+ dst = append(dst, `,"styles":`...)
38+ if update.Styles == nil {
39+ dst = append(dst, "null"...)
40+ } else {
41+ dst = append(dst, '[')
42+ for i := range update.Styles {
43+ if i > 0 {
44+ dst = append(dst, ',')
45+ }
46+ var err error
47+ if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil {
48+ return nil, err
49+ }
50+ }
51+ dst = append(dst, ']')
52+ }
53+ dst = append(dst, `,"cursor":{"row":`...)
54+ dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10)
55+ dst = append(dst, `,"column":`...)
56+ dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10)
57+ dst = append(dst, `,"visible":`...)
58+ dst = strconv.AppendBool(dst, update.Cursor.Visible)
59+ dst = append(dst, `,"style":`...)
60+ dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10)
61+ dst = append(dst, `,"styleSet":`...)
62+ dst = strconv.AppendBool(dst, update.Cursor.StyleSet)
63+ dst = append(dst, "}}"...)
64+ return dst, nil
65+}
66+
67+func appendRowJSON(dst []byte, row *RowUpdate) []byte {
68+ dst = append(dst, `{"row":`...)
69+ dst = strconv.AppendInt(dst, int64(row.Row), 10)
70+ dst = append(dst, `,"cells":`...)
71+ if row.Cells == nil {
72+ return append(dst, "null}"...)
73+ }
74+ dst = append(dst, '[')
75+ for i := range row.Cells {
76+ cell := &row.Cells[i]
77+ if i > 0 {
78+ dst = append(dst, ',')
79+ }
80+ dst = append(dst, `{"column":`...)
81+ dst = strconv.AppendInt(dst, int64(cell.Column), 10)
82+ dst = append(dst, `,"width":`...)
83+ dst = strconv.AppendInt(dst, int64(cell.Width), 10)
84+ dst = append(dst, `,"text":`...)
85+ dst = appendJSONString(dst, cell.Text)
86+ dst = append(dst, `,"style":`...)
87+ dst = strconv.AppendInt(dst, int64(cell.Style), 10)
88+ dst = append(dst, '}')
89+ }
90+ return append(dst, "]}"...)
91+}
92+
93+func appendStyleJSON(dst []byte, style *Style) ([]byte, error) {
94+ dst = append(dst, '{')
95+ first := true
96+ flag := func(name string, set bool) {
97+ if !set {
98+ return
99+ }
100+ if !first {
101+ dst = append(dst, ',')
102+ }
103+ first = false
104+ dst = append(dst, '"')
105+ dst = append(dst, name...)
106+ dst = append(dst, `":true`...)
107+ }
108+ flag("bold", style.Bold)
109+ flag("italic", style.Italic)
110+ flag("inverse", style.Inverse)
111+ flag("dim", style.Dim)
112+ flag("blink", style.Blink)
113+ flag("strikethrough", style.Strikethrough)
114+ flag("underline", style.Underline)
115+ if style.UnderlineStyle != 0 {
116+ if !first {
117+ dst = append(dst, ',')
118+ }
119+ first = false
120+ dst = append(dst, `"underlineStyle":`...)
121+ dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10)
122+ }
123+ if !first {
124+ dst = append(dst, ',')
125+ }
126+ var err error
127+ dst = append(dst, `"foreground":`...)
128+ if dst, err = appendColorJSON(dst, style.Foreground); err != nil {
129+ return nil, err
130+ }
131+ dst = append(dst, `,"background":`...)
132+ if dst, err = appendColorJSON(dst, style.Background); err != nil {
133+ return nil, err
134+ }
135+ dst = append(dst, `,"underlineColor":`...)
136+ if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil {
137+ return nil, err
138+ }
139+ return append(dst, '}'), nil
140+}
141+
142+// appendColorJSON mirrors Color.MarshalJSON.
143+func appendColorJSON(dst []byte, color Color) ([]byte, error) {
144+ switch color.Kind {
145+ case ColorDefault:
146+ return append(dst, `{"kind":0}`...), nil
147+ case ColorIndexed:
148+ dst = append(dst, `{"kind":1,"index":`...)
149+ dst = strconv.AppendUint(dst, uint64(color.Index), 10)
150+ return append(dst, '}'), nil
151+ case ColorRGB:
152+ dst = append(dst, `{"kind":2,"rgb":{"r":`...)
153+ dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10)
154+ dst = append(dst, `,"g":`...)
155+ dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10)
156+ dst = append(dst, `,"b":`...)
157+ dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)
158+ return append(dst, "}}"...), nil
159+ default:
160+ // Same error value encoding/json produced via Color.MarshalJSON.
161+ return nil, &json.MarshalerError{
162+ Type: reflect.TypeFor[*Color](),
163+ Err: fmt.Errorf("html: invalid color kind %d", color.Kind),
164+ }
165+ }
166+}
167+
168+const hexDigits = "0123456789abcdef"
169+
170+// appendJSONString appends s as a JSON string using encoding/json's default
171+// escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029
172+// are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the
173+// literal character, byte-identical to encoding/json on this toolchain.
174+func appendJSONString(dst []byte, s string) []byte {
175+ dst = append(dst, '"')
176+ start := 0
177+ for i := 0; i < len(s); {
178+ b := s[i]
179+ if b < utf8.RuneSelf {
180+ if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' {
181+ i++
182+ continue
183+ }
184+ dst = append(dst, s[start:i]...)
185+ switch b {
186+ case '"', '\\':
187+ dst = append(dst, '\\', b)
188+ case '\b':
189+ dst = append(dst, '\\', 'b')
190+ case '\f':
191+ dst = append(dst, '\\', 'f')
192+ case '\n':
193+ dst = append(dst, '\\', 'n')
194+ case '\r':
195+ dst = append(dst, '\\', 'r')
196+ case '\t':
197+ dst = append(dst, '\\', 't')
198+ default:
199+ dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF])
200+ }
201+ i++
202+ start = i
203+ continue
204+ }
205+ r, size := utf8.DecodeRuneInString(s[i:])
206+ if r == utf8.RuneError && size == 1 {
207+ dst = append(dst, s[start:i]...)
208+ dst = append(dst, "\uFFFD"...)
209+ i += size
210+ start = i
211+ continue
212+ }
213+ if r == '\u2028' || r == '\u2029' {
214+ dst = append(dst, s[start:i]...)
215+ dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF])
216+ i += size
217+ start = i
218+ continue
219+ }
220+ i += size
221+ }
222+ dst = append(dst, s[start:]...)
223+ return append(dst, '"')
224+}
225+
226+// estimateUpdateJSONSize returns a capacity hint close to the encoded size so
227+// the output buffer normally grows zero times.
228+func estimateUpdateJSONSize(update Update) int {
229+ size := 256 + 160*len(update.Styles)
230+ for i := range update.Rows {
231+ size += 32 + 48*len(update.Rows[i].Cells)
232+ }
233+ return size
234+}
A html/encode_test.go +215 -0
@@ -0,0 +1,215 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "math/rand"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) {
14+ cases := []string{
15+ "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t",
16+ "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f",
17+ }
18+ for r := rune(0); r < 0x300; r++ {
19+ cases = append(cases, string(r))
20+ }
21+ for _, text := range cases {
22+ want, err := json.Marshal(text)
23+ require.NoError(t, err)
24+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
25+ }
26+}
27+
28+// Invalid UTF-8 never reaches the encoder (cells are validated), but the
29+// escaping must still match encoding/json byte for byte.
30+func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) {
31+ for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} {
32+ want, err := json.Marshal(text)
33+ require.NoError(t, err)
34+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
35+ }
36+}
37+
38+func TestAsciiTextIndexesEveryByte(t *testing.T) {
39+ require.Len(t, asciiText, 128)
40+ for i := 0; i < 128; i++ {
41+ require.Equal(t, byte(i), asciiText[i])
42+ }
43+}
44+
45+func randomColor(rng *rand.Rand) Color {
46+ switch rng.Intn(3) {
47+ case 0:
48+ return Color{Kind: ColorDefault}
49+ case 1:
50+ return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))}
51+ }
52+ return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}}
53+}
54+
55+func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) {
56+ rng := rand.New(rand.NewSource(1))
57+ texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"}
58+ for range 300 {
59+ update := Update{
60+ SchemaVersion: UpdateSchemaVersion,
61+ Width: rng.Intn(500),
62+ Height: rng.Intn(500),
63+ Snapshot: rng.Intn(2) == 0,
64+ Cursor: Cursor{
65+ Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0,
66+ Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0,
67+ },
68+ }
69+ if rng.Intn(5) != 0 {
70+ update.Rows = make([]RowUpdate, rng.Intn(4))
71+ for i := range update.Rows {
72+ update.Rows[i].Row = rng.Intn(1000)
73+ if rng.Intn(5) != 0 {
74+ update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5))
75+ for j := range update.Rows[i].Cells {
76+ 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)}
77+ }
78+ }
79+ }
80+ }
81+ if rng.Intn(5) != 0 {
82+ update.Styles = make([]Style, rng.Intn(4))
83+ for i := range update.Styles {
84+ update.Styles[i] = Style{
85+ Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0,
86+ Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0,
87+ Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)),
88+ Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng),
89+ }
90+ }
91+ }
92+ want, err := json.Marshal(update)
93+ require.NoError(t, err)
94+ got, err := appendUpdateJSON(nil, update)
95+ require.NoError(t, err)
96+ require.Equal(t, string(want), string(got))
97+ }
98+}
99+
100+func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {
101+ update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}
102+ _, err := appendUpdateJSON(nil, update)
103+ _, want := json.Marshal(update)
104+ require.Error(t, want)
105+ require.EqualError(t, err, want.Error())
106+}
107+
108+func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
109+ frame := core.NewFrame(6, 2)
110+ style := core.DefaultStyle()
111+ style.Bold = true
112+ style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3}
113+ style.Background = 200
114+ frame.Set(0, 0, core.Cell{Rune: '<', Style: style})
115+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
116+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
117+ frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
118+ frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
119+ frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()})
120+ frame.Set(0, 1, core.Cell{Rune: '"', Style: style})
121+
122+ renderer, err := New(Options{})
123+ require.NoError(t, err)
124+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true})
125+ require.NoError(t, err)
126+ want, err := json.Marshal(prepared.tx.update)
127+ require.NoError(t, err)
128+ require.Equal(t, string(want), string(prepared.JSON()))
129+ require.Contains(t, string(prepared.JSON()), "😀")
130+}
131+
132+// fillAll sets every field reachable from v to a distinctive non-zero value
133+// (negative for signed integers) so a field added to the update types without
134+// a matching encoder change shows up as a JSON mismatch.
135+func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) {
136+ t.Helper()
137+ *counter++
138+ n := *counter
139+ if v.Type() == reflect.TypeOf(Color{}) {
140+ v.FieldByName("Kind").SetUint(uint64(colorKind))
141+ v.FieldByName("Index").SetUint(uint64(100 + n%100))
142+ fillAll(t, v.FieldByName("RGB"), colorKind, counter)
143+ return
144+ }
145+ switch v.Kind() {
146+ case reflect.Bool:
147+ v.SetBool(true)
148+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
149+ v.SetInt(-int64(n) - 1)
150+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
151+ v.SetUint(uint64(1 + n%120))
152+ case reflect.String:
153+ v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26)))
154+ case reflect.Slice:
155+ v.Set(reflect.MakeSlice(v.Type(), 2, 2))
156+ for i := range 2 {
157+ fillAll(t, v.Index(i), colorKind, counter)
158+ }
159+ case reflect.Struct:
160+ for i := range v.NumField() {
161+ require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name)
162+ fillAll(t, v.Field(i), colorKind, counter)
163+ }
164+ default:
165+ t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type())
166+ }
167+}
168+
169+func requireNoZeroFields(t *testing.T, v reflect.Value) {
170+ t.Helper()
171+ switch v.Kind() {
172+ case reflect.Struct:
173+ for i := range v.NumField() {
174+ requireNoZeroFields(t, v.Field(i))
175+ }
176+ case reflect.Slice:
177+ require.NotZero(t, v.Len())
178+ for i := range v.Len() {
179+ requireNoZeroFields(t, v.Index(i))
180+ }
181+ default:
182+ require.False(t, v.IsZero(), "zero %s left in filled value", v.Type())
183+ }
184+}
185+
186+// TestEncoderCoversEveryUpdateField fails when a field is added to (or removed
187+// from) the update types without updating appendUpdateJSON and this test.
188+func TestEncoderCoversEveryUpdateField(t *testing.T) {
189+ for typ, fields := range map[reflect.Type]int{
190+ reflect.TypeOf(Update{}): 7,
191+ reflect.TypeOf(RowUpdate{}): 2,
192+ reflect.TypeOf(CellUpdate{}): 4,
193+ reflect.TypeOf(Style{}): 11,
194+ reflect.TypeOf(Cursor{}): 5,
195+ reflect.TypeOf(RGB{}): 3,
196+ reflect.TypeOf(Color{}): 3,
197+ } {
198+ require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ)
199+ }
200+
201+ for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} {
202+ var update Update
203+ counter := 0
204+ fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter)
205+ update.Cursor.Style = 6 // keep within the documented DECSCUSR range
206+ if kind != ColorDefault {
207+ requireNoZeroFields(t, reflect.ValueOf(update))
208+ }
209+ want, err := json.Marshal(update)
210+ require.NoError(t, err)
211+ got, err := appendUpdateJSON(nil, update)
212+ require.NoError(t, err)
213+ require.Equal(t, string(want), string(got), "color kind %d", kind)
214+ }
215+}
@@ -0,0 +1,215 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "math/rand"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) {
14+ cases := []string{
15+ "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t",
16+ "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f",
17+ }
18+ for r := rune(0); r < 0x300; r++ {
19+ cases = append(cases, string(r))
20+ }
21+ for _, text := range cases {
22+ want, err := json.Marshal(text)
23+ require.NoError(t, err)
24+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
25+ }
26+}
27+
28+// Invalid UTF-8 never reaches the encoder (cells are validated), but the
29+// escaping must still match encoding/json byte for byte.
30+func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) {
31+ for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} {
32+ want, err := json.Marshal(text)
33+ require.NoError(t, err)
34+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
35+ }
36+}
37+
38+func TestAsciiTextIndexesEveryByte(t *testing.T) {
39+ require.Len(t, asciiText, 128)
40+ for i := 0; i < 128; i++ {
41+ require.Equal(t, byte(i), asciiText[i])
42+ }
43+}
44+
45+func randomColor(rng *rand.Rand) Color {
46+ switch rng.Intn(3) {
47+ case 0:
48+ return Color{Kind: ColorDefault}
49+ case 1:
50+ return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))}
51+ }
52+ return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}}
53+}
54+
55+func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) {
56+ rng := rand.New(rand.NewSource(1))
57+ texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"}
58+ for range 300 {
59+ update := Update{
60+ SchemaVersion: UpdateSchemaVersion,
61+ Width: rng.Intn(500),
62+ Height: rng.Intn(500),
63+ Snapshot: rng.Intn(2) == 0,
64+ Cursor: Cursor{
65+ Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0,
66+ Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0,
67+ },
68+ }
69+ if rng.Intn(5) != 0 {
70+ update.Rows = make([]RowUpdate, rng.Intn(4))
71+ for i := range update.Rows {
72+ update.Rows[i].Row = rng.Intn(1000)
73+ if rng.Intn(5) != 0 {
74+ update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5))
75+ for j := range update.Rows[i].Cells {
76+ 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)}
77+ }
78+ }
79+ }
80+ }
81+ if rng.Intn(5) != 0 {
82+ update.Styles = make([]Style, rng.Intn(4))
83+ for i := range update.Styles {
84+ update.Styles[i] = Style{
85+ Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0,
86+ Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0,
87+ Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)),
88+ Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng),
89+ }
90+ }
91+ }
92+ want, err := json.Marshal(update)
93+ require.NoError(t, err)
94+ got, err := appendUpdateJSON(nil, update)
95+ require.NoError(t, err)
96+ require.Equal(t, string(want), string(got))
97+ }
98+}
99+
100+func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {
101+ update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}
102+ _, err := appendUpdateJSON(nil, update)
103+ _, want := json.Marshal(update)
104+ require.Error(t, want)
105+ require.EqualError(t, err, want.Error())
106+}
107+
108+func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
109+ frame := core.NewFrame(6, 2)
110+ style := core.DefaultStyle()
111+ style.Bold = true
112+ style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3}
113+ style.Background = 200
114+ frame.Set(0, 0, core.Cell{Rune: '<', Style: style})
115+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
116+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
117+ frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
118+ frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
119+ frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()})
120+ frame.Set(0, 1, core.Cell{Rune: '"', Style: style})
121+
122+ renderer, err := New(Options{})
123+ require.NoError(t, err)
124+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true})
125+ require.NoError(t, err)
126+ want, err := json.Marshal(prepared.tx.update)
127+ require.NoError(t, err)
128+ require.Equal(t, string(want), string(prepared.JSON()))
129+ require.Contains(t, string(prepared.JSON()), "😀")
130+}
131+
132+// fillAll sets every field reachable from v to a distinctive non-zero value
133+// (negative for signed integers) so a field added to the update types without
134+// a matching encoder change shows up as a JSON mismatch.
135+func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) {
136+ t.Helper()
137+ *counter++
138+ n := *counter
139+ if v.Type() == reflect.TypeOf(Color{}) {
140+ v.FieldByName("Kind").SetUint(uint64(colorKind))
141+ v.FieldByName("Index").SetUint(uint64(100 + n%100))
142+ fillAll(t, v.FieldByName("RGB"), colorKind, counter)
143+ return
144+ }
145+ switch v.Kind() {
146+ case reflect.Bool:
147+ v.SetBool(true)
148+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
149+ v.SetInt(-int64(n) - 1)
150+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
151+ v.SetUint(uint64(1 + n%120))
152+ case reflect.String:
153+ v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26)))
154+ case reflect.Slice:
155+ v.Set(reflect.MakeSlice(v.Type(), 2, 2))
156+ for i := range 2 {
157+ fillAll(t, v.Index(i), colorKind, counter)
158+ }
159+ case reflect.Struct:
160+ for i := range v.NumField() {
161+ require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name)
162+ fillAll(t, v.Field(i), colorKind, counter)
163+ }
164+ default:
165+ t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type())
166+ }
167+}
168+
169+func requireNoZeroFields(t *testing.T, v reflect.Value) {
170+ t.Helper()
171+ switch v.Kind() {
172+ case reflect.Struct:
173+ for i := range v.NumField() {
174+ requireNoZeroFields(t, v.Field(i))
175+ }
176+ case reflect.Slice:
177+ require.NotZero(t, v.Len())
178+ for i := range v.Len() {
179+ requireNoZeroFields(t, v.Index(i))
180+ }
181+ default:
182+ require.False(t, v.IsZero(), "zero %s left in filled value", v.Type())
183+ }
184+}
185+
186+// TestEncoderCoversEveryUpdateField fails when a field is added to (or removed
187+// from) the update types without updating appendUpdateJSON and this test.
188+func TestEncoderCoversEveryUpdateField(t *testing.T) {
189+ for typ, fields := range map[reflect.Type]int{
190+ reflect.TypeOf(Update{}): 7,
191+ reflect.TypeOf(RowUpdate{}): 2,
192+ reflect.TypeOf(CellUpdate{}): 4,
193+ reflect.TypeOf(Style{}): 11,
194+ reflect.TypeOf(Cursor{}): 5,
195+ reflect.TypeOf(RGB{}): 3,
196+ reflect.TypeOf(Color{}): 3,
197+ } {
198+ require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ)
199+ }
200+
201+ for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} {
202+ var update Update
203+ counter := 0
204+ fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter)
205+ update.Cursor.Style = 6 // keep within the documented DECSCUSR range
206+ if kind != ColorDefault {
207+ requireNoZeroFields(t, reflect.ValueOf(update))
208+ }
209+ want, err := json.Marshal(update)
210+ require.NoError(t, err)
211+ got, err := appendUpdateJSON(nil, update)
212+ require.NoError(t, err)
213+ require.Equal(t, string(want), string(got), "color kind %d", kind)
214+ }
215+}
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