Compare commits

..
Author SHA1 Message Date
Jörg Breitbart f9c38786bb fix IIP resize, fix viewport resize 2026-09-05 11:53:50 +02:00
Jörg Breitbart 039e103ed8 add painting primitives to IImageSpec, move IIP resize to late rendering 2026-09-02 21:50:25 +02:00
Jörg Breitbart c9aabf9cf5 fix demo 2026-09-01 11:35:05 +02:00
Jörg Breitbart dc1fab5d5d multiple changes:
- renderer: delete static primitives
- renderer: use device pixels for drawing and clearing
- renderer: simplify placeholder creation
- renderer: localize dimensions in rescaleCanvas
- renderer: rescale on drawing
- storage: store raw bytes as blobs
- storage: alter addImage interface adding blob and metrics data
- storage: switch from pixel to byte counting
- storage: getBlobUsage()
- Drawable as storage/renderer handle
- use metrics for all protocols
- remove extractTileAtBufferCell
- use device pixels in CSI t 14|16 reports
2026-09-01 11:16:43 +02:00
Jörg Breitbart b7ef9fa25e cleanup primitives and move to own file 2026-08-30 22:53:15 +02:00
jerch c58ea3637f Merge pull request #5879 from PerBothner/image-ext
Change image storage to use new ExtendedAttrs payload field
2026-08-30 22:11:27 +02:00
Per Bothner 3eaea160f4 Fix bad ExtendedAttrs sharing in ImageStore 2026-08-30 12:40:59 -07:00
Per Bothner c9b14056c5 Merge remote-tracking branch 'upstream/master' into image-ext
Also make faster by removing much loadCell calls.
2026-08-30 08:52:48 -07:00
jerch d3e32b344d Merge pull request #6125 from xtermjs/webp_avif_support
webp and avif support for IIP
2026-08-24 13:33:17 +02:00
jerch 0659493637 Merge branch 'master' into webp_avif_support 2026-08-24 13:27:57 +02:00
Anthony Kim 08ad9a4de9 Merge pull request #6131 from anthonykim1/anthonykim1/fix-image-text-overwrite
Restore text overwrite of image tiles
2026-08-23 19:58:25 -10:00
Anthony Kim fbaf28af5c restore text overwrite of image tiles 2026-08-23 22:48:22 -07:00
Anthony Kim 8938fe3785 Merge pull request #6128 from anthonykim1/anthonykim1/fix-pattern-glyph-owner-document
Fix pattern custom glyph rendering across documents
2026-08-23 17:35:52 -10:00
Anthony Kim 163104e14f Fix pattern custom glyph rendering across documents 2026-08-23 20:12:13 -07:00
Jörg Breitbart 9db3a4beeb update addon docs 2026-08-22 11:27:37 +02:00
Jörg Breitbart 3558626911 add demo images 2026-08-22 11:15:32 +02:00
Jörg Breitbart a783fc1817 test images for WEBP and AVIF 2026-08-22 10:52:45 +02:00
Jörg Breitbart 90fad0f277 make linter happy 2026-08-21 02:08:45 +02:00
Jörg Breitbart 4e41215a87 webp and avif support 2026-08-21 02:02:24 +02:00
jerch 6a49e1aa61 Merge pull request #6122 from xtermjs/fix_handleBufferLinesTrim
perf: optimize DecorationService._handleBufferLinesTrim
2026-08-20 18:13:02 +02:00
jerch f039ba3fba Merge branch 'master' into fix_handleBufferLinesTrim 2026-08-20 18:01:07 +02:00
jerch 116a09617e Merge pull request #6114 from xtermjs/fix_bufferline
bufferline perf fixes
2026-08-20 18:00:12 +02:00
Jörg Breitbart bc62843844 optimize Event.fire 2026-08-20 16:32:41 +02:00
Jörg Breitbart aca98b8a5e silence noisy code for most trim events 2026-08-20 16:32:14 +02:00
Jörg Breitbart 409c931093 remove any type 2026-08-20 12:50:14 +02:00
Jörg Breitbart 4c8963a487 remove cache invalidation from clone 2026-08-19 13:55:26 +02:00
Jörg Breitbart c480898d0d fix naming typo 2026-08-19 01:11:53 +02:00
Jörg Breitbart c44a913c2d faster extended resetting in loadCell 2026-08-18 23:50:15 +02:00
Jörg Breitbart 4708890a27 simpler string cache on bufferline 2026-08-18 22:34:57 +02:00
Jörg Breitbart fa3821adb9 optimize copyFrom and clone for blank lines 2026-08-18 16:36:57 +02:00
Per Bothner e69a8b6240 Merge from upstream. 2026-08-14 10:37:20 -07:00
Per Bothner 9f64663bd0 Merge branch 'master' into image-ext 2026-08-14 07:48:16 -07:00
29a7384233 ci: scope GitHub Actions permissions to least privilege (#6028)
Co-authored-by: Ankur Jangid <ankurjangid@Ankurs-MacBook-Air.local>
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-10 11:39:43 +00:00
Taran d6cfe3db5d Fix viewport staying pinned to the top after ESC[3J clears the scrollback (#6081) 2026-08-10 11:38:58 +00:00
8c9b9fdb9b ci: restrict release workflow permissions (#6095)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-08-10 04:34:45 -07:00
dependabot[bot]andPretaSOFT 6ccafd9779 build(deps): Bump ws (#6094)
Bumps [ws](https://github.com/websockets/ws) to 7.5.13 and updates ancestor dependency . These dependencies need to be updated together.


Updates `ws` from 7.5.10 to 7.5.13
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.10...7.5.13)

Updates `ws` from 8.20.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.10...7.5.13)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 7.5.13
  dependency-type: indirect
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 06:16:25 -07:00
dependabot[bot]andPretaSOFT b5222f8010 build(deps): Bump brace-expansion (#6093)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.6 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

Updates `brace-expansion` from 2.1.0 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 06:02:19 -07:00
Simon Lamon e2ff222c4f Apply autofixes from Zizmor (#6021) 2026-08-09 06:01:58 -07:00
dependabot[bot]andPretaSOFT 1b822cb814 build(deps): Bump shell-quote and concurrently (#6076)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together.


Updates `shell-quote` from 1.8.3 to 1.9.0
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.9.0)

Updates `concurrently` from 9.2.1 to 9.2.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v9.2.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.9.0
  dependency-type: indirect
- dependency-name: concurrently
  dependency-version: 9.2.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 06:01:11 -07:00
rufatkulandClaude Fable 5 8961a72d2d Set autocomplete="off" on the helper textarea (#6057)
Without an autocomplete hint, focusing a terminal on iOS/iPadOS Safari
pops the AutoFill accessory (passwords / credit cards / contacts) for
the hidden textarea. The sibling hints (autocorrect, autocapitalize,
spellcheck) are already set; this adds the missing one.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 05:56:52 -07:00
Graciliano Monteiro PassosandDaniel Imms 576181d93d Add OmnyShell PWA to the list of tools (#6034)
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-09 05:54:39 -07:00
kkd927andDaniel Imms eef62138a4 Add kmux to real-world uses (#6031)
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-09 05:54:04 -07:00
dependabot[bot]andPretaSOFT c793c317fd build(deps-dev): Bump fast-uri from 3.1.2 to 3.1.5 (#6091)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 05:52:59 -07:00
dependabot[bot]andPretaSOFT a4e4e28f97 build(deps-dev): Bump undici from 7.25.0 to 7.29.0 (#6088)
Bumps [undici](https://github.com/nodejs/undici) from 7.25.0 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.25.0...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 05:52:51 -07:00
Bob LeeandDaniel Imms 4452773563 Add BitFun to real-world uses (#6092)
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-09 05:52:24 -07:00
Gevik BabakhaniandDaniel Imms 7aea5197d8 Add JSVision to real-world uses (#6085)
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-09 05:50:48 -07:00
Ark0NandDaniel Imms 9f39dc2bf9 Add Codeman to real-world uses (#6082)
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-08-09 05:50:08 -07:00
KindaBrazy 330eaaf205 add "LynxHub" to Real-world uses. (#6077) 2026-08-09 05:49:33 -07:00
Anthony Kim 904ae93526 Merge pull request #6055 from heudev/fix-shared-atlas-clear-invalidation
Fix stale rendering after a shared texture atlas is cleared
2026-07-26 21:24:07 -07:00
Anthony Kim 699f5537b0 Merge pull request #6056 from anthonykim1/anthonykim1/webgl-atlas-tui-stress-dual-coverage
Add WebGL atlas alternate-buffer stress test and refine the manual rendering demo
2026-07-19 16:26:06 -07:00
Anthony Kim abce6f1c62 call scroll output 2026-07-17 11:36:15 -07:00
Anthony Kim 4d93815c36 Improve types in webglatlasstress.test.ts 2026-07-17 11:31:12 -07:00
Anthony Kim 5b00193876 adjust button style 2026-07-17 11:17:30 -07:00
Enes Uysal 0b1c0b5c5a Fix stale rendering after a shared texture atlas is cleared
TextureAtlas.clearTexture() wipes every page and both cache maps but never
bumped _pageLayoutVersion, so it silently broke the invalidation contract that
#6042 introduced for shared atlases.

The atlas is shared by every terminal with an equal config (CharAtlasCache),
but WebglRenderer.clearTextureAtlas() only clears the *calling* renderer's
model. Every other owner keeps vertex data pointing into the rows that were
just wiped, and GlyphRenderer.beginFrame() has no way to notice, so those
terminals keep drawing from stale texture coords until something else happens
to invalidate them. Once the clearing terminal rasterizes new glyphs into the
freed rows, the siblings render whatever landed there instead of their own
text.

Embedders hit this whenever they force a redraw on one terminal while others
are alive; VS Code calls Terminal.clearTextureAtlas() on OS resume, which is
why a stale terminal there stays garbled even after opening a new one (the new
terminal re-acquires the same cached atlas) and only recovers once the whole
panel is closed and the last owner drops the atlas.

Bump _pageLayoutVersion on clear so sibling renderers rebuild their models on
their next frame, matching what page merges and evictions already do.

The added test fails on master (terminal B's row renders terminal A's flood
glyphs) and passes with the fix.
2026-07-16 18:38:24 +03:00
Anthony Kim ce21694856 Merge pull request #6043 from anthonykim1/anthonykim1/webgl-atlas-page-cap-eviction
Prevent WebGL atlas pages from exceeding texture capacity
2026-07-15 09:34:06 -07:00
Anthony Kim 2ea67f9ea8 Clean up WebGL atlas overflow coverage 2026-07-13 11:49:25 -07:00
Anthony Kim 2ae6e3a603 Useful comments. 2026-07-13 11:23:39 -07:00
Anthony Kim 06ce754fcc Don't mess with formatting 2026-07-13 11:10:08 -07:00
Anthony Kim 6791a0abea Log to measure eviction time 2026-07-13 10:58:05 -07:00
Anthony Kim 5501e931f7 Merge branch 'master' into anthonykim1/webgl-atlas-page-cap-eviction 2026-07-13 10:20:44 -07:00
Anthony Kim b1aee19ac6 Merge pull request #6042 from anthonykim1/anthonykim1/webgl-shared-atlas-pages-version
Fix stale rendering after shared texture atlas page merges
2026-07-13 10:16:29 -07:00
Anthony Kim 2b982bc39c Prevent WebGL atlas page overflow 2026-07-09 15:54:33 -07:00
Anthony Kim 8237004d5c Add WebGL atlas page overflow repros 2026-07-09 15:51:27 -07:00
Anthony Kim 9559005bbc wroding 2026-07-09 11:39:38 -07:00
Anthony Kim 46556eb6cf Rename to pageLayoutVersion 2026-07-09 11:32:23 -07:00
Anthony Kim 64ecce4f25 Do a better job with types in testing 2026-07-09 11:18:29 -07:00
Anthony Kim 77bf56066c wordings 2026-07-09 11:00:31 -07:00
Anthony Kim 6fe6fe1af3 Fix shared atlas page invalidation per renderer 2026-07-04 14:44:23 -07:00
Anthony Kim 780cbef3cf Add shared atlas page merge garble repro 2026-07-04 14:43:52 -07:00
Anthony Kim 8aab310366 Merge pull request #6019 from anthonykim1/anthonykim1/fix-terminal-window-mouse-listeners
Use current document for mouse listeners
2026-06-28 13:56:22 -07:00
Anthony Kim 9f232d981b destructure doc from target like the old days 2026-06-16 19:53:03 -07:00
Anthony Kim 1efd80cd17 Dont forget target.document 2026-06-16 19:44:27 -07:00
Anthony Kim 1701dcef90 use disposables for transient mouse listeners 2026-06-16 19:35:29 -07:00
Anthony Kim 55dbe3b65e use current document for mouse listeners 2026-06-16 19:21:19 -07:00
Per Bothner fed1138b85 Merge branch 'master' into image-ext 2026-06-13 06:24:20 -07:00
Paul Horn 53a98a720a Add Cate to real-world uses (#6013) 2026-06-13 03:12:27 -07:00
dependabot[bot]andPretaSOFT b69ae4b3c9 build(deps-dev): Bump esbuild from 0.28.0 to 0.28.1 (#6016)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 03:09:12 -07:00
Per Bothner 092b07da86 Merge remote-tracking branch 'upstream' into image-ext 2026-06-07 13:47:27 -07:00
3cdbc77386 fix(addon-search): advance highlight scan by buffer match size (#5994)
* fix(addon-search): advance highlight scan by buffer match size

_highlightAllMatches() stepped to the next find() using term.length
and col + 1 when staying on the same row. Search results expose size
in buffer columns (wide glyphs, wrapped matches), which select() and
decorations already use.

Advance with col + size and wrap row/col when crossing the terminal
width so highlight-all does not skip, duplicate, or stop early on
non-ASCII matches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(addon-search): regression for highlight-all wide char advance by size

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

* test(addon-search): move highlight-all regression to integration tests

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-06 11:12:04 -07:00
f57b8e1534 Update GitHub Actions to Node 24 compatible versions (#6007)
Upgrade actions/checkout, setup-node, upload-artifact, download-artifact,
and codeql-action to their latest major versions that run on Node 24.

Replace NathanFirmo/wait-for-other-job with native needs: build for
test-unit and test-integration jobs.

Fixes #6006

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-06 16:58:42 +00:00
c8f11d63b5 fix(terminal): always run clear() at cursor home (#5992)
Remove early return when ybase===0 && y===0 so clear() always
clears markers, resets buffer lines, and refills the viewport.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-06 16:43:22 +00:00
3d929a1db0 fix(selection): drag-scroll end row and trim start clamp (#5993)
* fix(selection): drag-scroll end row matches last visible line

Use ydisp + rows - 1 for selectionEnd when scrolling down during drag,
consistent with finalSelectionEnd for select-all.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

* fix(selection): reset start to origin when start row is trimmed

When handleTrim removes the selection start row, clamp to [0,0] and
refresh; do not keep a stale column on row 0.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-06 16:18:47 +00:00
2fe3fd13a1 fix(addon-webgl): skip missing buffer lines in _updateModel (#5991)
* fix(addon-webgl): skip missing buffer lines in _updateModel

_updateModel force-unwrapped buffer.lines.get(row), which can be
undefined during trim or resize. That threw and left stale glyphs on
rows below the gap.

Clear the WebGL model row and glyph cells, reset blink state, and
continue with remaining viewport rows, matching DomRenderer and
ImageStorage handling.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(addon-webgl): extract _nullModelCell helper

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-06 09:15:09 -07:00
Daniel ImmsandCursor f33f502267 fix(DomRenderer): resilience when buffer lines are missing (#5998)
* fix(DomRenderer): skip missing buffer lines during row render

renderRows and link underline refresh stopped updating when any
viewport row lacked buffer data, leaving stale DOM on rows below.
Match ImageStorage.render behavior: clear the row element when
present and continue with remaining rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(DomRenderer): clear blink state when skipping missing lines

When a viewport row has no buffer line, renderRows clears the row DOM
and continues but did not reset _rowHasBlinkingCells for that row.
That could leave the text blink manager thinking blinking cells remain
after the row was emptied.

Clear blink state for the row when skipping a missing buffer line,
including during link underline refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 09:09:32 -07:00
jerch 7c24c58007 fix non-ascii clipboard handling (#6002)
* fix non-ascii clipboard handling

* rename type helpers

* make linter happy
2026-06-06 09:07:03 -07:00
jerch 596634ac41 fix #6003 (#6004) 2026-06-06 09:02:43 -07:00
jerch 0c8271db49 fix atlas callstack exception (#6001)
* fix atlas callstack exception

* remove spread operator, integrate stress test into demo
2026-06-06 09:01:49 -07:00
Daniel ImmsandCursor f3f4dcaf9f fix(CharacterJoiner): bound join ranges to trimmed line length (#5990)
Character joiner scanning already iterated cells with
getTrimmedLength(), but trailing attribute runs and
_stringRangesToCellRanges used buffer cols. On short lines that
could run joiners on a single trailing cell and extend open ranges
through empty tail columns, affecting ligature/join rendering.

Use trimmed length consistently for trailing-run detection, the
cell-range walk, and the final range end column.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 07:05:19 -07:00
64718444e2 fix(webgl): avoid glyph atlas mipmaps (#5987)
Co-authored-by: Will Hampson <whamp@ggl.slmail.me>
Co-authored-by: jerch <jerch@rockborn.de>
2026-06-03 06:00:44 -07:00
ea75b99e2b fix(deps): override serialize-javascript to ^7.0.3 (#5989)
Mocha 11.7.x pulls in serialize-javascript 6.0.2, which is affected by
code injection via RegExp.flags and Date.toISOString (CVE incomplete fix).
Force the patched 7.x release via npm overrides.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-02 13:34:19 +00:00
Daniel ImmsandCursor 564113e053 Add unit tests for parser reset, buffer scroll, ConPTY, and search (#5988)
* Add InputHandler unit tests for ISO-2022 charset mapping

InputHandler unit tests always used MockCharsetService, so regressions in
print()'s ASCII remapping, G0/G1 designation (ESC ( ) * +), and SO/SI
level switching were never caught at unit level.

Add an ISO-2022 character sets suite wired to real CharsetService and
BufferService that verifies DEC line drawing on G0 (ESC ( 0), G1 after
ESC ) 0 + SO, and charset/glevel restoration via ESC 7 / ESC 8 before
printing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scroll unit tests for isUserScrolling

BufferService.scroll() adjusts ybase and ydisp differently depending on
whether the circular buffer is full and whether isUserScrolling is set,
but no unit test asserted those branches. InputHandler and other callers
use MockBufferService.scroll, which throws, so real scroll viewport logic
was only exercised indirectly.

Add BufferService.test.ts covering full-buffer scroll with the user
scrolled up (ydisp decrements), partial-buffer scroll while scrolled up
(ydisp unchanged), and full-buffer scroll at the viewport bottom
(ydisp tracks ybase).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit tests for DECOM cursor positioning

CUP with DEC origin mode (DECOM) offsets rows by the scroll region top
and clamps within DECSTBM margins, but unit tests only toggled the mode
for save/restore. Playwright covered the combination; headless unit tests
did not.

Add a cursor positioning test matching the Playwright DECOM case: with
margins and origin on, CUP places the cursor relative to scrollTop, clamps
to the margin, and uses absolute rows when origin is off.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add WindowsMode unit tests for wrap heuristic edges

ConPTY does not set isWrapped on soft-wrapped lines; updateWindowsModeWrappedState
infers wrapping from the last cell of the previous row. Browser Terminal tests
exercise this only through full writes, with no direct coverage of the helper
or negative cases (trailing space or null).

Add WindowsMode.test.ts asserting isWrapped is set for a full non-space line,
cleared for trailing whitespace, and cleared when the previous line ends empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add OscParser.reset unit test for mid-payload abort

OscParser.reset() must call end(false) on active handlers when aborted
during PAYLOAD, but no test invoked reset() directly. EscapeSequenceParser
tests mock OSC, so production reset/cleanup was unverified at unit level.

Add a reset describe block asserting handlers receive END false after a
partial payload, then a subsequent OSC sequence completes normally.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DcsParser.reset unit test for mid-payload abort

DcsParser.reset() must call unhook(false) on hooked handlers when aborted
mid-DCS, matching OscParser's end(false) cleanup. No DcsParser test called
reset() directly, leaving the abort path unverified at unit level.

Add a reset describe block asserting UNHOOK false after partial put, then a
subsequent DCS sequence completes with UNHOOK true.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler print tests for grapheme joining

print() uses UnicodeService.shouldJoin to merge combining marks, but
InputHandler tests never exercised that path. The U+200B infinite-loop
regression test used mocks and had no assertions, so it would pass even
if print() misbehaved.

Add tests for e + combining acute in one parse and across two parses.
Strengthen the U+200B regression test to use BufferService and assert the
buffer does not grow spurious rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add CharsetService unit tests for glevel and designation

CharsetService only updates the active charset when setgCharset targets the
current glevel; most InputHandler tests use MockCharsetService instead. A
regression in setgLevel or inactive-slot designation would not be caught
without direct service tests.

Add CharsetService.test.ts covering inactive designation, setgLevel
activation, active-slot updates, and reset clearing state.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add reflowCursorLine unit tests for ConPTY resize behavior

reflowCursorLine lets resize reflow wrapped lines that contain the cursor
(needed when ConPTY does not). No test referenced the option or
reflowLargerGetLinesToRemove's cursor skip branch.

Add BufferReflow tests for skip vs reflow when the cursor is inside a
wrapped block, and Buffer.resize tests showing merge on widen only when
reflowCursorLine is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add EscapeSequenceParser reset test for async pause recovery

reset() sets ParserStackType.RESET so the next parse continues in GROUND
at chunkPos + 1 without resuming a paused async handler. Existing async
tests only verified reset lifts FAIL after improper continuation, not
mid-chunk recovery with trailing printable data.

Add a test that pauses on async CSI, calls reset(), then continues parsing
and asserts XY is printed once and the stack returns to NONE.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add Buffer.resize unit test for ConPTY row growth

When rows increase on Windows ConPTY, Buffer.resize appends lines without
decrementing ybase or ydisp so the viewport stays stable while ConPTY
repaints. Buffer tests covered the non-Windows scroll-up path but never
passed windowsPty into options.

Add a ConPTY row-growth test asserting ybase, ydisp, and line count when
windowsPty is configured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add Buffer reflow test for ConPTY buildNumber threshold

Buffer disables reflow on ConPTY when buildNumber is below 21376, but no
unit test asserted that gate. Column shrink without reflow leaves wrapped
content on a single line instead of splitting across rows.

Add a test comparing shrink behavior at build 21375 versus 21376, plus
retain unwrap-on-widen coverage for supported ConPTY builds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add ApcParser.reset unit test for mid-payload abort

ApcParser.reset() calls end(false) on active handlers when aborted mid-APC,
matching OscParser and DcsParser, but no ApcParser test invoked reset()
directly.

Add a reset describe block asserting END false after partial put and a
clean subsequent APC sequence.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit test for REP with grapheme clusters

repeatPrecedingCharacter uses parser.precedingJoinState and reprints the
full cluster from the buffer, but only Playwright covered e + combining
acute with CSI b. A regression could break REP for joined sequences without
failing unit tests.

Add a print-suite test asserting CSI 2 b triples e + acute in one parse.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit tests for REP no-op cases

repeatPrecedingCharacter returns early when parser.precedingJoinState is
zero, including after another escape sequence clears join state. Only the
positive grapheme REP case was unit tested.

Add tests for bare CSI b and for REP following SGR so regressions cannot
spuriously duplicate buffer content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DecorationManager unit test for wrapped search matches

DecorationManager splits matches across wrapped rows with per-segment x
and width, but only SearchEngine had unit coverage; decoration geometry
was left to Playwright.

Add DecorationManager.test.ts using a real Terminal, a wrapped-buffer
match from SearchEngine, and assertions on registerDecoration options
for both row segments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scroll unit test for DECSTBM margins

When scrollTop is non-zero, scroll() shifts lines inside the margin with
shiftElements instead of pushing scrollback. Existing BufferService tests
only covered scrollTop === 0.

Add a test with margins on rows 1–3 that verifies rows outside the region
stay fixed and lines inside the region scroll correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scrollLines unit tests

scrollLines adjusts ydisp and toggles isUserScrolling for viewport
scrolling, but only scroll() had unit coverage. Regressions in the
user-scroll flag or ydisp clamping would affect the whole viewport.

Add tests for scrolling up, clamping at ydisp 0, and clearing
isUserScrolling when reaching the buffer bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DecorationManager overview ruler dedup unit test

Multiple highlight matches on the same buffer line should only register
one overview ruler marker; later matches on that line omit ruler options.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assert overview ruler on each line of wrapped search match

Wrapped highlight decorations span two buffer lines; each segment
should register overview ruler options unlike same-line dedup.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 12:41:54 +00:00
b3de9fa6db fix(buffer): clear stale attrs and combined data in BufferLine (#5972)
* fix(buffer): bound combined-cell copy in copyCellsFrom

When copying a column range, combined-string metadata was copied for
every source column >= srcCol with no upper limit. Partial copies could
pull _combined entries from outside [srcCol, srcCol + length) into the
destination and corrupt cells beyond the copied span.

Only copy combined keys within the same range as the cell data loop.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(buffer): clear stale _combined entry in BufferLine.setCell

setCell updated combined data when IS_COMBINED_MASK was set but left
orphaned _combined[index] entries when writing a non-combined cell.
Delete the map entry in that case so later combined copies and lookups
stay aligned with cell content flags.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(buffer): clear stale extended attrs in BufferLine.setCell

setCell stored extended attributes when HAS_EXTENDED was set but did
not remove orphaned _extendedAttrs[index] when overwriting with a
cell without extended background data. Delete the entry in that case,
matching the combined-cell cleanup added for setCell earlier.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(buffer): clear dest extended attrs in copyCellsFrom

When copying a column range, extended attributes were copied from
source cells with HAS_EXTENDED but stale _extendedAttrs on the
destination were left for cells without extended data. Delete dest
entries in that case, matching setCell behavior and the bounded
_combined copy loop.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(buffer): clear extended attrs in setCellFromCodepoint

The input-handler fast path set extended data when HAS_EXTENDED was
present but left orphaned _extendedAttrs entries when overwriting
with non-extended cells. Delete the map entry in that case, matching
setCell and copyCellsFrom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(buffer): clear stale combined data in setCellFromCodepoint

When the input handler overwrites a cell that previously held a
grapheme cluster (IS_COMBINED_MASK), setCellFromCodepoint updated
_data and _extendedAttrs but left this._combined[index] intact.
setCell already deletes _combined for non-combined cells; this path
did not, so getString and renderers could show the old cluster string
after a normal codepoint write.

Add delete this._combined[index] at the start of setCellFromCodepoint,
matching setCell's non-combined branch and the extended-attrs cleanup
already performed in the same method.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(buffer): only delete stale combined/extended map entries when present

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

* fix(buffer): treat combined/extended maps as flag-gated caches

_flags in _data are authoritative; sparse maps are only read when
IS_COMBINED_MASK or HAS_EXTENDED is set. setCellFromCodepoint no longer
touches _combined. loadCell clears CellData side fields when flags are off.
copyCellsFrom copies map entries per-cell from src flags only.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-02 03:04:25 +00:00
1f054097f3 Fix benchmarks with NODE_PATH and common/browser imports (#5985)
PR #5966 switched benchmark imports to ../../src/ paths that do not
resolve at runtime. Restore common/* and browser/* imports (with
benchmark-only tsconfig paths for typechecking) and NODE_PATH=./out
on benchmark npm scripts so emitted requires resolve against out/.

Fixes #5970

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 19:43:47 -07:00
Daniel Imms a250987508 Merge pull request #5984 from xtermjs/cursor/addons-functional-fixes-89d6
fix(addons): search bounds, webgl sort, web-links regex, fit height clamp
2026-06-01 19:34:14 -07:00
Daniel Imms 21c99d0574 fix(bin): script path, exit codes, and integration suite handling (#5973)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Improves reliability and correctness of `bin/` maintenance scripts used for local development and CI.

## Behavioral fixes

- **Exit codes**: `test_integration.js` exits with code 1 when `--suite` names an unknown suite; `test_unit.js` uses a non-zero fallback (`-1`) when the coverage or mocha child process has no status; `esbuild_all.mjs` exits non-zero if any parallel esbuild child fails.
- **Repo root**: `setup-full.mjs` resolves the repository root one directory above `bin/` (was incorrectly using `bin/` as root).
- **esbuild_all**: Propagates child build failures instead of always exiting 0.
- **Integration suites**: When `--suite` is omitted, all configured Playwright suites (core + addons) run; when provided, only the matching suite runs.
- **test_unit.js**: Declares the `files` filter variable so file arguments from `npm run test-unit` work without a ReferenceError.
- **test_mousemodes.js**: Uses the correct cursor-home CSI (`ESC [ H`) when clearing the menu screen.
- **Cleanup**: Removes debug logging from the test-unit coverage path; adds a missing semicolon after the suite regex match.

## Affected scripts

- `bin/test_integration.js`
- `bin/test_unit.js`
- `bin/esbuild_all.mjs`
- `bin/setup-full.mjs`
- `bin/test_mousemodes.js`

## Testing

- Syntax-checked affected scripts with `node --check`
- Verified `node bin/test_integration.js --suite=unknown` exits 1 with an error message
- `npm run lint-changes` (no TypeScript files changed)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-94b28f5d-740c-4cb6-8cd9-40f35bc6086c"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-94b28f5d-740c-4cb6-8cd9-40f35bc6086c"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-06-01 19:34:13 -07:00
Daniel Imms 0b6fd2e8b2 Merge pull request #5979 from xtermjs/cursor/addon-image-readme-typos-89d6
docs(addon-image): fix README spelling and wording
2026-06-01 10:38:36 -07:00
Daniel Imms 4226e64245 Merge pull request #5982 from xtermjs/cursor/addons-docs-typos-7eb2
docs(addons): fix comment and README typos (webgl, web-links, unicode-graphemes)
2026-06-01 10:35:35 -07:00
Daniel Imms a374c6c5af Merge pull request #5980 from xtermjs/cursor/browser-functional-fixes-b5c0
fix(browser): debounce, render, mouse coords, and CoreBrowserTerminal
2026-06-01 09:23:57 -07:00
Cursor AgentandDaniel Imms 454e0749c2 fix(browser): use ??= in RenderDebouncer for lint
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 16:15:01 +00:00
Daniel Imms 59264f1f5b Merge pull request #5974 from xtermjs/cursor/parser-validation-fixes-89d6
fix(parser): Params error grammar and ESC prefix precedence
2026-06-01 09:05:28 -07:00
Cursor AgentandDaniel Imms 16ef64f8bf Merge origin/master into cursor/browser-functional-fixes-b5c0
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:59:10 +00:00
Daniel Imms a0f93cdcc4 Merge pull request #5983 from xtermjs/cursor/test-fixes-explore-e565
test: search, parser title, and attach suite fixes
2026-06-01 08:50:09 -07:00
Daniel Imms 5b911db9cf Merge branch 'master' into cursor/test-fixes-explore-e565 2026-06-01 08:45:29 -07:00
Daniel Imms 560c0ce3a1 Merge pull request #5978 from xtermjs/cursor/src-docs-typos-0755
docs(src): fix comment and JSDoc typos
2026-06-01 08:33:32 -07:00
Daniel Imms 94958c2489 Fix whitespace 2026-06-01 08:33:02 -07:00
Cursor AgentandDaniel Imms 6426573f45 docs(addon-web-links): shorten comment to satisfy max-len after typo fix
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:30:04 +00:00
Daniel Imms 5ab59506aa Merge pull request #5975 from xtermjs/cursor/contributing-typo-b5c5
docs: fix typo in CONTRIBUTING.md
2026-06-01 08:30:00 -07:00
Cursor AgentandDaniel Imms 096707d736 test: fix typos in test titles and comments
Apply test-only hunks from docs typo fixes: SharedRendererTests
duplicate word, SerializeAddon workaround comment, and mouse range
spelling in MouseService and InputHandler tests.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:18 +00:00
Cursor AgentandDaniel Imms 967d3f59e6 docs(addon-image): fix README spelling and wording
README spelling and wording fixes only; no code changes.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:08 +00:00
Daniel ImmsandCursor 9a70816dad test(addon-attach): correct Playwright suite name in AttachAddon tests
The integration test file used test.describe('Search Tests'), copied from
addon-search. That mislabels attach-addon results in Playwright reports and
makes the suite harder to filter or debug.

Rename the block to 'AttachAddon' so the describe name matches the addon
under test. No test logic changed.

Validated with: npm run build && npm run esbuild, npm run test-unit,
npm run esbuild-demo-client && npm run esbuild-demo-server &&
npm run test-integration, npm run package, npm run package-headless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:29:07 +00:00
Daniel ImmsandCursor 8e7e4f16e5 test(parser): fix typo in EscapeSequenceParser test title
Rename the mocha case "inital states" to "initial states".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:29:07 +00:00
Daniel ImmsandCursor a9a2a8e3c1 test(search): reject readFile errors in #2444 fixture loader
beforeAll always resolved the readFile promise with data even when
err was set, which led to calling toString on undefined. Reject the
promise on err so fixture load failures surface clearly. Use res/rej
callback names to avoid shadowing path.resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:29:07 +00:00
Daniel ImmsandCursor 92a1bfcf2e test(search): send real CSI CUP in #3834 null-char case
The integration test intended to move the cursor forward with ESC [ C
but wrote the literal string \x1b[C due to double escaping. Use a
single-escaped '\x1b[CHi Hi' so the terminal receives the control
sequence and creates the null cell the scenario describes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:29:06 +00:00
Cursor AgentandDaniel Imms be38252e37 docs: fix comment typos in Constants, DomRenderer, and addons (addons only)
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:04 +00:00
Cursor AgentandDaniel Imms 0b590dacd6 docs: fix comment typos in BufferReflow, Types, and addons (addons only)
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:04 +00:00
Cursor AgentandDaniel Imms 1faa41eac1 docs: fix typos in typings, tests, and core comments (addons only)
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:03 +00:00
Cursor AgentandDaniel Imms fa5eb33cdb fix: small comment, error string, and guard cleanups (addons only)
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:02 +00:00
Cursor AgentandDaniel Imms 3494f6b5b0 docs: fix additional comment typos across renderer and selection (addons only)
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-06-01 15:29:02 +00:00
Daniel ImmsandCursor 58fc09ff0e docs(webgl): remove duplicate word in context lost README
The webglcontextlost section repeated "fired" ("fires the ... event
fired on the canvas"). Drop the extra word.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:51 +00:00
Daniel ImmsandCursor a47e4905e6 docs(addon-webgl): fix comment typos in TextureAtlas and GlyphRenderer
Correct "transferred", remove duplicate "is", and use "one-off"
instead of "once of" in developer-facing comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:51 +00:00
Daniel ImmsandCursor 1252c69ccc docs(addons): fix comment typos in unicode-graphemes and web-links
Correct grapheme/handling spelling in UnicodeGraphemesAddon header and
everything/addresses typos in WebLinksAddon URL matcher comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:51 +00:00
Daniel ImmsandCursor be3dd80171 docs(parser): fix JSDoc typos in IFunctionIdentifier
Correct "representation" and "registration" spelling in the comment
describing how handler identifiers are encoded for EscapeSequenceParser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor 2db34c4955 docs(common): fix main thread spelling in TaskQueue comment
Change "mainthread" to "main thread" in the workload scheduling note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor 8854430df4 docs(parser): fix sub-parameter limit name in Params JSDoc
addSubParam documents a cap on stored sub parameters; the limit is
enforced via maxSubParamsLength, not subParamsLength (the current count).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor 26e771d075 docs(common): fix grammar in Unicode and platform comments
Use "a Unicode" instead of "an Unicode" on IUnicodeService.register,
and "user's platform" instead of "users platform" above the platform
detection exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor fd13893687 docs(buffer): fix possessive typo in Windows conpty comment
Change "it's view" to "its view" in the comment explaining why new
rows are appended on Windows when using conpty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor 5ef8001413 docs(common): fix activeVersion onChange JSDoc wording
The onChange event comment said "activate version changed" while the
property is activeVersion. Clarify that the event fires when the active
Unicode version changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor e2d1420ff7 docs(common): fix throughput typo in WriteBuffer comment
Correct "throughtput" to "throughput" in the FPS vs throughput tradeoff
note inside WriteBuffer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor cf1484d7fd docs(browser): fix incomplete and typo comments in Mouse.ts
getCoords had a truncated comment ("if there are no valid") and a typo
("addition" vs "additional") in the selection precision note. Complete
the char-size guard comment and fix the selection wording.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:36 +00:00
Daniel ImmsandCursor e12f007597 docs(buffer): fix Content enum names in Constants JSDoc
The Content bitmask JSDoc examples used camelCase names that do not
exist on the enum (codepointMask, isCombined, hasContent, widthMask,
widthShift). Update examples to match the real members:
CODEPOINT_MASK, IS_COMBINED_MASK, HAS_CONTENT_MASK, WIDTH_MASK, and
WIDTH_SHIFT as used throughout BufferLine and CellData.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:35 +00:00
Daniel ImmsandCursor 3a4f9e235d docs: fix "transferred" spelling in CONTRIBUTING.md
Correct typo in the note about moving support questions to
GitHub discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:28 +00:00
Daniel ImmsandCursor 7b05c8a1b2 docs: fix comment and JSDoc spelling typos
Correct "occurred" in IMouseEvent and keydown JSDoc, remove duplicate
"This" in EscapeSequenceParser ZDM comment, use "elapsed" instead of
"lapsed" in TimeBasedDebouncer, and fix "event" in Keyboard comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:14 +00:00
Daniel ImmsandCursor b45167e00b docs: fix comment typos in ThemeService and Constants
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:14 +00:00
Daniel ImmsandCursor bc42be2074 docs(common): correct ST sequence metadata and comment typos
- EscapeSequenceParser: ESC ST is ESC backslash (0x1B 0x5C), not ESC
  plus a double-quote character; align @vt metadata with C1 ST on the
  following line.
- Color.contrastRatio: document l2 as the second luminance argument.
- Marker.dispose: fix "change" -> "chance" in dispose listener comment.
- Buffer.clear: fix possessive "it's" -> "its" in JSDoc.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:14 +00:00
Daniel ImmsandCursor bc26cc16c1 fix(browser): place imports before module const in TimeBasedDebouncer
RENDER_DEBOUNCE_THRESHOLD_MS was declared above the import, which
breaks ES module conventions and can confuse tooling. Move the import
to the top of the module after the license header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:07 +00:00
Daniel ImmsandCursor 80468eeb4c fix(browser): clear idle blink timer when handle is 0
_clearIdleTimer() used truthiness on _idleTimeout, so a setTimeout
handle of 0 would not be cleared. Compare against undefined, consistent
with RenderDebouncer rAF handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:07 +00:00
Daniel ImmsandCursor 69b11a1a48 fix(browser): treat rAF id 0 as scheduled in RenderDebouncer
dispose(), addRefreshCallback(), and refresh() used truthiness on
_animationFrame, so a requestAnimationFrame handle of 0 would not be
cancelled or deduplicated. Compare against undefined instead, matching
other timer checks in the browser layer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:07 +00:00
Daniel ImmsandCursor 98d7c25bd5 fix(browser): cancel trailing debounce timeout on immediate refresh
TimeBasedDebouncer can schedule a trailing setTimeout when refresh
requests are throttled. If the debounce window later elapses and an
immediate refresh runs, that pending timeout was left armed and could
fire afterward, causing a duplicate _innerRefresh (e.g. extra live
region updates for screen readers).

Clear any pending timeout and reset _additionalRefreshRequested before
running the immediate refresh path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:07 +00:00
Daniel ImmsandCursor 6d1f12cb7b fix(web-links): avoid duplicate g flag when cloning url regex
LinkComputer rebuilt the search RegExp with (regex.flags || '') + 'g',
which throws if the caller already passed a global regex. Only append
g when it is not already present in regex.flags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:04 +00:00
Daniel ImmsandCursor 4500485e79 fix(parser): validate ESC prefix byte range with correct precedence
_identifier() checked custom prefix bytes with
`res && 0x3c > res || res > 0x3f`, which parsed as
`(res && 0x3c > res) || (res > 0x3f)` because && binds tighter
than ||. A prefix of NUL (0x00) skipped both clauses and was
accepted even though only 0x3c..0x3f are valid.

Use an explicit range test: res < 0x3c || res > 0x3f. Mirror the
same fix in ApcParser and DcsParser test identifier helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:04 +00:00
Daniel ImmsandCursor b4bd92d62c fix(webgl): use stable numeric sort for merging texture pages
The merging-pages index sort used (a, b) => a > b ? 1 : -1, which
returns -1 when a === b and violates the comparator contract. Use
(a, b) => a - b for ascending numeric order.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:04 +00:00
Daniel ImmsandCursor 1a01e9634f fix(parser): use correct grammar in Params validation errors
Replace "values lesser than -1" with "values less than -1" in
addParam and addSubParam throw messages. "Lesser" is not standard
English for numeric comparisons; tests assert the exact message.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:04 +00:00
Daniel ImmsandCursor e35a9c1e90 fix(addon-search): reject startCol equal to terminal column count
Valid column indices are 0 through cols - 1. The previous check used
startCol > cols, so on an 80-column terminal column 80 was accepted while
column 100 correctly threw. Use >= so the out-of-range guard matches the
documented error for invalid search positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:28:04 +00:00
Daniel ImmsandCursor 1dd113b38a fix(browser): reset TimeBasedDebouncer state on dispose
dispose() cleared the pending timeout but left _refreshTimeoutID and
_additionalRefreshRequested set. After dispose, a stale timeout id or
trailing-refresh flag could confuse a new instance or tests. Match
RenderDebouncer by clearing the timeout id and reset the trailing flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:27:54 +00:00
Daniel ImmsandCursor 536eeb4db0 refactor(browser): remove unreachable coords guard in getCoords
getCoordsRelativeToElement always returns a two-element tuple, so the
array is always truthy and if (!coords) could never run. hasValidCharSize
already handles the only early-exit path before coordinates are computed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:27:54 +00:00
Daniel ImmsandCursor 3f3f6abfb9 fix(browser): parenthesize document fallback for CoreBrowserService
Nullish coalescing binds tighter than the conditional operator, so the
previous expression was parsed as (this._document ?? windowDefined) ?
window.document : null. When this._document was set (e.g. parent
ownerDocument or documentOverride), the ternary still returned
window.document instead of the configured document.

CoreBrowserService.mainDocument is used by AccessibilityManager, viewport
styles, and decoration renderers; CharSizeService already received
this._document directly. Add parentheses so the fallback applies only when
_document is nullish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:27:54 +00:00
Daniel ImmsandCursor c961408bf7 fix(browser): use startY in horizontalDirection row helper call
horizontalDirection called moveToRequestedRow with targetX as the first
argument, but that parameter is the starting row (startY). resetStartingRow
and moveToRequestedCol already pass startY correctly; only this copy-paste
mistake passed a column index where a row was expected.

That could miscompute wrapped-row offsets and emit the wrong horizontal
arrow key sequence when building selection/cursor movement strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:27:54 +00:00
Per Bothner 7b66cd026a Add 'type' to another import in ImageStorage.ts. 2026-05-23 16:42:09 -07:00
Per Bothner 235457ccdd Merge remote-tracking branch 'upstream/master' into image-ext 2026-05-23 16:39:23 -07:00
Per BothnerandDaniel Imms eaa3a63ddf Use 'import type' in addons/addon-image/src/ImageStorage.ts
Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com>
2026-05-21 08:55:49 -07:00
Per Bothner 8883dc17a7 Change image storage to use new ExtendedAttrs payload field. 2026-05-13 11:29:00 -07:00
135 changed files with 3513 additions and 1492 deletions
+34 -27
View File
@@ -6,14 +6,19 @@ on:
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22.x
cache: 'npm'
@@ -69,7 +74,7 @@ jobs:
./addons/addon-webgl/out/* \
./addons/addon-webgl/out-*st/*
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: build-artifacts
path: compressed-build.zip
@@ -79,9 +84,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22.x
cache: 'npm'
@@ -100,16 +107,18 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: |
npm ci
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build-artifacts
- name: Unzip artifacts
@@ -129,6 +138,7 @@ jobs:
exit $EXIT_CODE
test-unit:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -136,21 +146,18 @@ jobs:
runs-on: [ubuntu, macos, windows]
runs-on: ${{ matrix.runs-on }}-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
- name: Install dependencies
run: |
npm ci
- name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build-artifacts
- name: Unzip artifacts
@@ -166,6 +173,7 @@ jobs:
run: npm run test-unit --forbid-only
test-integration:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -174,9 +182,11 @@ jobs:
browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -185,12 +195,7 @@ jobs:
npm ci
- name: Install playwright
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build-artifacts
- name: Unzip artifacts
@@ -240,9 +245,11 @@ jobs:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -251,7 +258,7 @@ jobs:
npm ci
- name: Install playwright
run: npx playwright install
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build-artifacts
- name: Unzip artifacts
+11 -4
View File
@@ -8,6 +8,11 @@ on:
schedule:
- cron: '41 17 * * 0'
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -24,12 +29,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
+7 -2
View File
@@ -9,6 +9,9 @@ on:
paths:
- .github/workflows/copilot-setup-steps.yml
permissions:
contents: read
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
@@ -18,9 +21,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22.x
cache: 'npm'
+7 -2
View File
@@ -6,13 +6,18 @@ on:
# without running tests to save time to publish
branches: [ "master" ]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x
uses: actions/setup-node@v3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22.x
cache: 'npm'
+1 -1
View File
@@ -39,7 +39,7 @@ The preferred way to report bugs or request features is to use
It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this:
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transfered there without further comment and core maintainers may or may not participate in discussions.
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transferred there without further comment and core maintainers may or may not participate in discussions.
- Issues are strictly for well defined features or bugs that are _actionable_.
- Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that.
- If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion.
+7
View File
@@ -254,6 +254,13 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**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.
- [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty.
- [**OmnyShell PWA**](https://github.com/OmnyGrid/omnyshell_web): Web client for [**OmnyShell**](https://github.com/OmnyGrid/omnyshell), featuring remote terminals, a built-in IDE, AI-assisted workflows, and detachable/resumable sessions for seamless browser-based access to nodes.
- [**kmux**](https://github.com/kkd927/kmux): A keyboard-centric terminal workspace for running parallel AI coding agents side by side on macOS and Linux.
- [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management.
- [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone.
- [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal.
- [**BitFun**](https://github.com/GCWing/BitFun): Open-source desktop AI agent whose integrated terminals and read-only terminal output views use xterm.js with the Fit, Web Links, and WebGL addons.
- [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.
+1 -1
View File
@@ -15,7 +15,7 @@ test.beforeAll(async ({ browser }) => {
});
test.afterAll(async () => await ctx.page.close());
test.describe('Search Tests', () => {
test.describe('AttachAddon', () => {
test.beforeEach(async () => {
await ctx.proxy.reset();
+32 -2
View File
@@ -78,13 +78,43 @@ export class BrowserClipboardProvider implements IClipboardProvider {
}
}
/**
* TODO: Once the base64 handling on Uint8Arrays is more common,
* remove the btoa/atob fallbacks below.
*/
interface IUint8ArrayB64 extends Uint8Array {
toBase64(): string;
}
interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor {
fromBase64(s: string): IUint8ArrayB64;
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
return btoa(data);
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64;
if (bytes.toBase64 !== undefined) {
return bytes.toBase64();
}
let bin = '';
for (let i = 0; i < bytes.length; i++) {
bin += String.fromCharCode(bytes[i]);
}
return btoa(bin);
}
public decodeText(data: string): string {
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
try {
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
} catch {}
return '';
}
try {
return atob(data);
const bin = atob(data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
bytes[i] = bin.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch {}
return '';
}
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
}
await ctx.page.evaluate(`
window.term.reset()
window.term.reset();
window.clipboard?.dispose();
window.clipboard = new ClipboardAddon();
window.term.loadAddon(window.clipboard);
@@ -92,4 +92,22 @@ test.describe('ClipboardAddon', () => {
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
});
});
test.describe('non-ASCII', () => {
const testDataEncoded = '4oKsbWzDpMO8dMOf';
const testDataDecoded = '€mläütß';
test('write simple string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('read simple string', async () => {
await ctx.page.evaluate(`
window.data2 = [];
window.term.onData(e => data2.push(e));
`);
await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded);
await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]);
});
});
});
+1 -1
View File
@@ -82,7 +82,7 @@ declare module '@xterm/addon-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,
* Note that 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{
+17 -10
View File
@@ -90,7 +90,7 @@ terminal.loadAddon(imageAddon);
therefore palette animations cannot be used.
- **SIXEL Raster Attributes Handling**
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification.
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extent. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extent. Both is discouraged by the >30 years old specification.
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
@@ -98,7 +98,7 @@ terminal.loadAddon(imageAddon);
Set by default, change it with `{iipSupport: true}`.
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
- Supported formats: PNG, JPEG, GIF and QOI
- Supported formats: PNG, JPEG, GIF, QOI, WEBP and AVIF
- No animation support.
- Image type hinting is not supported (always deducted from data header).
- File download is not supported.
@@ -112,6 +112,13 @@ terminal.loadAddon(imageAddon);
- ReportCellSize sequence is supported.
- Hi-res display support is currently limited (image gets blurred).
- **Note on IIP image format support**
For all image formats except QOI the addon only does the dimension extraction and leaves
the image decoding to the underlying browser engine. Note that some formats are implemented
and enabled with different constraints by browser vendors, especially newer formats
(e.g. under Firefox you might need to change the setting _image.avif.compliance_strictness_ to 0
to get broader AVIF support).
- **Kitty Graphics Support (TGP)**
Set by default, change it with `{kittySupport: true}`.
@@ -149,9 +156,9 @@ The addon provides the following API endpoints to retrieve raw image data as can
### Memory Usage
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
- Incomming image data chunk at `term.write` (terminal)
`term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
The addon does most image processing in JavaScript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, let's look at the data flow and processing steps:
- Incoming image data chunk at `term.write` (terminal)
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
- Sequence Parser (terminal)
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
- Sequence Handler - Chunk Decoding (addon)
@@ -177,18 +184,18 @@ const totalActive = storageBytes + decodingBytes;
```
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%).
Further note that the formulas above do not respect the JavaScript object's overhead. Compared to the raw buffer needs the bookkeeping by these objects is rather small (<<5%).
_Why should I care about memory usage at all?_
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
_How can I adjust the memory usage?_
- `pixelLimit`
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage only peaks temporarily, a rather high value should work even with multiple terminals in one page.
- `storageLimit`
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding.
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
Constructor settings. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
Constructor settings. This has only a small impact on peak memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
### Terminal Interaction
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+49 -41
View File
@@ -3,13 +3,13 @@
* @license MIT
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
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, SequenceType } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
import { imageType, UNSUPPORTED_TYPE } from './Metrics';
import { Drawable } from './Primitives';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
@@ -39,13 +39,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _qoiDec: QoiDecoder;
private _metrics = UNSUPPORTED_TYPE;
private _isMultipart = false;
private _abortMulti = false;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
@@ -63,7 +61,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public start(): void {
this._aborted = false;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset();
}
@@ -120,14 +117,16 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (seqType === SequenceType.REPORTCELLSIZE) {
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
let width = CELL_SIZE_DEFAULT.width;
let height = CELL_SIZE_DEFAULT.height;
if (this._renderer.dimensions) {
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
// IMPORTANT: ReportCellSize uses logical points (CSS pixels)
let w = CELL_SIZE_DEFAULT.width;
let h = CELL_SIZE_DEFAULT.height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
w = dimensions.css.canvas.width / this._coreTerminal.cols;
h = dimensions.css.canvas.height / this._coreTerminal.rows;
}
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
this._coreTerminal.input(report, false);
return true;
}
@@ -149,22 +148,21 @@ export class IIPHandler implements IOscHandler, IResetHandler {
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
let w = 0;
let h = 0;
// early exit condition chain
let cond: number | boolean;
let metrics = UNSUPPORTED_TYPE;
if (cond = success) {
if (cond = !this._dec.end()) {
this._metrics = imageType(this._dec.data8);
if (cond = this._metrics.mime !== 'unsupported') {
w = this._metrics.width;
h = this._metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit;
metrics = imageType(this._dec.data8);
if (cond = metrics.mime !== 'unsupported') {
if (!(cond = metrics.width && metrics.height && metrics.width * metrics.height < this._opts.pixelLimit)) {
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
}
} else {
console.warn('IIP: unsupported image type');
}
} else {
console.warn('IIP: error during BASE64 decoding');
}
}
if (!cond) {
@@ -172,42 +170,52 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
let blob: Blob | ImageData;
if (this._metrics.mime === 'image/qoi') {
let bmSrc: Blob | ImageData;
let imgBlob: Blob;
if (metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
bmSrc = 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 });
imgBlob = bmSrc = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
return createImageBitmap(bmSrc)
.then(bm => {
this._storage.addImage(bm);
const [w, h] = this._resize(metrics.width, metrics.height);
this._storage.addImage(new Drawable(bm), imgBlob, metrics, w / metrics.width, h / metrics.height);
return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
return true;
});
}
private _resize(w: number, h: number): [number, number] {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;
const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;
let cw;
let ch;
let width;
let height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
width = dimensions.device.canvas.width;
height = dimensions.device.canvas.height;
cw = width / this._coreTerminal.cols;
ch = height / this._coreTerminal.rows;
} else {
cw = CELL_SIZE_DEFAULT.width;
ch = CELL_SIZE_DEFAULT.height;
width = cw * this._coreTerminal.cols;
height = ch * this._coreTerminal.rows;
}
const rw = this._dim(this._header.width!, width, cw);
const rh = this._dim(this._header.height!, height, ch);
const rw = this._dim(this._header.width ?? '', width, cw);
const rh = this._dim(this._header.height ?? '', height, ch);
if (!rw && !rh) {
const wf = width / w; // TODO: should this respect initial cursor offset?
const hf = (height - ch) / h; // TODO: fix offset issues from float cell height
+5 -3
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IAddImageOpts } from './Types';
import { IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageStorage } from './ImageStorage';
/**
@@ -22,7 +22,9 @@ export class IIPImageStorage {
* Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._storage.addImage(img, this._addImageOpts);
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, prescaleX: number, prescaleY: number): void {
this._addImageOpts.prescaleX = prescaleX;
this._addImageOpts.prescaleY = prescaleY;
this._storage.addImage(src, data, metrics, this._addImageOpts);
}
}
-84
View File
@@ -1,84 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 24) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
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;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+6 -7
View File
@@ -144,6 +144,9 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
terminal.options.windowOptions = windowOps;
}
// apply placeholder setting
this._renderer.showPlaceholder(this._opts.showPlaceholder);
this._disposeLater(
this._renderer,
this._storage,
@@ -187,7 +190,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
const iipHandler = new IIPHandler(this._opts, iipStorage, terminal);
this._handlers.set('iip', iipHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
@@ -250,10 +253,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
return this._storage?.getImageAtBufferCell(x, y);
}
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
return this._storage?.extractTileAtBufferCell(x, y);
}
private _report(s: string): void {
this._terminal?._core.input(s, false);
}
@@ -344,8 +343,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
switch (params[1]) {
// we only implement read and read_max here
case GaAction.READ:
let width = this._renderer?.dimensions?.css.canvas.width;
let height = this._renderer?.dimensions?.css.canvas.height;
let width = this._renderer?.dimensions?.device.canvas.width;
let height = this._renderer?.dimensions?.device.canvas.height;
if (!width || !height) {
// for some reason we have no working image renderer
// --> fallback to default cell size
+106 -211
View File
@@ -3,19 +3,14 @@
* @license MIT
*/
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import type { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createCanvas } from './Primitives';
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
/**
* ImageRenderer - terminal frontend extension:
* - provide primitives for canvas, ImageData, Bitmap (static)
* - add canvas layer to DOM (browser only for now)
* - draw image tiles onRender
*/
@@ -24,51 +19,14 @@ export class ImageRenderer extends Disposable implements IDisposable {
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
private _oldOpen: ((parent: HTMLElement) => void) | undefined;
private _renderService: IRenderService | undefined;
private _oldSetRenderer: ((renderer: any) => void) | undefined;
// drawing primitive - canvas
public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
return canvas;
}
// drawing primitive - ImageData with optional buffer
public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {
if (typeof ImageData !== 'function') {
const imgData = ctx.createImageData(width, height);
if (buffer) {
imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));
}
return imgData;
}
return buffer
? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)
: new ImageData(width, height);
}
// drawing primitive - ImageBitmap
public static createImageBitmap(img: ImageBitmapSource): Promise<ImageBitmap | undefined> {
if (typeof createImageBitmap !== 'function') {
return Promise.resolve(undefined);
}
return createImageBitmap(img);
}
// some local variables for faster access
private _dimensions: IRenderDimensions | undefined;
private _cellSize: ICellSize | undefined;
constructor(private _terminal: ITerminalExt) {
super();
@@ -100,8 +58,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
this._renderService = undefined;
this._layers.clear();
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}));
}
@@ -110,15 +66,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Enable the placeholder.
*/
public showPlaceholder(value: boolean): void {
if (value) {
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}
this._placeholder = value ? this._createPlaceHolder() : undefined;
this._renderService?.refreshRows(0, this._terminal.rows);
}
@@ -131,22 +79,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
/**
* Current cell size (float).
* Current cell size.
*/
public get cellSize(): ICellSize {
return {
width: this.dimensions?.css.cell.width || -1,
height: this.dimensions?.css.cell.height || -1
};
public getCellSize(dimensions?: IRenderDimensions): ICellSize | undefined {
dimensions ??= this._terminal.dimensions;
if (dimensions) {
return {
width: dimensions.device.canvas.width / this._terminal.cols,
height: dimensions.device.canvas.height / this._terminal.rows,
};
}
}
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0);
const w = this.dimensions?.css.canvas.width || 0;
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
const deviceGrid = this._cellSize;
if (!deviceGrid || !this._dimensions) return;
const y = Math.floor(start * deviceGrid.height);
const w = this._dimensions.device.canvas.width;
const h = Math.ceil((end + 1 - start) * deviceGrid.height);
if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h);
}
@@ -174,142 +127,103 @@ export class ImageRenderer extends Disposable implements IDisposable {
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer);
if (!ctx) {
return;
}
const { width, height } = this.cellSize;
const initialGrid = imgSpec.cellSize;
const deviceGrid = this._cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
if (!ctx || !imgSpec.src.native || !deviceGrid) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const img = imgSpec.src;
const cols = Math.ceil(img.width * imgSpec.prescaleX / initialGrid.width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const dx = col * width;
const dy = row * height;
const gridWidth = initialGrid.width / imgSpec.prescaleX;
const gridHeight = initialGrid.height / imgSpec.prescaleY;
// safari bug: never access image source out of bounds
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
// crops are initialGrid px
const sx = (tileId % cols) * gridWidth + imgSpec.precropX;
const sy = Math.floor(tileId / cols) * gridHeight + imgSpec.precropY;
const dx = Math.floor(col * deviceGrid.width + imgSpec.offsetX);
const dy = Math.floor(row * deviceGrid.height + imgSpec.offsetY);
// safari bug: never access image source out of bounds, thus we clamp its dimensions
const sWidth = Math.min(count * gridWidth, img.width - sx);
const sHeight = Math.min(gridHeight, img.height - sy);
const dWidth = Math.ceil(sWidth / gridWidth * deviceGrid.width * imgSpec.scaleX);
const dHeight = Math.ceil(sHeight / gridHeight * deviceGrid.height * imgSpec.scaleY);
if (imgSpec.blendMode === 'overwrite') {
ctx.clearRect(dx, dy, dWidth, dHeight);
}
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
img.native,
Math.floor(sx), Math.floor(sy), Math.ceil(sWidth), Math.ceil(sHeight),
dx, dy, dWidth, dHeight
);
}
/**
* Extract a single tile from an image.
*/
public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const finalWidth = width + sx > img.width ? img.width - sx : width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
);
return canvas;
}
}
/**
* Draw a line with placeholder on the image layer canvas.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top');
if (ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
width * count,
height,
col * width,
row * height,
width * count,
height
);
const deviceGrid = this._cellSize;
if (!ctx || !deviceGrid) {
return;
}
this._placeholder ??= this._createPlaceHolder();
if (!this._placeholder) return;
ctx.drawImage(
this._placeholder,
0, 0, 1, 1,
col * deviceGrid.width,
row * deviceGrid.height,
count * deviceGrid.width,
deviceGrid.height
);
}
/**
* Rescale image layer canvas if needed.
* Checked once from `ImageStorage.render`.
* NOTE: This method updates dimensions on instance properties
* for faster access during draw and clear calls.
* So make sure to always call this before doing any draws.
*/
public rescaleCanvas(): void {
const w = this.dimensions?.css.canvas.width || 0;
const h = this.dimensions?.css.canvas.height || 0;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
ctx.canvas.width = w;
ctx.canvas.height = h;
public rescaleCanvas(force: boolean = false): void {
const dimensions = this._terminal.dimensions;
if (dimensions) {
const cssW = dimensions.css.canvas.width;
const cssH = dimensions.css.canvas.height;
const devW = dimensions.device.canvas.width;
const devH = dimensions.device.canvas.height;
let recalc = force;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== devW
|| ctx.canvas.height !== devH
|| ctx.canvas.style.width !== `${cssW}px`
|| ctx.canvas.style.height !== `${cssH}px`
) {
ctx.canvas.width = devW;
ctx.canvas.height = devH;
ctx.canvas.style.width = `${cssW}px`;
ctx.canvas.style.height = `${cssH}px`;
recalc = true;
}
}
}
}
/**
* Rescale image in storage if needed.
*/
private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {
if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {
return;
}
const { width: originalWidth, height: originalHeight } = spec.origCellSize;
if (currentWidth === originalWidth && currentHeight === originalHeight) {
spec.actual = spec.orig;
spec.actualCellSize.width = originalWidth;
spec.actualCellSize.height = originalHeight;
return;
}
const canvas = ImageRenderer.createCanvas(
this.document,
Math.ceil(spec.orig!.width * currentWidth / originalWidth),
Math.ceil(spec.orig!.height * currentHeight / originalHeight)
);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
spec.actual = canvas;
spec.actualCellSize.width = currentWidth;
spec.actualCellSize.height = currentHeight;
if (recalc) {
this._dimensions = dimensions;
this._cellSize = this.getCellSize(dimensions);
}
} else {
// we were not able to get render dimensions:
// set cellsize to undefined so we don't try to draw anything
this._cellSize = this.getCellSize();
}
}
@@ -336,9 +250,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
if (this._layers.has(layer)) {
return;
}
const canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
const canvas = createCanvas(
this.document,
this.dimensions?.device.canvas.width || 0,
this.dimensions?.device.canvas.height || 0,
this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0,
);
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
@@ -366,6 +283,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
return;
}
this._layers.set(layer, ctx);
// force rescaling to update stored cellSize (maybe not be populated yet)
this.rescaleCanvas(true);
this.clearAll(layer);
}
@@ -381,41 +300,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
return this._layers.has(layer);
}
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
// create blueprint to fill placeholder with
const bWidth = 32; // must be 2^n
const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);
const ctx = blueprint.getContext('2d', { alpha: false });
/**
* Create a semi-transparent gray 1x1 placeholder
*/
private _createPlaceHolder(): HTMLCanvasElement | undefined {
const imgData = new ImageData(1, 1);
new Uint32Array(imgData.data.buffer).fill(128 << 24 | 128 << 16 | 128 << 8 | 128);
const canvas = createCanvas(this.document, 1, 1);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const imgData = ImageRenderer.createImageData(ctx, bWidth, height);
const d32 = new Uint32Array(imgData.data.buffer);
const black = toRGBA8888(0, 0, 0);
const white = toRGBA8888(255, 255, 255);
d32.fill(black);
for (let y = 0; y < height; ++y) {
const shift = y % 2;
const offset = y * bWidth;
for (let x = 0; x < bWidth; x += 2) {
d32[offset + x + shift] = white;
}
}
ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width
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) {
this._placeholder = undefined;
return;
}
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
return canvas;
}
public get document(): Document | undefined {
+138 -230
View File
@@ -5,12 +5,14 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import {
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
UnderlineStyle, IAddImageOpts
import type {
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts,
IDrawable,
IMetrics
} from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
import { createCanvas } from './Primitives';
// fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -18,93 +20,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
height: 14
};
/**
* Extend extended attribute to also hold image tile information.
*
* Object definition is copied from base repo to fully mimick its behavior.
* Image data is added as additional public properties `imageId` and `tileId`.
*/
class ExtendedAttrsImage implements IExtendedAttrsImage {
private _ext: number = 0;
public get ext(): number {
if (this._urlId) {
return (
(this._ext & ~ExtFlags.UNDERLINE_STYLE) |
(this.underlineStyle << 26)
);
}
return this._ext;
}
public set ext(value: number) { this._ext = value; }
public get underlineStyle(): UnderlineStyle {
// Always return the URL style if it has one
if (this._urlId) {
return UnderlineStyle.DASHED;
}
return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
}
public set underlineStyle(value: UnderlineStyle) {
this._ext &= ~ExtFlags.UNDERLINE_STYLE;
this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
}
public get underlineColor(): number {
return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
}
public set underlineColor(value: number) {
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
}
public get underlineVariantOffset(): number {
const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;
if (val < 0) {
return val ^ 0xFFFFFFF8;
}
return val;
}
public set underlineVariantOffset(value: number) {
this._ext &= ~ExtFlags.VARIANT_OFFSET;
this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;
}
private _urlId: number = 0;
public get urlId(): number {
return this._urlId;
}
public set urlId(value: number) {
this._urlId = value;
}
class ImageTileInfo {
constructor(
ext: number = 0,
urlId: number = 0,
public imageId = -1,
public tileId = -1
) {
this._ext = ext;
this._urlId = urlId;
}
public clone(): IExtendedAttrsImage {
/**
* Technically we dont need a clone variant of ExtendedAttrsImage,
* as we never clone a cell holding image data.
* Note: Clone is only meant to be used by the InputHandler for
* sticky attributes, which is never the case for image data.
* We still provide a proper clone method to reflect the full ext attr
* state in case there are future use cases for clone.
*/
return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);
}
public isEmpty(): boolean {
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;
public tileId = -1) {
}
}
const EMPTY_ATTRS = new ExtendedAttrsImage();
/**
* ImageStorage - extension of CoreTerminal:
@@ -125,7 +46,8 @@ export class ImageStorage implements IDisposable {
// whether render should do a full clear
private _needsFullClear = false;
// hard limit of stored pixels (fallback limit of 10 MB)
private _pixelLimit: number = 2500000;
private _byteLimit: number = 10000000;
private _workCell: CellData = new CellData();
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
@@ -164,43 +86,54 @@ export class ImageStorage implements IDisposable {
this._renderer.clearAll();
}
/**
* FIXME: below we need to introduce separation of:
* - RAM usage
* - VRAM usage
* - Blob unsage
*/
public getLimit(): number {
return this._pixelLimit * 4 / 1000000;
return this._byteLimit / 1000000;
}
public setLimit(value: number): void {
if (value < 0.5 || value > 1000) {
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1GB');
}
this._pixelLimit = (value / 4 * 1000000) >>> 0;
this._byteLimit = (value * 1000000) >>> 0;
this._evictOldest(0);
}
public getUsage(): number {
return this._getStoredPixels() * 4 / 1000000;
return this._getVRAMUsage() / 1000000;
}
private _getStoredPixels(): number {
let storedPixels = 0;
private _getVRAMUsage(): number {
let bytes = 0;
for (const spec of this._images.values()) {
if (spec.orig) {
storedPixels += spec.orig.width * spec.orig.height;
if (spec.actual && spec.actual !== spec.orig) {
storedPixels += spec.actual.width * spec.actual.height;
}
bytes += spec.src.bytes;
}
return bytes;
}
public getBlobUsage(): number {
let size = 0;
for (const spec of this._images.values()) {
if (spec.data instanceof Blob) {
size += spec.data.size;
}
}
return storedPixels;
return size;
}
private _delImg(id: number): void {
const spec = this._images.get(id);
if (!spec) return;
this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :(
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
spec.src.close();
spec.data = undefined;
this.onImageDeleted?.(id);
}
@@ -237,8 +170,10 @@ export class ImageStorage implements IDisposable {
}
/**
* Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
* Method to add an drawable to the storage.
* @param src - The drawable to add.
* @param data - Blob or Uint8Array of the original image data.
* @param metrics - IMetrics of the original image.
* @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move.
@@ -247,17 +182,16 @@ export class ImageStorage implements IDisposable {
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
* @returns The internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, opts: IAddImageOpts): number {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
this._evictOldest(src.bytes);
// calc rows x cols needed to display the image
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const cols = Math.ceil(img.width / cellSize.width);
const rows = Math.ceil(img.height / cellSize.height);
const prescaleX = opts.prescaleX ?? 1.0;
const prescaleY = opts.prescaleY ?? 1.0;
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const cols = Math.ceil(src.width * prescaleX / cellSize.width);
const rows = Math.ceil(src.height * prescaleY / cellSize.height);
const imageId = ++this._lastId;
@@ -277,10 +211,10 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(buffer.y + buffer.ybase);
const line = buffer.lines.get(buffer.y + buffer.ybase)!;
for (let col = 0; col < cols; ++col) {
if (offset + col >= termCols) break;
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
this._writeToCell(line, offset + col, imageId, row * cols + col);
tileCount++;
}
if (opts.scrolling) {
@@ -334,15 +268,24 @@ export class ImageStorage implements IDisposable {
// create storage entry
const imgSpec: IImageSpec = {
orig: img,
origCellSize: cellSize,
actual: img,
actualCellSize: { ...cellSize }, // clone needed, since later modified
src,
data,
metrics,
cellSize,
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer: opts.layer,
zIndex: opts.zIndex
zIndex: opts.zIndex,
prescaleX,
prescaleY,
precropX: 0,
precropY: 0,
scaleX: 1.0,
scaleY: 1.0,
offsetX: 0,
offsetY: 0,
blendMode: 'alpha'
};
// finally add the image
@@ -427,55 +370,42 @@ export class ImageStorage implements IDisposable {
const placeholderCalls: { col: number, row: number, count: number }[] = [];
// walk all cells in viewport and collect tiles found
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
// because text writes clear the BG flag but leave image tile data intact.
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
const line = buffer.lines.get(row + buffer.ydisp);
if (!line) return;
for (let col = 0; col < cols; ++col) {
let e: IExtendedAttrsImage;
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
} else {
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
const e = line.getExtended(col)?.payload;
if (e instanceof ImageTileInfo) {
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
e = maybeImg;
}
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
*/
while (++col < cols) {
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
*/
while (++col < cols) {
const nextE = line.getExtended(col)?.payload;
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
}
count++;
}
count++;
}
col--;
if (imgSpec) {
if (imgSpec.actual) {
col--;
if (imgSpec) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
}
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
this._fullyCleared = false;
}
this._fullyCleared = false;
}
}
}
@@ -513,9 +443,9 @@ export class ImageStorage implements IDisposable {
const rows = buffer.lines.length;
const oldCol = this._viewportMetrics.cols - 1;
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row) as IBufferLineExt;
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
const line = buffer.lines.get(row)!;
const e = line.getExtended(oldCol)?.payload;
if (e instanceof ImageTileInfo) {
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
@@ -525,14 +455,14 @@ export class ImageStorage implements IDisposable {
continue;
}
// found an image tile at oldCol, check if it qualifies for right exapansion
const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);
const tilesPerRow = Math.ceil(imgSpec.src.width * imgSpec.prescaleX / imgSpec.cellSize.width);
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
continue;
}
// expand only if right side is empty (nothing got wrapped from below)
let hasData = false;
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
if (line.hasContent(rightCol)) {
hasData = true;
break;
}
@@ -544,7 +474,7 @@ export class ImageStorage implements IDisposable {
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
let lastTile = e.tileId;
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
this._writeToCell(line, expandCol, imageId, ++lastTile);
imgSpec.tileCount++;
}
}
@@ -558,34 +488,17 @@ export class ImageStorage implements IDisposable {
*/
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig;
if (window.ImageBitmap && orig instanceof ImageBitmap) {
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);
const line = buffer.lines.get(y);
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
const src = this._images.get(e.imageId)?.src;
if (src?.native instanceof ImageBitmap || src?.native instanceof VideoFrame) {
const canvas = createCanvas(window.document, src.width, src.height);
canvas.getContext('2d')?.drawImage(src.native, 0, 0, src.width, src.height);
return canvas;
}
return orig as HTMLCanvasElement;
}
}
}
/**
* Extract active single tile at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
return this._renderer.extractTile(spec, e.tileId);
}
return src?.native;
}
}
}
@@ -593,15 +506,12 @@ export class ImageStorage implements IDisposable {
// TODO: Do we need some blob offloading tricks here to avoid early eviction?
// also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size
private _evictOldest(room: number): number {
const used = this._getStoredPixels();
const used = this._getVRAMUsage();
let current = used;
while (this._pixelLimit < current + room && this._images.size) {
while (this._byteLimit < current + room && this._images.size) {
const spec = this._images.get(++this._lowestId);
if (spec && spec.orig) {
current -= spec.orig.width * spec.orig.height;
if (spec.actual && spec.orig !== spec.actual) {
current -= spec.actual.width * spec.actual.height;
}
if (spec) {
current -= spec.src.bytes;
spec.marker?.dispose();
this._delImg(this._lowestId);
}
@@ -609,31 +519,31 @@ export class ImageStorage implements IDisposable {
return used - current;
}
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
const old = line._extendedAttrs[x];
if (old) {
if (old.imageId !== undefined) {
// found an old ExtendedAttrsImage, since we know that
// they are always isolated instances (single cell usage),
// we can re-use it and just update their id entries
const oldSpec = this._images.get(old.imageId);
if (oldSpec) {
// early eviction for in-viewport overwrites
oldSpec.tileCount--;
}
old.imageId = imageId;
old.tileId = tileId;
return;
private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void {
const workCell = this._workCell;
line.loadCell(x, workCell);
if (this._workCell.hasExtendedAttrs()) {
const old = workCell.extended.payload;
if (old instanceof ImageTileInfo) {
// found an old ExtendedAttrsImage, since we know that
// they are always isolated instances (single cell usage),
// we can re-use it and just update their id entries
const oldSpec = this._images.get(old.imageId);
if (oldSpec) {
// early eviction for in-viewport overwrites
oldSpec.tileCount--;
}
// found a plain ExtendedAttrs instance, clone it to new entry
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
old.imageId = imageId;
old.tileId = tileId;
return;
}
}
// fall-through: always create new ExtendedAttrsImage entry
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
// fall-through: always create new ExtendedAttrs entry
const extattr = workCell.extended.clone();
extattr.payload = new ImageTileInfo(imageId, tileId);
workCell.extended = extattr;
workCell.updateExtended();
line.setCell(x, workCell);
}
private _evictOnAlternate(): void {
@@ -646,18 +556,16 @@ export class ImageStorage implements IDisposable {
// re-count tiles on whole buffer
const buffer = this._terminal._core.buffer;
for (let y = 0; y < this._terminal.rows; ++y) {
const line = buffer.lines.get(y) as IBufferLineExt;
const line = buffer.lines.get(y);
if (!line) {
continue;
}
for (let x = 0; x < this._terminal.cols; ++x) {
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
const imgId = line._extendedAttrs[x]?.imageId;
if (imgId) {
const spec = this._images.get(imgId);
if (spec) {
spec.tileCount++;
}
const payload = line.getExtended(x)?.payload;
if (payload instanceof ImageTileInfo) {
const spec = this._images.get(payload.imageId);
if (spec) {
spec.tileCount++;
}
}
}
@@ -4,7 +4,8 @@
*/
import { assert } from 'chai';
import { imageType, IMetrics } from './IIPMetrics';
import { imageType } from './Metrics';
import type { IMetrics } from 'Types';
// fix missing nodejs decl
declare const require: (s: string) => any;
@@ -32,6 +33,28 @@ const TEST_IMAGES: [string, IMetrics][] = [
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
// VP8
['1.webp', { mime: 'image/webp', width: 550, height: 368 }],
['2.webp', { mime: 'image/webp', width: 550, height: 404 }],
['3.webp', { mime: 'image/webp', width: 1280, height: 720 }],
['4.webp', { mime: 'image/webp', width: 1024, height: 772 }],
['5.webp', { mime: 'image/webp', width: 1024, height: 752 }],
// VP8X
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }],
// VP8L
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }],
// some AVIF test images
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }],
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }],
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }],
];
+137
View File
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IMetrics } from './Types';
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
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]
};
}
// WEBP: RIFF | xxxx | WEBP | VP8x
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
switch (d[15]) {
case 0x58: // Extended WebP VP8X --> "X"
return {
mime: 'image/webp',
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
};
case 0x4C: // Lossless WebP VP8L --> "L"
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
return {
mime: 'image/webp',
width: (dim & 0x3FFF) + 1,
height: (dim >>> 14 & 0x3FFF) + 1
};
case 0x20: // Lossy WebP VP8 --> " "
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
return {
mime: 'image/webp',
width: (d[26] | d[27] << 8) & 0x3FFF,
height: (d[28] | d[29] << 8) & 0x3FFF
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
// search for ispe box within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
break;
}
}
if (pos !== -1) {
// dimensions are in BE at +8 (width) at +12 (height)
const width =
d[pos + 8] << 24 |
d[pos + 9] << 16 |
d[pos + 10] << 8 |
d[pos + 11];
const height =
d[pos + 12] << 24 |
d[pos + 13] << 16 |
d[pos + 14] << 8 |
d[pos + 15];
if (width > 0 && height > 0) {
return { mime: 'image/avif', width, height };
}
}
return UNSUPPORTED_TYPE;
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDrawable } from './Types';
export function createCanvas(
localDocument: Document | undefined,
width: number,
height: number,
cssWidth?: number,
cssHeight?: number
): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
canvas.style.width = `${(cssWidth ?? width) | 0}px`;
canvas.style.height = `${(cssHeight ?? height) | 0}px`;
return canvas;
}
/**
* Drawable is a resource the ImageRenderer knows to draw on the screen.
*/
export class Drawable implements IDrawable {
public readonly width: number;
public readonly height: number;
// @readonly: only for memory tracking
public bytes: number;
constructor(public native: ImageBitmap | VideoFrame | HTMLCanvasElement) {
if (native instanceof VideoFrame) {
this.width = native.displayWidth;
this.height = native.displayHeight;
// NOTE: native.allocationSize() is too expensive
this.bytes = native.codedWidth * native.codedHeight * 4;
} else {
this.width = native.width;
this.height = native.height;
this.bytes = native.width * native.height * 4;
}
}
public close(): void {
if (this.bytes === 0) return;
this.bytes = 0;
try {
if (this.native instanceof HTMLCanvasElement) {
this.native.width = 0;
this.native.height = 0;
} else {
this.native.close?.();
}
} catch {}
this.native = null!;
}
}
+3 -3
View File
@@ -7,9 +7,9 @@ import { SixelImageStorage } from './SixelImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
import { createCanvas, Drawable } from './Primitives';
// always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -98,12 +98,12 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
return true;
}
const canvas = ImageRenderer.createCanvas(undefined, width, height);
const canvas = createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
this._dec.release();
}
this._storage.addImage(canvas);
this._storage.addImage(new Drawable(canvas), undefined, { width, height, mime: 'image/sixel' });
return true;
}
}
+4 -7
View File
@@ -4,7 +4,7 @@
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageRenderer } from './ImageRenderer';
/**
@@ -27,9 +27,9 @@ export class SixelImageStorage {
* Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(img, this._addImageOpts);
this._storage.addImage(src, data, metrics, this._addImageOpts);
}
/**
@@ -39,10 +39,7 @@ export class SixelImageStorage {
*/
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
+60 -20
View File
@@ -10,7 +10,6 @@ import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/b
import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IInputHandler } from 'common/Types';
import type { IBufferLine, IExtendedAttrs } from 'common/buffer/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
@@ -48,6 +47,7 @@ export interface IResetHandler {
reset(): void;
}
/* eslint-disable */
/**
* Stub into private interfaces.
* This should be kept in line with common libs.
@@ -55,19 +55,6 @@ export interface IResetHandler {
* have a somewhat reliable testing against code changes in the core repo.
*/
// overloaded IExtendedAttrs to hold image refs
export interface IExtendedAttrsImage extends IExtendedAttrs {
imageId: number;
tileId: number;
clone(): IExtendedAttrsImage;
}
/* eslint-disable */
export interface IBufferLineExt extends IBufferLine {
_extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined};
_data: Uint32Array;
}
interface IInputHandlerExt extends IInputHandler {
_parser: IEscapeSequenceParser;
_curAttrData: AttributeData;
@@ -78,6 +65,7 @@ interface IInputHandlerExt extends IInputHandler {
};
onRequestReset(handler: () => void): IDisposable;
}
/* eslint-enable */
export interface ICoreTerminalExt extends ITerminal {
_themeService: IThemeService | undefined;
@@ -89,8 +77,6 @@ export interface ICoreTerminalExt extends ITerminal {
export interface ITerminalExt extends Terminal {
_core: ICoreTerminalExt;
}
/* eslint-enable */
/**
* Some storage definitions.
@@ -108,16 +94,70 @@ export interface IAddImageOpts {
layer: ImageLayer;
zIndex: number;
cursorPos: CursorPos;
prescaleX?: number;
prescaleY?: number;
}
export interface IDrawable {
native: ImageBitmap | VideoFrame | HTMLCanvasElement;
width: number;
height: number;
close(): void;
readonly bytes: number;
}
export type ImageType = 'unsupported'
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/qoi'
| 'image/webp'
| 'image/avif'
// sixel
| 'image/sixel'
// rgb|a blobs for kitty
| 'image/rgb'
| 'image/rgba';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
actual: HTMLCanvasElement | ImageBitmap | undefined;
actualCellSize: ICellSize;
/** entries for a later IImageSource type */
/** drawable for screen rendering */
src: IDrawable;
/** image bytes as blob */
data: Blob | undefined;
/** metrics about image like mime and dimensions */
metrics: IMetrics;
/** entries for a later IPlacement type */
/** cell size at time of insert */
cellSize: ICellSize;
/** eviction marker */
marker: IMarker | undefined;
/** used tiles as eviction hint */
tileCount: number;
/** buffer it was placed on */
bufferType: 'alternate' | 'normal';
/** layer it was placed on */
layer: ImageLayer;
zIndex: number;
/** scaling factor on source dimensions, default = 1.0 */
prescaleX: number;
prescaleY: number;
/** crop offset into source, default = 0 (currently unused) */
precropX: number;
precropY: number;
/** output scaling, default = 1.0 (currently unused) */
scaleX: number;
scaleY: number;
/** output offset, default = 0 (currently unused) */
offsetX: number;
offsetY: number;
/** compositing mode on output, default = 'alpha' (currently unused) */
blendMode: 'overwrite' | 'alpha';
}
@@ -19,6 +19,7 @@ import {
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
import { createCanvas } from '../Primitives';
const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler)
@@ -573,8 +574,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
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;
const cw = this._renderer.dimensions?.device.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.device.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.
@@ -636,7 +637,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// 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 offsetCanvas = createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
@@ -700,7 +701,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
const canvas = createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
@@ -7,6 +7,8 @@ import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage';
import { ImageLayer, IAddImageOpts } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes';
import { Drawable } from '../Primitives';
import { UNSUPPORTED_TYPE } from '../Metrics';
// Kitty-specific image storage controller.
//
@@ -102,7 +104,8 @@ export class KittyImageStorage implements IDisposable {
this._addImageOpts.scrolling = scrolling;
this._addImageOpts.layer = layer;
this._addImageOpts.zIndex = zIndex;
const storageId = this._storage.addImage(image, this._addImageOpts);
// FIXME: store image data in blob, fill metrics correctly
const storageId = this._storage.addImage(new Drawable(image), undefined, UNSUPPORTED_TYPE, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
+51 -25
View File
@@ -294,10 +294,10 @@ test.describe('ImageAddon', () => {
test('size params is optional/informative', async () => {
// too small size param
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
// no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
deepStrictEqual(await getImageSize(2), [640, 80]);
});
test.skip('FilePart support - bytewise', async () => {
// opener
@@ -308,7 +308,7 @@ test.describe('ImageAddon', () => {
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
});
test('FilePart support - chunks', async () => {
// opener
@@ -319,7 +319,7 @@ test.describe('ImageAddon', () => {
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
});
test('FilePart support - edgecases', async () => {
// multiple opener do not harm
@@ -347,39 +347,39 @@ test.describe('ImageAddon', () => {
// finalizer skipped
// write spinfox.png
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
});
});
test.describe('IIP support - testimages', () => {
test('palette.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[0][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[0][1]);
});
test('spinfox.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
});
test('w3c gif', async () => {
await ctx.proxy.write(TESTDATA_IIP[2][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[2][1]);
});
test('w3c jpeg', async () => {
await ctx.proxy.write(TESTDATA_IIP[3][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[3][1]);
});
test('w3c png', async () => {
await ctx.proxy.write(TESTDATA_IIP[4][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
deepStrictEqual(await getImageSize(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]);
deepStrictEqual(await getImageSize(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]);
deepStrictEqual(await getImageSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape);
@@ -406,20 +406,20 @@ test.describe('ImageAddon', () => {
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]);
deepStrictEqual(await getImageSize(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]);
deepStrictEqual(await getImageSize(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)]);
deepStrictEqual(await getImageSize(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
@@ -427,14 +427,23 @@ test.describe('ImageAddon', () => {
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)]);
deepStrictEqual(await getImageSize(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]);
deepStrictEqual(await getImageSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
test.describe('text overwrite removes tiles', () => {
test('sixel', async () => {
await assertTextClearsTiles(SIXEL_SEQ_0);
});
test('iip', async () => {
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
});
});
});
/**
@@ -443,10 +452,10 @@ test.describe('ImageAddon', () => {
async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return {
cellWidth: Math.round(dimensions.css.cell.width),
cellHeight: Math.round(dimensions.css.cell.height),
width: Math.round(dimensions.css.canvas.width),
height: Math.round(dimensions.css.canvas.height)
cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.device.canvas.height)
};
}
@@ -462,13 +471,30 @@ async function getScrollbackPlusRows(): Promise<number> {
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
}
async function getOrigSize(id: number): Promise<[number, number]> {
async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width,
window.imageAddon._storage._images.get(${id}).orig.height
]`);
window.imageAddon._storage._images.get(${id}).src.width * window.imageAddon._storage._images.get(${id}).prescaleX,
window.imageAddon._storage._images.get(${id}).src.height * window.imageAddon._storage._images.get(${id}).prescaleY
]`).then(r => r.map(Math.floor));
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
}
+45 -26
View File
@@ -123,7 +123,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [1, 1]);
deepStrictEqual(await getImageSize(1), [1, 1]);
});
test('stores 3x1 RGB PNG with a=T', async () => {
@@ -131,7 +131,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('transmit only (a=t) does not display but stores in handler', async () => {
@@ -1107,7 +1107,7 @@ test.describe('Kitty Graphics Protocol', () => {
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [20, 50]);
deepStrictEqual(await getImageSize(1), [20, 50]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
});
@@ -1283,7 +1283,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell;
const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height];
});
const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]);
@@ -1301,7 +1301,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell;
const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height];
});
const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]);
@@ -1535,7 +1535,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('transmit only (a=t) stores 200x100 image without display', async () => {
@@ -1564,7 +1564,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('handles 3-chunk transmission', async () => {
@@ -1580,7 +1580,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('verifies chunked data assembles correctly', async () => {
@@ -1713,7 +1713,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [20, 50]);
deepStrictEqual(await getImageSize(1), [20, 50]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]);
});
@@ -1747,7 +1747,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
});
@@ -1756,7 +1756,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
});
@@ -1772,7 +1772,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
});
@@ -1893,28 +1893,28 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]);
deepStrictEqual(await getImageSize(1), [2, 2]);
});
test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
deepStrictEqual(await getImageSize(1), [5, 1]);
});
test('stores image with correct original dimensions (4x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [4, 2]);
deepStrictEqual(await getImageSize(1), [4, 2]);
});
});
@@ -2034,21 +2034,21 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]);
deepStrictEqual(await getImageSize(1), [2, 2]);
});
test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
deepStrictEqual(await getImageSize(1), [5, 1]);
});
});
@@ -2213,6 +2213,21 @@ test.describe('Kitty Graphics Protocol', () => {
await pollFor(ctx.page, 'window._imageAddedCount', 2);
});
});
test.describe('text overwrite removes tiles', () => {
test('a=T placement', async () => {
await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
});
});
});
/**
@@ -2221,10 +2236,10 @@ test.describe('Kitty Graphics Protocol', () => {
async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return {
cellWidth: Math.round(dimensions.css.cell.width),
cellHeight: Math.round(dimensions.css.cell.height),
width: Math.round(dimensions.css.canvas.width),
height: Math.round(dimensions.css.canvas.height)
cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.device.canvas.height)
};
}
@@ -2236,10 +2251,10 @@ async function getImageStorageLength(): Promise<number> {
return ctx.page.evaluate('window.imageAddon._storage._images.size');
}
async function getOrigSize(id: number): Promise<[number, number]> {
async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width,
window.imageAddon._storage._images.get(${id}).orig.height
window.imageAddon._storage._images.get(${id}).src.width,
window.imageAddon._storage._images.get(${id}).src.height
]`);
}
@@ -2262,3 +2277,7 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb
return Array.from(ctx2d.getImageData(x, y, w, h).data);
}, [col, row, x, y, w, h]);
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
-5
View File
@@ -125,10 +125,5 @@ declare module '@xterm/addon-image' {
* Get original image canvas at buffer position.
*/
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
/**
* Extract single tile canvas at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
/**
* Enable ligature support for the provided Terminal instance. To function
* properly, this must be called after `open()` is called on the therminal. If
* properly, this must be called after `open()` is called on the terminal. If
* the font currently in use supports ligatures, the terminal will automatically
* start to render them.
* @param term Terminal instance from xterm.js
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DecorationManager } from './DecorationManager';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import type { IDecorationOptions } from '@xterm/xterm';
import { DisposableStore } from 'common/Lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('DecorationManager', () => {
let store: DisposableStore;
let terminal: Terminal;
let decorationManager: DecorationManager;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
decorationManager = store.add(new DecorationManager(terminal));
});
it('should split highlight decorations for a wrapped match', async () => {
await writeP(terminal, '0123456789abcde');
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
const match = searchEngine.find('9abc', 0, 0);
assert.ok(match);
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([match], options);
assert.strictEqual(decorationOptions.length, 2);
assert.strictEqual(decorationOptions[0].x, 9);
assert.strictEqual(decorationOptions[0].width, 1);
assert.strictEqual(decorationOptions[1].x, 0);
assert.strictEqual(decorationOptions[1].width, 3);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 2);
});
it('should only add one overview ruler marker per buffer line', async () => {
await writeP(terminal, 'abcdefghij');
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([
{ term: 'a', col: 0, row: 0, size: 1 },
{ term: 'f', col: 5, row: 0, size: 1 }
], options);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 1);
assert.strictEqual(withOverviewRuler[0].x, 0);
});
});
+8 -6
View File
@@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
}
prevResult = result;
results.push(prevResult);
result = this._engine.find(
term,
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
searchOptions
);
const cols = this._terminal.cols;
let nextCol = prevResult.col + prevResult.size;
let nextRow = prevResult.row;
if (nextCol >= cols) {
nextRow += Math.floor(nextCol / cols);
nextCol = nextCol % cols;
}
result = this._engine.find(term, nextRow, nextCol, searchOptions);
}
this._resultTracker.updateResults(results, this._highlightLimit);
+1 -1
View File
@@ -60,7 +60,7 @@ export class SearchEngine {
this._terminal.clearSelection();
return undefined;
}
if (startCol > this._terminal.cols) {
if (startCol >= this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
+16 -2
View File
@@ -414,10 +414,24 @@ test.describe('Search Tests', () => {
});
test.describe('Regression tests', () => {
test('should advance highlight-all scan by buffer match size for wide characters', async () => {
await ctx.page.evaluate(`
window.calls = [];
window.search.onDidChangeResults(e => window.calls.push(e));
`);
await ctx.proxy.resize(3, 5);
await ctx.proxy.write('𝄞𝄞𝄞');
strictEqual(await ctx.page.evaluate(`window.search.findNext('𝄞', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 3, resultIndex: 0 }
]);
await ctx.proxy.resize(80, 24);
});
test.describe('#2444 wrapped line content not being found', () => {
let fixture: string;
test.beforeAll(async () => {
fixture = (await new Promise<Buffer>(r => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => r(data)))).toString();
fixture = (await new Promise<Buffer>((res, rej) => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => err ? rej(err) : res(data)))).toString();
if (process.platform !== 'win32') {
fixture = fixture.replace(/\n/g, '\n\r');
}
@@ -501,7 +515,7 @@ test.describe('Search Tests', () => {
window.search.onDidChangeResults(e => window.calls.push(e));
`);
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
await ctx.proxy.write('\\x1b[CHi Hi');
await ctx.proxy.write('\x1b[CHi Hi');
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 2, resultIndex: 1 }
+3 -3
View File
@@ -173,7 +173,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
protected _rowEnd(row: number, isLastRow: boolean): void {
// if there is colorful empty cell at line end, whe must pad it back, or the the color block
// if there is colorful empty cell at line end, we must pad it back, or the color block
// will missing
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
// use clear right to set background.
@@ -221,7 +221,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
// you can't use control sequence to move cursor to (x === row)
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simply the process to determine line separator
// so we mark it as invalid to simplify the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar)
) {
isValid = true;
@@ -233,7 +233,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
isNextRowFirstCharDoubleWidth &&
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simply the process to determine line separator
// so we mark it as invalid to simplify the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar) &&
equalBg(thisRowLastSecondChar, nextRowFirstChar)
) {
@@ -192,7 +192,7 @@ test.describe('SerializeAddon', () => {
const cols = 10;
const line = '+'.repeat(cols);
const lines: string[] = [
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags at the end, serialize will use \x1b[0m to clear instead of the specific disable sequence
sgr(INVERSE) + line,
sgr(BOLD) + line,
sgr(UNDERLINED) + line,
@@ -2,7 +2,7 @@
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*
* UnicodeVersionProvider for V15 with grapeme cluster handleing.
* UnicodeVersionProvider for V15 with grapheme cluster handling.
*/
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
@@ -57,7 +57,8 @@ function isUrl(urlString: string): boolean {
export class LinkComputer {
public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] {
const rex = new RegExp(regex.source, (regex.flags || '') + 'g');
const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
const rex = new RegExp(regex.source, flags);
const [lines, startLineIndex] = LinkComputer._getWindowedLineStrings(y - 1, terminal);
const line = lines.join('');
+2 -2
View File
@@ -7,13 +7,13 @@ import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { WebLinksAddon as IWebLinksApi } from '@xterm/addon-web-links';
import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider';
// consider everthing starting with http:// or https://
// consider everything starting with http:// or https://
// up to first whitespace, `"` or `'` as url
// NOTE: The repeated end clause is needed to not match a dangling `:`
// resembling the old (...)*([^:"\'\\s]) final path clause
// additionally exclude early + final:
// - unsafe from rfc3986: !*'()
// - unsafe chars from rfc1738: {}|\^~[]` (minus [] as we need them for ipv6 adresses, also allow ~)
// - unsafe chars from rfc1738: {}|\^~[]` (minus [] for ipv6 addresses, also allow ~)
// also exclude as finals:
// - final interpunction like ,.!?
// - any sort of brackets <>()[]{} (not spec conform, but often used to enclose urls)
+1 -1
View File
@@ -23,7 +23,7 @@ See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-
### Handling Context Loss
The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. There is an API exposed that fires the `webglcontextlost` event fired on the canvas so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the event fires:
The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. There is an API exposed that fires the `webglcontextlost` event on the canvas so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the event fires:
```ts
const terminal = new Terminal();
+25 -4
View File
@@ -100,6 +100,8 @@ export class GlyphRenderer extends Disposable {
private readonly _attributesBuffer: WebGLBuffer;
private _atlas: ITextureAtlas | undefined;
private _lastSeenPageLayoutVersion: number = -1;
private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0;
private readonly _vertices: IVertices = {
count: 0,
@@ -213,14 +215,25 @@ export class GlyphRenderer extends Disposable {
this.handleResize();
}
/**
* Call when a frame is being drawn. Returns whether the full model must be rebuilt before
* rendering this frame because the atlas page layout changed since this renderer last drew.
*/
public beginFrame(): boolean {
return this._atlas ? this._atlas.beginFrame() : true;
if (!this._atlas) {
return true;
}
if (this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion) {
this._lastSeenPageLayoutVersion = this._atlas.pageLayoutVersion;
return true;
}
return false;
}
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
// Since this function is called for every cell (`rows*cols`), it must be very optimized. It
// should not instantiate any variables unless a new glyph is drawn to the cache where the
// slight slowdown is acceptable for the developer ergonomics provided as it's a once of for
// slight slowdown is acceptable for the developer ergonomics provided as it's a one-off for
// each glyph.
this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, width, lastBg);
}
@@ -363,7 +376,13 @@ export class GlyphRenderer extends Disposable {
// 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++) {
// Clamp defensively in case the atlas unexpectedly exceeds texture capacity.
const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length);
if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) {
this._pageOverflowWarned = true;
this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`);
}
for (let i = 0; i < pageCount; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
}
@@ -375,6 +394,7 @@ export class GlyphRenderer extends Disposable {
public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas;
this._lastSeenPageLayoutVersion = -1;
this.invalidateAtlasTextures();
}
@@ -389,8 +409,9 @@ export class GlyphRenderer extends Disposable {
gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas);
gl.generateMipmap(gl.TEXTURE_2D);
this._atlasTextures[i].version = atlas.pages[i].version;
}
+60 -20
View File
@@ -69,7 +69,7 @@ export class TextureAtlas implements ITextureAtlas {
private _overflowSizePage: AtlasPage | undefined;
private _tmpCanvas: HTMLCanvasElement;
// A temporary context that glyphs are drawn to before being transfered to the atlas.
// A temporary context that glyphs are drawn to before being transferred to the atlas.
private _tmpCtx: CanvasRenderingContext2D;
private _workBoundingBox: IBoundingBox = { top: 0, left: 0, bottom: 0, right: 0 };
@@ -132,12 +132,8 @@ export class TextureAtlas implements ITextureAtlas {
}
}
private _requestClearModel = false;
public beginFrame(): boolean {
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
}
private _pageLayoutVersion = 0;
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
public clearTexture(): void {
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
@@ -149,10 +145,15 @@ export class TextureAtlas implements ITextureAtlas {
this._cacheMap.clear();
this._cacheMapCombined.clear();
this._didWarmUp = false;
// Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
// which case the clearing renderer has cleared only its own model and every other owner still
// holds texture coords into the rows just wiped.
this._pageLayoutVersion++;
}
private _createNewPage(): AtlasPage {
// Try merge the set of the 4 most used pages of the largest size. This is is deferred to a
// Try merge the set of the 4 most used pages of the largest size. This is deferred to a
// microtask to ensure it does not interrupt textures that will be rendered in the current
// animation frame which would result in blank rendered areas. This is actually not that
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
@@ -184,6 +185,8 @@ export class TextureAtlas implements ITextureAtlas {
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
// effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
// Evict instead of adding a page beyond the renderer's texture capacity.
this._evictAllPages();
const newPage = new AtlasPage(this._document, this._textureSize);
this._pages.push(newPage);
this._activePages.push(newPage);
@@ -191,7 +194,7 @@ export class TextureAtlas implements ITextureAtlas {
return newPage;
}
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a > b ? 1 : -1);
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
const mergedPageIndex = this.pages.length - mergingPages.length;
// Merge into the new page
@@ -206,8 +209,8 @@ export class TextureAtlas implements ITextureAtlas {
// Add the new merged page to the end
this.pages.push(mergedPage);
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
// Invalidate renderer models so all texture pages are refreshed.
this._pageLayoutVersion++;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
}
@@ -258,6 +261,23 @@ export class TextureAtlas implements ITextureAtlas {
}
}
private _evictAllPages(): void {
const startTime = performance.now();
const pageCount = this._pages.length;
for (const page of this._pages) {
this._onRemoveTextureAtlasCanvas.fire(page.canvas);
page.canvas.remove();
}
this._pages.length = 0;
this._activePages.length = 0;
this._overflowSizePage = undefined;
this._cacheMap.clear();
this._cacheMapCombined.clear();
this._didWarmUp = false;
this._pageLayoutVersion++;
this._logService.debug(`Evicted ${pageCount} WebGL atlas pages in ${(performance.now() - startTime).toFixed(2)}ms`);
}
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
}
@@ -815,11 +835,15 @@ export class TextureAtlas implements ITextureAtlas {
// Create a new page for oversized glyphs as they come up
if (rasterizedGlyph.size.x > this._textureSize) {
if (!this._overflowSizePage) {
// Make room for the oversized page without exceeding texture capacity.
if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) {
this._evictAllPages();
}
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
this.pages.push(this._overflowSizePage);
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
// Invalidate renderer models so all texture pages are refreshed.
this._pageLayoutVersion++;
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
}
activePage = this._overflowSizePage;
@@ -1080,17 +1104,33 @@ class AtlasPage {
size: number,
sourcePages?: AtlasPage[]
) {
if (sourcePages) {
for (const p of sourcePages) {
this._glyphs.push(...p.glyphs);
this._usedPixels += p._usedPixels;
}
}
this.canvas = createCanvas(document, size, size);
// The canvas needs alpha because we use clearColor to convert the background color to alpha.
// It might also contain some characters with transparent backgrounds if allowTransparency is
// set.
this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true }));
if (sourcePages) {
if (sourcePages.length === 4) {
// optimized for quadmerge
this._glyphs = this._glyphs.concat(
sourcePages[0].glyphs,
sourcePages[1].glyphs,
sourcePages[2].glyphs,
sourcePages[3].glyphs
);
this._usedPixels = sourcePages[0]._usedPixels +
sourcePages[1]._usedPixels +
sourcePages[2]._usedPixels +
sourcePages[3]._usedPixels;
} else {
// fallback for non quadmerges (should never be used)
for (let i = 0; i < sourcePages.length; ++i) {
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
this._usedPixels += sourcePages[i]._usedPixels;
}
}
}
}
public clear(): void {
@@ -1125,7 +1165,7 @@ function clearColor(imageData: ImageData, bg: IColor, fg: IColor, enableThreshol
// were covered (fg=#8ae234, bg=#c4a000).
const threshold = Math.floor((Math.abs(r - fgR) + Math.abs(g - fgG) + Math.abs(b - fgB)) / 12);
// Set alpha channel of relevent pixels to 0
// Set alpha channel of relevant pixels to 0
let isEmpty = true;
for (let offset = 0; offset < imageData.data.length; offset += 4) {
// Check exact match
+5 -3
View File
@@ -68,10 +68,12 @@ export interface ITextureAtlas extends IDisposable {
warmUp(): void;
/**
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
* for a new set of glyphs.
* Incremented whenever cached glyph texture page mappings may be stale, such as after atlas page
* merges or overflow page creation. Renderers compare this against their own last-seen value and
* rebuild their model when it changes; a shared atlas can have many renderers, so this must not
* be a consume-once flag.
*/
beginFrame(): boolean;
readonly pageLayoutVersion: number;
/**
* Clear all glyphs from the texture atlas.
+1 -1
View File
@@ -40,7 +40,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
};
const gl = document.createElement('canvas').getContext('webgl2', contextAttributes) as IWebGL2RenderingContext;
if (!gl) {
throw new Error('Webgl2 is only supported on Safari 16 and above');
throw new Error('WebGL2 is only supported on Safari 16 and above');
}
}
super();
+23 -8
View File
@@ -456,7 +456,18 @@ export class WebglRenderer extends Disposable implements IRenderer {
for (y = start; y <= end; y++) {
row = y + terminal.buffer.ydisp;
line = terminal.buffer.lines.get(row)!;
const bufferLine = terminal.buffer.lines.get(row);
if (!bufferLine) {
this._model.lineLengths[y] = 0;
for (x = 0; x < terminal.cols; x++) {
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
modelUpdated = true;
this._nullModelCell(x, y, j, 0, 0, 0);
}
this._setRowBlinkState(y, false);
continue;
}
line = bufferLine;
let rowHasBlinkingCells = false;
this._model.lineLengths[y] = 0;
isCursorRow = cursorY === row;
@@ -587,13 +598,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Null out non-first cells
for (x++; x <= lastCharX; x++) {
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 + 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;
this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext);
}
x--; // Go back to the previous update cell for next iteration
}
@@ -607,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateTextBlinkState();
}
private _nullModelCell(x: number, y: number, cellIndex: number, bg: number, fg: number, ext: number): void {
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, bg, fg, ext, NULL_CELL_CHAR, 0, 0);
this._model.cells[cellIndex] = NULL_CELL_CODE;
this._model.cells[cellIndex + RenderModelConstants.BG_OFFSET] = bg;
this._model.cells[cellIndex + RenderModelConstants.FG_OFFSET] = fg;
this._model.cells[cellIndex + RenderModelConstants.EXT_OFFSET] = ext;
}
private _resetBlinkingRowState(): void {
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
this._rowHasBlinkingCellsCount = 0;
@@ -664,10 +679,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
this.dimensions.device.canvas.height = this._terminal.rows * this.dimensions.device.cell.height;
this.dimensions.device.canvas.width = this._terminal.cols * this.dimensions.device.cell.width;
// The the size of the canvas on the page. It's important that this rounds to nearest integer
// The size of the canvas on the page. It's important that this rounds to nearest integer
// and not ceils as browsers often have floating point precision issues where
// `window.devicePixelRatio` ends up being something like `1.100000023841858` for example, when
// it's actually 1.1. Ceiling may causes blurriness as the backing canvas image is 1 pixel too
// it's actually 1.1. Ceiling may cause blurriness as the backing canvas image is 1 pixel too
// large for the canvas element size.
this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / this._devicePixelRatio);
this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / this._devicePixelRatio);
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { createPatternCanvas } from './CustomGlyphRasterizer';
describe('CustomGlyphRasterizer', () => {
describe('createPatternCanvas', () => {
it('prefers an offscreen canvas without using the DOM canvas factory', () => {
const expectedCanvas = {} as OffscreenCanvas;
const calls: string[] = [];
const canvas = createPatternCanvas(
2,
3,
(width, height) => {
calls.push(`offscreen:${width}x${height}`);
return expectedCanvas;
},
() => {
calls.push('dom');
return {} as HTMLCanvasElement;
}
);
assert.deepEqual({ canvas, calls }, {
canvas: expectedCanvas,
calls: ['offscreen:2x3']
});
});
it('uses the main-realm DOM canvas factory when OffscreenCanvas is unavailable', () => {
const expectedCanvas = {} as HTMLCanvasElement;
const calls: string[] = [];
const canvas = createPatternCanvas(
4,
5,
undefined,
(width, height) => {
calls.push(`dom:${width}x${height}`);
return expectedCanvas;
}
);
assert.deepEqual({ canvas, calls }, {
canvas: expectedCanvas,
calls: ['dom:4x5']
});
});
});
});
@@ -8,6 +8,29 @@ 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';
type PatternCanvas = HTMLCanvasElement | OffscreenCanvas;
type PatternCanvasFactory = (width: number, height: number) => PatternCanvas;
const createOffscreenPatternCanvas: PatternCanvasFactory | undefined = typeof OffscreenCanvas === 'undefined'
? undefined
: (width, height) => new OffscreenCanvas(width, height);
const createDomPatternCanvas: PatternCanvasFactory = (width, height) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
export function createPatternCanvas(
width: number,
height: number,
offscreenCanvasFactory: PatternCanvasFactory | undefined = createOffscreenPatternCanvas,
domCanvasFactory: PatternCanvasFactory = createDomPatternCanvas
): PatternCanvas {
return offscreenCanvasFactory?.(width, height) ?? domCanvasFactory(width, height);
}
/**
* Try drawing a custom block element or box drawing character, returning whether it was
* successfully drawn.
@@ -458,9 +481,9 @@ function drawPatternChar(
if (!pattern) {
const width = charDefinition[0].length;
const height = charDefinition.length;
const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas');
tmpCanvas.width = width;
tmpCanvas.height = height;
// The atlas canvas can be adopted into another document, so temporary resources must not use
// its mutable ownerDocument.
const tmpCanvas = createPatternCanvas(width, height);
const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d'));
const imageData = new ImageData(width, height);
@@ -0,0 +1,481 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IImage32, decodePng } from '@lunapaint/png-codec';
import test, { expect } from '@playwright/test';
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
type CellSignature = number[];
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
interface ITestTextureAtlasConstructor {
maxAtlasPages: number | undefined;
maxTextureSize: number | undefined;
}
interface ITestAtlasPage {
canvas: HTMLCanvasElement;
}
interface ITestTextureAtlas {
constructor: ITestTextureAtlasConstructor;
pages: ITestAtlasPage[];
pageLayoutVersion: number;
_overflowSizePage?: ITestAtlasPage;
_textureSize: number;
}
interface ITestGlyphRenderer {
_atlasTextures: unknown[];
}
interface ITestRenderer {
_charAtlas?: ITestTextureAtlas;
_glyphRenderer?: {
value?: ITestGlyphRenderer;
};
dimensions: {
device: {
cell: {
width: number;
};
};
};
}
interface ITestRenderService {
_renderer?: {
value?: ITestRenderer;
};
}
interface ITestTerminal extends Terminal {
_core?: {
_renderService?: ITestRenderService;
};
}
interface ITestWebglAddon extends WebglAddon {
_renderer?: ITestRenderer;
}
interface IAtlasEventStats {
removals: number;
maxPages: number;
}
interface ITestWindow extends Window {
Terminal: TestTerminalConstructor;
WebglAddon: TestWebglAddonConstructor;
term: ITestTerminal;
termB?: ITestTerminal;
addon?: ITestWebglAddon;
addonB?: ITestWebglAddon;
atlasEventStats?: IAtlasEventStats;
}
interface IAtlasLimits {
maxAtlasPages: number;
maxTextureSize: number;
}
interface IAtlasState {
pages: number;
atlasTextures: number;
pageLayoutVersion: number;
overflowPageCreated: boolean;
removals: number;
maxPages: number;
}
const NORMAL_HEADER = ' NORMAL_REF_0123456789';
const POST_EVICTION_TEXT = ' AFTER_EVICTION_0123456789';
const NORMAL_HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const POST_EVICTION_COLS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
const TERM_SELECTOR = '#terminal-container .xterm-screen';
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
w.addon = new w.WebglAddon({ preserveDrawingBuffer: true });
w.term.loadAddon(w.addon);
});
const isWebglRenderer = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer;
});
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
}
async function createTerminalB(ctx: ITestContext, cols: number): Promise<void> {
await ctx.page.evaluate(columnCount => {
const w = window as unknown as ITestWindow;
w.termB?.dispose();
document.getElementById('terminal-container-b')?.remove();
const element = document.createElement('div');
element.id = 'terminal-container-b';
document.body.appendChild(element);
w.termB = new w.Terminal({ cols: columnCount, rows: 24, allowProposedApi: true });
w.termB.open(element);
w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true });
w.termB.loadAddon(w.addonB);
}, cols);
}
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
await ctx.page.evaluate(value => new Promise<void>(resolve => {
const w = window as unknown as ITestWindow;
if (!w.termB) {
throw new Error('Terminal B must be created before writing to it');
}
const disposable = w.termB.onRender(() => {
disposable.dispose();
resolve();
});
w.termB.write(value);
}), data);
}
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
const renderPromise = new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
await ctx.proxy.write(data);
await renderPromise;
}
async function refreshAndWaitForRender(ctx: ITestContext, terminal: 'term' | 'termB'): Promise<void> {
await ctx.page.evaluate(name => new Promise<void>(resolve => {
const w = window as unknown as ITestWindow;
const term = w[name];
if (!term) {
throw new Error(`${name} must exist before refreshing it`);
}
const disposable = term.onRender(() => {
disposable.dispose();
resolve();
});
term.refresh(0, term.rows - 1);
}), terminal);
}
async function configureAtlasLimits(ctx: ITestContext): Promise<IAtlasLimits> {
const limits = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
if (!atlas || atlas.constructor.maxAtlasPages === undefined || atlas.constructor.maxTextureSize === undefined) {
return undefined;
}
const result = {
maxAtlasPages: atlas.constructor.maxAtlasPages,
maxTextureSize: atlas.constructor.maxTextureSize
};
atlas.constructor.maxAtlasPages = 4;
atlas.constructor.maxTextureSize = 512;
return result;
});
expect(limits, 'TextureAtlas limits must be initialized').toBeDefined();
return limits!;
}
async function restoreAtlasLimits(ctx: ITestContext, limits: IAtlasLimits | undefined): Promise<void> {
if (!limits) {
return;
}
await ctx.page.evaluate(original => {
const w = window as unknown as ITestWindow;
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
if (atlas) {
atlas.constructor.maxAtlasPages = original.maxAtlasPages;
atlas.constructor.maxTextureSize = original.maxTextureSize;
}
}, limits);
}
async function installAtlasEventStats(ctx: ITestContext): Promise<void> {
const installed = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
if (!atlas || !w.addon) {
return false;
}
const stats: IAtlasEventStats = {
removals: 0,
maxPages: atlas.pages.length
};
w.atlasEventStats = stats;
w.addon.onAddTextureAtlasCanvas(() => {
stats.maxPages = Math.max(stats.maxPages, atlas.pages.length);
});
w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++);
return true;
});
expect(installed, 'atlas event tracking must be installed').toBe(true);
}
async function getAtlasState(ctx: ITestContext): Promise<IAtlasState> {
const state = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const renderer = w.term._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
const glyphRenderer = renderer?._glyphRenderer?.value;
const stats = w.atlasEventStats;
if (!atlas || !glyphRenderer || !stats) {
return undefined;
}
return {
pages: atlas.pages.length,
atlasTextures: glyphRenderer._atlasTextures.length,
pageLayoutVersion: atlas.pageLayoutVersion,
overflowPageCreated: !!atlas._overflowSizePage,
removals: stats.removals,
maxPages: stats.maxPages
};
});
expect(state, 'atlas state must be available').toBeDefined();
return state!;
}
function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string {
const base = 0x4E00;
const range = 0x9FFF - base;
const perRow = Math.max(1, Math.floor(cols / 2));
let result = '';
for (let i = 0; i < count; i++) {
result += String.fromCodePoint(base + ((offset + i) % range));
if ((i + 1) % perRow === 0 && i + 1 < count) {
result += '\r\n';
}
}
return result;
}
async function captureRowSignatures(ctx: ITestContext, selector: string, cols: number, row: number, sampleCols: number[], debugName: string): Promise<CellSignature[]> {
const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined;
const buffer = await ctx.page.locator(selector).screenshot(screenshotOptions);
const frame = {
cols,
rows: 24,
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
};
return sampleCols.map(col => cellSignature(frame, col, row));
}
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
const grid = 4;
const data = frame.decoded.data;
const imageWidth = frame.decoded.width;
const cellWidth = frame.decoded.width / frame.cols;
const cellHeight = frame.decoded.height / frame.rows;
const x0 = (col - 1) * cellWidth;
const y0 = (row - 1) * cellHeight;
const sums = new Array<number>(grid * grid * 4).fill(0);
const counts = new Array<number>(grid * grid).fill(0);
const startX = Math.max(0, Math.floor(x0));
const startY = Math.max(0, Math.floor(y0));
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
for (let y = startY; y < endY; y++) {
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
for (let x = startX; x < endX; x++) {
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
const sub = gy * grid + gx;
const i = (y * imageWidth + x) * 4;
sums[sub * 4] += data[i];
sums[sub * 4 + 1] += data[i + 1];
sums[sub * 4 + 2] += data[i + 2];
sums[sub * 4 + 3] += data[i + 3];
counts[sub]++;
}
}
const signature = new Array<number>(grid * grid * 4).fill(0);
for (let sub = 0; sub < counts.length; sub++) {
if (counts[sub] > 0) {
signature[sub * 4] = sums[sub * 4] / counts[sub];
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
}
}
return signature;
}
function signatureDiff(actual: CellSignature, reference: CellSignature): number {
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
}
return diff;
}
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
const diff = signatureDiff(actual, reference);
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
}
test.describe('atlas page overflow (#6038)', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 90000 });
test('evicts normal pages when the page cap cannot be reduced by merging', async ({ browser }) => {
const ctx = await createTestContext(browser);
const errors: string[] = [];
const onError = (error: Error): void => { errors.push(error.message); };
let limits: IAtlasLimits | undefined;
ctx.page.on('pageerror', onError);
try {
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
limits = await configureAtlasLimits(ctx);
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await createTerminalB(ctx, 80);
await installAtlasEventStats(ctx);
const atlasShared = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
return w.term._core?._renderService?._renderer?.value?._charAtlas === w.termB?._core?._renderService?._renderer?.value?._charAtlas;
});
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
await writeToBAndWaitForRender(ctx, `\x1b[H${NORMAL_HEADER}\x1b[2;1H${POST_EVICTION_TEXT}`);
const headerReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-reference');
const postReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-reference');
await writeToBAndWaitForRender(ctx, '\x1b[2;1H\x1b[2K');
const initialState = await getAtlasState(ctx);
const glyphsPerChunk = 23 * 40 - 1;
for (let chunk = 0; chunk < 24; chunk++) {
await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphsPerChunk, 80, chunk * glyphsPerChunk));
await ctx.page.waitForTimeout(50);
const state = await getAtlasState(ctx);
if (state.removals > 0 || state.pages > state.atlasTextures || errors.length > 0) {
break;
}
}
const evictionState = await getAtlasState(ctx);
expect(evictionState.removals, 'normal atlas pages must be evicted at the page cap').toBeGreaterThan(0);
expect(evictionState.pageLayoutVersion, 'eviction must invalidate renderer models').toBeGreaterThan(initialState.pageLayoutVersion);
expect(evictionState.maxPages, 'atlas pages must stay within renderer texture capacity').toBeLessThanOrEqual(evictionState.atlasTextures);
expect(errors, `renderer must not throw during atlas eviction: ${errors[0] ?? ''}`).toEqual([]);
await writeToBAndWaitForRender(ctx, `\x1b[2;1H${POST_EVICTION_TEXT}`);
const termBHeader = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
return w.termB?.buffer.active.getLine(0)?.translateToString(true);
});
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(NORMAL_HEADER);
const headerAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-after');
const postAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-after');
for (let i = 0; i < NORMAL_HEADER_COLS.length; i++) {
expectSignatureMatches(headerAfter[i], headerReference[i], `existing header col ${NORMAL_HEADER_COLS[i]} changed after eviction`);
}
for (let i = 0; i < POST_EVICTION_COLS.length; i++) {
expectSignatureMatches(postAfter[i], postReference[i], `new text col ${POST_EVICTION_COLS[i]} changed after eviction`);
}
const stableState = await getAtlasState(ctx);
await refreshAndWaitForRender(ctx, 'termB');
const refreshedState = await getAtlasState(ctx);
expect(refreshedState.pageLayoutVersion, 'an unchanged full refresh must not evict again').toBe(stableState.pageLayoutVersion);
expect(refreshedState.removals, 'an unchanged full refresh must not remove more pages').toBe(stableState.removals);
} finally {
ctx.page.off('pageerror', onError);
await restoreAtlasLimits(ctx, limits).catch(() => {});
await ctx.page.close();
}
});
test('evicts before adding an oversized glyph page at the page cap', async ({ browser }) => {
const ctx = await createTestContext(browser);
const errors: string[] = [];
const onError = (error: Error): void => { errors.push(error.message); };
let limits: IAtlasLimits | undefined;
ctx.page.on('pageerror', onError);
try {
await openTerminal(ctx, { cols: 160, rows: 24 });
await loadWebglStrict(ctx);
limits = await configureAtlasLimits(ctx);
await openTerminal(ctx, { cols: 160, rows: 24 });
await loadWebglStrict(ctx);
await installAtlasEventStats(ctx);
let state = await getAtlasState(ctx);
let offset = 0;
for (let chunk = 0; chunk < 120 && state.pages < state.atlasTextures; chunk++) {
const glyphCount = state.pages < state.atlasTextures - 1 ? 200 : 25;
await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphCount, 160, offset));
await ctx.page.waitForTimeout(50);
offset += glyphCount;
state = await getAtlasState(ctx);
}
expect(state.pages, 'normal atlas pages must reach the texture capacity before the oversized glyph').toBe(state.atlasTextures);
expect(state.overflowPageCreated, 'normal glyph filling must not create the oversized page').toBe(false);
expect(errors, `renderer must not fail while filling normal pages: ${errors[0] ?? ''}`).toEqual([]);
await writeAndWaitForRender(ctx, '\x1b[H\x1b[2K\x1b[24;1H');
const sampleCols = [1, 8, 16, 24, 32];
const blankSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-blank');
const joinedLength = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const renderer = w.term._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
if (!renderer || !atlas) {
throw new Error('renderer and atlas must exist');
}
const cellWidth = renderer.dimensions.device.cell.width;
const length = Math.min(w.term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32);
if (length * cellWidth <= atlas._textureSize) {
throw new Error('joined glyph must be wider than a normal atlas page');
}
const marker = 'W'.repeat(length);
w.term.registerCharacterJoiner(text => text.startsWith(marker) ? [[0, length]] : []);
return length;
});
const beforeOversized = await getAtlasState(ctx);
await ctx.proxy.write(`\x1b[H${'W'.repeat(joinedLength)}`);
await ctx.page.waitForTimeout(50);
const oversizedState = await getAtlasState(ctx);
expect(oversizedState.overflowPageCreated, 'the joined glyph must use the oversized atlas page').toBe(true);
expect(oversizedState.removals, 'normal pages must be evicted before adding the oversized page').toBeGreaterThan(beforeOversized.removals);
expect(oversizedState.maxPages, 'oversized page creation must stay within renderer texture capacity').toBeLessThanOrEqual(oversizedState.atlasTextures);
expect(oversizedState.pageLayoutVersion, 'oversized eviction must invalidate renderer models').toBeGreaterThan(beforeOversized.pageLayoutVersion);
expect(errors, `renderer must not throw while adding the oversized page: ${errors[0] ?? ''}`).toEqual([]);
const wideSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-after');
for (let i = 0; i < sampleCols.length; i++) {
expect(signatureDiff(wideSignatures[i], blankSignatures[i]), `wide glyph col ${sampleCols[i]} must render visible pixels`).toBeGreaterThan(14);
}
const stableState = await getAtlasState(ctx);
await refreshAndWaitForRender(ctx, 'term');
const refreshedState = await getAtlasState(ctx);
expect(refreshedState.pageLayoutVersion, 'oversized glyph refresh must settle without another eviction').toBe(stableState.pageLayoutVersion);
expect(refreshedState.removals, 'oversized glyph refresh must not remove more pages').toBe(stableState.removals);
const wideAfterRefresh = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-refreshed');
for (let i = 0; i < sampleCols.length; i++) {
expectSignatureMatches(wideAfterRefresh[i], wideSignatures[i], `wide glyph col ${sampleCols[i]} changed after refresh`);
}
} finally {
ctx.page.off('pageerror', onError);
await restoreAtlasLimits(ctx, limits).catch(() => {});
await ctx.page.close();
}
});
});
@@ -0,0 +1,288 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { decodePng, type IImage32 } from '@lunapaint/png-codec';
import test, { expect } from '@playwright/test';
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
type CellSignature = number[];
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
interface ITestTextureAtlas {
readonly pages: ReadonlyArray<{ readonly canvas: HTMLCanvasElement }>;
}
interface ITestGlyphRenderer {
readonly _atlasTextures: ReadonlyArray<unknown>;
}
interface ITestRenderer {
_charAtlas?: ITestTextureAtlas;
_glyphRenderer?: {
value?: ITestGlyphRenderer;
};
}
interface ITestRenderService {
_renderer?: {
value?: ITestRenderer;
};
}
interface ITestTerminal extends Terminal {
_core?: {
_renderService?: ITestRenderService;
};
}
interface ITestWebglAddon extends WebglAddon {
_renderer?: ITestRenderer;
}
interface IAtlasEventStats {
additions: number;
maxPages: number;
}
interface ITestWindow {
WebglAddon: TestWebglAddonConstructor;
term: ITestTerminal;
addon?: ITestWebglAddon;
atlasEventStats?: IAtlasEventStats;
}
interface IAtlasState {
additions: number;
atlasTextures: number;
maxPages: number;
}
const REFERENCE_LINE = 'Ref0123456789ABCDEF';
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
const TERM_SELECTOR = '#terminal-container .xterm-screen';
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
w.addon = new w.WebglAddon({ preserveDrawingBuffer: true });
w.term.loadAddon(w.addon);
});
const isWebglRenderer = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer;
});
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
}
async function installAtlasEventStats(ctx: ITestContext): Promise<void> {
const installed = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
if (!atlas || !w.addon) {
return false;
}
const stats: IAtlasEventStats = {
additions: 0,
maxPages: atlas.pages.length
};
w.atlasEventStats = stats;
w.addon.onAddTextureAtlasCanvas(() => {
stats.additions++;
stats.maxPages = Math.max(stats.maxPages, atlas.pages.length);
});
return true;
});
expect(installed, 'atlas event tracking must be installed').toBe(true);
}
async function getAtlasState(ctx: ITestContext): Promise<IAtlasState> {
const state = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
const renderer = w.term._core?._renderService?._renderer?.value;
const glyphRenderer = renderer?._glyphRenderer?.value;
const stats = w.atlasEventStats;
if (!glyphRenderer || !stats) {
return undefined;
}
return {
additions: stats.additions,
atlasTextures: glyphRenderer._atlasTextures.length,
maxPages: stats.maxPages
};
});
expect(state, 'atlas state must be available').toBeDefined();
return state!;
}
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
const renderPromise = new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
await ctx.proxy.write(data);
await renderPromise;
}
async function refreshAndWaitForRender(ctx: ITestContext): Promise<void> {
const renderPromise = new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
await renderPromise;
}
function generateSyntheticTuiFrames(frameCount: number, cols: number, rows: number): string[] {
const spinner = ['|', '/', '-', '\\'];
const words = ['reading', 'rendering', 'atlas', 'terminal', 'buffer', 'tokens', 'diff', 'patch'];
const frames: string[] = [];
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
const lines = [
`+${'-'.repeat(cols - 2)}+`,
`|${(` ${spinner[frameIndex % spinner.length]} Copilot is working`).padEnd(cols - 2).slice(0, cols - 2)}|`,
`+${'-'.repeat(cols - 2)}+`
];
for (let row = 3; row < rows - 1; row++) {
const foreground = 31 + ((frameIndex + row) % 6);
const text = `${words[(frameIndex + row) % words.length]} ${frameIndex}:${row}`;
lines.push(`\x1b[${foreground}m${text}\x1b[0m`);
}
lines.push(`\x1b[2mtokens ${1000 + frameIndex * 37}\x1b[0m`);
frames.push(`\x1b[2J\x1b[H${lines.join('\r\n')}`);
}
return frames;
}
function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string {
const base = 0x4E00;
const range = 0x9FFF - base;
const perRow = Math.max(1, Math.floor(cols / 2));
let result = '';
for (let i = 0; i < count; i++) {
result += String.fromCodePoint(base + ((offset + i) % range));
if ((i + 1) % perRow === 0 && i + 1 < count) {
result += '\r\n';
}
}
return result;
}
async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[], debugName: string): Promise<CellSignature[]> {
const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined;
const buffer = await ctx.page.locator(TERM_SELECTOR).screenshot(screenshotOptions);
const frame = {
cols: await ctx.proxy.cols,
rows: await ctx.proxy.rows,
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
};
return cols.map(col => cellSignature(frame, col, row));
}
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
const grid = 4;
const data = frame.decoded.data;
const imageWidth = frame.decoded.width;
const cellWidth = frame.decoded.width / frame.cols;
const cellHeight = frame.decoded.height / frame.rows;
const x0 = (col - 1) * cellWidth;
const y0 = (row - 1) * cellHeight;
const sums = new Array<number>(grid * grid * 4).fill(0);
const counts = new Array<number>(grid * grid).fill(0);
const startX = Math.max(0, Math.floor(x0));
const startY = Math.max(0, Math.floor(y0));
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
for (let y = startY; y < endY; y++) {
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
for (let x = startX; x < endX; x++) {
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
const sub = gy * grid + gx;
const i = (y * imageWidth + x) * 4;
sums[sub * 4] += data[i];
sums[sub * 4 + 1] += data[i + 1];
sums[sub * 4 + 2] += data[i + 2];
sums[sub * 4 + 3] += data[i + 3];
counts[sub]++;
}
}
const signature = new Array<number>(grid * grid * 4).fill(0);
for (let sub = 0; sub < counts.length; sub++) {
if (counts[sub] > 0) {
signature[sub * 4] = sums[sub * 4] / counts[sub];
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
}
}
return signature;
}
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
}
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
}
test.describe('WebGL atlas TUI stress (#6038)', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
test.describe.configure({ timeout: 90000 });
test('primary-buffer glyphs survive alternate-buffer redraws and atlas page churn', async ({ browser }) => {
const ctx = await createTestContext(browser);
const errors: string[] = [];
const onError = (error: Error): void => { errors.push(error.message); };
ctx.page.on('pageerror', onError);
try {
await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 });
await loadWebglStrict(ctx);
await installAtlasEventStats(ctx);
await writeAndWaitForRender(ctx, REFERENCE_LINE);
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-reference');
await writeAndWaitForRender(ctx, '\x1b[?1049h');
for (const frame of generateSyntheticTuiFrames(30, 80, 24)) {
await writeAndWaitForRender(ctx, frame);
}
const glyphsPerViewport = 23 * 40 - 1;
for (let chunk = 0; chunk < 4; chunk++) {
await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${generateUniqueGlyphFlood(glyphsPerViewport, 80, chunk * glyphsPerViewport)}`);
}
await writeAndWaitForRender(ctx, '\x1b[?1049l');
await refreshAndWaitForRender(ctx);
const state = await getAtlasState(ctx);
expect(state.additions, 'stress workload must add atlas pages').toBeGreaterThan(0);
expect(state.maxPages, 'stress workload must exercise multiple atlas pages').toBeGreaterThan(1);
expect(state.maxPages, 'atlas pages must remain within renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures);
expect(errors, `renderer must not throw during TUI stress: ${errors[0] ?? ''}`).toEqual([]);
const primaryText = await ctx.page.evaluate(() => {
const w = window as unknown as ITestWindow;
return w.term.buffer.active.getLine(0)?.translateToString(true);
});
expect(primaryText, 'primary buffer must still contain the reference line').toBe(REFERENCE_LINE);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-after');
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], `primary-buffer glyph at col ${REFERENCE_COLS[i]} changed after TUI stress`);
}
} finally {
ctx.page.off('pageerror', onError);
await ctx.page.close();
}
});
});
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import test, { expect } from '@playwright/test';
import { platform } from 'os';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
interface ITestRendererWithAtlasCanvas {
readonly _charAtlas?: {
readonly _tmpCanvas?: HTMLCanvasElement;
};
}
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
const renderPromise = new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
await ctx.proxy.write(data);
await renderPromise;
}
test.describe('WebGL custom glyphs', () => {
if (platform() === 'linux') {
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
}
test('pattern glyphs render after the terminal is adopted into another document', async ({ browser }) => {
const ctx = await createTestContext(browser);
const errors: string[] = [];
const onError = (error: Error): void => { errors.push(error.message); };
ctx.page.on('pageerror', onError);
try {
await openTerminal(ctx);
await ctx.page.evaluate(`
window.addon = new window.WebglAddon({ customGlyphs: true });
window.term.loadAddon(window.addon);
`);
await ctx.page.evaluate(() => {
const frame = document.createElement('iframe');
document.body.appendChild(frame);
const auxiliaryDocument = frame.contentDocument;
if (!auxiliaryDocument?.body || !window.term.element) {
throw new Error('Auxiliary document and terminal element must be available');
}
const renderer = window.term._core?._renderService?._renderer?.value as ITestRendererWithAtlasCanvas | undefined;
const atlasCanvas = renderer?._charAtlas?._tmpCanvas;
if (!atlasCanvas) {
throw new Error('Texture atlas canvas must be available');
}
// Model the atlas state after an uncached glyph has attached the measurement canvas.
window.term.element.appendChild(atlasCanvas);
auxiliaryDocument.body.appendChild(window.term.element);
if (atlasCanvas.ownerDocument !== auxiliaryDocument) {
throw new Error('Texture atlas canvas must be adopted into the auxiliary document');
}
auxiliaryDocument.createElement = () => {
throw new Error('Not allowed to create elements in the auxiliary document');
};
});
await writeAndWaitForRender(ctx, '\u2591');
await writeAndWaitForRender(ctx, 'X');
const line = await ctx.page.evaluate(() => window.term.buffer.active.getLine(0)?.translateToString(true));
expect(errors, `renderer must not create pattern canvases in the auxiliary document: ${errors[0] ?? ''}`).toEqual([]);
expect(line).toBe('\u2591X');
} finally {
ctx.page.off('pageerror', onError);
await ctx.page.close();
}
});
});
@@ -0,0 +1,282 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IImage32, decodePng } from '@lunapaint/png-codec';
import test, { expect } from '@playwright/test';
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
type CellSignature = number[];
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
interface ITestTextureAtlasConstructor {
maxAtlasPages: number | undefined;
}
interface ITestTextureAtlas {
constructor: ITestTextureAtlasConstructor;
}
interface ITestRenderer {
_charAtlas?: ITestTextureAtlas;
}
interface ITestRenderService {
_renderer?: {
value?: ITestRenderer;
};
}
interface ITestTerminal extends Terminal {
_core?: {
_renderService?: ITestRenderService;
};
}
interface ITestWebglAddon extends WebglAddon {
_renderer?: ITestRenderer;
}
declare global {
interface Window { // eslint-disable-line @typescript-eslint/naming-convention
Terminal: TestTerminalConstructor;
WebglAddon: TestWebglAddonConstructor;
term: ITestTerminal;
termB?: ITestTerminal;
addon?: ITestWebglAddon;
addonB?: ITestWebglAddon;
}
}
const HEADER = ' HEADER_REF_0123456789';
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
window.addon = new window.WebglAddon({ preserveDrawingBuffer: true });
window.term.loadAddon(window.addon);
});
const isWebglRenderer = await ctx.page.evaluate(() => {
return !!window.addon && window.term?._core?._renderService?._renderer?.value === window.addon._renderer;
});
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
}
async function createTerminalB(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
window.termB?.dispose();
document.getElementById('terminal-container-b')?.remove();
const el = document.createElement('div');
el.id = 'terminal-container-b';
document.body.appendChild(el);
window.termB = new window.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
window.termB.open(el);
window.addonB = new window.WebglAddon({ preserveDrawingBuffer: true });
window.termB.loadAddon(window.addonB);
});
}
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
await ctx.page.evaluate(d => new Promise<void>(resolve => {
const termB = window.termB;
if (!termB) {
throw new Error('Terminal B must be created before writing to it');
}
const disposable = termB.onRender(() => {
disposable.dispose();
resolve();
});
termB.write(d);
}), data);
}
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
const renderPromise = new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
await ctx.proxy.write(data);
await renderPromise;
}
async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promise<void> {
const applied = await ctx.page.evaluate(max => {
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
if (!atlas) {
return false;
}
atlas.constructor.maxAtlasPages = max;
return true;
}, maxAtlasPages);
expect(applied, 'should be able to set TextureAtlas.maxAtlasPages').toBe(true);
}
async function resetMaxAtlasPages(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
if (atlas) {
atlas.constructor.maxAtlasPages = undefined;
}
});
}
function generateColoredAsciiFlood(cells: number, offset: number = 0): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < cells; i++) {
const ch = letters[(offset + i) % letters.length];
const fg = (offset + i) % 256;
const bg = (offset + i * 7) % 256;
out += `\x1b[38;5;${fg}m\x1b[48;5;${bg}m${ch}`;
if ((i + 1) % 78 === 0 && i + 1 < cells) {
out += '\x1b[0m\r\n';
}
}
return out + '\x1b[0m';
}
async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[]): Promise<CellSignature[]> {
const screenshotOptions = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/shared-atlas-term-b.png' } : undefined;
const buffer = await ctx.page.locator(TERM_B_SELECTOR).screenshot(screenshotOptions);
const frame = {
cols: 80,
rows: 24,
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
};
return cols.map(col => cellSignature(frame, col, row));
}
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
const grid = 4;
const data = frame.decoded.data;
const imageWidth = frame.decoded.width;
const cellWidth = frame.decoded.width / frame.cols;
const cellHeight = frame.decoded.height / frame.rows;
const x0 = (col - 1) * cellWidth;
const y0 = (row - 1) * cellHeight;
const sums = new Array<number>(grid * grid * 4).fill(0);
const counts = new Array<number>(grid * grid).fill(0);
const startX = Math.max(0, Math.floor(x0));
const startY = Math.max(0, Math.floor(y0));
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
for (let y = startY; y < endY; y++) {
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
for (let x = startX; x < endX; x++) {
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
const sub = gy * grid + gx;
const i = (y * imageWidth + x) * 4;
sums[sub * 4] += data[i];
sums[sub * 4 + 1] += data[i + 1];
sums[sub * 4 + 2] += data[i + 2];
sums[sub * 4 + 3] += data[i + 3];
counts[sub]++;
}
}
const signature = new Array<number>(grid * grid * 4).fill(0);
for (let sub = 0; sub < counts.length; sub++) {
if (counts[sub] > 0) {
signature[sub * 4] = sums[sub * 4] / counts[sub];
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
}
}
return signature;
}
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
}
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
}
test.describe('shared-atlas garble across terminals (#6038)', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 60000 });
test('keeps a second terminal rendering correctly after shared atlas page merges', async ({ browser }) => {
const ctx = await createTestContext(browser);
try {
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await createTerminalB(ctx);
const atlasShared = await ctx.page.evaluate(() => {
return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas;
});
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
await setMaxAtlasPages(ctx, 4);
await writeToBAndWaitForRender(ctx, HEADER);
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
for (let c = 0; c < 10; c++) {
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
}
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true));
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER);
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
for (let i = 0; i < HEADER_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
}
} finally {
await resetMaxAtlasPages(ctx).catch(() => {});
await ctx.page.close();
}
});
test('keeps a second terminal rendering correctly after terminal A clears the shared atlas', async ({ browser }) => {
const ctx = await createTestContext(browser);
try {
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await createTerminalB(ctx);
const atlasShared = await ctx.page.evaluate(() => {
return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas;
});
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
await writeToBAndWaitForRender(ctx, HEADER);
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
// Terminal A alone clears the shared atlas, as embedders do to force a redraw (VS Code calls
// this for every terminal on OS resume). This wipes the pages that terminal B's model still
// holds texture coords into.
await ctx.proxy.clearTextureAtlas();
// Terminal A repopulates the cleared pages with a different glyph set, so B's stale coords now
// resolve to whatever A rasterized into those slots.
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78));
// Nudge B into a render that does not touch row 1, mirroring a terminal that is idle but
// visible. Row 1 must still be drawn from a model B rebuilt against the current atlas.
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true));
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER);
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
for (let i = 0; i < HEADER_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas clear`);
}
} finally {
await ctx.page.close();
}
});
});
+2 -2
View File
@@ -23,12 +23,12 @@ declare module '@xterm/addon-webgl' {
public readonly onChangeTextureAtlas: IEvent<HTMLCanvasElement>;
/**
* An event that is fired when the a new page is added to the texture atlas.
* An event that is fired when a new page is added to the texture atlas.
*/
public readonly onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
/**
* An event that is fired when the a page is removed from the texture atlas.
* An event that is fired when a page is removed from the texture atlas.
*/
public readonly onRemoveTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
+5 -2
View File
@@ -19,14 +19,17 @@ for (const addon of addons) {
// Demo job - This requires the others to be built so it's not included when building all
// jobs.push(createJob('demo-client', [`--demo-client`]));
await Promise.all(jobs.map((job, i) => {
const codes = await Promise.all(jobs.map(job => {
return new Promise(r => {
job.cp.on('exit', code => {
log(`Finished \x1b[32m${job.name}\x1b[0m${code ? ' \x1b[31mwith errors\x1b[0m' : ''}`);
r(code);
r(code ?? 1);
});
});
}));
if (codes.some(code => code !== 0)) {
process.exit(1);
}
/**
* @param {string} message
+2 -2
View File
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
import { basename, dirname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const nodeModulesPath = resolve(repoRoot, 'node_modules');
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -13,7 +13,7 @@ const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
/** @param {string} message */
function log(message) {
console.info(`[setup-fast] ${message}`);
console.info(`[setup-full] ${message}`);
}
/** @param {string[]} args */
+12 -3
View File
@@ -12,7 +12,7 @@ let argv = process.argv.slice(2);
let suiteFilter = undefined;
while (argv.some(e => e.startsWith('--suite='))) {
const i = argv.findIndex(e => e.startsWith('--suite='));
const match = argv[i].match(/--suite=(?<suitename>.+)/)
const match = argv[i].match(/--suite=(?<suitename>.+)/);
suiteFilter = match?.groups?.suitename ?? undefined;
argv.splice(i, 1);
}
@@ -42,12 +42,18 @@ if (suiteFilter) {
configs = configs.filter(e => e.name === suiteFilter);
}
if (suiteFilter && configs.length === 0) {
console.error(`Unknown suite: ${suiteFilter}`);
process.exit(1);
}
function npmBinScript(script) {
return path.resolve(__dirname, `../node_modules/.bin/` + (process.platform === 'win32' ?
`${script}.cmd` : script));
}
async function run() {
let exitCode = 0;
for (const config of configs) {
const command = npmBinScript('playwright');
const args = ['test', '-c', config.path, ...argv];
@@ -62,10 +68,13 @@ async function run() {
if (run.error) {
console.error(run.error);
process.exit(run.status ?? -1);
process.exit(run.status ?? 1);
}
process.exit(run.status);
if (run.status) {
exitCode = run.status;
}
}
process.exit(exitCode);
}
run();
+1 -1
View File
@@ -177,7 +177,7 @@ const ENC = {
}
function printMenu() {
console.log('\x1b[2J\x1b\[HTest mouse reports [Ctrl-C to exit]');
console.log('\x1b[2J\x1b[HTest mouse reports [Ctrl-C to exit]');
console.log();
console.log(' Selected protocol [Ctrl-A to switch]');
const protocols = Object.keys(PROTOCOLS);
+2 -4
View File
@@ -21,7 +21,7 @@ if (process.argv.length > 2) {
flagArgs = args.filter(e => e.startsWith('--'));
// ability to inject particular test files via
// npm run test [testFileA testFileB ...]
files = args.filter(e => !e.startsWith('--'));
const files = args.filter(e => !e.startsWith('--'));
if (files.length) {
testFiles = files;
}
@@ -39,8 +39,6 @@ if (checkCoverage) {
...testFiles,
...flagArgs
];
console.info('executable', executable);
console.info('args', args);
const run = cp.spawnSync(
executable,
args,
@@ -51,7 +49,7 @@ if (checkCoverage) {
stdio: 'inherit'
}
);
process.exit(run.status);
process.exit(run.status ?? -1);
}
const run = cp.spawnSync(
@@ -67,6 +67,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
dlIip.appendChild(dtIip);
this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)');
this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)');
this._addDdWithButton(dlIip, 'image-demo5', 'rose (webp File)');
this._addDdWithButton(dlIip, 'image-demo6', 'kimono (avif MultipartFile)');
container.appendChild(dlIip);
// Kitty demos
@@ -173,11 +175,15 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
document.getElementById('image-demo2')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
document.getElementById('image-demo3')!.addEventListener('click',
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png'));
document.getElementById('image-demo4')!.addEventListener('click',
iipDemoMulti('https://raw.githubusercontent.com/jerch/xterm-wasm-parts/master/fixtures/qoi/dice.qoi'));
iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/dice.qoi'));
document.getElementById('image-demo5')!.addEventListener('click',
iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/1_webp_a.webp'));
document.getElementById('image-demo6')!.addEventListener('click',
iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/kimono.crop.avif'));
document.getElementById('image-demo-kitty1')!.addEventListener('click',
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
kittyDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png'));
// demo for image retrieval API
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
@@ -197,11 +203,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
const x = pos[0] - 1;
const y = pos[1] - 1;
const canvas = ev.shiftKey
// ctrl+shift+click: get single tile
? imageAddon.extractTileAtBufferCell(x, this._terminal.buffer.active.viewportY + y)
// ctrl+click: get original image
: imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
// ctrl+click: get original image
const canvas = imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
canvas?.toBlob(data => data && window.open(URL.createObjectURL(data), '_blank'));
});
}
@@ -11,8 +11,35 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
public readonly label = 'webgl';
private _textureAtlasContainer!: HTMLElement;
private _stressRunning = false;
private _scrollOutput: HTMLInputElement | undefined;
public build(container: HTMLElement): void {
const stressButtons = document.createElement('div');
const stressStart = document.createElement('button');
stressStart.id = 'stress-start';
stressStart.textContent = 'atlas stress start';
stressStart.addEventListener('click', () => this._stress());
stressButtons.appendChild(stressStart);
const stressStop = document.createElement('button');
stressStop.id = 'stress-stop';
stressStop.textContent = 'atlas stress stop';
stressStop.addEventListener('click', () => this._stressRunning = false);
stressButtons.appendChild(stressStop);
container.appendChild(stressButtons);
const stressOptions = document.createElement('div');
this._scrollOutput = document.createElement('input');
this._scrollOutput.type = 'checkbox';
this._scrollOutput.id = 'scroll-output';
stressOptions.appendChild(this._scrollOutput);
const scrollOutputLabel = document.createElement('label');
scrollOutputLabel.htmlFor = 'scroll-output';
scrollOutputLabel.textContent = 'Scroll output';
stressOptions.appendChild(scrollOutputLabel);
container.appendChild(stressOptions);
const zoomCheckbox = document.createElement('input');
zoomCheckbox.type = 'checkbox';
zoomCheckbox.id = 'texture-atlas-zoom';
@@ -48,4 +75,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
canvas.style.width = `${canvas.width / dpr}px`;
canvas.style.height = `${canvas.height / dpr}px`;
}
private async _stress(): Promise<void> {
const TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
if (this._stressRunning) {
return;
}
this._stressRunning = true;
const scrollOutput = !!(this._scrollOutput?.checked);
for (let r = 0; r < 256; r += 2) {
for (let g = 0; g < 256; g += 2) {
let s: string[] = [];
for (let b = 0; b < 256; b += 2) {
if (!this._stressRunning) {
return;
}
const rbg = `RGB: ${[r, g, b]}`;
s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`);
if (s.length >= 16) {
if (scrollOutput) {
await new Promise<void>(r => this._terminal.write(s.join('\r\n') + '\r\n', r));
} else {
await new Promise<void>(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r));
}
s = [];
}
}
}
}
}
}
+141 -172
View File
@@ -663,9 +663,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -680,9 +680,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -697,9 +697,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -714,9 +714,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -731,9 +731,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -748,9 +748,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -765,9 +765,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -782,9 +782,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -799,9 +799,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -816,9 +816,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -833,9 +833,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -850,9 +850,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -867,9 +867,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -884,9 +884,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -901,9 +901,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -918,9 +918,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -935,9 +935,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -952,9 +952,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -969,9 +969,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -986,9 +986,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -1003,9 +1003,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -1020,9 +1020,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -1037,9 +1037,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -1054,9 +1054,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -1071,9 +1071,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -1088,9 +1088,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -3061,16 +3061,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -3519,15 +3519,15 @@
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
@@ -3869,9 +3869,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3882,32 +3882,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@@ -4308,9 +4308,9 @@
}
},
"node_modules/express-ws/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4351,9 +4351,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -4749,9 +4749,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5795,9 +5795,9 @@
"license": "MIT"
},
"node_modules/mocha/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6689,16 +6689,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -6932,27 +6922,6 @@
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -7078,13 +7047,13 @@
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/serve-static": {
@@ -7158,9 +7127,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8006,9 +7975,9 @@
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8483,9 +8452,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
+6 -3
View File
@@ -11,6 +11,9 @@
"workspaces": [
"addons/*"
],
"overrides": {
"serialize-javascript": "^7.0.3"
},
"keywords": [
"cli",
"command-line",
@@ -61,9 +64,9 @@
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
"test-integration-webkit": "node ./bin/test_integration.js --workers=75% \"--project=WebKit\"",
"test-integration-debug": "node ./bin/test_integration.js --workers=1 --headed --timeout=30000",
"benchmark": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json",
"benchmark-baseline": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
"benchmark-eval": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
"benchmark": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json",
"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 src/*.ts addons/**/src/*.ts",
"prepackage": "npm run build",
+6 -9
View File
@@ -102,7 +102,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
private _keyDownHandled: boolean = false;
/**
* Records whether a keydown event has occured since the last keyup event, i.e. whether a key
* Records whether a keydown event has occurred since the last keyup event, i.e. whether a key
* is currently "pressed".
*/
private _keyDownSeen: boolean = false;
@@ -492,6 +492,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// https://issuetracker.google.com/issues/260170397
this.textarea.setAttribute('aria-multiline', 'false');
}
this.textarea.setAttribute('autocomplete', 'off');
this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false');
@@ -1072,10 +1073,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
* Clear the entire buffer, making the prompt line the new first line.
*/
public clear(): void {
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
// Don't clear if it's already clear
return;
}
this.buffer.clearAllMarkers();
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
this.buffer.lines.length = 1;
@@ -1140,13 +1137,13 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
switch (type) {
case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS:
const canvasWidth = this._renderService.dimensions.css.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.css.canvas.height.toFixed(0);
const canvasWidth = this._renderService.dimensions.device.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.device.canvas.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`);
break;
case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS:
const cellWidth = this._renderService.dimensions.css.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.css.cell.height.toFixed(0);
const cellWidth = this._renderService.dimensions.device.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.device.cell.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`);
break;
}
+1 -3
View File
@@ -31,9 +31,7 @@ export class RenderDebouncer implements IRenderDebouncerWithCallback {
public addRefreshCallback(callback: FrameRequestCallback): number {
this._refreshCallbacks.push(callback);
if (this._animationFrame === undefined) {
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh());
}
this._animationFrame ??= this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh());
return this._animationFrame;
}
+2 -2
View File
@@ -31,7 +31,7 @@ export function getCoordsRelativeToElement(window: Pick<Window, 'getComputedStyl
* select that cell and the right half will select the next cell.
*/
export function getCoords(window: Pick<Window, 'getComputedStyle'>, event: Pick<MouseEvent, 'clientX' | 'clientY'>, element: HTMLElement, colCount: number, rowCount: number, hasValidCharSize: boolean, cssCellWidth: number, cssCellHeight: number, isSelection?: boolean): [number, number] | undefined {
// Coordinates cannot be measured if there are no valid
// Coordinates cannot be measured if there is no valid character size.
if (!hasValidCharSize) {
return undefined;
}
@@ -41,7 +41,7 @@ export function getCoords(window: Pick<Window, 'getComputedStyle'>, event: Pick<
coords[1] = Math.ceil(coords[1] / cssCellHeight);
// Ensure coordinates are within the terminal viewport. Note that selections
// need an addition point of precision to cover the end point (as characters
// need an additional point of precision to cover the end point (as characters
// cover half of one char and half of the next).
coords[0] = Math.min(Math.max(coords[0], 1), colCount + (isSelection ? 1 : 0));
coords[1] = Math.min(Math.max(coords[1], 1), rowCount);
+14 -4
View File
@@ -536,9 +536,14 @@ export class DomRenderer extends Disposable implements IRenderer {
for (let y = start; y <= end; y++) {
const row = y + buffer.ydisp;
const rowElement = this._rowElements[y];
if (!rowElement) {
continue;
}
const lineData = buffer.lines.get(row);
if (!rowElement || !lineData) {
break;
if (!lineData) {
rowElement.replaceChildren();
this._setRowBlinkState(y, false);
continue;
}
rowElement.replaceChildren(
...this._rowFactory.createRow(
@@ -610,9 +615,14 @@ export class DomRenderer extends Disposable implements IRenderer {
for (let i = y; i <= y2; ++i) {
const row = i + buffer.ydisp;
const rowElement = this._rowElements[i];
if (!rowElement) {
continue;
}
const bufferline = buffer.lines.get(row);
if (!rowElement || !bufferline) {
break;
if (!bufferline) {
rowElement.replaceChildren();
this._setRowBlinkState(i, false);
continue;
}
rowElement.replaceChildren(
...this._rowFactory.createRow(
@@ -6,16 +6,14 @@
import jsdom = require('jsdom');
import { assert } from 'chai';
import { DomRendererRowFactory } from './DomRendererRowFactory';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants';
import { 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/buffer/Types';
import { CellData } from '../../../common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test';
import { TestWidthCache } from './WidthCache.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -517,7 +515,7 @@ describe('DomRendererRowFactory', () => {
}
function createEmptyLineData(cols: number): IBufferLine {
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
const lineData = new BufferLine(cols);
for (let i = 0; i < cols; i++) {
lineData.setCell(i, NULL_CELL_DATA);
}
@@ -64,6 +64,13 @@ describe('SelectionModel', () => {
assert.deepEqual(model.finalSelectionStart, undefined);
assert.deepEqual(model.finalSelectionEnd, undefined);
});
it('should reset selection start to origin when start row is trimmed', () => {
model.selectionStart = [50, 0];
model.selectionEnd = [10, 2];
assert.equal(model.handleTrim(1), true);
assert.deepEqual(model.finalSelectionStart, [0, 0]);
assert.deepEqual(model.finalSelectionEnd, [10, 1]);
});
});
describe('finalSelectionStart', () => {
+3 -2
View File
@@ -135,9 +135,10 @@ export class SelectionModel {
return true;
}
// If the selection start is trimmed, ensure the start column is 0.
// If the selection start row is trimmed away, reset to the buffer origin.
if (this.selectionStart && this.selectionStart[1] < 0) {
this.selectionStart[1] = 0;
this.selectionStart = [0, 0];
return true;
}
return false;
}
@@ -7,12 +7,10 @@ import { assert } from 'chai';
import { ICharacterJoinerService } from './Services';
import { CharacterJoinerService } from './CharacterJoinerService';
import { BufferLine } from '../../common/buffer/BufferLine';
import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache';
import { IBufferLine } from '../../common/buffer/Types';
import { CellData } from '../../common/buffer/CellData';
import { MockBufferService, createCellData } from '../../common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService;
@@ -25,7 +23,7 @@ describe('CharacterJoinerService', () => {
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
lines.set(3, lineData([['no joined ranges']]));
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
lines.set(4, new BufferLine(0));
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]);
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
@@ -270,7 +268,7 @@ describe('CharacterJoinerService', () => {
type IPartialLineData = ([string] | [string, number]);
function lineData(data: IPartialLineData[]): IBufferLine {
const tline = new BufferLine(TEST_STRING_CACHE, 0);
const tline = new BufferLine(0);
for (let i = 0; i < data.length; ++i) {
const line = data[i][0];
const attr = (data[i][1] || 0) as number;
@@ -100,6 +100,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
const ranges: [number, number][] = [];
const lineStr = line.translateToString(true);
const trimmedLength = line.getTrimmedLength();
// Because some cells can be represented by multiple javascript characters,
// we track the cell and the string indexes separately. This allows us to
@@ -111,7 +112,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
let rangeAttrFG = line.getFg(0);
let rangeAttrBG = line.getBg(0);
for (let x = 0; x < line.getTrimmedLength(); x++) {
for (let x = 0; x < trimmedLength; x++) {
line.loadCell(x, this._workCell);
if (this._workCell.getWidth() === 0) {
@@ -147,7 +148,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
}
// Process any trailing ranges.
if (this._bufferService.cols - rangeStartColumn > 1) {
if (trimmedLength - rangeStartColumn > 1) {
const joinedRanges = this._getJoinedRanges(
lineStr,
rangeStartStringIndex,
@@ -216,7 +217,8 @@ export class CharacterJoinerService implements ICharacterJoinerService {
return;
}
for (let x = startCol; x < this._bufferService.cols; x++) {
const trimmedLength = line.getTrimmedLength();
for (let x = startCol; x < trimmedLength; x++) {
const width = line.getWidth(x);
const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length;
@@ -264,7 +266,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
// If there is still a range left at the end, it must extend all the way to
// the end of the line.
if (currentRange) {
currentRange[1] = this._bufferService.cols;
currentRange[1] = trimmedLength;
}
}
+94 -1
View File
@@ -176,7 +176,7 @@ describe('MouseService _triggerMouseEvent', () => {
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)
// suppress mouse reports if we are out of addressable range (max. 222)
assert.deepEqual(toBytes(reports.pop()), []);
} else {
assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]);
@@ -351,3 +351,96 @@ describe('MouseService mouseEventsRequireAlt', () => {
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
});
});
describe('MouseService multi-window', () => {
interface IRecordingDocument {
doc: Document;
added: string[];
removed: string[];
listeners: { [type: string]: EventListener[] };
}
function createRecordingDocument(): IRecordingDocument {
const added: string[] = [];
const removed: string[] = [];
const listeners: { [type: string]: EventListener[] } = {};
const doc = {
addEventListener: (type: string, listener: EventListener) => {
added.push(type);
(listeners[type] ??= []).push(listener);
},
removeEventListener: (type: string) => removed.push(type)
} as any;
return { doc, added, removed, listeners };
}
it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => {
const mouseStateService = new MouseStateService();
const optionsService = new OptionsService({});
const mouseService = new MouseService(
new MockRenderService(),
{
getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 })
} as any,
mouseStateService,
{
triggerDataEvent: () => {},
triggerBinaryEvent: () => {},
decPrivateModes: { applicationCursorKeys: false }
} as any,
bufferService,
optionsService,
new TestSelectionService(),
logService,
new MockCoreBrowserService()
);
// Simulate a terminal that was opened with one document but whose element now lives in
// another document (eg. it was moved to a child window)
const openDocument = createRecordingDocument();
const childDocument = createRecordingDocument();
const elementListeners: { [type: string]: EventListener[] } = {};
const element = {
classList: {
add: () => {},
remove: () => {},
contains: () => false
},
addEventListener: (type: string, listener: EventListener) => {
(elementListeners[type] ??= []).push(listener);
},
removeEventListener: () => {},
ownerDocument: childDocument.doc
} as any;
mouseService.bindMouse({
element,
screenElement: createTestMouseTargetElement(),
document: openDocument.doc
}, disposable => disposable, () => {});
mouseStateService.activeProtocol = 'ANY';
elementListeners['mousedown'][0]({
type: 'mousedown',
button: 0,
buttons: 1,
altKey: false,
ctrlKey: false,
shiftKey: false,
preventDefault: () => {}
} as any);
assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']);
assert.deepEqual(openDocument.added, []);
childDocument.listeners['mouseup'][0]({
type: 'mouseup',
button: 0,
buttons: 0,
altKey: false,
ctrlKey: false,
shiftKey: false
} as any);
assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']);
assert.deepEqual(openDocument.removed, []);
});
});
+18 -27
View File
@@ -7,7 +7,7 @@ import { addDisposableListener } from '../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 { DisposableStore, MutableDisposable, toDisposable } from '../../common/Lifecycle';
import { DisposableStore, MutableDisposable } from '../../common/Lifecycle';
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch';
@@ -21,6 +21,8 @@ interface IMouseBindContext {
readonly target: IMouseServiceTarget;
readonly focus: () => void;
readonly requestedEvents: RequestedMouseEvents;
readonly mouseupListener: MutableDisposable<IDisposable>;
readonly mousedragListener: MutableDisposable<IDisposable>;
}
export class MouseService implements IMouseService {
@@ -61,7 +63,11 @@ export class MouseService implements IMouseService {
mousedrag: null,
mousemove: null
};
const ctx: IMouseBindContext = { target, focus, requestedEvents };
const mouseupListener = new MutableDisposable<IDisposable>();
const mousedragListener = new MutableDisposable<IDisposable>();
register(mouseupListener);
register(mousedragListener);
const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener };
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),
@@ -85,16 +91,6 @@ export class MouseService implements IMouseService {
// 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.
*/
@@ -199,12 +195,8 @@ export class MouseService implements IMouseService {
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);
}
ctx.mouseupListener.clear();
ctx.mousedragListener.clear();
}
}
@@ -246,11 +238,14 @@ export class MouseService implements IMouseService {
// 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.
// Use the element's current document in case it moved to another window after open.
const { element, document: targetDocument } = ctx.target;
const listenerDocument = element.ownerDocument ?? targetDocument;
if (ctx.requestedEvents.mouseup) {
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup);
}
if (ctx.requestedEvents.mousedrag) {
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag);
}
}
@@ -398,7 +393,7 @@ export class MouseService implements IMouseService {
}
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
const { element, document } = ctx.target;
const { element } = ctx.target;
const { requestedEvents } = ctx;
// apply global changes on events
if (events) {
@@ -433,18 +428,14 @@ export class MouseService implements IMouseService {
}
if (!(events & CoreMouseEventType.UP)) {
if (requestedEvents.mouseup) {
document.removeEventListener('mouseup', requestedEvents.mouseup);
}
ctx.mouseupListener.clear();
requestedEvents.mouseup = null;
} else {
requestedEvents.mouseup ??= eventListeners.mouseup;
}
if (!(events & CoreMouseEventType.DRAG)) {
if (requestedEvents.mousedrag) {
document.removeEventListener('mousemove', requestedEvents.mousedrag);
}
ctx.mousedragListener.clear();
requestedEvents.mousedrag = null;
} else {
requestedEvents.mousedrag ??= eventListeners.mousedrag;
@@ -9,13 +9,11 @@ import { SelectionModel } from '../selection/SelectionModel';
import { IBuffer, IBufferLine } from '../../common/buffer/Types';
import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, 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 '../TestUtils.test';
import { CellData } from '../../common/buffer/CellData';
import { IRenderService } from './Services';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestSelectionService extends SelectionService {
constructor(
@@ -60,7 +58,7 @@ describe('SelectionService', () => {
});
function stringToRow(text: string): IBufferLine {
const result = new BufferLine(TEST_STRING_CACHE, text.length);
const result = new BufferLine(text.length);
for (let i = 0; i < text.length; i++) {
result.setCell(i, createCellData(0, text.charAt(i), 1));
}
@@ -68,7 +66,7 @@ describe('SelectionService', () => {
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
const line = new BufferLine(chars.length);
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line;
}
@@ -123,7 +121,7 @@ describe('SelectionService', () => {
[0, 'o', 1, 'o'.charCodeAt(0)],
[0, 'o', 1, 'o'.charCodeAt(0)]
];
const line = new BufferLine(TEST_STRING_CACHE, data.length);
const line = new BufferLine(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
+1 -1
View File
@@ -704,7 +704,7 @@ export class SelectionService extends Disposable implements ISelectionService {
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
this._model.selectionEnd[0] = this._bufferService.cols;
}
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1);
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows - 1, buffer.lines.length - 1);
} else {
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
this._model.selectionEnd[0] = 0;
+1 -1
View File
@@ -132,7 +132,7 @@ export class ThemeService extends Disposable implements IThemeService {
colors.ansi[i + 16] = parseColor(theme.extendedAnsi[i], DEFAULT_ANSI_COLORS[i + 16]);
}
}
// Clear our the cache
// Clear the cache
this._contrastCache.clear();
this._halfContrastCache.clear();
this._updateRestoreColors();
+1 -1
View File
@@ -373,7 +373,7 @@ export function toPaddedHex(c: number): string {
/**
* Gets the contrast ratio between two relative luminance values.
* @param l1 The first relative luminance.
* @param l2 The first relative luminance.
* @param l2 The second relative luminance.
* @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef
*/
export function contrastRatio(l1: number, l2: number): number {
+10 -15
View File
@@ -27,11 +27,13 @@ export class Emitter<T> {
}
const entry = { fn: listener, thisArgs };
this._listeners = this._listeners.slice();
this._listeners.push(entry);
const result = toDisposable(() => {
const idx = this._listeners.indexOf(entry);
if (idx !== -1) {
this._listeners = this._listeners.slice();
this._listeners.splice(idx, 1);
}
});
@@ -50,23 +52,16 @@ export class Emitter<T> {
}
public fire(event: T): void {
if (this._disposed) {
if (this._disposed || !this._listeners.length) {
return;
}
switch (this._listeners.length) {
case 0: return;
case 1: {
const { fn, thisArgs } = this._listeners[0];
fn.call(thisArgs, event);
return;
}
default: {
// Snapshot listeners to allow modifications during iteration (2+ listeners)
const listeners = this._listeners.slice();
for (const { fn, thisArgs } of listeners) {
fn.call(thisArgs, event);
}
}
if (this._listeners.length === 1) {
this._listeners[0].fn.call(this._listeners[0].thisArgs, event);
return;
}
const listeners = this._listeners;
for (let i = 0, len = listeners.length; i < len; ++i) {
listeners[i].fn.call(listeners[i].thisArgs, event);
}
}
+136 -11
View File
@@ -16,8 +16,10 @@ import { MockCoreService, MockBufferService, MockOptionsService, MockLogService,
import { IBufferService, ICoreService, type IOscLinkService } from './services/Services';
import { DEFAULT_OPTIONS } from './services/OptionsService';
import { BufferService } from './services/BufferService';
import { CharsetService } from './services/CharsetService';
import { CoreService } from './services/CoreService';
import { OscLinkService } from './services/OscLinkService';
import { CHARSETS } from './data/Charsets';
function getCursor(bufferService: IBufferService): number[] {
@@ -639,19 +641,38 @@ describe('InputHandler', () => {
});
describe('print', () => {
it('should not cause an infinite loop (regression test)', () => {
const inputHandler = new TestInputHandler(
new MockBufferService(80, 30),
new MockCharsetService(),
new MockCoreService(),
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockMouseStateService(),
new MockUnicodeService()
);
const container = new Uint32Array(10);
container[0] = 0x200B;
const lineCountBefore = bufferService.buffer.lines.length;
inputHandler.print(container, 0, 1);
assert.strictEqual(bufferService.buffer.y, 0);
assert.strictEqual(bufferService.buffer.lines.length, lineCountBefore);
});
it('should join combining characters in a single print', async () => {
await inputHandler.parseP('e\u0301');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should join combining characters split across parse calls', async () => {
await inputHandler.parseP('e');
await inputHandler.parseP('\u0301');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should repeat preceding grapheme cluster via REP', async () => {
await inputHandler.parseP('e\u0301\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301e\u0301e\u0301');
assert.strictEqual(bufferService.buffer.x, 3);
});
it('should not repeat when REP has no preceding join state', async () => {
await inputHandler.parseP('\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '');
assert.strictEqual(bufferService.buffer.x, 0);
});
it('should not repeat after an intervening escape sequence', async () => {
await inputHandler.parseP('a\x1b[0m\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'a');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should clear cells to the right on early wrap-around', async () => {
bufferService.resize(5, 5);
@@ -668,6 +689,49 @@ describe('InputHandler', () => {
});
});
describe('ISO-2022 character sets', () => {
let charsetService: CharsetService;
beforeEach(() => {
charsetService = new CharsetService();
inputHandler = new TestInputHandler(
bufferService,
charsetService,
coreService,
new MockLogService(),
optionsService,
oscLinkService,
new MockMouseStateService(),
new MockUnicodeService()
);
});
it('should map G0 line drawing via ESC ( 0', async () => {
await inputHandler.parseP('\x1b(0q\x1b(Bq');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500q');
});
it('should map G1 line drawing after ESC ) 0 and SO', async () => {
await inputHandler.parseP('\x1b)0\x0eq\x0f\x1b(Bq');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500q');
});
it('should restore charset and glevel on ESC 7 / ESC 8', async () => {
await inputHandler.parseP('\x1b)0\x0e');
assert.strictEqual(charsetService.glevel, 1);
assert.strictEqual(charsetService.charset, CHARSETS['0']);
await inputHandler.parseP('\x1b7');
await inputHandler.parseP('\x0f\x1b(B');
assert.strictEqual(charsetService.glevel, 0);
assert.ok(charsetService.charset === undefined);
await inputHandler.parseP('\x1b8');
assert.strictEqual(charsetService.glevel, 1);
assert.strictEqual(charsetService.charset, CHARSETS['0']);
await inputHandler.parseP('q');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500');
});
});
describe('alt screen', () => {
let bufferService: IBufferService;
let handler: TestInputHandler;
@@ -1182,6 +1246,19 @@ describe('InputHandler', () => {
await inputHandler.parseP('\x1b[100;100H');
assert.deepEqual(getCursor(bufferService), [9, 9]);
});
it('cursor position (CUP) with DECOM and scroll margins', async () => {
await inputHandler.parseP('\x1b[?6h\x1b[2;3r\x1b[1;1H');
assert.deepEqual(getCursor(bufferService), [0, 1]);
await inputHandler.parseP('X');
assert.strictEqual(getLines(bufferService, 3)[1], 'X');
await inputHandler.parseP('\x1b[2;1H');
assert.deepEqual(getCursor(bufferService), [0, 2]);
await inputHandler.parseP('\x1b[10;10H');
assert.deepEqual(getCursor(bufferService), [9, 2]);
await inputHandler.parseP('\x1b[?6l');
await inputHandler.parseP('\x1b[2;1H');
assert.deepEqual(getCursor(bufferService), [0, 1]);
});
it('horizontal position absolute (HPA)', async () => {
await inputHandler.parseP('\x1b[`');
assert.deepEqual(getCursor(bufferService), [0, 0]);
@@ -1241,7 +1318,7 @@ describe('InputHandler', () => {
await inputHandler.parseP('\x1b[e');
assert.deepEqual(getCursor(bufferService), [8, 5]);
});
describe('should clamp cursor into addressible range', () => {
describe('should clamp cursor into addressable range', () => {
it('CUF', async () => {
bufferService.buffer.x = 10000;
bufferService.buffer.y = 10000;
@@ -2317,6 +2394,54 @@ describe('InputHandler', () => {
});
});
describe('ED3 - erase saved lines', () => {
beforeEach(() => {
bufferService.resize(10, 5);
});
it('should stop locking the viewport when the scrollback the user scrolled into is erased', async () => {
for (let i = 0; i < 20; ++i) {
await inputHandler.parseP(`old ${i}\r\n`);
}
bufferService.scrollLines(-5);
assert.strictEqual(bufferService.isUserScrolling, true);
await inputHandler.parseP('\x1b[3J');
assert.strictEqual(bufferService.isUserScrolling, false);
assert.strictEqual(bufferService.buffer.ybase, 0);
assert.strictEqual(bufferService.buffer.ydisp, 0);
for (let i = 0; i < 20; ++i) {
await inputHandler.parseP(`new ${i}\r\n`);
}
assert.strictEqual(bufferService.buffer.ydisp, bufferService.buffer.ybase);
});
it('should not reset the scroll position when erasing scrollback on a resized alt buffer', async () => {
bufferService.resize(10, 50);
bufferService.resize(10, 5);
for (let i = 0; i < 20; ++i) {
await inputHandler.parseP(`old ${i}\r\n`);
}
bufferService.scrollLines(-5);
const ydisp = bufferService.buffer.ydisp;
await inputHandler.parseP('\x1b[?1049h');
for (let i = 0; i < 20; ++i) {
await inputHandler.parseP(`new ${i}\r\n`);
}
await inputHandler.parseP('\x1b[3J\x1b[?1049l');
assert.strictEqual(bufferService.isUserScrolling, true);
assert.strictEqual(bufferService.buffer.ydisp, ydisp);
await inputHandler.parseP('more\r\n');
assert.strictEqual(bufferService.isUserScrolling, true);
assert.strictEqual(bufferService.buffer.ydisp, ydisp);
});
});
describe('DECSCA and DECSED/DECSEL', () => {
it('default is unprotected', async () => {
await inputHandler.parseP('some text');
+5
View File
@@ -1286,6 +1286,11 @@ export class InputHandler extends Disposable implements IInputHandler {
this._activeBuffer.lines.trimStart(scrollBackSize);
this._activeBuffer.ybase = Math.max(this._activeBuffer.ybase - scrollBackSize, 0);
this._activeBuffer.ydisp = Math.max(this._activeBuffer.ydisp - scrollBackSize, 0);
// isUserScrolling tracks the normal buffer's viewport, so ED3 on the alt
// screen must not touch it
if (this._activeBuffer === this._bufferService.buffers.normal) {
this._bufferService.isUserScrolling = false;
}
// Force a scroll event to refresh viewport
this._onScroll.fire(0);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function getSafariVersion(): number {
return parseInt(majorVersion[1], 10);
}
// Find the users platform. We use this to interpret the meta key
// Find the user's platform. We use this to interpret the meta key
// and ISO third level shifts.
// http://stackoverflow.com/q/19877924/577598
export const isMac = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'].includes(platform);
+1 -1
View File
@@ -8,7 +8,7 @@ import type { ILogService } from './services/Services';
interface ITaskQueue {
/**
* Adds a task to the queue which will run in a future idle callback.
* To avoid perceivable stalls on the mainthread, tasks with heavy workload
* To avoid perceivable stalls on the main thread, tasks with heavy workload
* should split their work into smaller pieces and return `true` to get
* called again until the work is done (on falsy return value).
*/
+1 -1
View File
@@ -181,7 +181,7 @@ export interface ICoreMouseEvent {
x: number;
y: number;
/**
* Button the action occured. Due to restrictions of the tracking protocols
* Button the action occurred. Due to restrictions of the tracking protocols
* 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
+54
View File
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DEFAULT_ATTR_DATA } from './buffer/BufferLine';
import { updateWindowsModeWrappedState } from './WindowsMode';
import { BufferService } from './services/BufferService';
import { OptionsService } from './services/OptionsService';
import { MockLogService } from './TestUtils.test';
describe('WindowsMode', () => {
describe('updateWindowsModeWrappedState', () => {
it('should mark the next line wrapped when the previous line ends in a non-whitespace character', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
const previousLine = buffer.lines.get(buffer.ybase)!;
for (let i = 0; i < bufferService.cols; i++) {
previousLine!.setCellFromCodepoint(i, 'a'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
}
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, true);
});
it('should not mark the next line wrapped when the previous line ends in whitespace', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
const previousLine = buffer.lines.get(buffer.ybase)!;
for (let i = 0; i < bufferService.cols - 1; i++) {
previousLine!.setCellFromCodepoint(i, 'a'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
}
previousLine!.setCellFromCodepoint(bufferService.cols - 1, ' '.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, false);
});
it('should not mark the next line wrapped when the previous line ends in a null cell', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, false);
});
});
});

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