fix(html): keep applyAll atomic and the merged cursor current
applyAll now validates the whole geometry chain against the mounted terminal before any DOM change and applies exactly one merged update, so invalid batches no longer leave a partial mutation. The merged update always carries the latest cursor (a trailing cursor-only update was previously dropped). Style remapping caches per source array instead of stringifying per cell, and a batch whose merged styles exceed maxStyles falls back to ordered apply. The Go renderer reuses its run scratch buffers (bounded to 240x80) and sizes the cell slice for one row: snapshots allocate 33-57% fewer objects, one-row updates go from 14 to 15. CellUpdate documents that texts share storage, the README notes the monospace and maxTextBytes requirements of text runs, and new tests cover multi-row incremental slicing and the run coverage invariants.
- commit
- a41ec87bac99b05dc95d69657398e7475d8fea3f
- author
- brice · · 2026-10-02 14:52 UTC
- parent
- 4123601
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +185 -58
M README.md +3 -1
| @@ -112,7 +112,9 @@ provides a labeled input proxy, synchronized plain-text accessible output, | |||
| 112 | 112 | typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and | |
| 113 | 113 | focus events. A synchronous consumer callback decides default prevention. | |
| 114 | 114 | `applyAll` validates a queue of updates and merges them before touching the | |
| 115 | - | DOM, so a consumer that falls behind rebuilds each changed row once. | |
| 115 | + | DOM, so a consumer that falls behind rebuilds each changed row once. Text runs | |
| 116 | + | assume a monospace primary font whose ASCII advance is `1ch`, and | |
| 117 | + | `maxTextBytes` bounds one run, so keep it at least as large as the widest row. | |
| 116 | 118 | Consumers remain responsible for transport and mapping events to terminal bytes | |
| 117 | 119 | or application actions. Clipboard text is preserved unchanged, including | |
| 118 | 120 | control bytes, so consumers forwarding paste events to a PTY must apply their | |
| @@ -112,7 +112,9 @@ provides a labeled input proxy, synchronized plain-text accessible output, | |||||
| 112 | typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and | 112 | typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and | ||
| 113 | focus events. A synchronous consumer callback decides default prevention. | 113 | focus events. A synchronous consumer callback decides default prevention. | ||
| 114 | `applyAll` validates a queue of updates and merges them before touching the | 114 | `applyAll` validates a queue of updates and merges them before touching the | ||
| 115 | - | DOM, so a consumer that falls behind rebuilds each changed row once. | 115 | + | DOM, so a consumer that falls behind rebuilds each changed row once. Text runs |
| 116 | + | assume a monospace primary font whose ASCII advance is `1ch`, and | |||
| 117 | + | `maxTextBytes` bounds one run, so keep it at least as large as the widest row. | |||
| 116 | Consumers remain responsible for transport and mapping events to terminal bytes | 118 | Consumers remain responsible for transport and mapping events to terminal bytes | ||
| 117 | or application actions. Clipboard text is preserved unchanged, including | 119 | or application actions. Clipboard text is preserved unchanged, including | ||
| 118 | control bytes, so consumers forwarding paste events to a PTY must apply their | 120 | control bytes, so consumers forwarding paste events to a PTY must apply their | ||
M html/browser/terminal.js +60 -46
| @@ -203,49 +203,56 @@ | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 205 | /** | |
| 206 | - | * Merge validated updates into the fewest equivalent ones. Each output | |
| 207 | - | * update keeps the geometry, snapshot flag, and cursor semantics of the | |
| 208 | - | * sequence it replaces; style ids are remapped to the styles still used. | |
| 206 | + | * Merge validated updates into one equivalent update, or return null when | |
| 207 | + | * the merged styles would exceed maxStyles (callers then apply in order). | |
| 208 | + | * Every incremental update must match the geometry established by the | |
| 209 | + | * mounted terminal or the latest snapshot, exactly as sequential apply | |
| 210 | + | * requires, so a protocol error is reported before any DOM mutation. | |
| 211 | + | * The merged update keeps the latest snapshot flag, geometry and cursor; | |
| 212 | + | * rows are last-writer-wins and style ids are remapped. | |
| 209 | 213 | */ | |
| 210 | - | function coalesce(updates, maxStyles) { | |
| 211 | - | const groups = []; | |
| 214 | + | function coalesce(updates, mounted, maxStyles) { | |
| 215 | + | let geometry = mounted; | |
| 212 | 216 | for (const update of updates) { | |
| 213 | - | const last = groups[groups.length - 1]; | |
| 214 | - | if (update.snapshot) { | |
| 215 | - | groups.length = 0; | |
| 216 | - | } else if (last && last.width === update.width && last.height === update.height) { | |
| 217 | - | for (const row of update.rows) last.rows.set(row.row, { row, styles: update.styles }); | |
| 218 | - | last.cursor = update.cursor; | |
| 219 | - | continue; | |
| 220 | - | } | |
| 221 | - | const rows = new Map(); | |
| 222 | - | for (const row of update.rows) rows.set(row.row, { row, styles: update.styles }); | |
| 223 | - | groups.push({ width: update.width, height: update.height, snapshot: update.snapshot, cursor: update.cursor, rows, single: update }); | |
| 217 | + | if (update.snapshot) geometry = { width: update.width, height: update.height }; | |
| 218 | + | else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot'); | |
| 224 | 219 | } | |
| 225 | - | return groups.flatMap(group => { | |
| 226 | - | if (group.rows.size === group.single.rows.length && [...group.rows.values()].every(entry => entry.styles === group.single.styles)) return [group.single]; | |
| 227 | - | const styles = []; | |
| 228 | - | const ids = new Map(); | |
| 229 | - | const rows = [...group.rows.keys()].sort((left, right) => left - right).map(key => { | |
| 230 | - | const { row, styles: source } = group.rows.get(key); | |
| 231 | - | return { | |
| 232 | - | row: row.row, | |
| 233 | - | bytes: row.bytes, | |
| 234 | - | cells: row.cells.map(cell => { | |
| 235 | - | const styleKey = JSON.stringify(source[cell.style]); | |
| 236 | - | let id = ids.get(styleKey); | |
| 237 | - | if (id === undefined) { | |
| 238 | - | id = styles.length; | |
| 239 | - | ids.set(styleKey, id); | |
| 240 | - | styles.push(source[cell.style]); | |
| 241 | - | } | |
| 242 | - | return { ...cell, style: id }; | |
| 243 | - | }) | |
| 244 | - | }; | |
| 220 | + | if (updates.length === 1) return updates[0]; | |
| 221 | + | let start = 0; | |
| 222 | + | for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index; | |
| 223 | + | const tail = updates.slice(start); | |
| 224 | + | const last = tail[tail.length - 1]; | |
| 225 | + | const rows = new Map(); | |
| 226 | + | for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles }); | |
| 227 | + | const styles = []; | |
| 228 | + | const ids = new Map(); | |
| 229 | + | const remaps = new Map(); | |
| 230 | + | const merged = []; | |
| 231 | + | for (const key of [...rows.keys()].sort((left, right) => left - right)) { | |
| 232 | + | const { row, styles: source } = rows.get(key); | |
| 233 | + | let remap = remaps.get(source); | |
| 234 | + | if (!remap) { | |
| 235 | + | remap = new Array(source.length); | |
| 236 | + | remaps.set(source, remap); | |
| 237 | + | } | |
| 238 | + | const cells = row.cells.map(cell => { | |
| 239 | + | let id = remap[cell.style]; | |
| 240 | + | if (id === undefined) { | |
| 241 | + | const styleKey = JSON.stringify(source[cell.style]); | |
| 242 | + | id = ids.get(styleKey); | |
| 243 | + | if (id === undefined) { | |
| 244 | + | id = styles.length; | |
| 245 | + | ids.set(styleKey, id); | |
| 246 | + | styles.push(source[cell.style]); | |
| 247 | + | } | |
| 248 | + | remap[cell.style] = id; | |
| 249 | + | } | |
| 250 | + | return id === cell.style ? cell : { ...cell, style: id }; | |
| 245 | 251 | }); | |
| 246 | - | if (styles.length > maxStyles) fail('merged update styles exceed their limit'); | |
| 247 | - | return [{ schemaVersion: SCHEMA_VERSION, width: group.width, height: group.height, snapshot: group.snapshot, rows, styles, cursor: group.cursor }]; | |
| 248 | - | }); | |
| 252 | + | merged.push({ row: row.row, bytes: row.bytes, cells }); | |
| 253 | + | } | |
| 254 | + | if (styles.length > maxStyles) return null; | |
| 255 | + | return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor }; | |
| 249 | 256 | } | |
| 250 | 257 | ||
| 251 | 258 | function xtermColor(index) { | |
| @@ -470,18 +477,25 @@ | |||
| 470 | 477 | } | |
| 471 | 478 | ||
| 472 | 479 | /** | |
| 473 | - | * Validate a queue of updates, then apply the fewest equivalent updates: | |
| 474 | - | * a snapshot supersedes everything before it and consecutive updates of | |
| 475 | - | * the same geometry merge, last row wins. A consumer that falls behind | |
| 476 | - | * rebuilds each changed row once instead of once per queued update. | |
| 480 | + | * Validate a queue of updates and their geometry chain, then apply one | |
| 481 | + | * merged update: a snapshot supersedes everything before it and later | |
| 482 | + | * rows win. A consumer that falls behind rebuilds each changed row once | |
| 483 | + | * instead of once per queued update. Invalid input throws before the DOM | |
| 484 | + | * changes. | |
| 477 | 485 | * @param {Update[]} values | |
| 478 | 486 | */ | |
| 479 | 487 | function applyAll(values) { | |
| 480 | 488 | alive(); | |
| 481 | 489 | if (!Array.isArray(values)) fail('updates must be an array'); | |
| 482 | - | for (const update of coalesce(values.map(value => validateUpdate(value, configured)), configured.maxStyles)) { | |
| 483 | - | applyValidated(update); | |
| 484 | - | } | |
| 490 | + | if (values.length === 0) return; | |
| 491 | + | const updates = values.map(value => validateUpdate(value, configured)); | |
| 492 | + | const mounted = rowNodes.length === 0 ? null : { width, height }; | |
| 493 | + | const merged = coalesce(updates, mounted, configured.maxStyles); | |
| 494 | + | if (merged) applyValidated(merged); | |
| 495 | + | // Rare: the merged rows need more distinct styles than one update may | |
| 496 | + | // carry. Each update is valid on its own and the geometry chain was | |
| 497 | + | // checked above, so apply them in order. | |
| 498 | + | else for (const update of updates) applyValidated(update); | |
| 485 | 499 | } | |
| 486 | 500 | ||
| 487 | 501 | function applyValidated(update) { | |
| @@ -203,49 +203,56 @@ | |||||
| 203 | } | 203 | } | ||
| 204 | 204 | ||||
| 205 | /** | 205 | /** | ||
| 206 | - | * Merge validated updates into the fewest equivalent ones. Each output | 206 | + | * Merge validated updates into one equivalent update, or return null when |
| 207 | - | * update keeps the geometry, snapshot flag, and cursor semantics of the | 207 | + | * the merged styles would exceed maxStyles (callers then apply in order). |
| 208 | - | * sequence it replaces; style ids are remapped to the styles still used. | 208 | + | * Every incremental update must match the geometry established by the |
| 209 | + | * mounted terminal or the latest snapshot, exactly as sequential apply | |||
| 210 | + | * requires, so a protocol error is reported before any DOM mutation. | |||
| 211 | + | * The merged update keeps the latest snapshot flag, geometry and cursor; | |||
| 212 | + | * rows are last-writer-wins and style ids are remapped. | |||
| 209 | */ | 213 | */ | ||
| 210 | - | function coalesce(updates, maxStyles) { | 214 | + | function coalesce(updates, mounted, maxStyles) { |
| 211 | - | const groups = []; | 215 | + | let geometry = mounted; |
| 212 | for (const update of updates) { | 216 | for (const update of updates) { | ||
| 213 | - | const last = groups[groups.length - 1]; | 217 | + | if (update.snapshot) geometry = { width: update.width, height: update.height }; |
| 214 | - | if (update.snapshot) { | 218 | + | else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot'); |
| 215 | - | groups.length = 0; | |||
| 216 | - | } else if (last && last.width === update.width && last.height === update.height) { | |||
| 217 | - | for (const row of update.rows) last.rows.set(row.row, { row, styles: update.styles }); | |||
| 218 | - | last.cursor = update.cursor; | |||
| 219 | - | continue; | |||
| 220 | - | } | |||
| 221 | - | const rows = new Map(); | |||
| 222 | - | for (const row of update.rows) rows.set(row.row, { row, styles: update.styles }); | |||
| 223 | - | groups.push({ width: update.width, height: update.height, snapshot: update.snapshot, cursor: update.cursor, rows, single: update }); | |||
| 224 | } | 219 | } | ||
| 225 | - | return groups.flatMap(group => { | 220 | + | if (updates.length === 1) return updates[0]; |
| 226 | - | if (group.rows.size === group.single.rows.length && [...group.rows.values()].every(entry => entry.styles === group.single.styles)) return [group.single]; | 221 | + | let start = 0; |
| 227 | - | const styles = []; | 222 | + | for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index; |
| 228 | - | const ids = new Map(); | 223 | + | const tail = updates.slice(start); |
| 229 | - | const rows = [...group.rows.keys()].sort((left, right) => left - right).map(key => { | 224 | + | const last = tail[tail.length - 1]; |
| 230 | - | const { row, styles: source } = group.rows.get(key); | 225 | + | const rows = new Map(); |
| 231 | - | return { | 226 | + | for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles }); |
| 232 | - | row: row.row, | 227 | + | const styles = []; |
| 233 | - | bytes: row.bytes, | 228 | + | const ids = new Map(); |
| 234 | - | cells: row.cells.map(cell => { | 229 | + | const remaps = new Map(); |
| 235 | - | const styleKey = JSON.stringify(source[cell.style]); | 230 | + | const merged = []; |
| 236 | - | let id = ids.get(styleKey); | 231 | + | for (const key of [...rows.keys()].sort((left, right) => left - right)) { |
| 237 | - | if (id === undefined) { | 232 | + | const { row, styles: source } = rows.get(key); |
| 238 | - | id = styles.length; | 233 | + | let remap = remaps.get(source); |
| 239 | - | ids.set(styleKey, id); | 234 | + | if (!remap) { |
| 240 | - | styles.push(source[cell.style]); | 235 | + | remap = new Array(source.length); |
| 241 | - | } | 236 | + | remaps.set(source, remap); |
| 242 | - | return { ...cell, style: id }; | 237 | + | } |
| 243 | - | }) | 238 | + | const cells = row.cells.map(cell => { |
| 244 | - | }; | 239 | + | let id = remap[cell.style]; |
| 240 | + | if (id === undefined) { | |||
| 241 | + | const styleKey = JSON.stringify(source[cell.style]); | |||
| 242 | + | id = ids.get(styleKey); | |||
| 243 | + | if (id === undefined) { | |||
| 244 | + | id = styles.length; | |||
| 245 | + | ids.set(styleKey, id); | |||
| 246 | + | styles.push(source[cell.style]); | |||
| 247 | + | } | |||
| 248 | + | remap[cell.style] = id; | |||
| 249 | + | } | |||
| 250 | + | return id === cell.style ? cell : { ...cell, style: id }; | |||
| 245 | }); | 251 | }); | ||
| 246 | - | if (styles.length > maxStyles) fail('merged update styles exceed their limit'); | 252 | + | merged.push({ row: row.row, bytes: row.bytes, cells }); |
| 247 | - | return [{ schemaVersion: SCHEMA_VERSION, width: group.width, height: group.height, snapshot: group.snapshot, rows, styles, cursor: group.cursor }]; | 253 | + | } |
| 248 | - | }); | 254 | + | if (styles.length > maxStyles) return null; |
| 255 | + | return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor }; | |||
| 249 | } | 256 | } | ||
| 250 | 257 | ||||
| 251 | function xtermColor(index) { | 258 | function xtermColor(index) { | ||
| @@ -470,18 +477,25 @@ | |||||
| 470 | } | 477 | } | ||
| 471 | 478 | ||||
| 472 | /** | 479 | /** | ||
| 473 | - | * Validate a queue of updates, then apply the fewest equivalent updates: | 480 | + | * Validate a queue of updates and their geometry chain, then apply one |
| 474 | - | * a snapshot supersedes everything before it and consecutive updates of | 481 | + | * merged update: a snapshot supersedes everything before it and later |
| 475 | - | * the same geometry merge, last row wins. A consumer that falls behind | 482 | + | * rows win. A consumer that falls behind rebuilds each changed row once |
| 476 | - | * rebuilds each changed row once instead of once per queued update. | 483 | + | * instead of once per queued update. Invalid input throws before the DOM |
| 484 | + | * changes. | |||
| 477 | * @param {Update[]} values | 485 | * @param {Update[]} values | ||
| 478 | */ | 486 | */ | ||
| 479 | function applyAll(values) { | 487 | function applyAll(values) { | ||
| 480 | alive(); | 488 | alive(); | ||
| 481 | if (!Array.isArray(values)) fail('updates must be an array'); | 489 | if (!Array.isArray(values)) fail('updates must be an array'); | ||
| 482 | - | for (const update of coalesce(values.map(value => validateUpdate(value, configured)), configured.maxStyles)) { | 490 | + | if (values.length === 0) return; |
| 483 | - | applyValidated(update); | 491 | + | const updates = values.map(value => validateUpdate(value, configured)); |
| 484 | - | } | 492 | + | const mounted = rowNodes.length === 0 ? null : { width, height }; |
| 493 | + | const merged = coalesce(updates, mounted, configured.maxStyles); | |||
| 494 | + | if (merged) applyValidated(merged); | |||
| 495 | + | // Rare: the merged rows need more distinct styles than one update may | |||
| 496 | + | // carry. Each update is valid on its own and the geometry chain was | |||
| 497 | + | // checked above, so apply them in order. | |||
| 498 | + | else for (const update of updates) applyValidated(update); | |||
| 485 | } | 499 | } | ||
| 486 | 500 | ||||
| 487 | function applyValidated(update) { | 501 | function applyValidated(update) { | ||
M html/renderer.go +22 -6
| @@ -15,6 +15,10 @@ type Renderer struct { | |||
| 15 | 15 | committed core.Frame | |
| 16 | 16 | cursor Cursor | |
| 17 | 17 | initialized bool | |
| 18 | + | // Encoder scratch reused across updates; never referenced by an Update. | |
| 19 | + | ends []int | |
| 20 | + | text []byte | |
| 21 | + | rowStarts []int | |
| 18 | 22 | } | |
| 19 | 23 | ||
| 20 | 24 | // CellSource is the read-only semantic grid consumed by HTML rendering. | |
| @@ -269,12 +273,24 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b | |||
| 269 | 273 | // Every row appends its runs to cells and their text to text; ends[i] | |
| 270 | 274 | // is the text offset after cells[i]. Rows and texts are sliced out once | |
| 271 | 275 | // at the end, so an update costs one cell slice and one string. | |
| 272 | - | var ( | |
| 273 | - | cells []CellUpdate | |
| 274 | - | ends []int | |
| 275 | - | text []byte | |
| 276 | - | rowStarts []int | |
| 277 | - | ) | |
| 276 | + | // cells is owned by the update and sized for one full row (an | |
| 277 | + | // incremental update usually changes one or a few rows). The other | |
| 278 | + | // buffers are renderer scratch: the text is copied into one string. | |
| 279 | + | cells := make([]CellUpdate, 0, frame.Width) | |
| 280 | + | ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0] | |
| 281 | + | if cap(ends) < frame.Width { | |
| 282 | + | ends = make([]int, 0, frame.Width) | |
| 283 | + | text = make([]byte, 0, frame.Width) | |
| 284 | + | rowStarts = make([]int, 0, 4) | |
| 285 | + | } | |
| 286 | + | defer func() { | |
| 287 | + | // Keep scratch for frames up to the common 240x80 size only, so one | |
| 288 | + | // huge snapshot does not pin its buffers for the renderer lifetime. | |
| 289 | + | const maxRetainedCells = 240 * 80 | |
| 290 | + | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells { | |
| 291 | + | r.ends, r.text, r.rowStarts = ends, text, rowStarts | |
| 292 | + | } | |
| 293 | + | }() | |
| 278 | 294 | for y := range frame.Height { | |
| 279 | 295 | row := readRow(frame, y, scratch) | |
| 280 | 296 | if !snapshot && rowEqualsFrame(row, r.committed, y) { | |
| @@ -15,6 +15,10 @@ type Renderer struct { | |||||
| 15 | committed core.Frame | 15 | committed core.Frame | ||
| 16 | cursor Cursor | 16 | cursor Cursor | ||
| 17 | initialized bool | 17 | initialized bool | ||
| 18 | + | // Encoder scratch reused across updates; never referenced by an Update. | |||
| 19 | + | ends []int | |||
| 20 | + | text []byte | |||
| 21 | + | rowStarts []int | |||
| 18 | } | 22 | } | ||
| 19 | 23 | ||||
| 20 | // CellSource is the read-only semantic grid consumed by HTML rendering. | 24 | // CellSource is the read-only semantic grid consumed by HTML rendering. | ||
| @@ -269,12 +273,24 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b | |||||
| 269 | // Every row appends its runs to cells and their text to text; ends[i] | 273 | // Every row appends its runs to cells and their text to text; ends[i] | ||
| 270 | // is the text offset after cells[i]. Rows and texts are sliced out once | 274 | // is the text offset after cells[i]. Rows and texts are sliced out once | ||
| 271 | // at the end, so an update costs one cell slice and one string. | 275 | // at the end, so an update costs one cell slice and one string. | ||
| 272 | - | var ( | 276 | + | // cells is owned by the update and sized for one full row (an |
| 273 | - | cells []CellUpdate | 277 | + | // incremental update usually changes one or a few rows). The other |
| 274 | - | ends []int | 278 | + | // buffers are renderer scratch: the text is copied into one string. |
| 275 | - | text []byte | 279 | + | cells := make([]CellUpdate, 0, frame.Width) |
| 276 | - | rowStarts []int | 280 | + | ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0] |
| 277 | - | ) | 281 | + | if cap(ends) < frame.Width { |
| 282 | + | ends = make([]int, 0, frame.Width) | |||
| 283 | + | text = make([]byte, 0, frame.Width) | |||
| 284 | + | rowStarts = make([]int, 0, 4) | |||
| 285 | + | } | |||
| 286 | + | defer func() { | |||
| 287 | + | // Keep scratch for frames up to the common 240x80 size only, so one | |||
| 288 | + | // huge snapshot does not pin its buffers for the renderer lifetime. | |||
| 289 | + | const maxRetainedCells = 240 * 80 | |||
| 290 | + | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells { | |||
| 291 | + | r.ends, r.text, r.rowStarts = ends, text, rowStarts | |||
| 292 | + | } | |||
| 293 | + | }() | |||
| 278 | for y := range frame.Height { | 294 | for y := range frame.Height { | ||
| 279 | row := readRow(frame, y, scratch) | 295 | row := readRow(frame, y, scratch) | ||
| 280 | if !snapshot && rowEqualsFrame(row, r.committed, y) { | 296 | if !snapshot && rowEqualsFrame(row, r.committed, y) { | ||
M html/renderer_test.go +74 -0
| @@ -3,6 +3,7 @@ package html | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "math/rand" | |
| 6 | 7 | "os" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| @@ -158,6 +159,79 @@ func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) { | |||
| 158 | 159 | require.NoError(t, prepared.Commit()) | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 162 | + | func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) { | |
| 163 | + | plain := core.DefaultStyle() | |
| 164 | + | frame := core.NewFrame(4, 3) | |
| 165 | + | renderer, err := New(Options{}) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | require.NoError(t, first.Commit()) | |
| 170 | + | ||
| 171 | + | frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain}) | |
| 172 | + | frame.Set(1, 2, core.Cell{Rune: '界', Style: plain}) | |
| 173 | + | frame.Set(2, 2, core.Cell{Continuation: true, Style: plain}) | |
| 174 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | rows := prepared.Update().Rows | |
| 177 | + | require.Len(t, rows, 2) | |
| 178 | + | require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0]) | |
| 179 | + | require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{ | |
| 180 | + | {Column: 0, Width: 1, Text: " ", Style: 0}, | |
| 181 | + | {Column: 1, Width: 2, Text: "界", Style: 0}, | |
| 182 | + | {Column: 3, Width: 1, Text: " ", Style: 0}, | |
| 183 | + | }}, rows[1]) | |
| 184 | + | require.NoError(t, prepared.Commit()) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // TestRendererRunsCoverEveryRowExactly checks the run invariants on random | |
| 188 | + | // frames: entries are contiguous, widths sum to the frame width, and every | |
| 189 | + | // entry wider than one cell is printable ASCII with one byte per column. | |
| 190 | + | func TestRendererRunsCoverEveryRowExactly(t *testing.T) { | |
| 191 | + | rng := rand.New(rand.NewSource(1)) | |
| 192 | + | runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'} | |
| 193 | + | styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()} | |
| 194 | + | for iteration := range 200 { | |
| 195 | + | width, height := 1+rng.Intn(20), 1+rng.Intn(4) | |
| 196 | + | frame := core.NewFrame(width, height) | |
| 197 | + | for y := range height { | |
| 198 | + | for x := 0; x < width; x++ { | |
| 199 | + | r := runes[rng.Intn(len(runes))] | |
| 200 | + | style := styles[rng.Intn(len(styles))] | |
| 201 | + | if core.RuneWidth(r) == 2 { | |
| 202 | + | if x+1 >= width { | |
| 203 | + | r = 'w' | |
| 204 | + | } else { | |
| 205 | + | frame.Set(x, y, core.Cell{Rune: r, Style: style}) | |
| 206 | + | frame.Set(x+1, y, core.Cell{Continuation: true, Style: style}) | |
| 207 | + | x++ | |
| 208 | + | continue | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | frame.Set(x, y, core.Cell{Rune: r, Style: style}) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | renderer, err := New(Options{}) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 217 | + | require.NoError(t, err, "iteration %d", iteration) | |
| 218 | + | for _, row := range prepared.Update().Rows { | |
| 219 | + | column := 0 | |
| 220 | + | for _, cell := range row.Cells { | |
| 221 | + | require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row) | |
| 222 | + | if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 { | |
| 223 | + | require.Len(t, cell.Text, cell.Width) | |
| 224 | + | for i := range len(cell.Text) { | |
| 225 | + | require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | column += cell.Width | |
| 229 | + | } | |
| 230 | + | require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 161 | 235 | func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) { | |
| 162 | 236 | renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}}) | |
| 163 | 237 | require.NoError(t, err) | |
| @@ -3,6 +3,7 @@ package html | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "math/rand" | |||
| 6 | "os" | 7 | "os" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| @@ -158,6 +159,79 @@ func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) { | |||||
| 158 | require.NoError(t, prepared.Commit()) | 159 | require.NoError(t, prepared.Commit()) | ||
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| 162 | + | func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) { | |||
| 163 | + | plain := core.DefaultStyle() | |||
| 164 | + | frame := core.NewFrame(4, 3) | |||
| 165 | + | renderer, err := New(Options{}) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | require.NoError(t, first.Commit()) | |||
| 170 | + | ||||
| 171 | + | frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain}) | |||
| 172 | + | frame.Set(1, 2, core.Cell{Rune: '界', Style: plain}) | |||
| 173 | + | frame.Set(2, 2, core.Cell{Continuation: true, Style: plain}) | |||
| 174 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | rows := prepared.Update().Rows | |||
| 177 | + | require.Len(t, rows, 2) | |||
| 178 | + | require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0]) | |||
| 179 | + | require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{ | |||
| 180 | + | {Column: 0, Width: 1, Text: " ", Style: 0}, | |||
| 181 | + | {Column: 1, Width: 2, Text: "界", Style: 0}, | |||
| 182 | + | {Column: 3, Width: 1, Text: " ", Style: 0}, | |||
| 183 | + | }}, rows[1]) | |||
| 184 | + | require.NoError(t, prepared.Commit()) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // TestRendererRunsCoverEveryRowExactly checks the run invariants on random | |||
| 188 | + | // frames: entries are contiguous, widths sum to the frame width, and every | |||
| 189 | + | // entry wider than one cell is printable ASCII with one byte per column. | |||
| 190 | + | func TestRendererRunsCoverEveryRowExactly(t *testing.T) { | |||
| 191 | + | rng := rand.New(rand.NewSource(1)) | |||
| 192 | + | runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'} | |||
| 193 | + | styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()} | |||
| 194 | + | for iteration := range 200 { | |||
| 195 | + | width, height := 1+rng.Intn(20), 1+rng.Intn(4) | |||
| 196 | + | frame := core.NewFrame(width, height) | |||
| 197 | + | for y := range height { | |||
| 198 | + | for x := 0; x < width; x++ { | |||
| 199 | + | r := runes[rng.Intn(len(runes))] | |||
| 200 | + | style := styles[rng.Intn(len(styles))] | |||
| 201 | + | if core.RuneWidth(r) == 2 { | |||
| 202 | + | if x+1 >= width { | |||
| 203 | + | r = 'w' | |||
| 204 | + | } else { | |||
| 205 | + | frame.Set(x, y, core.Cell{Rune: r, Style: style}) | |||
| 206 | + | frame.Set(x+1, y, core.Cell{Continuation: true, Style: style}) | |||
| 207 | + | x++ | |||
| 208 | + | continue | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | frame.Set(x, y, core.Cell{Rune: r, Style: style}) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | renderer, err := New(Options{}) | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 217 | + | require.NoError(t, err, "iteration %d", iteration) | |||
| 218 | + | for _, row := range prepared.Update().Rows { | |||
| 219 | + | column := 0 | |||
| 220 | + | for _, cell := range row.Cells { | |||
| 221 | + | require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row) | |||
| 222 | + | if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 { | |||
| 223 | + | require.Len(t, cell.Text, cell.Width) | |||
| 224 | + | for i := range len(cell.Text) { | |||
| 225 | + | require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | column += cell.Width | |||
| 229 | + | } | |||
| 230 | + | require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 161 | func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) { | 235 | func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) { | ||
| 162 | renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}}) | 236 | renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}}) | ||
| 163 | require.NoError(t, err) | 237 | require.NoError(t, err) | ||
M html/update.go +2 -1
| @@ -84,7 +84,8 @@ type Style struct { | |||
| 84 | 84 | // either a text run of Width printable ASCII characters (blank cells are | |
| 85 | 85 | // spaces) sharing one style, or one non-ASCII character of width 1 or 2. | |
| 86 | 86 | // Wide-cell continuation markers are represented by the preceding cell's | |
| 87 | - | // width. | |
| 87 | + | // width. Text values of one update may share backing storage, so retaining | |
| 88 | + | // one keeps that update's text alive. | |
| 88 | 89 | type CellUpdate struct { | |
| 89 | 90 | Column int `json:"column"` | |
| 90 | 91 | Width int `json:"width"` | |
| @@ -84,7 +84,8 @@ type Style struct { | |||||
| 84 | // either a text run of Width printable ASCII characters (blank cells are | 84 | // either a text run of Width printable ASCII characters (blank cells are | ||
| 85 | // spaces) sharing one style, or one non-ASCII character of width 1 or 2. | 85 | // spaces) sharing one style, or one non-ASCII character of width 1 or 2. | ||
| 86 | // Wide-cell continuation markers are represented by the preceding cell's | 86 | // Wide-cell continuation markers are represented by the preceding cell's | ||
| 87 | - | // width. | 87 | + | // width. Text values of one update may share backing storage, so retaining |
| 88 | + | // one keeps that update's text alive. | |||
| 88 | type CellUpdate struct { | 89 | type CellUpdate struct { | ||
| 89 | Column int `json:"column"` | 90 | Column int `json:"column"` | ||
| 90 | Width int `json:"width"` | 91 | Width int `json:"width"` | ||
M internal/htmlharness/terminal.spec.js +24 -4
| @@ -308,14 +308,34 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap | |||
| 308 | 308 | const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]); | |
| 309 | 309 | terminal.applyAll([queue[0], snapshot, queue[2]]); | |
| 310 | 310 | const afterSnapshot = capture().text; | |
| 311 | - | let invalid = ''; | |
| 312 | - | try { terminal.applyAll([queue[0], { ...queue[1], width: 5 }]); } catch (error) { invalid = error.message; } | |
| 313 | - | return { sequential, merged, rebuilt, afterSnapshot, invalid, unchanged: capture().text === afterSnapshot }; | |
| 311 | + | const cursorAt = () => { | |
| 312 | + | const node = document.querySelector('.vev-terminal__cursor'); | |
| 313 | + | return [node.style.top, node.style.left]; | |
| 314 | + | }; | |
| 315 | + | const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } }); | |
| 316 | + | terminal.apply(snapshot); | |
| 317 | + | for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update); | |
| 318 | + | const cursorSequential = cursorAt(); | |
| 319 | + | terminal.apply(snapshot); | |
| 320 | + | terminal.applyAll([queue[0], cursorOnly(3)]); | |
| 321 | + | const cursorMerged = cursorAt(); | |
| 322 | + | terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]); | |
| 323 | + | const cursorAfterSnapshot = cursorAt(); | |
| 324 | + | const before = capture(); | |
| 325 | + | const failures = [ | |
| 326 | + | [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]], | |
| 327 | + | [queue[0], { ...cursorOnly(0), width: 5 }, snapshot] | |
| 328 | + | ].map(batch => { | |
| 329 | + | try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; } | |
| 330 | + | }); | |
| 331 | + | return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) }; | |
| 314 | 332 | }); | |
| 315 | 333 | expect(result.merged).toEqual(result.sequential); | |
| 316 | 334 | expect(result.rebuilt).toEqual([true, true, true]); | |
| 317 | 335 | expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc'); | |
| 318 | - | expect(result.invalid).not.toBe(''); | |
| 336 | + | expect(result.cursorMerged).toEqual(result.cursorSequential); | |
| 337 | + | expect(result.cursorAfterSnapshot[1]).toContain('1 *'); | |
| 338 | + | expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true); | |
| 319 | 339 | expect(result.unchanged).toBe(true); | |
| 320 | 340 | }); | |
| 321 | 341 | ||
| @@ -308,14 +308,34 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap | |||||
| 308 | const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]); | 308 | const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]); | ||
| 309 | terminal.applyAll([queue[0], snapshot, queue[2]]); | 309 | terminal.applyAll([queue[0], snapshot, queue[2]]); | ||
| 310 | const afterSnapshot = capture().text; | 310 | const afterSnapshot = capture().text; | ||
| 311 | - | let invalid = ''; | 311 | + | const cursorAt = () => { |
| 312 | - | try { terminal.applyAll([queue[0], { ...queue[1], width: 5 }]); } catch (error) { invalid = error.message; } | 312 | + | const node = document.querySelector('.vev-terminal__cursor'); |
| 313 | - | return { sequential, merged, rebuilt, afterSnapshot, invalid, unchanged: capture().text === afterSnapshot }; | 313 | + | return [node.style.top, node.style.left]; |
| 314 | + | }; | |||
| 315 | + | const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } }); | |||
| 316 | + | terminal.apply(snapshot); | |||
| 317 | + | for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update); | |||
| 318 | + | const cursorSequential = cursorAt(); | |||
| 319 | + | terminal.apply(snapshot); | |||
| 320 | + | terminal.applyAll([queue[0], cursorOnly(3)]); | |||
| 321 | + | const cursorMerged = cursorAt(); | |||
| 322 | + | terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]); | |||
| 323 | + | const cursorAfterSnapshot = cursorAt(); | |||
| 324 | + | const before = capture(); | |||
| 325 | + | const failures = [ | |||
| 326 | + | [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]], | |||
| 327 | + | [queue[0], { ...cursorOnly(0), width: 5 }, snapshot] | |||
| 328 | + | ].map(batch => { | |||
| 329 | + | try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; } | |||
| 330 | + | }); | |||
| 331 | + | return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) }; | |||
| 314 | }); | 332 | }); | ||
| 315 | expect(result.merged).toEqual(result.sequential); | 333 | expect(result.merged).toEqual(result.sequential); | ||
| 316 | expect(result.rebuilt).toEqual([true, true, true]); | 334 | expect(result.rebuilt).toEqual([true, true, true]); | ||
| 317 | expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc'); | 335 | expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc'); | ||
| 318 | - | expect(result.invalid).not.toBe(''); | 336 | + | expect(result.cursorMerged).toEqual(result.cursorSequential); |
| 337 | + | expect(result.cursorAfterSnapshot[1]).toContain('1 *'); | |||
| 338 | + | expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true); | |||
| 319 | expect(result.unchanged).toBe(true); | 339 | expect(result.unchanged).toBe(true); | ||
| 320 | }); | 340 | }); | ||
| 321 | 341 | ||||