Compare commits

..
Author SHA1 Message Date
Cursor AgentandDaniel Imms 3556f16138 feat(buffer): add logical line storage for wrapped rows
Introduce BufferOverflowLine rows that share cell storage with a parent
BufferLine head, implementing the logical-line model from issue #4800.

- Extend BufferLine with logical capacity and segment-aware indexing
- Add BufferOverflowLine, BufferLineUtils helpers, and unit tests
- Expose attachWrappedRow/clearWrappedRow on Buffer for future integration
- Keep InputHandler on isWrapped flag until reflow/SL paths are updated

Reflow still uses per-row buffers; materializeLogicalLinesForReflow helpers
are included for a follow-up that simplifies resize reflow.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-28 03:42:52 +00:00
Daniel Imms c2ecfe7446 Merge pull request #5895 from xtermjs/dependabot/npm_and_yarn/multi-f792d6d6d9
Bump qs and express
2026-05-27 18:26:29 -07:00
Daniel Imms 5656443163 Merge pull request #5957 from xtermjs/cursor/fix-progress-webkit-flake-5577
fix(addon-progress): stabilize flaky WebKit integration test harness
2026-05-27 17:32:25 -07:00
Cursor AgentandDaniel Imms 38174907f3 fix(addon-progress): stabilize WebKit integration test harness
Remove unnecessary unawaited setViewportSize from ProgressAddon tests,
which was the line reported in flaky CI failures. Use serial mode and
scoped hooks per Playwright shared-page guidance, and guard afterAll
teardown when setup fails.

Fixes #5956

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-28 00:24:06 +00:00
Daniel Imms 91fcb46a1a Merge pull request #5955 from xtermjs/cursor/gitignore-cursor-logs-5a86
Ignore .cursor/*.log files in git
2026-05-27 16:25:02 -07:00
Cursor AgentandDaniel Imms 1d569f873d Ignore .cursor/*.log files in git
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 23:22:32 +00:00
dependabot[bot] e89fbf2979 Bump qs and express
Bumps [qs](https://github.com/ljharb/qs) to 6.15.2 and updates ancestor dependency [express](https://github.com/expressjs/express). These dependencies need to be updated together.


Updates `qs` from 6.14.2 to 6.15.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.2...v6.15.2)

Updates `express` from 4.22.1 to 4.22.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/v4.22.1...v4.22.2)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 4.22.2
  dependency-type: direct:development
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 21:53:01 +00:00
Daniel Imms db244ea5a8 Merge pull request #5954 from xtermjs/cursor/align-headless-marker-alt-buffer-2866
Allow markers on alt buffer in headless terminal
2026-05-27 14:51:35 -07:00
Cursor AgentandDaniel Imms fdcd1c2720 Align xterm-headless marker JSDoc with xterm.d.ts
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 21:46:20 +00:00
Cursor AgentandDaniel Imms 57b48f6048 Allow markers on alt buffer in headless terminal
Remove the alt-buffer guard in headless registerMarker so behavior
matches the browser terminal. Update headless public API and typings
to return IMarker instead of IMarker | undefined.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 21:28:36 +00:00
Daniel Imms ec0c93fd5a Merge pull request #5950 from xtermjs/cursor/writebuffer-dispose-timeout-124c
Cancel WriteBuffer innerWrite timers on dispose
2026-05-27 14:14:41 -07:00
Daniel Imms be339bf796 Merge pull request #5947 from xtermjs/cursor/decoration-bucket-merge-loop-7132
Avoid spread when merging decoration line buckets
2026-05-27 13:15:37 -07:00
Daniel Imms 7294124736 Merge pull request #5945 from xtermjs/cursor/bufferline-stringbuilder-5939-79f8
Use StringBuilder in BufferLine.translateToString
2026-05-27 13:11:02 -07:00
Daniel Imms 2c3fad39af Merge pull request #5948 from xtermjs/cursor/hoist-get-current-link-id-e29d
Hoist _getCurrentLinkId() in print() loop
2026-05-27 13:09:09 -07:00
Cursor AgentandDaniel Imms edb4f89dd8 Add WriteBuffer regression tests for dispose and hot paths
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 20:00:06 +00:00
Cursor AgentandDaniel Imms 224c113b64 Move linkId declaration above use site in print loop
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:36:45 +00:00
Cursor AgentandDaniel Imms 3e0c7d18e5 Register WriteBuffer cleanup via toDisposable in constructor
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:35:58 +00:00
Daniel Imms 95d9224b8c Merge pull request #5946 from xtermjs/cursor/fix-duplicate-buildnumber-guard-cbb8
Fix duplicate buildNumber guard in Windows ConPTY logic
2026-05-27 12:34:08 -07:00
Cursor AgentandDaniel Imms 7499a2181d Use DisposableStore.isDisposed instead of duplicate flag
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:34:08 +00:00
Daniel Imms 7dc820f5fa Merge pull request #5949 from xtermjs/cursor/rename-headless-register-marker-22c3
Rename headless core addMarker → registerMarker
2026-05-27 12:33:02 -07:00
Cursor AgentandDaniel Imms e59a6c1f7b Reuse shared StringBuilder in BufferLine.translateToString
Avoid per-call allocation when translating many lines in sequence by
resetting a module-level work StringBuilder before and after each use.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:31:00 +00:00
Daniel Imms f3e2c08a5d Merge pull request #5944 from xtermjs/cursor/buffer-reflow-single-loop-sum-705e
perf: use single loop for cellsNeeded sum in BufferReflow
2026-05-27 12:28:08 -07:00
Daniel Imms b138bf38d0 Merge pull request #5943 from xtermjs/cursor/remove-duplicate-defineproperty-57b2
Remove duplicate Object.defineProperty loop in headless Terminal
2026-05-27 12:26:36 -07:00
Cursor AgentandDaniel Imms c6e4627b45 Hoist _getCurrentLinkId() in print() loop
Avoid calling _getCurrentLinkId() twice per codepoint when a hyperlink
is active by reading the link id once before the print loop.

Fixes #5940

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:24 +00:00
Cursor AgentandDaniel Imms d91045dc5f Cancel WriteBuffer innerWrite timers on dispose
Guard _innerWrite and public write methods after disposal so pending
setTimeout continuations cannot run against a disposed terminal.

Fixes #5936

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:22 +00:00
Cursor AgentandDaniel Imms 41dfdba2cf Avoid spread when merging decoration line buckets
Replace existing.push(...bucket) with an indexed loop in
_mergeLineBucket to avoid intermediate array allocations during
buffer trim/insert/delete re-indexing.

Reuse _mergeLineBucket in _handleBufferLinesTrim for consistency.

Fixes #5941

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:13 +00:00
Cursor AgentandDaniel Imms ba33decb68 Rename headless core addMarker to registerMarker
Align headless Terminal core API with browser CoreBrowserTerminal
and the public registerMarker naming.

Fixes #5938

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:01 +00:00
Cursor AgentandDaniel Imms 17e3b84bcc perf: use single loop for cellsNeeded sum in BufferReflow
Replace map().reduce() with one accumulator loop in
reflowSmallerGetNewLineLengths to avoid allocating an intermediate
array when summing wrapped line trimmed lengths.

Fixes #5942

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:17:17 +00:00
Cursor AgentandDaniel Imms 182ff2f635 Remove duplicate Object.defineProperty loop in headless Terminal
The constructor defined property getters/setters twice on _publicOptions;
the first block was immediately overwritten by the second. Align with the
browser public Terminal implementation.

Fixes #5935

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:17:08 +00:00
Cursor AgentandDaniel Imms bcb4e5298b Fix duplicate buildNumber guard in Windows ConPTY logic
The windowsPty option change handler checked buildNumber twice
instead of requiring both backend and buildNumber to be defined.
This aligns with Buffer.ts and ensures ConPTY wrapping heuristics
are evaluated only when both fields are present.

Fixes #5934

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:16:54 +00:00
Cursor AgentandDaniel Imms 42facac2d9 Use StringBuilder in BufferLine.translateToString
Replace O(n²) string concatenation in the translate loop with
StringBuilder.append, matching the pattern used elsewhere in the project.

Fixes #5939

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:16:51 +00:00
Daniel Imms 2ab0f10a26 Merge pull request #5929 from xtermjs/cursor/atlas-cache-texture-size-7cad
fix(webgl): include texture size and cell dimensions in atlas cache equality
2026-05-27 10:31:32 -07:00
Daniel Imms 280dc0a36a Merge pull request #5927 from xtermjs/cursor/webgl-vao-dispose-ef2f
fix(addon-webgl): delete VAOs on renderer dispose
2026-05-27 09:34:46 -07:00
Daniel Imms ebe8cb749a Merge pull request #5928 from xtermjs/cursor/fix-dcs-osc-apc-payload-o-n-f60b
Fix O(n²) payload accumulation in DCS/OSC/APC handlers
2026-05-27 09:32:42 -07:00
Daniel Imms c28f3c8d73 Merge pull request #5922 from xtermjs/cursor/remove-stale-sgr-fixme-e362
Remove stale FIXME comments from charAttributes JSDoc
2026-05-27 09:28:29 -07:00
Daniel Imms 3bd7ac083a Merge pull request #5923 from xtermjs/cursor/texture-atlas-remove-emitter-leak-75ac
Fix TextureAtlas remove-emitter leak on dispose
2026-05-27 09:27:51 -07:00
Cursor AgentandDaniel Imms 30b42838fd Extract StringBuilder and LimitedStringBuilder with unit tests
Replace PayloadStringBuffer with LimitedStringBuilder from common.
Add thorough StringBuilder and LimitedStringBuilder test coverage.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 16:27:26 +00:00
Daniel Imms 80e832e417 Merge pull request #5930 from xtermjs/cursor/webgl-ilogservice-5921-3745
fix(addon-webgl): use ILogService instead of console for logging
2026-05-27 09:21:12 -07:00
Daniel Imms 470947abd8 Merge pull request #5931 from xtermjs/cursor/headless-clear-markers-ccc0
fix(headless): clear markers when clear() is called
2026-05-27 09:18:17 -07:00
Daniel Imms bb49a80ace Merge branch 'master' into cursor/headless-clear-markers-ccc0 2026-05-27 09:13:38 -07:00
Daniel Imms 65b87e0f40 Merge pull request #5933 from xtermjs/cursor/replace-clone-with-structuredclone-34fc
Replace custom clone utility with structuredClone
2026-05-27 09:12:41 -07:00
Daniel Imms 39fc0f259e Merge pull request #5932 from xtermjs/cursor/webgl-context-loss-timeout-dispose-3f04
Clear WebGL context-loss timeout on WebglRenderer dispose
2026-05-27 09:10:07 -07:00
Daniel Imms b787ac8b1b Merge pull request #5924 from xtermjs/cursor/selection-trim-listener-leak-135c
Fix SelectionService trim listener leak
2026-05-27 09:10:04 -07:00
Daniel Imms 457efbf55d Merge pull request #5925 from xtermjs/cursor/fix-onwillopen-error-logging-7e14
Log onWillOpen handler errors instead of swallowing them
2026-05-27 09:00:33 -07:00
Daniel Imms 42270a0b68 Merge pull request #5926 from xtermjs/cursor/fix-flushsync-onwriteparsed-7a21
Fix flushSync() to fire onWriteParsed
2026-05-27 09:00:13 -07:00
Cursor AgentandDaniel Imms 090b77f354 Replace custom clone utility with structuredClone
Remove common/Clone.ts and its tests. Use the native structuredClone API
for cloning default modes, options, and test fixtures.

Fixes #5911

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:41:00 +00:00
Cursor AgentandDaniel Imms 534ce1365b Clear WebGL context-loss timeout on WebglRenderer dispose
Fixes #5919

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:40:10 +00:00
Cursor AgentandDaniel Imms f757c965ee fix(addon-webgl): keep VAO WebGL types non-nullable
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:38:58 +00:00
Cursor AgentandDaniel Imms 4aa02f3fd9 fix(addon-webgl): route logging through ILogService instead of console
Replace direct console.log/warn/error calls in the WebGL addon with
ILogService so messages respect the terminal logLevel and custom logger.

Fixes xtermjs/xterm.js#5921

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:37:09 +00:00
Cursor AgentandDaniel Imms f43273b3f0 fix(webgl): include texture size and cell dimensions in atlas cache equality
configEquals omitted deviceMaxTextureSize, deviceCellWidth, and
deviceCellHeight, which could cause the char atlas cache to reuse an
atlas built for a different GPU texture limit or cell size.

Fixes #5915

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:35:05 +00:00
Cursor AgentandDaniel Imms c06c3025f2 fix(addon-webgl): delete VAOs on renderer dispose
Register vertex array objects for disposal in GlyphRenderer and
RectangleRenderer, matching existing buffer and program cleanup.

Fixes #5918

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:35:04 +00:00
Daniel Imms 1064e4f783 Merge pull request #5812 from JeffreyCA/osc8-hover-fix
Underline entire wrapped OSC 8 links on hover
2026-05-27 08:34:50 -07:00
Cursor AgentandDaniel Imms fad4821fd9 Fix O(n²) payload accumulation in DCS/OSC/APC handlers
Replace repeated string concatenation with a chunk buffer that joins
once on completion. Fixes #5916.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:48 +00:00
Cursor AgentandDaniel Imms 306c3d73ec Fix flushSync() to fire onWriteParsed
flushSync() processes pending writes synchronously (e.g. during
CoreTerminal.resize()) but did not emit onWriteParsed, unlike the
async _innerWrite path. Addons listening for parse completion could
miss updates after resize.

Fixes #5912

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:26 +00:00
Cursor AgentandDaniel Imms 8f2bb32c9c fix(headless): clear markers when clear() is called
Headless clear() now calls buffer.clearAllMarkers() to match the browser
implementation, preventing markers from surviving clear() and pointing at
wrong lines.

Fixes #5910

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:00 +00:00
Cursor AgentandDaniel Imms 7b6c814566 Log errors from onWillOpen handlers instead of swallowing them
Fixes #5920

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:48 +00:00
Cursor AgentandDaniel Imms adc9dea083 Fix TextureAtlas remove-emitter leak on dispose
Dispose _onRemoveTextureAtlasCanvas in TextureAtlas.dispose() alongside
_onAddTextureAtlasCanvas to avoid leaking listeners when the atlas is torn down.

Fixes #5917

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:28 +00:00
Cursor AgentandDaniel Imms d565a2750e Fix SelectionService trim listener leak with MutableDisposable
Register _trimListener via _register(MutableDisposable) so it is disposed
when SelectionService is torn down. Use .value assignment on buffer
activate instead of manual dispose/swap.

Fixes #5914

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:20 +00:00
Cursor AgentandDaniel Imms c5ab83d697 Remove stale FIXME comments from charAttributes JSDoc
Fixes #5913. Blinking is supported in renderers via TextBlinkStateManager,
and bright background colors (100-107) are handled in the SGR loop.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:03 +00:00
c62e6dd765 Register BufferNamespaceApi onBufferActivate listener with disposables (#5906)
Wrap the onBufferActivate subscription in this._register so it is
disposed when BufferNamespaceApi is disposed, matching the pattern used
elsewhere in the codebase.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 04:39:27 +00:00
Daniel Imms 29a07f1d8f Merge pull request #5905 from xtermjs/cursor/multiline-comment-style-5899-d349
Add multiline-comment-style lint rule
2026-05-26 21:05:48 -07:00
Cursor AgentandDaniel Imms 88cef97049 Attach method signatures to last handler JSDoc line in Types.ts
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 04:01:00 +00:00
Cursor AgentandDaniel Imms 0e823004e6 Use single-line JSDoc comments per handler in Types.ts
Document multi-variant escape handlers with separate /** ... */ lines
instead of multiline blocks.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 03:59:42 +00:00
Cursor AgentandDaniel Imms df3422fb0b Use jsdoc/multiline-blocks instead of multiline-comment-style
Replace multiline-comment-style (starred-block), which also forces
multiline // comments into block comments, with jsdoc/multiline-blocks.
That rule only enforces starred JSDoc layout for /** blocks (no text on
the opening line) and leaves multiline // comments unchanged for
internal documentation.

Fix multiline JSDoc in Types.ts.

Fixes #5899

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 20:32:23 +00:00
Cursor AgentandDaniel Imms 4ec85e67ba Add multiline-comment-style lint rule and fix violations
Enable multiline-comment-style with starred-block to require JSDoc-style
block comments with content on separate lines from the opening delimiter.
Auto-fix existing violations across the codebase.

Fixes #5899

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 20:15:55 +00:00
Daniel Imms 1a67b6f4b3 Merge pull request #5903 from xtermjs/cursor/space-unary-ops-5898-604c
Add space-unary-ops lint rule to prevent space after !
2026-05-26 13:01:20 -07:00
Daniel Imms afaed108e4 Merge pull request #5828 from lawrence3699/fix/addon-ligatures-prepublish-only
fix(addon-ligatures): run package build on publish
2026-05-26 12:26:53 -07:00
Daniel Imms c27d6bb462 Merge branch 'master' into fix/addon-ligatures-prepublish-only 2026-05-26 12:26:23 -07:00
Daniel Imms 9c4f0f96ca Merge pull request #5831 from juspay/fix/intersection-observer-nullify
Clear IntersectionObserver reference on dispose
2026-05-26 12:23:13 -07:00
Daniel Imms 18c370a154 Merge pull request #5813 from CodinGame/fix-wrong-window-regression
Use element window instead of global object
2026-05-26 12:18:29 -07:00
Daniel Imms 850f4ad738 Merge pull request #5872 from xtermjs/dependabot/npm_and_yarn/fast-uri-3.1.2
Bump fast-uri from 3.1.0 to 3.1.2
2026-05-26 12:15:57 -07:00
Daniel Imms b665f6259a Merge pull request #5871 from xtermjs/dependabot/npm_and_yarn/axios-1.15.2
Bump axios from 1.15.0 to 1.15.2
2026-05-26 12:15:54 -07:00
Cursor AgentandDaniel Imms d195dc9a3b Add space-unary-ops lint rule and fix SixelHandler violation
Enable @stylistic/space-unary-ops to warn on spaces after unary
operators like ! in conditions. Fix existing violation in SixelHandler.

Fixes #5898

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 18:03:15 +00:00
Daniel Imms 4e5994d8a2 Merge pull request #5902 from Tyriar/tyriar/cell_opt
Optimize forEachDecorationAtCell by maintaining cache against buffer line index
2026-05-26 10:52:17 -07:00
Daniel ImmsandCursor 4a0cb5ae6b Add MicrotaskTimer and use it for decoration line index sync
Introduce MicrotaskTimer as a TimeoutTimer-style helper that schedules a
single runner via queueMicrotask, with set and cancel but no cancelAndSet.

Use it in DecorationLineCache to batch insert/delete line-index updates after
marker line adjustments, replacing ad-hoc queueMicrotask scheduling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:52:53 -07:00
Daniel ImmsandCursor 9e72a4b6d3 Refactor decoration line index into DecorationLineCache
Extract the per-line lookup index from DecorationService into an exported
DecorationLineCache class in the same module, with DecorationService listed
first for readability.

Optimize buffer insert/delete maintenance to shift map keys in O(unique
indexed lines) instead of full re-indexing every affected decoration. Only
multi-line decorations that span the mutation point get a full re-index.

Add a direct DecorationLineCache unit test in DecorationService.test.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:47:11 -07:00
Daniel ImmsandCursor f484584b70 Speed up decoration cell lookup with a per-line index
Refs #5176.

forEachDecorationAtCell and getDecorationsAtCell previously scanned every
registered decoration on each call. With thousands of search highlights this
becomes O(total decorations) per cell and blocks rendering during viewport
refreshes.

Maintain a Map of buffer line -> decorations that cover that line. Multi-line
decorations are indexed on every line in their span, so mixed single-line and
multi-line workloads stay fast without falling back to a full scan.

Keep the index aligned with buffer mutations:
- Register and unregister decorations in the line buckets on add/dispose.
- On trim, shift line keys synchronously (before marker handlers run).
- On insert/delete, re-index affected decorations in a microtask after marker
  line updates are applied.

Inject IBufferService so line listeners follow the active buffer. Add unit
tests for trim, dispose-on-trim, insert, and dense single-line plus multi-line
lookup. Add DecorationService.benchmark.ts to measure sparse hits and
viewport-sized grid scans with 20k decorations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:30:25 -07:00
Daniel Imms e749cb6125 Merge pull request #5891 from Tyriar/cross-env
Remove cross-env dep
2026-05-21 03:17:50 -07:00
Daniel Imms 8df996f9e7 Merge pull request #5892 from Tyriar/tyriar/deps
Remove @types/debug and trusted-types
2026-05-21 03:17:24 -07:00
Daniel Imms 51f6186352 Remove @types/debug and trusted-types 2026-05-21 03:09:41 -07:00
Daniel Imms ae5c41c518 Merge pull request #5890 from Tyriar/utf8
Remove utf8 dep
2026-05-21 03:09:04 -07:00
dependabot[bot] 17e7dafa17 Bump axios from 1.15.0 to 1.15.2
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 10:06:53 +00:00
Daniel Imms df9fb7e473 Remove cross-env dep 2026-05-21 03:06:53 -07:00
dependabot[bot] 41d9c4080f Bump fast-uri from 3.1.0 to 3.1.2
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 10:06:23 +00:00
Daniel Imms f100e93ba5 Remove utf8 dep 2026-05-21 03:04:16 -07:00
Daniel Imms 1d3825d341 Merge pull request #5889 from Tyriar/deepEqual
Remove deep-equal/types packages
2026-05-21 03:03:37 -07:00
Daniel Imms 57315e6cc9 Remove deep-equal/types packages 2026-05-21 02:58:43 -07:00
Daniel Imms 2be4fc68e5 Merge pull request #5884 from sotasan/addon-image-kitty-q2-suppresses-ok
addon-image: Kitty graphics q=2 should suppress OK responses too
2026-05-21 02:37:53 -07:00
Daniel Imms 66fabfc232 Merge pull request #5878 from PerBothner/demo-size-style
demo - indicate screen area size when non-auto
2026-05-21 02:36:24 -07:00
Daniel Imms 3e2a42eb29 Merge pull request #5883 from vsych/fix-webgl-atlas-merge-stale-vertices
Fix webgl rendering corruption from atlas page merges
2026-05-21 02:26:03 -07:00
Daniel Imms 6a47d09b4c Merge pull request #5888 from Tyriar/tyriar/nal-trace
Git ignore cursor nal-traces
2026-05-21 02:18:06 -07:00
Daniel Imms b36001ee7b Git ignore cursor nal-traces 2026-05-21 02:17:43 -07:00
Daniel Imms e64f25a0dd Merge pull request #5880 from ciembor/master
Add 4bit to the Real-world uses section.
2026-05-21 02:13:34 -07:00
Victor Sych 3bcb57545e Re-upload atlas textures after page merges 2026-05-21 04:59:37 +02:00
Victor Sych dc726a2ae6 Fix webgl rendering corruption from atlas page merges 2026-05-19 02:27:15 +02:00
Sōta 06e5ac7ab4 addon-image: Kitty graphics q=2 should suppress OK responses too 2026-05-18 18:26:32 +02:00
Maciej Ciemborowicz c6e3492a4a Add 4bit to the Real-world uses section. 2026-05-14 04:45:52 +02:00
Per Bothner 86291b6e96 Highlight xterm-screen when size is non-auto 2026-05-12 17:53:33 -07:00
Daniel Imms c8c8b3a7fe Merge pull request #5875 from zeertzjq/kitty
Fix keys getting swallowed when enabling only "Report event types"
2026-05-12 04:23:01 -07:00
zeertzjq 50e90ddf8a Fix keys getting swallowed when enabling only "Report event types"
Per kovidgoyal/kitty#9999, enabling "Report event types" without
"Disambiguate escape codes" doesn't really make sense, and terminals may
choose whatever behavior they think makes sense. However, keys shouldn't
get swallowed (which, after #5791, happens when the !useCsiU branch
cannot handle the event), so instead make "Report event types" imply
"Disambiguate escape codes".
2026-05-12 13:41:22 +08:00
Daniel Imms 7059a07c09 Merge pull request #5874 from xtermjs/clipboard_base64
clipboard addon: various fixes
2026-05-11 06:43:58 -07:00
Daniel Imms c5c6fc2a64 Merge pull request #5791 from zeertzjq/kitty
Fix kitty keyboard "Report event types" behavior not matching spec
2026-05-11 06:42:32 -07:00
zeertzjq e0add7fe4b Move return earlier 2026-05-11 14:11:28 +08:00
zeertzjq b0f5bc2d28 Fix kitty keyboard "Report event types" behavior not matching spec
According to spec:
- The Enter, Tab and Backspace keys will not have release events unless
  "Report all keys as escape codes" is also set.
- Key events that result in text are reported as plain UTF-8 text, so
  events are not supported for them, unless the application requests
  "key report" mode.

I checked kitty's behavior. While it does report release events for keys
that result in text as CSI sequences, press and repeat events for these
are reported as plain UTF-8 text. This commit changes Xterm.js to match
this behavior.
2026-05-11 13:59:01 +08:00
Jörg Breitbart c5a11220c1 always refer to navigator.clipboard in default provider 2026-05-10 18:00:26 +02:00
Jörg Breitbart 6f55150485 make linter happy 2026-05-10 17:06:08 +02:00
Jörg Breitbart c8dddddbbb remove js-base64 dependency 2026-05-10 16:45:46 +02:00
Daniel Imms 6ba731d73b Merge pull request #5867 from Tyriar/inst_const_enuym
Add disposable/const enum agent instructions, convert a lot of top level consts to const enums
2026-05-02 06:59:12 -07:00
Daniel Imms 2d3909be2c Fix lint 2026-05-02 06:54:41 -07:00
Daniel Imms 1e189625fa Merge remote-tracking branch 'upstream/master' into inst_const_enuym 2026-05-02 06:49:10 -07:00
Daniel Imms 80debafa11 Convert a lot of top level consts to const enums 2026-05-02 06:44:25 -07:00
Daniel Imms 1023e40428 Merge pull request #5864 from Tyriar/tyriar/bufferline_cache
Cache translateToString calls inside BufferLine
2026-05-02 06:30:49 -07:00
Daniel Imms 5c47e6dd9a Add instructions around MutableDisposables and const enums 2026-05-02 06:30:18 -07:00
Daniel Imms 4ebb3fffe9 Ignore cache keys in serialize addon
Bit hacky, but this is due to the way we're serializing the line in the test
2026-05-02 06:21:42 -07:00
Daniel Imms 093e81b9a7 Fix build, lint 2026-05-02 06:07:41 -07:00
Daniel Imms e04dd2501f Avoid thrashing timeouts 2026-05-02 05:58:56 -07:00
Daniel Imms d911b40a47 Avoid any in test 2026-05-02 05:52:33 -07:00
Daniel Imms 3dbfa7ce3e Convert cache to Disposable 2026-05-02 05:48:08 -07:00
Daniel Imms 6287c6ddfe Cache/buffer lifecycle 2026-05-02 05:44:13 -07:00
Daniel Imms 52d4b2719c Move cache into Buffer owned object, use WeakRefs
This allows O(1) clearing of the cache, a single TTL timer and we
can avoid making BufferLine's Disposable
2026-05-02 05:32:43 -07:00
Daniel Imms 7615cd210a Store a boolean on whether the cache is trimmed 2026-05-02 04:35:18 -07:00
Daniel Imms 4d4b994c35 Support caching of trimRight 2026-05-02 04:35:18 -07:00
Daniel Imms 7fa8b7a069 Merge branch 'master' into tyriar/bufferline_cache 2026-05-02 03:56:22 -07:00
Daniel Imms a2e51e8d36 Merge pull request #5865 from Tyriar/tyriar/image
Fix integration test defaults
2026-05-01 16:22:01 -07:00
Daniel Imms f89253d24b Fix integration test defaults
Broke due to 2 recent conflicting PRs
2026-05-01 16:17:16 -07:00
Daniel Imms 7aeee95346 Merge remote-tracking branch 'upstream/master' into tyriar/bufferline_cache 2026-05-01 16:10:45 -07:00
Daniel Imms 66fcdb2583 Fix test/startCol handling 2026-05-01 14:11:14 -07:00
Daniel Imms 2a18600d18 Cache translateToString calls inside BufferLine
Fixes #5863
2026-05-01 14:06:39 -07:00
Daniel Imms 5ef4f86543 Merge pull request #5846 from xtermjs/qoi_decoder
QOI support for IIP
2026-05-01 10:28:25 -07:00
Daniel Imms 874e767b40 Merge pull request #5840 from xtermjs/fix_apc
changes in APC handling
2026-05-01 10:22:52 -07:00
Daniel Imms 605a7c6e30 Merge pull request #5844 from xtermjs/adjust_image_addon_defaults
adjust image addon defaults
2026-05-01 10:21:58 -07:00
Daniel Imms 802decb046 Merge pull request #5856 from xtermjs/fix_colorbits
fix RG colorbits in AttributeData
2026-05-01 10:21:21 -07:00
Daniel Imms 79a95dbfd8 Merge pull request #5857 from nwparker/add-orca-real-world-use
Add Orca to real-world uses
2026-05-01 10:20:18 -07:00
Daniel Imms 75ba88c51b Merge pull request #5862 from Tyriar/05-01-make_agent_instructions_harness_agnostic
Make agent instructions harness agnostic
2026-05-01 10:13:01 -07:00
Daniel Imms f9cea732f2 Make agent instructions harness agnostic 2026-05-01 10:08:04 -07:00
jerch 4dc934ce7c Merge branch 'master' into qoi_decoder 2026-04-29 15:14:29 +02:00
Jörg Breitbart b2eff5ac67 merge resize tests for PNG & QOI into one block 2026-04-29 15:09:40 +02:00
Jörg Breitbart 31c53bc9c2 IIP resizing tests for QOI 2026-04-29 14:54:57 +02:00
Jörg Breitbart 0d2975884f activate tests for webkit 2026-04-29 14:40:31 +02:00
Jörg Breitbart e8b5fd3c23 add iip resize & qoi tests 2026-04-29 14:28:02 +02:00
Neil 7abcca7e73 Add Orca to real-world uses 2026-04-28 23:57:12 -07:00
Jörg Breitbart 92339ac9dd fix #5849 2026-04-28 21:45:50 +02:00
jerch ad4a6e8cb9 Merge pull request #5802 from xtermjs/dependabot/npm_and_yarn/axios-1.15.0
Bump axios from 1.13.2 to 1.15.0
2026-04-28 21:37:24 +02:00
jerch a094340afe Merge branch 'master' into dependabot/npm_and_yarn/axios-1.15.0 2026-04-28 21:32:51 +02:00
jerch cc7e3bbbf7 Merge pull request #5855 from xtermjs/dependabot/npm_and_yarn/follow-redirects-1.16.0
Bump follow-redirects from 1.15.11 to 1.16.0
2026-04-28 21:31:52 +02:00
jerch 73d87b2c20 Merge branch 'master' into fix_apc 2026-04-28 21:26:18 +02:00
dependabot[bot] c5bf395b10 Bump follow-redirects from 1.15.11 to 1.16.0
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 19:25:56 +00:00
jerch 9d24099aa1 Merge pull request #5852 from xtermjs/fix_ci_tests
fix CI addon tests
2026-04-28 21:24:47 +02:00
Jörg Breitbart 066c227436 disable firefox webgl 2026-04-28 21:10:52 +02:00
Jörg Breitbart 32ad3a18bf disable wrong webgl tests 2026-04-28 20:42:38 +02:00
Jörg Breitbart d43b19c260 fix safari 2026-04-28 19:54:49 +02:00
Jörg Breitbart 7dd0dac859 fix safari 2026-04-28 19:49:21 +02:00
Jörg Breitbart 7e70d6ad78 fix safari 2026-04-28 19:45:06 +02:00
Jörg Breitbart 893cdd494a remove broken test condition 2026-04-28 19:36:10 +02:00
Jörg Breitbart 2794e32b3f newer safari promotes different loading state 2026-04-28 19:31:05 +02:00
Jörg Breitbart 7d69e730ca fix web-fonts tests 2026-04-28 19:24:18 +02:00
Jörg Breitbart da51d9608c add missing separator for test arguments 2026-04-28 19:07:35 +02:00
Daniel Imms 3a84bcb7ae Merge pull request #5766 from SergioChan/fix-image-renderer-desync-5691
fix(addon-image): disable desynchronized resize canvas
2026-04-26 16:17:03 -07:00
Jörg Breitbart d9082215e7 qoi support for IIP 2026-04-26 23:28:18 +02:00
Jörg Breitbart 41938b307e adjust image addon defaults 2026-04-26 16:44:22 +02:00
Jörg Breitbart 785c3cb336 fix vt feature listing 2026-04-26 14:43:03 +02:00
Jörg Breitbart ea7e958959 fix vt feature listing 2026-04-26 14:41:36 +02:00
Jörg Breitbart 915b0b81a1 fix vt feature listing 2026-04-26 14:37:30 +02:00
Jörg Breitbart 4e9abe7d96 fix vt feature listing 2026-04-26 14:34:42 +02:00
Jörg Breitbart 84aca0b912 fix ApcParser tests 2026-04-25 15:46:33 +02:00
Jörg Breitbart d82d42ef9a APC async tests 2026-04-25 15:12:45 +02:00
Jörg Breitbart 4d2f2e0d8e handler registration tests, fix mulitple immediates 2026-04-25 14:39:22 +02:00
Jörg Breitbart 56986cb7eb register APC fallback handler 2026-04-25 11:10:24 +02:00
Jörg Breitbart 4946436997 make linter happy 2026-04-24 17:35:56 +02:00
Jörg Breitbart bf83ea06e4 better read ahead condition 2026-04-24 17:29:49 +02:00
Jörg Breitbart b7e535e2ec APC perf test 2026-04-24 17:09:37 +02:00
Jörg Breitbart 8e12e69e4e basic read ahead optimization 2026-04-24 16:49:06 +02:00
Jörg Breitbart 849a9a53ca add state and transition tests 2026-04-24 16:19:02 +02:00
Jörg Breitbart ee7d84bdd3 make linter happy 2026-04-24 14:19:55 +02:00
Jörg Breitbart 9c8de633e9 multiple changes in APC handling:
- change interface to IFunctionIdentifier (API change)
- extend parser states APC_ENTRY, APC_INTERMEDIATE, APC_PASSTHROUGH
- remove APC_STRING
- change ApcParser.start to retrieve ident
- change actions APC_START and APC_PUT
2026-04-24 13:53:31 +02:00
jerch 9c5420371b Merge pull request #5838 from xtermjs/fix_terminal_benchmark
fix utf8 terminal benchmark
2026-04-24 09:24:19 +02:00
Jörg Breitbart 22778b5b49 fix utf8 terminal benchmark 2026-04-24 09:15:26 +02:00
jerch cbe59a17ae Merge pull request #5830 from homanp/perf/array-exe-handlers
perf: array-indexed execute handler lookup in hot path
2026-04-23 18:59:47 +02:00
jerch 76671cecb6 Merge branch 'master' into perf/array-exe-handlers 2026-04-23 18:50:41 +02:00
jerch a11fcd2e0b remove type hint 2026-04-23 18:50:03 +02:00
Ismail Pelaseyed b28b5d0c2b perf: use array-indexed lookup for execute handlers in hot path
Add a parallel array container for execute handlers (codes < 0x18)
alongside the existing object. Setters populate both, the EXE
fast-path reads from the array with nullish coalescing to avoid
branching. ExecuteHandlerType updated to accept optional ident param
to allow the ?? pattern with the fallback handler.
2026-04-23 18:19:10 +02:00
Sridhar Ratnakumar 685434ca84 Fix IntersectionObserver retention on dispose 2026-04-23 11:35:56 -04:00
jerch 7fe89a1c1a Merge pull request #5829 from homanp/perf/reset-zdm
refactor: add Params.resetZdm() to avoid double call on foreign object
2026-04-23 17:05:15 +02:00
Ismail Pelaseyed eaebe2ab15 refactor: add Params.resetZdm() to collapse reset+addParam(0) into single call
Replaces 10 separate reset()+addParam(0) pairs across the parser with
a single resetZdm() method that inlines both operations, avoiding two
calls to a foreign object per sequence reset.
2026-04-23 16:55:46 +02:00
jerch 32553b4135 Merge pull request #5825 from homanp/perf/csi-fast-path
perf: CSI fast-path (+42% throughput)
2026-04-23 16:16:19 +02:00
Ismail Pelaseyed 6c2465d745 docs: note EXE and CSI fast paths in parse method docs 2026-04-23 15:59:27 +02:00
jerch 5922123b9e Merge branch 'master' into perf/csi-fast-path 2026-04-23 15:53:21 +02:00
Ismail Pelaseyed eb65910c1b perf: simplify CSI fast-path - drop intermediate handling and extra length checks
Remove intermediate byte handling from the fast-path, bail to table
lookup instead. Remove redundant per-step length checks by requiring
i + 2 < length upfront and letting the for-loop guard the rest.
2026-04-23 15:35:06 +02:00
jerch f0aeb70c92 make linter happy 2026-04-23 14:39:26 +02:00
jerch 9deec7aabf make linter happy 2026-04-23 14:36:51 +02:00
Ismail Pelaseyed 18cd9858ed perf: collapse CSI state guard into single range check
Replace three !== checks (OSC_STRING, DCS_PASSTHROUGH, APC_STRING)
with one < OSC_STRING comparison. States below OSC_STRING are all
safe for the CSI fast-path; states above need the normal table path
for proper subparser abort propagation.
2026-04-23 14:09:47 +02:00
Ismail Pelaseyed 6fd7ecc812 perf: move CSI fast-path to top of main loop, add EXE fast-path, improve PRINT unrolling
Address reviewer feedback:
- CSI fast-path is now a condition at the top of the codepoint loop
  instead of inside CLEAR, avoiding table lookups for the common case
- EXE bytes < 0x18 get a fast-path before the CSI check so control
  bytes don't pay the penalty
- PRINT loop unrolling replaced with while-based version that uses
  trueish eval and spares individual length checks in the unrolled case
- Removed misplaced PRINT scan-ahead from CSI dispatch
2026-04-23 14:01:19 +02:00
lawrence3699 ff72c00219 fix(addon-ligatures): run package build on publish 2026-04-22 12:57:27 +10:00
Daniel Imms 0911688295 Merge pull request #5817 from juspay/fix/dispose-leaks
Fix dispose leaks: register CursorBlinkStateManager + _pausedResizeTask
2026-04-21 12:57:24 -07:00
Daniel Imms 62ce318a07 Merge pull request #5827 from Tyriar/or_lifecycle
Improve overview ruler lifecycle
2026-04-21 11:22:09 -07:00
Daniel Imms 08ae141ef7 Improve overview ruler lifecycle
Fixes #5826
2026-04-21 11:13:59 -07:00
Ismail PelaseyedandClaude Opus 4.6 ba7e775a17 fix: move CSI fast-path into CLEAR action to eliminate non-CSI regression
The CSI fast-path check previously ran on every byte of input, causing
3-18% regressions on ESCAPE, OSC, DCS, and other non-CSI sequences.
Moving the check into the CLEAR action handler means it only evaluates
after the state machine already identified an ESC byte. The fast-path
body is extracted into a separate _csiFast method to keep the switch
case body small for V8 optimization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:19:32 +02:00
Ismail Pelaseyed 487b27f512 try: scan print run after CSI fast-path dispatch 2026-04-21 09:26:07 +02:00
Ismail Pelaseyed 5d120de3d5 try: CSI fast-path - scan entire ESC[params;final without table lookups 2026-04-21 08:50:49 +02:00
slim-forge 3ab299af7f slim-forge: baseline 2026-04-21 08:38:43 +02:00
slim-forge 6e375cb588 slim-forge: initial harness 2026-04-21 08:38:27 +02:00
Sridhar Ratnakumar 7a6e724bfe Fix dispose leaks: register CursorBlinkStateManager + _pausedResizeTask
Three related disposable-registration gaps that leak xterm Terminal
instances when the host application unmounts a Terminal. Evidence +
full writeup at juspay/kolu#606.

1. addon-webgl/WebglRenderer._cursorBlinkStateManager was declared
   `= new MutableDisposable()` without the `this._register(...)`
   wrapper that every sibling MutableDisposable in the class uses.
   CursorBlinkStateManager runs a setInterval for the cursor blink
   and its `dispose()` correctly clears it — but dispose() was never
   called because the disposable wasn't registered. The live timer
   kept the renderer (and thus the Terminal) alive past host
   unmount.

2. common/TaskQueue.DebouncedIdleTask had no `dispose()` method.
   Added one that clears the internal IdleTaskQueue (cancelling any
   pending idle callback).

3. browser/services/RenderService._pausedResizeTask (a
   DebouncedIdleTask) was not registered. Wrapped with
   `this._register(...)` so RenderService.dispose propagates.

Verified in a downstream app (kolu) with a repro that toggles Focus
vs Canvas layout modes:

- Pre-fix: 24/28 disposed xterm Terminals retained past host unmount
  + forced GC.
- Post-fix: 0/28 retained.

Retention chain (before fix):

    DOMTimer → ScheduledAction → V8Function
      → CursorBlinkStateManager._renderCallback → WebglRenderer
      → Terminal
2026-04-17 13:32:01 -04:00
Loïc Mangeonjean 69ee4ad23b fix: use element window instead of global object 2026-04-13 12:51:17 +02:00
dependabot[bot] 4b77e601cd Bump axios from 1.13.2 to 1.15.0
Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 08:49:48 +00:00
Daniel Imms 5e9b2cbc14 Update ImageRenderer.ts 2026-03-12 09:47:51 +09:00
sergiochan 3a59c8ec2b Fix image layer resize jitter
Disable desynchronized 2D canvas mode for image layers so resize paint stays synchronized and avoids blank-frame jitter on some platforms.

Signed-off-by: sergiochan <yuheng9211@qq.com>
2026-03-11 00:36:17 -07:00
116 changed files with 4260 additions and 2160 deletions
@@ -1,7 +1,11 @@
---
applyTo: '**/*.benchmark.ts'
name: benchmark
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
---
# Benchmark run instructions
# Benchmark Run Instructions
## Running Benchmarks
- Full suite: `npm run benchmark`
- Single benchmark file:
@@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts'
- Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Benchmark Case Selection
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
Notes:
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script).
@@ -1,7 +1,11 @@
---
applyTo: '**/*.test.ts'
name: unit-test
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
---
When writing unit tests follow these rules:
# Unit Test Instructions
When writing unit tests, follow these rules:
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
-10
View File
@@ -1,10 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "npm run agent:setup-repo"
}
]
}
}
+13 -13
View File
@@ -207,31 +207,31 @@ jobs:
- name: Build demo server
run: npm run esbuild-demo-server
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
- name: Integration tests (addon-attach)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
- name: Integration tests (addon-clipboard)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
- name: Integration tests (addon-fit)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
- name: Integration tests (addon-serialize)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
- name: Integration tests (addon-unicode-graphemes)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
- name: Integration tests (addon-web-fonts)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
release-dry-run:
needs: build
+4
View File
@@ -22,6 +22,10 @@ build/
yarn.lock
test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git
dist/
demo/dist/
@@ -57,6 +57,13 @@ export class MyAddon implements ITerminalAddon {
- Proposed APIs require `allowProposedApi: true` option
- Constructor-only options (cols, rows) cannot be changed after instantiation
**Disposable Management**:
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
**TypeScript Constants**:
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
**Testing Utilities**: Use `TestUtils.ts` helpers:
- `openTerminal(ctx, options)` for setup
- `pollFor(page, fn, expectedValue)` for async assertions
+2
View File
@@ -252,6 +252,8 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
-3
View File
@@ -21,8 +21,5 @@
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"dependencies": {
"js-base64": "^3.7.5"
}
}
+10 -18
View File
@@ -4,8 +4,7 @@
*/
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
import { Base64 as JSBase64 } from 'js-base64';
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
export class ClipboardAddon implements ITerminalAddon {
private _terminal?: Terminal;
@@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose();
}
private _readText(sel: ClipboardSelectionType, data: string): void {
private _readText(sel: string, data: string): void {
const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
}
@@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true;
}
const pc = args[0] as ClipboardSelectionType;
const pc = args[0];
const pd = args[1];
if (pd === '?') {
const text = this._provider.readText(pc);
@@ -70,30 +69,23 @@ export class ClipboardAddon implements ITerminalAddon {
}
export class BrowserClipboardProvider implements IClipboardProvider {
public async readText(selection: ClipboardSelectionType): Promise<string> {
if (selection !== 'c') {
return Promise.resolve('');
}
public readText(selection: string): Promise<string> {
return navigator.clipboard.readText();
}
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
if (selection !== 'c') {
return Promise.resolve();
}
public writeText(selection: string, text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
return JSBase64.encode(data);
return btoa(data);
}
public decodeText(data: string): string {
const text = JSBase64.decode(data);
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return '';
}
return text;
try {
return atob(data);
} catch {}
return '';
}
}
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test doesn't work since the migration to @playwright/test
if (ctx.browser.browserType().name() !== 'chromium') {
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
@@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('primary selection', async () => {
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('empty selection (default)', async () => {
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('invalid base64 string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
@@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => {
});
test.describe('read data', async function (): Promise<any> {
test('simple string', async () => {
test.beforeAll(async () => {
await ctx.page.evaluate(`
window.data = [];
window.term.onData(e => data.push(e));
`);
});
test('simple string', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
});
test('primary selection', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;p;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
});
test('clear clipboard', async () => {
await ctx.proxy.write(`\x1b]52;c;!\x07`);
await ctx.proxy.write(`\x1b]52;c;?\x07`);
+12 -21
View File
@@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' {
public dispose(): void
}
/**
* Clipboard selection type. This is used to specify which selection buffer to
* read or write to.
* - SYSTEM `c`: The system clipboard.
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
* with Linux X11.
*/
export const enum ClipboardSelectionType {
SYSTEM = 'c',
PRIMARY = 'p',
}
export interface IBase64 {
/**
* Converts a utf-8 string to a base64 string.
@@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider {
/**
* Gets the clipboard content.
* @param selection The clipboard selection to read.
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
* @returns A promise that resolves with clipboard selection data.
*/
readText(selection: ClipboardSelectionType): string | Promise<string>;
readText(selection: string): string | Promise<string>;
/**
* Sets the clipboard content.
* @param selection The clipboard selection to set.
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
* @param data The clipboard text to write.
*/
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
writeText(selection: string, text: string): void | Promise<void>;
}
/**
* The clipboard provider interface that enables xterm.js to access the system clipboard.
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
*
* Note this this clipboard provider does not implement own cut buffers as xterm does,
* but redirect the selection parameter always to navigator.clipboard.
*/
export class BrowserClipboardProvider implements IClipboardProvider{
/**
* Reads text from the clipboard.
* @param selection The selection type to read from.
* @param selection The selection type to read from (always refers to navigator.clipboard).
* @returns A promise that resolves with the text from the clipboard.
*/
public readText(selection: ClipboardSelectionType): Promise<string>;
public readText(selection: string): Promise<string>;
/**
* Writes text to the clipboard.
* @param selection The selection type to write to.
* @param selection The selection type to write to (always refers to navigator.clipboard).
* @param data The text to write to the clipboard.
* @returns A promise that resolves when the text has been written to the clipboard.
*/
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
public writeText(selection: string, data: string): Promise<void>;
}
}
+6 -4
View File
@@ -19,8 +19,10 @@ interface ITerminalDimensions {
cols: number;
}
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
const enum Constants {
MINIMUM_COLS = 2,
MINIMUM_ROWS = 1
}
function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) {
@@ -86,8 +88,8 @@ export class FitAddon implements ITerminalAddon, IFitApi {
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
return geometry;
}
+4 -4
View File
@@ -25,14 +25,14 @@ const customSettings: IImageAddonOptions = {
pixelLimit: 16777216, // max. pixel size of a single image
sixelSupport: true, // enable sixel support
sixelScrolling: true, // whether to scroll on image output
sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
sixelPaletteLimit: 4096, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support
iipSizeLimit: 20000000, // size limit of a single IIP sequence
iipSizeLimit: 33554432, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
}
// initialization
+1 -1
View File
@@ -25,6 +25,6 @@
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.3.0"
"xterm-wasm-parts": "^0.4.1"
}
}
+23 -20
View File
@@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
@@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _qoiDec: QoiDecoder;
private _metrics = UNSUPPORTED_TYPE;
constructor(
@@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
}
public reset(): void {}
@@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
this._dec.release();
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<boolean>(r => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, w, h);
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
this._storage.addImage(canvas);
r(true);
});
img.src = url;
// sanity measure to avoid terminal blocking from dangling promise
// happens from corrupt data (onload never gets fired)
setTimeout(() => r(true), 1000);
});
let blob: Blob | ImageData;
if (this._metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
this._qoiDec.height
);
this._qoiDec.release();
if (w === this._qoiDec.width && h === this._qoiDec.height) {
// use fast-path if we don't need to rescale
this._dec.release();
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
canvas.getContext('2d')?.putImageData(blob, 0, 0);
this._storage.addImage(canvas);
return true;
}
} else {
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
}
this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
this._storage.addImage(bm);
+9 -1
View File
@@ -4,7 +4,7 @@
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
@@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics {
height: d[9] << 8 | d[8]
};
}
// QOI: qoif
if (d32[0] === 0x66696F71) {
return {
mime: 'image/qoi',
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
};
}
return UNSUPPORTED_TYPE;
}
+46 -5
View File
@@ -16,20 +16,61 @@ import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
/**
* Document VT features provided by this addon.
*
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
*
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
* - max. palette size of 4096 colors
* - max. pixel width of 16K
* - max. 25 MB per sixel sequence
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
*
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
* the following limitations:
* - sequence:
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
* no separator bytes allowed)
* - supported params: size, name, width, height, preserveAspectRatio
* - image formats: PNG, JPEG and GIF
* - no animation support (renders first image of a GIF)
* - no multipart support
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
*
* Kitty graphics support is provided by the addon @xterm/addon-image.
* Note that while basic image output already works, this is still work in progress.
*/
// default values of addon ctor options
const DEFAULT_OPTIONS: IImageAddonOptions = {
enableSizeReports: true,
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 256,
sixelSizeLimit: 25000000,
sixelPaletteLimit: 4096,
sixelSizeLimit: 33554432,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 20000000
kittySizeLimit: 33554432
};
// max palette size supported by the sixel lib (compile time setting)
@@ -166,7 +207,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
this._disposeLater(
kittyStorage,
kittyHandler,
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
}
}
+9 -7
View File
@@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const PLACEHOLDER_LENGTH = 4096;
const PLACEHOLDER_HEIGHT = 24;
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
/**
* ImageRenderer - terminal frontend extension:
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
public showPlaceholder(value: boolean): void {
if (value) {
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
@@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
@@ -358,7 +360,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
}
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
return;
@@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
return this._layers.has(layer);
}
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
@@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
+1 -1
View File
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const height = this._dec.height;
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
if (!width || ! height) {
if (!width || !height) {
if (height) {
this._storage.advanceCursor(height);
}
@@ -16,24 +16,23 @@ import {
IKittyCommand,
IPendingTransmission,
IKittyImageData,
BYTES_PER_PIXEL_RGB,
BYTES_PER_PIXEL_RGBA,
ALPHA_OPAQUE,
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
// Memory limit for base64 decoder (4MB, same as IIPHandler)
const DECODER_KEEP_DATA = 4194304;
const DECODER_INITIAL_DATA = 4194304; // 4MB
const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler)
DECODER_KEEP_DATA = 4194304,
DECODER_INITIAL_DATA = 4194304, // 4MB
// Local mirror of const enum (esbuild can't inline const enums from external packages)
DECODER_OK = 0,
// Maximum control data size
MAX_CONTROL_DATA_SIZE = 512,
// Semicolon codepoint
SEMICOLON = 0x3B
}
// Local mirror of const enum (esbuild can't inline const enums from external packages)
const DECODER_OK: DecodeStatus.OK = 0;
// Maximum control data size
const MAX_CONTROL_DATA_SIZE = 512;
// Semicolon codepoint
const SEMICOLON = 0x3B;
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
@@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private _inControlData = true;
// Buffer for control data.
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
private _controlLength = 0;
// Pre-calculated encoded size limit
@@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Convert decoded size limit -> max encoded bytes.
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
// ensure we preallocate more than configured limit while using 4mb initial size.
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
}
public reset(): void {
@@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Scan for semicolon
let controlEnd = end;
for (let i = start; i < end; i++) {
if (data[i] === SEMICOLON) {
if (data[i] === Constants.SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
@@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
this._aborted = true;
return;
}
@@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
@@ -484,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
@@ -534,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
const isOk = message === 'OK';
if (isOk && quiet === 1) return;
if (!isOk && quiet === 2) return;
if (isOk && quiet >= 1) return;
if (!isOk && quiet >= 2) return;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
@@ -723,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
throw new Error('Width and height required for raw pixel data');
}
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
@@ -734,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
if (image.format === KittyFormat.RGBA) {
// RGBA: use bytes directly — no copy needed
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height));
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
}
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
// Assumes little-endian (all modern browsers/Node.js).
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
const dst32 = new Uint32Array(data.buffer);
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
@@ -759,15 +758,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
// Handle remaining 1–3 pixels
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
for (let i = alignedPixels; i < pixelCount; i++) {
data[dstByte] = bytes[srcByte];
data[dstByte + 1] = bytes[srcByte + 1];
data[dstByte + 2] = bytes[srcByte + 2];
data[dstByte + 3] = ALPHA_OPAQUE;
srcByte += BYTES_PER_PIXEL_RGB;
dstByte += BYTES_PER_PIXEL_RGBA;
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
@@ -82,9 +82,11 @@ export const enum KittyKey {
}
// Pixel format constants
export const BYTES_PER_PIXEL_RGB = 3;
export const BYTES_PER_PIXEL_RGBA = 4;
export const ALPHA_OPAQUE = 255;
export const enum KittyPixelConstants {
BYTES_PER_PIXEL_RGB = 3,
BYTES_PER_PIXEL_RGBA = 4,
ALPHA_OPAQUE = 255
}
// Parsed Kitty graphics command.
export interface IKittyCommand {
+74 -8
View File
@@ -8,6 +8,7 @@ import { readFileSync } from 'fs';
import { FINALIZER, introducer, sixelEncode } from 'sixel';
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
import { deepStrictEqual, ok, strictEqual } from 'assert';
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
/**
* Plugin ctor options.
@@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
];
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
const qoiEnc = new QoiEncoder(1024*1024);
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
@@ -85,11 +90,6 @@ test.afterAll(async () => await ctx.page.close());
test.describe('ImageAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(`
window.term.reset()
window.imageAddon?.dispose();
@@ -132,13 +132,13 @@ test.describe('ImageAddon', () => {
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 25000000,
sixelSizeLimit: 33554432,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 20000000
kittySizeLimit: 33554432
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -312,6 +312,68 @@ test.describe('ImageAddon', () => {
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
});
});
test.describe('IIP - QOI support', () => {
test('palette should yield same bytes from PNG and QOI', async () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape);
});
});
test.describe('IIP - resizing', () => {
/**
* The correct resize behavior is wonky and needs to be tested against iTerm2,
* especially for missing params to derive correct default behavior.
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
* ref: https://iterm2.com/documentation-images.html
* imgcat: https://iterm2.com/utilities/imgcat
*
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
*/
const images = [
['palette.png', 525, PALETTE_PNG_BASE64],
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
];
for (const [name, size, payload] of images) {
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
// cell based resize
const header = 'width=20;height=5;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
});
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
const header = 'width=320px;height=160px;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
deepStrictEqual(await getOrigSize(1), [320, 160]);
});
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
const header = 'width=50%;height=30%;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
});
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
const header = 'width=50%';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
const width = Math.floor(dim.width * 0.5);
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel
const header2 = 'height=200px';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
});
/**
@@ -345,3 +407,7 @@ async function getOrigSize(id: number): Promise<[number, number]> {
window.imageAddon._storage._images.get(${id}).orig.height
]`);
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
+52 -5
View File
@@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => {
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(`
window.term.reset()
window.imageAddon?.dispose();
@@ -622,6 +617,18 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses OK response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses error response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
@@ -802,6 +809,30 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=t OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Transmission medium rejection', () => {
@@ -1151,6 +1182,22 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await getImageStorageLength(), 1);
});
test('OK response suppressed by q=2', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
strictEqual(await getImageStorageLength(), 1);
});
test('ENOENT error suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
+2 -2
View File
@@ -20,7 +20,7 @@
"package": "webpack",
"pretest": "npm run build",
"test": "nyc mocha out/**/*.test.js",
"prepublish": "npm run package"
"prepublishOnly": "npm run package"
},
"keywords": [
"font",
@@ -37,7 +37,7 @@
"devDependencies": {
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.6.0",
"axios": "^1.15.2",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^3.2.1"
@@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
ctx.page.setViewportSize({ width: 1024, height: 768 });
await openTerminal(ctx);
});
test.afterAll(async () => await ctx.page.close());
test.describe('ProgressAddon', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
await openTerminal(ctx);
});
test.afterAll(async () => {
await ctx?.page?.close();
});
test.beforeEach(async function(): Promise<any> {
await ctx.page.evaluate(`
window.progressStack = [];
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
for (let i = 0; i < buffer.length; i++) {
// Do this intentionally to get content of underlining source
const bufferLine = buffer.getLine(i)._line;
lines.push(JSON.stringify(bufferLine));
lines.push(JSON.stringify(bufferLine, (key, value) => {
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
}
return {
x: buffer.cursorX,
@@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => {
test.describe('font loading at runtime', () => {
test('loadFonts (JS)', async () => {
await ctx.page.evaluate(`
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
loadFonts([ff1, ff2]);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
});
test('loadFonts (CSS, unquoted)', async () => {
await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
loadFonts(['Kongtext', 'BPdots']);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
@@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => {
test('loadFonts (CSS, quoted)', async ({ browser }) => {
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
loadFonts(['Kongtext', 'BPdots']);
`);
if (browser.browserType().name() === 'firefox') {
@@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => {
test('FontFace hashing', async () => {
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
await ctx.page.evaluate(`
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
loadFonts([ff1, ff2]);
loadFonts([ff1, ff2]);
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
@@ -76,7 +76,7 @@ test.describe('WebFontsAddon', () => {
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
const data = await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
@@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => {
// safari loads the font, firefox & chrome dont
if (browser.browserType().name() === 'webkit') {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
try {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
} catch (e) {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
} else {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
}
@@ -103,7 +107,10 @@ test.describe('WebFontsAddon', () => {
window.helperTerm.open(term.element);
`);
await timeout(100);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
if (browser.browserType().name() !== 'webkit') {
// safari sometimes reports 'unloadeed'?
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
// cleanup this messy test case
await ctx.page.evaluate(`
@@ -0,0 +1,87 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { configEquals } from './CharAtlasUtils';
import { ICharAtlasConfig } from './Types';
import { NULL_COLOR } from 'common/Color';
import { IColor } from 'common/Types';
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
const contrastCache = {
clear: () => {},
setCss: () => {},
getCss: () => undefined,
setColor: () => {},
getColor: () => undefined
};
const colors = {
foreground: color,
background: color,
cursor: NULL_COLOR,
cursorAccent: NULL_COLOR,
selectionForeground: undefined,
selectionBackgroundTransparent: NULL_COLOR,
selectionBackgroundOpaque: NULL_COLOR,
selectionInactiveBackgroundTransparent: NULL_COLOR,
selectionInactiveBackgroundOpaque: NULL_COLOR,
overviewRulerBorder: NULL_COLOR,
scrollbarSliderBackground: NULL_COLOR,
scrollbarSliderHoverBackground: NULL_COLOR,
scrollbarSliderActiveBackground: NULL_COLOR,
ansi: new Array(256).fill(color),
contrastCache,
halfContrastCache: contrastCache
};
return {
customGlyphs: true,
devicePixelRatio: 1,
deviceMaxTextureSize: 4096,
letterSpacing: 0,
lineHeight: 1,
fontSize: 15,
fontFamily: 'monospace',
fontWeight: 'normal',
fontWeightBold: 'bold',
deviceCellWidth: 10,
deviceCellHeight: 20,
deviceCharWidth: 8,
deviceCharHeight: 16,
allowTransparency: false,
drawBoldTextInBrightColors: true,
minimumContrastRatio: 1,
colors,
...overrides
};
}
describe('CharAtlasUtils', () => {
describe('configEquals', () => {
it('should return true for identical configs', () => {
const a = createTestConfig();
const b = createTestConfig();
assert.ok(configEquals(a, b));
});
it('should return false when deviceMaxTextureSize differs', () => {
const a = createTestConfig();
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
assert.ok(!configEquals(a, b));
});
it('should return false when deviceCellWidth differs', () => {
const a = createTestConfig();
const b = createTestConfig({ deviceCellWidth: 11 });
assert.ok(!configEquals(a, b));
});
it('should return false when deviceCellHeight differs', () => {
const a = createTestConfig();
const b = createTestConfig({ deviceCellHeight: 21 });
assert.ok(!configEquals(a, b));
});
});
});
+3
View File
@@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
}
}
return a.devicePixelRatio === b.devicePixelRatio &&
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
a.customGlyphs === b.customGlyphs &&
a.lineHeight === b.lineHeight &&
a.letterSpacing === b.letterSpacing &&
@@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
a.fontWeight === b.fontWeight &&
a.fontWeightBold === b.fontWeightBold &&
a.allowTransparency === b.allowTransparency &&
a.deviceCellWidth === b.deviceCellWidth &&
a.deviceCellHeight === b.deviceCellHeight &&
a.deviceCharWidth === b.deviceCharWidth &&
a.deviceCharHeight === b.deviceCharHeight &&
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
+32 -22
View File
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
import { Terminal } from '@xterm/xterm';
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
import type { IOptionsService } from 'common/services/Services';
import type { ILogService, IOptionsService } from 'common/services/Services';
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
interface IVertices {
@@ -78,9 +78,11 @@ void main() {
}`);
}
const INDICES_PER_CELL = 11;
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
const CELL_POSITION_INDICES = 2;
const enum Constants {
INDICES_PER_CELL = 11,
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
CELL_POSITION_INDICES = 2
}
// Work variables to avoid garbage collection
let $i = 0;
@@ -112,7 +114,8 @@ export class GlyphRenderer extends Disposable {
private readonly _terminal: Terminal,
private readonly _gl: IWebGL2RenderingContext,
private _dimensions: IRenderDimensions,
private readonly _optionsService: IOptionsService
private readonly _optionsService: IOptionsService,
private readonly _logService: ILogService
) {
super();
@@ -125,7 +128,7 @@ export class GlyphRenderer extends Disposable {
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
}
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
this._register(toDisposable(() => gl.deleteProgram(this._program)));
// Uniform locations
@@ -134,8 +137,9 @@ export class GlyphRenderer extends Disposable {
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
// Create and set the vertex array object
this._vertexArrayObject = gl.createVertexArray();
gl.bindVertexArray(this._vertexArrayObject);
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
gl.bindVertexArray(vertexArrayObject);
// Setup a_unitquad, this defines the 4 vertices of a rectangle
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
@@ -160,22 +164,22 @@ export class GlyphRenderer extends Disposable {
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0);
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
// Setup static uniforms
@@ -222,12 +226,12 @@ export class GlyphRenderer extends Disposable {
}
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
$i = (y * this._terminal.cols + x) * INDICES_PER_CELL;
$i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL;
// Exit early if this is a null character, allow space character to continue as it may have
// underline/strikethrough styles
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
return;
}
@@ -288,7 +292,7 @@ export class GlyphRenderer extends Disposable {
public clear(): void {
const terminal = this._terminal;
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
// Clear vertices
if (this._vertices.count !== newCount) {
@@ -310,7 +314,7 @@ export class GlyphRenderer extends Disposable {
for (let x = 0; x < terminal.cols; x++) {
this._vertices.attributes[i + 9] = x / terminal.cols;
this._vertices.attributes[i + 10] = y / terminal.rows;
i += INDICES_PER_CELL;
i += Constants.INDICES_PER_CELL;
}
}
}
@@ -346,8 +350,8 @@ export class GlyphRenderer extends Disposable {
// - So we don't send vertices for all the line-ending whitespace to the GPU
let bufferLength = 0;
for (let y = 0; y < renderModel.lineLengths.length; y++) {
const si = y * this._terminal.cols * INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
const si = y * this._terminal.cols * Constants.INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
activeBuffer.set(sub, bufferLength);
bufferLength += sub.length;
}
@@ -356,7 +360,9 @@ export class GlyphRenderer extends Disposable {
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
// Bind the atlas page texture if they have changed
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
for (let i = 0; i < this._atlas.pages.length; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
@@ -364,11 +370,15 @@ export class GlyphRenderer extends Disposable {
}
// Draw the viewport
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
}
public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas;
this.invalidateAtlasTextures();
}
public invalidateAtlasTextures(): void {
for (const glTexture of this._atlasTextures) {
glTexture.version = -1;
}
+14 -10
View File
@@ -10,10 +10,11 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IColor } from 'common/Types';
import { Terminal } from '@xterm/xterm';
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
import { RenderModelConstants } from './RenderModel';
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
import type { ILogService } from 'common/services/Services';
const enum VertexAttribLocations {
POSITION = 0,
@@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable {
private _terminal: Terminal,
private _gl: IWebGL2RenderingContext,
private _dimensions: IRenderDimensions,
private readonly _themeService: IThemeService
private readonly _themeService: IThemeService,
private readonly _logService: ILogService
) {
super();
const gl = this._gl;
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
this._register(toDisposable(() => gl.deleteProgram(this._program)));
// Uniform locations
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
// Create and set the vertex array object
this._vertexArrayObject = gl.createVertexArray();
gl.bindVertexArray(this._vertexArrayObject);
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
gl.bindVertexArray(vertexArrayObject);
// Setup a_unitquad, this defines the 4 vertices of a rectangle
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
@@ -217,9 +220,9 @@ export class RectangleRenderer extends Disposable {
currentFg = 0;
currentInverse = false;
for (x = 0; x < terminal.cols; x++) {
modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET];
fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET];
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
inverse = !!(fg & FgFlags.INVERSE);
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
// A rectangle needs to be drawn if going from non-default to another color
@@ -336,8 +339,9 @@ export class RectangleRenderer extends Disposable {
}
}
if (vertices.attributes.length < offset + 4) {
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
// +1 for the viewport-clear rectangle at offset 0.
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
}
$x1 = startX * this._dimensions.device.cell.width;
$y1 = y * this._dimensions.device.cell.height;
+7 -5
View File
@@ -7,10 +7,12 @@ import { ICursorRenderModel, IRenderModel } from './Types';
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
export const RENDER_MODEL_INDICIES_PER_CELL = 4;
export const RENDER_MODEL_BG_OFFSET = 1;
export const RENDER_MODEL_FG_OFFSET = 2;
export const RENDER_MODEL_EXT_OFFSET = 3;
export const enum RenderModelConstants {
INDICIES_PER_CELL = 4,
BG_OFFSET = 1,
FG_OFFSET = 2,
EXT_OFFSET = 3
}
export const COMBINED_CHAR_BIT_MASK = 0x80000000;
@@ -27,7 +29,7 @@ export class RenderModel implements IRenderModel {
}
public resize(cols: number, rows: number): void {
const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL;
const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL;
if (indexCount !== this.cells.length) {
this.cells = new Uint32Array(indexCount);
this.lineLengths = new Uint32Array(rows);
+15 -8
View File
@@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas {
page.canvas.remove();
}
this._onAddTextureAtlasCanvas.dispose();
this._onRemoveTextureAtlasCanvas.dispose();
}
public warmUp(): void {
@@ -133,7 +134,9 @@ export class TextureAtlas implements ITextureAtlas {
private _requestClearModel = false;
public beginFrame(): boolean {
return this._requestClearModel;
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
}
public clearTexture(): void {
@@ -193,7 +196,7 @@ export class TextureAtlas implements ITextureAtlas {
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version++;
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
@@ -251,7 +254,7 @@ export class TextureAtlas implements ITextureAtlas {
for (const g of adjustingPage.glyphs) {
g.texturePage--;
}
adjustingPage.version++;
adjustingPage.version = ++AtlasPage.nextVersion;
}
}
@@ -527,7 +530,7 @@ export class TextureAtlas implements ITextureAtlas {
let customGlyph = false;
if (this._config.customGlyphs !== false) {
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
}
// Whether to clear pixels based on a threshold difference between the glyph color and the
@@ -937,7 +940,7 @@ export class TextureAtlas implements ITextureAtlas {
rasterizedGlyph.size.y
);
activePage.addGlyph(rasterizedGlyph);
activePage.version++;
activePage.version = ++AtlasPage.nextVersion;
return rasterizedGlyph;
}
@@ -1047,9 +1050,13 @@ class AtlasPage {
}
/**
* Used to check whether the canvas of the atlas page has changed.
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
* unit at a given index needs to be re-uploaded — both for content changes within the same
* page and for a page object swap at the same index (which happens after a page merge,
* where a per-page counter could coincide with the previously-bound page's value).
*/
public version = 0;
public static nextVersion: number = 0;
public version = ++AtlasPage.nextVersion;
// Texture atlas current positioning data. The texture packing strategy used is to fill from
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
@@ -1092,7 +1099,7 @@ class AtlasPage {
this.currentRow.y = 0;
this.currentRow.height = 0;
this.fixedRows.length = 0;
this.version++;
this.version = ++AtlasPage.nextVersion;
}
}
+1
View File
@@ -28,6 +28,7 @@ export interface ICursorRenderModel {
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
vertexAttribDivisor(index: number, divisor: number): void;
createVertexArray(): IWebGLVertexArrayObject;
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
bindVertexArray(vao: IWebGLVertexArrayObject): void;
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
}
+3 -1
View File
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
import { ITerminal } from 'browser/Types';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { getSafariVersion, isSafari } from 'common/Platform';
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
import { IWebGL2RenderingContext } from './Types';
import { WebglRenderer } from './WebglRenderer';
import { Emitter, EventUtils } from 'common/Event';
@@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
const decorationService: IDecorationService = unsafeCore._decorationService;
const logService: ILogService = unsafeCore._logService;
const themeService: IThemeService = unsafeCore._themeService;
this._renderer = this._register(new WebglRenderer(
@@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
coreBrowserService,
coreService,
decorationService,
logService,
optionsService,
themeService,
this._customGlyphs,
+39 -19
View File
@@ -15,11 +15,11 @@ import { AttributeData } from 'common/buffer/AttributeData';
import { CellData } from 'common/buffer/CellData';
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
import { Terminal } from '@xterm/xterm';
import { GlyphRenderer } from './GlyphRenderer';
import { RectangleRenderer } from './RectangleRenderer';
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel';
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
import { IRenderLayer } from './renderLayer/Types';
@@ -28,9 +28,13 @@ import { addDisposableListener } from 'browser/Dom';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
const enum Constants {
MERGE_RETRY_LIMIT = 32
}
export class WebglRenderer extends Disposable implements IRenderer {
private _renderLayers: IRenderLayer[];
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
private _textBlinkStateManager: TextBlinkStateManager;
private _charAtlasDisposable = this._register(new MutableDisposable());
private _charAtlas: ITextureAtlas | undefined;
@@ -73,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
private readonly _coreBrowserService: ICoreBrowserService,
private readonly _coreService: ICoreService,
private readonly _decorationService: IDecorationService,
private readonly _logService: ILogService,
private readonly _optionsService: IOptionsService,
private readonly _themeService: IThemeService,
private readonly _customGlyphs: boolean = true,
@@ -118,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
console.log('webglcontextlost event received');
this._logService.debug('webglcontextlost event received');
// Prevent the default behavior in order to enable WebGL context restoration.
e.preventDefault();
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
// If not, dispatch the onContextLoss notification to observers.
this._contextRestorationTimeout = setTimeout(() => {
this._contextRestorationTimeout = undefined;
console.warn('webgl context not restored; firing onContextLoss');
this._logService.warn('webgl context not restored; firing onContextLoss');
this._onContextLoss.fire(e);
}, 3000 /* ms */);
}));
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
console.warn('webglcontextrestored event received');
this._logService.warn('webglcontextrestored event received');
clearTimeout(this._contextRestorationTimeout);
this._contextRestorationTimeout = undefined;
// The texture atlas and glyph renderer must be fully reinitialized
@@ -154,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._isAttached = this._core.screenElement!.isConnected;
this._register(toDisposable(() => {
clearTimeout(this._contextRestorationTimeout);
this._contextRestorationTimeout = undefined;
for (const l of this._renderLayers) {
l.dispose();
}
@@ -270,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
* Initializes members dependent on WebGL context state.
*/
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
// Update dimensions and acquire char atlas
this.handleCharSizeChanged();
@@ -375,6 +382,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateModel(start, end);
}
// A mid-update atlas page merge invalidates vertex data and may not bump the host
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
merged = true;
this._clearModel(true);
this._updateModel(0, this._terminal.rows - 1);
}
if (merged) {
this._glyphRenderer.value.invalidateAtlasTextures();
}
// Render
this._rectangleRenderer.value.renderBackgrounds();
this._glyphRenderer.value.render(this._model);
@@ -493,7 +513,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
chars = cell.getChars();
code = cell.getCode();
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
if (!rowHasBlinkingCells && cell.isBlink()) {
rowHasBlinkingCells = true;
@@ -538,9 +558,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Nothing has changed, no updates needed
if (this._model.cells[i] === code &&
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg &&
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg &&
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) {
this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg &&
this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg &&
this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) {
continue;
}
@@ -553,9 +573,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Cache the results in the model
this._model.cells[i] = code;
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
width = cell.getWidth();
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
@@ -566,14 +586,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Null out non-first cells
for (x++; x <= lastCharX; x++) {
j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
this._model.cells[j] = NULL_CELL_CODE;
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
// supported
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
}
x--; // Go back to the previous update cell for next iteration
}
+7 -6
View File
@@ -4,6 +4,7 @@
*/
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
import type { ILogService } from 'common/services/Services';
/**
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
@@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([
-1, 1, 0, 1
]);
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
const program = throwIfFalsy(gl.createProgram());
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
gl.linkProgram(program);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
return program;
}
console.error(gl.getProgramInfoLog(program));
logService.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
}
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
const shader = throwIfFalsy(gl.createShader(type));
gl.shaderSource(shader, source);
gl.compileShader(shader);
@@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
return shader;
}
console.error(gl.getShaderInfoLog(shader));
logService.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
}
@@ -4,6 +4,7 @@
*/
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
import type { ILogService } from 'common/services/Services';
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
@@ -22,6 +23,7 @@ export function tryDrawCustomGlyph(
deviceCharHeight: number,
fontSize: number,
devicePixelRatio: number,
logService: ILogService,
backgroundColor?: string,
variantOffset: number = 0
): boolean {
@@ -30,7 +32,7 @@ export function tryDrawCustomGlyph(
// Normalize to array for uniform handling
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
for (const part of parts) {
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
}
return true;
}
@@ -49,6 +51,7 @@ function drawDefinitionPart(
deviceCharHeight: number,
fontSize: number,
devicePixelRatio: number,
logService: ILogService,
backgroundColor?: string,
variantOffset: number = 0
): void {
@@ -79,7 +82,7 @@ function drawDefinitionPart(
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
break;
case CustomGlyphDefinitionType.PATH_FUNCTION:
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
break;
case CustomGlyphDefinitionType.PATH:
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
@@ -88,7 +91,7 @@ function drawDefinitionPart(
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
break;
case CustomGlyphDefinitionType.VECTOR_SHAPE:
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
break;
case CustomGlyphDefinitionType.BRAILLE:
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
@@ -510,6 +513,7 @@ function drawPathFunctionCharacter(
deviceCellWidth: number,
deviceCellHeight: number,
devicePixelRatio: number,
logService: ILogService,
strokeWidth?: number
): void {
ctx.save();
@@ -536,7 +540,7 @@ function drawPathFunctionCharacter(
}
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
logService.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
@@ -598,7 +602,8 @@ function drawVectorShape(
deviceCellWidth: number,
deviceCellHeight: number,
fontSize: number,
devicePixelRatio: number
devicePixelRatio: number,
logService: ILogService
): void {
// Clip the cell to make sure drawing doesn't occur beyond bounds
const clipRegion = new Path2D();
@@ -619,7 +624,7 @@ function drawVectorShape(
}
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
logService.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
+10 -5
View File
@@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => {
await openTerminal(ctx);
ctxWrapper.value = ctx;
await ctx.page.evaluate(`
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
`);
});
test.afterAll(async () => await ctx.page.close());
@@ -24,15 +26,18 @@ test.afterAll(async () => await ctx.page.close());
test.describe('WebGL Renderer Integration Tests', async () => {
// HACK: webgl2 is often not supported in headless firefox on Linux
// https://github.com/microsoft/playwright/issues/11566
// also disable safari due to #5852
if (platform() === 'linux') {
test.skip(({ browserName }) => browserName === 'firefox');
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
}
injectSharedRendererTests(ctxWrapper);
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
await ctx.page.evaluate(`
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
`);
});
});
+4 -1
View File
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
{
name: 'Chromium',
use: {
browserName: 'chromium'
browserName: 'chromium',
launchOptions: {
args: ['--enable-unsafe-swiftshader']
}
}
},
{
+32 -1
View File
@@ -57,6 +57,9 @@ xterm.js version: {{version}}
{{#CSI.length}}
- [CSI](#csi)
{{/CSI.length}}
{{#APC.length}}
- [APC](#apc)
{{/APC.length}}
{{#DCS.length}}
- [DCS](#dcs)
{{/DCS.length}}
@@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit)
Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
any sequence of these types will be silently ignored. They are also not hookable by the API.
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
@@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does:
{{/CSI.length}}
{{#APC.length}}
## APC
| Mnemonic | Name | Sequence | Short Description | Support |
| -------- | ---- | -------- | ----------------- | ------- |
{{#APC}}
| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} |
{{/APC}}
{{#APC.hasLongDescriptions}}
{{#APC}}
{{#longDescription.length}}
<section class="sequence-details">
### {{name}}
{{#longDescription}}
{{{.}}}
{{/longDescription}}
</section>
{{/longDescription.length}}
{{/APC}}
{{/APC.hasLongDescriptions}}
{{/APC.length}}
{{#DCS.length}}
## DCS
@@ -252,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
input.value = 'auto';
this._handlers.updateTerminalSize();
}
const screenElement = document.querySelector('.xterm-screen');
if (screenElement instanceof HTMLElement) {
screenElement.style.outline = this._autoResize ? ''
: 'solid thin red';
}
} else if (o === 'theme') {
value = this._getTheme();
}
+2
View File
@@ -43,6 +43,7 @@ export default tseslint.config(
singleline: { delimiter: 'comma', requireLast: false }
}],
'@stylistic/type-annotation-spacing': 'warn',
'@stylistic/space-unary-ops': 'warn',
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
'@typescript-eslint/consistent-type-assertions': 'warn',
@@ -85,6 +86,7 @@ export default tseslint.config(
'eqeqeq': ['warn', 'always'],
'jsdoc/check-alignment': 'warn',
'jsdoc/check-param-names': 'warn',
'jsdoc/multiline-blocks': 'warn',
'jsdoc/no-multi-asterisks': 'warn',
'keyword-spacing': 'warn',
'max-len': ['warn', {
+63 -735
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -29,6 +29,7 @@
"scripts": {
"presetup": "npm run build",
"setup": "npm run esbuild",
"setup-full": "node bin/setup-full.mjs",
"postsetup": "npm run esbuild-demo-server",
"start": "node demo/start",
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
@@ -55,7 +56,6 @@
"test-unit": "node ./bin/test_unit.js",
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
"test-integration": "node ./bin/test_integration.js --workers=75%",
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
@@ -65,30 +65,25 @@
"benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
"clean": "rm -rf lib out addons/*/lib addons/*/out",
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
"vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts",
"prepackage": "npm run build",
"package": "webpack",
"postpackage": "npm run esbuild-package",
"prepackage-headless": "npm run esbuild-package-headless-only",
"package-headless": "webpack --config ./webpack.config.headless.js",
"postpackage-headless": "node ./bin/package_headless.js",
"prepublishOnly": "npm run package",
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
"prepublishOnly": "npm run package"
},
"devDependencies": {
"@lunapaint/png-codec": "^0.2.0",
"@playwright/test": "^1.57.0",
"@stylistic/eslint-plugin": "^4.4.1",
"@types/chai": "^4.2.22",
"@types/debug": "^4.1.7",
"@types/deep-equal": "^1.0.1",
"@types/express": "4",
"@types/express-ws": "^3.0.1",
"@types/jsdom": "^27.0.0",
"@types/mocha": "^9.0.0",
"@types/node": "^22.19.3",
"@types/trusted-types": "^1.0.6",
"@types/utf8": "^3.0.0",
"@types/webpack": "^5.28.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -96,8 +91,6 @@
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"deep-equal": "^2.0.5",
"esbuild": "~0.25.2",
"eslint": "^9.39.2",
"eslint-plugin-jsdoc": "^50.8.0",
@@ -113,7 +106,6 @@
"ts-loader": "^9.3.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"utf8": "^3.0.0",
"webpack": "^5.61.0",
"webpack-cli": "^4.9.1",
"ws": "^8.2.3",
+5 -3
View File
@@ -12,7 +12,9 @@ import { IBuffer } from 'common/buffer/Types';
import { IInstantiationService } from 'common/services/Services';
import { addDisposableListener } from 'browser/Dom';
const MAX_ROWS_TO_READ = 20;
const enum Constants {
MAX_ROWS_TO_READ = 20
}
const enum BoundaryPosition {
TOP,
@@ -137,7 +139,7 @@ export class AccessibilityManager extends Disposable {
}
private _handleChar(char: string): void {
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) {
if (this._charsToConsume.length > 0) {
// Have the screen reader ignore the char if it was just input
const shiftedChar = this._charsToConsume.shift();
@@ -150,7 +152,7 @@ export class AccessibilityManager extends Disposable {
if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent = Strings.tooMuchOutput.get();
}
}
+2 -1
View File
@@ -563,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
try {
this._onWillOpen.fire(this.element);
} catch (e) {
this._logService.error('onWillOpen handler threw an exception', e);
}
catch { /* fails to load addon for some reason */ }
if (!this._renderService.hasRenderer()) {
this._renderService.setRenderer(this._createRenderer());
}
+10 -1
View File
@@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal {
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
throw new Error('Method not implemented.');
}
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
throw new Error('Method not implemented.');
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
@@ -258,6 +258,15 @@ export class MockBuffer implements IBuffer {
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return Buffer.prototype.getBlankLine.apply(this, arguments as any);
}
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
return Buffer.prototype.attachWrappedRow.apply(this, arguments as any);
}
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void {
Buffer.prototype.clearWrappedRow.apply(this, arguments as any);
}
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
return Buffer.prototype.createScrollWrappedLine.apply(this, arguments as any);
}
public getNullCell(attr?: IAttributeData): ICellData {
throw new Error('Method not implemented.');
}
@@ -94,6 +94,12 @@ export class OverviewRulerRenderer extends Disposable {
this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true)));
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
this._register(this._themeService.onChangeColors(() => this._queueRefresh()));
this._register(toDisposable(() => {
if (this._animationFrame !== undefined) {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
}));
this._queueRefresh(true);
}
@@ -136,6 +142,9 @@ export class OverviewRulerRenderer extends Disposable {
}
private _refreshCanvasDimensions(): void {
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
return;
}
const cssCanvasHeight = this._renderService.dimensions.css.canvas.height;
const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height;
this._canvas.style.width = `${this._width}px`;
@@ -147,6 +156,9 @@ export class OverviewRulerRenderer extends Disposable {
}
private _refreshDecorations(): void {
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
return;
}
if (this._shouldUpdateDimensions) {
this._refreshCanvasDimensions();
}
@@ -200,13 +212,18 @@ export class OverviewRulerRenderer extends Disposable {
}
private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void {
if (this._store.isDisposed) {
return;
}
this._shouldUpdateDimensions = updateCanvasDimensions || this._shouldUpdateDimensions;
this._shouldUpdateAnchor = updateAnchor || this._shouldUpdateAnchor;
if (this._animationFrame !== undefined) {
return;
}
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
this._refreshDecorations();
if (!this._store.isDisposed) {
this._refreshDecorations();
}
this._animationFrame = undefined;
});
}
+41 -39
View File
@@ -19,13 +19,15 @@ import { Emitter } from 'common/Event';
import { addDisposableListener } from 'browser/Dom';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
const ROW_CONTAINER_CLASS = 'xterm-rows';
const FG_CLASS_PREFIX = 'xterm-fg-';
const BG_CLASS_PREFIX = 'xterm-bg-';
const FOCUS_CLASS = 'xterm-focus';
const SELECTION_CLASS = 'xterm-selection';
const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle';
const enum Constants {
TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-',
ROW_CONTAINER_CLASS = 'xterm-rows',
FG_CLASS_PREFIX = 'xterm-fg-',
BG_CLASS_PREFIX = 'xterm-bg-',
FOCUS_CLASS = 'xterm-focus',
SELECTION_CLASS = 'xterm-selection',
CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'
}
let nextTerminalId = 1;
@@ -76,12 +78,12 @@ export class DomRenderer extends Disposable implements IRenderer {
) {
super();
this._rowContainer = this._document.createElement('div');
this._rowContainer.classList.add(ROW_CONTAINER_CLASS);
this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS);
this._rowContainer.style.lineHeight = 'normal';
this._rowContainer.setAttribute('aria-hidden', 'true');
this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
this._selectionContainer = this._document.createElement('div');
this._selectionContainer.classList.add(SELECTION_CLASS);
this._selectionContainer.classList.add(Constants.SELECTION_CLASS);
this._selectionContainer.setAttribute('aria-hidden', 'true');
this.dimensions = createRenderDimensions();
@@ -93,7 +95,7 @@ export class DomRenderer extends Disposable implements IRenderer {
this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);
this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
this._screenElement.appendChild(this._rowContainer);
this._screenElement.appendChild(this._selectionContainer);
@@ -110,7 +112,7 @@ export class DomRenderer extends Disposable implements IRenderer {
));
this._register(toDisposable(() => {
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
// Outside influences such as React unmounts may manipulate the DOM before our disposal.
// https://github.com/xtermjs/xterm.js/issues/2960
@@ -160,7 +162,7 @@ export class DomRenderer extends Disposable implements IRenderer {
}
const styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
` height: 100%;` +
` vertical-align: top;` +
@@ -181,7 +183,7 @@ export class DomRenderer extends Disposable implements IRenderer {
// Base CSS
let styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` +
// Disabling pointer events circumvents a browser behavior that prevents `click` events from
// being delivered if the target element is replaced during the click. This happened due to
// refresh() being called during the mousedown handler to start a selection.
@@ -189,14 +191,14 @@ export class DomRenderer extends Disposable implements IRenderer {
` color: ${colors.foreground.css};` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
` font-kerning: none;` +
` white-space: pre` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` +
` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
`}`;
// Text styles
@@ -242,70 +244,70 @@ export class DomRenderer extends Disposable implements IRenderer {
`}`;
// Cursor
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` animation: ${blinkAnimationBarId} 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
`}` +
// Disable cursor blinking when idle
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
` animation: none !important;` +
`}` +
// !important helps fix an issue where the cursor will not render on top of the selection,
// however it's very hard to fix this issue and retain the blink animation without the use of
// !important. So this edge case fails when cursor blink is on.
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
` background-color: ${colors.cursor.css} !important;` +
` color: ${colors.cursorAccent.css} !important;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
` outline: 1px solid ${colors.cursor.css};` +
` outline-offset: -1px;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` border-bottom: 1px ${colors.cursor.css};` +
` border-bottom-style: solid;` +
` height: calc(100% - 1px);` +
`}`;
// Selection
styles +=
`${this._terminalSelector} .${SELECTION_CLASS} {` +
`${this._terminalSelector} .${Constants.SELECTION_CLASS} {` +
` position: absolute;` +
` top: 0;` +
` left: 0;` +
` z-index: 1;` +
` pointer-events: none;` +
`}` +
`${this._terminalSelector}.focus .${SELECTION_CLASS} div {` +
`${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionBackgroundOpaque.css};` +
`}` +
`${this._terminalSelector} .${SELECTION_CLASS} div {` +
`${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
`}`;
// Colors
for (const [i, c] of colors.ansi.entries()) {
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
}
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
this._themeStyleElement.textContent = styles;
}
@@ -361,13 +363,13 @@ export class DomRenderer extends Disposable implements IRenderer {
}
public handleBlur(): void {
this._rowContainer.classList.remove(FOCUS_CLASS);
this._rowContainer.classList.remove(Constants.FOCUS_CLASS);
this._cursorBlinkStateManager.pause();
this.renderRows(0, this._bufferService.rows - 1);
}
public handleFocus(): void {
this._rowContainer.classList.add(FOCUS_CLASS);
this._rowContainer.classList.add(Constants.FOCUS_CLASS);
this._cursorBlinkStateManager.resume();
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
}
@@ -561,7 +563,7 @@ export class DomRenderer extends Disposable implements IRenderer {
}
private get _terminalSelector(): string {
return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
}
private _handleLinkHover(e: ILinkifierEvent): void {
@@ -667,7 +669,7 @@ class CursorBlinkStateManager {
public restartBlinkAnimation(): void {
if (this._isIdlePaused) {
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
}
this._resetIdleTimer();
}
@@ -679,7 +681,7 @@ class CursorBlinkStateManager {
public resume(): void {
this._isIdlePaused = false;
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
this._resetIdleTimer();
}
@@ -699,7 +701,7 @@ class CursorBlinkStateManager {
}
private _stopBlinkingDueToIdle(): void {
this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS);
this._isIdlePaused = true;
this._idleTimeout = undefined;
}
@@ -8,14 +8,14 @@ import { assert } from 'chai';
import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
const dom = new jsdom.JSDOM('');
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -517,7 +517,7 @@ describe('DomRendererRowFactory', () => {
}
function createEmptyLineData(cols: number): IBufferLine {
const lineData = new BufferLine(cols);
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
for (let i = 0; i < cols; i++) {
lineData.setCell(i, NULL_CELL_DATA);
}
+7 -5
View File
@@ -18,8 +18,10 @@ import * as platform from 'common/Platform';
import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable';
// import 'vs/css!./media/scrollbars';
const HIDE_TIMEOUT = 500;
const SCROLL_WHEEL_SENSITIVITY = 50;
const enum Constants {
HIDE_TIMEOUT = 500,
SCROLL_WHEEL_SENSITIVITY = 50
}
class MouseWheelClassifierItem {
public timestamp: number;
@@ -404,12 +406,12 @@ export class SmoothScrollableElement extends Widget {
let desiredScrollPosition: INewScrollPosition = {};
if (deltaY) {
const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY;
const deltaScrollTop = Constants.SCROLL_WHEEL_SENSITIVITY * deltaY;
const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop));
this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop);
}
if (deltaX) {
const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX;
const deltaScrollLeft = Constants.SCROLL_WHEEL_SENSITIVITY * deltaX;
const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft));
this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft);
}
@@ -533,7 +535,7 @@ export class SmoothScrollableElement extends Widget {
private _scheduleHide(): void {
if (!this._mouseIsOver && !this._isDragging) {
this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT);
this._hideTimeout.cancelAndSet(() => this._hide(), Constants.HIDE_TIMEOUT);
}
}
}
@@ -7,10 +7,13 @@ import { assert } from 'chai';
import { ICharacterJoinerService } from 'browser/services/Services';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockBufferService, createCellData } from 'common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService;
@@ -22,7 +25,7 @@ describe('CharacterJoinerService', () => {
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
lines.set(3, lineData([['no joined ranges']]));
lines.set(4, new BufferLine(0));
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]);
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
@@ -267,7 +270,7 @@ describe('CharacterJoinerService', () => {
type IPartialLineData = ([string] | [string, number]);
function lineData(data: IPartialLineData[]): IBufferLine {
const tline = new BufferLine(0);
const tline = new BufferLine(TEST_STRING_CACHE, 0);
for (let i = 0; i < data.length; ++i) {
const line = data[i][0];
const attr = (data[i][1] || 0) as number;
+1 -1
View File
@@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService {
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
window,
getWindow(element),
event,
element,
colCount,
+7 -2
View File
@@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService {
private _renderDebouncer: IRenderDebouncerWithCallback;
private _pausedResizeTask: DebouncedIdleTask;
private _observerDisposable = this._register(new MutableDisposable());
private _intersectionObserver: IntersectionObserver | undefined;
private _isPaused: boolean = false;
private _needsFullRefresh: boolean = false;
@@ -68,7 +69,7 @@ export class RenderService extends Disposable implements IRenderService {
) {
super();
this._pausedResizeTask = new DebouncedIdleTask(this._logService);
this._pausedResizeTask = this._register(new DebouncedIdleTask(this._logService));
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
this._register(this._renderDebouncer);
@@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService {
// and resume based on terminal visibility if so
if ('IntersectionObserver' in w) {
const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
this._observerDisposable.value = toDisposable(() => {
this._intersectionObserver?.disconnect();
this._intersectionObserver = undefined;
});
this._intersectionObserver = observer;
observer.observe(screenElement);
this._observerDisposable.value = toDisposable(() => observer.disconnect());
}
}
@@ -9,12 +9,15 @@ import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferService, IOptionsService } from 'common/services/Services';
import { MockCoreBrowserService, MockMouseService, MockRenderService } from 'browser/TestUtils.test';
import { CellData } from 'common/buffer/CellData';
import { IBuffer } from 'common/buffer/Types';
import { IRenderService } from 'browser/services/Services';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestSelectionService extends SelectionService {
constructor(
bufferService: IBufferService,
@@ -55,7 +58,7 @@ describe('SelectionService', () => {
});
function stringToRow(text: string): IBufferLine {
const result = new BufferLine(text.length);
const result = new BufferLine(TEST_STRING_CACHE, text.length);
for (let i = 0; i < text.length; i++) {
result.setCell(i, createCellData(0, text.charAt(i), 1));
}
@@ -63,7 +66,7 @@ describe('SelectionService', () => {
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(chars.length);
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line;
}
@@ -118,7 +121,7 @@ describe('SelectionService', () => {
[0, 'o', 1, 'o'.charCodeAt(0)],
[0, 'o', 1, 'o'.charCodeAt(0)]
];
const line = new BufferLine(data.length);
const line = new BufferLine(TEST_STRING_CACHE, data.length);
for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i]));
buffer.lines.set(0, line);
// Ensure wide characters take up 2 columns
+30 -32
View File
@@ -9,7 +9,7 @@ import { moveToCellSequence } from 'browser/input/MoveToCell';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import * as Browser from 'common/Platform';
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
import { getRangeLength } from 'common/buffer/BufferRange';
@@ -18,27 +18,26 @@ import { IBuffer } from 'common/buffer/Types';
import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'common/Event';
/**
* The number of pixels the mouse needs to be above or below the viewport in
* order to scroll at the maximum speed.
*/
const DRAG_SCROLL_MAX_THRESHOLD = 50;
/**
* The maximum scrolling speed
*/
const DRAG_SCROLL_MAX_SPEED = 15;
/**
* The number of milliseconds between drag scroll updates.
*/
const DRAG_SCROLL_INTERVAL = 50;
/**
* The maximum amount of time that can have elapsed for an alt click to move the
* cursor.
*/
const ALT_CLICK_MOVE_CURSOR_TIME = 500;
const enum Constants {
/**
* The number of pixels the mouse needs to be above or below the viewport in
* order to scroll at the maximum speed.
*/
DRAG_SCROLL_MAX_THRESHOLD = 50,
/**
* The maximum scrolling speed
*/
DRAG_SCROLL_MAX_SPEED = 15,
/**
* The number of milliseconds between drag scroll updates.
*/
DRAG_SCROLL_INTERVAL = 50,
/**
* The maximum amount of time that can have elapsed for an alt click to move the
* cursor.
*/
ALT_CLICK_MOVE_CURSOR_TIME = 500
}
const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');
@@ -103,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService {
private _mouseMoveListener: EventListener;
private _mouseUpListener: EventListener;
private _trimListener: IDisposable;
private readonly _trimListener = this._register(new MutableDisposable<IDisposable>());
private _workCell: CellData = new CellData();
private _mouseDownTimeStamp: number = 0;
@@ -141,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService {
this.clearSelection();
}
});
this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e)));
this.enable();
@@ -424,9 +423,9 @@ export class SelectionService extends Disposable implements ISelectionService {
offset -= terminalHeight;
}
offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
offset /= DRAG_SCROLL_MAX_THRESHOLD;
return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
offset = Math.min(Math.max(offset, -Constants.DRAG_SCROLL_MAX_THRESHOLD), Constants.DRAG_SCROLL_MAX_THRESHOLD);
offset /= Constants.DRAG_SCROLL_MAX_THRESHOLD;
return (offset / Math.abs(offset)) + Math.round(offset * (Constants.DRAG_SCROLL_MAX_SPEED - 1));
}
/**
@@ -500,7 +499,7 @@ export class SelectionService extends Disposable implements ISelectionService {
this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
}
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), Constants.DRAG_SCROLL_INTERVAL);
}
/**
@@ -675,7 +674,7 @@ export class SelectionService extends Disposable implements ISelectionService {
}
/**
* The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the
* The callback that occurs every Constants.DRAG_SCROLL_INTERVAL ms that does the
* scrolling of the viewport.
*/
private _dragScroll(): void {
@@ -713,7 +712,7 @@ export class SelectionService extends Disposable implements ISelectionService {
this._removeMouseDownListeners();
if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
if (this.selectionText.length <= 1 && timeElapsed < Constants.ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) {
const coordinates = this._mouseCoordsService.getCoords(
event,
@@ -770,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService {
// reverseIndex) and delete in a splice is only ever used when the same
// number of elements was just added. Given this is could actually be
// beneficial to leave the selection as is for these cases.
this._trimListener.dispose();
this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
}
/**
+36
View File
@@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable {
}
}
/**
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
* run yet.
*/
export class MicrotaskTimer implements IDisposable {
private _isScheduled = false;
private _isDisposed = false;
public dispose(): void {
this.cancel();
this._isDisposed = true;
}
public cancel(): void {
this._isScheduled = false;
}
public set(runner: () => void): void {
if (this._isDisposed) {
throw new Error('Calling set on a disposed MicrotaskTimer');
}
if (this._isScheduled) {
return;
}
this._isScheduled = true;
queueMicrotask(() => {
if (!this._isScheduled) {
return;
}
this._isScheduled = false;
runner();
});
}
}
export class IntervalTimer implements IDisposable {
private _disposable: IDisposable | undefined;
private _isDisposed = false;
-129
View File
@@ -1,129 +0,0 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { clone } from 'common/Clone';
describe('clone', () => {
it('should clone simple objects', () => {
const test = {
a: 1,
b: 2
};
assert.deepEqual(clone(test), { a: 1, b: 2 });
});
it('should clone nested objects', () => {
const test = {
bar: {
a: 1,
b: 2,
c: {
foo: 'bar'
}
}
};
assert.deepEqual(clone(test), {
bar: {
a: 1,
b: 2,
c: {
foo: 'bar'
}
}
});
});
it('should clone array values', () => {
const test = {
a: [1, 2, 3],
b: [1, null, 'test', { foo: 'bar' }]
};
assert.deepEqual(clone(test), {
a: [1, 2, 3],
b: [1, null, 'test', { foo: 'bar' }]
});
});
it('should stop mutation from occuring on the original object', () => {
const test = {
a: 1,
b: 2,
c: {
foo: 'bar'
}
};
const cloned = clone(test);
test.a = 5;
test.c.foo = 'barbaz';
assert.deepEqual(cloned, {
a: 1,
b: 2,
c: {
foo: 'bar'
}
});
});
it('should clone to a maximum depth of 5 by default', () => {
const test = {
a: {
b: {
c: {
d: {
e: {
f: 'foo'
}
}
}
}
}
};
const cloned = clone(test);
test.a.b.c.d.e.f = 'bar';
// The values at a greater depth then 5 should not be cloned
assert.equal((cloned as any).a.b.c.d.e.f, 'bar');
});
it('should allow an optional maximum depth to be set', () => {
const test = {
a: {
b: {
c: 'foo'
}
}
};
const cloned = clone(test, 2);
test.a.b.c = 'bar';
// The values at a greater depth then 2 should not be cloned
assert.equal((cloned as any).a.b.c, 'bar');
});
it('should not throw when cloning a recursive reference', () => {
const test = {
a: {
b: {
c: {}
}
}
};
test.a.b.c = test;
assert.doesNotThrow(() => clone(test));
});
});
-23
View File
@@ -1,23 +0,0 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
/*
* A simple utility for cloning values
*/
export function clone<T>(val: T, depth: number = 5): T {
if (typeof val !== 'object') {
return val;
}
// If we're cloning an array, use an array as the base, otherwise use an object
const clonedObject: any = Array.isArray(val) ? [] : {};
for (const key in val) {
// Recursively clone eack item unless we're at the maximum depth
clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1));
}
return clonedObject as T;
}
+6 -6
View File
@@ -24,7 +24,7 @@
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
import { InstantiationService } from 'common/services/InstantiationService';
import { LogService } from 'common/services/LogService';
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
import { BufferService, BufferServiceConstants } from 'common/services/BufferService';
import { OptionsService } from 'common/services/OptionsService';
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types';
import { CoreService } from 'common/services/CoreService';
@@ -173,8 +173,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
return;
}
x = Math.max(x, MINIMUM_COLS);
y = Math.max(y, MINIMUM_ROWS);
x = Math.max(x, BufferServiceConstants.MINIMUM_COLS);
y = Math.max(y, BufferServiceConstants.MINIMUM_ROWS);
// Flush pending writes before resize to avoid race conditions where async
// writes are processed with incorrect dimensions
@@ -243,8 +243,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
}
/** Add handler for APC escape sequence. See xterm.d.ts for details. */
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerApcHandler(ident, callback);
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerApcHandler(id, callback);
}
protected _setup(): void {
@@ -263,7 +263,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
private _handleWindowsPtyOptionChange(): void {
let value = false;
const windowsPty = this.optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) {
if (windowsPty && windowsPty.backend !== undefined && windowsPty.buildNumber !== undefined) {
value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376);
}
if (value) {
+6 -7
View File
@@ -14,7 +14,6 @@ import { Params } from 'common/parser/Params';
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test';
import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services';
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
import { clone } from 'common/Clone';
import { BufferService } from 'common/services/BufferService';
import { CoreService } from 'common/services/CoreService';
import { OscLinkService } from 'common/services/OscLinkService';
@@ -227,32 +226,32 @@ describe('InputHandler', () => {
assert.equal(coreService.decPrivateModes.cursorStyle, undefined);
assert.equal(coreService.decPrivateModes.cursorBlink, undefined);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([1]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
assert.equal(coreService.decPrivateModes.cursorBlink, true);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([2]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
assert.equal(coreService.decPrivateModes.cursorBlink, false);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([3]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
assert.equal(coreService.decPrivateModes.cursorBlink, true);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([4]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
assert.equal(coreService.decPrivateModes.cursorBlink, false);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([5]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
assert.equal(coreService.decPrivateModes.cursorBlink, true);
optionsService.options = clone(DEFAULT_OPTIONS);
optionsService.options = structuredClone(DEFAULT_OPTIONS);
inputHandler.setCursorStyle(Params.fromArray([6]));
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
assert.equal(coreService.decPrivateModes.cursorBlink, false);
+71 -72
View File
@@ -11,6 +11,8 @@ import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
import { Disposable } from 'common/Lifecycle';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { getWrappedRangeEnd } from 'common/buffer/BufferLineUtils';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
@@ -30,28 +32,9 @@ import { XTERM_VERSION } from 'common/Version';
*/
const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2 };
/**
* VT commands done by the parser - FIXME: move this to the parser?
*/
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
/**
* Document xterm VT features here that are currently unsupported
*/
// @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
// @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps \| Pt ST" "Definitions for user-defined keys."
// @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String."
// @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data."
@@ -60,12 +43,13 @@ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-':
/**
* Max length of the UTF32 input buffer. Real memory consumption is 4 times higher.
*/
const MAX_PARSEBUFFER_LENGTH = 131072;
/**
* Limit length of title and icon name stacks.
*/
const STACK_LIMIT = 10;
const enum Constants {
MAX_PARSEBUFFER_LENGTH = 131072,
/** Limit length of title and icon name stacks. */
STACK_LIMIT = 10,
// create a warning log if an async handler takes longer than the limit (in ms)
SLOW_ASYNC_LIMIT = 5000
}
// map params to window option
function paramToWindowOption(n: number, opts: IWindowOptions): boolean {
@@ -104,9 +88,6 @@ export enum WindowsOptionsReportType {
GET_CELL_SIZE_PIXELS = 1
}
// create a warning log if an async handler takes longer than the limit (in ms)
const SLOW_ASYNC_LIMIT = 5000;
// Work variables to avoid garbage collection
let $temp = 0;
@@ -211,6 +192,9 @@ export class InputHandler extends Disposable implements IInputHandler {
}
this._logService.debug('Unknown DCS code: ', { identifier: this._parser.identToString(ident), action, payload });
});
this._parser.setApcHandlerFallback((ident, action, payload) => {
this._logService.debug('Unknown APC code: ', { identifier: this._parser.identToString(ident), action, payload });
});
/**
* print handler
@@ -409,7 +393,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (this._logService.logLevel <= LogLevelEnum.WARN) {
let slowTimeout: ReturnType<typeof setTimeout> | undefined;
const slowPromise = new Promise<never>((_res, rej) => {
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), SLOW_ASYNC_LIMIT);
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), Constants.SLOW_ASYNC_LIMIT);
});
Promise.race([p, slowPromise])
.then(() => {
@@ -423,7 +407,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (err !== '#SLOW_TIMEOUT') {
throw err;
}
console.warn(`async parser handler taking longer than ${SLOW_ASYNC_LIMIT} ms`);
console.warn(`async parser handler taking longer than ${Constants.SLOW_ASYNC_LIMIT} ms`);
});
}
}
@@ -461,8 +445,8 @@ export class InputHandler extends Disposable implements IInputHandler {
cursorStartX = this._parseStack.cursorStartX;
cursorStartY = this._parseStack.cursorStartY;
this._parseStack.paused = false;
if (data.length > MAX_PARSEBUFFER_LENGTH) {
start = this._parseStack.position + MAX_PARSEBUFFER_LENGTH;
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
start = this._parseStack.position + Constants.MAX_PARSEBUFFER_LENGTH;
}
}
@@ -479,8 +463,8 @@ export class InputHandler extends Disposable implements IInputHandler {
// resize input buffer if needed
if (this._parseBuffer.length < data.length) {
if (this._parseBuffer.length < MAX_PARSEBUFFER_LENGTH) {
this._parseBuffer = new Uint32Array(Math.min(data.length, MAX_PARSEBUFFER_LENGTH));
if (this._parseBuffer.length < Constants.MAX_PARSEBUFFER_LENGTH) {
this._parseBuffer = new Uint32Array(Math.min(data.length, Constants.MAX_PARSEBUFFER_LENGTH));
}
}
@@ -491,9 +475,9 @@ export class InputHandler extends Disposable implements IInputHandler {
}
// process big data in smaller chunks
if (data.length > MAX_PARSEBUFFER_LENGTH) {
for (let i = start; i < data.length; i += MAX_PARSEBUFFER_LENGTH) {
const end = i + MAX_PARSEBUFFER_LENGTH < data.length ? i + MAX_PARSEBUFFER_LENGTH : data.length;
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
for (let i = start; i < data.length; i += Constants.MAX_PARSEBUFFER_LENGTH) {
const end = i + Constants.MAX_PARSEBUFFER_LENGTH < data.length ? i + Constants.MAX_PARSEBUFFER_LENGTH : data.length;
const len = (typeof data === 'string')
? this._stringDecoder.decode(data.substring(i, end), this._parseBuffer)
: this._utf8Decoder.decode(data.subarray(i, end), this._parseBuffer);
@@ -585,8 +569,9 @@ export class InputHandler extends Disposable implements IInputHandler {
if (screenReaderMode) {
this._onA11yChar.fire(stringFromCodePoint(code));
}
if (this._getCurrentLinkId()) {
this._oscLinkService.addLineToLink(this._getCurrentLinkId(), this._activeBuffer.ybase + this._activeBuffer.y);
const linkId = this._getCurrentLinkId();
if (linkId) {
this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y);
}
// goto next line if ch would overflow
@@ -607,8 +592,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (this._activeBuffer.y >= this._bufferService.rows) {
this._activeBuffer.y = this._bufferService.rows - 1;
}
// The line already exists (eg. the initial viewport), mark it as a
// wrapped line
// The line already exists (eg. the initial viewport), mark it as a wrapped line
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true;
}
// row changed, get it again
@@ -729,8 +713,8 @@ export class InputHandler extends Disposable implements IInputHandler {
/**
* Forward registerApcHandler from parser.
*/
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._parser.registerApcHandler(ident, new ApcHandler(callback));
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._parser.registerApcHandler(id, new ApcHandler(callback));
}
/**
@@ -773,7 +757,7 @@ export class InputHandler extends Disposable implements IInputHandler {
// reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics
// can mess with this so windowsMode should be disabled, which is recommended on Windows build
// 21376 and above.
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
}
// If the end of the line is hit, prevent this action from wrapping around to the next line.
if (this._activeBuffer.x >= this._bufferService.cols) {
@@ -837,7 +821,7 @@ export class InputHandler extends Disposable implements IInputHandler {
&& this._activeBuffer.y > this._activeBuffer.scrollTop
&& this._activeBuffer.y <= this._activeBuffer.scrollBottom
&& this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) {
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
this._activeBuffer.y--;
this._activeBuffer.x = this._bufferService.cols - 1;
// find last taken cell - last cell can have 3 different states:
@@ -1201,21 +1185,43 @@ export class InputHandler extends Disposable implements IInputHandler {
respectProtect
);
if (clearWrap) {
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
}
private _unwrapBufferRow(y: number, preserveContent: boolean): void {
const line = this._activeBuffer.lines.get(y);
if (!line?.isWrapped) {
return;
}
if (line instanceof BufferOverflowLine) {
if (preserveContent) {
this._activeBuffer.lines.set(y, line.materializeToStandalone(this._activeBuffer.getNullCell(this._eraseAttrData())));
} else {
this._activeBuffer.clearWrappedRow(y, this._activeBuffer.getNullCell(this._eraseAttrData()), false);
}
return;
}
line.isWrapped = false;
}
/**
* Helper method to reset cells in a terminal row. The cell gets replaced with the eraseChar of
* the terminal and the isWrapped property is set to false.
* @param y row index
*/
private _resetBufferLine(y: number, respectProtect: boolean = false): void {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y);
const absY = this._activeBuffer.ybase + y;
const nullCell = this._activeBuffer.getNullCell(this._eraseAttrData());
const last = getWrappedRangeEnd(this._activeBuffer.lines, absY);
for (let i = last; i > absY; i--) {
this._activeBuffer.clearWrappedRow(i, nullCell);
}
const line = this._activeBuffer.lines.get(absY);
if (line) {
line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect);
this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y);
line.isWrapped = false;
line.fill(nullCell, respectProtect);
this._bufferService.buffer.clearMarkers(absY);
this._activeBuffer.clearWrappedRow(absY, nullCell);
}
}
@@ -1263,10 +1269,7 @@ export class InputHandler extends Disposable implements IInputHandler {
this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect);
if (this._activeBuffer.x + 1 >= this._bufferService.cols) {
// Deleted entire previous line. This next line can no longer be wrapped.
const nextLine = this._activeBuffer.lines.get(j + 1);
if (nextLine) {
nextLine.isWrapped = false;
}
this._unwrapBufferRow(this._activeBuffer.ybase + j + 1, true);
}
while (j--) {
this._resetBufferLine(j, respectProtect);
@@ -1530,7 +1533,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1563,7 +1566,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1586,7 +1589,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1609,7 +1612,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -2415,7 +2418,7 @@ export class InputHandler extends Disposable implements IInputHandler {
color &= ~Attributes.RGB_MASK;
color |= AttributeData.fromColorRGB([c1, c2, c3]);
} else if (mode === 5) {
color &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
color &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
color |= Attributes.CM_P256 | (c1 & 0xff);
}
return color;
@@ -2608,10 +2611,6 @@ export class InputHandler extends Disposable implements IInputHandler {
* | 3 | CMY color. | #N |
* | 4 | CMYK color. | #N |
* | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y |
*
*
* FIXME: blinking is implemented in attrs, but not working in renderers?
* FIXME: remove dead branch for p=100
*/
public charAttributes(params: IParams): boolean {
// Optimize a single SGR0.
@@ -2628,19 +2627,19 @@ export class InputHandler extends Disposable implements IInputHandler {
p = params.params[i];
if (p >= 30 && p <= 37) {
// fg color 8
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= Attributes.CM_P16 | (p - 30);
} else if (p >= 40 && p <= 47) {
// bg color 8
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= Attributes.CM_P16 | (p - 40);
} else if (p >= 90 && p <= 97) {
// fg color 16
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= Attributes.CM_P16 | (p - 90) | 8;
} else if (p >= 100 && p <= 107) {
// bg color 16
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= Attributes.CM_P16 | (p - 100) | 8;
} else if (p === 0) {
// default
@@ -2700,11 +2699,11 @@ export class InputHandler extends Disposable implements IInputHandler {
} else if (p === 39) {
// reset fg
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
attr.fg |= DEFAULT_ATTR_DATA.fg & Attributes.RGB_MASK;
} else if (p === 49) {
// reset bg
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= DEFAULT_ATTR_DATA.bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
attr.bg |= DEFAULT_ATTR_DATA.bg & Attributes.RGB_MASK;
} else if (p === 38 || p === 48 || p === 58) {
// fg color 256 and RGB
i += this._extractColor(params, i, attr);
@@ -2971,13 +2970,13 @@ export class InputHandler extends Disposable implements IInputHandler {
case 22: // PushTitle
if (second === 0 || second === 2) {
this._windowTitleStack.push(this._windowTitle);
if (this._windowTitleStack.length > STACK_LIMIT) {
if (this._windowTitleStack.length > Constants.STACK_LIMIT) {
this._windowTitleStack.shift();
}
}
if (second === 0 || second === 1) {
this._iconNameStack.push(this._iconName);
if (this._iconNameStack.length > STACK_LIMIT) {
if (this._iconNameStack.length > Constants.STACK_LIMIT) {
this._iconNameStack.shift();
}
}
@@ -3497,7 +3496,7 @@ export class InputHandler extends Disposable implements IInputHandler {
const line = this._activeBuffer.lines.get(row);
if (line) {
line.fill(cell);
line.isWrapped = false;
this._unwrapBufferRow(row, true);
}
}
this._dirtyRowTracker.markAllDirty();
+143
View File
@@ -0,0 +1,143 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder';
describe('StringBuilder', () => {
it('should start empty', () => {
const builder = new StringBuilder();
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should append a single chunk', () => {
const builder = new StringBuilder();
builder.append('hello');
assert.strictEqual(builder.length, 5);
assert.strictEqual(builder.toString(), 'hello');
});
it('should join multiple chunks in order', () => {
const builder = new StringBuilder();
builder.append('foo');
builder.append('bar');
builder.append('baz');
assert.strictEqual(builder.length, 9);
assert.strictEqual(builder.toString(), 'foobarbaz');
});
it('should handle empty chunks', () => {
const builder = new StringBuilder();
builder.append('');
builder.append('a');
builder.append('');
assert.strictEqual(builder.length, 1);
assert.strictEqual(builder.toString(), 'a');
});
it('should reset accumulated data', () => {
const builder = new StringBuilder();
builder.append('hello');
builder.reset();
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should allow appending after reset', () => {
const builder = new StringBuilder();
builder.append('old');
builder.reset();
builder.append('new');
assert.strictEqual(builder.toString(), 'new');
});
it('should accumulate many small chunks without quadratic concatenation', () => {
const builder = new StringBuilder();
const chunk = 'x';
const count = 10000;
for (let i = 0; i < count; i++) {
builder.append(chunk);
}
assert.strictEqual(builder.length, count);
assert.strictEqual(builder.toString(), 'x'.repeat(count));
});
});
describe('LimitedStringBuilder', () => {
it('should expose the configured limit', () => {
const builder = new LimitedStringBuilder(42);
assert.strictEqual(builder.limit, 42);
});
it('should start empty', () => {
const builder = new LimitedStringBuilder(10);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should accept data up to the limit', () => {
const builder = new LimitedStringBuilder(10);
assert.strictEqual(builder.append('12345'), false);
assert.strictEqual(builder.append('67890'), false);
assert.strictEqual(builder.length, 10);
assert.strictEqual(builder.toString(), '1234567890');
});
it('should accept a single chunk exactly at the limit', () => {
const builder = new LimitedStringBuilder(5);
assert.strictEqual(builder.append('abcde'), false);
assert.strictEqual(builder.length, 5);
assert.strictEqual(builder.toString(), 'abcde');
});
it('should reject data exceeding the limit and clear the buffer', () => {
const builder = new LimitedStringBuilder(5);
builder.append('abc');
assert.strictEqual(builder.append('def'), true);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should reject a single chunk larger than the limit', () => {
const builder = new LimitedStringBuilder(3);
assert.strictEqual(builder.append('toolong'), true);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should allow appending again after reset following a limit breach', () => {
const builder = new LimitedStringBuilder(3);
assert.strictEqual(builder.append('abcd'), true);
builder.reset();
assert.strictEqual(builder.append('ab'), false);
assert.strictEqual(builder.toString(), 'ab');
});
it('should accumulate many chunks before hitting the limit', () => {
const limit = 100;
const builder = new LimitedStringBuilder(limit);
const chunk = 'A';
for (let i = 0; i < limit; i++) {
assert.strictEqual(builder.append(chunk), false);
}
assert.strictEqual(builder.toString(), 'A'.repeat(limit));
assert.strictEqual(builder.append('B'), true);
assert.strictEqual(builder.toString(), '');
});
it('should reject when limit is zero and any data is appended', () => {
const builder = new LimitedStringBuilder(0);
assert.strictEqual(builder.append('a'), true);
assert.strictEqual(builder.length, 0);
});
it('should allow zero-length appends at the limit', () => {
const builder = new LimitedStringBuilder(0);
assert.strictEqual(builder.append(''), false);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* Accumulates string data from multiple chunks without O(n²) string concatenation.
*/
export class StringBuilder {
private _chunks: string[] = [];
private _length = 0;
public get length(): number {
return this._length;
}
public reset(): void {
this._chunks.length = 0;
this._length = 0;
}
public append(chunk: string): void {
this._chunks.push(chunk);
this._length += chunk.length;
}
public toString(): string {
return this._chunks.join('');
}
}
/**
* String builder that rejects payloads larger than a fixed limit.
*/
export class LimitedStringBuilder {
private readonly _builder = new StringBuilder();
constructor(private readonly _limit: number) { }
public get length(): number {
return this._builder.length;
}
public get limit(): number {
return this._limit;
}
public reset(): void {
this._builder.reset();
}
/**
* @returns true if the limit was exceeded (buffer is cleared in that case)
*/
public append(chunk: string): boolean {
this._builder.append(chunk);
if (this._builder.length > this._limit) {
this._builder.reset();
return true;
}
return false;
}
public toString(): string {
return this._builder.toString();
}
}
+4
View File
@@ -168,4 +168,8 @@ export class DebouncedIdleTask {
public flush(): void {
this._queue.flush();
}
public dispose(): void {
this._queue.clear();
}
}
+1 -2
View File
@@ -5,7 +5,6 @@
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
import { clone } from 'common/Clone';
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
import { IBufferSet, IBuffer } from 'common/buffer/Types';
import { BufferSet } from 'common/buffer/BufferSet';
@@ -157,7 +156,7 @@ export class MockLogService implements ILogService {
export class MockOptionsService implements IOptionsService {
public serviceBrand: any;
public readonly rawOptions: Required<ITerminalOptions> = clone(DEFAULT_OPTIONS);
public readonly rawOptions: Required<ITerminalOptions> = structuredClone(DEFAULT_OPTIONS);
public options: Required<ITerminalOptions> = this.rawOptions;
public onOptionChange: IEvent<keyof ITerminalOptions> = new Emitter<keyof ITerminalOptions>().event;
constructor(testOptions?: Partial<ITerminalOptions>) {
+18 -18
View File
@@ -22,7 +22,7 @@ export interface ICoreTerminal {
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
}
export interface IDisposable {
@@ -476,7 +476,7 @@ export interface IInputHandler {
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
/** C0 BEL */ bell(): boolean;
/** C0 LF */ lineFeed(): boolean;
@@ -528,8 +528,8 @@ export interface IInputHandler {
/** CSI ' } */ insertColumns(params: IParams): boolean;
/** CSI ' ~ */ deleteColumns(params: IParams): boolean;
/** OSC 0
OSC 2 */ setTitle(data: string): boolean;
/** OSC 0 */
/** OSC 2 */ setTitle(data: string): boolean;
/** OSC 4 */ setOrReportIndexedColor(data: string): boolean;
/** OSC 10 */ setOrReportFgColor(data: string): boolean;
/** OSC 11 */ setOrReportBgColor(data: string): boolean;
@@ -542,24 +542,24 @@ export interface IInputHandler {
/** ESC E */ nextLine(): boolean;
/** ESC = */ keypadApplicationMode(): boolean;
/** ESC > */ keypadNumericMode(): boolean;
/** ESC % G
ESC % @ */ selectDefaultCharset(): boolean;
/** ESC ( C
ESC ) C
ESC * C
ESC + C
ESC - C
ESC . C
ESC / C */ selectCharset(collectAndFlag: string): boolean;
/** ESC % G */
/** ESC % @ */ selectDefaultCharset(): boolean;
/** ESC ( C */
/** ESC ) C */
/** ESC * C */
/** ESC + C */
/** ESC - C */
/** ESC . C */
/** ESC / C */ selectCharset(collectAndFlag: string): boolean;
/** ESC D */ index(): boolean;
/** ESC H */ tabSet(): boolean;
/** ESC M */ reverseIndex(): boolean;
/** ESC c */ fullReset(): boolean;
/** ESC n
ESC o
ESC |
ESC }
ESC ~ */ setgLevel(level: number): boolean;
/** ESC n */
/** ESC o */
/** ESC | */
/** ESC } */
/** ESC ~ */ setgLevel(level: number): boolean;
/** ESC # 8 */ screenAlignmentPattern(): boolean;
}
+118 -13
View File
@@ -8,22 +8,34 @@ import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
const INIT_COLS = 80;
const INIT_ROWS = 24;
const INIT_SCROLLBACK = 1000;
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBuffer extends Buffer {
public getStringCache(): BufferLineStringCache {
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
}
public getStringCacheClearTimeout(): unknown {
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
}
}
describe('Buffer', () => {
let optionsService: MockOptionsService;
let bufferService: MockBufferService;
let buffer: Buffer;
let buffer: TestBuffer;
beforeEach(() => {
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
});
describe('constructor', () => {
@@ -151,7 +163,7 @@ describe('Buffer', () => {
describe('no scrollback', () => {
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
assert.equal(buffer.lines.maxLength, INIT_ROWS);
buffer.y = INIT_ROWS - 1;
buffer.fillViewportRows();
@@ -1054,7 +1066,7 @@ describe('Buffer', () => {
describe('buffer marked to have no scrollback', () => {
it('should always have a scrollback of 0', () => {
// Test size on initialization
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.lines.maxLength, INIT_ROWS);
// Test size on buffer increase
@@ -1068,7 +1080,7 @@ describe('Buffer', () => {
describe('addMarker', () => {
it('should adjust a marker line when the buffer is trimmed', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
const marker = buffer.addMarker(buffer.lines.length - 1);
assert.equal(marker.line, buffer.lines.length - 1);
@@ -1076,7 +1088,7 @@ describe('Buffer', () => {
assert.equal(marker.line, buffer.lines.length - 2);
});
it('should dispose of a marker if it is trimmed off the buffer', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1088,7 +1100,7 @@ describe('Buffer', () => {
});
it('should call onDispose', () => {
const eventStack: string[] = [];
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1104,7 +1116,7 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.setCell(0, createCellData(0, 'a', 1));
line.setCell(1, createCellData(0, 'b', 1));
line.setCell(2, createCellData(0, 'c', 1));
@@ -1116,7 +1128,7 @@ describe('Buffer', () => {
});
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1127,7 +1139,7 @@ describe('Buffer', () => {
});
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1144,7 +1156,7 @@ describe('Buffer', () => {
});
it('should handle single width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 1));
line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
@@ -1157,7 +1169,7 @@ describe('Buffer', () => {
});
it('should handle double width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 2));
line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line);
@@ -1168,7 +1180,7 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
const line2 = new BufferLine(3);
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
line2.setCell(0, createCellData(0, '😁', 2));
line2.setCell(1, createCellData(0, '', 0));
line2.setCell(2, createCellData(0, 'a', 1));
@@ -1179,6 +1191,99 @@ describe('Buffer', () => {
});
});
describe('line string cache cleanup', () => {
it('should clear shared cache entries with a single timer', () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const originalDateNow = Date.now;
let timeoutId = 0;
let now = 0;
const clearedTimeouts: number[] = [];
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
const id = ++timeoutId;
scheduledTimeouts.set(id, {
delay: timeout ?? 0,
fire: () => {
scheduledTimeouts.delete(id);
handler();
}
});
return id as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
const numericId = id as unknown as number;
clearedTimeouts.push(numericId);
scheduledTimeouts.delete(numericId);
}) as typeof clearTimeout;
Date.now = () => now;
try {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 2);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
const initialTimerCreationCount = timeoutId;
now = 5000;
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(timeoutId, initialTimerCreationCount);
assert.equal(scheduledTimeouts.size, 1);
assert.deepEqual(clearedTimeouts, []);
now = 15000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(timeoutId, initialTimerCreationCount + 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
now = 20000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(cache.entries.size, 1);
} finally {
Date.now = originalDateNow;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('should reset line string cache state on clear and resize', () => {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.translateBufferLineToString(0, false);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
buffer.clear();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
buffer.translateBufferLineToString(0, false);
assert.equal(cache.entries.size, 1);
buffer.resize(INIT_COLS - 1, INIT_ROWS);
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
});
});
describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
buffer.fillViewportRows();
+27 -5
View File
@@ -4,10 +4,13 @@
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { attachWrappedRow, clearWrappedRow, createScrollWrappedLine } from 'common/buffer/BufferLineUtils';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
@@ -25,7 +28,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
* - cursor position
* - scroll position
*/
export class Buffer implements IBuffer {
export class Buffer extends Disposable implements IBuffer {
public lines: CircularList<IBufferLine>;
public ydisp: number = 0;
public ybase: number = 0;
@@ -50,6 +53,7 @@ export class Buffer implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private readonly _stringCache: BufferLineStringCache;
constructor(
private _hasScrollback: boolean,
@@ -57,6 +61,7 @@ export class Buffer implements IBuffer {
private _bufferService: IBufferService,
private readonly _logService: ILogService
) {
super();
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
@@ -64,6 +69,9 @@ export class Buffer implements IBuffer {
this.scrollBottom = this._rows - 1;
this.setupTabStops();
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
this._register(toDisposable(() => this.clearAllMarkers()));
this._stringCache = this._register(new BufferLineStringCache());
}
public getNullCell(attr?: IAttributeData): ICellData {
@@ -93,7 +101,19 @@ export class Buffer implements IBuffer {
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), !!isWrapped);
}
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
return attachWrappedRow(this.lines, y, this._stringCache, this._cols, fillCellData);
}
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent: boolean = true): void {
clearWrappedRow(this.lines, y, fillCellData, this._stringCache, this._cols, preserveContent);
}
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
return createScrollWrappedLine(lineAbove, this._stringCache, this._cols, this.getNullCell(attr), attr);
}
public get hasScrollback(): boolean {
@@ -138,6 +158,7 @@ export class Buffer implements IBuffer {
* Clears the buffer to it's initial state, discarding all previous data.
*/
public clear(): void {
this._stringCache.clear();
this.ydisp = 0;
this.ybase = 0;
this.y = 0;
@@ -156,6 +177,7 @@ export class Buffer implements IBuffer {
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
this._stringCache.clear();
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
@@ -190,7 +212,7 @@ export class Buffer implements IBuffer {
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
// Just add the new missing rows on Windows as conpty reprints the screen with it's
// view of the world. Once a line enters scrollback for conpty it remains there
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
} else {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
// There is room above the buffer and there are no empty elements below the line,
@@ -204,7 +226,7 @@ export class Buffer implements IBuffer {
} else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there
// are blank lines after the cursor
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
}
}
@@ -345,7 +367,7 @@ export class Buffer implements IBuffer {
}
if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
} else {
if (this.ydisp === this.ybase) {
+100 -1
View File
@@ -4,18 +4,41 @@
*/
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
import { BufferLine } from 'common/buffer//BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { CharData, IBufferLine } from '../Types';
import { CharData, IBufferLine, ICellData } from '../Types';
import { assert } from 'chai';
import { AttributeData } from 'common/buffer/AttributeData';
import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBufferLine extends BufferLine {
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
}
public get combined(): {[index: number]: string} {
return this._combined;
}
public get cachedString(): string | undefined {
return this._getStringCacheEntry(false)?.value;
}
public set cachedString(value: string | undefined) {
this._getStringCacheEntry(true)!.value = value;
}
public get isCachedStringTrimmed(): boolean {
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
}
public set isCachedStringTrimmed(value: boolean) {
this._getStringCacheEntry(true)!.isTrimmed = value;
}
public toArray(): CharData[] {
const result = [];
for (let i = 0; i < this.length; ++i) {
@@ -807,5 +830,81 @@ describe('BufferLine', function(): void {
assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
});
it('should cache canonical string translations', () => {
const line = new TestBufferLine(5);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(1, 'b', 1));
line.setCell(2, createCellData(1, 'c', 1));
// Trimmed-only canonical request should cache the trimmed value.
const trimmed = line.translateToString(true, undefined, undefined, undefined);
assert.equal(trimmed, 'abc');
assert.equal(line.cachedString, 'abc');
assert.equal(line.isCachedStringTrimmed, true);
// Non-trimmed canonical request should refresh cache with the full value.
const translated = line.translateToString(false, undefined, undefined, undefined);
assert.equal(translated, 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Once non-trimmed is cached, trimmed should be derived via trimEnd().
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
line.cachedString = 'cached-non-trimmed ';
line.isCachedStringTrimmed = false;
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed ');
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed');
line.cachedString = 'cached-trimmed';
line.isCachedStringTrimmed = true;
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed');
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Any optional translation argument should bypass cache.
assert.equal(line.translateToString(false, 0, 2, undefined), 'ab');
assert.equal(line.translateToString(true, 0, 2, undefined), 'ab');
});
it('should invalidate cached canonical strings on line mutations', () => {
const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => {
const line = new TestBufferLine(5);
line.fill(createCellData(1, 'a', 1));
line.translateToString(true, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, true);
line.translateToString(false, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, false);
mutate(line);
assert.equal(line.cachedString, undefined);
assert.equal(line.isCachedStringTrimmed, false);
};
assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)]));
assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0));
assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1)));
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyFrom(src);
});
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyCellsFrom(src, 0, 0, 2, false);
});
});
});
});
+369 -99
View File
@@ -8,26 +8,28 @@ import { AttributeData } from 'common/buffer/AttributeData';
import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { StringBuilder } from 'common/StringBuilder';
/**
* buffer memory layout:
*
* | uint32_t | uint32_t | uint32_t |
* | `content` | `FG` | `BG` |
* | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) |
*/
// Buffer memory layout:
//
// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21)
// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8)
// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8)
/** typed array slots taken by one cell */
const CELL_SIZE = 3;
const enum Constants {
/** The number of 32 bit array indices taken by one cell. */
CELL_INDICIES = 3,
/** Factor when to cleanup underlying array buffer after shrinking. */
CLEANUP_THRESHOLD = 2
}
/**
* Cell member indices.
*
* Direct access:
* `content = data[column * CELL_SIZE + Cell.CONTENT];`
* `fg = data[column * CELL_SIZE + Cell.FG];`
* `bg = data[column * CELL_SIZE + Cell.BG];`
* `content = data[column * Constants.CELL_INDICIES + Cell.CONTENT];`
* `fg = data[column * Constants.CELL_INDICIES + Cell.FG];`
* `bg = data[column * Constants.CELL_INDICIES + Cell.BG];`
*/
const enum Cell {
CONTENT = 0,
@@ -40,9 +42,19 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection
let $startIndex = 0;
const $workCell = new CellData();
const $translateToStringBuilder = new StringBuilder();
/** Factor when to cleanup underlying array buffer after shrinking. */
const CLEANUP_THRESHOLD = 2;
export interface IBufferLineStringCacheEntry {
value: string | undefined;
isTrimmed: boolean;
generation: number;
}
export interface IBufferLineStringCache {
generation: number;
allocateEntry(): IBufferLineStringCacheEntry;
touch?(): void;
}
/**
* Typed array based bufferline implementation.
@@ -63,15 +75,107 @@ export class BufferLine implements IBufferLine {
protected _data: Uint32Array;
protected _combined: {[index: number]: string} = {};
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
/** When set, this row is a viewport slice of the logical line owned by this buffer line. */
protected _logicalHead: BufferLine | undefined;
protected _segmentStart: number = 0;
protected _logicalCellCount: number = 0;
protected _overflowRowCount: number = 0;
protected _fillCellData: ICellData;
public length: number;
public isWrapped: boolean = false;
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
this._data = new Uint32Array(cols * CELL_SIZE);
constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number,
fillCellData?: ICellData,
isWrapped: boolean = false,
logicalHead?: BufferLine,
segmentStart?: number
) {
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
this._fillCellData = cell;
if (logicalHead) {
this._logicalHead = logicalHead;
this._segmentStart = segmentStart ?? 0;
this._data = logicalHead._data;
this._combined = logicalHead._combined;
this._extendedAttrs = logicalHead._extendedAttrs;
this.length = cols;
this.isWrapped = true;
return;
}
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
this._logicalCellCount = cols;
for (let i = 0; i < cols; ++i) {
this.setCell(i, cell);
}
this.length = cols;
this.isWrapped = isWrapped;
}
public get logicalCellCount(): number {
return this._logicalHead ? this._logicalHead._logicalCellCount : this._logicalCellCount;
}
public registerOverflowRow(): void {
if (this._logicalHead) {
this._logicalHead.registerOverflowRow();
} else {
this._overflowRowCount++;
}
}
public unregisterOverflowRow(): void {
if (this._logicalHead) {
this._logicalHead.unregisterOverflowRow();
} else if (this._overflowRowCount > 0) {
this._overflowRowCount--;
}
}
public clearOverflowRows(): void {
if (!this._logicalHead) {
this._overflowRowCount = 0;
}
}
/** After reflow merges wrapped rows into the head, reset overflow tracking. */
public clearReflowMergedState(): void {
if (!this._logicalHead) {
this._overflowRowCount = 0;
}
}
public ensureLogicalCapacity(minCells: number, fillCellData: ICellData): void {
const head = this._logicalHead ?? this;
if (minCells <= head._logicalCellCount) {
return;
}
head._invalidateStringCache();
const uint32Cells = minCells * Constants.CELL_INDICIES;
if (head._data.buffer.byteLength >= uint32Cells * 4) {
head._data = new Uint32Array(head._data.buffer, 0, uint32Cells);
} else {
const data = new Uint32Array(uint32Cells);
data.set(head._data);
head._data = data;
}
for (let i = head._logicalCellCount; i < minCells; ++i) {
const start = i * Constants.CELL_INDICIES;
head._data[start + Cell.CONTENT] = fillCellData.content;
head._data[start + Cell.FG] = fillCellData.fg;
head._data[start + Cell.BG] = fillCellData.bg;
}
head._logicalCellCount = minCells;
}
protected _absoluteColumn(column: number): number {
return this._segmentStart + column;
}
protected _cellStartIndex(column: number): number {
return this._absoluteColumn(column) * Constants.CELL_INDICIES;
}
/**
@@ -79,16 +183,18 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public get(index: number): CharData {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
const content = this._data[start + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
return [
this._data[index * CELL_SIZE + Cell.FG],
this._data[start + Cell.FG],
(content & Content.IS_COMBINED_MASK)
? this._combined[index]
? this._combined[abs]
: (cp) ? stringFromCodePoint(cp) : '',
content >> Content.WIDTH_SHIFT,
(content & Content.IS_COMBINED_MASK)
? this._combined[index].charCodeAt(this._combined[index].length - 1)
? this._combined[abs].charCodeAt(this._combined[abs].length - 1)
: cp
];
}
@@ -98,12 +204,15 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public set(index: number, value: CharData): void {
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
this._data[start + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._combined[abs] = value[1];
this._data[start + Cell.CONTENT] = abs | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
} else {
this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._data[start + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
}
@@ -112,22 +221,22 @@ export class BufferLine implements IBufferLine {
* use these when only one value is needed, otherwise use `loadCell`
*/
public getWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] >> Content.WIDTH_SHIFT;
}
/** Test whether content has width. */
public hasWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.WIDTH_MASK;
}
/** Get FG cell component. */
public getFg(index: number): number {
return this._data[index * CELL_SIZE + Cell.FG];
return this._data[this._cellStartIndex(index) + Cell.FG];
}
/** Get BG cell component. */
public getBg(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG];
return this._data[this._cellStartIndex(index) + Cell.BG];
}
/**
@@ -136,7 +245,7 @@ export class BufferLine implements IBufferLine {
* from real empty cells.
*/
public hasContent(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
}
/**
@@ -145,23 +254,25 @@ export class BufferLine implements IBufferLine {
* a single UTF32 codepoint or the last codepoint of a combined string.
*/
public getCodePoint(index: number): number {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index].charCodeAt(this._combined[index].length - 1);
return this._combined[abs].charCodeAt(this._combined[abs].length - 1);
}
return content & Content.CODEPOINT_MASK;
}
/** Test whether the cell contains a combined string. */
public isCombined(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.IS_COMBINED_MASK;
}
/** Returns the string content of the cell. */
public getString(index: number): string {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index];
return this._combined[abs];
}
if (content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
@@ -172,7 +283,7 @@ export class BufferLine implements IBufferLine {
/** Get state of protected flag. */
public isProtected(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED;
return this._data[this._cellStartIndex(index) + Cell.BG] & BgFlags.PROTECTED;
}
/**
@@ -180,15 +291,16 @@ export class BufferLine implements IBufferLine {
* to GC as it significantly reduced the amount of new objects/references needed.
*/
public loadCell(index: number, cell: ICellData): ICellData {
$startIndex = index * CELL_SIZE;
const abs = this._absoluteColumn(index);
$startIndex = this._cellStartIndex(index);
cell.content = this._data[$startIndex + Cell.CONTENT];
cell.fg = this._data[$startIndex + Cell.FG];
cell.bg = this._data[$startIndex + Cell.BG];
if (cell.content & Content.IS_COMBINED_MASK) {
cell.combinedData = this._combined[index];
cell.combinedData = this._combined[abs];
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._extendedAttrs[index]!;
cell.extended = this._extendedAttrs[abs]!;
}
return cell;
}
@@ -197,15 +309,18 @@ export class BufferLine implements IBufferLine {
* Set data at `index` to `cell`.
*/
public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
this._combined[abs] = cell.combinedData;
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = cell.extended;
this._extendedAttrs[abs] = cell.extended;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content;
this._data[index * CELL_SIZE + Cell.FG] = cell.fg;
this._data[index * CELL_SIZE + Cell.BG] = cell.bg;
this._data[start + Cell.CONTENT] = cell.content;
this._data[start + Cell.FG] = cell.fg;
this._data[start + Cell.BG] = cell.bg;
}
/**
@@ -214,12 +329,15 @@ export class BufferLine implements IBufferLine {
* it gets an optimized access method.
*/
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended;
this._extendedAttrs[abs] = attrs.extended;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * CELL_SIZE + Cell.FG] = attrs.fg;
this._data[index * CELL_SIZE + Cell.BG] = attrs.bg;
this._data[start + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[start + Cell.FG] = attrs.fg;
this._data[start + Cell.BG] = attrs.bg;
}
/**
@@ -229,16 +347,19 @@ export class BufferLine implements IBufferLine {
* by the previous `setDataFromCodePoint` call, we can omit it here.
*/
public addCodepointToCell(index: number, codePoint: number, width: number): void {
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
let content = this._data[start + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
this._combined[index] += stringFromCodePoint(codePoint);
this._combined[abs] += stringFromCodePoint(codePoint);
} else {
if (content & Content.CODEPOINT_MASK) {
// normal case for combining chars:
// - move current leading char + new one into combined string
// - set combined flag
this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
this._combined[abs] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0
content |= Content.IS_COMBINED_MASK;
} else {
@@ -251,10 +372,11 @@ export class BufferLine implements IBufferLine {
content &= ~Content.WIDTH_MASK;
content |= width << Content.WIDTH_SHIFT;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = content;
this._data[start + Cell.CONTENT] = content;
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
@@ -282,6 +404,7 @@ export class BufferLine implements IBufferLine {
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
@@ -308,6 +431,7 @@ export class BufferLine implements IBufferLine {
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
@@ -344,23 +468,32 @@ export class BufferLine implements IBufferLine {
* The underlying array buffer will not change if there is still enough space
* to hold the new buffer line data.
* Returns a boolean indicating, whether a `cleanupMemory` call would free
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
if (cols === this.length) {
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * CELL_SIZE;
if (cols > this.length) {
if (this._data.buffer.byteLength >= uint32Cells * 4) {
// optimization: avoid alloc and data copy if buffer has enough room
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
} else {
// slow path: new alloc and full data copy
const data = new Uint32Array(uint32Cells);
data.set(this._data);
this._data = data;
this._invalidateStringCache();
if (this._logicalHead) {
if (cols > this.length) {
this._logicalHead.ensureLogicalCapacity(this._segmentStart + cols, fillCellData);
}
this.length = cols;
return false;
}
if (this._overflowRowCount > 0) {
if (cols > this.length) {
this.ensureLogicalCapacity(Math.max(this._logicalCellCount, this._segmentStart + cols), fillCellData);
}
this.length = cols;
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
if (cols === this.length) {
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * Constants.CELL_INDICIES;
if (cols > this.length) {
const data = new Uint32Array(uint32Cells);
data.set(this._data.subarray(0, this.length * Constants.CELL_INDICIES));
this._data = data;
for (let i = this.length; i < cols; ++i) {
this.setCell(i, fillCellData);
}
@@ -385,20 +518,23 @@ export class BufferLine implements IBufferLine {
}
}
this.length = cols;
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
this._logicalCellCount = cols;
return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
/**
* Cleanup underlying array buffer.
* A cleanup will be triggered if the array buffer exceeds the actual used
* memory by a factor of CLEANUP_THRESHOLD.
* memory by a factor of Constants.CLEANUP_THRESHOLD.
* Returns 0 or 1 indicating whether a cleanup happened.
*/
public cleanupMemory(): number {
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
const data = new Uint32Array(this._data.length);
data.set(this._data);
this._data = data;
const head = this._logicalHead ?? this;
const usedCells = head._logicalCellCount * Constants.CELL_INDICIES;
if (usedCells * 4 * Constants.CLEANUP_THRESHOLD < head._data.buffer.byteLength) {
const data = new Uint32Array(usedCells);
data.set(head._data.subarray(0, usedCells));
head._data = data;
return 1;
}
return 0;
@@ -406,6 +542,7 @@ export class BufferLine implements IBufferLine {
/** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
@@ -415,8 +552,10 @@ export class BufferLine implements IBufferLine {
}
return;
}
this._combined = {};
this._extendedAttrs = {};
if (!this._logicalHead) {
this._combined = {};
this._extendedAttrs = {};
}
for (let i = 0; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
@@ -424,29 +563,72 @@ export class BufferLine implements IBufferLine {
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
if (this._logicalHead) {
this.materializeFromLogicalHead(this._fillCellData);
}
this._invalidateStringCache();
this._logicalHead = undefined;
this._segmentStart = 0;
this._overflowRowCount = 0;
if (line._logicalHead) {
const standalone = new BufferLine(this._stringCache, line.length, this._fillCellData, false);
standalone.copyCellsFrom(line, 0, 0, line.length, false);
line = standalone;
}
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
this._data = new Uint32Array(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
} else {
// use high speed copy if lengths are equal
this._data.set(line._data);
this._data.set(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
}
this.length = line.length;
this._logicalCellCount = line._logicalCellCount || line.length;
this._combined = {};
for (const el in line._combined) {
this._combined[el] = line._combined[el];
const key = parseInt(el, 10);
if (key < this._logicalCellCount) {
this._combined[key] = line._combined[el];
}
}
this._extendedAttrs = {};
for (const el in line._extendedAttrs) {
this._extendedAttrs[el] = line._extendedAttrs[el];
const key = parseInt(el, 10);
if (key < this._logicalCellCount) {
this._extendedAttrs[key] = line._extendedAttrs[el];
}
}
this.isWrapped = line.isWrapped;
}
public materializeFromLogicalHead(fillCellData: ICellData): BufferLine {
if (!this._logicalHead) {
return this;
}
const standalone = new BufferLine(this._stringCache, this.length, fillCellData, false);
standalone.copyCellsFrom(this._logicalHead, this._segmentStart, 0, this.length, false);
this._logicalHead.unregisterOverflowRow();
this._data = standalone._data;
this._combined = standalone._combined;
this._extendedAttrs = standalone._extendedAttrs;
this._logicalHead = undefined;
this._segmentStart = 0;
this._logicalCellCount = this.length;
this.isWrapped = false;
return this;
}
/** create a new clone */
public clone(): IBufferLine {
const newLine = new BufferLine(0);
newLine._data = new Uint32Array(this._data);
if (this._logicalHead) {
const newLine = new BufferLine(this._stringCache, 0, undefined, true, this._logicalHead, this._segmentStart);
newLine.length = this.length;
return newLine;
}
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
newLine._data = new Uint32Array(this._data.subarray(0, this._logicalCellCount * Constants.CELL_INDICIES));
newLine.length = this.length;
newLine._logicalCellCount = this._logicalCellCount;
newLine._overflowRowCount = this._overflowRowCount;
for (const el in this._combined) {
newLine._combined[el] = this._combined[el];
}
@@ -459,8 +641,34 @@ export class BufferLine implements IBufferLine {
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
const start = this._cellStartIndex(i);
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
/** Creates a standalone line containing the full logical line content. */
public toStandaloneLogicalLine(cols: number, fillCellData: ICellData, stringCache: IBufferLineStringCache): BufferLine {
const head = this._logicalHead ?? this;
const trimmed = head.getLogicalTrimmedLength();
const flat = new BufferLine(stringCache, cols, fillCellData, false);
if (trimmed > 0) {
flat.copyCellsFrom(head, 0, 0, trimmed, false);
flat._logicalCellCount = trimmed;
}
head.clearReflowMergedState();
return flat;
}
/** Trimmed length across the entire logical line (all segments). */
public getLogicalTrimmedLength(): number {
const head = this._logicalHead ?? this;
for (let i = head._logicalCellCount - 1; i >= 0; --i) {
const start = i * Constants.CELL_INDICIES;
if ((head._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (head._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
@@ -468,31 +676,43 @@ export class BufferLine implements IBufferLine {
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
const start = this._cellStartIndex(i);
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[start + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache();
const srcData = src._data;
const srcOffset = src._segmentStart;
const destOffset = this._segmentStart;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[destStart + i] = srcData[srcStart + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
const absSrc = srcOffset + srcCol + cell;
const absDest = destOffset + destCol + cell;
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
}
}
} else {
for (let cell = 0; cell < length; cell++) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[destStart + i] = srcData[srcStart + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
const absSrc = srcOffset + srcCol + cell;
const absDest = destOffset + destCol + cell;
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
}
}
}
@@ -501,14 +721,16 @@ export class BufferLine implements IBufferLine {
const srcCombinedKeys = Object.keys(src._combined);
for (let i = 0; i < srcCombinedKeys.length; i++) {
const key = parseInt(srcCombinedKeys[i], 10);
if (key >= srcCol) {
this._combined[key - srcCol + destCol] = src._combined[key];
const srcAbsCol = srcOffset + srcCol;
if (key >= srcAbsCol && key < srcAbsCol + length) {
this._combined[key - srcCol + destOffset + destCol] = src._combined[key];
}
}
}
/**
* Translates the buffer line to a string.
* Translates the buffer line to a string. Caching only applies to canonical full-line translation
* requests (regardless of `trimRight` value).
*
* @param trimRight Whether to trim any empty cells on the right.
* @param startCol The column to start the string (0-based inclusive).
@@ -521,6 +743,19 @@ export class BufferLine implements IBufferLine {
* returned string, the corresponding entries in `outColumns` will have the same column number.
*/
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
if (isCanonicalRequest) {
this._stringCache.touch?.();
}
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
if (trimRight) {
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
}
if (!stringCacheEntry.isTrimmed) {
return stringCacheEntry.value;
}
}
startCol = startCol ?? 0;
endCol = endCol ?? this.length;
if (trimRight) {
@@ -529,12 +764,16 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.length = 0;
}
let result = '';
$translateToStringBuilder.reset();
while (startCol < endCol) {
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
const abs = this._absoluteColumn(startCol);
const start = this._cellStartIndex(startCol);
const content = this._data[start + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
result += chars;
const chars = (content & Content.IS_COMBINED_MASK)
? (this._combined[abs] ?? '')
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
$translateToStringBuilder.append(chars);
if (outColumns) {
for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol);
@@ -545,6 +784,37 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.push(startCol);
}
const result = $translateToStringBuilder.toString();
$translateToStringBuilder.reset();
if (isCanonicalRequest) {
const cacheEntry = this._getStringCacheEntry(true)!;
cacheEntry.value = result;
cacheEntry.isTrimmed = !!trimRight;
}
return result;
}
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
const cachedEntry = this._stringCacheEntryRef?.deref();
if (cachedEntry) {
if (cachedEntry.generation === this._stringCache.generation) {
return cachedEntry;
}
}
if (!createIfNeeded) {
return undefined;
}
const cacheEntry = this._stringCache.allocateEntry();
this._stringCacheEntryRef = new WeakRef(cacheEntry);
return cacheEntry;
}
protected _invalidateStringCache(): void {
const head = this._logicalHead ?? this;
const cacheEntry = head._getStringCacheEntry(false);
if (cacheEntry) {
cacheEntry.value = undefined;
cacheEntry.isTrimmed = false;
}
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine';
import { disposableTimeout } from 'common/Async';
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle';
const enum Constants {
CACHE_TTL_MS = 15000
}
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
public generation: number = 0;
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
private _lastAccessTimestamp: number = 0;
constructor() {
super();
this._register(toDisposable(() => this.entries.clear()));
}
public touch(): void {
this._scheduleClear();
}
public allocateEntry(): IBufferLineStringCacheEntry {
const entry: IBufferLineStringCacheEntry = {
value: undefined,
isTrimmed: false,
generation: this.generation
};
this.entries.add(entry);
this._scheduleClear();
return entry;
}
public clear(): void {
this._clearTimeout.clear();
this._lastAccessTimestamp = 0;
this.generation++;
for (const entry of this.entries) {
entry.value = undefined;
entry.isTrimmed = false;
}
this.entries.clear();
}
private _scheduleClear(): void {
this._lastAccessTimestamp = Date.now();
if (this._clearTimeout.value) {
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
}
private _scheduleClearTimeout(timeoutMs: number): void {
this._clearTimeout.value = disposableTimeout(() => {
const elapsed = Date.now() - this._lastAccessTimestamp;
if (elapsed >= Constants.CACHE_TTL_MS) {
this.clear();
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
}, timeoutMs);
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { IAttributeData, IBufferLine, ICellData } from 'common/Types';
import { IBufferLineStringCache } from 'common/buffer/BufferLine';
export function getLogicalHead(line: IBufferLine): BufferLine {
if (line instanceof BufferOverflowLine) {
return line.head;
}
return line as BufferLine;
}
export function isBufferOverflowLine(line: IBufferLine): line is BufferOverflowLine {
return line instanceof BufferOverflowLine;
}
/**
* Returns the logical cell offset for the start of a buffer row.
*/
export function getSegmentStart(line: IBufferLine): number {
if (line instanceof BufferOverflowLine) {
return line.segmentStart;
}
return 0;
}
/**
* Marks the row at `y` as a wrapped continuation of the logical line above.
* Extends the logical line storage and replaces the row with a {@link BufferOverflowLine}.
*/
export function attachWrappedRow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
y: number,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData
): IBufferLine {
const headY = y > 0 ? getWrappedRangeStart(lines, y - 1) : y;
const head = getLogicalHead(lines.get(headY)!);
const segmentStart = head.logicalCellCount;
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
const existingLine = lines.get(y);
if (existingLine && !(existingLine instanceof BufferOverflowLine) && existingLine.getTrimmedLength() > 0) {
head.copyCellsFrom(existingLine as BufferLine, 0, segmentStart, existingLine.length, false);
}
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
lines.set(y, overflowLine);
head.registerOverflowRow();
return overflowLine;
}
/**
* Clears the wrapped state for a row, detaching overflow rows from their logical line.
*/
export function clearWrappedRow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
y: number,
fillCellData: ICellData,
stringCache?: IBufferLineStringCache,
cols?: number,
preserveContent: boolean = true
): void {
const line = lines.get(y);
if (!line) {
return;
}
if (line.isWrapped) {
if (line instanceof BufferOverflowLine) {
if (preserveContent) {
lines.set(y, line.materializeToStandalone(fillCellData));
} else if (stringCache !== undefined && cols !== undefined) {
line.head.unregisterOverflowRow();
lines.set(y, new BufferLine(stringCache, cols, fillCellData, false));
} else {
lines.set(y, line.materializeToStandalone(fillCellData));
}
} else if (line instanceof BufferLine) {
(line as BufferLine).materializeFromLogicalHead(fillCellData);
}
return;
}
const head = line as BufferLine;
head.clearOverflowRows();
}
export function getWrappedRangeStart(lines: { get(index: number): IBufferLine | undefined }, y: number): number {
let first = y;
while (first > 0 && lines.get(first)?.isWrapped) {
first--;
}
return first;
}
export function getWrappedRangeEnd(lines: { get(index: number): IBufferLine | undefined; length: number }, y: number): number {
let last = y;
while (last + 1 < lines.length && lines.get(last + 1)?.isWrapped) {
last++;
}
return last;
}
/**
* Copies a logical line group into a single standalone buffer row and removes overflow rows.
* Used before reflow so existing reflow algorithms can operate on independent lines.
*/
export function flattenLogicalGroup(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
headY: number,
cols: number,
fillCellData: ICellData,
stringCache: IBufferLineStringCache
): void {
const last = getWrappedRangeEnd(lines, headY);
if (last <= headY) {
return;
}
const head = getLogicalHead(lines.get(headY)!);
lines.set(headY, head.toStandaloneLogicalLine(cols, fillCellData, stringCache));
for (let i = headY + 1; i <= last; i++) {
const line = lines.get(i);
if (line instanceof BufferOverflowLine) {
const standalone = line.materializeToStandalone(fillCellData);
standalone.isWrapped = true;
lines.set(i, standalone);
}
}
}
export function materializeLogicalLinesForReflow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
cols: number,
fillCellData: ICellData,
stringCache: IBufferLineStringCache
): void {
let y = 0;
while (y < lines.length) {
const line = lines.get(y);
const next = lines.get(y + 1);
if (line && next instanceof BufferOverflowLine && next.head === getLogicalHead(line)) {
flattenLogicalGroup(lines, y, cols, fillCellData, stringCache);
y++;
continue;
}
y++;
}
}
export function createScrollWrappedLine(
lineAbove: IBufferLine | undefined,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData,
attr: IAttributeData
): IBufferLine {
if (!lineAbove) {
return new BufferLine(stringCache, cols, fillCellData, true);
}
const head = getLogicalHead(lineAbove);
const segmentStart = head.logicalCellCount;
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
head.registerOverflowRow();
return overflowLine;
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { attachWrappedRow } from 'common/buffer/BufferLineUtils';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH } from 'common/buffer/Constants';
import { IBufferLine } from 'common/Types';
const TEST_STRING_CACHE = new BufferLineStringCache();
const NULL_CELL = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
class LineList {
private _lines: IBufferLine[] = [];
public get(index: number): IBufferLine | undefined { return this._lines[index]; }
public set(index: number, line: IBufferLine): void { this._lines[index] = line; }
public get length(): number { return this._lines.length; }
}
describe('BufferLogicalLine', () => {
it('should share storage across wrapped rows', () => {
const cols = 5;
const lines = new LineList();
const head = new BufferLine(TEST_STRING_CACHE, cols, NULL_CELL, false);
lines.set(0, head);
for (let i = 0; i < cols; i++) {
head.setCellFromCodepoint(i, 'a'.charCodeAt(0) + i, 1, NULL_CELL);
}
attachWrappedRow(lines, 1, TEST_STRING_CACHE, cols, NULL_CELL);
const overflow = lines.get(1)!;
assert.instanceOf(overflow, BufferOverflowLine);
for (let i = 0; i < cols; i++) {
(overflow as BufferLine).setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
}
assert.equal(head.translateToString(true), 'abcde');
assert.equal(overflow.translateToString(true), 'fghij');
assert.equal(head.getLogicalTrimmedLength(), 10);
});
it('should reflow unwrap without copying when logical content fits', () => {
const lines = new LineList();
const head = new BufferLine(TEST_STRING_CACHE, 5, NULL_CELL, false);
lines.set(0, head);
for (let i = 0; i < 5; i++) {
head.setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
}
attachWrappedRow(lines, 1, TEST_STRING_CACHE, 2, NULL_CELL);
const overflow = lines.get(1)! as BufferOverflowLine;
for (let i = 0; i < 2; i++) {
overflow.setCellFromCodepoint(i, 'k'.charCodeAt(0) + i, 1, NULL_CELL);
}
assert.equal(head.getLogicalTrimmedLength(), 7);
assert.equal(overflow.translateToString(true), 'kl');
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICellData } from 'common/Types';
import { BufferLine, IBufferLineStringCache } from 'common/buffer/BufferLine';
/**
* A viewport row that is a view into a slice of a parent {@link BufferLine}'s logical storage.
*/
export class BufferOverflowLine extends BufferLine {
public readonly head: BufferLine;
public readonly segmentStart: number;
constructor(
head: BufferLine,
segmentStart: number,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData
) {
super(stringCache, cols, fillCellData, true, head, segmentStart);
this.head = head;
this.segmentStart = segmentStart;
}
/**
* Copies this row's segment into a standalone {@link BufferLine}.
*/
public materializeToStandalone(fillCellData: ICellData): BufferLine {
return this.materializeFromLogicalHead(fillCellData);
}
}
+9 -6
View File
@@ -4,13 +4,16 @@
*/
import { assert } from 'chai';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants';
import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('BufferReflow', () => {
describe('reflowSmallerGetNewLineLengths', () => {
it('should return correct line lengths for a small line with wide characters', () => {
const line = new BufferLine(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -20,7 +23,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
});
it('should return correct line lengths for a large line with wide characters', () => {
const line = new BufferLine(12);
const line = new BufferLine(TEST_STRING_CACHE, 12);
for (let i = 0; i < 12; i += 4) {
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -42,7 +45,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语');
});
it('should return correct line lengths for a string with wide and single characters', () => {
const line = new BufferLine(6);
const line = new BufferLine(TEST_STRING_CACHE, 6);
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(2, [0, '', 0, 0]);
@@ -56,14 +59,14 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b');
});
it('should return correct line lengths for a wrapped line with wide and single characters', () => {
const line1 = new BufferLine(6);
const line1 = new BufferLine(TEST_STRING_CACHE, 6);
line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line1.set(2, [0, '', 0, 0]);
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
line1.set(4, [0, '', 0, 0]);
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]);
const line2 = new BufferLine(6, undefined, true);
const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true);
line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line2.set(2, [0, '', 0, 0]);
@@ -78,7 +81,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b');
});
it('should work on lines ending in null space', () => {
const line = new BufferLine(5);
const line = new BufferLine(TEST_STRING_CACHE, 5);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
+4 -1
View File
@@ -178,7 +178,10 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
*/
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
const newLineLengths: number[] = [];
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
let cellsNeeded = 0;
for (let i = 0; i < wrappedLines.length; i++) {
cellsNeeded += getWrappedLineTrimmedLength(wrappedLines, i, oldCols);
}
// Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and
// linesNeeded
+42 -1
View File
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { BufferSet } from 'common/buffer/BufferSet';
import { Buffer } from 'common/buffer/Buffer';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
describe('BufferSet', () => {
let bufferSet: BufferSet;
@@ -82,4 +82,45 @@ describe('BufferSet', () => {
assert.equal(bufferSet.alt.markers.length, 0);
});
});
describe('lifecycle', () => {
it('should dispose previous buffers on reset', () => {
const oldNormal = bufferSet.normal as any;
oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
oldNormal.translateBufferLineToString(0, false);
const oldCache = oldNormal._stringCache;
assert.equal(oldCache.entries.size, 1);
assert.notEqual(oldCache._clearTimeout.value, undefined);
bufferSet.reset();
assert.notEqual(bufferSet.normal, oldNormal);
assert.equal(oldCache.entries.size, 0);
assert.equal(oldCache._clearTimeout.value, undefined);
});
it('should dispose both buffers when disposed', () => {
const normal = bufferSet.normal as any;
normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
normal.translateBufferLineToString(0, false);
bufferSet.activateAltBuffer();
const alt = bufferSet.alt as any;
alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
alt.translateBufferLineToString(0, false);
const normalCache = normal._stringCache;
const altCache = alt._stringCache;
assert.notEqual(normalCache._clearTimeout.value, undefined);
assert.notEqual(altCache._clearTimeout.value, undefined);
bufferSet.dispose();
assert.equal(normalCache.entries.size, 0);
assert.equal(altCache.entries.size, 0);
assert.equal(normalCache._clearTimeout.value, undefined);
assert.equal(altCache._clearTimeout.value, undefined);
});
});
});
+5 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { Disposable, MutableDisposable } from 'common/Lifecycle';
import { IAttributeData } from 'common/Types';
import { Buffer } from 'common/buffer/Buffer';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
@@ -18,6 +18,8 @@ export class BufferSet extends Disposable implements IBufferSet {
private _normal!: Buffer;
private _alt!: Buffer;
private _activeBuffer!: Buffer;
private readonly _normalBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _altBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _onBufferActivate = this._register(new Emitter<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>());
public readonly onBufferActivate = this._onBufferActivate.event;
@@ -38,11 +40,13 @@ export class BufferSet extends Disposable implements IBufferSet {
public reset(): void {
this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService);
this._normalBuffer.value = this._normal;
this._normal.fillViewportRows();
// The alt buffer should never have scrollback.
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService);
this._altBuffer.value = this._alt;
this._activeBuffer = this._normal;
this._onBufferActivate.fire({
activeBuffer: this._normal,
+3
View File
@@ -34,6 +34,9 @@ export interface IBuffer {
nextStop(x?: number): number;
prevStop(x?: number): number;
getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine;
attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine;
clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void;
createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine;
getNullCell(attr?: IAttributeData): ICellData;
getWhitespaceCell(attr?: IAttributeData): ICellData;
addMarker(y: number): IMarker;
+135 -5
View File
@@ -453,19 +453,59 @@ describe('KittyKeyboard', () => {
describe('event types (press/repeat/release)', () => {
const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('press event (default, no suffix)', () => {
it('UTF-8 text press event', () => {
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97u');
assert.strictEqual(result.key, 'a');
});
it('press event explicit :1 when modifiers present', () => {
it('Escape key press event (default, no suffix)', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[27u');
});
it('Enter key press event → legacy \\r', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\r');
});
it('Tab key press event → legacy \\t', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\t');
});
it('Backspace key press event → legacy \\x7f', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x7f');
});
it('press event when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97;5u');
});
it('repeat event → :2 suffix', () => {
it('UTF-8 text repeat event', () => {
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[97;1:2u');
assert.strictEqual(result.key, 'a');
});
it('Escape key repeat event → :2 suffix', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[27;1:2u');
});
it('Enter key repeat event → legacy \\r', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\r');
});
it('Tab key repeat event → legacy \\t', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\t');
});
it('Backspace key repeat event → legacy \\x7f', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x7f');
});
it('release event → :3 suffix', () => {
@@ -473,6 +513,26 @@ describe('KittyKeyboard', () => {
assert.strictEqual(result.key, '\x1b[97;1:3u');
});
it('Escape key release event → :3 suffix', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[27;1:3u');
});
it('Enter key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('Tab key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('Backspace key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('release with modifier → mod:3', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[97;5:3u');
@@ -494,6 +554,27 @@ describe('KittyKeyboard', () => {
});
});
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense and
// isn't specified in the spec, but press and repeat events shouldn't get swallowed.
describe('REPORT_EVENT_TYPES flag without DISAMBIGUATE_ESCAPE_CODES', () => {
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('press event is not swallowed when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97;5u');
});
it('repeat event is not swallowed when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[97;5:2u');
});
it('release event is reported as CSI u sequence', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[97;5:3u');
});
});
describe('modifier-only reporting', () => {
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
@@ -563,6 +644,55 @@ describe('KittyKeyboard', () => {
});
});
describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag with REPORT_EVENT_TYPES', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('Enter key press event → CSI 13 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[13u');
});
it('Tab key press event → CSI 9 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[9u');
});
it('Backspace key press event → CSI 127 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[127u');
});
it('Enter key repeat event → CSI 13;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[13;1:2u');
});
it('Tab key repeat event → CSI 9;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[9;1:2u');
});
it('Backspace key repeat event → CSI 127;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[127;1:2u');
});
it('Enter key release event → CSI 13;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[13;1:3u');
});
it('Tab key release event → CSI 9;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[9;1:3u');
});
it('Backspace key release event → CSI 127;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[127;1:3u');
});
});
describe('REPORT_ASSOCIATED_TEXT flag', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT;
+28 -21
View File
@@ -471,29 +471,36 @@ export class KittyKeyboard {
return result;
}
// Special handling for Enter/Tab/Backspace.
const specialKey = keyCode === 13 || keyCode === 9 || keyCode === 127;
// Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as escape
// codes" is also set.
if (specialKey && eventType === KittyKeyboardEventType.RELEASE && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) {
return result;
}
const isFunc = this._functionalKeyCodes[ev.key] !== undefined || this._getNumpadKeyCode(ev) !== undefined;
let useCsiU = false;
if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) {
useCsiU = true;
} else if (reportEventTypes) {
useCsiU = true;
} else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) {
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy
// mode" and consider space to be a text-generating key, so these skip the isFunc fast-path
// and only get CSI u when modifiers are present (handled below).
const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127;
if (isFunc && !isDisambiguateLegacy) {
useCsiU = true;
} else if (modifiers > 0) {
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) {
useCsiU = false;
} else {
useCsiU = true;
}
}
}
const useCsiU = !!(
flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES ||
(reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) ||
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense, so
// just make REPORT_EVENT_TYPES imply DISAMBIGUATE_ESCAPE_CODES here for simplicity.
// See: https://github.com/kovidgoyal/kitty/issues/9999
((flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES || reportEventTypes) &&
(
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and
// consider space to be a text-generating key, so these skip the isFunc fast-path and only
// get CSI u when modifiers are present (handled below).
(isFunc && !specialKey) ||
(
(modifiers > 0 && ev.key.length !== 1) ||
modifiers - 1 > KittyKeyboardModifiers.SHIFT
)
)
)
);
if (useCsiU) {
result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);
+33 -5
View File
@@ -5,7 +5,6 @@
import { assert } from 'chai';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32, utf32ToString } from 'common/input/TextDecoder';
import { encode } from 'utf8';
// convert UTF32 codepoints to string
@@ -29,6 +28,35 @@ function fromByteString(s: string): Uint8Array {
return result;
}
function stringToUtf8Bytes(s: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let cp = s.charCodeAt(i);
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.length) {
const next = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (next - 0xDC00);
i++;
}
}
if (cp < 0x80) {
bytes.push(cp);
} else if (cp < 0x800) {
bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
bytes.push(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
} else {
bytes.push(
0xF0 | (cp >> 18),
0x80 | ((cp >> 12) & 0x3F),
0x80 | ((cp >> 6) & 0x3F),
0x80 | (cp & 0x3F)
);
}
}
return new Uint8Array(bytes);
}
function assertDecodedRange(
min: number,
max: number,
@@ -172,7 +200,7 @@ describe('text encodings', () => {
max,
(i) => (i >= 0xD800 && i <= 0xDFFF) || i === 0xFEFF,
(i) => String.fromCharCode(i),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(data, length) => toString(data, length)
);
});
@@ -187,7 +215,7 @@ describe('text encodings', () => {
max,
() => false,
(i) => stringFromCodePoint(i),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(data, length) => toString(data, length)
);
});
@@ -196,7 +224,7 @@ describe('text encodings', () => {
it('0xFEFF(BOM)', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(5);
const utf8Data = fromByteString(encode(String.fromCharCode(0xFEFF)));
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
const length = decoder.decode(utf8Data, target);
assert.equal(length, 0);
decoder.clear();
@@ -207,7 +235,7 @@ describe('text encodings', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(500);
for (let i = 0; i < TEST_STRINGS.length; ++i) {
const utf8Data = fromByteString(encode(TEST_STRINGS[i]));
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
const length = decoder.decode(utf8Data, target);
assert.equal(toString(target, length), TEST_STRINGS[i]);
decoder.clear();
+81
View File
@@ -125,5 +125,86 @@ describe('WriteBuffer', () => {
assert.deepEqual(stack, []);
assert.deepEqual(cbStack, []);
});
it('flushSync fires onWriteParsed', () => {
let parsed = 0;
wb.onWriteParsed(() => parsed++);
wb.write('a');
wb.write('b');
assert.equal(parsed, 0);
wb.flushSync();
assert.equal(parsed, 1);
});
it('flushSync with no pending writes does not fire onWriteParsed', () => {
let parsed = 0;
wb.onWriteParsed(() => parsed++);
wb.flushSync();
assert.equal(parsed, 0);
});
it('dispose cancels scheduled innerWrite', done => {
wb.write('a');
wb.dispose();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('dispose does not fire onWriteParsed for pending writes', done => {
let parsed = 0;
wb.onWriteParsed(() => parsed++);
wb.write('a');
wb.dispose();
setTimeout(() => {
assert.equal(parsed, 0);
done();
}, 20);
});
it('write after dispose is a no-op', done => {
wb.dispose();
wb.write('a');
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('dispose is idempotent', done => {
wb.write('a');
wb.dispose();
wb.dispose();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('async handler continuation is skipped after dispose', done => {
let resolve!: (value: boolean) => void;
const pending = new Promise<boolean>(r => { resolve = r; });
wb = new WriteBuffer(() => pending);
wb.write('a');
wb.dispose();
resolve(true);
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('handleUserInput still processes first chunk synchronously', () => {
wb.handleUserInput();
wb.write('a');
assert.deepEqual(stack, ['a']);
});
it('flushSync after dispose is a no-op', done => {
wb.write('a');
wb.dispose();
wb.flushSync();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('writeSync after dispose is a no-op', () => {
wb.dispose();
wb.writeSync('a');
assert.deepEqual(stack, []);
});
});
});
+74 -37
View File
@@ -4,35 +4,34 @@
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { TimeoutTimer } from 'common/Async';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
declare const setTimeout: (handler: () => void, timeout?: number) => void;
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
* Enable flow control to avoid this limit and make sure that your backend correctly
* propagates this to the underlying pty. (see docs for further instructions)
* Since this limit is meant as a safety parachute to prevent browser crashs,
* it is set to a very high number. Typically xterm.js gets unresponsive with
* a 100 times lower number (>500 kB).
*/
const DISCARD_WATERMARK = 50000000; // ~50 MB
/**
* The max number of ms to spend on writes before allowing the renderer to
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
* depends on the time it takes for the renderer to draw the frame.
*/
const WRITE_TIMEOUT_MS = 12;
/**
* Threshold of max held chunks in the write buffer, that were already processed.
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
* memory consumption by data thats not used anymore.
*/
const WRITE_BUFFER_LENGTH_THRESHOLD = 50;
const enum Constants {
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
* Enable flow control to avoid this limit and make sure that your backend correctly
* propagates this to the underlying pty. (see docs for further instructions)
* Since this limit is meant as a safety parachute to prevent browser crashs,
* it is set to a very high number. Typically xterm.js gets unresponsive with
* a 100 times lower number (>500 kB).
*/
DISCARD_WATERMARK = 50000000, // ~50 MB
/**
* The max number of ms to spend on writes before allowing the renderer to
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
* depends on the time it takes for the renderer to draw the frame.
*/
WRITE_TIMEOUT_MS = 12,
/**
* Threshold of max held chunks in the write buffer, that were already processed.
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
* memory consumption by data thats not used anymore.
*/
WRITE_BUFFER_LENGTH_THRESHOLD = 50
}
export class WriteBuffer extends Disposable {
private _writeBuffer: (string | Uint8Array)[] = [];
@@ -43,11 +42,18 @@ export class WriteBuffer extends Disposable {
private _syncCalls = 0;
private _didUserInput = false;
private readonly _innerWriteTimer = this._register(new TimeoutTimer());
private readonly _onWriteParsed = this._register(new Emitter<void>());
public readonly onWriteParsed = this._onWriteParsed.event;
constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise<boolean>) {
super();
this._register(toDisposable(() => {
this._writeBuffer.length = 0;
this._callbacks.length = 0;
this._pendingData = 0;
this._bufferOffset = 0;
}));
}
public handleUserInput(): void {
@@ -63,6 +69,9 @@ export class WriteBuffer extends Disposable {
* promises to resolve.
*/
public flushSync(): void {
if (this._store.isDisposed) {
return;
}
// exit early if another sync write loop is active
if (this._isSyncWriting) {
return;
@@ -71,7 +80,9 @@ export class WriteBuffer extends Disposable {
// Process all pending chunks synchronously
let chunk: string | Uint8Array | undefined;
let didProcess = false;
while (chunk = this._writeBuffer.shift()) {
didProcess = true;
this._action(chunk);
const cb = this._callbacks.shift();
if (cb) cb();
@@ -84,12 +95,18 @@ export class WriteBuffer extends Disposable {
this._callbacks.length = 0;
this._isSyncWriting = false;
if (didProcess) {
this._onWriteParsed.fire();
}
}
/**
* @deprecated Unreliable, to be removed soon.
*/
public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void {
if (this._store.isDisposed) {
return;
}
// stop writeSync recursions with maxSubsequentCalls argument
// This is dangerous to use as it will lose the current data chunk
// and return immediately.
@@ -133,7 +150,10 @@ export class WriteBuffer extends Disposable {
}
public write(data: string | Uint8Array, callback?: () => void): void {
if (this._pendingData > DISCARD_WATERMARK) {
if (this._store.isDisposed) {
return;
}
if (this._pendingData > Constants.DISCARD_WATERMARK) {
throw new Error('write data discarded, use flow control to avoid losing data');
}
@@ -153,7 +173,7 @@ export class WriteBuffer extends Disposable {
return;
}
setTimeout(() => this._innerWrite());
this._scheduleInnerWrite();
}
this._pendingData += data.length;
@@ -169,7 +189,7 @@ export class WriteBuffer extends Disposable {
* effectively lowering the redrawing needs, schematically:
*
* macroTask _innerWrite:
* if (performance.now() - (lastTime | 0) < WRITE_TIMEOUT_MS):
* if (performance.now() - (lastTime | 0) < Constants.WRITE_TIMEOUT_MS):
* schedule microTask _innerWrite(lastTime)
* else:
* schedule macroTask _innerWrite(0)
@@ -189,7 +209,17 @@ export class WriteBuffer extends Disposable {
*
* Note, for pure sync code `lastTime` and `promiseResult` have no meaning.
*/
private _scheduleInnerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
if (this._store.isDisposed) {
return;
}
this._innerWriteTimer.cancelAndSet(() => this._innerWrite(lastTime, promiseResult), 0);
}
protected _innerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
if (this._store.isDisposed) {
return;
}
const startTime = lastTime || performance.now();
while (this._writeBuffer.length > this._bufferOffset) {
const data = this._writeBuffer[this._bufferOffset];
@@ -218,9 +248,16 @@ export class WriteBuffer extends Disposable {
* responsibility to slice hard work), but we can at least schedule a screen update as we
* gain control.
*/
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= WRITE_TIMEOUT_MS
? setTimeout(() => this._innerWrite(0, r))
: this._innerWrite(startTime, r);
const continuation: (r: boolean) => void = (r: boolean) => {
if (this._store.isDisposed) {
return;
}
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
this._scheduleInnerWrite(0, r);
} else {
this._innerWrite(startTime, r);
}
};
/**
* Optimization considerations:
@@ -235,7 +272,7 @@ export class WriteBuffer extends Disposable {
* current microtask queue (executed before setTimeout).
*/
// const continuation: (r: boolean) => void = performance.now() - startTime >=
// WRITE_TIMEOUT_MS
// Constants.WRITE_TIMEOUT_MS
// ? r => setTimeout(() => this._innerWrite(0, r))
// : r => this._innerWrite(startTime, r);
@@ -255,19 +292,19 @@ export class WriteBuffer extends Disposable {
this._bufferOffset++;
this._pendingData -= data.length;
if (performance.now() - startTime >= WRITE_TIMEOUT_MS) {
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
break;
}
}
if (this._writeBuffer.length > this._bufferOffset) {
// Allow renderer to catch up before processing the next batch
// trim already processed chunks if we are above threshold
if (this._bufferOffset > WRITE_BUFFER_LENGTH_THRESHOLD) {
if (this._bufferOffset > Constants.WRITE_BUFFER_LENGTH_THRESHOLD) {
this._writeBuffer = this._writeBuffer.slice(this._bufferOffset);
this._callbacks = this._callbacks.slice(this._bufferOffset);
this._bufferOffset = 0;
}
setTimeout(() => this._innerWrite());
this._scheduleInnerWrite();
} else {
this._writeBuffer.length = 0;
this._callbacks.length = 0;
+354 -255
View File
@@ -5,7 +5,7 @@
import { assert } from 'chai';
import { ApcParser, ApcHandler } from 'common/parser/ApcParser';
import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder';
import { IApcHandler } from 'common/parser/Types';
import { IApcHandler, IFunctionIdentifier } from 'common/parser/Types';
function toUtf32(s: string): Uint32Array {
const utf32 = new Uint32Array(s.length);
@@ -14,31 +14,53 @@ function toUtf32(s: string): Uint32Array {
return utf32.subarray(0, length);
}
class TestHandler implements IApcHandler {
public id: number;
public output: [string, number, string, (boolean | string)?][];
public msg: string;
public returnFalse: boolean;
constructor(
id: number,
output: [string, number, string, (boolean | string)?][],
msg: string,
returnFalse: boolean = false
) {
this.id = id;
this.output = output;
this.msg = msg;
this.returnFalse = returnFalse;
function identifier(id: IFunctionIdentifier): number {
let res = 0;
if (id.prefix) {
if (id.prefix.length > 1) {
throw new Error('only one byte as prefix supported');
}
res = id.prefix.charCodeAt(0);
if (res && 0x3c > res || res > 0x3f) {
throw new Error('prefix must be in range 0x3c .. 0x3f');
}
}
if (id.intermediates) {
if (id.intermediates.length > 2) {
throw new Error('only two bytes as intermediates are supported');
}
for (let i = 0; i < id.intermediates.length; ++i) {
const intermediate = id.intermediates.charCodeAt(i);
if (0x20 > intermediate || intermediate > 0x2f) {
throw new Error('intermediate must be in range 0x20 .. 0x2f');
}
res <<= 8;
res |= intermediate;
}
}
if (id.final.length !== 1) {
throw new Error('final must be a single byte');
}
const finalCode = id.final.charCodeAt(0);
if (0x40 > finalCode || finalCode > 0x7e) {
throw new Error('final must be in range 0x40 .. 0x7e');
}
res <<= 8;
res |= finalCode;
return res;
}
class TestHandler implements IApcHandler {
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
public start(): void {
this.output.push([this.msg, this.id, 'START']);
this.output.push([this.msg, 'START']);
}
public put(data: Uint32Array, start: number, end: number): void {
this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]);
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
}
public end(success: boolean): boolean {
this.output.push([this.msg, this.id, 'END', success]);
this.output.push([this.msg, 'END', success]);
if (this.returnFalse) {
return false;
}
@@ -48,173 +70,105 @@ class TestHandler implements IApcHandler {
describe('ApcParser', () => {
let parser: ApcParser;
let reports: [number, string, (boolean | string | undefined)?][] = [];
let reports: any[] = [];
beforeEach(() => {
reports = [];
parser = new ApcParser();
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
reports.push([id, action, data]);
});
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
});
describe('identifier parsing', () => {
it('single character identifier', () => {
parser.start();
const data = toUtf32('Gf=100,a=T;payload');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined], // 0x47 = 'G'
[0x47, 'PUT', 'f=100,a=T;payload'],
[0x47, 'END', true]
]);
});
it('identifier with no payload', () => {
parser.start();
const data = toUtf32('G');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined],
[0x47, 'END', true]
]);
});
it('identifier with chunked payload', () => {
parser.start();
let data = toUtf32('Gf=100');
parser.put(data, 0, data.length);
data = toUtf32(',a=T');
parser.put(data, 0, data.length);
data = toUtf32(';payload');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined],
[0x47, 'PUT', 'f=100'],
[0x47, 'PUT', ',a=T'],
[0x47, 'PUT', ';payload'],
[0x47, 'END', true]
]);
});
it('empty APC sequence', () => {
parser.start();
parser.end(true);
assert.deepEqual(reports, []);
});
});
describe('handler registration', () => {
let handlerReports: [string, number, string, (boolean | string)?][];
beforeEach(() => {
handlerReports = [];
});
it('registerHandler for specific identifier', () => {
const G_CODE = 0x47; // 'G'
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Gf=100,a=T;imagedata');
it('setApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['kitty', G_CODE, 'START'],
['kitty', G_CODE, 'PUT', 'f=100,a=T;imagedata'],
['kitty', G_CODE, 'END', true]
]);
assert.deepEqual(reports, []);
});
it('unregistered identifier falls back', () => {
const G_CODE = 0x47; // 'G'
const X_CODE = 0x58; // 'X'
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Xsome data');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, []);
assert.deepEqual(reports, [
[X_CODE, 'START', undefined],
[X_CODE, 'PUT', 'some data'],
[X_CODE, 'END', true]
// messages from TestHandler
['th', 'START'],
['th', 'PUT', 'Here comes'],
['th', 'PUT', 'the mouse!'],
['th', 'END', true]
]);
});
it('clearHandler removes handler', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.clearHandler(G_CODE);
parser.start();
const data = toUtf32('Gf=100');
it('clearApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
parser.clearHandler(identifier({intermediates: '+', final: 'p'}));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, []);
assert.deepEqual(reports, [
[G_CODE, 'START', undefined],
[G_CODE, 'PUT', 'f=100'],
[G_CODE, 'END', true]
// messages from fallback handler
[identifier({intermediates: '+', final: 'p'}), 'START', undefined],
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'Here comes'],
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'the mouse!'],
[identifier({intermediates: '+', final: 'p'}), 'END', true]
]);
});
it('multiple handlers for same identifier', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
parser.start();
const data = toUtf32('Gdata');
it('addApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2'));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler2', G_CODE, 'START'],
['handler1', G_CODE, 'START'],
['handler2', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'PUT', 'data'],
['handler2', G_CODE, 'END', true],
['handler1', G_CODE, 'END', false]
assert.deepEqual(reports, [
['th2', 'START'],
['th1', 'START'],
['th2', 'PUT', 'Here comes'],
['th1', 'PUT', 'Here comes'],
['th2', 'PUT', 'the mouse!'],
['th1', 'PUT', 'the mouse!'],
['th2', 'END', true],
['th1', 'END', false] // false due being already handled by th2!
]);
});
it('handler returning false allows fallthrough', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true));
parser.start();
const data = toUtf32('Gdata');
it('addApcHandler with return false', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler2', G_CODE, 'START'],
['handler1', G_CODE, 'START'],
['handler2', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'PUT', 'data'],
['handler2', G_CODE, 'END', true],
['handler1', G_CODE, 'END', true]
assert.deepEqual(reports, [
['th2', 'START'],
['th1', 'START'],
['th2', 'PUT', 'Here comes'],
['th1', 'PUT', 'Here comes'],
['th2', 'PUT', 'the mouse!'],
['th1', 'PUT', 'the mouse!'],
['th2', 'END', true],
['th1', 'END', true] // true since th2 indicated to keep bubbling
]);
});
it('dispose removes handler', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
disposable.dispose();
parser.start();
const data = toUtf32('Gdata');
it('dispose handlers', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler1', G_CODE, 'START'],
['handler1', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'END', true]
assert.deepEqual(reports, [
['th1', 'START'],
['th1', 'PUT', 'Here comes'],
['th1', 'PUT', 'the mouse!'],
['th1', 'END', true]
]);
});
});
describe('ApcHandler convenience class', () => {
describe('ApcHandlerFactory', () => {
const TEST_PAYLOAD_LIMIT = 100;
const CHUNK_SIZE = 10;
let originalPayloadLimit: number;
@@ -231,133 +185,278 @@ describe('ApcParser', () => {
});
it('should be called once on end(true)', () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('Gf=100');
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(',a=T;payload');
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(results, [[G_CODE, 'f=100,a=T;payload']]);
assert.deepEqual(reports, ['Here comes the mouse!']);
});
it('should not be called on end(false)', () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
const data = toUtf32('Gf=100,a=T;payload');
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(false);
assert.deepEqual(results, []);
assert.deepEqual(reports, []);
});
it('should handle payload up to limit', function(): void {
this.timeout(30000);
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('G');
it('should be disposable', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('A'.repeat(CHUNK_SIZE));
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
data = toUtf32('some other');
parser.put(data, 0, data.length);
data = toUtf32(' data');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
});
it('should respect return false', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return false; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
});
it('should work up to payload limit', function(): void {
this.timeout(30000);
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
const data = toUtf32('A'.repeat(CHUNK_SIZE));
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
parser.put(data, 0, data.length);
}
parser.end(true);
assert.deepEqual(results, [[G_CODE, 'A'.repeat(TEST_PAYLOAD_LIMIT)]]);
assert.deepEqual(reports, ['A'.repeat(TEST_PAYLOAD_LIMIT)]);
});
it('should abort for payload over limit', function(): void {
it('should abort for payload limit +1', function(): void {
this.timeout(30000);
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('G');
parser.put(data, 0, data.length);
data = toUtf32('A'.repeat(CHUNK_SIZE));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('A'.repeat(CHUNK_SIZE));
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
parser.put(data, 0, data.length);
}
data = toUtf32('A');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(results, []);
});
});
describe('reset behavior', () => {
let handlerReports: [string, number, string, (boolean | string)?][];
beforeEach(() => {
handlerReports = [];
});
it('reset during payload cleans up handlers', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Gf=100');
parser.put(data, 0, data.length);
parser.reset();
assert.deepEqual(handlerReports, [
['kitty', G_CODE, 'START'],
['kitty', G_CODE, 'PUT', 'f=100'],
['kitty', G_CODE, 'END', false]
]);
assert.deepEqual(reports, []);
});
});
});
class TestHandlerAsync implements IApcHandler {
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
public start(): void {
this.output.push([this.msg, 'START']);
}
public put(data: Uint32Array, start: number, end: number): void {
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
}
public async end(success: boolean): Promise<boolean> {
// simple sleep to check in tests whether ordering gets messed up
await Promise.resolve();
this.output.push([this.msg, 'END', success]);
if (this.returnFalse) {
return false;
}
return true;
}
}
async function unhookP(parser: ApcParser, success: boolean): Promise<void> {
let result: void | Promise<boolean>;
let prev: boolean | undefined;
while (result = parser.end(success, prev)) {
prev = await result;
}
}
describe('ApcParser - async tests', () => {
let parser: ApcParser;
let reports: [number, string, (boolean | string | undefined)?][] = [];
let reports: any[] = [];
beforeEach(() => {
reports = [];
parser = new ApcParser();
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
reports.push([id, action, data]);
});
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
});
async function endP(parser: ApcParser, success: boolean): Promise<void> {
let result: void | Promise<boolean>;
let prev: boolean | undefined;
while (result = parser.end(success, prev)) {
prev = await result;
}
}
describe('async ApcHandler', () => {
it('should handle async handler', async () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler(async (data: string) => {
await new Promise(res => setTimeout(res, 10));
results.push([G_CODE, data]);
return true;
}));
parser.start();
const data = toUtf32('Gf=100,a=T');
parser.put(data, 0, data.length);
await endP(parser, true);
assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]);
describe('sync and async mixed', () => {
describe('sync | async | sync', () => {
it('first should run, cleanup action for others', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', false));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['s2', 'START'],
['a1', 'START'],
['s1', 'START'],
['s2', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['s2', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['s2', 'END', true],
['a1', 'END', false], // important: a1 before s1
['s1', 'END', false]
]);
});
it('all should run', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['s2', 'START'],
['a1', 'START'],
['s1', 'START'],
['s2', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['s2', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['s2', 'END', true],
['a1', 'END', true], // important: a1 before s1
['s1', 'END', true]
]);
});
});
describe('async | sync | async', () => {
it('first should run, cleanup action for others', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', false));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['a2', 'START'],
['s1', 'START'],
['a1', 'START'],
['a2', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['a2', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['a2', 'END', true],
['s1', 'END', false], // important: s1 between a2 .. a1
['a1', 'END', false]
]);
});
it('all should run', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['a2', 'START'],
['s1', 'START'],
['a1', 'START'],
['a2', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['a2', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['a2', 'END', true],
['s1', 'END', true], // important: s1 between a2 .. a1
['a1', 'END', true]
]);
});
});
describe('ApcHandlerFactory', () => {
it('should be called once on end(true)', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, ['Here comes the mouse!']);
});
it('should not be called on end(false)', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, false);
assert.deepEqual(reports, []);
});
it('should be disposable', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
data = toUtf32('some other');
parser.put(data, 0, data.length);
data = toUtf32(' data');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
});
it('should respect return false', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return false; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
});
});
});
});
+50 -99
View File
@@ -4,9 +4,10 @@
*/
import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, ISubParserStackState } from 'common/parser/Types';
import { ApcState, ParserConstants } from 'common/parser/Constants';
import { ParserConstants } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IApcHandler[] = [];
@@ -19,10 +20,9 @@ const EMPTY_HANDLERS: IApcHandler[] = [];
* The identifier is the character code of the first byte after ESC _.
*/
export class ApcParser implements IApcParser {
private _state = ApcState.START;
private _active = EMPTY_HANDLERS;
private _id = -1;
private _handlers: IHandlerCollection<IApcHandler> = Object.create(null);
private _active = EMPTY_HANDLERS;
private _ident: number = 0;
private _handlerFb: ApcFallbackHandlerType = () => { };
private _stack: ISubParserStackState = {
paused: false,
@@ -64,22 +64,24 @@ export class ApcParser implements IApcParser {
}
public reset(): void {
// force cleanup handlers if payload was already sent
if (this._state === ApcState.PAYLOAD) {
// force cleanup handlers
if (this._active.length) {
for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) {
this._active[j].end(false);
}
}
this._stack.paused = false;
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
this._ident = 0;
}
private _start(): void {
this._active = this._handlers[this._id] || EMPTY_HANDLERS;
public start(ident: number): void {
// always reset leftover handlers
this.reset();
this._ident = ident;
this._active = this._handlers[ident] || EMPTY_HANDLERS;
if (!this._active.length) {
this._handlerFb(this._id, 'START');
this._handlerFb(this._ident, 'START');
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].start();
@@ -87,9 +89,9 @@ export class ApcParser implements IApcParser {
}
}
private _put(data: Uint32Array, start: number, end: number): void {
public put(data: Uint32Array, start: number, end: number): void {
if (!this._active.length) {
this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end));
this._handlerFb(this._ident, 'PUT', utf32ToString(data, start, end));
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].put(data, start, end);
@@ -97,100 +99,51 @@ export class ApcParser implements IApcParser {
}
}
public start(): void {
// always reset leftover handlers
this.reset();
this._state = ApcState.ID;
}
/**
* Put data to current APC command.
* For APC, the first character is used as the identifier.
* Format: ESC _ <identifier><payload> ESC \
* Example: ESC _ G f=100,a=T;... ESC \ (Kitty graphics, identifier='G')
*/
public put(data: Uint32Array, start: number, end: number): void {
if (this._state === ApcState.ABORT) {
return;
}
if (this._state === ApcState.ID) {
// The first character is the identifier
if (start < end) {
this._id = data[start++];
this._state = ApcState.PAYLOAD;
this._start();
}
}
if (this._state === ApcState.PAYLOAD && end - start > 0) {
this._put(data, start, end);
}
}
/**
* Indicates end of an APC command.
* Whether the APC got aborted or finished normally
* is indicated by `success`.
*/
public end(success: boolean, promiseResult: boolean = true): void | Promise<boolean> {
if (this._state === ApcState.START) {
return;
}
// do nothing if command was faulty
if (this._state !== ApcState.ABORT) {
// if we are still in ID state and get an early end
// means we got an empty APC sequence with no identifier,
// which is invalid - just reset and return
if (this._state === ApcState.ID) {
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
return;
if (!this._active.length) {
this._handlerFb(this._ident, 'END', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!this._active.length) {
this._handlerFb(this._id, 'END', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].end(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers
// we always have to call .end for proper cleanup,
// here we use `success` to indicate whether a handler should execute
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].end(false);
if (handlerResult instanceof Promise) {
handlerResult = this._active[j].end(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers (fallThrough for async)
for (; j >= 0; j--) {
handlerResult = this._active[j].end(false);
if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
return handlerResult;
}
}
}
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
this._ident = 0;
}
}
@@ -201,13 +154,13 @@ export class ApcParser implements IApcParser {
export class ApcHandler implements IApcHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = '';
private _data = new LimitedStringBuilder(ApcHandler._payloadLimit);
private _hitLimit: boolean = false;
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
public start(): void {
this._data = '';
this._data.reset();
this._hitLimit = false;
}
@@ -215,9 +168,7 @@ export class ApcHandler implements IApcHandler {
if (this._hitLimit) {
return;
}
this._data += utf32ToString(data, start, end);
if (this._data.length > ApcHandler._payloadLimit) {
this._data = '';
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
@@ -227,18 +178,18 @@ export class ApcHandler implements IApcHandler {
if (this._hitLimit) {
ret = false;
} else if (success) {
ret = this._handler(this._data);
ret = this._handler(this._data.toString());
if (ret instanceof Promise) {
// need to hold data until `ret` got resolved
// dont care for errors, data will be freed anyway on next start
return ret.then(res => {
this._data = '';
this._data.reset();
this._hitLimit = false;
return res;
});
}
}
this._data = '';
this._data.reset();
this._hitLimit = false;
return ret;
}
+4 -12
View File
@@ -21,9 +21,11 @@ export const enum ParserState {
DCS_IGNORE = 11,
DCS_INTERMEDIATE = 12,
DCS_PASSTHROUGH = 13,
APC_STRING = 14,
APC_ENTRY = 14,
APC_INTERMEDIATE = 15,
APC_PASSTHROUGH = 16,
// Number of states, meaning LAST_STATE + 1.
STATE_LENGTH = 15
STATE_LENGTH = 17
}
/**
@@ -60,16 +62,6 @@ export const enum OscState {
ABORT = 3
}
/**
* Internal states of ApcParser.
*/
export const enum ApcState {
START = 0,
ID = 1,
PAYLOAD = 2,
ABORT = 3
}
// payload limit for OSC and DCS
export const enum ParserConstants {
PAYLOAD_LIMIT = 10000000
+7 -8
View File
@@ -8,6 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle
import { utf32ToString } from 'common/input/TextDecoder';
import { Params } from 'common/parser/Params';
import { ParserConstants } from 'common/parser/Constants';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IDcsHandler[] = [];
@@ -140,7 +141,7 @@ EMPTY_PARAMS.addParam(0);
export class DcsHandler implements IDcsHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = '';
private _data = new LimitedStringBuilder(DcsHandler._payloadLimit);
private _params: IParams = EMPTY_PARAMS;
private _hitLimit: boolean = false;
@@ -152,7 +153,7 @@ export class DcsHandler implements IDcsHandler {
// perf optimization:
// clone only, if we have non empty params, otherwise stick with default
this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS;
this._data = '';
this._data.reset();
this._hitLimit = false;
}
@@ -160,9 +161,7 @@ export class DcsHandler implements IDcsHandler {
if (this._hitLimit) {
return;
}
this._data += utf32ToString(data, start, end);
if (this._data.length > DcsHandler._payloadLimit) {
this._data = '';
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
@@ -172,20 +171,20 @@ export class DcsHandler implements IDcsHandler {
if (this._hitLimit) {
ret = false;
} else if (success) {
ret = this._handler(this._data, this._params);
ret = this._handler(this._data.toString(), this._params);
if (ret instanceof Promise) {
// need to hold data and params until `ret` got resolved
// dont care for errors, data will be freed anyway on next start
return ret.then(res => {
this._params = EMPTY_PARAMS;
this._data = '';
this._data.reset();
this._hitLimit = false;
return res;
});
}
}
this._params = EMPTY_PARAMS;
this._data = '';
this._data.reset();
this._hitLimit = false;
return ret;
}
+440 -42
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types';
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType } from 'common/parser/Types';
import { EscapeSequenceParser, TransitionTable, VT500_TRANSITION_TABLE } from 'common/parser/EscapeSequenceParser';
import { assert } from 'chai';
import { StringToUtf32, stringFromCodePoint, utf32ToString } from 'common/input/TextDecoder';
@@ -12,6 +12,7 @@ import { Params } from 'common/parser/Params';
import { OscHandler } from 'common/parser/OscParser';
import { IDisposable } from 'common/Types';
import { DcsHandler } from 'common/parser/DcsParser';
import { ApcHandler } from 'common/parser/ApcParser';
function r(a: number, b: number): string[] {
@@ -145,6 +146,15 @@ const testTerminal: any = {
},
actionDCSUnhook(success: boolean): void {
this.calls.push(['dcs unhook', success]);
},
actionAPCStart(): void {
this.calls.push(['apc start']);
},
actionAPCPut(s: string): void {
this.calls.push(['apc put', s]);
},
actionAPCEnd(success: boolean): void {
this.calls.push(['apc end', success]);
}
};
@@ -163,7 +173,9 @@ const states: number[] = [
ParserState.DCS_IGNORE,
ParserState.DCS_INTERMEDIATE,
ParserState.DCS_PASSTHROUGH,
ParserState.APC_STRING
ParserState.APC_ENTRY,
ParserState.APC_INTERMEDIATE,
ParserState.APC_PASSTHROUGH
];
let state: any;
@@ -198,6 +210,18 @@ testParser.setDcsHandlerFallback((collectAndFlag, action, payload) => {
testTerminal.actionDCSUnhook(payload);
}
});
testParser.setApcHandlerFallback((collectAndFlag, action, payload) => {
switch (action) {
case 'START':
testTerminal.actionAPCStart(payload);
break;
case 'PUT':
testTerminal.actionAPCPut(payload);
break;
case 'END':
testTerminal.actionAPCEnd(payload);
}
});
// translate string based parse calls into typed array based
@@ -278,7 +302,7 @@ describe('EscapeSequenceParser', () => {
const exceptions: { [key: number]: { [key: string]: any[] } } = {
8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING
13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH
14: { '\x18': [], '\x1a': [] } // abort APC_STRING
16: { '\x18': [['apc end', false]], '\x1a': [['apc end', false]] } // abort APC_PASSTHROUGH
};
parser.reset();
testTerminal.clear();
@@ -725,20 +749,6 @@ describe('EscapeSequenceParser', () => {
}
}
});
it('trans ANYWHERE/ESCAPE --> APC_STRING', () => {
parser.reset();
// C0 (ESC _)
parse(parser, '\x1b_');
assert.equal(parser.currentState, ParserState.APC_STRING);
parser.reset();
// C1
for (state in states) {
parser.currentState = state;
parse(parser, '\x9f');
assert.equal(parser.currentState, ParserState.APC_STRING);
parser.reset();
}
});
it('state SOS_PM_STRING ignore rules', () => {
parser.reset();
let ignored = r(0x00, 0x18);
@@ -1035,6 +1045,134 @@ describe('EscapeSequenceParser', () => {
parser.reset();
testTerminal.clear();
});
it('state APC_ENTRY', () => {
parser.reset();
// C0
parse(parser, '\x1b_');
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
// C1
for (state in states) {
parser.currentState = state;
parse(parser, '\x9f');
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
}
});
it('state APC_ENTRY ignore rules', () => {
parser.reset();
const ignored = [
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
for (let i = 0; i < ignored.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, ignored[i]);
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
}
});
it('trans APC_ENTRY --> APC_INTERMEDIATE with collect action', () => {
parser.reset();
const collect = r(0x20, 0x30);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
assert.equal(parser.collect, collect[i]);
parser.reset();
}
});
it('trans APC_ENTRY --> APC_PASSTHROUGH with start', () => {
parser.reset();
testTerminal.clear();
const collect = r(0x30, 0x7f);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc start']]);
parser.reset();
testTerminal.clear();
}
});
it('trans APC_INTERMEDIATE --> APC_PASSTHROUGH with start', () => {
parser.reset();
testTerminal.clear();
const collect = r(0x30, 0x7f);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_INTERMEDIATE;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc start']]);
parser.reset();
testTerminal.clear();
}
});
it('state APC_INTERMEDIATE ignore rules', () => {
parser.reset();
const ignored = [
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
for (let i = 0; i < ignored.length; ++i) {
parser.currentState = ParserState.APC_INTERMEDIATE;
parse(parser, ignored[i]);
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
parser.reset();
}
});
it('state APC_PASSTHROUGH put action', () => {
parser.reset();
testTerminal.clear();
let puts = r(0x08, 0x0e);
puts = puts.concat(r(0x20, 0x7f));
for (let i = 0; i < puts.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, puts[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc put', puts[i]]]);
parser.reset();
testTerminal.clear();
}
});
it('state APC_PASSTHROUGH ignore rules', () => {
parser.reset();
testTerminal.clear();
let puts = r(0x00, 0x08);
puts = puts.concat(r(0x0e, 0x18));
puts.push('\x19');
puts = puts.concat(r(0x1c, 0x20));
puts.push('\x7f');
for (let i = 0; i < puts.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, puts[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([]);
parser.reset();
testTerminal.clear();
}
});
it('trans APC_PASSTHROUGH --> GROUND|ESCAPE with end action', () => {
parser.reset();
testTerminal.clear();
const collect = ['\x9c', '\x18', '\x1a']; // ST - true, CAN & SUB - false
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.GROUND);
testTerminal.compare([['apc end', collect[i] === '\x9c']]);
parser.reset();
testTerminal.clear();
}
// ESC end
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, '\x1b');
assert.equal(parser.currentState, ParserState.ESCAPE);
testTerminal.compare([['apc end', true]]);
parser.reset();
testTerminal.clear();
});
});
function test(s: string, value: any, noReset: any): void {
@@ -1100,6 +1238,42 @@ describe('EscapeSequenceParser', () => {
['print', 'defg']
], null);
});
it('single APC', () => {
test('\x1b_X3+$aäbc;däe\x9c', [
['apc start'],
['apc put', '3+$aäbc;däe'],
['apc end', true]
], null);
});
it('multi APC', () => {
test('\x1b_Xabc;de', [
['apc start'],
['apc put', 'abc;de']
], null);
testTerminal.clear();
test('abc\x9c', [
['apc put', 'abc'],
['apc end', true]
], true);
});
it('print + DCS(C1) + print', () => {
test('abc\x9fAbc;de\x9cxyz', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', true],
['print', 'xyz']
], null);
});
it('print + DCS(C0) + print', () => {
test('abc\x1b_Abc;de\x1b\\xyz', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', true],
['print', 'xyz']
], null);
});
it('error recovery', () => {
test('\x1b[1€abcdefg\x9b<;c', [
['print', 'abcdefg'],
@@ -1146,6 +1320,22 @@ describe('EscapeSequenceParser', () => {
['dcs unhook', false] // false for abort
], null);
});
it('CAN should abort APC', () => {
test('abc\x9fXbc;de\x18', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', false] // false for abort
], null);
});
it('SUB should abort APC', () => {
test('abc\x9fXbc;de\x1a', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', false] // false for abort
], null);
});
it('CAN should abort OSC', () => {
test('\x1b]0;abc123€öäü\x18', [
['osc', '0;abc123€öäü, success: false']
@@ -1210,6 +1400,7 @@ describe('EscapeSequenceParser', () => {
const exe: string[] = [];
const osc: [number, string][] = [];
const dcs: ([string] | [string, string] | [string, string, ParamsArray, number])[] = [];
const apc: [string, string][] = [];
function clearAccu(): void {
print = '';
esc.length = 0;
@@ -1217,6 +1408,7 @@ describe('EscapeSequenceParser', () => {
exe.length = 0;
osc.length = 0;
dcs.length = 0;
apc.length = 0;
}
beforeEach(() => {
parser2 = new TestEscapeSequenceParser();
@@ -1574,6 +1766,95 @@ describe('EscapeSequenceParser', () => {
assert.deepEqual(dcsCustom, [['A', [1, 2, 3], 'abc']]);
});
});
it('APC handler', () => {
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, {
start: function (): void {
apc.push(['start', '']);
},
put: function (data: Uint32Array, start: number, end: number): void {
let s = '';
for (let i = start; i < end; ++i) {
s += stringFromCodePoint(data[i]);
}
apc.push(['put', s]);
},
end: function (success): boolean {
apc.push(['end', success ? '1' : '0']);
return true;
}
});
parse(parser2, '\x1b_+pabc');
parse(parser2, ';de\x9c');
assert.deepEqual(apc, [
['start', ''],
['put', 'abc'], ['put', ';de'],
['end', '1']
]);
parser2.clearApcHandler({ intermediates: '+', final: 'p' });
parser2.clearApcHandler({ intermediates: '+', final: 'p' }); // should not throw
clearAccu();
parse(parser2, '\x1b_+pabc');
parse(parser2, ';de\x9c');
assert.deepEqual(apc, []);
});
describe('APC custom handlers', () => {
const APC_INPUT = '\x1b_+pabc\x1b\\';
it('Prevent fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['B', 'abc']]);
});
it('Allow fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['B', 'abc'], ['A', 'abc']]);
});
it('Multiple custom handlers fallback once', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['C', 'abc'], ['B', 'abc']]);
});
it('Multiple custom handlers no fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return true; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['C', 'abc']]);
});
it('Execution order should go from latest handler down to the original', () => {
const order: number[] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(1); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(2); return false; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(3); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(order, [3, 2, 1]);
});
it('Dispose should work', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
dispo.dispose();
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['A', 'abc']]);
});
it('Should not corrupt the parser when dispose is called twice', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
dispo.dispose();
dispo.dispose();
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['A', 'abc']]);
});
});
it('ERROR handler', () => {
let errorState: IParsingState | null = null;
parser2.setErrorHandler(function (state: IParsingState): IParsingState {
@@ -1625,15 +1906,22 @@ describe('EscapeSequenceParser', () => {
assert.throws(() => { parser.identifier({ final: '\x7f' }); }, 'final must be in range 64 .. 126');
assert.throws(() => { parser.identifier({ final: 'zz' }); }, 'final must be a single byte');
});
it('final ESC range 0x30 .. 0x7e, one byte', () => {
it('final ESC + APC range 0x30 .. 0x7e, one byte', () => {
for (let i = 0x30; i <= 0x7e; ++i) {
const final = String.fromCharCode(i);
let handler: IDisposable | undefined;
assert.doesNotThrow(() => { handler = parser.registerEscHandler({ final }, () => true); }, 'final must be in range 48 .. 126');
if (handler) handler.dispose();
assert.doesNotThrow(
() => { handler = parser.registerApcHandler({ final }, {start: () => {}, put: ()=>{}, end: ()=>true}); },
'final must be in range 48 .. 126'
);
if (handler) handler.dispose();
}
assert.throws(() => { parser.registerEscHandler({ final: '\x2f' }, () => true); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerEscHandler({ final: '\x7f' }, () => true); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerApcHandler({ final: '\x2f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerApcHandler({ final: '\x7f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
});
it('id calculation - should stacking prefix -> intermediate -> final', () => {
assert.equal(parser.identToString(parser.identifier({ final: 'z' })), 'z');
@@ -1705,6 +1993,25 @@ describe('EscapeSequenceParser', () => {
]
);
});
it('APC', () => {
const callstack: any[] = [];
const h1 = parser.registerApcHandler({ final: 'z' }, new ApcHandler(data => { callstack.push(['z', data]); return true; }));
const h2 = parser.registerApcHandler({ intermediates: '!', final: 'z' }, new ApcHandler(data => { callstack.push(['!z', data]); return true; }));
const h3 = parser.registerApcHandler({ intermediates: '!!', final: 'z' }, new ApcHandler(data => { callstack.push(['!!z', data]); return true; }));
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
h1.dispose();
h2.dispose();
h3.dispose();
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
assert.deepEqual(
callstack,
[
['z', 'AB'],
['!z', 'AB'],
['!!z', 'AB'],
]
);
});
});
});
// TODO: error conditions and error recovery (not implemented yet in parser)
@@ -1758,9 +2065,9 @@ async function throwsAsync(fn: () => Promise<any>, message?: string | undefined)
}
describe('EscapeSequenceParser - async', () => {
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | FIN
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\FIN';
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | APC X abc ST | FIN
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1, APC X
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\\x1b_Xabc\x1b\\FIN';
let RESULT: any[];
let parser: TestEscapeSequenceParser;
const callstack: any[] = [];
@@ -1782,6 +2089,7 @@ describe('EscapeSequenceParser - async', () => {
['PRINT', '$>'],
['DCS a', ['xyz', [1, 2]]],
['OSC 1', 'foo=bar'],
['APC X', 'abc'],
['PRINT', 'FIN']
];
parser = new TestEscapeSequenceParser();
@@ -1805,6 +2113,7 @@ describe('EscapeSequenceParser - async', () => {
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
parser.registerOscHandler(1, new OscHandler(data => { callstack.push(['OSC 1', data]); return true; }));
parser.registerDcsHandler({ final: 'a' }, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => { callstack.push(['APC X', data]); return true; }));
});
it('sync handlers keep being parsed in sync mode', () => {
@@ -1840,6 +2149,7 @@ describe('EscapeSequenceParser - async', () => {
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
parser.registerOscHandler(1, new OscHandler(async data => { callstack.push(['OSC 1', data]); return true; }));
parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['APC X', data]); return true; }));
});
it('sync parse call does not work anymore', () => {
@@ -1876,7 +2186,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('correct result on chunked awaited parse calls', async () => {
@@ -1903,6 +2214,7 @@ describe('EscapeSequenceParser - async', () => {
['PRINT', '>'],
['DCS a', ['xyz', [1, 2]]],
['OSC 1', 'foo=bar'],
['APC X', 'abc'],
['PRINT', 'F'],
['PRINT', 'I'],
['PRINT', 'N']
@@ -1921,7 +2233,8 @@ describe('EscapeSequenceParser - async', () => {
[0, ParserStackType.ESC, 0],
[0, ParserStackType.CSI, 0],
[0, ParserStackType.DCS, 0],
[0, ParserStackType.OSC, 0]
[0, ParserStackType.OSC, 0],
[0, ParserStackType.APC, 0],
]);
});
it('multiple async SGR handlers', async () => {
@@ -1941,7 +2254,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -1954,7 +2268,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -1972,7 +2287,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 1],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -1985,7 +2301,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('multiple async ESC handlers', async () => {
@@ -2003,7 +2320,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2016,7 +2334,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2033,7 +2352,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2046,7 +2366,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('sync/async SGR mixed', async () => {
@@ -2071,7 +2392,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 2],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// dispose SGR2 (sync one)
@@ -2092,7 +2414,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// dispose SGR3 (async one)
@@ -2105,7 +2428,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('multiple async OSC handlers', async () => {
@@ -2123,7 +2447,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2136,7 +2461,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2153,7 +2479,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2166,7 +2493,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
@@ -2185,7 +2513,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2198,7 +2527,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2215,7 +2545,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2228,7 +2559,74 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
it('multiple async APC handlers', async () => {
// register with fallback
const APC2 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return false; }));
await parseP(parser, INPUT);
for (let i = 0; i < callstack.length; ++i) {
const entry = callstack[i];
if (entry[0] === '2# APC X') assert.equal(callstack[i + 1][0], 'APC a', 'Should fallback to original handler');
}
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
APC2.dispose();
await parseP(parser, INPUT);
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// register without fallback
const APC22 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return true; }));
await parseP(parser, INPUT);
for (let i = 0; i < callstack.length; ++i) {
const entry = callstack[i];
if (entry[0] === '2# APC X') assert.notEqual(callstack[i + 1][0], 'APC X', 'Should fallback to original handler');
}
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
APC22.dispose();
await parseP(parser, INPUT);
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
+151 -62
View File
@@ -12,6 +12,24 @@ import { OscParser } from 'common/parser/OscParser';
import { DcsParser } from 'common/parser/DcsParser';
import { ApcParser } from 'common/parser/ApcParser';
/**
* VT commands done by the parser
*/
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
/**
* Table values are generated like this:
* index: currentState << TableValue.INDEX_STATE_SHIFT | charCode
@@ -77,7 +95,9 @@ const NON_ASCII_PRINTABLE = 0xA0;
* Taken from https://vt100.net/emu/dec_ansi_parser.
*/
export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
const table: TransitionTable = new TransitionTable(4095);
// table size:
// (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1
const table: TransitionTable = new TransitionTable(4257);
// range macro for byte
const BYTE_VALUES = 256;
@@ -105,7 +125,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC
table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC
table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // SOS, PM
table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC
table.add(0x9f, state, ParserAction.CLEAR, ParserState.APC_ENTRY); // APC
table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI
table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS
}
@@ -136,11 +156,20 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x9c, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.GROUND);
table.add(0x7f, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING);
// apc
table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING);
table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND);
table.add(0x5f, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.APC_ENTRY);
table.addMany(EXECUTABLES, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
table.add(0x7f, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
table.addMany(r(0x20, 0x30), ParserState.APC_ENTRY, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
table.addMany(r(0x30, 0x7f), ParserState.APC_ENTRY, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
table.addMany(r(0x30, 0x7f), ParserState.APC_INTERMEDIATE, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
table.addMany(EXECUTABLES, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.APC_INTERMEDIATE, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
table.add(0x7f, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
table.addMany(PRINTABLES, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
table.addMany(EXECUTABLES, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
table.addMany(r(0x08, 0x0e), ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
table.add(0x7f, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_PASSTHROUGH, ParserAction.APC_END, ParserState.GROUND);
// csi entries
table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY);
table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND);
@@ -169,22 +198,18 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY);
table.addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM);
table.addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
@@ -200,7 +225,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
table.add(NON_ASCII_PRINTABLE, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
table.add(NON_ASCII_PRINTABLE, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
return table;
})();
@@ -247,6 +272,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
// handler lookup containers
protected _printHandler: PrintHandlerType;
protected _executeHandlers: { [flag: number]: ExecuteHandlerType };
// fast path for EXE bytes < 0x18
protected _executeHandlersArr: (ExecuteHandlerType | undefined)[];
protected _csiHandlers: IHandlerCollection<CsiHandlerType>;
protected _escHandlers: IHandlerCollection<EscHandlerType>;
protected readonly _oscParser: IOscParser;
@@ -290,11 +317,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
this._printHandler = this._printHandlerFb;
this._executeHandlers = Object.create(null);
this._executeHandlersArr = new Array(0x18).fill(undefined);
this._csiHandlers = Object.create(null);
this._escHandlers = Object.create(null);
this._register(toDisposable(() => {
this._csiHandlers = Object.create(null);
this._executeHandlers = Object.create(null);
this._executeHandlersArr = new Array(0x18).fill(undefined);
this._escHandlers = Object.create(null);
}));
this._oscParser = this._register(new OscParser());
@@ -381,10 +410,14 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
}
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
this._executeHandlers[flag.charCodeAt(0)] = handler;
const code = flag.charCodeAt(0);
this._executeHandlers[code] = handler;
if (code < 0x18) this._executeHandlersArr[code] = handler;
}
public clearExecuteHandler(flag: string): void {
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
const code = flag.charCodeAt(0);
if (this._executeHandlers[code]) delete this._executeHandlers[code];
if (code < 0x18) this._executeHandlersArr[code] = undefined;
}
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
this._executeHandlerFb = handler;
@@ -431,11 +464,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._oscParser.setHandlerFallback(handler);
}
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
return this._apcParser.registerHandler(ident, handler);
public registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable {
id.prefix = undefined; // APC does not support prefix byte
return this._apcParser.registerHandler(this._identifier(id, [0x30, 0x7e]), handler);
}
public clearApcHandler(ident: number): void {
this._apcParser.clearHandler(ident);
public clearApcHandler(id: IFunctionIdentifier): void {
id.prefix = undefined; // APC does not support prefix byte
this._apcParser.clearHandler(this._identifier(id, [0x30, 0x7e]));
}
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
this._apcParser.setHandlerFallback(handler);
@@ -462,8 +497,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._oscParser.reset();
this._dcsParser.reset();
this._apcParser.reset();
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
// abort pending continuation from async handler
@@ -506,6 +540,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
* - OSC_STRING:OSC_PUT
* - DCS_PASSTHROUGH:DCS_PUT
*
* Additionally the following fast paths exist before the table lookup:
* - EXE bytes < 0x18 in non-payload states (avoids table lookup entirely)
* - 7-bit CSI sequences without intermediates (ESC [ params final)
*
* Note on asynchronous handler support:
* Any handler returning a promise will be treated as asynchronous.
* To keep the in-band blocking working for async handlers, `parse` pauses execution,
@@ -608,8 +646,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserStackType.OSC:
@@ -619,8 +656,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserStackType.APC:
@@ -630,8 +666,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
}
@@ -649,34 +684,90 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
for (let i = start; i < length; ++i) {
code = data[i];
// EXE fast-path: common control bytes (0x00-0x17) in non-payload states
if (code < 0x18 && this.currentState <= ParserState.CSI_IGNORE) {
(this._executeHandlersArr[code] ?? this._executeHandlerFb)(code);
this.precedingJoinState = 0;
continue;
}
// CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop
if (code === 0x1b
&& this.currentState < ParserState.OSC_STRING
&& i + 2 < length && data[i + 1] === 0x5b
) {
this._params.resetZdm();
this._collect = 0;
let k = i + 2;
let ch = data[k];
if (ch >= 0x3c && ch <= 0x3f) {
this._collect = ch;
k++;
}
let csiDone = false;
for (; k < length; k++) {
ch = data[k];
if (ch >= 0x30 && ch <= 0x39) {
this._params.addDigit(ch - 48);
} else if (ch === 0x3b) {
this._params.addParam(0);
} else if (ch === 0x3a) {
this._params.addSubParam(-1);
} else if (ch >= 0x40 && ch <= 0x7e) {
const handlers = this._csiHandlers[this._collect << 8 | ch];
let j = handlers ? handlers.length - 1 : -1;
for (; j >= 0; j--) {
handlerResult = handlers[j](this._params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND;
this._preserveStack(ParserStackType.CSI, handlers, j, transition, k);
return handlerResult;
}
}
if (j < 0) {
this._csiHandlerFb(this._collect << 8 | ch, this._params);
}
this.precedingJoinState = 0;
i = k;
this.currentState = ParserState.GROUND;
csiDone = true;
break;
} else {
break;
}
}
if (!csiDone) {
i = k - 1;
this.currentState = ParserState.CSI_PARAM;
}
continue;
}
// normal transition & action lookup
transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)];
transition = this._transitions.table[
this.currentState << TableAccess.INDEX_STATE_SHIFT |
(code < NON_ASCII_PRINTABLE ? code : NON_ASCII_PRINTABLE)
];
switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) {
case ParserAction.PRINT:
// read ahead with loop unrolling
// Note: 0x20 (SP) is included, 0x7F (DEL) is excluded
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
let c = i;
const l4 = length - 4;
while (c < l4
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
) {}
if (c >= l4) {
while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) {
c++;
}
}
this._printHandler(data, i, c);
i = c - 1;
break;
case ParserAction.EXECUTE:
if (this._executeHandlers[code]) this._executeHandlers[code]();
@@ -758,8 +849,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this.precedingJoinState = 0;
break;
case ParserAction.CLEAR:
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserAction.DCS_HOOK:
@@ -783,8 +873,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
@@ -808,22 +897,23 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
case ParserAction.APC_START:
this._apcParser.start();
this._apcParser.start(this._collect << 8 | code);
break;
case ParserAction.APC_PUT:
// inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c
// allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) {
this._apcParser.put(data, i, j);
i = j - 1;
break;
}
if (j < length && (
(data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE
)) continue;
this._apcParser.put(data, i, j);
i = j - 1;
break;
}
break;
case ParserAction.APC_END:
@@ -833,8 +923,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
+7 -8
View File
@@ -7,6 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS
import { OscState, ParserConstants } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IOscHandler[] = [];
@@ -194,13 +195,13 @@ export class OscParser implements IOscParser {
export class OscHandler implements IOscHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = '';
private _data = new LimitedStringBuilder(OscHandler._payloadLimit);
private _hitLimit: boolean = false;
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
public start(): void {
this._data = '';
this._data.reset();
this._hitLimit = false;
}
@@ -208,9 +209,7 @@ export class OscHandler implements IOscHandler {
if (this._hitLimit) {
return;
}
this._data += utf32ToString(data, start, end);
if (this._data.length > OscHandler._payloadLimit) {
this._data = '';
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
@@ -220,18 +219,18 @@ export class OscHandler implements IOscHandler {
if (this._hitLimit) {
ret = false;
} else if (success) {
ret = this._handler(this._data);
ret = this._handler(this._data.toString());
if (ret instanceof Promise) {
// need to hold data until `ret` got resolved
// dont care for errors, data will be freed anyway on next start
return ret.then(res => {
this._data = '';
this._data.reset();
this._hitLimit = false;
return res;
});
}
}
this._data = '';
this._data.reset();
this._hitLimit = false;
return ret;
}
+27 -8
View File
@@ -4,10 +4,16 @@
*/
import { IParams, ParamsArray } from 'common/parser/Types';
// max value supported for a single param/subparam (clamped to positive int32 range)
const MAX_VALUE = 0x7FFFFFFF;
// max allowed subparams for a single sequence (hardcoded limitation)
const MAX_SUBPARAMS = 256;
const enum Constants {
/**
* Max value supported for a single param/subparam (clamped to positive int32 range)
*/
MAX_VALUE = 0x7FFFFFFF,
/**
* Max allowed subparams for a single sequence (hardcoded limitation)
*/
MAX_SUBPARAMS = 256
}
/**
* Params storage class.
@@ -70,7 +76,7 @@ export class Params implements IParams {
* @param maxSubParamsLength max length of storable sub parameters
*/
constructor(public maxLength: number = 32, public maxSubParamsLength: number = 32) {
if (maxSubParamsLength > MAX_SUBPARAMS) {
if (maxSubParamsLength > Constants.MAX_SUBPARAMS) {
throw new Error('maxSubParamsLength must not be greater than 256');
}
this.params = new Int32Array(maxLength);
@@ -129,6 +135,19 @@ export class Params implements IParams {
this._digitIsSub = false;
}
/**
* Reset and add 0 as first param (ZDM).
*/
public resetZdm(): void {
this.length = 1;
this._subParamsLength = 0;
this._rejectDigits = false;
this._rejectSubDigits = false;
this._digitIsSub = false;
this._subParamsIdx[0] = 0;
this.params[0] = 0;
}
/**
* Add a parameter value.
* `Params` only stores up to `maxLength` parameters, any later
@@ -146,7 +165,7 @@ export class Params implements IParams {
throw new Error('values lesser than -1 are not allowed');
}
this._subParamsIdx[this.length] = this._subParamsLength << 8 | this._subParamsLength;
this.params[this.length++] = value > MAX_VALUE ? MAX_VALUE : value;
this.params[this.length++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
}
/**
@@ -168,7 +187,7 @@ export class Params implements IParams {
if (value < -1) {
throw new Error('values lesser than -1 are not allowed');
}
this._subParams[this._subParamsLength++] = value > MAX_VALUE ? MAX_VALUE : value;
this._subParams[this._subParamsLength++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
this._subParamsIdx[this.length - 1]++;
}
@@ -224,6 +243,6 @@ export class Params implements IParams {
const store = this._digitIsSub ? this._subParams : this.params;
const cur = store[length - 1];
store[length - 1] = ~cur ? Math.min(cur * 10 + value, MAX_VALUE) : value;
store[length - 1] = ~cur ? Math.min(cur * 10 + value, Constants.MAX_VALUE) : value;
}
}
+5 -4
View File
@@ -32,6 +32,7 @@ export interface IParams {
clone(): IParams;
toArray(): ParamsArray;
reset(): void;
resetZdm(): void;
addParam(value: number): void;
addSubParam(value: number): void;
hasSubParams(idx: number): boolean;
@@ -107,7 +108,7 @@ export type EscFallbackHandlerType = (identifier: number) => void;
/**
* EXECUTE handler types.
*/
export type ExecuteHandlerType = () => boolean;
export type ExecuteHandlerType = (ident?: number) => boolean;
export type ExecuteFallbackHandlerType = (ident: number) => void;
/**
@@ -219,8 +220,8 @@ export interface IEscapeSequenceParser extends IDisposable {
clearOscHandler(ident: number): void;
setOscHandlerFallback(handler: OscFallbackHandlerType): void;
registerApcHandler(ident: number, handler: IApcHandler): IDisposable;
clearApcHandler(ident: number): void;
registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable;
clearApcHandler(id: IFunctionIdentifier): void;
setApcHandlerFallback(handler: ApcFallbackHandlerType): void;
setErrorHandler(handler: (state: IParsingState) => IParsingState): void;
@@ -251,7 +252,7 @@ export interface IDcsParser extends ISubParser<IDcsHandler, DcsFallbackHandlerTy
}
export interface IApcParser extends ISubParser<IApcHandler, ApcFallbackHandlerType> {
start(): void;
start(ident: number): void;
end(success: boolean, promiseResult?: boolean): void | Promise<boolean>;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export class BufferNamespaceApi extends Disposable implements IBufferNamespaceAp
super();
this._normal = new BufferApiView(this._core.buffers.normal, 'normal');
this._alternate = new BufferApiView(this._core.buffers.alt, 'alternate');
this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active));
this._register(this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active)));
}
public get active(): IBufferApi {
if (this._core.buffers.active === this._core.buffers.normal) { return this.normal; }
+2 -2
View File
@@ -34,7 +34,7 @@ export class ParserApi implements IParser {
public addOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this.registerOscHandler(ident, callback);
}
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._core.registerApcHandler(ident, callback);
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._core.registerApcHandler(id, callback);
}
}
+11 -9
View File
@@ -10,8 +10,10 @@ import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services';
import { Emitter } from 'common/Event';
export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars
export const MINIMUM_ROWS = 1;
export const enum BufferServiceConstants {
MINIMUM_COLS = 2, // Less than 2 can mess with wide chars
MINIMUM_ROWS = 1
}
export class BufferService extends Disposable implements IBufferService {
public serviceBrand: any;
@@ -37,8 +39,8 @@ export class BufferService extends Disposable implements IBufferService {
@ILogService logService: ILogService
) {
super();
this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS);
this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
this.cols = Math.max(optionsService.rawOptions.cols || 0, BufferServiceConstants.MINIMUM_COLS);
this.rows = Math.max(optionsService.rawOptions.rows || 0, BufferServiceConstants.MINIMUM_ROWS);
this.buffers = this._register(new BufferSet(optionsService, this, logService));
this._register(this.buffers.onBufferActivate(e => {
this._onScroll.fire(e.activeBuffer.ydisp);
@@ -67,17 +69,17 @@ export class BufferService extends Disposable implements IBufferService {
public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void {
const buffer = this.buffer;
let newLine: IBufferLine | undefined;
newLine = this._cachedBlankLine;
const topRow = buffer.ybase + buffer.scrollTop;
const bottomRow = buffer.ybase + buffer.scrollBottom;
let newLine: IBufferLine;
newLine = this._cachedBlankLine!;
if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) {
newLine = buffer.getBlankLine(eraseAttr, isWrapped);
this._cachedBlankLine = newLine;
}
newLine.isWrapped = isWrapped;
const topRow = buffer.ybase + buffer.scrollTop;
const bottomRow = buffer.ybase + buffer.scrollBottom;
if (buffer.scrollTop === 0) {
// Determine whether the buffer is going to be trimmed after insertion.
const willBufferBeTrimmed = buffer.lines.isFull;
+4 -5
View File
@@ -3,7 +3,6 @@
* @license MIT
*/
import { clone } from 'common/Clone';
import { Disposable } from 'common/Lifecycle';
import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types';
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
@@ -61,14 +60,14 @@ export class CoreService extends Disposable implements ICoreService {
) {
super();
this.isCursorInitialized = _optionsService.rawOptions.showCursorImmediately ?? false;
this.modes = clone(DEFAULT_MODES);
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
this.modes = structuredClone(DEFAULT_MODES);
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
}
public reset(): void {
this.modes = clone(DEFAULT_MODES);
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
this.modes = structuredClone(DEFAULT_MODES);
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
}

Some files were not shown because too many files have changed in this diff Show More