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
Jeffrey Chen 253d1e9beb Underline entire wrapped OSC 8 link on hover 2026-04-12 20:06:43 -07: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
Anthony Kim 34e0179355 Merge pull request #5794 from anthonykim1/anthonykim1/optionFBKitty
Fix macOS Option+key sending wrong codepoint in Kitty keyboard protocol
2026-04-05 22:53:54 -07:00
Anthony Kim bd722e7b07 keep things clean 2026-04-03 09:46:07 -07:00
Anthony Kim cccdd447a0 No need to mention vscode link 2026-04-03 09:45:01 -07:00
Anthony Kim 446bcb1f75 Fix option b and f 2026-04-03 09:26:32 -07:00
Anthony Kim 4316263457 Merge pull request #5790 from xtermjs/anthonykim1/closeKittyKeyboardGap
Do not report lock key presses without REPORT_ALL_KEYS in Kitty keyboard
2026-04-01 08:44:32 -07:00
Anthony Kim 4a35a4880d Do not report lock key presses without REPORT_ALL_KEYS in Kitty keyboard protocol 2026-03-31 16:45:52 -07:00
Daniel Imms ad730f2d54 Merge pull request #5777 from Tyler-V/fix/fit-addon-same-dimension-guard
fix(addon-fit): remove redundant same-dimension guard in fit()
2026-03-26 08:09:53 -07:00
Daniel Imms 9c7c163dd4 Merge pull request #5776 from Gaubee/fix/headless-bun-deno-compat
Fix @xterm/headless compatibility with Bun and Deno runtimes
2026-03-26 08:05:03 -07:00
Daniel Imms 8674bc5e82 Merge pull request #5780 from PerBothner/extendedAttrs-test
Move extendedAttributes function to TestUtils
2026-03-26 07:58:38 -07:00
Daniel Imms d552be8c45 Merge pull request #5783 from xtermjs/dependabot/npm_and_yarn/multi-bf05dc1ecf
Bump picomatch
2026-03-26 07:49:45 -07:00
Daniel Imms 082a51127f Merge pull request #5779 from xtermjs/dependabot/npm_and_yarn/flatted-3.4.2
Bump flatted from 3.3.3 to 3.4.2
2026-03-26 07:49:32 -07:00
dependabot[bot] 89100f6a3d Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 21:44:04 +00:00
Per Bothner 8149d4481f Fix bad import. 2026-03-22 22:04:21 -07:00
Per Bothner 2d9b21f5c7 Move extendedAttributes function to TestUtils so it can be used by InputHandler.test.ts.
This is a followup to pull request #5748.
It removes the remaining test uses of internal _extendedAttrs.
2026-03-22 20:53:13 -07:00
dependabot[bot] f43985c422 Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 10:28:04 +00:00
Tyler VorpahlandClaude Opus 4.6 9f6c7d7ced fix(addon-fit): remove redundant same-dimension guard in fit()
Terminal.resize() already no-ops internally when dimensions haven't
changed. The addon's guard prevented resize() from being called at all,
forcing callers using ResizeObserver-based layouts to use perturbation
workarounds (resize to rows-1, then fit) that race with the observer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:35:56 -04:00
GaubeeandClaude Opus 4.6 aa7716a333 Fix @xterm/headless compatibility with Bun and Deno runtimes
Replace `window` with `globalThis` in IdleTaskQueue export.
In Bun and Deno, `isNode` is false but `window` is undefined, causing
a ReferenceError. Using `globalThis` is safe in all JS runtimes and
makes the `isNode` guard unnecessary.

This is the only `in window` expression in code bundled into
@xterm/headless.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 10:30:12 +08:00
Daniel Imms 1432e20082 Merge pull request #5773 from xtermjs/dependabot/npm_and_yarn/yauzl-3.2.1
Bump yauzl from 2.10.0 to 3.2.1
2026-03-15 04:36:59 +09:00
Daniel Imms f1b0a96094 Merge pull request #5770 from mgabor3141/fix/viewport-sync-output-flicker
fix: defer viewport DOM sync during synchronized output (DEC 2026)
2026-03-15 04:35:17 +09:00
Daniel Imms 49096a21fd Delete src/browser/Viewport.test.ts
Trying to remove jsdom eventually, this test is also overly fragile, we'll just do without.
2026-03-15 04:27:01 +09:00
dependabot[bot] 63035678ec Bump yauzl from 2.10.0 to 3.2.1
Bumps [yauzl](https://github.com/thejoshwolfe/yauzl) from 2.10.0 to 3.2.1.
- [Commits](https://github.com/thejoshwolfe/yauzl/compare/2.10.0...3.2.1)

---
updated-dependencies:
- dependency-name: yauzl
  dependency-version: 3.2.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 20:11:05 +00:00
Daniel Imms 503abee8ae Merge pull request #5772 from Tyriar/5771
Prefer repeat over Array().join
2026-03-13 11:19:46 +09:00
Daniel Imms 4350536685 Prefer repeat over Array().join
Fixes #5771
2026-03-13 11:11:27 +09:00
mgabor3141 9feb963cc9 fix: defer viewport DOM sync during synchronized output
Defer Viewport DOM scroll updates while DEC 2026 synchronized output is
active, then flush once rendering resumes so DOM and canvas update
atomically.

Also update demo sizing to avoid relying on removed internal viewport
scrollBarWidth.
2026-03-12 18:51:38 +01:00
mgabor3141 f4042876c6 test: add viewport synchronized-output regression coverage (fails until fix) 2026-03-12 18:50:41 +01:00
mgabor3141 c294695444 refactor: plumb core service into Viewport constructor 2026-03-12 18:50:31 +01:00
Daniel Imms fe0952bb83 Merge pull request #5769 from Tyriar/4633
Only update necessary DOM range on selection change
2026-03-12 11:44:45 +09:00
Daniel Imms 5c2b19f2c8 Only update necessary DOM range on selection change
Fixes #4633
2026-03-12 11:36:33 +09: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
Daniel Imms fb25eb8f79 Merge pull request #5764 from Tyriar/5675
Apply font styles directly to spans
2026-03-11 16:09:13 +09:00
Daniel Imms e7a4578933 Don't apply color to the spans 2026-03-11 16:01:41 +09:00
Daniel Imms a05a3321d5 Apply font styles directly to spans
The universal selector was overriding this since the font styles
only applied to the div, not the span.

Fixes #5675
2026-03-11 13:44:22 +09:00
Daniel Imms 26ce9bd4d3 Merge pull request #5698 from ntomoya/fix/ime-middle-composition-suffix
Fix IME input when composing in the middle of the textarea
2026-03-11 13:20:16 +09:00
Daniel Imms 1c1aac0da8 Update src/browser/input/CompositionHelper.ts 2026-03-11 13:12:36 +09:00
Daniel Imms beeebca1a4 Merge branch 'master' into fix/ime-middle-composition-suffix 2026-03-11 13:06:35 +09:00
Daniel Imms fd1e53c63f Merge pull request #5762 from Tyriar/5760
Fix RTL breaking IME composition rendering
2026-03-11 12:49:50 +09:00
Daniel Imms 18262c87d9 Merge pull request #5761 from SergioChan/fix/textarea-sync-on-resize-3390
fix: sync helper textarea position after resize
2026-03-11 12:48:14 +09:00
Daniel Imms 2285ac6ab8 Fix RTL breaking IME composition rendering
Fixes #5760
2026-03-11 12:41:48 +09:00
sergiochan 5302e62c90 fix: sync helper textarea position after terminal resize 2026-03-10 20:38:45 -07:00
Daniel Imms af6731b533 Merge pull request #5685 from Tyriar/5377_scroll
Fix scrolling with touch
2026-03-11 12:27:34 +09:00
Daniel Imms 5b9b531012 Merge branch 'master' into 5377_scroll 2026-03-11 12:19:42 +09:00
Daniel Imms b0118dfd3a Merge pull request #5759 from SergioChan/fix/ime-sync-on-compositionstart-5734
fix(ime): resync textarea position when composition starts
2026-03-11 12:17:26 +09:00
Daniel Imms 6813fc175e Merge remote-tracking branch 'upstream/master' into 5377_scroll 2026-03-11 12:15:59 +09:00
Daniel Imms 409f049f39 Update src/browser/CoreBrowserTerminal.ts 2026-03-11 12:02:02 +09:00
sergiochan 9e1b89651a fix(ime): resync textarea position on composition start 2026-03-10 19:36:17 -07:00
Daniel Imms 89980fc565 Merge pull request #5758 from Tyriar/5754__5756
CoreMouseService -> MouseStateService, make mouse services more focused
2026-03-11 11:10:52 +09:00
Daniel Imms 16b5da5c4c Fix assertion, bring back triggerMouseEvent tests 2026-03-11 11:03:17 +09:00
Daniel Imms 4812abb24f Merge branch 'master' into 5754__5756 2026-03-11 10:52:13 +09:00
Daniel Imms 5f3a4646be Move non-state methods into MouseService
Fixes #5756
2026-03-11 10:49:55 +09:00
Daniel Imms d63445be94 Move custom mouse event handler into MouseStateService
Don't need to keep it in CoreBrowserTerminal
2026-03-11 10:36:35 +09:00
Daniel Imms 1400398abb Merge pull request #5757 from Tyriar/5754
Move bindMouse into MouseService
2026-03-11 10:32:42 +09:00
Daniel Imms 2331abe6bd Rename CoreMouseService to MouseStateService
Part of #5756
2026-03-11 10:31:57 +09:00
Daniel Imms b66128b239 Avoid unnecessary 2026-03-11 10:24:46 +09:00
Daniel Imms c727240f16 Fix cyclic dependency
Linkifier -> MouseService -> SelectionService -> Linkifier
2026-03-11 10:23:38 +09:00
Daniel Imms 649ce22081 Move bindMouse into MouseService
Fixes #5754
2026-03-11 10:10:13 +09:00
Daniel Imms 73adfdfd56 Merge pull request #5753 from Tyriar/scrollbar_warn
Suppress scrollbar warning in demo
2026-03-11 09:44:38 +09:00
Daniel Imms 1fc8a4687d Suppress scrollbar warning in demo 2026-03-11 09:36:05 +09:00
Daniel Imms c29e48c917 Merge pull request #5751 from Tyriar/5750
Fix error in loadTestLongLines
2026-03-11 09:05:23 +09:00
Daniel Imms 70fe22993b Merge pull request #5752 from anthonykim1/anthonykim1/fixKittyDisambiguateSpaceEncoding
Fix kitty keyboard disambiguate mode for space, enter, tab, backspace
2026-03-11 08:59:12 +09:00
Daniel Imms 4f3a7331bd Fix error in loadTestLongLines
Fixes #5750
2026-03-11 08:57:51 +09:00
Anthony Kim 6f3ce7b0fe comment 2026-03-10 00:30:29 -07:00
Anthony Kim fdb7346f3a Fix kitty keyboard disambiguate mode for space, enter, tab, backspace 2026-03-10 00:27:27 -07:00
Daniel Imms 783c4a34d2 Merge pull request #5747 from anthonykim1/anthonykim1/fixIMEoverFlowing
Fix IME composition text overflowing terminal
2026-03-10 11:18:48 +09:00
Daniel Imms 8a1760b924 Merge pull request #5748 from PerBothner/extendedAttrs-test
Fix testsuite access to internal _extendedAttrs
2026-03-10 11:16:00 +09:00
Daniel Imms 211db9d067 Merge pull request #5749 from Tyriar/readme
Build out getting started section
2026-03-10 11:04:50 +09:00
Daniel Imms ec6ad20e33 Clean up note about cjs 2026-03-10 11:04:09 +09:00
Daniel Imms 47235f47fe Build out getting started section
Fixes #5725
2026-03-10 11:01:07 +09:00
Per Bothner 8d6ff3a27b Fix testsuite access to internal _extendedAttrs 2026-03-09 18:19:04 -07:00
Anthony Kim 55281022a0 Fix IME composition text overflowing terminal 2026-03-09 14:32:38 -07:00
Daniel Imms ea50fbdd1e Merge pull request #5742 from Tyriar/5721
work around Safari sending keycode 13 (enter) when typing ctrl-c (take 2)
2026-03-09 13:54:14 +09:00
Daniel Imms 87bf81cf96 Call out hack, fix lint 2026-03-09 13:44:02 +09:00
Serge f155c65e5a work around Safari sending keycode 13 (enter) when typing ctrl-c on iphone, iPad, AppleVisionPro 2026-03-09 13:42:36 +09:00
Daniel Imms 413c87cbf9 Merge pull request #5700 from xtermjs/dependabot/npm_and_yarn/qs-6.14.2
Bump qs from 6.14.1 to 6.14.2
2026-03-09 13:16:29 +09:00
Daniel Imms 1620f8f5a8 Merge pull request #5726 from Amtrend/master
docs: add SSH Connection Manager to real-world uses
2026-03-09 13:15:32 +09:00
Daniel Imms e769f80a14 Merge branch 'master' into master 2026-03-09 13:15:13 +09:00
Daniel Imms 6651c4ca23 Merge pull request #5741 from Tyriar/5740
Improve readability of celldata/chardata assertions in unit tests
2026-03-09 13:14:22 +09:00
Daniel Imms cb78a53f57 Merge pull request #5736 from ScottMorse/master
README Files: Fix deprecated package references for xterm-headless and xterm
2026-03-09 13:09:53 +09:00
Daniel Imms 1c7b9f6d79 Merge pull request #5729 from masterkain/patch-1
Add WooTTY project to README
2026-03-09 13:09:18 +09:00
Daniel Imms 0d535100e4 Merge pull request #5727 from xtermjs/dependabot/npm_and_yarn/minimatch-3.1.5
Bump minimatch from 3.1.2 to 3.1.5
2026-03-09 13:08:55 +09:00
Daniel Imms 5e19624e71 Merge pull request #5739 from xtermjs/anthonykim1/addressPerfxterm
Remove redundant bitmask in RGB->RGBA conversion
2026-03-09 13:08:27 +09:00
Daniel Imms 671425bf5a Use helpers in other tests 2026-03-09 13:03:00 +09:00
Daniel Imms df5393ec45 Use helper function and const for cell data tests 2026-03-09 12:54:27 +09:00
Daniel Imms b2553c17b4 Clarify indexes in CharData and IColorRGB
Part of #5740
2026-03-09 12:42:55 +09:00
Anthony Kim 0cea95d3b3 Make code more readable 2026-03-08 20:33:01 -07:00
Anthony Kim 163868ff60 Remove redundant bitmask in RGB->RGBA conversion 2026-03-07 23:32:02 -08:00
Anthony Kim f4166a87ba Merge pull request #5737 from xtermjs/anthonykim1/fixKittyKeyboardFish
Fix ^C not working to end for newer fish + kitty keyboard protocol
2026-03-06 09:47:52 -08:00
ScottMorse f7a353cd34 Fix npm link to deprecated xterm package to @xterm/xterm 2026-03-06 00:14:20 -07:00
ScottMorse 7ec41471da Fix deprecated references of xterm-headless to @xterm/headless 2026-03-06 00:10:32 -07:00
Anthony Kim f5e81afba7 Fix ^C not working to end for newer fish + kitty keyboard protocol 2026-03-05 16:46:03 -08:00
Anthony Kim a12061c899 Merge pull request #5733 from xtermjs/anthonykim/exposeImageCount
add onImageAdded to api
2026-03-03 13:11:03 -08:00
Anthony Kim d6c872bb2c add onImageAdded to api 2026-03-03 12:19:14 -08:00
Kain a2cb36e6dd Add WooTTY project to README
Added WooTTY to the list of projects using xterm.js.
2026-03-02 04:23:54 +01:00
dependabot[bot] 114c66c86c Bump minimatch from 3.1.2 to 3.1.5
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-28 03:15:03 +00:00
amtrend fe9650ce16 docs: add SSH Connection Manager to real-world uses 2026-02-22 17:32:48 +03:00
Anthony Kim dedf3b1595 Merge pull request #5722 from xtermjs/anthonykim1/placementActions
Implment kitty graphics placement action
2026-02-19 20:22:18 -08:00
Anthony Kim 2158827b25 remove non-null assertion, improve lifecycle for bitmap 2026-02-19 15:51:22 -08:00
Anthony Kim b406797dc7 Merge branch 'master' into anthonykim1/placementActions 2026-02-19 15:07:58 -08:00
Anthony Kim 6e35b44f02 Merge pull request #5719 from xtermjs/anthonykim1/rectangleCrop
Implement rectangle crop + sub-cell offset
2026-02-18 19:37:49 -08:00
dependabot[bot] 375be97615 Bump qs from 6.14.1 to 6.14.2
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.14.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 15:15:15 +00:00
Tomoya Nakanishi c66b0ed168 Fix IME input when composing in the middle of the textarea
Track the composition range using the textarea selection and avoid re-sending trailing text that existed before composition started (notably in screenReaderMode).

Fixes #5456
2026-02-13 02:43:06 +09:00
Daniel Imms 38913e4ae1 Fix scrolling with touch
Part of #5377
2026-02-07 16:09:03 -08:00
143 changed files with 6583 additions and 3624 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
+36 -11
View File
@@ -24,7 +24,36 @@ First, you need to install the module. We ship exclusively through [npm](https:/
npm install --save @xterm/xterm
```
To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `<div id="terminal"></div>` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package.
```javascript
import { Terminal } from '@xterm/xterm';
```
Then instantiate a `Terminal` object, open it on an element and write to it:
```javascript
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
```
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
```javascript
pty.onData(data => term.write(data));
term.onData(data => pty.write(data));
```
Then make sure to also include the `css/xterm.css` file, for example in HTML:
```html
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
```
### Standalone example
Here is a complete standalone example in a HTML file:
```html
<!doctype html>
@@ -36,7 +65,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
<body>
<div id="terminal"></div>
<script>
var term = new Terminal();
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>
@@ -44,14 +73,6 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
</html>
```
### Importing
The recommended way to load xterm.js is via the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/xterm';
```
### Addons
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
@@ -94,7 +115,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
### Node.js Support
We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
## API
@@ -229,6 +250,10 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
- [**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>;
}
}
+7 -8
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) {
@@ -48,10 +50,7 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return;
}
// Force a full render
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
this._terminal.resize(dims.cols, dims.rows);
}
this._terminal.resize(dims.cols, dims.rows);
}
public proposeDimensions(): ITerminalDimensions | undefined {
@@ -89,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;
}
+11
View File
@@ -119,6 +119,17 @@ test.describe('FitAddon', () => {
strictEqual(await ctx.proxy.cols, 2);
strictEqual(await ctx.proxy.rows, 1);
});
test('same dimensions', async function(): Promise<any> {
await setDimensions();
await ctx.page.evaluate(`window.fit.fit()`);
const cols: number = await ctx.proxy.cols;
const rows: number = await ctx.proxy.rows;
// Calling fit() again at the same container size should not throw
await ctx.page.evaluate(`window.fit.fit()`);
strictEqual(await ctx.proxy.cols, cols);
strictEqual(await ctx.proxy.rows, rows);
});
});
});
+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;
}
+51 -5
View File
@@ -5,6 +5,7 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
@@ -15,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)
@@ -62,6 +104,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
constructor(opts?: Partial<IImageAddonOptions>) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -74,6 +118,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
this._disposables.length = 0;
this._handlers.clear();
this._onImageAdded.dispose();
}
private _disposeLater(...args: IDisposable[]): void {
@@ -88,6 +133,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// internal data structures
this._renderer = new ImageRenderer(terminal);
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
this._storage.onImageAdded = () => this._onImageAdded.fire();
// enable size reports
if (this._opts.enableSizeReports) {
@@ -161,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) {
+2
View File
@@ -124,6 +124,7 @@ export class ImageStorage implements IDisposable {
private _pixelLimit: number = 2500000;
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor(
@@ -336,6 +337,7 @@ export class ImageStorage implements IDisposable {
// finally add the image
this._images.set(imageId, imgSpec);
this.onImageAdded?.();
return imageId;
}
+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();
}
@@ -377,14 +376,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
if (cmd.id === undefined) {
return true;
}
const image = this._kittyStorage.getImage(cmd.id);
const id = cmd.id;
const image = this._kittyStorage.getImage(id);
if (!image) {
this._sendResponse(cmd.id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
const result = this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(cmd.id!, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
return true;
});
}
@@ -483,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) {
@@ -511,6 +511,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
break;
case 'i':
case 'I':
// TODO: When placement id tracking is implemented (see TODO in
// KittyImageStorage), d=i with p=<pid> should delete only that
// specific placement, while d=i without p should delete all
// placements for the image.
if (cmd.id !== undefined) {
const pending = this._pendingTransmissions.get(cmd.id);
if (pending) {
@@ -529,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\\`;
@@ -546,135 +550,137 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
let bitmap = await this._createBitmap(image);
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
try {
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
const maxCropW = Math.max(0, bitmap.width - cropX);
const maxCropH = Math.max(0, bitmap.height - cropY);
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
const maxCropW = Math.max(0, bitmap.width - cropX);
const maxCropH = Math.max(0, bitmap.height - cropY);
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
if (finalCropW === 0 || finalCropH === 0) {
bitmap.close();
throw new Error('invalid source rectangle');
}
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
bitmap.close();
bitmap = cropped;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
let imgCols: number;
let imgRows: number;
if (cmd.columns !== undefined && cmd.rows !== undefined) {
imgCols = cmd.columns;
imgRows = cmd.rows;
} else if (cmd.columns !== undefined) {
imgCols = cmd.columns;
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
} else if (cmd.rows !== undefined) {
imgRows = cmd.rows;
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
} else {
imgCols = Math.ceil(bitmap.width / cw);
imgRows = Math.ceil(bitmap.height / ch);
}
let w = bitmap.width;
let h = bitmap.height;
// Scale bitmap to fit placement rectangle when c/r are specified
if (cmd.columns !== undefined || cmd.rows !== undefined) {
w = Math.round(imgCols * cw);
h = Math.round(imgRows * ch);
}
if (w * h > this._opts.pixelLimit) {
bitmap.close();
throw new Error('image exceeds pixel limit');
}
// Save cursor position before addImage modifies it
const buffer = this._coreTerminal._core.buffer;
const savedX = buffer.x;
const savedY = buffer.y;
const savedYbase = buffer.ybase;
// Determine layer based on z-index: negative = behind text, 0+ = on top.
// When z<0 we always use the bottom layer even without allowTransparency —
// the image will simply be hidden behind the opaque text background, which
// is the correct behavior (client asked for "behind text").
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
let finalBitmap = bitmap;
if (w !== bitmap.width || h !== bitmap.height) {
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
}
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
if (xOffset !== 0 || yOffset !== 0) {
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
// total placement area remains c*cw × r*ch pixels and the offset image
// is clipped to fit. When c/r are unset, the padded canvas determines
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : finalBitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : finalBitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
finalBitmap.close();
throw new Error('Failed to create offset canvas context');
if (finalCropW === 0 || finalCropH === 0) {
throw new Error('invalid source rectangle');
}
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
bitmap.close();
bitmap = cropped;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
let imgCols: number;
let imgRows: number;
if (cmd.columns !== undefined && cmd.rows !== undefined) {
imgCols = cmd.columns;
imgRows = cmd.rows;
} else if (cmd.columns !== undefined) {
imgCols = cmd.columns;
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
} else if (cmd.rows !== undefined) {
imgRows = cmd.rows;
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
} else {
imgCols = Math.ceil(bitmap.width / cw);
imgRows = Math.ceil(bitmap.height / ch);
}
let w = bitmap.width;
let h = bitmap.height;
// Scale bitmap to fit placement rectangle when c/r are specified
if (cmd.columns !== undefined || cmd.rows !== undefined) {
w = Math.round(imgCols * cw);
h = Math.round(imgRows * ch);
}
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
finalBitmap.close();
finalBitmap = offsetBitmap;
w = finalBitmap.width;
h = finalBitmap.height;
if (w * h > this._opts.pixelLimit) {
finalBitmap.close();
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(finalBitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(finalBitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
// Save cursor position before addImage modifies it
const buffer = this._coreTerminal._core.buffer;
const savedX = buffer.x;
const savedY = buffer.y;
const savedYbase = buffer.ybase;
// Kitty cursor movement
// Per spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
if (cmd.cursorMovement === 1) {
// C=1: restore cursor to position before image was placed
const scrolled = buffer.ybase - savedYbase;
buffer.x = savedX;
// Can't restore cursor to scrollback?
buffer.y = Math.max(savedY - scrolled, 0);
} else {
// Default (C=0): advance cursor horizontally past the image
// addImage already positioned cursor on the last row via lineFeeds
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
// Determine layer based on z-index: negative = behind text, 0+ = on top.
// When z<0 we always use the bottom layer even without allowTransparency —
// the image will simply be hidden behind the opaque text background, which
// is the correct behavior (client asked for "behind text").
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
if (w !== bitmap.width || h !== bitmap.height) {
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
bitmap = scaled;
}
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
if (xOffset !== 0 || yOffset !== 0) {
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
// total placement area remains c*cw × r*ch pixels and the offset image
// is clipped to fit. When c/r are unset, the padded canvas determines
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
}
offsetCtx.drawImage(bitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
bitmap.close();
bitmap = offsetBitmap;
w = bitmap.width;
h = bitmap.height;
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(bitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(bitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
// Kitty cursor movement
// Per spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
if (cmd.cursorMovement === 1) {
// C=1: restore cursor to position before image was placed
const scrolled = buffer.ybase - savedYbase;
buffer.x = savedX;
// Can't restore cursor to scrollback?
buffer.y = Math.max(savedY - scrolled, 0);
} else {
// Default (C=0): advance cursor horizontally past the image
// addImage already positioned cursor on the last row via lineFeeds
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
}
} catch (e) {
bitmap?.close();
throw e;
}
}
@@ -716,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) {
@@ -727,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
@@ -745,22 +751,22 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
const b1 = src32[srcOffset++];
const b2 = src32[srcOffset++];
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
dst32[dstOffset++] = (b0 & 0x00FFFFFF) | 0xFF000000;
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
dst32[dstOffset++] = 0xFF000000 | b0;
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
}
// 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 {
@@ -20,6 +20,15 @@ export class KittyImageStorage implements IDisposable {
private _nextImageId = 1;
private readonly _images: Map<number, IKittyImageData> = new Map();
// TODO: Support multiple placements per image. The kitty spec identifies
// placements by an (image id, placement id) pair — same i + different p
// values should coexist, and same i + same p should replace the prior
// placement. Currently we track only one storage entry per kitty image id,
// so multiple placements of the same image overwrite each other. Fixing
// this requires changing these maps to Map<number, Map<number, number>>
// (kittyId → placementId → storageId) and updating addImage/deleteById
// accordingly. The underlying shared ImageStorage would also need to
// support multiple entries per logical image.
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
private readonly _storageIdToKittyId: Map<number, number> = new Map();
+86 -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);
});
@@ -196,6 +196,18 @@ test.describe('ImageAddon', () => {
});
test.describe('image lifecycle & eviction', () => {
test('onImageAdded fires for each image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 3);
});
test('delete image once scrolled off', async () => {
await ctx.proxy.write(SIXEL_SEQ_0);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
@@ -300,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]);
});
}
});
});
/**
@@ -333,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')`);
}
+65 -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;
@@ -2159,6 +2206,19 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
});
});
test.describe('onImageAdded callback', () => {
test('onImageAdded fires for each kitty image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
});
});
});
/**
+6 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ITerminalAddon } from '@xterm/xterm';
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
declare module '@xterm/addon-image' {
export interface IImageAddonOptions {
@@ -116,6 +116,11 @@ declare module '@xterm/addon-image' {
*/
public showPlaceholder: boolean;
/**
* Event fired whenever a new image is added to storage.
*/
public readonly onImageAdded: IEvent<void>;
/**
* Get original image canvas at buffer position.
*/
+3 -3
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,9 +37,9 @@
"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": "^2.10.0"
"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
+5 -1
View File
@@ -617,8 +617,12 @@ function addDomListener(element: HTMLElement, type: string, handler: (...args: a
}
function updateTerminalSize(): void {
const showScrollbar = term!.options.scrollbar?.showScrollbar ?? true;
const scrollBarWidth = (term!.options.scrollback === 0 || !showScrollbar)
? 0
: (term!.options.scrollbar?.width ?? 14);
const width = optionsWindow.autoResize ? '100%'
: ((term as any).dimensions.css.canvas.width + (term as any)._core.viewport.scrollBarWidth).toString() + 'px';
: ((term as any).dimensions.css.canvas.width + scrollBarWidth).toString() + 'px';
const height = optionsWindow.autoResize ? '100%'
: ((term as any).dimensions.css.canvas.height).toString() + 'px';
terminalContainer!.style.width = width;
@@ -85,7 +85,7 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow {
terminal.element.addEventListener('mousemove', (e: MouseEvent) => {
const core = (terminal as any)._core;
const coords = core._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
if (!coords) {
this._clearDisplay();
return;
@@ -116,6 +116,7 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
'linkHandler',
'logger',
'quirks',
'scrollbar',
'theme',
'vtExtensions',
'windowOptions',
@@ -251,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();
}
+12 -5
View File
@@ -705,13 +705,13 @@ function loadTest(term: Terminal, addons: AddonCollection): void {
term.write('', () => {
const time = Math.round(performance.now() - start);
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
}
function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise<void> {
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
const testData = [];
let byteCount = 0;
@@ -729,16 +729,23 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
testData.push(data);
}
const start = performance.now();
for (let i = 0; i < 1024 * 50; i++) {
for (let i = 0; i < 1024; i++) {
for (const d of testData) {
term.write(d);
try {
term.write(d);
} catch {
// Flush events when cap is hit and try again(workaround for not having flow control in
// demo)
await new Promise<void>(r => term.write('', () => r()));
term.write(d);
}
}
}
// Wait for all data to be parsed before evaluating time
term.write('', () => {
const time = Math.round(performance.now() - start);
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
+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', {
+5 -5
View File
@@ -2,17 +2,17 @@
⚠ This package is experimental
`xterm-headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
`@xterm/headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/@xterm/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
## Getting Started
First, you need to install the module, we ship exclusively through npm, so you need that installed and then add xterm.js as a dependency by running:
```sh
npm install xterm-headless
npm install @xterm/headless
```
Then import as you would a regular node package. The recommended way to load `xterm-headless` is with TypeScript and the ES6 module syntax:
Then import as you would a regular node package. The recommended way to load `@xterm/headless` is with TypeScript and the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/headless';
@@ -20,12 +20,12 @@ import { Terminal } from '@xterm/headless';
## API
The full API for `xterm-headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
The full API for `@xterm/headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
### Addons
Addons in `xterm-headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
Addons in `@xterm/headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
Currently no official addons are packaged on npm.
+132 -794
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();
}
}
+33 -283
View File
@@ -36,16 +36,17 @@ import { CharSizeService } from 'browser/services/CharSizeService';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
import { LinkProviderService } from 'browser/services/LinkProviderService';
import { MouseCoordsService } from 'browser/services/MouseCoordsService';
import { MouseService } from 'browser/services/MouseService';
import { RenderService } from 'browser/services/RenderService';
import { SelectionService } from 'browser/services/SelectionService';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { ThemeService } from 'browser/services/ThemeService';
import { KeyboardService } from 'browser/services/KeyboardService';
import { channels, color, rgb } from 'common/Color';
import { CoreTerminal } from 'common/CoreTerminal';
import * as Browser from 'common/Platform';
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { IBuffer } from 'common/buffer/Types';
import { C0, C1ESCAPED } from 'common/data/EscapeSequences';
@@ -77,7 +78,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
public browser: IBrowser = Browser as any;
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
// Browser services
private readonly _decorationService: DecorationService;
@@ -87,6 +87,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Optional browser services
private _charSizeService: ICharSizeService | undefined;
private _coreBrowserService: ICoreBrowserService | undefined;
private _mouseCoordsService: IMouseCoordsService | undefined;
private _mouseService: IMouseService | undefined;
private _renderService: IRenderService | undefined;
private _themeService: IThemeService | undefined;
@@ -413,7 +414,16 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => this._compositionHelper!.compositionstart()));
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => {
// Ensure the textarea is synced to the latest cursor location before composition begins. This
// is to workaround a problem where highly dynamic TUIs like agentic CLIs reprint agressively
// would cause the IME to appear in the wrong position. The theory is that when the IME is
// triggered during a partial render the textarea position becomes locked and will not move
// until it is hidden and a custom move occurs.
this._syncTextArea();
this._compositionHelper!.compositionstart();
this._compositionHelper!.updateCompositionElements();
}));
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
@@ -543,8 +553,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
this._helperContainer.appendChild(this._compositionView);
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
@@ -553,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());
}
@@ -563,7 +574,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._renderService!.handleCursorMove();
this._syncTextArea();
}));
this._register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows)));
this._register(this.onResize(() => {
this._renderService!.handleResize(this.cols, this.rows);
this._syncTextArea();
}));
this._register(this.onBlur(() => this._renderService!.handleBlur()));
this._register(this.onFocus(() => this._renderService!.handleFocus()));
@@ -579,6 +593,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
linkifier
));
this._instantiationService.setService(ISelectionService, this._selectionService);
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
@@ -602,7 +618,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
// apply mouse event classes set by escape codes before terminal was attached
if (this.coreMouseService.areMouseEventsActive) {
if (this.mouseStateService.areMouseEventsActive) {
this._selectionService.disable();
this.element.classList.add('enable-mouse-events');
} else {
@@ -638,285 +654,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Listen for mouse events and translate
// them into terminal mouse protocols.
this.bindMouse();
this._mouseService.bindMouse({
element: this.element!,
screenElement: this.screenElement!,
document: this._document!,
handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
}, disposable => this._register(disposable), () => this.focus());
}
private _createRenderer(): IRenderer {
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
}
/**
* Bind certain mouse events to the terminal.
* By default only 3 button + wheel up/down is ativated. For higher buttons
* no mouse report will be created. Typically the standard actions will be active.
*
* There are several reasons not to enable support for higher buttons/wheel:
* - Button 4 and 5 are typically used for history back and forward navigation,
* there is no straight forward way to supress/intercept those standard actions.
* - Support for higher buttons does not work in some platform/browser combinations.
* - Left/right wheel was not tested.
* - Emulators vary in mouse button support, typically only 3 buttons and
* wheel up/down work reliable.
*
* TODO: Move mouse event code into its own file.
*/
public bindMouse(): void {
const self = this;
const el = this.element!;
// send event to CoreMouseService
function sendEvent(ev: MouseEvent | WheelEvent): boolean {
// Get mouse coordinates
const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!);
if (!pos) {
return false;
}
let but: CoreMouseButton;
let action: CoreMouseAction | undefined;
switch ((ev as any).overrideType || ev.type) {
case 'mousemove':
action = CoreMouseAction.MOVE;
if (ev.buttons === undefined) {
// buttons is not supported on macOS, try to get a value from button instead
but = CoreMouseButton.NONE;
if (ev.button !== undefined) {
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
}
} else {
// according to MDN buttons only reports up to button 5 (AUX2)
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
ev.buttons & 2 ? CoreMouseButton.RIGHT :
CoreMouseButton.NONE; // fallback to NONE
}
break;
case 'mouseup':
action = CoreMouseAction.UP;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'mousedown':
action = CoreMouseAction.DOWN;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'wheel':
if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) {
return false;
}
const deltaY = (ev as WheelEvent).deltaY;
if (deltaY === 0) {
return false;
}
const lines = self.coreMouseService.consumeWheelEvent(
ev as WheelEvent,
self._renderService?.dimensions?.device?.cell?.height,
self._coreBrowserService?.dpr
);
if (lines === 0) {
return false;
}
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
but = CoreMouseButton.WHEEL;
break;
default:
// dont handle other event types by accident
return false;
}
// exit if we cannot determine valid button/action values
// do nothing for higher buttons than wheel
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
return false;
}
return self.coreMouseService.triggerMouseEvent({
col: pos.col,
row: pos.row,
x: pos.x,
y: pos.y,
button: but,
action,
ctrl: ev.ctrlKey,
alt: ev.altKey,
shift: ev.shiftKey
});
}
/**
* Event listener state handling.
* We listen to the onProtocolChange event of CoreMouseService and put
* requested listeners in `requestedEvents`. With this the listeners
* have all bits to do the event listener juggling.
* Note: 'mousedown' currently is "always on" and not managed
* by onProtocolChange.
*/
const requestedEvents: { [key: string]: ((ev: MouseEvent | WheelEvent) => void) | null } = {
mouseup: null,
wheel: null,
mousedrag: null,
mousemove: null
};
const eventListeners: { [key: string]: (ev: any) => void | boolean } = {
mouseup: (ev: MouseEvent) => {
sendEvent(ev);
if (!ev.buttons) {
// if no other button is held remove global handlers
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
if (requestedEvents.mousedrag) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
}
}
},
wheel: (ev: WheelEvent) => {
sendEvent(ev);
ev.preventDefault();
ev.stopPropagation();
return false;
},
mousedrag: (ev: MouseEvent) => {
// deal only with move while a button is held
if (ev.buttons) {
sendEvent(ev);
}
},
mousemove: (ev: MouseEvent) => {
// deal only with move without any button
if (!ev.buttons) {
sendEvent(ev);
}
}
};
this._register(this.coreMouseService.onProtocolChange(events => {
// apply global changes on events
if (events) {
if (this.optionsService.rawOptions.logLevel === 'debug') {
this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events));
}
this.element!.classList.add('enable-mouse-events');
this._selectionService!.disable();
} else {
this._logService.debug('Unbinding from mouse events.');
this.element!.classList.remove('enable-mouse-events');
this._selectionService!.enable();
}
// add/remove handlers from requestedEvents
if (!(events & CoreMouseEventType.MOVE)) {
el.removeEventListener('mousemove', requestedEvents.mousemove!);
requestedEvents.mousemove = null;
} else if (!requestedEvents.mousemove) {
el.addEventListener('mousemove', eventListeners.mousemove);
requestedEvents.mousemove = eventListeners.mousemove;
}
if (!(events & CoreMouseEventType.WHEEL)) {
el.removeEventListener('wheel', requestedEvents.wheel!);
requestedEvents.wheel = null;
} else if (!requestedEvents.wheel) {
el.addEventListener('wheel', eventListeners.wheel, { passive: false });
requestedEvents.wheel = eventListeners.wheel;
}
if (!(events & CoreMouseEventType.UP)) {
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
requestedEvents.mouseup = null;
} else {
requestedEvents.mouseup ??= eventListeners.mouseup;
}
if (!(events & CoreMouseEventType.DRAG)) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!);
requestedEvents.mousedrag = null;
} else {
requestedEvents.mousedrag ??= eventListeners.mousedrag;
}
}));
// force initial onProtocolChange so we dont miss early mouse requests
this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol;
// Ensure document-level listeners are removed on dispose
this._register(toDisposable(() => {
if (requestedEvents.mouseup) {
this._document!.removeEventListener('mouseup', requestedEvents.mouseup);
}
if (requestedEvents.mousedrag) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
}
}));
/**
* "Always on" event listeners.
*/
this._register(addDisposableListener(el, 'mousedown', (ev: MouseEvent) => {
ev.preventDefault();
this.focus();
// Don't send the mouse button to the pty if mouse events are disabled or
// if the selection manager is having selection forced (ie. a modifier is
// held).
if (!this.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) {
return;
}
sendEvent(ev);
// Register additional global handlers which should keep reporting outside
// of the terminal element.
// Note: Other emulators also do this for 'mousedown' while a button
// is held, we currently limit 'mousedown' to the terminal only.
if (requestedEvents.mouseup) {
this._document!.addEventListener('mouseup', requestedEvents.mouseup);
}
if (requestedEvents.mousedrag) {
this._document!.addEventListener('mousemove', requestedEvents.mousedrag);
}
}));
this._register(addDisposableListener(el, 'wheel', (ev: WheelEvent) => {
// do nothing, if app side handles wheel itself
if (requestedEvents.wheel) return;
if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) {
return false;
}
if (!this.buffer.hasScrollback) {
// Convert wheel events into up/down events when the buffer does not have scrollback, this
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
// events are not enabled.
// This used implementation used get the actual lines/partial lines scrolled from the
// viewport but since moving to the new viewport implementation has been simplified to
// simply send a single up or down sequence.
// Do nothing if there's no vertical scroll
const deltaY = (ev as WheelEvent).deltaY;
if (deltaY === 0) {
return false;
}
const lines = self.coreMouseService.consumeWheelEvent(
ev as WheelEvent,
self._renderService?.dimensions?.device?.cell?.height,
self._coreBrowserService?.dpr
);
if (lines === 0) {
ev.preventDefault();
ev.stopPropagation();
return false;
}
// Construct and send sequences
const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
this.coreService.triggerDataEvent(sequence, true);
ev.preventDefault();
ev.stopPropagation();
return false;
}
}, { passive: false }));
}
/**
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
* opportunity.
@@ -990,7 +739,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
}
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
this._customWheelEventHandler = customWheelEventHandler;
this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
@@ -1360,6 +1109,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._setup();
super.reset();
this._mouseService?.reset();
this._selectionService?.reset();
this._decorationService.reset();
+40
View File
@@ -43,6 +43,20 @@ describe('Linkifier2', () => {
},
activate: () => { }
};
const multiLineLink: ILink = {
text: 'foo',
range: {
start: {
x: 2,
y: 1
},
end: {
x: 4,
y: 2
}
},
activate: () => { }
};
beforeEach(() => {
const dom = new jsdom.JSDOM();
@@ -86,4 +100,30 @@ describe('Linkifier2', () => {
linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any);
});
it('onShowLinkUnderline event range is correct for wrapped links', done => {
linkifier.onShowLinkUnderline(e => {
assert.equal(multiLineLink.range.start.x - 1, e.x1);
assert.equal(multiLineLink.range.start.y - 1, e.y1);
assert.equal(multiLineLink.range.end.x, e.x2);
assert.equal(multiLineLink.range.end.y - 1, e.y2);
done();
});
linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
});
it('onHideLinkUnderline event range is correct for wrapped links', done => {
linkifier.onHideLinkUnderline(e => {
assert.equal(multiLineLink.range.start.x - 1, e.x1);
assert.equal(multiLineLink.range.start.y - 1, e.y1);
assert.equal(multiLineLink.range.end.x, e.x2);
assert.equal(multiLineLink.range.end.y - 1, e.y2);
done();
});
linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
});
});
+8 -8
View File
@@ -7,7 +7,7 @@ import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifie
import { Disposable, dispose, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { IBufferService } from 'common/services/Services';
import { ILinkProviderService, IMouseService, IRenderService } from './services/Services';
import { ILinkProviderService, IMouseCoordsService, IRenderService } from './services/Services';
import { Emitter } from 'common/Event';
import { addDisposableListener } from 'browser/Dom';
@@ -30,7 +30,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
constructor(
private readonly _element: HTMLElement,
@IMouseService private readonly _mouseService: IMouseService,
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IRenderService private readonly _renderService: IRenderService,
@IBufferService private readonly _bufferService: IBufferService,
@ILinkProviderService private readonly _linkProviderService: ILinkProviderService
@@ -60,7 +60,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
private _handleMouseMove(event: MouseEvent): void {
this._lastMouseEvent = event;
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
const position = this._positionFromMouseEvent(event, this._element);
if (!position) {
return;
}
@@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
const position = this._positionFromMouseEvent(event, this._element);
if (!position) {
return;
}
@@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
if (!position) {
return;
@@ -312,7 +312,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
this._clearCurrentLink(start, end);
if (this._lastMouseEvent) {
// re-eval previously active link after changes
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!);
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
if (position) {
this._askForLink(position, false);
}
@@ -378,8 +378,8 @@ export class Linkifier extends Disposable implements ILinkifier2 {
* Get the buffer position from a mouse event
* @param event
*/
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined {
const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined {
const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
if (!coords) {
return;
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { OscLinkProvider } from 'browser/OscLinkProvider';
import { ILink } from 'browser/Types';
import { createCellData, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
import { IBufferService, IOscLinkService } from 'common/services/Services';
import { IBufferLine, IOscLinkData } from 'common/Types';
class TestOscLinkService implements IOscLinkService {
public serviceBrand: any;
public registerLink(_linkData: IOscLinkData): number { return 0; }
public addLineToLink(_linkId: number, _y: number): void { }
public getLinkData(linkId: number): IOscLinkData | undefined {
return { uri: `https://example.com/${linkId}` };
}
}
function setText(line: IBufferLine | undefined, x: number, text: string): void {
if (!line) {
throw new Error('Missing buffer line');
}
for (let i = 0; i < text.length; i++) {
line.setCell(x + i, createCellData(0, text[i], 1));
}
}
function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void {
if (!line) {
throw new Error('Missing buffer line');
}
for (let i = 0; i < text.length; i++) {
const cell = createCellData(0, text[i], 1);
cell.extended.urlId = linkId;
cell.updateExtended();
line.setCell(x + i, cell);
}
}
function getLinks(provider: OscLinkProvider, y: number): Promise<ILink[]> {
return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? [])));
}
describe('OscLinkProvider', () => {
let bufferService: IBufferService;
let provider: OscLinkProvider;
beforeEach(() => {
const optionsService = new MockOptionsService();
bufferService = new MockBufferService(5, 5, optionsService);
provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService());
});
it('expands a wrapped link range backward to the previous line', async () => {
const line1 = bufferService.buffer.lines.get(0);
const line2 = bufferService.buffer.lines.get(1);
setText(line1, 0, 'aa');
setUrl(line1, 2, 'bbb', 1);
setUrl(line2, 0, 'cccc', 1);
setText(line2, 4, 'x');
line2!.isWrapped = true;
const links = await getLinks(provider, 2);
assert.lengthOf(links, 1);
assert.deepEqual(links[0].range, {
start: { x: 3, y: 1 },
end: { x: 4, y: 2 }
});
});
it('expands a wrapped link range forward when a link ends at line boundary', async () => {
const line1 = bufferService.buffer.lines.get(0);
const line2 = bufferService.buffer.lines.get(1);
setUrl(line1, 0, 'aaaaa', 1);
setUrl(line2, 0, 'bb', 1);
setText(line2, 2, 'ccc');
line2!.isWrapped = true;
const links = await getLinks(provider, 1);
assert.lengthOf(links, 1);
assert.deepEqual(links[0].range, {
start: { x: 1, y: 1 },
end: { x: 2, y: 2 }
});
});
it('does not merge wrapped links with different url ids', async () => {
const line1 = bufferService.buffer.lines.get(0);
const line2 = bufferService.buffer.lines.get(1);
setUrl(line1, 0, 'aaaaa', 1);
setUrl(line2, 0, 'bbb', 2);
setText(line2, 3, 'cc');
line2!.isWrapped = true;
const links = await getLinks(provider, 1);
assert.lengthOf(links, 1);
assert.deepEqual(links[0].range, {
start: { x: 1, y: 1 },
end: { x: 5, y: 1 }
});
});
});
+79 -13
View File
@@ -6,6 +6,7 @@
import { IBufferRange, ILink } from 'browser/Types';
import { ILinkProvider } from 'browser/services/Services';
import { CellData } from 'common/buffer/CellData';
import { IBufferLine } from 'common/Types';
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
export class OscLinkProvider implements ILinkProvider {
@@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider {
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
if (text) {
// These ranges are 1-based
const range: IBufferRange = {
start: {
x: currentStart + 1,
y
},
end: {
// Offset end x if it's a link that ends on the last cell in the line
x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
y
}
};
const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0);
const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId);
let ignoreLink = false;
if (!linkHandler?.allowNonHttpProtocols) {
try {
@@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider {
// id
callback(result);
}
/**
* Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id.
*/
private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange {
let startY = y;
let finalStartX = startX;
let endY = y;
let finalEndX = endX;
// Expand upward only when this segment starts at column 0 and the current line is wrapped.
while (finalStartX === 0) {
const currentLine = this._bufferService.buffer.lines.get(startY - 1);
if (!currentLine?.isWrapped) {
break;
}
const previousLine = this._bufferService.buffer.lines.get(startY - 2);
if (!previousLine) {
break;
}
const previousLineLength = previousLine.getTrimmedLength();
if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) {
break;
}
let previousStartX = previousLineLength - 1;
while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) {
previousStartX--;
}
startY--;
finalStartX = previousStartX;
}
// Expand downward only when this segment reaches trimmed EOL and the next line is wrapped.
while (true) {
const currentLine = this._bufferService.buffer.lines.get(endY - 1);
if (!currentLine) {
break;
}
const currentLineLength = currentLine.getTrimmedLength();
if (finalEndX !== currentLineLength) {
break;
}
const nextLine = this._bufferService.buffer.lines.get(endY);
if (!nextLine?.isWrapped) {
break;
}
const nextLineLength = nextLine.getTrimmedLength();
if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) {
break;
}
let nextEndX = 1;
while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) {
nextEndX++;
}
endY++;
finalEndX = nextEndX;
}
// IBufferRange uses 1-based coordinates.
return {
start: {
x: finalStartX + 1,
y: startY
},
end: {
x: finalEndX,
y: endY
}
};
}
private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean {
const cell = this._workCell;
line.loadCell(x, cell);
return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId;
}
}
function defaultActivate(e: MouseEvent, uri: string): void {
+43 -43
View File
@@ -8,7 +8,7 @@ import type { IBrowser } from 'browser/Types';
import { assert } from 'chai';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { MockUnicodeService } from 'common/TestUtils.test';
import { MockUnicodeService, createCellData } from 'common/TestUtils.test';
import { IMarker } from 'common/Types';
const INIT_COLS = 80;
@@ -440,8 +440,8 @@ describe('Terminal', () => {
describe('scroll() function', () => {
describe('when scrollback > 0', () => {
it('should create a new line and scroll', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.scroll(DEFAULT_ATTR_DATA.clone());
assert.equal(term.buffer.lines.length, INIT_ROWS + 1);
@@ -451,9 +451,9 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -463,11 +463,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.y = 3;
term.buffer.scrollBottom = 3;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -481,11 +481,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.buffer.scrollBottom = 3;
@@ -506,9 +506,9 @@ describe('Terminal', () => {
});
it('should create a new line and shift everything up', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
assert.equal(term.buffer.lines.length, INIT_ROWS);
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -521,9 +521,9 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -533,11 +533,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.y = 3;
term.buffer.scrollBottom = 3;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -550,11 +550,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.buffer.scrollBottom = 3;
@@ -871,7 +871,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 1);
});
it('multiple combined é', async () => {
await term.writeP(Array(100).join('e\u0301'));
await term.writeP('e\u0301'.repeat(99));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
assert.equal(cell.getChars(), 'e\u0301');
@@ -884,7 +884,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 1);
});
it('multiple surrogate with combined', async () => {
await term.writeP(Array(100).join('\uD800\uDC00\u0301'));
await term.writeP('\uD800\uDC00\u0301'.repeat(99));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
assert.equal(cell.getChars(), '\uD800\uDC00\u0301');
@@ -912,7 +912,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.x, 3);
});
it('line of ¥ even', async () => {
await term.writeP(Array(50).join('¥'));
await term.writeP('¥'.repeat(49));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -932,7 +932,7 @@ describe('Terminal', () => {
});
it('line of ¥ odd', async () => {
term.buffer.x = 1;
await term.writeP(Array(50).join('¥'));
await term.writeP('¥'.repeat(49));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -956,7 +956,7 @@ describe('Terminal', () => {
});
it('line of ¥ with combining odd', async () => {
term.buffer.x = 1;
await term.writeP(Array(50).join('¥\u0301'));
await term.writeP('¥\u0301'.repeat(49));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -979,7 +979,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 2);
});
it('line of ¥ with combining even', async () => {
await term.writeP(Array(50).join('¥\u0301'));
await term.writeP('¥\u0301'.repeat(49));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -999,7 +999,7 @@ describe('Terminal', () => {
});
it('line of surrogate fullwidth with combining odd', async () => {
term.buffer.x = 1;
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -1022,7 +1022,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 2);
});
it('line of surrogate fullwidth with combining even', async () => {
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -1045,7 +1045,7 @@ describe('Terminal', () => {
describe('insert mode', () => {
const cell = new CellData();
it('halfwidth - all', async () => {
await term.writeP(Array(9).join('0123456789').slice(-80));
await term.writeP('0123456789'.repeat(8).slice(-80));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
@@ -1057,7 +1057,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '4');
});
it('fullwidth - insert', async () => {
await term.writeP(Array(9).join('0123456789').slice(-80));
await term.writeP('0123456789'.repeat(8).slice(-80));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
@@ -1070,7 +1070,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '3');
});
it('fullwidth - right border', async () => {
await term.writeP(Array(41).join('¥'));
await term.writeP('¥'.repeat(40));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
+15 -3
View File
@@ -12,7 +12,7 @@ import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, I
import { Buffer } from 'common/buffer/Buffer';
import * as Browser from 'common/Platform';
import { CoreBrowserTerminal } from 'browser/CoreBrowserTerminal';
import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService } from 'common/services/Services';
import { IUnicodeService, IOptionsService, ICoreService, IMouseStateService } from 'common/services/Services';
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
import { AttributeData } from 'common/buffer/AttributeData';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
@@ -51,7 +51,7 @@ export class MockTerminal implements ITerminal {
public dimensions: IRenderDimensionsApi | undefined;
public markers!: IMarker[];
public linkifier: ILinkifier2 | undefined;
public coreMouseService!: ICoreMouseService;
public mouseStateService!: IMouseStateService;
public coreService!: ICoreService;
public optionsService!: IOptionsService;
public unicodeService!: IUnicodeService;
@@ -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.');
}
@@ -392,6 +401,9 @@ export class MockMouseService implements IMouseService {
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
throw new Error('Not implemented');
}
public bindMouse(): void { }
public reset(): void { }
}
export class MockRenderService implements IRenderService {
+24 -12
View File
@@ -6,14 +6,13 @@
import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { ViewportConstants } from 'browser/shared/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services';
import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services';
import { CoreMouseEventType } from 'common/Types';
import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom';
import { scheduleAtNextAnimationFrame } from 'browser/Dom';
import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement';
import type { IScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions';
import { Emitter, EventUtils } from 'common/Event';
import { Scrollable, ScrollbarVisibility, type IScrollEvent } from 'browser/scrollable/scrollable';
import { Gesture, EventType as GestureEventType, type IGestureEvent } from 'browser/scrollable/touch';
export class Viewport extends Disposable {
@@ -28,13 +27,15 @@ export class Viewport extends Disposable {
private _isSyncing: boolean = false;
private _isHandlingScroll: boolean = false;
private _suppressOnScrollHandler: boolean = false;
private _needsSyncOnRender: boolean = false;
constructor(
element: HTMLElement,
screenElement: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
@ICoreMouseService coreMouseService: ICoreMouseService,
@ICoreService private readonly _coreService: ICoreService,
@IMouseStateService mouseStateService: IMouseStateService,
@IThemeService themeService: IThemeService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService
@@ -65,7 +66,7 @@ export class Viewport extends Disposable {
'scrollbar'
], () => this._scrollableElement.updateOptions(this._getChangeOptions())));
// Don't handle mouse wheel if wheel events are supported by the current mouse prototcol
this._register(coreMouseService.onProtocolChange(type => {
this._register(mouseStateService.onProtocolChange(type => {
this._scrollableElement.updateOptions({
handleMouseWheel: !(type & CoreMouseEventType.WHEEL)
});
@@ -105,11 +106,18 @@ export class Viewport extends Disposable {
}));
this._register(this._bufferService.onScroll(() => this._sync()));
// Flush deferred viewport sync after a render completes (e.g. after ESU ends
// synchronized output mode). This ensures DOM scroll position updates atomically
// with the canvas render.
this._register(this._renderService.onRender(() => {
if (this._needsSyncOnRender) {
this._needsSyncOnRender = false;
this._sync();
}
}));
this._register(this._scrollableElement.onScroll(e => this._handleScroll(e)));
// Touch/gesture scrolling support
this._register(Gesture.addTarget(screenElement));
this._register(addDisposableListener(screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleGestureChange(e)));
}
public scrollLines(disp: number): void {
@@ -165,6 +173,12 @@ export class Viewport extends Disposable {
if (!this._renderService || this._isSyncing) {
return;
}
// Defer DOM scroll updates during synchronized output to prevent visible
// scroll position flickering while the canvas content is frozen.
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._needsSyncOnRender = true;
return;
}
this._isSyncing = true;
// Ignore any onScroll event that happens as a result of dimensions changing as this should
@@ -204,12 +218,10 @@ export class Viewport extends Disposable {
this._isHandlingScroll = false;
}
private _handleGestureChange(e: IGestureEvent): void {
e.preventDefault();
e.stopPropagation();
public handleTouchScroll(translationY: number): void {
const pos = this._scrollableElement.getScrollPosition();
this._scrollableElement.setScrollPosition({
scrollTop: pos.scrollTop - e.translationY
scrollTop: pos.scrollTop - translationY
});
}
}
@@ -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;
});
}
@@ -233,5 +233,31 @@ describe('CompositionHelper', () => {
}, 0);
}, 0);
});
it('Should insert middle composition and subsequent input without appending existing trailing text', (done) => {
textarea.value = '一二';
// screenReaderMode keeps textarea content/selection for assistive technologies (eg. screen
// readers), so the caret can be moved within the textarea (eg. via arrow keys) before
// starting composition.
textarea.selectionStart = 1;
textarea.selectionEnd = 1;
compositionHelper.compositionstart();
compositionHelper.compositionupdate({ data: '一' });
textarea.value = '一一二';
// After the composed text is inserted, the caret typically moves to after it.
textarea.selectionStart = 2;
textarea.selectionEnd = 2;
setTimeout(() => { // wait for any textarea updates
compositionHelper.compositionend();
// Second character '1' (a non-composition character)
textarea.value = '一一1二';
setTimeout(() => { // wait for any textarea updates
assert.equal(handledText, '一1');
done();
}, 0);
}, 0);
});
});
});
+34 -7
View File
@@ -30,6 +30,12 @@ export class CompositionHelper {
*/
private _compositionPosition: IPosition;
/**
* Text that existed after the composing range when composition started.
* This is used to avoid treating existing trailing text as new input.
*/
private _compositionSuffix: string;
/**
* Whether a composition is in the process of being sent, setting this to false will cancel any
* in-progress composition.
@@ -57,6 +63,7 @@ export class CompositionHelper {
this._isComposing = false;
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
this._dataAlreadySent = '';
}
@@ -65,7 +72,13 @@ export class CompositionHelper {
*/
public compositionstart(): void {
this._isComposing = true;
this._compositionPosition.start = this._textarea.value.length;
// It's important to use the selection here instead of textarea length to avoid conflicts with
// screen reader mode
const start = this._textarea.selectionStart ?? this._textarea.value.length;
const end = this._textarea.selectionEnd ?? start;
this._compositionPosition.start = Math.min(start, end);
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionView.textContent = '';
this._dataAlreadySent = '';
this._compositionView.classList.add('active');
@@ -76,10 +89,13 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
this._compositionView.textContent = ev.data;
// Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
// compositions
this._compositionView.textContent = `\u200E${ev.data}\u200E`;
this.updateCompositionElements();
setTimeout(() => {
this._compositionPosition.end = this._textarea.value.length;
const end = this._textarea.selectionEnd ?? this._textarea.value.length;
this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
}, 0);
}
@@ -146,6 +162,7 @@ export class CompositionHelper {
start: this._compositionPosition.start,
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -169,10 +186,14 @@ export class CompositionHelper {
// if a new composition has started.
input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
} else {
// Don't use the end position here in order to pick up any characters after the
// composition has finished, for example when typing a non-composition character
// (eg. 2) after a composition character.
input = this._textarea.value.substring(currentCompositionPosition.start);
// Keep support for non-composition characters typed immediately after composition end
// while avoiding re-sending the trailing text that was already present
// before composition started.
const value = this._textarea.value;
const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix)
? value.length - currentCompositionSuffix.length
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
if (input.length > 0) {
this._coreService.triggerDataEvent(input, true);
@@ -239,6 +260,12 @@ export class CompositionHelper {
this._compositionView.style.lineHeight = cellHeight + 'px';
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
// Limit the composition view width to the space between the cursor and
// the terminal's right edge, preventing it from overflowing the terminal.
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
this._compositionView.style.direction = 'rtl';
// Sync the textarea to the exact position of the composition view so the IME knows where the
// text is.
const compositionViewBounds = this._compositionView.getBoundingClientRect();
+1 -1
View File
@@ -103,7 +103,7 @@ export class Terminal extends Disposable implements ITerminalApi {
public get modes(): IModes {
const m = this._core.coreService.decPrivateModes;
let mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' = 'none';
switch (this._core.coreMouseService.activeProtocol) {
switch (this._core.mouseStateService.activeProtocol) {
case 'X10': mouseTrackingMode = 'x10'; break;
case 'VT200': mouseTrackingMode = 'vt200'; break;
case 'DRAG': mouseTrackingMode = 'drag'; break;
+116 -70
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;
@@ -45,6 +47,9 @@ export class DomRenderer extends Disposable implements IRenderer {
private _selectionContainer: HTMLElement;
private _widthCache: WidthCache;
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
private _lastSelectionStart: [number, number] | undefined;
private _lastSelectionEnd: [number, number] | undefined;
private _lastSelectionColumnMode: boolean = false;
private _cursorBlinkStateManager: CursorBlinkStateManager;
private _textBlinkStateManager: TextBlinkStateManager;
private _rowHasBlinkingCells: boolean[] = [];
@@ -73,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();
@@ -90,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);
@@ -107,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
@@ -157,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;` +
@@ -178,19 +183,22 @@ 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.
` pointer-events: none;` +
` color: ${colors.foreground.css};` +
`}`;
styles +=
`${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
@@ -236,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;
}
@@ -355,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);
}
@@ -371,51 +379,89 @@ export class DomRenderer extends Disposable implements IRenderer {
}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
const rows = this._bufferService.rows;
// Remove all selections
this._selectionContainer.replaceChildren();
this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);
this.renderRows(0, this._bufferService.rows - 1);
// Selection does not exist
// Determine old selection viewport band
let oldViewportStart = 0;
let oldViewportEnd = -1;
if (this._lastSelectionStart && this._lastSelectionEnd) {
this._selectionRenderModel.update(this._terminal, this._lastSelectionStart, this._lastSelectionEnd, this._lastSelectionColumnMode);
if (this._selectionRenderModel.hasSelection) {
oldViewportStart = this._selectionRenderModel.viewportCappedStartRow;
oldViewportEnd = this._selectionRenderModel.viewportCappedEndRow;
}
}
// Determine new selection viewport band and create overlays
let newViewportStart = 0;
let newViewportEnd = -1;
if (!start || !end) {
return;
}
this._selectionRenderModel.update(this._terminal, start, end, columnSelectMode);
if (!this._selectionRenderModel.hasSelection) {
return;
}
if (this._selectionRenderModel.hasSelection) {
const viewportStartRow = this._selectionRenderModel.viewportStartRow;
const viewportEndRow = this._selectionRenderModel.viewportEndRow;
const viewportCappedStartRow = this._selectionRenderModel.viewportCappedStartRow;
const viewportCappedEndRow = this._selectionRenderModel.viewportCappedEndRow;
// Translate from buffer position to viewport position
const viewportStartRow = this._selectionRenderModel.viewportStartRow;
const viewportEndRow = this._selectionRenderModel.viewportEndRow;
const viewportCappedStartRow = this._selectionRenderModel.viewportCappedStartRow;
const viewportCappedEndRow = this._selectionRenderModel.viewportCappedEndRow;
newViewportStart = viewportCappedStartRow;
newViewportEnd = viewportCappedEndRow;
// Create the selections
const documentFragment = this._document.createDocumentFragment();
// Create the selections
const documentFragment = this._document.createDocumentFragment();
if (columnSelectMode) {
const isXFlipped = start[0] > end[0];
documentFragment.appendChild(
this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
);
} else {
// Draw first row
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
// Draw middle rows
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewporttartRow
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol));
if (columnSelectMode) {
const isXFlipped = start[0] > end[0];
documentFragment.appendChild(
this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
);
} else {
// Draw first row
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
// Draw middle rows
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewporttartRow
const finalEndCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, finalEndCol));
}
}
this._selectionContainer.appendChild(documentFragment);
}
this._selectionContainer.appendChild(documentFragment);
// Compute minimal row range to redraw
let renderStartRow = Math.min(oldViewportStart, newViewportStart);
let renderEndRow = Math.max(oldViewportEnd, newViewportEnd);
if (renderEndRow >= 0) {
// Clamp to viewport
renderStartRow = Math.max(renderStartRow, 0);
renderEndRow = Math.min(renderEndRow, rows - 1);
// Ensure cursor row is included when a selection is present
const buffer = this._bufferService.buffer;
const cursorViewportRow = buffer.y;
if (this._selectionRenderModel.hasSelection && cursorViewportRow >= 0 && cursorViewportRow < rows) {
renderStartRow = Math.min(renderStartRow, cursorViewportRow);
renderEndRow = Math.max(renderEndRow, cursorViewportRow);
}
this.renderRows(renderStartRow, renderEndRow);
}
// Update last selection state
this._lastSelectionStart = start;
this._lastSelectionEnd = end;
this._lastSelectionColumnMode = columnSelectMode;
}
/**
@@ -517,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 {
@@ -623,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();
}
@@ -635,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();
}
@@ -655,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 } from 'common/TestUtils.test';
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;
@@ -48,9 +48,9 @@ describe('DomRendererRowFactory', () => {
it('should set correct attributes for double width characters', () => {
widthCache.setWidths({ '語': 10 });
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, '語', 2));
// There should be no element for the following "empty" cell
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0]));
lineData.setCell(1, createCellData(DEFAULT_ATTR, '', 0));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'<span>語</span>'
@@ -117,7 +117,7 @@ describe('DomRendererRowFactory', () => {
describe('attributes', () => {
it('should add class for bold', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -127,7 +127,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for italic', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -137,7 +137,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for dim', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -148,7 +148,7 @@ describe('DomRendererRowFactory', () => {
describe('underline', () => {
it('should add class for straight underline style', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
@@ -159,7 +159,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for double underline style', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOUBLE;
@@ -170,7 +170,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for curly underline style', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.CURLY;
@@ -181,7 +181,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for double dotted style', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
@@ -192,7 +192,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for dashed underline style', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DASHED;
@@ -205,7 +205,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for overline', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -215,7 +215,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for strikethrough', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -225,7 +225,7 @@ describe('DomRendererRowFactory', () => {
});
it('should hide blinking text when blink is off', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BLINK | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
@@ -241,7 +241,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add classes for 256 foreground colors', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P256;
for (let i = 0; i < 256; i++) {
cell.fg &= ~Attributes.PCOLOR_MASK;
@@ -255,7 +255,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add classes for 256 background colors', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.bg |= Attributes.CM_P256;
for (let i = 0; i < 256; i++) {
cell.bg &= ~Attributes.PCOLOR_MASK;
@@ -269,7 +269,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert colors', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
@@ -280,7 +280,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert default fg color', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
@@ -291,7 +291,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert default bg color', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -301,7 +301,7 @@ describe('DomRendererRowFactory', () => {
});
it('should turn bold fg text bright', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= FgFlags.BOLD | Attributes.CM_P16;
for (let i = 0; i < 8; i++) {
cell.fg &= ~Attributes.PCOLOR_MASK;
@@ -315,7 +315,7 @@ describe('DomRendererRowFactory', () => {
});
it('should set style attribute for RBG', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
@@ -326,7 +326,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert RGB colors', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
@@ -339,8 +339,8 @@ describe('DomRendererRowFactory', () => {
describe('selectionForeground', () => {
it('should force selected cells with content to be rendered above the background', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
rowFactory.handleSelectionChanged([1, 0], [2, 0], false);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
@@ -348,7 +348,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should force whitespace cells to be rendered above the background', () => {
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
rowFactory.handleSelectionChanged([0, 0], [2, 0], false);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
@@ -374,9 +374,9 @@ describe('DomRendererRowFactory', () => {
});
it('can merge codepoints for equal spacing', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'<span>abc</span>'
@@ -385,9 +385,9 @@ describe('DomRendererRowFactory', () => {
it('should not merge codepoints with different spacing', () => {
widthCache.setWidths({ '€': 2 });
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c</span>'
@@ -395,9 +395,9 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge on FG change', () => {
const aColor1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]);
const aColor1 = createCellData(DEFAULT_ATTR, 'a', 1);
aColor1.fg |= Attributes.CM_P16 | 1;
const bColor2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]);
const bColor2 = createCellData(DEFAULT_ATTR, 'b', 1);
bColor2.fg |= Attributes.CM_P16 | 2;
lineData.setCell(0, aColor1);
lineData.setCell(1, aColor1);
@@ -410,11 +410,11 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge cursor cell', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)]));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'X', 1));
lineData.setCell(3, createCellData(DEFAULT_ATTR, 'b', 1));
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'b', 1));
const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'<span>aa</span><span class="xterm-cursor xterm-cursor-block">X</span><span>bb</span>'
@@ -453,7 +453,7 @@ describe('DomRendererRowFactory', () => {
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'a', 1));
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span><span>a</span>'
@@ -462,9 +462,9 @@ describe('DomRendererRowFactory', () => {
it('should apply correct positive or negative spacing', () => {
widthCache.setWidths({ '€': 2, '語': 10, '𝄞': 7 }); // €: too small (+3px), 語: exact, 𝄞: too wide (-2px)
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -474,13 +474,13 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge across link borders', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
lineData.setCell(3, createCellData(DEFAULT_ATTR, 'x', 1));
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
lineData.setCell(5, createCellData(DEFAULT_ATTR, 'b', 1));
lineData.setCell(6, createCellData(DEFAULT_ATTR, 'b', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
assert.equal(extractHtml(spans),
'<span>aa</span><span style="text-decoration: underline;">xxx</span><span>bb</span>'
@@ -488,10 +488,10 @@ describe('DomRendererRowFactory', () => {
});
it('empty cells included in link underline', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
assert.equal(extractHtml(spans),
'<span>aa</span><span style="text-decoration: underline;">x x</span>'
@@ -500,7 +500,7 @@ describe('DomRendererRowFactory', () => {
it('link range gets capped to actual line borders', () => {
for (let i = 0; i < 10; ++i) {
lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(i, createCellData(DEFAULT_ATTR, 'a', 1));
}
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -100, 100);
assert.equal(extractHtml(spans),
@@ -517,9 +517,9 @@ 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, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]));
lineData.setCell(i, NULL_CELL_DATA);
}
return lineData;
}
+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);
}
}
}
+4
View File
@@ -153,6 +153,8 @@ export interface IGestureEvent extends MouseEvent {
translationY: number;
pageX: number;
pageY: number;
clientX: number;
clientY: number;
tapCount: number;
}
@@ -459,6 +461,8 @@ export class Gesture extends Disposable {
evt.translationY = touch.pageY - tail(data.rollingPageY)!;
evt.pageX = touch.pageX;
evt.pageY = touch.pageY;
evt.clientX = touch.clientX;
evt.clientY = touch.clientY;
this._dispatchEvent(evt);
if (data.rollingPageX.length > 3) {
@@ -7,9 +7,12 @@ 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 } from 'common/TestUtils.test';
import { MockBufferService, createCellData } from 'common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService;
@@ -22,20 +25,20 @@ 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, CellData.fromCharData([0, '¥', 2, '¥'.charCodeAt(0)]));
line6.resize(line6.length + 1, CellData.fromCharData([0, '', 0, 0]));
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
line6.resize(line6.length + 1, createCellData(0, '', 0));
let sub = lineData([['deemo']]);
let oldSize = line6.length;
line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
line6.resize(oldSize + sub.length, createCellData(0, '', 0));
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
line6.resize(line6.length + 1, CellData.fromCharData([0, '\xf0\x9f\x98\x81', 1, 128513]));
line6.resize(line6.length + 1, CellData.fromCharData([0, ' ', 1, ' '.charCodeAt(0)]));
line6.resize(line6.length + 1, createCellData(0, ' ', 1));
sub = lineData([['jiabc']]);
oldSize = line6.length;
line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
line6.resize(oldSize + sub.length, createCellData(0, '', 0));
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
lines.set(6, line6);
@@ -267,13 +270,13 @@ 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;
const offset = tline.length;
tline.resize(tline.length + line.split('').length, CellData.fromCharData([0, '', 0, 0]));
line.split('').map((char, idx) => tline.setCell(idx + offset, CellData.fromCharData([attr, char, 1, char.charCodeAt(0)])));
tline.resize(tline.length + line.split('').length, createCellData(0, '', 0));
line.split('').map((char, idx) => tline.setCell(idx + offset, createCellData(attr, char, 1)));
}
return tline;
}
+2 -2
View File
@@ -40,7 +40,7 @@ export class KeyboardService implements IKeyboardService {
}
const kittyFlags = this._coreService.kittyKeyboard.flags;
return this.useKitty
? this._getKittyKeyboard().evaluate(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS)
? this._getKittyKeyboard().evaluate(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS, isMac && this._optionsService.rawOptions.macOptionIsMeta)
: evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, isMac, this._optionsService.rawOptions.macOptionIsMeta);
}
@@ -51,7 +51,7 @@ export class KeyboardService implements IKeyboardService {
}
const kittyFlags = this._coreService.kittyKeyboard.flags;
if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) {
return this._getKittyKeyboard().evaluate(event, kittyFlags, KittyKeyboardEventType.RELEASE);
return this._getKittyKeyboard().evaluate(event, kittyFlags, KittyKeyboardEventType.RELEASE, isMac && this._optionsService.rawOptions.macOptionIsMeta);
}
return undefined;
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { getWindow } from 'browser/Dom';
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
import { ICharSizeService, IMouseCoordsService, IRenderService } from 'browser/services/Services';
export class MouseCoordsService implements IMouseCoordsService {
public serviceBrand: undefined;
constructor(
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@IRenderService private readonly _renderService: IRenderService
) {
}
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
getWindow(element),
event,
element,
colCount,
rowCount,
this._charSizeService.hasValidSize,
this._renderService.dimensions.css.cell.width,
this._renderService.dimensions.css.cell.height,
isSelection
);
}
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
const coords = getCoordsRelativeToElement(getWindow(element), event, element);
if (!this._charSizeService.hasValidSize) {
return undefined;
}
coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
return {
col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
x: Math.floor(coords[0]),
y: Math.floor(coords[1])
};
}
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { MouseService } from 'browser/services/MouseService';
import { MouseStateService } from 'common/services/MouseStateService';
import { CoreMouseAction, CoreMouseButton } from 'common/Types';
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
import { MockCoreBrowserService, MockRenderService, MockSelectionService } from 'browser/TestUtils.test';
function toBytes(s: string | undefined): number[] {
if (!s) {
return [];
}
const res: number[] = [];
for (let i = 0; i < s.length; ++i) {
res.push(s.charCodeAt(i));
}
return res;
}
// Minimal mocks for deps that MouseService touches in these tests
const bufferService: IBufferService = {
buffer: { hasScrollback: true } as any,
cols: 500,
rows: 500
} as any;
const optionsService: IOptionsService = {
rawOptions: {
logLevel: 'info',
fastScrollSensitivity: 1,
scrollSensitivity: 1
}
} as any;
const logService: ILogService = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {}
} as any;
describe('MouseService _triggerMouseEvent', () => {
let mouseService: MouseService;
let mouseStateService: MouseStateService;
let coreService: ICoreService;
let reports: string[];
beforeEach(() => {
reports = [];
mouseStateService = new MouseStateService();
coreService = {
triggerDataEvent: (data: string) => reports.push(data),
triggerBinaryEvent: (data: string) => reports.push(data),
decPrivateModes: { applicationCursorKeys: false }
} as any;
mouseService = new MouseService(
new MockRenderService(),
{
getMouseReportCoords: (_ev: MouseEvent, _el: HTMLElement) => ({ col: 0, row: 0, x: 0, y: 0 })
} as any,
mouseStateService,
coreService,
bufferService,
optionsService,
new MockSelectionService(),
logService,
new MockCoreBrowserService()
);
});
function trigger(e: Parameters<any>[0]): boolean {
return (mouseService as any)._triggerMouseEvent(e);
}
it('NONE', () => {
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
});
it('X10', () => {
mouseStateService.activeProtocol = 'X10';
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
});
it('VT200', () => {
mouseStateService.activeProtocol = 'VT200';
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
});
it('DRAG', () => {
mouseStateService.activeProtocol = 'DRAG';
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
});
it('ANY', () => {
mouseStateService.activeProtocol = 'ANY';
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), true);
// should not report in any case
// invalid button + action combinations
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.MOVE }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.UP }), false);
// invalid coords
assert.equal(trigger({ col: -1, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 500, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: -1, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
assert.equal(trigger({ col: 0, row: 500, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
});
describe('coords', () => {
it('DEFAULT encoding', () => {
mouseStateService.activeProtocol = 'ANY';
for (let i = 0; i < bufferService.cols; ++i) {
assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
if (i > 222) {
// supress mouse reports if we are out of addressible range (max. 222)
assert.deepEqual(toBytes(reports.pop()), []);
} else {
assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]);
}
}
});
it('SGR encoding', () => {
mouseStateService.activeProtocol = 'ANY';
mouseStateService.activeEncoding = 'SGR';
for (let i = 0; i < bufferService.cols; ++i) {
assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`);
}
});
it('SGR_PIXELS encoding', () => {
mouseStateService.activeProtocol = 'ANY';
mouseStateService.activeEncoding = 'SGR_PIXELS';
for (let i = 0; i < 500; ++i) {
assert.equal(trigger({ col: 0, row: 0, x: i, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
assert.deepEqual(reports.pop(), `\x1b[<0;${i};0M`);
}
});
});
it('eventCodes with modifiers (DEFAULT encoding)', () => {
// TODO: implement AUX button tests
mouseStateService.activeProtocol = 'ANY';
mouseStateService.activeEncoding = 'DEFAULT';
// all buttons + down + no modifer
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']);
reports = [];
// all buttons + up + no modifier
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']);
reports = [];
// all buttons + move + no modifier
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']);
reports = [];
// button none + move + modifiers
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: false, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: true }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: false }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: true }), true);
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: true }), true);
assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']);
reports = [];
});
});
+518 -26
View File
@@ -3,45 +3,537 @@
* @license MIT
*/
import { getWindow } from 'browser/Dom';
import { ICharSizeService, IRenderService, IMouseService } from './Services';
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
import { addDisposableListener } from 'browser/Dom';
import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types';
import { C0 } from 'common/data/EscapeSequences';
import { toDisposable } from 'common/Lifecycle';
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/scrollable/touch';
type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>;
interface IMouseBindContext {
readonly target: IMouseServiceTarget;
readonly focus: () => void;
readonly requestedEvents: RequestedMouseEvents;
}
export class MouseService implements IMouseService {
public serviceBrand: undefined;
private _lastEvent: ICoreMouseEvent | null = null;
private _wheelPartialScroll: number = 0;
private _touchScrollAccumulator: number = 0;
constructor(
@IRenderService private readonly _renderService: IRenderService,
@ICharSizeService private readonly _charSizeService: ICharSizeService
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IMouseStateService private readonly _mouseStateService: IMouseStateService,
@ICoreService private readonly _coreService: ICoreService,
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ISelectionService private readonly _selectionService: ISelectionService,
@ILogService private readonly _logService: ILogService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
) {
}
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
window,
event,
element,
colCount,
rowCount,
this._charSizeService.hasValidSize,
this._renderService.dimensions.css.cell.width,
this._renderService.dimensions.css.cell.height,
isSelection
);
public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void {
const { element, document } = target;
/**
* Event listener state handling.
* We listen to the onProtocolChange event of MouseStateService and put
* requested listeners in `requestedEvents`. With this the listeners
* have all bits to do the event listener juggling.
* Note: 'mousedown' currently is "always on" and not managed
* by onProtocolChange.
*/
const requestedEvents: RequestedMouseEvents = {
mouseup: null,
wheel: null,
mousedrag: null,
mousemove: null
};
const ctx: IMouseBindContext = { target, focus, requestedEvents };
const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent),
mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent)
};
register(this._mouseStateService.onProtocolChange(events => {
this._handleProtocolChange(ctx, eventListeners, events);
}));
// force initial onProtocolChange so we dont miss early mouse requests
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
// Ensure document-level listeners are removed on dispose
register(toDisposable(() => {
if (requestedEvents.mouseup) {
document.removeEventListener('mouseup', requestedEvents.mouseup);
}
if (requestedEvents.mousedrag) {
document.removeEventListener('mousemove', requestedEvents.mousedrag);
}
}));
/**
* "Always on" event listeners.
*/
register(addDisposableListener(element, 'mousedown', (ev: MouseEvent) => this._handleMouseDown(ctx, ev)));
register(addDisposableListener(element, 'wheel', (ev: WheelEvent) => this._handlePassiveWheel(ctx, ev), { passive: false }));
register(Gesture.addTarget(target.screenElement));
register(addDisposableListener(target.screenElement, GestureEventType.START, () => this._handleTouchStart()));
register(addDisposableListener(target.screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleTouchChange(ctx, e)));
}
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
const coords = getCoordsRelativeToElement(getWindow(element), event, element);
if (!this._charSizeService.hasValidSize) {
return undefined;
private _sendEvent(ctx: IMouseBindContext, ev: MouseEvent | WheelEvent): boolean {
// Get mouse coordinates
const pos = this._mouseCoordsService.getMouseReportCoords(ev as MouseEvent, ctx.target.screenElement);
if (!pos) {
return false;
}
coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
let but: CoreMouseButton;
let action: CoreMouseAction | undefined;
switch ((ev as MouseEvent & { overrideType?: string }).overrideType || ev.type) {
case 'mousemove':
action = CoreMouseAction.MOVE;
if (ev.buttons === undefined) {
// buttons is not supported on macOS, try to get a value from button instead
but = CoreMouseButton.NONE;
if (ev.button !== undefined) {
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
}
} else {
// according to MDN buttons only reports up to button 5 (AUX2)
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
ev.buttons & 2 ? CoreMouseButton.RIGHT :
CoreMouseButton.NONE; // fallback to NONE
}
break;
case 'mouseup':
action = CoreMouseAction.UP;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'mousedown':
action = CoreMouseAction.DOWN;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'wheel':
if (!this._mouseStateService.allowCustomWheelEvent(ev as WheelEvent)) {
return false;
}
const deltaY = (ev as WheelEvent).deltaY;
if (deltaY === 0) {
return false;
}
const lines = this._consumeWheelEvent(
ev as WheelEvent,
this._renderService?.dimensions?.device?.cell?.height,
this._coreBrowserService?.dpr
);
if (lines === 0) {
return false;
}
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
but = CoreMouseButton.WHEEL;
break;
default:
// dont handle other event types by accident
return false;
}
// exit if we cannot determine valid button/action values
// do nothing for higher buttons than wheel
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
return false;
}
return this._triggerMouseEvent({
col: pos.col,
row: pos.row,
x: pos.x,
y: pos.y,
button: but,
action,
ctrl: ev.ctrlKey,
alt: ev.altKey,
shift: ev.shiftKey
});
}
private _handleMouseUp(ctx: IMouseBindContext, ev: MouseEvent): void {
this._sendEvent(ctx, ev);
if (!ev.buttons) {
// if no other button is held remove global handlers
if (ctx.requestedEvents.mouseup) {
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
}
if (ctx.requestedEvents.mousedrag) {
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
}
}
}
private _handleWheel(ctx: IMouseBindContext, ev: WheelEvent): false {
this._sendEvent(ctx, ev);
ev.preventDefault();
ev.stopPropagation();
return false;
}
private _handleMouseDrag(ctx: IMouseBindContext, ev: MouseEvent): void {
// deal only with move while a button is held
if (ev.buttons) {
this._sendEvent(ctx, ev);
}
}
private _handleMouseMove(ctx: IMouseBindContext, ev: MouseEvent): void {
// deal only with move without any button
if (!ev.buttons) {
this._sendEvent(ctx, ev);
}
}
private _handleMouseDown(ctx: IMouseBindContext, ev: MouseEvent): void {
ev.preventDefault();
ctx.focus();
// Don't send the mouse button to the pty if mouse events are disabled or
// if the selection manager is having selection forced (ie. a modifier is
// held).
if (!this._mouseStateService.areMouseEventsActive || this._selectionService.shouldForceSelection(ev)) {
return;
}
this._sendEvent(ctx, ev);
// Register additional global handlers which should keep reporting outside
// of the terminal element.
// Note: Other emulators also do this for 'mousedown' while a button
// is held, we currently limit 'mousedown' to the terminal only.
if (ctx.requestedEvents.mouseup) {
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
}
if (ctx.requestedEvents.mousedrag) {
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
}
}
private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void {
// do nothing, if app side handles wheel itself
if (ctx.requestedEvents.wheel) {
return;
}
if (!this._mouseStateService.allowCustomWheelEvent(ev)) {
return false;
}
if (!this._bufferService.buffer.hasScrollback) {
// Convert wheel events into up/down events when the buffer does not have scrollback, this
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
// events are not enabled.
// This used implementation used get the actual lines/partial lines scrolled from the
// viewport but since moving to the new viewport implementation has been simplified to
// simply send a single up or down sequence.
// Do nothing if there's no vertical scroll
const deltaY = ev.deltaY;
if (deltaY === 0) {
return false;
}
const lines = this._consumeWheelEvent(
ev,
this._renderService?.dimensions?.device?.cell?.height,
this._coreBrowserService?.dpr
);
if (lines === 0) {
ev.preventDefault();
ev.stopPropagation();
return false;
}
// Construct and send sequences
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
this._coreService.triggerDataEvent(sequence, true);
ev.preventDefault();
ev.stopPropagation();
return false;
}
}
private _handleTouchStart(): void {
this._touchScrollAccumulator = 0;
}
private _handleTouchChange(ctx: IMouseBindContext, e: IGestureEvent): void {
e.preventDefault();
e.stopPropagation();
// When mouse protocol has wheel events active, send as mouse wheel events.
if (ctx.requestedEvents.wheel) {
this._handleTouchScrollAsWheel(ctx, e);
return;
}
// When in alt buffer (no scrollback), send up/down key sequences.
if (!this._bufferService.buffer.hasScrollback) {
this._handleTouchScrollAsKeys(e);
return;
}
// Normal scrollback: delegate to viewport scrolling when available.
ctx.target.handleTouchScroll?.(e.translationY);
}
private _handleTouchScrollAsKeys(e: IGestureEvent): void {
const cellHeight = this._renderService?.dimensions.css.cell.height;
if (!cellHeight) {
return;
}
this._touchScrollAccumulator -= e.translationY;
const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
if (lines === 0) {
return;
}
this._touchScrollAccumulator -= lines * cellHeight;
const sequence = C0.ESC
+ (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[')
+ (lines < 0 ? 'A' : 'B');
for (let i = 0; i < Math.abs(lines); i++) {
this._coreService.triggerDataEvent(sequence, true);
}
}
private _handleTouchScrollAsWheel(ctx: IMouseBindContext, e: IGestureEvent): void {
const cellHeight = this._renderService?.dimensions.css.cell.height;
if (!cellHeight) {
return;
}
this._touchScrollAccumulator -= e.translationY;
const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
if (lines === 0) {
return;
}
this._touchScrollAccumulator -= lines * cellHeight;
const pos = this._mouseCoordsService.getMouseReportCoords(e, ctx.target.screenElement);
if (!pos) {
return;
}
for (let i = 0; i < Math.abs(lines); i++) {
this._triggerMouseEvent({
col: pos.col,
row: pos.row,
x: pos.x,
y: pos.y,
button: CoreMouseButton.WHEEL,
action: lines < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN,
ctrl: false,
alt: false,
shift: false
});
}
}
public reset(): void {
this._lastEvent = null;
this._wheelPartialScroll = 0;
this._touchScrollAccumulator = 0;
}
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
const { element, document } = ctx.target;
const { requestedEvents } = ctx;
// apply global changes on events
if (events) {
if (this._optionsService.rawOptions.logLevel === 'debug') {
this._logService.debug('Binding to mouse events:', this._explainEvents(events));
}
element.classList.add('enable-mouse-events');
this._selectionService.disable();
} else {
this._logService.debug('Unbinding from mouse events.');
element.classList.remove('enable-mouse-events');
this._selectionService.enable();
}
// add/remove handlers from requestedEvents
if (!(events & CoreMouseEventType.MOVE)) {
if (requestedEvents.mousemove) {
element.removeEventListener('mousemove', requestedEvents.mousemove);
}
requestedEvents.mousemove = null;
} else if (!requestedEvents.mousemove) {
element.addEventListener('mousemove', eventListeners.mousemove);
requestedEvents.mousemove = eventListeners.mousemove;
}
if (!(events & CoreMouseEventType.WHEEL)) {
if (requestedEvents.wheel) {
element.removeEventListener('wheel', requestedEvents.wheel);
}
requestedEvents.wheel = null;
} else if (!requestedEvents.wheel) {
element.addEventListener('wheel', eventListeners.wheel, { passive: false });
requestedEvents.wheel = eventListeners.wheel;
}
if (!(events & CoreMouseEventType.UP)) {
if (requestedEvents.mouseup) {
document.removeEventListener('mouseup', requestedEvents.mouseup);
}
requestedEvents.mouseup = null;
} else {
requestedEvents.mouseup ??= eventListeners.mouseup;
}
if (!(events & CoreMouseEventType.DRAG)) {
if (requestedEvents.mousedrag) {
document.removeEventListener('mousemove', requestedEvents.mousedrag);
}
requestedEvents.mousedrag = null;
} else {
requestedEvents.mousedrag ??= eventListeners.mousedrag;
}
}
private _applyScrollModifier(amount: number, ev: WheelEvent): number {
// Multiply the scroll speed when the modifier key is pressed
if (ev.altKey || ev.ctrlKey || ev.shiftKey) {
return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity;
}
return amount * this._optionsService.rawOptions.scrollSensitivity;
}
/**
* Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls.
* This prevents hyper-sensitive scrolling in alt buffer.
*/
private _consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number {
// Do nothing if it's not a vertical scroll event
if (ev.deltaY === 0 || ev.shiftKey) {
return 0;
}
if (cellHeight === undefined || dpr === undefined) {
return 0;
}
const targetWheelEventPixels = cellHeight / dpr;
let amount = this._applyScrollModifier(ev.deltaY, ev);
if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
amount /= (targetWheelEventPixels + 0.0); // Prevent integer division
const isLikelyTrackpad = Math.abs(ev.deltaY) < 50;
if (isLikelyTrackpad) {
amount *= 0.3;
}
this._wheelPartialScroll += amount;
amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1);
this._wheelPartialScroll %= 1;
} else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
amount *= this._bufferService.rows;
}
return amount;
}
/**
* Triggers a mouse event to be sent.
*
* Returns true if the event passed all protocol restrictions and a report
* was sent, otherwise false. The return value may be used to decide whether
* the default event action in the browser component should be omitted.
*
* Note: The method will change values of the given event object
* to fulfill protocol and encoding restrictions.
*/
private _triggerMouseEvent(e: ICoreMouseEvent): boolean {
// range check for col/row
if (e.col < 0 || e.col >= this._bufferService.cols
|| e.row < 0 || e.row >= this._bufferService.rows) {
return false;
}
// filter nonsense combinations of button + action
if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) {
return false;
}
if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) {
return false;
}
if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) {
return false;
}
// report 1-based coords
e.col++;
e.row++;
// debounce move events at grid or pixel level
if (e.action === CoreMouseAction.MOVE
&& this._lastEvent
&& this._equalEvents(this._lastEvent, e, this._mouseStateService.isPixelEncoding)
) {
return false;
}
// apply protocol restrictions
if (!this._mouseStateService.restrictMouseEvent(e)) {
return false;
}
// encode report and send
const report = this._mouseStateService.encodeMouseEvent(e);
if (report) {
if (this._mouseStateService.isDefaultEncoding) {
this._coreService.triggerBinaryEvent(report);
} else {
this._coreService.triggerDataEvent(report, true);
}
}
this._lastEvent = e;
return true;
}
private _explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
return {
col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
x: Math.floor(coords[0]),
y: Math.floor(coords[1])
down: !!(events & CoreMouseEventType.DOWN),
up: !!(events & CoreMouseEventType.UP),
drag: !!(events & CoreMouseEventType.DRAG),
move: !!(events & CoreMouseEventType.MOVE),
wheel: !!(events & CoreMouseEventType.WHEEL)
};
}
private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean {
if (pixels) {
if (e1.x !== e2.x) return false;
if (e1.y !== e2.y) return false;
} else {
if (e1.col !== e2.col) return false;
if (e1.row !== e2.row) return false;
}
if (e1.button !== e2.button) return false;
if (e1.action !== e2.action) return false;
if (e1.ctrl !== e2.ctrl) return false;
if (e1.alt !== e2.alt) return false;
if (e1.shift !== e2.shift) return false;
return true;
}
}
+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());
}
}
@@ -7,14 +7,17 @@ import { assert } from 'chai';
import { SelectionService, SelectionMode } from './SelectionService';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test';
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,16 +58,16 @@ 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, CellData.fromCharData([0, text.charAt(i), 1, text.charCodeAt(i)]));
result.setCell(i, createCellData(0, text.charAt(i), 1));
}
return result;
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(chars.length);
chars.map((c, idx) => line.setCell(idx, CellData.fromCharData([0, c, 1, c.charCodeAt(0)])));
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
+34 -36
View File
@@ -8,8 +8,8 @@ import { getCoordsRelativeToElement } from 'browser/input/Mouse';
import { moveToCellSequence } from 'browser/input/MoveToCell';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
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;
@@ -126,7 +125,7 @@ export class SelectionService extends Disposable implements ISelectionService {
private readonly _linkifier: ILinkifier2,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreService private readonly _coreService: ICoreService,
@IMouseService private readonly _mouseService: IMouseService,
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
@@ -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();
@@ -395,7 +394,7 @@ export class SelectionService extends Disposable implements ISelectionService {
* @param event The mouse event.
*/
private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined {
const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
const coords = this._mouseCoordsService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
if (!coords) {
return undefined;
}
@@ -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,9 +712,9 @@ 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._mouseService.getCoords(
const coordinates = this._mouseCoordsService.getCoords(
event,
this._element,
this._bufferService.cols,
@@ -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));
}
/**
+16 -2
View File
@@ -49,14 +49,28 @@ export interface ICoreBrowserService {
readonly dpr: number;
}
export const IMouseService = createDecorator<IMouseService>('MouseService');
export interface IMouseService {
export const IMouseCoordsService = createDecorator<IMouseCoordsService>('MouseCoordsService');
export interface IMouseCoordsService {
serviceBrand: undefined;
getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined;
getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined;
}
export const IMouseService = createDecorator<IMouseService>('MouseService');
export interface IMouseService {
serviceBrand: undefined;
bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void;
reset(): void;
}
export interface IMouseServiceTarget {
element: HTMLElement;
screenElement: HTMLElement;
document: Document;
handleTouchScroll?(amount: number): void;
}
export const IRenderService = createDecorator<IRenderService>('RenderService');
export interface IRenderService extends IDisposable {
serviceBrand: undefined;
+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;
}
+13 -13
View File
@@ -21,14 +21,14 @@
* http://linux.die.net/man/7/urxvt
*/
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
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';
import { CoreMouseService } from 'common/services/CoreMouseService';
import { MouseStateService } from 'common/services/MouseStateService';
import { UnicodeService } from 'common/services/UnicodeService';
import { CharsetService } from 'common/services/CharsetService';
import { updateWindowsModeWrappedState } from 'common/WindowsMode';
@@ -50,7 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
protected readonly _charsetService: ICharsetService;
protected readonly _oscLinkService: IOscLinkService;
public readonly coreMouseService: ICoreMouseService;
public readonly mouseStateService: IMouseStateService;
public readonly coreService: ICoreService;
public readonly unicodeService: IUnicodeService;
public readonly optionsService: IOptionsService;
@@ -113,8 +113,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this._instantiationService.setService(IBufferService, this._bufferService);
this.coreService = this._register(this._instantiationService.createInstance(CoreService));
this._instantiationService.setService(ICoreService, this.coreService);
this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService));
this._instantiationService.setService(ICoreMouseService, this.coreMouseService);
this.mouseStateService = this._register(this._instantiationService.createInstance(MouseStateService));
this._instantiationService.setService(IMouseStateService, this.mouseStateService);
this.unicodeService = this._register(this._instantiationService.createInstance(UnicodeService));
this._instantiationService.setService(IUnicodeService, this.unicodeService);
this._charsetService = this._instantiationService.createInstance(CharsetService);
@@ -124,7 +124,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
// Register input handler and handle/forward events
this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService));
this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.mouseStateService, this.unicodeService));
this._register(EventUtils.forward(this._inputHandler.onLineFeed, this._onLineFeed));
// Setup listeners
@@ -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 {
@@ -256,14 +256,14 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this._bufferService.reset();
this._charsetService.reset();
this.coreService.reset();
this.coreMouseService.reset();
this.mouseStateService.reset();
}
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) {
+94 -95
View File
@@ -11,10 +11,9 @@ import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
import { Params } from 'common/parser/Params';
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
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';
@@ -70,7 +69,7 @@ describe('InputHandler', () => {
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
oscLinkService = new OscLinkService(bufferService);
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockCoreMouseService(), new MockUnicodeService());
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockMouseStateService(), new MockUnicodeService());
});
describe('SL/SR/DECIC/DECDC', () => {
@@ -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);
@@ -261,7 +260,7 @@ describe('InputHandler', () => {
describe('setMode', () => {
it('should toggle bracketedPasteMode', () => {
const coreService = new MockCoreService();
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Set bracketed paste mode
inputHandler.setModePrivate(Params.fromArray([2004]));
assert.equal(coreService.decPrivateModes.bracketedPasteMode, true);
@@ -272,7 +271,7 @@ describe('InputHandler', () => {
it('should toggle colorSchemeUpdates (DECSET 2031)', () => {
const coreService = new MockCoreService();
const optionsService = new MockOptionsService();
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Set color scheme updates mode (default colorSchemeQuery=true)
inputHandler.setModePrivate(Params.fromArray([2031]));
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, true);
@@ -284,7 +283,7 @@ describe('InputHandler', () => {
const coreService = new MockCoreService();
const optionsService = new MockOptionsService();
optionsService.rawOptions.vtExtensions = { colorSchemeQuery: false };
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Attempt to set color scheme updates mode
inputHandler.setModePrivate(Params.fromArray([2031]));
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, false);
@@ -306,42 +305,42 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
// insert some data in first and second line
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
await inputHandler.parseP('1234567890');
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
await inputHandler.parseP('1234567890');
const line1: IBufferLine = bufferService.buffer.lines.get(0)!;
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '1234567890');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '1234567890');
// insert one char from params = [0]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.insertChars(Params.fromArray([0]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 123456789');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 123456789');
// insert one char from params = [1]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.insertChars(Params.fromArray([1]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 12345678');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 12345678');
// insert two chars from params = [2]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.insertChars(Params.fromArray([2]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 123456');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 123456');
// insert 10 chars from params = [10]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.insertChars(Params.fromArray([10]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' ');
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a'));
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' ');
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10));
});
it('deleteChars', async () => {
const bufferService = new MockBufferService(80, 30);
@@ -352,46 +351,46 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
// insert some data in first and second line
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
await inputHandler.parseP('1234567890');
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
await inputHandler.parseP('1234567890');
const line1: IBufferLine = bufferService.buffer.lines.get(0)!;
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '1234567890');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '1234567890');
// delete one char from params = [0]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.deleteChars(Params.fromArray([0]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '234567890 ');
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '234567890');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '234567890 ');
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '234567890');
// insert one char from params = [1]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.deleteChars(Params.fromArray([1]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '34567890 ');
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '34567890');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '34567890 ');
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '34567890');
// insert two chars from params = [2]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.deleteChars(Params.fromArray([2]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '567890 ');
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '567890');
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '567890 ');
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '567890');
// insert 10 chars from params = [10]
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.deleteChars(Params.fromArray([10]));
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' ');
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a'));
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' ');
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10));
});
it('eraseInLine', async () => {
const bufferService = new MockBufferService(80, 30);
@@ -402,32 +401,32 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
// fill 6 lines to test 3 different states
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols));
await inputHandler.parseP('a'.repeat(bufferService.cols));
await inputHandler.parseP('a'.repeat(bufferService.cols));
// params[0] - right erase
bufferService.buffer.y = 0;
bufferService.buffer.x = 70;
inputHandler.eraseInLine(Params.fromArray([0]));
assert.equal(bufferService.buffer.lines.get(0)!.translateToString(false), Array(71).join('a') + ' ');
assert.equal(bufferService.buffer.lines.get(0)!.translateToString(false), 'a'.repeat(70) + ' ');
// params[1] - left erase
bufferService.buffer.y = 1;
bufferService.buffer.x = 70;
inputHandler.eraseInLine(Params.fromArray([1]));
assert.equal(bufferService.buffer.lines.get(1)!.translateToString(false), Array(71).join(' ') + ' aaaaaaaaa');
assert.equal(bufferService.buffer.lines.get(1)!.translateToString(false), ' '.repeat(70) + ' aaaaaaaaa');
// params[1] - left erase
bufferService.buffer.y = 2;
bufferService.buffer.x = 70;
inputHandler.eraseInLine(Params.fromArray([2]));
assert.equal(bufferService.buffer.lines.get(2)!.translateToString(false), Array(bufferService.cols + 1).join(' '));
assert.equal(bufferService.buffer.lines.get(2)!.translateToString(false), ' '.repeat(bufferService.cols));
});
it('eraseInLine reflow', async () => {
@@ -439,7 +438,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
@@ -447,9 +446,9 @@ describe('InputHandler', () => {
// reset and add a wrapped line
bufferService.buffer.y = 0;
bufferService.buffer.x = 0;
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// confirm precondition that line 2 is wrapped
assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, true);
@@ -488,10 +487,10 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService({ scrollOnEraseInDisplay: true }),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
const aLine = Array(bufferService.cols + 1).join('a');
const aLine = 'a'.repeat(bufferService.cols);
// add 2 full lines of text.
await inputHandler.parseP(aLine);
await inputHandler.parseP(aLine);
@@ -520,53 +519,53 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
// fill display with a's
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// params [0] - right and below erase
bufferService.buffer.y = 5;
bufferService.buffer.x = 40;
inputHandler.eraseInDisplay(Params.fromArray([0]));
assert.deepEqual(termContent(bufferService, false), [
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(40 + 1).join('a') + Array(bufferService.cols - 40 + 1).join(' '),
Array(bufferService.cols + 1).join(' ')
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(40) + ' '.repeat(bufferService.cols - 40),
' '.repeat(bufferService.cols)
]);
assert.deepEqual(termContent(bufferService, true), [
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(bufferService.cols + 1).join('a'),
Array(40 + 1).join('a'),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(bufferService.cols),
'a'.repeat(40),
''
]);
// reset
bufferService.buffer.y = 0;
bufferService.buffer.x = 0;
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// params [1] - left and above
bufferService.buffer.y = 5;
bufferService.buffer.x = 40;
inputHandler.eraseInDisplay(Params.fromArray([1]));
assert.deepEqual(termContent(bufferService, false), [
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(41 + 1).join(' ') + Array(bufferService.cols - 41 + 1).join('a'),
Array(bufferService.cols + 1).join('a')
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(41) + 'a'.repeat(bufferService.cols - 41),
'a'.repeat(bufferService.cols)
]);
assert.deepEqual(termContent(bufferService, true), [
'',
@@ -574,27 +573,27 @@ describe('InputHandler', () => {
'',
'',
'',
Array(41 + 1).join(' ') + Array(bufferService.cols - 41 + 1).join('a'),
Array(bufferService.cols + 1).join('a')
' '.repeat(41) + 'a'.repeat(bufferService.cols - 41),
'a'.repeat(bufferService.cols)
]);
// reset
bufferService.buffer.y = 0;
bufferService.buffer.x = 0;
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// params [2] - whole screen
bufferService.buffer.y = 5;
bufferService.buffer.x = 40;
inputHandler.eraseInDisplay(Params.fromArray([2]));
assert.deepEqual(termContent(bufferService, false), [
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' '),
Array(bufferService.cols + 1).join(' ')
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols),
' '.repeat(bufferService.cols)
]);
assert.deepEqual(termContent(bufferService, true), [
'',
@@ -609,9 +608,9 @@ describe('InputHandler', () => {
// reset and add a wrapped line
bufferService.buffer.y = 0;
bufferService.buffer.x = 0;
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// params[1] left and above with wrap
// confirm precondition that line 2 is wrapped
@@ -624,9 +623,9 @@ describe('InputHandler', () => {
// reset and add a wrapped line
bufferService.buffer.y = 0;
bufferService.buffer.x = 0;
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
// params[1] left and above with wrap
// confirm precondition that line 2 is wrapped
@@ -646,7 +645,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockCoreMouseService(),
new MockMouseStateService(),
new MockUnicodeService()
);
const container = new Uint32Array(10);
@@ -674,7 +673,7 @@ describe('InputHandler', () => {
beforeEach(() => {
bufferService = new MockBufferService(80, 30);
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => {
await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST');
@@ -887,7 +886,7 @@ describe('InputHandler', () => {
describe('colon notation', () => {
let inputHandler2: TestInputHandler;
beforeEach(() => {
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
describe('should equal to semicolon', () => {
it('CSI 38:2::50:100:150 m', async () => {
@@ -2002,15 +2001,15 @@ describe('InputHandler', () => {
// eAttrs in buffer pos 0 and 1 should be the same object
assert.equal(
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[0],
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[1]
extendedAttributes(bufferService.buffer!.lines.get(0)!, 0),
extendedAttributes(bufferService.buffer!.lines.get(0)!, 1)
);
// should not have written eAttr for pos 2 in the buffer
assert.equal((bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[2], undefined);
assert.equal(extendedAttributes(bufferService.buffer!.lines.get(0)!, 2), undefined);
// eAttrs in buffer pos 1 and pos 3 must be different objs
assert.notEqual(
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[1],
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[3]
extendedAttributes(bufferService.buffer!.lines.get(0)!, 1),
extendedAttributes(bufferService.buffer!.lines.get(0)!, 3)
);
});
});
@@ -2461,7 +2460,7 @@ describe('InputHandler', () => {
bufferService = new BufferService(optionsService, new MockLogService());
bufferService.resize(80, 30);
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
describe('stack limit', () => {
@@ -2513,7 +2512,7 @@ describe('InputHandler', () => {
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
coreService.onData(data => { console.log(data); });
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
it('async CUP with CPR check', async () => {
+84 -85
View File
@@ -11,11 +11,13 @@ 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';
import { AttributeData } from 'common/buffer/AttributeData';
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
import { ICoreService, IBufferService, IOptionsService, ILogService, IMouseStateService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
import { OscHandler } from 'common/parser/OscParser';
import { DcsHandler } from 'common/parser/DcsParser';
@@ -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;
@@ -178,7 +159,7 @@ export class InputHandler extends Disposable implements IInputHandler {
private readonly _logService: ILogService,
private readonly _optionsService: IOptionsService,
private readonly _oscLinkService: IOscLinkService,
private readonly _coreMouseService: ICoreMouseService,
private readonly _mouseStateService: IMouseStateService,
private readonly _unicodeService: IUnicodeService,
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
) {
@@ -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;
@@ -1987,19 +1990,19 @@ export class InputHandler extends Disposable implements IInputHandler {
break;
case 9: // X10 Mouse
// no release, no motion, no wheel, no modifiers.
this._coreMouseService.activeProtocol = 'X10';
this._mouseStateService.activeProtocol = 'X10';
break;
case 1000: // vt200 mouse
// no motion.
this._coreMouseService.activeProtocol = 'VT200';
this._mouseStateService.activeProtocol = 'VT200';
break;
case 1002: // button event mouse
this._coreMouseService.activeProtocol = 'DRAG';
this._mouseStateService.activeProtocol = 'DRAG';
break;
case 1003: // any event mouse
// any event - sends motion events,
// even if there is no button held down.
this._coreMouseService.activeProtocol = 'ANY';
this._mouseStateService.activeProtocol = 'ANY';
break;
case 1004: // send focusin/focusout events
// focusin: ^[[I
@@ -2011,13 +2014,13 @@ export class InputHandler extends Disposable implements IInputHandler {
this._logService.debug('DECSET 1005 not supported (see #2507)');
break;
case 1006: // sgr ext mode mouse
this._coreMouseService.activeEncoding = 'SGR';
this._mouseStateService.activeEncoding = 'SGR';
break;
case 1015: // urxvt ext mode mouse - removed in #2507
this._logService.debug('DECSET 1015 not supported (see #2507)');
break;
case 1016: // sgr pixels mode mouse
this._coreMouseService.activeEncoding = 'SGR_PIXELS';
this._mouseStateService.activeEncoding = 'SGR_PIXELS';
break;
case 25: // show cursor
this._coreService.isCursorHidden = false;
@@ -2248,7 +2251,7 @@ export class InputHandler extends Disposable implements IInputHandler {
case 1000: // vt200 mouse
case 1002: // button event mouse
case 1003: // any event mouse
this._coreMouseService.activeProtocol = 'NONE';
this._mouseStateService.activeProtocol = 'NONE';
break;
case 1004: // send focusin/focusout events
this._coreService.decPrivateModes.sendFocus = false;
@@ -2257,13 +2260,13 @@ export class InputHandler extends Disposable implements IInputHandler {
this._logService.debug('DECRST 1005 not supported (see #2507)');
break;
case 1006: // sgr ext mode mouse
this._coreMouseService.activeEncoding = 'DEFAULT';
this._mouseStateService.activeEncoding = 'DEFAULT';
break;
case 1015: // urxvt ext mode mouse - removed in #2507
this._logService.debug('DECRST 1015 not supported (see #2507)');
break;
case 1016: // sgr pixels mode mouse
this._coreMouseService.activeEncoding = 'DEFAULT';
this._mouseStateService.activeEncoding = 'DEFAULT';
break;
case 25: // hide cursor
this._coreService.isCursorHidden = true;
@@ -2357,7 +2360,7 @@ export class InputHandler extends Disposable implements IInputHandler {
// access helpers
const dm = this._coreService.decPrivateModes;
const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._coreMouseService;
const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._mouseStateService;
const cs = this._coreService;
const { buffers, cols } = this._bufferService;
const { active, alt } = buffers;
@@ -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();
@@ -3570,7 +3569,7 @@ export class InputHandler extends Disposable implements IInputHandler {
return true;
}
const flags = params.params[0] || 0;
const mode = params.params[1] || 1;
const mode = params.length > 1 ? (params.params[1] || 1) : 1;
const state = this._coreService.kittyKeyboard;
switch (mode) {
+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();
}
}
+5 -2
View File
@@ -3,7 +3,6 @@
* @license MIT
*/
import { isNode } from 'common/Platform';
import type { ILogService } from 'common/services/Services';
interface ITaskQueue {
@@ -148,7 +147,7 @@ class IdleTaskQueueInternal extends TaskQueue {
* This reverts to a {@link PriorityTaskQueue} if the environment does not support idle callbacks.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? IdleTaskQueueInternal : PriorityTaskQueue;
export const IdleTaskQueue = ('requestIdleCallback' in globalThis) ? IdleTaskQueueInternal : PriorityTaskQueue;
/**
* An object that tracks a single debounced task that will run on the next idle frame. When called
@@ -169,4 +168,8 @@ export class DebouncedIdleTask {
public flush(): void {
this._queue.flush();
}
public dispose(): void {
this._queue.clear();
}
}
+24 -12
View File
@@ -3,16 +3,29 @@
* @license MIT
*/
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
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';
import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData, IDisposable } from 'common/Types';
import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData, IDisposable, IBufferLine, IExtendedAttrs } from 'common/Types';
import { UnicodeV6 } from 'common/input/UnicodeV6';
import { IDecorationOptions, IDecoration } from '@xterm/xterm';
import { Emitter, type IEvent } from 'common/Event';
import { CellData } from 'common/buffer/CellData';
import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from 'common/buffer/Constants';
export function createCellData(attr: number, char: string, width: number): CellData {
return CellData.fromCharData([attr, char, width, char.length === 0 ? 0 : char.charCodeAt(0)]);
}
export function extendedAttributes(line: IBufferLine, index: number): IExtendedAttrs | undefined {
const cell = new CellData();
line.loadCell(index, cell);
return cell.hasExtendedAttrs() !== 0 ? cell.extended : undefined;
}
export const NULL_CELL_DATA = Object.freeze(createCellData(DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH));
export class MockBufferService implements IBufferService {
public serviceBrand: any;
@@ -58,22 +71,21 @@ export class MockBufferService implements IBufferService {
public reset(): void { }
}
export class MockCoreMouseService implements ICoreMouseService {
export class MockMouseStateService implements IMouseStateService {
public serviceBrand: any;
public areMouseEventsActive: boolean = false;
public activeEncoding: string = '';
public activeProtocol: string = '';
public isDefaultEncoding: boolean = true;
public isPixelEncoding: boolean = false;
public addEncoding(name: string): void { }
public addProtocol(name: string): void { }
public reset(): void { }
public triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; }
public onProtocolChange: IEvent<CoreMouseEventType> = new Emitter<CoreMouseEventType>().event;
public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
throw new Error('Method not implemented.');
}
public consumeWheelEvent(ev: WheelEvent, cellHeight: number, dpr: number): number {
return 1;
}
public restrictMouseEvent(event: ICoreMouseEvent): boolean { return true; }
public encodeMouseEvent(event: ICoreMouseEvent): string { return ''; }
public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void { }
public allowCustomWheelEvent(ev: WheelEvent): boolean { return true; }
}
export class MockCharsetService implements ICharsetService {
@@ -144,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>) {
+26 -26
View File
@@ -7,12 +7,12 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { UnderlineStyle } from 'common/buffer/Constants';
import { IBufferSet } from 'common/buffer/Types';
import { IParams } from 'common/parser/Types';
import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
import { IMouseStateService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from '@xterm/xterm';
import type { Emitter, IEvent } from 'common/Event';
export interface ICoreTerminal {
coreMouseService: ICoreMouseService;
mouseStateService: IMouseStateService;
coreService: ICoreService;
optionsService: IOptionsService;
unicodeService: IUnicodeService;
@@ -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 {
@@ -101,13 +101,13 @@ export interface ICharset {
[key: string]: string | undefined;
}
export type CharData = [number, string, number, number];
export type CharData = [attr: number, char: string, width: number, code: number];
export interface IColor {
readonly css: string;
readonly rgba: number; // 32-bit int with rgba in each byte
}
export type IColorRGB = [number, number, number];
export type IColorRGB = [red: number, green: number, blue: number];
export interface IExtendedAttrs {
ext: number;
@@ -343,7 +343,7 @@ export interface ICoreMouseEvent {
* it is not possible to report multiple buttons at once.
* Wheel is treated as a button.
* There are invalid combinations of buttons and actions possible
* (like move + wheel), those are silently ignored by the CoreMouseService.
* (like move + wheel), those are silently ignored by the MouseStateService.
*/
button: CoreMouseButton;
action: CoreMouseAction;
@@ -360,7 +360,7 @@ export interface ICoreMouseEvent {
* CoreMouseEventType
* To be reported to the browser component which events a mouse
* protocol wants to be catched and forwarded as an ICoreMouseEvent
* to CoreMouseService.
* to MouseStateService.
*/
export const enum CoreMouseEventType {
NONE = 0,
@@ -378,7 +378,7 @@ export const enum CoreMouseEventType {
/**
* Mouse protocol interface.
* A mouse protocol can be registered and activated at the CoreMouseService.
* A mouse protocol can be registered and activated at the MouseStateService.
* `events` should contain a list of needed events as a hint for the browser component
* to install/remove the appropriate event handlers.
* `restrict` applies further protocol specific restrictions like not allowed
@@ -391,7 +391,7 @@ export interface ICoreMouseProtocol {
/**
* CoreMouseEncoding
* The tracking encoding can be registered and activated at the CoreMouseService.
* The tracking encoding can be registered and activated at the MouseStateService.
* If a ICoreMouseEvent passes all procotol restrictions it will be encoded
* with the active encoding and sent out.
* Note: Returning an empty string will supress sending a mouse report,
@@ -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;
}
+136 -31
View File
@@ -6,24 +6,36 @@
import { assert } from 'chai';
import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
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,11 +1116,11 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(4);
line.setCell(0, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([ 0, 'b', 1, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([ 0, 'c', 1, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([ 0, 'd', 1, 'd'.charCodeAt(0)]));
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));
line.setCell(3, createCellData(0, 'd', 1));
buffer.lines.set(0, line);
const str = buffer.translateBufferLineToString(0, true, 0, 2);
@@ -1116,10 +1128,10 @@ describe('Buffer', () => {
});
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(3);
line.setCell(0, CellData.fromCharData([ 0, '語', 2, 35486 ]));
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
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));
buffer.lines.set(0, line);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1127,10 +1139,10 @@ describe('Buffer', () => {
});
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(3);
line.setCell(0, CellData.fromCharData([ 0, '語', 2, '語'.charCodeAt(0) ]));
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
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));
buffer.lines.set(0, line);
const str0 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1144,9 +1156,9 @@ describe('Buffer', () => {
});
it('should handle single width emojis', () => {
const line = new BufferLine(2);
line.setCell(0, CellData.fromCharData([ 0, '😁', 1, '😁'.charCodeAt(0) ]));
line.setCell(1, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
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);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1157,9 +1169,9 @@ describe('Buffer', () => {
});
it('should handle double width emojis', () => {
const line = new BufferLine(2);
line.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
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);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1168,10 +1180,10 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
const line2 = new BufferLine(3);
line2.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
line2.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
line2.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
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));
buffer.lines.set(0, line2);
const str3 = buffer.translateBufferLineToString(0, true, 0, 3);
@@ -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) {
+311 -211
View File
@@ -4,17 +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) {
@@ -173,78 +197,78 @@ describe('BufferLine', function(): void {
assert.equal(line.length, 10);
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
assert.equal(line.isWrapped, true);
line = new TestBufferLine(10, CellData.fromCharData([123, 'a', 456, 'a'.charCodeAt(0)]), true);
line = new TestBufferLine(10, createCellData(123, 'a', 456), true);
assert.equal(line.length, 10);
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [123, 'a', 456, 'a'.charCodeAt(0)]);
assert.equal(line.isWrapped, true);
});
it('insertCells', function(): void {
const line = new TestBufferLine(3);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.insertCells(1, 3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.insertCells(1, 3, createCellData(4, 'd', 1));
assert.deepEqual(line.toArray(), [
[1, 'a', 0, 'a'.charCodeAt(0)],
[4, 'd', 0, 'd'.charCodeAt(0)],
[4, 'd', 0, 'd'.charCodeAt(0)]
[1, 'a', 1, 'a'.charCodeAt(0)],
[4, 'd', 1, 'd'.charCodeAt(0)],
[4, 'd', 1, 'd'.charCodeAt(0)]
]);
});
it('deleteCells', function(): void {
const line = new TestBufferLine(5);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
line.deleteCells(1, 2, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.setCell(3, createCellData(4, 'd', 1));
line.setCell(4, createCellData(5, 'e', 1));
line.deleteCells(1, 2, createCellData(6, 'f', 1));
assert.deepEqual(line.toArray(), [
[1, 'a', 0, 'a'.charCodeAt(0)],
[4, 'd', 0, 'd'.charCodeAt(0)],
[5, 'e', 0, 'e'.charCodeAt(0)],
[6, 'f', 0, 'f'.charCodeAt(0)],
[6, 'f', 0, 'f'.charCodeAt(0)]
[1, 'a', 1, 'a'.charCodeAt(0)],
[4, 'd', 1, 'd'.charCodeAt(0)],
[5, 'e', 1, 'e'.charCodeAt(0)],
[6, 'f', 1, 'f'.charCodeAt(0)],
[6, 'f', 1, 'f'.charCodeAt(0)]
]);
});
it('replaceCells', function(): void {
const line = new TestBufferLine(5);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
line.replaceCells(2, 4, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.setCell(3, createCellData(4, 'd', 1));
line.setCell(4, createCellData(5, 'e', 1));
line.replaceCells(2, 4, createCellData(6, 'f', 1));
assert.deepEqual(line.toArray(), [
[1, 'a', 0, 'a'.charCodeAt(0)],
[2, 'b', 0, 'b'.charCodeAt(0)],
[6, 'f', 0, 'f'.charCodeAt(0)],
[6, 'f', 0, 'f'.charCodeAt(0)],
[5, 'e', 0, 'e'.charCodeAt(0)]
[1, 'a', 1, 'a'.charCodeAt(0)],
[2, 'b', 1, 'b'.charCodeAt(0)],
[6, 'f', 1, 'f'.charCodeAt(0)],
[6, 'f', 1, 'f'.charCodeAt(0)],
[5, 'e', 1, 'e'.charCodeAt(0)]
]);
});
it('fill', function(): void {
const line = new TestBufferLine(5);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
line.fill(CellData.fromCharData([123, 'z', 0, 'z'.charCodeAt(0)]));
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.setCell(3, createCellData(4, 'd', 1));
line.setCell(4, createCellData(5, 'e', 1));
line.fill(createCellData(123, 'z', 1));
assert.deepEqual(line.toArray(), [
[123, 'z', 0, 'z'.charCodeAt(0)],
[123, 'z', 0, 'z'.charCodeAt(0)],
[123, 'z', 0, 'z'.charCodeAt(0)],
[123, 'z', 0, 'z'.charCodeAt(0)],
[123, 'z', 0, 'z'.charCodeAt(0)]
[123, 'z', 1, 'z'.charCodeAt(0)],
[123, 'z', 1, 'z'.charCodeAt(0)],
[123, 'z', 1, 'z'.charCodeAt(0)],
[123, 'z', 1, 'z'.charCodeAt(0)],
[123, 'z', 1, 'z'.charCodeAt(0)]
]);
});
it('clone', function(): void {
const line = new TestBufferLine(5, undefined, true);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.setCell(3, createCellData(4, 'd', 1));
line.setCell(4, createCellData(5, 'e', 1));
const line2 = line.clone();
assert.deepEqual(TestBufferLine.prototype.toArray.apply(line2), line.toArray());
assert.equal(line2.length, line.length);
@@ -252,12 +276,12 @@ describe('BufferLine', function(): void {
});
it('copyFrom', function(): void {
const line = new TestBufferLine(5);
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), true);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(2, 'b', 1));
line.setCell(2, createCellData(3, 'c', 1));
line.setCell(3, createCellData(4, 'd', 1));
line.setCell(4, createCellData(5, 'e', 1));
const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
line2.copyFrom(line);
assert.deepEqual(line2.toArray(), line.toArray());
assert.equal(line2.length, line.length);
@@ -267,9 +291,9 @@ describe('BufferLine', function(): void {
// CHAR_DATA_CODE_INDEX resembles current behavior in InputHandler.print
// --> set code to the last charCodeAt value of the string
// Note: needs to be fixed once the string pointer is in place
const line = new TestBufferLine(2, CellData.fromCharData([1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]));
assert.deepEqual(line.toArray(), [[1, 'e\u0301', 0, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]]);
const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, '\u0301'.charCodeAt(0)]), true);
const line = new TestBufferLine(2, createCellData(1, 'e\u0301', 1));
assert.deepEqual(line.toArray(), [[1, 'e\u0301', 1, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]]);
const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
line2.copyFrom(line);
assert.deepEqual(line2.toArray(), line.toArray());
const line3 = line.clone();
@@ -277,72 +301,72 @@ describe('BufferLine', function(): void {
});
describe('resize', function(): void {
it('enlarge(false)', function(): void {
const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
line.resize(10, createCellData(1, 'a', 1));
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('enlarge(true)', function(): void {
const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
line.resize(10, createCellData(1, 'a', 1));
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('shrink(true) - should apply new size', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
line.resize(5, createCellData(1, 'a', 1));
assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('shrink to 0 length', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
line.resize(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
line.resize(0, createCellData(1, 'a', 1));
assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('should remove combining data on replaced cells after shrinking then enlarging', () => {
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
line.set(2, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
line.set(9, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
assert.equal(line.translateToString(), 'aa😁aaaaaa😁');
assert.equal(Object.keys(line.combined).length, 2);
line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.resize(5, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa😁aa');
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
line.resize(10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa😁aaaaaaa');
assert.equal(Object.keys(line.combined).length, 1);
});
});
describe('getTrimLength', function(): void {
it('empty line', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
assert.equal(line.getTrimmedLength(), 0);
});
it('ASCII', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, 'a', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('surrogate', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, '𝄞', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('combining', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, 'e\u0301', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('fullwidth', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, '1', 2));
line.setCell(3, createCellData(0, '', 0));
assert.equal(line.getTrimmedLength(), 4); // also counts null cell after fullwidth
});
});
describe('translateToString with and w\'o trimming', function(): void {
it('empty line', function(): void {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
const columns: number[] = [];
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
@@ -351,11 +375,11 @@ describe('BufferLine', function(): void {
});
it('ASCII', function(): void {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, 'a', 1));
line.setCell(4, createCellData(1, 'a', 1));
line.setCell(5, createCellData(1, 'a', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa');
@@ -372,11 +396,11 @@ describe('BufferLine', function(): void {
});
it('surrogate', function(): void {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
line.setCell(5, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, '𝄞', 1));
line.setCell(4, createCellData(1, '𝄞', 1));
line.setCell(5, createCellData(1, '𝄞', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 𝄞 𝄞𝄞 ');
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 𝄞 𝄞𝄞');
@@ -392,11 +416,11 @@ describe('BufferLine', function(): void {
});
it('combining', function(): void {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
line.setCell(5, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, 'e\u0301', 1));
line.setCell(4, createCellData(1, 'e\u0301', 1));
line.setCell(5, createCellData(1, 'e\u0301', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301 ');
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301');
@@ -412,14 +436,14 @@ describe('BufferLine', function(): void {
});
it('fullwidth', function(): void {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
line.setCell(5, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
line.setCell(6, CellData.fromCharData([0, '', 0, 0]));
line.setCell(7, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
line.setCell(8, CellData.fromCharData([0, '', 0, 0]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, '1', 2));
line.setCell(3, createCellData(0, '', 0));
line.setCell(5, createCellData(1, '1', 2));
line.setCell(6, createCellData(0, '', 0));
line.setCell(7, createCellData(1, '1', 2));
line.setCell(8, createCellData(0, '', 0));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 1 11 ');
assert.deepEqual(columns, [0, 1, 2, 4, 5, 7, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 1 11');
@@ -441,12 +465,12 @@ describe('BufferLine', function(): void {
});
it('space at end', function(): void {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.setCell(6, CellData.fromCharData([1, ' ', 1, ' '.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(2, createCellData(1, 'a', 1));
line.setCell(4, createCellData(1, 'a', 1));
line.setCell(5, createCellData(1, 'a', 1));
line.setCell(6, createCellData(1, ' ', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa ');
@@ -457,7 +481,7 @@ describe('BufferLine', function(): void {
// sanity check - broken line with invalid out of bound null width cells
// this can atm happen with deleting/inserting chars in inputhandler by "breaking"
// fullwidth pairs --> needs to be fixed after settling BufferLine impl
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), '');
@@ -465,15 +489,15 @@ describe('BufferLine', function(): void {
});
it('should work with endCol=0', () => {
const columns: number[] = [];
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
line.setCell(0, createCellData(1, 'a', 1));
assert.equal(line.translateToString(true, 0, 0, columns), '');
assert.deepEqual(columns, [0]);
});
});
describe('addCharToCell', () => {
it('should set width to 1 for empty cell', () => {
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
line.addCodepointToCell(0, '\u0301'.charCodeAt(0), 0);
const cell = line.loadCell(0, new CellData());
// chars contains single combining char
@@ -483,7 +507,7 @@ describe('BufferLine', function(): void {
assert.equal(cell.isCombined(), 0);
});
it('should add char to combining string in cell', () => {
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
const cell = line .loadCell(0, new CellData());
cell.setFromCharData([123, 'e\u0301', 1, 'e\u0301'.charCodeAt(1)]);
line.setCell(0, cell);
@@ -496,7 +520,7 @@ describe('BufferLine', function(): void {
assert.equal(cell.isCombined(), Content.IS_COMBINED_MASK);
});
it('should create combining string on taken cell', () => {
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
const cell = line .loadCell(0, new CellData());
cell.setFromCharData([123, 'e', 1, 'e'.charCodeAt(1)]);
line.setCell(0, cell);
@@ -511,98 +535,98 @@ describe('BufferLine', function(): void {
});
describe('correct fullwidth handling', () => {
function populate(line: BufferLine): void {
const cell = CellData.fromCharData([1, '¥', 2, '¥'.charCodeAt(0)]);
const cell = createCellData(1, '¥', 2);
for (let i = 0; i < line.length; i += 2) {
line.setCell(i, cell);
}
}
it('insert - wide char at pos', () => {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.insertCells(9, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(9, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), '¥¥¥¥ a');
line.insertCells(8, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(8, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), '¥¥¥¥a ');
line.insertCells(1, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(1, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' a ¥¥¥a');
});
it('insert - wide char at end', () => {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.insertCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(0, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa¥¥¥ ');
line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(4, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa a ¥¥');
line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.insertCells(4, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa aa ¥ ');
});
it('delete', () => {
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.deleteCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.deleteCells(0, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥¥¥a');
line.deleteCells(5, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.deleteCells(5, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥¥aaa');
line.deleteCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.deleteCells(0, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥aaaaa');
});
it('replace - start at 0', () => {
let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
let line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'a ¥¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa¥¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa ¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 8, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaa¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 9, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaaa ');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(0, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(0, 10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaaaa');
});
it('replace - start at 1', () => {
let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
let line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' a¥¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aa ¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 4, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaa¥¥¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 8, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaa¥');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 9, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaaa ');
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
line.replaceCells(1, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
line.replaceCells(1, 10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaaaa');
});
});
describe('extended attributes', () => {
it('setCells', function(): void {
const line = new TestBufferLine(5);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -612,7 +636,7 @@ describe('BufferLine', function(): void {
line.setCell(1, cell);
// same eAttr, different codepoint
cell.content = 65; // 'A'
cell.content = createCellData(1, 'A', 1).content;
line.setCell(2, cell);
// different eAttr
@@ -625,25 +649,25 @@ describe('BufferLine', function(): void {
line.setCell(4, cell);
assert.deepEqual(line.toArray(), [
[1, 'a', 0, 'a'.charCodeAt(0)],
[1, 'a', 0, 'a'.charCodeAt(0)],
[1, 'A', 0, 'A'.charCodeAt(0)],
[1, 'A', 0, 'A'.charCodeAt(0)],
[1, 'A', 0, 'A'.charCodeAt(0)]
[1, 'a', 1, 'a'.charCodeAt(0)],
[1, 'a', 1, 'a'.charCodeAt(0)],
[1, 'A', 1, 'A'.charCodeAt(0)],
[1, 'A', 1, 'A'.charCodeAt(0)],
[1, 'A', 1, 'A'.charCodeAt(0)]
]);
assert.equal((line as any)._extendedAttrs[0], undefined);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
assert.equal((line as any)._extendedAttrs[4], undefined);
assert.equal(extendedAttributes(line, 0), undefined);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
assert.equal(extendedAttributes(line, 4)?.underlineStyle, undefined);
// should be ref to the same object
assert.equal((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[2]);
assert.equal(extendedAttributes(line, 1), extendedAttributes(line, 2));
// should be a different obj
assert.notEqual((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[3]);
assert.notEqual(extendedAttributes(line, 1), extendedAttributes(line, 3));
});
it('loadCell', () => {
const line = new TestBufferLine(5);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -686,65 +710,65 @@ describe('BufferLine', function(): void {
});
it('fill', () => {
const line = new TestBufferLine(3);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
cell.extended.underlineStyle = UnderlineStyle.CURLY;
cell.bg |= BgFlags.HAS_EXTENDED;
line.fill(cell);
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
});
it('insertCells', () => {
const line = new TestBufferLine(5);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
cell.extended.underlineStyle = UnderlineStyle.CURLY;
cell.bg |= BgFlags.HAS_EXTENDED;
line.insertCells(1, 3, cell);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[4], undefined);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 4), undefined);
cell.extended = cell.extended.clone();
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
line.insertCells(2, 2, cell);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOTTED);
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOTTED);
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
});
it('deleteCells', () => {
const line = new TestBufferLine(5);
const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const fillCell = createCellData(1, 'a', 1);
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
fillCell.bg |= BgFlags.HAS_EXTENDED;
line.fill(fillCell);
fillCell.extended = fillCell.extended.clone();
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
line.deleteCells(1, 3, fillCell);
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOUBLE);
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.DOUBLE);
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOUBLE);
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.DOUBLE);
});
it('replaceCells', () => {
const line = new TestBufferLine(5);
const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const fillCell = createCellData(1, 'a', 1);
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
fillCell.bg |= BgFlags.HAS_EXTENDED;
line.fill(fillCell);
fillCell.extended = fillCell.extended.clone();
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
line.replaceCells(1, 3, fillCell);
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.DOUBLE);
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.DOUBLE);
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
});
it('clone', () => {
const line = new TestBufferLine(5);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -767,15 +791,15 @@ describe('BufferLine', function(): void {
line.setCell(4, cell);
const nLine = line.clone();
assert.equal((nLine as any)._extendedAttrs[0], (line as any)._extendedAttrs[0]);
assert.equal((nLine as any)._extendedAttrs[1], (line as any)._extendedAttrs[1]);
assert.equal((nLine as any)._extendedAttrs[2], (line as any)._extendedAttrs[2]);
assert.equal((nLine as any)._extendedAttrs[3], (line as any)._extendedAttrs[3]);
assert.equal((nLine as any)._extendedAttrs[4], (line as any)._extendedAttrs[4]);
assert.equal(extendedAttributes(nLine, 0), extendedAttributes(line, 0));
assert.equal(extendedAttributes(nLine, 1), extendedAttributes(line, 1));
assert.equal(extendedAttributes(nLine, 2), extendedAttributes(line, 2));
assert.equal(extendedAttributes(nLine, 3), extendedAttributes(line, 3));
assert.equal(extendedAttributes(nLine, 4), extendedAttributes(line, 4));
});
it('copyFrom', () => {
const initial = new TestBufferLine(5);
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
const cell = createCellData(1, 'a', 1);
// no eAttrs
initial.setCell(0, cell);
@@ -798,13 +822,89 @@ describe('BufferLine', function(): void {
initial.setCell(4, cell);
const line = new TestBufferLine(5);
line.fill(CellData.fromCharData([1, 'b', 0, 'b'.charCodeAt(0)]));
line.fill(createCellData(1, 'b', 1));
line.copyFrom(initial);
assert.equal((line as any)._extendedAttrs[0], (initial as any)._extendedAttrs[0]);
assert.equal((line as any)._extendedAttrs[1], (initial as any)._extendedAttrs[1]);
assert.equal((line as any)._extendedAttrs[2], (initial as any)._extendedAttrs[2]);
assert.equal((line as any)._extendedAttrs[3], (initial as any)._extendedAttrs[3]);
assert.equal((line as any)._extendedAttrs[4], (initial as any)._extendedAttrs[4]);
assert.equal(extendedAttributes(line, 0), extendedAttributes(initial, 0));
assert.equal(extendedAttributes(line, 1), extendedAttributes(initial, 1));
assert.equal(extendedAttributes(line, 2), extendedAttributes(initial, 2));
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)]);

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