Compare commits

..
Author SHA1 Message Date
Jörg Breitbart 22e01b7fdc apply storage changes 2026-08-30 22:31:27 +02:00
Jörg Breitbart 2cc9ff0317 Merge branch 'master' into image_animations 2026-08-30 22:21:04 +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
Jörg Breitbart c1d2d6853f make linter happy 2026-08-28 22:15:05 +02:00
Jörg Breitbart 42894ff9a1 pass metrics for correct rescaling, minor fixes 2026-08-28 22:10:31 +02:00
Jörg Breitbart 8c8c228ac5 expose image spec on storage 2026-08-28 19:42:02 +02:00
Jörg Breitbart e54045d89b fix more bundler error 2026-08-28 18:24:12 +02:00
Jörg Breitbart d6d95171a5 fix bundler error 2026-08-28 18:16:53 +02:00
Jörg Breitbart 4d0a9c3419 fix addon tests 2026-08-28 18:08:52 +02:00
Jörg Breitbart de620dc826 make linter happy 2026-08-28 17:41:58 +02:00
Jörg Breitbart 651a3537dd make linter happy 2026-08-28 17:32:53 +02:00
Jörg Breitbart f1ae0ab30b early animation PoC 2026-08-28 16:42:43 +02: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
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
79 changed files with 3055 additions and 894 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'
+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.
+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`]);
});
});
});
+8 -1
View File
@@ -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}`.
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

+630
View File
@@ -0,0 +1,630 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IDisposable } from '@xterm/xterm';
import type { ImageRenderer } from './ImageRenderer';
import { ImageTileInfo, type ImageStorage } from './ImageStorage';
import type { IImageSpec, ITerminalExt, IResetHandler } from './Types';
import type { IMetrics } from 'IIPMetrics';
/**
* Note on Firefox Bug (https://bugzilla.mozilla.org/show_bug.cgi?id=2066911)
*
* ImageDecoder.decode() returns a corrupted VideoFrame,
* if the frame was closed from a previous decode.
*
* Therefore the AnimationManager class implements certain
* firefox shims gated by the isFirefox boolean:
* - IAnimation.highest tracks highest frameID ever decoded
* - whenever frameID is lesser or equal zu highest create new decoder
* - raw image bytes need to be held for new decoder
*
* To be removed once the bug got fixed.
*/
interface IAnimation {
/** image id in storage */
id: number;
/** image bytes */
data: Blob;
/** metrics of the image */
metrics: IMetrics;
/** width */
w: number;
/** height */
h: number;
/** number of frames */
numFrames: number;
frames: number[];
/** ImageDecoder instance */
decoder: ImageDecoder | undefined;
decoderPromise: Promise<ImageDecoder> | undefined;
cache: BitmapBuffer;
/** start timestamp */
startTime: number | undefined;
/** whether a decode task ist scheduled */
scheduled: boolean;
/** whether the image is in viewport */
inViewport: boolean;
/** firefox bug helper */
highest: number;
/** playtime of animation */
playtime: number;
/** frame timestamps */
timestamps: number[];
/** frame ID targeted by animation */
current: number;
/** frame ID currently visible (owned by spec.actual) */
actual: number;
/** idx of current on frames */
frameIdx: number;
}
interface IImageDraw {
imageId: number;
imgSpec: IImageSpec;
tileId: number;
col: number;
row: number;
count: number;
}
interface IBufferSlot {
id: number;
bm: ImageBitmap | null;
}
const enum Constants {
BUFFERSIZE = 4,
BUFFERMOD = BUFFERSIZE - 1,
MAX_FRAMES = 65535
}
class BitmapBuffer implements IDisposable {
private readonly _d: IBufferSlot[];
private _disposed = false;
constructor() {
this._d = Array(Constants.BUFFERSIZE).fill(0).map(() => ({ id: -1, bm: null }));
}
public dispose(): void {
this.clear();
this._disposed = true;
}
public peek(id: number): ImageBitmap | null {
const slot = this._d[id & Constants.BUFFERMOD];
return id === slot.id ? slot.bm : null;
}
public pop(id: number): ImageBitmap | null {
const slot = this._d[id & Constants.BUFFERMOD];
const res = id === slot.id
? slot.bm
: (slot.bm?.close(), null);
slot.bm = null;
slot.id = -1;
return res;
}
public push(id: number, bm: ImageBitmap): void {
/**
* A late decode task might still push bitmaps.
* Since push gives the buffer ownership of
* the bitmap, we use the isDisposed flag to
* free it right away.
*/
if (this._disposed) {
bm.close();
return;
}
const slot = this._d[id & Constants.BUFFERMOD];
slot.bm?.close();
slot.bm = bm;
slot.id = id;
}
public clear(): void {
for (let i = 0; i < Constants.BUFFERSIZE; ++i) {
const slot = this._d[i];
slot.bm?.close();
slot.bm = null;
slot.id = -1;
}
}
}
// eslint-disable-next-line
declare const InstallTrigger: any;
// FIXME: move to AnimationManager ctor to control it from xterm side
const isChromium = (window as any).chrome !== undefined;
const isFirefox: boolean = typeof InstallTrigger !== 'undefined' || 'MozAppearance' in document.documentElement.style;
/**
* Helper to create a read to use ImageDecoder instance from a blob.
*/
async function BlobDecoder(blob: Blob, type: string): Promise<ImageDecoder> {
/**
* Dealing with browser bugs - in theory this should be possible:
*
* const decoder = new ImageDecoder({ data: blob.stream(), type, preferAnimation: true });
* await decoder.tracks.ready;
* return decoder;
*
* But this straightforward handling with blob.stream() uncovers several bugs:
* 1. Chromium: Fails to stream chunked blobs reliably.
* 2. Firefox: Native lifecycle race condition. Closing an old decoder breaks the
* shared underlying native stream of the same blob for new decoders, throwing
* an uncatchable "Closed decoder" DOMException during `tracks.ready`.
*
* So we use a safer approach by pulling full raw bytes over first and transferring ownership.
*/
const data = await blob.arrayBuffer();
const decoder = new ImageDecoder({ data, type, preferAnimation: true, transfer: [data] });
await decoder.tracks.ready;
if (isChromium && type === 'image/avif') {
// chromium bug: random frame access in AVIF is
// only possible after reading frame 0
const res = await decoder.decode({ frameIndex: 0 });
res.image.close();
}
return decoder;
}
/**
* AnimationManager - orchestrates rendering of animated images.
*
* Current Workflow
* When IIPMetrics.imageType reports an animated image the IIP handler
* stores the image in the storage and registers an animation of it.
*
* The animation manager works mostly self-contained:
* - RAF loop
* - 3 frame ahead decoding
* - own bitmap ownership handling
*
* There are a few intersections with the ImageStorage and the ImageRenderer,
* which still need to be consolidated (TODOs):
* - ImageStorage: API for announcing memory state, better eviction strategy
* - ImageRenderer: API for better drawing primitives
*
* In particular the ownership model of ImageSpec bitmaps (actual and orig)
* needs a better story. We prolly need here an API to flip bitmaps.
*/
export class AnimationManager implements IDisposable, IResetHandler {
private _animations = new Map<number, IAnimation>();
private _animationFrame: number | undefined = undefined;
private _draws: IImageDraw[] = [];
constructor(
private _storage: ImageStorage,
private _renderer: ImageRenderer,
private _terminal: ITerminalExt
) {}
private _requestAF(cb: FrameRequestCallback): number | undefined {
return this._terminal._core._coreBrowserService?.window.requestAnimationFrame(cb);
}
private _cancelAF(id: number): void | undefined {
return this._terminal._core._coreBrowserService?.window.cancelAnimationFrame(id);
}
public dispose(): void {
this.reset();
}
/**
* Reset handler.
* Stops all animations and frees all ressources.
*/
public reset(): void {
for (const anim of this._animations.values()) {
anim.inViewport = false;
this._closeDecoder(anim);
anim.cache.dispose();
}
this._animations.clear();
this._draws = [];
if (this._animationFrame) this._cancelAF(this._animationFrame);
this._animationFrame = undefined;
}
/**
* Main hook to register an animated image for animation.
*
* @param id Image ID of the image storage the image was registered under.
* @param data Raw data bytes of the image.
* @param metrics Metrics of the image as returned by IIPMetrics.imageType.
* @param w Output width in pixel.
* @param h Output height in pixel.
*/
public async registerAnimation(id: number, data: Blob, metrics: IMetrics, w: number, h: number): Promise<void> {
let decoder = await BlobDecoder(data, metrics.mime);
const track = decoder.tracks.selectedTrack;
if (!track || track.frameCount < 2) {
decoder.close();
return;
}
const frameCount = Math.min(track.frameCount, Constants.MAX_FRAMES);
const frames: number[] = [];
const timestamps: number[] = [];
const durations: number[] = [];
let elapsed = 0;
for (let i = 0; i < frameCount; i++) {
try {
const result = await decoder.decode({ frameIndex: i });
const frame = result.image;
const width = frame.displayWidth;
const height = frame.displayHeight;
const duration = (frame.duration ?? 0) / 1000;
frame.close();
// skip wrong dimensions and zero duration
if (width !== metrics.width || height !== metrics.height || !duration) {
if (durations.length) {
durations[durations.length - 1] += duration;
elapsed += duration;
}
continue;
}
frames.push(i);
timestamps.push(elapsed);
durations.push(duration);
elapsed += duration;
} catch {
break;
}
}
/**
* FIXME: still needs a consolidation of short frame durations:
* We don't track v-sync, but just decode the next 3 follow-up frames.
* This creates nonsense decoder pressure decoding frames,
* that effectively never make it to the screen.
* Currently our shortest time resolution is 1ms,
* thus with 60 fps we would decode 15 1ms frames for no reason.
* We prolly should introduce a lower bound of 8ms:
* - in sync with 120 fps
* - only every second frame is nonsense with 60 fps
*/
if (frames.length < 2 || elapsed <= 0) {
decoder.close();
return;
}
if (isFirefox) {
decoder.close();
decoder = await BlobDecoder(data, metrics.mime);
}
const anim: IAnimation = {
id, data, w, h, decoder,
metrics,
numFrames: frames.length,
decoderPromise: undefined,
cache: new BitmapBuffer(),
frames,
startTime: undefined,
current: 0,
actual: 0,
scheduled: false, // set by _schedule
inViewport: true, // start with active prefetching
highest: -1,
playtime: elapsed,
timestamps,
frameIdx: 0
};
this._animations.set(id, anim);
this.viewportUpdated();
// early schedule to give decoder time to fill the cache before RAF
this._schedule(anim);
this._animationFrame ??= this._requestAF(this._loop);
}
/**
* Remove the animation for the given image id.
*/
public unregisterAnimation(id: number): void {
const a = this._animations.get(id);
if (a) {
a.inViewport = false;
this._closeDecoder(a);
a.cache.dispose();
this._animations.delete(id);
}
}
/**
* onRender handler.
* Scratches the viewport buffer to collect draws for animation RAF
* and to pause/resume invisible/visible animations.
*/
public viewportUpdated(_range?: { start: number, end: number }): void {
const rows = this._terminal.rows;
const cols = this._terminal.cols;
const buffer = this._terminal._core.buffer;
const actives: Set<number> = new Set();
this._draws = [];
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row + buffer.ydisp);
if (!line) break;
for (let col = 0; col < cols; ++col) {
const e = line.getExtended(col)?.payload;
if (e instanceof ImageTileInfo) {
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
const imgSpec = this._storage.getImage(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++;
}
col--;
if (imgSpec && this._animations.has(imageId)) {
this._draws.push({ imageId, imgSpec, tileId: startTile, col: startCol, row, count });
actives.add(imageId);
}
}
}
}
}
this._updateAnimations(actives);
}
/**
* Get the frameIdx for elapsed time.
* Note: The returned value is not the frame ID,
* but the index for IAnimation.frames.
*/
private _timedFrameIdx(a: IAnimation, elapsed: number): number {
elapsed %= a.playtime;
let low = 0;
let high = a.timestamps.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const startTime = a.timestamps[mid];
if (elapsed < startTime) {
high = mid - 1;
} else {
if (mid === a.timestamps.length - 1 || elapsed < a.timestamps[mid + 1]) {
return mid;
}
low = mid + 1;
}
}
return 0;
}
/**
* Pause/resume decoding from viewport changes.
*/
private _updateAnimations(visibles: Set<number>): void {
for (const [id, anim] of this._animations) {
const isVisible = visibles.has(id);
if (isVisible && !anim.inViewport) {
/**
* There are two strategies possible here:
*
* 1. Resume decoding and pumping frames as soon as possible.
* 2. Delay resuming further by a debounce to prevent flooding
* new decoder instances on heavy scrolling.
*
* Currently the code below does 1. as the impact is rather
* small from tests and gives the better user experience.
*/
anim.inViewport = true;
const frameIdx = this._timedFrameIdx(anim, performance.now() - anim.startTime!);
anim.frameIdx = frameIdx;
anim.current = anim.frames[frameIdx];
if (!anim.decoder && !anim.decoderPromise) {
anim.decoderPromise = BlobDecoder(anim.data, anim.metrics.mime);
}
this._schedule(anim);
} else if (!isVisible && anim.inViewport) {
anim.inViewport = false;
anim.cache.clear();
this._closeDecoder(anim);
}
}
if (visibles.size && this._animationFrame === undefined) {
this._animationFrame = this._requestAF(this._loop);
}
}
/**
* RAF for animations in viewport.
*/
private _loop = (ts: number): void => {
if (!this._animations.size) {
this._animationFrame = undefined;
return;
}
let mustRender = false;
let anyVisible = false;
for (const anim of this._animations.values()) {
if (!anim.inViewport) continue;
const spec = this._storage.getImage(anim.id);
if (!spec) {
this.unregisterAnimation(anim.id);
continue;
}
anyVisible = true;
anim.startTime ??= ts;
const frameIdx = this._timedFrameIdx(anim, ts - anim.startTime);
anim.frameIdx = frameIdx;
const current = anim.frames[frameIdx];
if (current !== anim.current) {
anim.current = current;
const bm = anim.cache.pop(current);
if (bm) {
// FIXME: setting spec.actual delegates draws from onRender back to ImageStorage.render
// --> should be avoided to prevent double draws within same AF
// idea: create a mutex on image spec to tell storage.render to skip
// by swapping spec.actual we take ownership, thus have to close it here
// but only if we have more than 4 frames (ringbuffer size is 4)
// for <= 4 frames we move the bitmap back into cache
// FIXME: create a bitmap swapping API in storage
if (spec.actual instanceof ImageBitmap) {
if (anim.numFrames > Constants.BUFFERSIZE) {
spec.actual.close();
} else {
anim.cache.push(anim.actual, spec.actual);
}
}
spec.actual = bm;
anim.actual = current;
mustRender = true;
}
this._schedule(anim);
}
}
// FIXME: merge clears and draws into col x row rects
if (mustRender) {
for (let i = 0; i < this._draws.length; ++i) {
const d = this._draws[i];
this._renderer.clearLines(d.row, d.row);
this._renderer.draw(d.imgSpec, d.tileId, d.col, d.row, d.count);
}
}
this._animationFrame = anyVisible ? this._requestAF(this._loop) : undefined;
};
/**
* Try to schedule frame decoding.
*/
private _schedule(a: IAnimation): void {
if (!a.scheduled) {
let needsSchedule = false;
// FIXME: do we need a == 4 test too?
if (a.numFrames > 3) {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 3) % a.numFrames]);
} else if (a.numFrames === 3) {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]);
} else {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]);
}
if (needsSchedule) {
a.scheduled = true;
setTimeout(() => this._decode(a), 0);
}
}
}
/**
* Decode up to next 3 frames.
*/
private async _decode(a: IAnimation): Promise<void> {
if (!a.inViewport) {
a.scheduled = false;
return;
}
if (!a.decoder) {
const decP = a.decoderPromise;
a.decoderPromise = undefined;
a.decoder = await (decP ?? BlobDecoder(a.data, a.metrics.mime));
}
const n1 = a.frames[(a.frameIdx + 1) % a.numFrames];
const n2 = a.frames[(a.frameIdx + 2) % a.numFrames];
const n3 = a.frames[(a.frameIdx + 3) % a.numFrames];
for (const n of [n1, n2, n3]) {
if (!a.cache.peek(n)) {
if (isFirefox) {
if (n <= a.highest) {
a.highest = -1;
a.decoder?.close();
a.decoder = await BlobDecoder(a.data, a.metrics.mime);
}
a.highest = Math.max(n, a.highest);
}
let res: ImageDecodeResult;
try {
res = await a.decoder.decode({ frameIndex: n });
} catch { continue; }
let bm: ImageBitmap;
try {
// FIXME: we should not resize here yet
// BETTER: rescale during draw:
// - eases the GPU druing decoding
// - rescales only what needs to be drawn
// --> funneling down
// FIXME: needs changes in image spec and rendering functions
bm = await createImageBitmap(res.image, { resizeWidth: a.w, resizeHeight: a.h });
} catch { continue; } finally { res?.image?.close(); }
if (!a.inViewport) {
bm.close();
break;
}
a.cache.push(n, bm);
}
}
a.scheduled = false;
}
/**
* Close the decoder defensively.
*/
private _closeDecoder(a: IAnimation): void {
const decoder = a.decoder;
a.decoder = undefined;
try { decoder?.close(); } catch {}
const decP = a.decoderPromise;
a.decoderPromise = undefined;
decP?.then(dec => dec && dec !== decoder ? dec.close() : null).catch(() => {});
}
}
+32 -17
View File
@@ -2,14 +2,15 @@
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import type { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage';
import type { 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 type { AnimationManager } from 'AnimationManager';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
@@ -39,7 +40,6 @@ 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;
@@ -47,7 +47,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage,
private readonly _coreTerminal: ITerminalExt
private readonly _coreTerminal: ITerminalExt,
private readonly _aniManager: AnimationManager | undefined
) {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
@@ -63,7 +64,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public start(): void {
this._aborted = false;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset();
}
@@ -120,14 +120,14 @@ 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;
let w = CELL_SIZE_DEFAULT.width;
let h = 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;
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
h = this._renderer.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;
}
@@ -154,17 +154,24 @@ export class IIPHandler implements IOscHandler, IResetHandler {
// 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;
metrics = imageType(this._dec.data8);
if (cond = metrics.mime !== 'unsupported') {
w = metrics.width;
h = 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;
} else {
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) {
@@ -173,7 +180,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
}
let blob: Blob | ImageData;
if (this._metrics.mime === 'image/qoi') {
if (metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
@@ -190,12 +197,20 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
} else {
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
blob = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
this._storage.addImage(bm);
const id = this._storage.addImage(bm);
if (this._aniManager && metrics.animated) {
this._aniManager.registerAnimation(id, blob as Blob, metrics, w, h).catch(() => {});
}
return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
// FIXME: try ImageDecoder here instead (e.g. some AVIF work in Firefox)
return true;
});
}
+2 -2
View File
@@ -22,7 +22,7 @@ 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(img: HTMLCanvasElement | ImageBitmap): number {
return this._storage.addImage(img, this._addImageOpts);
}
}
+42 -20
View File
@@ -12,26 +12,48 @@ const fs = require('fs');
const TEST_IMAGES: [string, IMetrics][] = [
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
['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 }],
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48, animated: true }],
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['spinfox.png', { mime: 'image/png', width: 148, height: 148, animated: true }],
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448, animated: false }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600, animated: false }],
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8, animated: false }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000, animated: false }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600, animated: false }],
// VP8
['1.webp', { mime: 'image/webp', width: 550, height: 368, animated: false }],
['2.webp', { mime: 'image/webp', width: 550, height: 404, animated: false }],
['3.webp', { mime: 'image/webp', width: 1280, height: 720, animated: false }],
['4.webp', { mime: 'image/webp', width: 1024, height: 772, animated: false }],
['5.webp', { mime: 'image/webp', width: 1024, height: 752, animated: false }],
// VP8X
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
// VP8L
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
// some AVIF test images
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799, animated: false }],
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048, animated: false }],
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024, animated: false }],
];
+162 -11
View File
@@ -4,62 +4,140 @@
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
animated: boolean;
}
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
height: 0,
animated: false
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 24) {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
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]
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23],
animated: isAPNG(d)
};
}
// 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 };
return { mime: 'image/jpeg', width, height, animated: false };
}
// 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]
width: d[6] | d[7] << 8,
height: d[8] | d[9] << 8,
animated: isAGIF(d)
};
}
// 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]
width: d[4] << 24 | d[5] << 16 | d[ 6] << 8 | d[ 7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11],
animated: false
};
}
// 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,
animated: (d[20] & 0x02) !== 0
};
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,
animated: false
};
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,
animated: false
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
let anim = d32[2] === 0x73697661; // avis is meant to be animated
// search for boxes within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe for dimensions
if (pos === -1 && d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
}
// scan for stsz or moov
if (!anim) {
const isStsz = d[i] === 0x73 && d[i + 1] === 0x74 && d[i + 2] === 0x73 && d[i + 3] === 0x7A;
const isMoov = d[i] === 0x6D && d[i + 1] === 0x6F && d[i + 2] === 0x6F && d[i + 3] === 0x76;
if (isStsz || isMoov) {
anim = true;
}
}
if (pos !== -1 && anim) {
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, animated: anim };
}
}
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];
if (blockLength < 2) return [0, 0];
while (true) {
i += blockLength;
if (i >= len) {
@@ -80,5 +158,78 @@ function jpgSize(d: Uint8Array): [number, number] {
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
if (blockLength < 2) return [0, 0];
}
}
function isAGIF(data: Uint8Array): boolean {
let pos = 10;
const gPacked = data[pos];
const hasGP = (gPacked & 0x80) !== 0;
pos += 3;
if (hasGP) {
const gpSize = 2 << (gPacked & 0x07);
pos += 3 * gpSize;
}
let gceCount = 0;
while (pos < data.length) {
const blockType = data[pos];
if (blockType === 0x21) { // Extension Block
if (pos + 1 >= data.length) return false;
const extLabel = data[pos + 1];
if (extLabel === 0xF9) { // Graphic Control Extension
gceCount++;
if (gceCount > 1) return true; // early exit on 2nd frame
}
pos += 2;
while (pos < data.length) {
const blockSize = data[pos];
if (blockSize === 0x00) break;
pos += blockSize + 1;
}
if (pos >= data.length) return false;
pos++;
} else if (blockType === 0x2C) { // Image Descriptor
if (pos + 10 >= data.length) return false;
pos += 10;
const lPacked = data[pos - 1];
if ((lPacked & 0x80) !== 0) {
const lpSize = 2 << (lPacked & 0x07);
pos += 3 * lpSize;
}
if (pos >= data.length) return false;
pos++;
while (pos < data.length) {
const blockSize = data[pos];
if (blockSize === 0x00) break;
pos += blockSize + 1;
}
if (pos >= data.length) return false;
pos++;
} else if (blockType === 0x3B) {
break;
} else {
return false;
}
}
return false;
}
function isAPNG(d: Uint8Array): boolean {
let p = 8;
while (p + 8 < d.length) {
const length = (d[p] << 24 | d[p + 1] << 16 | d[p + 2] << 8 | d[p + 3]) >>> 0;
// 'acTL' before 'IDAT': APNG
if (d[p + 4] === 0x61 && d[p + 5] === 0x63 && d[p + 6] === 0x54 && d[p + 7] === 0x4c) {
return true;
}
// 'IDAT' first: no APNG
if (d[p + 4] === 0x49 && d[p + 5] === 0x44 && d[p + 6] === 0x41 && d[p + 7] === 0x54) {
return false;
}
p += 12 + length;
}
return false;
}
+12 -2
View File
@@ -15,6 +15,7 @@ import { SixelHandler } from './SixelHandler';
import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
import { AnimationManager } from './AnimationManager';
/**
@@ -70,7 +71,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
kittySizeLimit: 33554432,
animationSupport: true, // alpha
animationPixelLimit: 4194304 // limit to 2048 * 2048 pixels
};
// max palette size supported by the sixel lib (compile time setting)
@@ -104,6 +107,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private _aniManager: AnimationManager | undefined;
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
@@ -144,6 +148,11 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
terminal.options.windowOptions = windowOps;
}
if (this._opts.animationSupport && typeof ImageDecoder === 'function') {
this._aniManager = new AnimationManager(this._storage, this._renderer, terminal);
this._disposeLater(terminal.onRender(range => this._aniManager!.viewportUpdated(range)));
}
this._disposeLater(
this._renderer,
this._storage,
@@ -187,7 +196,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, this._renderer!, iipStorage, terminal, this._aniManager);
this._handlers.set('iip', iipHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
@@ -218,6 +227,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
for (const handler of this._handlers.values()) {
handler.reset();
}
this._aniManager?.reset();
return false;
}
+88 -178
View File
@@ -5,12 +5,11 @@
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
} from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
// fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -18,93 +17,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;
}
export 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:
@@ -126,6 +44,7 @@ export class ImageStorage implements IDisposable {
private _needsFullClear = false;
// hard limit of stored pixels (fallback limit of 10 MB)
private _pixelLimit: number = 2500000;
private _workCell: CellData = new CellData();
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
@@ -224,6 +143,13 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = false;
}
/**
* Retrieve image spec from storage.
*/
public getImage(id: number): IImageSpec | undefined {
return this._images.get(id);
}
/**
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
@@ -277,10 +203,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) {
@@ -427,55 +353,44 @@ 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 | undefined;
if (!line) continue;
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) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
col--;
if (imgSpec) {
if (imgSpec.actual) {
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,12 +428,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 | undefined;
if (!line) {
continue;
}
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;
@@ -534,8 +446,8 @@ export class ImageStorage implements IDisposable {
}
// 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) {
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
if (line.hasContent(rightCol)) {
hasData = true;
break;
}
@@ -547,7 +459,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++;
}
}
@@ -561,10 +473,10 @@ 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 line = buffer.lines.get(y);
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && 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);
@@ -581,10 +493,10 @@ export class ImageStorage implements IDisposable {
*/
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 line = buffer.lines.get(y);
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
return this._renderer.extractTile(spec, e.tileId);
@@ -612,31 +524,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 {
@@ -649,18 +561,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 -16
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';
@@ -41,6 +40,8 @@ export interface IImageAddonOptions {
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
animationSupport: boolean;
animationPixelLimit: number;
}
export interface IResetHandler {
@@ -48,6 +49,7 @@ export interface IResetHandler {
reset(): void;
}
/* eslint-disable */
/**
* Stub into private interfaces.
* This should be kept in line with common libs.
@@ -55,19 +57,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 +67,7 @@ interface IInputHandlerExt extends IInputHandler {
};
onRequestReset(handler: () => void): IDisposable;
}
/* eslint-enable */
export interface ICoreTerminalExt extends ITerminal {
_themeService: IThemeService | undefined;
@@ -89,8 +79,6 @@ export interface ICoreTerminalExt extends ITerminal {
export interface ITerminalExt extends Terminal {
_core: ICoreTerminalExt;
}
/* eslint-enable */
/**
* Some storage definitions.
+34 -2
View File
@@ -26,6 +26,8 @@ export interface IImageAddonOptions {
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
animationSupport: boolean;
animationPixelLimit: number;
}
// eslint-disable-next-line
@@ -138,7 +140,9 @@ test.describe('ImageAddon', () => {
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
kittySizeLimit: 33554432,
animationSupport: true,
animationPixelLimit: 4194304
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -155,7 +159,9 @@ test.describe('ImageAddon', () => {
iipSupport: false,
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
kittySizeLimit: 1000,
animationSupport: false,
animationPixelLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -435,6 +441,15 @@ test.describe('ImageAddon', () => {
});
}
});
test.describe('text overwrite removes tiles', () => {
test('sixel', async () => {
await assertTextClearsTiles(SIXEL_SEQ_0);
});
test('iip', async () => {
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
});
});
});
/**
@@ -472,3 +487,20 @@ async function getOrigSize(id: number): Promise<[number, number]> {
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.extractTileAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(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('#######'));
}
@@ -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.extractTileAtBufferCell(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('#######'));
});
});
});
/**
@@ -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.extractTileAtBufferCell(${x}, ${y})`);
}
+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);
@@ -414,6 +414,20 @@ 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 () => {
+24 -3
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,8 +215,19 @@ 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 {
@@ -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;
}
+56 -16
View File
@@ -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,6 +145,11 @@ 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 {
@@ -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);
@@ -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 {
+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.
+21 -6
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;
@@ -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();
}
});
});
@@ -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) => {
@@ -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 = [];
}
}
}
}
}
}
+136 -136
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": {
@@ -7127,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": {
@@ -7975,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": {
@@ -8452,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": {
+1 -4
View File
@@ -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;
+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;
}
}
+93
View File
@@ -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;
+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);
}
}
+48
View File
@@ -2394,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);
}
+2 -1
View File
@@ -139,6 +139,7 @@ export class AttributeData implements IAttributeData {
*/
export class ExtendedAttrs implements IExtendedAttrs {
private _ext: number = 0;
public payload: Object | undefined;
public get ext(): number {
if (this._urlId) {
return (
@@ -207,6 +208,6 @@ export class ExtendedAttrs implements IExtendedAttrs {
* that needs to be persistant in the buffer.
*/
public isEmpty(): boolean {
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0;
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined;
}
}
+8 -111
View File
@@ -8,24 +8,14 @@ import { Buffer } from './Buffer';
import { CircularList } from '../CircularList';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData';
import { ExtendedAttrs } from './AttributeData';
const INIT_COLS = 80;
const INIT_ROWS = 24;
const INIT_SCROLLBACK = 1000;
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBuffer extends Buffer {
public getStringCache(): BufferLineStringCache {
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
}
public getStringCacheClearTimeout(): unknown {
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
}
}
class TestBuffer extends Buffer {}
describe('Buffer', () => {
let optionsService: MockOptionsService;
@@ -1202,7 +1192,7 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(TEST_STRING_CACHE, 4);
const line = new BufferLine(4);
line.setCell(0, createCellData(0, 'a', 1));
line.setCell(1, createCellData(0, 'b', 1));
line.setCell(2, createCellData(0, 'c', 1));
@@ -1214,7 +1204,7 @@ describe('Buffer', () => {
});
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(TEST_STRING_CACHE, 3);
const line = new BufferLine(3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1225,7 +1215,7 @@ describe('Buffer', () => {
});
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(TEST_STRING_CACHE, 3);
const line = new BufferLine(3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1242,7 +1232,7 @@ describe('Buffer', () => {
});
it('should handle single width emojis', () => {
const line = new BufferLine(TEST_STRING_CACHE, 2);
const line = new BufferLine(2);
line.setCell(0, createCellData(0, '😁', 1));
line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
@@ -1255,7 +1245,7 @@ describe('Buffer', () => {
});
it('should handle double width emojis', () => {
const line = new BufferLine(TEST_STRING_CACHE, 2);
const line = new BufferLine(2);
line.setCell(0, createCellData(0, '😁', 2));
line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line);
@@ -1266,7 +1256,7 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
const line2 = new BufferLine(3);
line2.setCell(0, createCellData(0, '😁', 2));
line2.setCell(1, createCellData(0, '', 0));
line2.setCell(2, createCellData(0, 'a', 1));
@@ -1277,99 +1267,6 @@ describe('Buffer', () => {
});
});
describe('line string cache cleanup', () => {
it('should clear shared cache entries with a single timer', () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const originalDateNow = Date.now;
let timeoutId = 0;
let now = 0;
const clearedTimeouts: number[] = [];
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
const id = ++timeoutId;
scheduledTimeouts.set(id, {
delay: timeout ?? 0,
fire: () => {
scheduledTimeouts.delete(id);
handler();
}
});
return id as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
const numericId = id as unknown as number;
clearedTimeouts.push(numericId);
scheduledTimeouts.delete(numericId);
}) as typeof clearTimeout;
Date.now = () => now;
try {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 2);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
const initialTimerCreationCount = timeoutId;
now = 5000;
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(timeoutId, initialTimerCreationCount);
assert.equal(scheduledTimeouts.size, 1);
assert.deepEqual(clearedTimeouts, []);
now = 15000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(timeoutId, initialTimerCreationCount + 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
now = 20000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(cache.entries.size, 1);
} finally {
Date.now = originalDateNow;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('should reset line string cache state on clear and resize', () => {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.translateBufferLineToString(0, false);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
buffer.clear();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
buffer.translateBufferLineToString(0, false);
assert.equal(cache.entries.size, 1);
buffer.resize(INIT_COLS - 1, INIT_ROWS);
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
});
});
describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
buffer.fillViewportRows();
+4 -9
View File
@@ -10,7 +10,6 @@ import { ICharset } from '../Types';
import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types';
import { ExtendedAttrs } from './AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
import { CellData } from './CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from './Constants';
@@ -52,7 +51,6 @@ export class Buffer extends Disposable implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private readonly _stringCache: BufferLineStringCache;
constructor(
private _hasScrollback: boolean,
@@ -70,7 +68,6 @@ export class Buffer extends Disposable implements IBuffer {
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
this._register(toDisposable(() => this.clearAllMarkers()));
this._stringCache = this._register(new BufferLineStringCache());
}
public getNullCell(attr?: IAttributeData): ICellData {
@@ -100,7 +97,7 @@ export class Buffer extends Disposable implements IBuffer {
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
}
public get hasScrollback(): boolean {
@@ -145,7 +142,6 @@ export class Buffer extends Disposable implements IBuffer {
* Clears the buffer to its initial state, discarding all previous data.
*/
public clear(): void {
this._stringCache.clear();
this.ydisp = 0;
this.ybase = 0;
this.y = 0;
@@ -164,7 +160,6 @@ export class Buffer extends Disposable implements IBuffer {
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
this._stringCache.clear();
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
@@ -199,7 +194,7 @@ export class Buffer extends Disposable implements IBuffer {
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
// Just add the new missing rows on Windows as conpty reprints the screen with its
// view of the world. Once a line enters scrollback for conpty it remains there
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
this.lines.push(new BufferLine(newCols, nullCell, false));
} else {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
// There is room above the buffer and there are no empty elements below the line,
@@ -213,7 +208,7 @@ export class Buffer extends Disposable implements IBuffer {
} else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there
// are blank lines after the cursor
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
this.lines.push(new BufferLine(newCols, nullCell, false));
}
}
}
@@ -354,7 +349,7 @@ export class Buffer extends Disposable implements IBuffer {
}
if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
this.lines.push(new BufferLine(newCols, nullCell, false));
}
} else {
if (this.ydisp === this.ybase) {
+5 -8
View File
@@ -4,19 +4,16 @@
*/
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants';
import { BufferLine } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData';
import { CharData, IBufferLine, ICellData } from './Types';
import { assert } from 'chai';
import { AttributeData } from './AttributeData';
import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBufferLine extends BufferLine {
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
super(cols, fillCellData, isWrapped);
}
public get combined(): {[index: number]: string} {
@@ -24,19 +21,19 @@ class TestBufferLine extends BufferLine {
}
public get cachedString(): string | undefined {
return this._getStringCacheEntry(false)?.value;
return this._cacheValid ? this._cache : undefined;
}
public set cachedString(value: string | undefined) {
this._getStringCacheEntry(true)!.value = value;
this._cache = value ?? '';
}
public get isCachedStringTrimmed(): boolean {
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
return this._cacheTrimmed;
}
public set isCachedStringTrimmed(value: boolean) {
this._getStringCacheEntry(true)!.isTrimmed = value;
this._cacheTrimmed = value;
}
public toArray(): CharData[] {
+73 -81
View File
@@ -8,7 +8,6 @@ import { AttributeData } from './AttributeData';
import { CellData } from './CellData';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants';
import { stringFromCodePoint } from '../input/TextDecoder';
import { StringBuilder } from '../StringBuilder';
// Buffer memory layout:
//
@@ -37,24 +36,20 @@ const enum Cell {
BG = 2 // currently unused
}
interface IExtendedAttrsExt extends IExtendedAttrs {
_ext: number;
_urlId: number;
}
export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection
let $startIndex = 0;
const $workCell = new CellData();
const $translateToStringBuilder = new StringBuilder();
const $extended = DEFAULT_ATTR_DATA.extended.clone() as IExtendedAttrsExt;
export interface IBufferLineStringCacheEntry {
value: string | undefined;
isTrimmed: boolean;
generation: number;
}
export interface IBufferLineStringCache {
generation: number;
allocateEntry(): IBufferLineStringCacheEntry;
touch?(): void;
}
/**
* Typed array based bufferline implementation.
@@ -77,11 +72,14 @@ export class BufferLine implements IBufferLine {
protected _combined: {[index: number]: string} = {};
/** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
public length: number;
/** line text cache */
protected _cacheValid = false;
protected _cache: string = '';
protected _cacheTrimmed = false;
constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number,
fillCellData?: ICellData,
public isWrapped: boolean = false
@@ -118,7 +116,7 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public set(index: number, value: CharData): void {
this._invalidateStringCache();
this._cacheValid = false;
this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
@@ -210,21 +208,30 @@ export class BufferLine implements IBufferLine {
} else {
cell.combinedData = '';
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._extendedAttrs[index]!;
} else {
// Do not mutate cell.extended in place: it may still reference this line's map entry from a
// prior loadCell into a reused CellData (e.g. $workCell during insert/delete).
cell.extended = DEFAULT_ATTR_DATA.extended.clone();
}
cell.extended = this.getExtended(index);
return cell;
}
public getExtended(index: number): IExtendedAttrs {
$startIndex = index * Constants.CELL_INDICIES;
if (this._data[$startIndex + Cell.BG] & BgFlags.HAS_EXTENDED) {
return this._extendedAttrs[index]!;
}
// Do not mutate cell.extended in place: it may still reference this line's map entry from a
// prior loadCell into a reused CellData (e.g. $workCell during insert/delete).
// We use $extended as blueprint and reset the internals
// mimicking the ctor to avoid a new allocation.
$extended._ext = 0;
$extended._urlId = 0;
$extended.payload = undefined;
return $extended;
}
/**
* Set data at `index` to `cell`.
*/
public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache();
this._cacheValid = false;
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
}
@@ -242,13 +249,14 @@ export class BufferLine implements IBufferLine {
* it gets an optimized access method.
*/
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache();
this._cacheValid = false;
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended;
}
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg;
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg;
const $idx = index * Constants.CELL_INDICIES;
this._data[$idx + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[$idx + Cell.FG] = attrs.fg;
this._data[$idx + Cell.BG] = attrs.bg;
}
/**
@@ -258,7 +266,7 @@ export class BufferLine implements IBufferLine {
* by the previous `setDataFromCodePoint` call, we can omit it here.
*/
public addCodepointToCell(index: number, codePoint: number, width: number): void {
this._invalidateStringCache();
this._cacheValid = false;
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
@@ -285,7 +293,7 @@ export class BufferLine implements IBufferLine {
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
this._cacheValid = false;
pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
@@ -313,7 +321,7 @@ export class BufferLine implements IBufferLine {
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
this._cacheValid = false;
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
@@ -340,7 +348,7 @@ export class BufferLine implements IBufferLine {
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
this._cacheValid = false;
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
@@ -380,7 +388,7 @@ export class BufferLine implements IBufferLine {
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
this._invalidateStringCache();
this._cacheValid = false;
if (cols === this.length) {
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
@@ -440,7 +448,7 @@ export class BufferLine implements IBufferLine {
/** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
this._cacheValid = false;
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
@@ -458,8 +466,7 @@ export class BufferLine implements IBufferLine {
}
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
this._invalidateStringCache();
public copyFrom(line: BufferLine, blank?: boolean): void {
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
} else {
@@ -467,16 +474,29 @@ export class BufferLine implements IBufferLine {
this._data.set(line._data);
}
this.length = line.length;
this._copySparseMapsFrom(line);
if (blank) {
// a blank line may never hold combined or extended attrs,
// thus we can skip handling them
this._combined = {};
this._extendedAttrs = {};
} else {
this._copySparseMapsFrom(line);
}
this._cache = '';
this._cacheValid = false;
this.isWrapped = line.isWrapped;
}
/** create a new clone */
public clone(): IBufferLine {
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
public clone(blank?: boolean): IBufferLine {
const newLine = new BufferLine(0, undefined, false);
newLine._data = new Uint32Array(this._data);
newLine.length = this.length;
newLine._copySparseMapsFrom(this);
if (!blank) {
// a blank line may never hold combined or extended attrs,
// thus we can skip handling them
newLine._copySparseMapsFrom(this);
}
newLine.isWrapped = this.isWrapped;
return newLine;
}
@@ -500,7 +520,7 @@ export class BufferLine implements IBufferLine {
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache();
this._cacheValid = false;
const srcData = src._data;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
@@ -534,17 +554,13 @@ export class BufferLine implements IBufferLine {
* returned string, the corresponding entries in `outColumns` will have the same column number.
*/
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
if (isCanonicalRequest) {
this._stringCache.touch?.();
}
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
if (isCanonical && this._cacheValid) {
if (trimRight) {
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
return this._cacheTrimmed ? this._cache : this._cache.trimEnd();
}
if (!stringCacheEntry.isTrimmed) {
return stringCacheEntry.value;
if (!this._cacheTrimmed) {
return this._cache;
}
}
startCol = startCol ?? 0;
@@ -555,12 +571,12 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.length = 0;
}
$translateToStringBuilder.reset();
const cellContents: string[] = [];
while (startCol < endCol) {
const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
$translateToStringBuilder.append(chars);
cellContents.push(chars);
if (outColumns) {
for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol);
@@ -571,39 +587,15 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.push(startCol);
}
const result = $translateToStringBuilder.toString();
$translateToStringBuilder.reset();
if (isCanonicalRequest) {
const cacheEntry = this._getStringCacheEntry(true)!;
cacheEntry.value = result;
cacheEntry.isTrimmed = !!trimRight;
const result = cellContents.join('');
if (isCanonical) {
this._cache = result;
this._cacheValid = true;
this._cacheTrimmed = !!trimRight;
}
return result;
}
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
const cachedEntry = this._stringCacheEntryRef?.deref();
if (cachedEntry) {
if (cachedEntry.generation === this._stringCache.generation) {
return cachedEntry;
}
}
if (!createIfNeeded) {
return undefined;
}
const cacheEntry = this._stringCache.allocateEntry();
this._stringCacheEntryRef = new WeakRef(cacheEntry);
return cacheEntry;
}
private _invalidateStringCache(): void {
const cacheEntry = this._getStringCacheEntry(false);
if (cacheEntry) {
cacheEntry.value = undefined;
cacheEntry.isTrimmed = false;
}
}
/** Copy sparse map entries for a single cell when `_data` flags require them. */
private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void {
const srcStart = srcCol * Constants.CELL_INDICIES;
@@ -1,69 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from './BufferLine';
import { disposableTimeout } from '../Async';
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../Lifecycle';
const enum Constants {
CACHE_TTL_MS = 15000
}
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
public generation: number = 0;
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
private _lastAccessTimestamp: number = 0;
constructor() {
super();
this._register(toDisposable(() => this.entries.clear()));
}
public touch(): void {
this._scheduleClear();
}
public allocateEntry(): IBufferLineStringCacheEntry {
const entry: IBufferLineStringCacheEntry = {
value: undefined,
isTrimmed: false,
generation: this.generation
};
this.entries.add(entry);
this._scheduleClear();
return entry;
}
public clear(): void {
this._clearTimeout.clear();
this._lastAccessTimestamp = 0;
this.generation++;
for (const entry of this.entries) {
entry.value = undefined;
entry.isTrimmed = false;
}
this.entries.clear();
}
private _scheduleClear(): void {
this._lastAccessTimestamp = Date.now();
if (this._clearTimeout.value) {
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
}
private _scheduleClearTimeout(timeoutMs: number): void {
this._clearTimeout.value = disposableTimeout(() => {
const elapsed = Date.now() - this._lastAccessTimestamp;
if (elapsed >= Constants.CACHE_TTL_MS) {
this.clear();
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
}, timeoutMs);
}
}
+7 -9
View File
@@ -5,18 +5,16 @@
import { assert } from 'chai';
import { CircularList } from '../CircularList';
import { BufferLine } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants';
import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
import { IBufferLine } from './Types';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('BufferReflow', () => {
describe('reflowSmallerGetNewLineLengths', () => {
it('should return correct line lengths for a small line with wide characters', () => {
const line = new BufferLine(TEST_STRING_CACHE, 4);
const line = new BufferLine(4);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -26,7 +24,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
});
it('should return correct line lengths for a large line with wide characters', () => {
const line = new BufferLine(TEST_STRING_CACHE, 12);
const line = new BufferLine(12);
for (let i = 0; i < 12; i += 4) {
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -48,7 +46,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语');
});
it('should return correct line lengths for a string with wide and single characters', () => {
const line = new BufferLine(TEST_STRING_CACHE, 6);
const line = new BufferLine(6);
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(2, [0, '', 0, 0]);
@@ -62,14 +60,14 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b');
});
it('should return correct line lengths for a wrapped line with wide and single characters', () => {
const line1 = new BufferLine(TEST_STRING_CACHE, 6);
const line1 = new BufferLine(6);
line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line1.set(2, [0, '', 0, 0]);
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
line1.set(4, [0, '', 0, 0]);
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]);
const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true);
const line2 = new BufferLine(6, undefined, true);
line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line2.set(2, [0, '', 0, 0]);
@@ -84,7 +82,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b');
});
it('should work on lines ending in null space', () => {
const line = new BufferLine(TEST_STRING_CACHE, 5);
const line = new BufferLine(5);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -102,7 +100,7 @@ describe('BufferReflow', () => {
function createWrappedLines(chars: string): CircularList<IBufferLine> {
const lines = new CircularList<IBufferLine>(chars.length);
for (let i = 0; i < chars.length; i++) {
const line = new BufferLine(TEST_STRING_CACHE, 1);
const line = new BufferLine(1);
line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]);
line.isWrapped = i > 0;
lines.push(line);
-41
View File
@@ -82,45 +82,4 @@ describe('BufferSet', () => {
assert.equal(bufferSet.alt.markers.length, 0);
});
});
describe('lifecycle', () => {
it('should dispose previous buffers on reset', () => {
const oldNormal = bufferSet.normal as any;
oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
oldNormal.translateBufferLineToString(0, false);
const oldCache = oldNormal._stringCache;
assert.equal(oldCache.entries.size, 1);
assert.notEqual(oldCache._clearTimeout.value, undefined);
bufferSet.reset();
assert.notEqual(bufferSet.normal, oldNormal);
assert.equal(oldCache.entries.size, 0);
assert.equal(oldCache._clearTimeout.value, undefined);
});
it('should dispose both buffers when disposed', () => {
const normal = bufferSet.normal as any;
normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
normal.translateBufferLineToString(0, false);
bufferSet.activateAltBuffer();
const alt = bufferSet.alt as any;
alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
alt.translateBufferLineToString(0, false);
const normalCache = normal._stringCache;
const altCache = alt._stringCache;
assert.notEqual(normalCache._clearTimeout.value, undefined);
assert.notEqual(altCache._clearTimeout.value, undefined);
bufferSet.dispose();
assert.equal(normalCache.entries.size, 0);
assert.equal(altCache.entries.size, 0);
assert.equal(normalCache._clearTimeout.value, undefined);
assert.equal(altCache._clearTimeout.value, undefined);
});
});
});
+4 -2
View File
@@ -20,6 +20,7 @@ export interface IExtendedAttrs {
underlineColor: number;
underlineVariantOffset: number;
urlId: number;
payload: Object | undefined;
clone(): IExtendedAttrs;
isEmpty(): boolean;
}
@@ -131,8 +132,8 @@ export interface IBufferLine {
resize(cols: number, fill: ICellData): boolean;
cleanupMemory(): number;
fill(fillCellData: ICellData, respectProtect?: boolean): void;
copyFrom(line: IBufferLine): void;
clone(): IBufferLine;
copyFrom(line: IBufferLine, blank?: boolean): void;
clone(blank?: boolean): IBufferLine;
getTrimmedLength(): number;
getNoBgTrimmedLength(): number;
translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string;
@@ -146,6 +147,7 @@ export interface IBufferLine {
getCodePoint(index: number): number;
isCombined(index: number): number;
getString(index: number): string;
getExtended(index: number): IExtendedAttrs;
}
export interface IMarker extends IDisposable {
+45 -19
View File
@@ -79,7 +79,7 @@ function assertDecodedRange(
}
const target = new Uint32Array(count);
const length = decode(input, target);
assert.equal(length, count);
assert.strictEqual(length, count);
let mismatchIndex = -1;
let index = 0;
for (let i = min; i < max; ++i) {
@@ -92,8 +92,8 @@ function assertDecodedRange(
}
index++;
}
assert.equal(mismatchIndex, -1);
assert.equal(outputToString(target, length), input);
assert.strictEqual(mismatchIndex, -1);
assert.strictEqual(outputToString(target, length), input);
}
const BATCH_SIZE = 8192;
@@ -116,9 +116,9 @@ describe('text encodings', () => {
const data = new Uint32Array(s.length);
for (let i = 0; i < s.length; ++i) {
data[i] = s.charCodeAt(i);
assert.equal(stringFromCodePoint(data[i]), s[i]);
assert.strictEqual(stringFromCodePoint(data[i]), s[i]);
}
assert.equal(utf32ToString(data), s);
assert.strictEqual(utf32ToString(data), s);
});
describe('StringToUtf32 decoder', () => {
@@ -159,7 +159,7 @@ describe('text encodings', () => {
const decoder = new StringToUtf32();
const target = new Uint32Array(5);
const length = decoder.decode(String.fromCharCode(0xFEFF), target);
assert.equal(length, 0);
assert.strictEqual(length, 0);
decoder.clear();
});
});
@@ -169,7 +169,7 @@ describe('text encodings', () => {
const target = new Uint32Array(500);
for (let i = 0; i < TEST_STRINGS.length; ++i) {
const length = decoder.decode(TEST_STRINGS[i], target);
assert.equal(toString(target, length), TEST_STRINGS[i]);
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
decoder.clear();
}
});
@@ -184,7 +184,7 @@ describe('text encodings', () => {
const written = decoder.decode(input[i], target);
decoded += toString(target, written);
}
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
});
});
});
@@ -226,7 +226,7 @@ describe('text encodings', () => {
const target = new Uint32Array(5);
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
const length = decoder.decode(utf8Data, target);
assert.equal(length, 0);
assert.strictEqual(length, 0);
decoder.clear();
});
});
@@ -237,7 +237,7 @@ describe('text encodings', () => {
for (let i = 0; i < TEST_STRINGS.length; ++i) {
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
const length = decoder.decode(utf8Data, target);
assert.equal(toString(target, length), TEST_STRINGS[i]);
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
decoder.clear();
}
});
@@ -252,7 +252,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written);
}
assert(decoded, 'ÄÖÜßöäü');
assert.strictEqual(decoded, 'ÄÖÜßöäü');
});
it('2/3 byte sequences - advance by 1', () => {
@@ -264,7 +264,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written);
}
assert(decoded, 'Āր܀߀ö€ä€ü');
assert.strictEqual(decoded, 'Āր܀߀ö€ä€ü');
});
it('2/3/4 byte sequences - advance by 1', () => {
@@ -276,7 +276,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written);
}
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
});
it('2/3/4 byte sequences - advance by 2', () => {
@@ -288,7 +288,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written);
}
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
});
it('2/3/4 byte sequences - advance by 3', () => {
@@ -300,7 +300,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 3), target);
decoded += toString(target, written);
}
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
});
it('BOMs (3 byte sequences) - advance by 2', () => {
@@ -312,7 +312,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written);
}
assert.equal(decoded, '');
assert.strictEqual(decoded, '');
});
it('test break after 3 bytes - issue #2495', () => {
@@ -320,11 +320,37 @@ describe('text encodings', () => {
const target = new Uint32Array(5);
const utf8Data = fromByteString('\xf0\xa0\x9c\x8e');
let written = decoder.decode(utf8Data.slice(0, 3), target);
assert.equal(written, 0);
assert.strictEqual(written, 0);
written = decoder.decode(utf8Data.slice(3), target);
assert.equal(written, 1);
assert(toString(target, written), '𠜎');
assert.strictEqual(written, 1);
assert.strictEqual(toString(target, written), '𠜎');
});
describe('0x80 not swallowed in continuation', () => {
it('A—B', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(5);
const utf8Data = new TextEncoder().encode('A—BA—BA—BA—BA—B');
let decoded = '';
for (let i = 0; i < utf8Data.length; i += 2) {
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written);
}
assert.strictEqual(decoded, 'A—BA—BA—BA—BA—B');
});
it('A𐀀B', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(5);
const utf8Data = new TextEncoder().encode('A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
let decoded = '';
for (let i = 0; i < utf8Data.length; i += 2) {
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written);
}
assert.strictEqual(decoded, 'A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
});
});
});
});
});
+2 -2
View File
@@ -158,9 +158,9 @@ export class Utf8ToUtf32 {
cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07);
let pos = 0;
let tmp: number;
while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) {
while ((tmp = this.interim[++pos]) && pos < 4) {
cp <<= 6;
cp |= tmp;
cp |= tmp & 0x3F;
}
// missing bytes - read ahead from input
const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4;
+4 -4
View File
@@ -86,12 +86,12 @@ export class BufferService extends Disposable implements IBufferService {
// Insert the line using the fastest method
if (bottomRow === buffer.lines.length - 1) {
if (willBufferBeTrimmed) {
buffer.lines.recycle().copyFrom(newLine);
buffer.lines.recycle().copyFrom(newLine, true);
} else {
buffer.lines.push(newLine.clone());
buffer.lines.push(newLine.clone(true));
}
} else {
buffer.lines.splice(bottomRow + 1, 0, newLine.clone());
buffer.lines.splice(bottomRow + 1, 0, newLine.clone(true));
}
// Only adjust ybase and ydisp when the buffer is not trimmed
@@ -113,7 +113,7 @@ export class BufferService extends Disposable implements IBufferService {
// scrollback, instead we can just shift them in-place.
const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */;
buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1);
buffer.lines.set(bottomRow, newLine.clone());
buffer.lines.set(bottomRow, newLine.clone(true));
}
// Move the viewport to the bottom of the buffer unless the user is
+1 -1
View File
@@ -214,7 +214,7 @@ export class DecorationLineCache extends Disposable {
}
private _handleBufferLinesTrim(amount: number): void {
if (amount <= 0) {
if (amount <= 0 || !this._decorationsByLine.size) {
return;
}
const newMap = new Map<number, IInternalDecoration[]>();
-4
View File
@@ -99,10 +99,6 @@ export class Terminal extends CoreTerminal {
* 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;