Compare commits

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Fixes #6006

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

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

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

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

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

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

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

---------

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

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

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

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

* refactor(addon-webgl): extract _nullModelCell helper

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

---------

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

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

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

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

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

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

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

---------

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

* rename type helpers

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 07:05:19 -07:00
64718444e2 fix(webgl): avoid glyph atlas mipmaps (#5987)
Co-authored-by: Will Hampson <whamp@ggl.slmail.me>
Co-authored-by: jerch <jerch@rockborn.de>
2026-06-03 06:00:44 -07:00
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
86 changed files with 2743 additions and 1328 deletions
+34 -27
View File
@@ -6,14 +6,19 @@ on:
pull_request: pull_request:
branches: [ "master" ] branches: [ "master" ]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
@@ -69,7 +74,7 @@ jobs:
./addons/addon-webgl/out/* \ ./addons/addon-webgl/out/* \
./addons/addon-webgl/out-*st/* ./addons/addon-webgl/out-*st/*
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: build-artifacts name: build-artifacts
path: compressed-build.zip path: compressed-build.zip
@@ -79,9 +84,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
@@ -100,16 +107,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: | run: |
npm ci npm ci
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -129,6 +138,7 @@ jobs:
exit $EXIT_CODE exit $EXIT_CODE
test-unit: test-unit:
needs: build
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
matrix: matrix:
@@ -136,21 +146,18 @@ jobs:
runs-on: [ubuntu, macos, windows] runs-on: [ubuntu, macos, windows]
runs-on: ${{ matrix.runs-on }}-latest runs-on: ${{ matrix.runs-on }}-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: ${{ matrix.node-version }}.x node-version: ${{ matrix.node-version }}.x
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: | run: |
npm ci npm ci
- name: Wait for build job - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -166,6 +173,7 @@ jobs:
run: npm run test-unit --forbid-only run: npm run test-unit --forbid-only
test-integration: test-integration:
needs: build
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
matrix: matrix:
@@ -174,9 +182,11 @@ jobs:
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: ${{ matrix.node-version }}.x node-version: ${{ matrix.node-version }}.x
cache: 'npm' cache: 'npm'
@@ -185,12 +195,7 @@ jobs:
npm ci npm ci
- name: Install playwright - name: Install playwright
run: npx playwright install --with-deps ${{ matrix.browser }} run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Wait for build job - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -240,9 +245,11 @@ jobs:
matrix: matrix:
node-version: [22] node-version: [22]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: ${{ matrix.node-version }}.x node-version: ${{ matrix.node-version }}.x
cache: 'npm' cache: 'npm'
@@ -251,7 +258,7 @@ jobs:
npm ci npm ci
- name: Install playwright - name: Install playwright
run: npx playwright install run: npx playwright install
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
+11 -4
View File
@@ -8,6 +8,11 @@ on:
schedule: schedule:
- cron: '41 17 * * 0' - cron: '41 17 * * 0'
permissions:
actions: read
contents: read
security-events: write
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
@@ -24,12 +29,14 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
- name: Perform CodeQL Analysis - 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: paths:
- .github/workflows/copilot-setup-steps.yml - .github/workflows/copilot-setup-steps.yml
permissions:
contents: read
jobs: jobs:
copilot-setup-steps: copilot-setup-steps:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -18,9 +21,11 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
+7 -2
View File
@@ -6,13 +6,18 @@ on:
# without running tests to save time to publish # without running tests to save time to publish
branches: [ "master" ] branches: [ "master" ]
permissions:
contents: read
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@v3 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' 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. - [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders. - [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [**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) - [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. 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 { export class Base64 implements IBase64 {
public encodeText(data: string): string { 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 { public decodeText(data: string): string {
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
try {
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
} catch {}
return '';
}
try { 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 {} } catch {}
return ''; return '';
} }
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']); await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
} }
await ctx.page.evaluate(` await ctx.page.evaluate(`
window.term.reset() window.term.reset();
window.clipboard?.dispose(); window.clipboard?.dispose();
window.clipboard = new ClipboardAddon(); window.clipboard = new ClipboardAddon();
window.term.loadAddon(window.clipboard); window.term.loadAddon(window.clipboard);
@@ -92,4 +92,22 @@ test.describe('ClipboardAddon', () => {
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); 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}`. Set by default, change it with `{iipSupport: true}`.
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions): 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. - No animation support.
- Image type hinting is not supported (always deducted from data header). - Image type hinting is not supported (always deducted from data header).
- File download is not supported. - File download is not supported.
@@ -112,6 +112,13 @@ terminal.loadAddon(imageAddon);
- ReportCellSize sequence is supported. - ReportCellSize sequence is supported.
- Hi-res display support is currently limited (image gets blurred). - 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)** - **Kitty Graphics Support (TGP)**
Set by default, change it with `{kittySupport: true}`. 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

+49 -41
View File
@@ -3,13 +3,13 @@
* @license MIT * @license MIT
*/ */
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage'; import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage'; import { CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm'; import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser'; import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; import { imageType, UNSUPPORTED_TYPE } from './Metrics';
import { Drawable } from './Primitives';
// Local const enum mirror - esbuild can't inline const enums from external packages // Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst { const enum DecoderConst {
@@ -39,13 +39,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _header: IHeaderFields = DEFAULT_HEADER; private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder; private _dec: Base64Decoder;
private _qoiDec: QoiDecoder; private _qoiDec: QoiDecoder;
private _metrics = UNSUPPORTED_TYPE;
private _isMultipart = false; private _isMultipart = false;
private _abortMulti = false; private _abortMulti = false;
constructor( constructor(
private readonly _opts: IImageAddonOptions, private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage, private readonly _storage: IIPImageStorage,
private readonly _coreTerminal: ITerminalExt private readonly _coreTerminal: ITerminalExt
) { ) {
@@ -63,7 +61,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public start(): void { public start(): void {
this._aborted = false; this._aborted = false;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset(); this._hp.reset();
} }
@@ -120,14 +117,16 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (seqType === SequenceType.REPORTCELLSIZE) { if (seqType === SequenceType.REPORTCELLSIZE) {
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
let width = CELL_SIZE_DEFAULT.width; // IMPORTANT: ReportCellSize uses logical points (CSS pixels)
let height = CELL_SIZE_DEFAULT.height; let w = CELL_SIZE_DEFAULT.width;
if (this._renderer.dimensions) { let h = CELL_SIZE_DEFAULT.height;
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols; const dimensions = this._coreTerminal.dimensions;
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows; if (dimensions) {
w = dimensions.css.canvas.width / this._coreTerminal.cols;
h = dimensions.css.canvas.height / this._coreTerminal.rows;
} }
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1; const 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); this._coreTerminal.input(report, false);
return true; return true;
} }
@@ -149,22 +148,21 @@ export class IIPHandler implements IOscHandler, IResetHandler {
// fallthrough for SequenceType.FILE & SequenceType.FILEEND // fallthrough for SequenceType.FILE & SequenceType.FILEEND
let w = 0;
let h = 0;
// early exit condition chain // early exit condition chain
let cond: number | boolean; let cond: number | boolean;
let metrics = UNSUPPORTED_TYPE;
if (cond = success) { if (cond = success) {
if (cond = !this._dec.end()) { if (cond = !this._dec.end()) {
this._metrics = imageType(this._dec.data8); metrics = imageType(this._dec.data8);
if (cond = this._metrics.mime !== 'unsupported') { if (cond = metrics.mime !== 'unsupported') {
w = this._metrics.width; if (!(cond = metrics.width && metrics.height && metrics.width * metrics.height < this._opts.pixelLimit)) {
h = this._metrics.height; console.warn(`IIP: image dimension issue ${metrics.width}x${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: unsupported image type');
} }
} else {
console.warn('IIP: error during BASE64 decoding');
} }
} }
if (!cond) { if (!cond) {
@@ -172,42 +170,52 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true; return true;
} }
let blob: Blob | ImageData; let bmSrc: Blob | ImageData;
if (this._metrics.mime === 'image/qoi') { let imgBlob: Blob;
if (metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8); const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData( bmSrc = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width, this._qoiDec.width,
this._qoiDec.height this._qoiDec.height
); );
this._qoiDec.release(); this._qoiDec.release();
if (w === this._qoiDec.width && h === this._qoiDec.height) {
// use fast-path if we don't need to rescale
this._dec.release();
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
canvas.getContext('2d')?.putImageData(blob, 0, 0);
this._storage.addImage(canvas);
return true;
}
} else { } else {
blob = new Blob([this._dec.data8], { type: this._metrics.mime }); imgBlob = bmSrc = new Blob([this._dec.data8], { type: metrics.mime });
} }
this._dec.release(); this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) return createImageBitmap(bmSrc)
.then(bm => { .then(bm => {
this._storage.addImage(bm); const [w, h] = this._resize(metrics.width, metrics.height);
this._storage.addImage(new Drawable(bm), imgBlob, metrics, w / metrics.width, h / metrics.height);
return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
return true; return true;
}); });
} }
private _resize(w: number, h: number): [number, number] { private _resize(w: number, h: number): [number, number] {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; let cw;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; let ch;
const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols; let width;
const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows; let height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
width = dimensions.device.canvas.width;
height = dimensions.device.canvas.height;
cw = width / this._coreTerminal.cols;
ch = height / this._coreTerminal.rows;
} else {
cw = CELL_SIZE_DEFAULT.width;
ch = CELL_SIZE_DEFAULT.height;
width = cw * this._coreTerminal.cols;
height = ch * this._coreTerminal.rows;
}
const rw = this._dim(this._header.width!, width, cw); const rw = this._dim(this._header.width ?? '', width, cw);
const rh = this._dim(this._header.height!, height, ch); const rh = this._dim(this._header.height ?? '', height, ch);
if (!rw && !rh) { if (!rw && !rh) {
const wf = width / w; // TODO: should this respect initial cursor offset? const wf = width / w; // TODO: should this respect initial cursor offset?
const hf = (height - ch) / h; // TODO: fix offset issues from float cell height const hf = (height - ch) / h; // TODO: fix offset issues from float cell height
+5 -3
View File
@@ -3,7 +3,7 @@
* @license MIT * @license MIT
*/ */
import { IAddImageOpts } from './Types'; import { IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageStorage } from './ImageStorage'; import { ImageStorage } from './ImageStorage';
/** /**
@@ -22,7 +22,9 @@ export class IIPImageStorage {
* Add an IIP image to storage. * Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image. * Always uses scrolling mode — cursor advances past the image.
*/ */
public addImage(img: HTMLCanvasElement | ImageBitmap): void { public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, prescaleX: number, prescaleY: number): void {
this._storage.addImage(img, this._addImageOpts); this._addImageOpts.prescaleX = prescaleX;
this._addImageOpts.prescaleY = prescaleY;
this._storage.addImage(src, data, metrics, this._addImageOpts);
} }
} }
-84
View File
@@ -1,84 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 24) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
height: d[9] << 8 | d[8]
};
}
// QOI: qoif
if (d32[0] === 0x66696F71) {
return {
mime: 'image/qoi',
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
};
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+6 -7
View File
@@ -144,6 +144,9 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
terminal.options.windowOptions = windowOps; terminal.options.windowOptions = windowOps;
} }
// apply placeholder setting
this._renderer.showPlaceholder(this._opts.showPlaceholder);
this._disposeLater( this._disposeLater(
this._renderer, this._renderer,
this._storage, this._storage,
@@ -187,7 +190,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler // iTerm IIP handler
if (this._opts.iipSupport) { if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!); const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal); const iipHandler = new IIPHandler(this._opts, iipStorage, terminal);
this._handlers.set('iip', iipHandler); this._handlers.set('iip', iipHandler);
this._disposeLater( this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler) terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
@@ -250,10 +253,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
return this._storage?.getImageAtBufferCell(x, y); return this._storage?.getImageAtBufferCell(x, y);
} }
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
return this._storage?.extractTileAtBufferCell(x, y);
}
private _report(s: string): void { private _report(s: string): void {
this._terminal?._core.input(s, false); this._terminal?._core.input(s, false);
} }
@@ -344,8 +343,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
switch (params[1]) { switch (params[1]) {
// we only implement read and read_max here // we only implement read and read_max here
case GaAction.READ: case GaAction.READ:
let width = this._renderer?.dimensions?.css.canvas.width; let width = this._renderer?.dimensions?.device.canvas.width;
let height = this._renderer?.dimensions?.css.canvas.height; let height = this._renderer?.dimensions?.device.canvas.height;
if (!width || !height) { if (!width || !height) {
// for some reason we have no working image renderer // for some reason we have no working image renderer
// --> fallback to default cell size // --> fallback to default cell size
+106 -211
View File
@@ -3,19 +3,14 @@
* @license MIT * @license MIT
*/ */
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm'; import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types'; import type { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createCanvas } from './Primitives';
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
/** /**
* ImageRenderer - terminal frontend extension: * ImageRenderer - terminal frontend extension:
* - provide primitives for canvas, ImageData, Bitmap (static)
* - add canvas layer to DOM (browser only for now) * - add canvas layer to DOM (browser only for now)
* - draw image tiles onRender * - draw image tiles onRender
*/ */
@@ -24,51 +19,14 @@ export class ImageRenderer extends Disposable implements IDisposable {
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; } public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>(); private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
private _placeholder: HTMLCanvasElement | undefined; private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable()); private _optionsRefresh = this._register(new MutableDisposable());
private _oldOpen: ((parent: HTMLElement) => void) | undefined; private _oldOpen: ((parent: HTMLElement) => void) | undefined;
private _renderService: IRenderService | undefined; private _renderService: IRenderService | undefined;
private _oldSetRenderer: ((renderer: any) => void) | undefined; private _oldSetRenderer: ((renderer: any) => void) | undefined;
// drawing primitive - canvas // some local variables for faster access
public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement { private _dimensions: IRenderDimensions | undefined;
/** private _cellSize: ICellSize | undefined;
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
return canvas;
}
// drawing primitive - ImageData with optional buffer
public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {
if (typeof ImageData !== 'function') {
const imgData = ctx.createImageData(width, height);
if (buffer) {
imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));
}
return imgData;
}
return buffer
? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)
: new ImageData(width, height);
}
// drawing primitive - ImageBitmap
public static createImageBitmap(img: ImageBitmapSource): Promise<ImageBitmap | undefined> {
if (typeof createImageBitmap !== 'function') {
return Promise.resolve(undefined);
}
return createImageBitmap(img);
}
constructor(private _terminal: ITerminalExt) { constructor(private _terminal: ITerminalExt) {
super(); super();
@@ -100,8 +58,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
} }
this._renderService = undefined; this._renderService = undefined;
this._layers.clear(); this._layers.clear();
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined; this._placeholder = undefined;
})); }));
} }
@@ -110,15 +66,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Enable the placeholder. * Enable the placeholder.
*/ */
public showPlaceholder(value: boolean): void { public showPlaceholder(value: boolean): void {
if (value) { this._placeholder = value ? this._createPlaceHolder() : undefined;
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}
this._renderService?.refreshRows(0, this._terminal.rows); this._renderService?.refreshRows(0, this._terminal.rows);
} }
@@ -131,22 +79,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
} }
/** /**
* Current cell size (float). * Current cell size.
*/ */
public get cellSize(): ICellSize { public getCellSize(dimensions?: IRenderDimensions): ICellSize | undefined {
return { dimensions ??= this._terminal.dimensions;
width: this.dimensions?.css.cell.width || -1, if (dimensions) {
height: this.dimensions?.css.cell.height || -1 return {
}; width: dimensions.device.canvas.width / this._terminal.cols,
height: dimensions.device.canvas.height / this._terminal.rows,
};
}
} }
/** /**
* Clear a region of the image layer canvas. * Clear a region of the image layer canvas.
*/ */
public clearLines(start: number, end: number, layer?: ImageLayer): void { public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0); const deviceGrid = this._cellSize;
const w = this.dimensions?.css.canvas.width || 0; if (!deviceGrid || !this._dimensions) return;
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0); const y = Math.floor(start * deviceGrid.height);
const w = this._dimensions.device.canvas.width;
const h = Math.ceil((end + 1 - start) * deviceGrid.height);
if (!layer || layer === 'top') { if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h); this._layers.get('top')?.clearRect(0, y, w, h);
} }
@@ -174,142 +127,103 @@ export class ImageRenderer extends Disposable implements IDisposable {
*/ */
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void { public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer); const ctx = this._layers.get(imgSpec.layer);
if (!ctx) { const initialGrid = imgSpec.cellSize;
return; const deviceGrid = this._cellSize;
}
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics. if (!ctx || !imgSpec.src.native || !deviceGrid) {
if (width === -1 || height === -1) {
return; return;
} }
this._rescaleImage(imgSpec, width, height); const img = imgSpec.src;
const img = imgSpec.actual!; const cols = Math.ceil(img.width * imgSpec.prescaleX / initialGrid.width);
const cols = Math.ceil(img.width / width);
const sx = (tileId % cols) * width; const gridWidth = initialGrid.width / imgSpec.prescaleX;
const sy = Math.floor(tileId / cols) * height; const gridHeight = initialGrid.height / imgSpec.prescaleY;
const dx = col * width;
const dy = row * height;
// safari bug: never access image source out of bounds // crops are initialGrid px
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width; const sx = (tileId % cols) * gridWidth + imgSpec.precropX;
const finalHeight = sy + height > img.height ? img.height - sy : height; const sy = Math.floor(tileId / cols) * gridHeight + imgSpec.precropY;
const dx = Math.floor(col * deviceGrid.width + imgSpec.offsetX);
const dy = Math.floor(row * deviceGrid.height + imgSpec.offsetY);
// safari bug: never access image source out of bounds, thus we clamp its dimensions
const sWidth = Math.min(count * gridWidth, img.width - sx);
const sHeight = Math.min(gridHeight, img.height - sy);
const dWidth = Math.ceil(sWidth / gridWidth * deviceGrid.width * imgSpec.scaleX);
const dHeight = Math.ceil(sHeight / gridHeight * deviceGrid.height * imgSpec.scaleY);
if (imgSpec.blendMode === 'overwrite') {
ctx.clearRect(dx, dy, dWidth, dHeight);
}
// Floor all pixel offsets to get stable tile mapping without any overflows. // Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer // Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it). // this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage( ctx.drawImage(
img, img.native,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight), Math.floor(sx), Math.floor(sy), Math.ceil(sWidth), Math.ceil(sHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight) dx, dy, dWidth, dHeight
); );
} }
/**
* Extract a single tile from an image.
*/
public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const finalWidth = width + sx > img.width ? img.width - sx : width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
);
return canvas;
}
}
/** /**
* Draw a line with placeholder on the image layer canvas. * Draw a line with placeholder on the image layer canvas.
*/ */
public drawPlaceholder(col: number, row: number, count: number = 1): void { public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top'); const ctx = this._layers.get('top');
if (ctx) { const deviceGrid = this._cellSize;
const { width, height } = this.cellSize; if (!ctx || !deviceGrid) {
return;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
width * count,
height,
col * width,
row * height,
width * count,
height
);
} }
this._placeholder ??= this._createPlaceHolder();
if (!this._placeholder) return;
ctx.drawImage(
this._placeholder,
0, 0, 1, 1,
col * deviceGrid.width,
row * deviceGrid.height,
count * deviceGrid.width,
deviceGrid.height
);
} }
/** /**
* Rescale image layer canvas if needed. * Rescale image layer canvas if needed.
* Checked once from `ImageStorage.render`. * Checked once from `ImageStorage.render`.
* NOTE: This method updates dimensions on instance properties
* for faster access during draw and clear calls.
* So make sure to always call this before doing any draws.
*/ */
public rescaleCanvas(): void { public rescaleCanvas(force: boolean = false): void {
const w = this.dimensions?.css.canvas.width || 0; const dimensions = this._terminal.dimensions;
const h = this.dimensions?.css.canvas.height || 0; if (dimensions) {
for (const ctx of this._layers.values()) { const cssW = dimensions.css.canvas.width;
if (ctx.canvas.width !== w || ctx.canvas.height !== h) { const cssH = dimensions.css.canvas.height;
ctx.canvas.width = w; const devW = dimensions.device.canvas.width;
ctx.canvas.height = h; const devH = dimensions.device.canvas.height;
let recalc = force;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== devW
|| ctx.canvas.height !== devH
|| ctx.canvas.style.width !== `${cssW}px`
|| ctx.canvas.style.height !== `${cssH}px`
) {
ctx.canvas.width = devW;
ctx.canvas.height = devH;
ctx.canvas.style.width = `${cssW}px`;
ctx.canvas.style.height = `${cssH}px`;
recalc = true;
}
} }
} if (recalc) {
} this._dimensions = dimensions;
this._cellSize = this.getCellSize(dimensions);
/** }
* Rescale image in storage if needed. } else {
*/ // we were not able to get render dimensions:
private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void { // set cellsize to undefined so we don't try to draw anything
if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) { this._cellSize = this.getCellSize();
return;
}
const { width: originalWidth, height: originalHeight } = spec.origCellSize;
if (currentWidth === originalWidth && currentHeight === originalHeight) {
spec.actual = spec.orig;
spec.actualCellSize.width = originalWidth;
spec.actualCellSize.height = originalHeight;
return;
}
const canvas = ImageRenderer.createCanvas(
this.document,
Math.ceil(spec.orig!.width * currentWidth / originalWidth),
Math.ceil(spec.orig!.height * currentHeight / originalHeight)
);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
spec.actual = canvas;
spec.actualCellSize.width = currentWidth;
spec.actualCellSize.height = currentHeight;
} }
} }
@@ -336,9 +250,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
if (this._layers.has(layer)) { if (this._layers.has(layer)) {
return; return;
} }
const canvas = ImageRenderer.createCanvas( const canvas = createCanvas(
this.document, this.dimensions?.css.canvas.width || 0, this.document,
this.dimensions?.css.canvas.height || 0 this.dimensions?.device.canvas.width || 0,
this.dimensions?.device.canvas.height || 0,
this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0,
); );
canvas.classList.add(`xterm-image-layer-${layer}`); canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement; const screenElement = this._terminal._core.screenElement;
@@ -366,6 +283,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
return; return;
} }
this._layers.set(layer, ctx); this._layers.set(layer, ctx);
// force rescaling to update stored cellSize (maybe not be populated yet)
this.rescaleCanvas(true);
this.clearAll(layer); this.clearAll(layer);
} }
@@ -381,41 +300,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
return this._layers.has(layer); return this._layers.has(layer);
} }
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void { /**
this._placeholderBitmap?.close(); * Create a semi-transparent gray 1x1 placeholder
this._placeholderBitmap = undefined; */
private _createPlaceHolder(): HTMLCanvasElement | undefined {
// create blueprint to fill placeholder with const imgData = new ImageData(1, 1);
const bWidth = 32; // must be 2^n new Uint32Array(imgData.data.buffer).fill(128 << 24 | 128 << 16 | 128 << 8 | 128);
const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height); const canvas = createCanvas(this.document, 1, 1);
const ctx = blueprint.getContext('2d', { alpha: false }); const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return; if (!ctx) return;
const imgData = ImageRenderer.createImageData(ctx, bWidth, height);
const d32 = new Uint32Array(imgData.data.buffer);
const black = toRGBA8888(0, 0, 0);
const white = toRGBA8888(255, 255, 255);
d32.fill(black);
for (let y = 0; y < height; ++y) {
const shift = y % 2;
const offset = y * bWidth;
for (let x = 0; x < bWidth; x += 2) {
d32[offset + x + shift] = white;
}
}
ctx.putImageData(imgData, 0, 0); ctx.putImageData(imgData, 0, 0);
return canvas;
// create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
this._placeholder = undefined;
return;
}
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
} }
public get document(): Document | undefined { public get document(): Document | undefined {
+140 -235
View File
@@ -5,12 +5,14 @@
import { IDisposable } from '@xterm/xterm'; import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer'; import { ImageRenderer } from './ImageRenderer';
import { import type {
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts,
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, IDrawable,
UnderlineStyle, IAddImageOpts IMetrics
} from './Types'; } from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
import { createCanvas } from './Primitives';
// fallback default cell size // fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = { export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -18,93 +20,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
height: 14 height: 14
}; };
/** class ImageTileInfo {
* 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;
}
constructor( constructor(
ext: number = 0,
urlId: number = 0,
public imageId = -1, public imageId = -1,
public tileId = -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;
} }
} }
const EMPTY_ATTRS = new ExtendedAttrsImage();
/** /**
* ImageStorage - extension of CoreTerminal: * ImageStorage - extension of CoreTerminal:
@@ -125,7 +46,8 @@ export class ImageStorage implements IDisposable {
// whether render should do a full clear // whether render should do a full clear
private _needsFullClear = false; private _needsFullClear = false;
// hard limit of stored pixels (fallback limit of 10 MB) // hard limit of stored pixels (fallback limit of 10 MB)
private _pixelLimit: number = 2500000; private _byteLimit: number = 10000000;
private _workCell: CellData = new CellData();
private _viewportMetrics: { cols: number, rows: number }; private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined; public onImageAdded: (() => void) | undefined;
@@ -164,43 +86,54 @@ export class ImageStorage implements IDisposable {
this._renderer.clearAll(); this._renderer.clearAll();
} }
/**
* FIXME: below we need to introduce separation of:
* - RAM usage
* - VRAM usage
* - Blob unsage
*/
public getLimit(): number { public getLimit(): number {
return this._pixelLimit * 4 / 1000000; return this._byteLimit / 1000000;
} }
public setLimit(value: number): void { public setLimit(value: number): void {
if (value < 0.5 || value > 1000) { if (value < 0.5 || value > 1000) {
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G'); throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1GB');
} }
this._pixelLimit = (value / 4 * 1000000) >>> 0; this._byteLimit = (value * 1000000) >>> 0;
this._evictOldest(0); this._evictOldest(0);
} }
public getUsage(): number { public getUsage(): number {
return this._getStoredPixels() * 4 / 1000000; return this._getVRAMUsage() / 1000000;
} }
private _getStoredPixels(): number { private _getVRAMUsage(): number {
let storedPixels = 0; let bytes = 0;
for (const spec of this._images.values()) { for (const spec of this._images.values()) {
if (spec.orig) { bytes += spec.src.bytes;
storedPixels += spec.orig.width * spec.orig.height; }
if (spec.actual && spec.actual !== spec.orig) { return bytes;
storedPixels += spec.actual.width * spec.actual.height; }
}
public getBlobUsage(): number {
let size = 0;
for (const spec of this._images.values()) {
if (spec.data instanceof Blob) {
size += spec.data.size;
} }
} }
return storedPixels; return size;
} }
private _delImg(id: number): void { private _delImg(id: number): void {
const spec = this._images.get(id); const spec = this._images.get(id);
if (!spec) return; if (!spec) return;
this._images.delete(id); this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :( spec.src.close();
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) { spec.data = undefined;
spec.orig.close();
}
this.onImageDeleted?.(id); this.onImageDeleted?.(id);
} }
@@ -237,8 +170,10 @@ export class ImageStorage implements IDisposable {
} }
/** /**
* Method to add an image to the storage. * Method to add an drawable to the storage.
* @param img - The image to add (canvas or bitmap). * @param src - The drawable to add.
* @param data - Blob or Uint8Array of the original image data.
* @param metrics - IMetrics of the original image.
* @param opts - Options for addImage: * @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image. * - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move. * When false, image is placed at ORIGIN and cursor does not move.
@@ -247,17 +182,16 @@ export class ImageStorage implements IDisposable {
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right. * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
* @returns The internal image ID assigned to the stored image. * @returns The internal image ID assigned to the stored image.
*/ */
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number { public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, opts: IAddImageOpts): number {
// never allow storage to exceed memory limit // never allow storage to exceed memory limit
this._evictOldest(img.width * img.height); this._evictOldest(src.bytes);
// calc rows x cols needed to display the image // calc rows x cols needed to display the image
let cellSize = this._renderer.cellSize; const prescaleX = opts.prescaleX ?? 1.0;
if (cellSize.width === -1 || cellSize.height === -1) { const prescaleY = opts.prescaleY ?? 1.0;
cellSize = CELL_SIZE_DEFAULT; const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
} const cols = Math.ceil(src.width * prescaleX / cellSize.width);
const cols = Math.ceil(img.width / cellSize.width); const rows = Math.ceil(src.height * prescaleY / cellSize.height);
const rows = Math.ceil(img.height / cellSize.height);
const imageId = ++this._lastId; const imageId = ++this._lastId;
@@ -277,10 +211,10 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
for (let row = 0; row < rows; ++row) { 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) { for (let col = 0; col < cols; ++col) {
if (offset + col >= termCols) break; 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++; tileCount++;
} }
if (opts.scrolling) { if (opts.scrolling) {
@@ -334,15 +268,24 @@ export class ImageStorage implements IDisposable {
// create storage entry // create storage entry
const imgSpec: IImageSpec = { const imgSpec: IImageSpec = {
orig: img, src,
origCellSize: cellSize, data,
actual: img, metrics,
actualCellSize: { ...cellSize }, // clone needed, since later modified cellSize,
marker: endMarker || undefined, marker: endMarker || undefined,
tileCount, tileCount,
bufferType: this._terminal.buffer.active.type, bufferType: this._terminal.buffer.active.type,
layer: opts.layer, layer: opts.layer,
zIndex: opts.zIndex zIndex: opts.zIndex,
prescaleX,
prescaleY,
precropX: 0,
precropY: 0,
scaleX: 1.0,
scaleY: 1.0,
offsetX: 0,
offsetY: 0,
blendMode: 'alpha'
}; };
// finally add the image // finally add the image
@@ -427,55 +370,42 @@ export class ImageStorage implements IDisposable {
const placeholderCalls: { col: number, row: number, count: number }[] = []; const placeholderCalls: { col: number, row: number, count: number }[] = [];
// walk all cells in viewport and collect tiles found // 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) { for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt | undefined; const line = buffer.lines.get(row + buffer.ydisp);
if (!line) continue; if (!line) return;
for (let col = 0; col < cols; ++col) { for (let col = 0; col < cols; ++col) {
let e: IExtendedAttrsImage; const e = line.getExtended(col)?.payload;
if (line.getBg(col) & BgFlags.HAS_EXTENDED) { if (e instanceof ImageTileInfo) {
e = line._extendedAttrs[col] ?? EMPTY_ATTRS; const imageId = e.imageId;
} else { if (imageId === undefined || imageId === -1) {
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
continue; continue;
} }
e = maybeImg; const imgSpec = this._images.get(imageId);
} if (e.tileId !== -1) {
const imageId = e.imageId; const startTile = e.tileId;
if (imageId === undefined || imageId === -1) { const startCol = col;
continue; let count = 1;
} /**
const imgSpec = this._images.get(imageId); * merge tiles to the right into a single draw call, if:
if (e.tileId !== -1) { * - not at end of line
const startTile = e.tileId; * - cell has same image id
const startCol = col; * - cell has consecutive tile id
let count = 1; */
/** while (++col < cols) {
* merge tiles to the right into a single draw call, if: const nextE = line.getExtended(col)?.payload;
* - not at end of line if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
* - cell has same image id break;
* - cell has consecutive tile id }
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. count++;
*/
while (++col < cols) {
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
} }
count++; col--;
} if (imgSpec) {
col--;
if (imgSpec) {
if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); 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) { this._fullyCleared = false;
placeholderCalls.push({ col: startCol, row, count });
} }
this._fullyCleared = false;
} }
} }
} }
@@ -513,12 +443,9 @@ export class ImageStorage implements IDisposable {
const rows = buffer.lines.length; const rows = buffer.lines.length;
const oldCol = this._viewportMetrics.cols - 1; const oldCol = this._viewportMetrics.cols - 1;
for (let row = 0; row < rows; ++row) { for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row) as IBufferLineExt | undefined; const line = buffer.lines.get(row)!;
if (!line) { const e = line.getExtended(oldCol)?.payload;
continue; if (e instanceof ImageTileInfo) {
}
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
const imageId = e.imageId; const imageId = e.imageId;
if (imageId === undefined || imageId === -1) { if (imageId === undefined || imageId === -1) {
continue; continue;
@@ -528,14 +455,14 @@ export class ImageStorage implements IDisposable {
continue; continue;
} }
// found an image tile at oldCol, check if it qualifies for right exapansion // found an image tile at oldCol, check if it qualifies for right exapansion
const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width); const tilesPerRow = Math.ceil(imgSpec.src.width * imgSpec.prescaleX / imgSpec.cellSize.width);
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) { if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
continue; continue;
} }
// expand only if right side is empty (nothing got wrapped from below) // expand only if right side is empty (nothing got wrapped from below)
let hasData = false; let hasData = false;
for (let rightCol = oldCol + 1; rightCol < metrics.cols; ++rightCol) { for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) { if (line.hasContent(rightCol)) {
hasData = true; hasData = true;
break; break;
} }
@@ -547,7 +474,7 @@ export class ImageStorage implements IDisposable {
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
let lastTile = e.tileId; let lastTile = e.tileId;
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile); this._writeToCell(line, expandCol, imageId, ++lastTile);
imgSpec.tileCount++; imgSpec.tileCount++;
} }
} }
@@ -561,34 +488,17 @@ export class ImageStorage implements IDisposable {
*/ */
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer; const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt; const line = buffer.lines.get(y);
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { if (line) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS; const e = line.getExtended(x)?.payload;
if (e.imageId && e.imageId !== -1) { if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig; const src = this._images.get(e.imageId)?.src;
if (window.ImageBitmap && orig instanceof ImageBitmap) { if (src?.native instanceof ImageBitmap || src?.native instanceof VideoFrame) {
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height); const canvas = createCanvas(window.document, src.width, src.height);
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height); canvas.getContext('2d')?.drawImage(src.native, 0, 0, src.width, src.height);
return canvas; return canvas;
} }
return orig as HTMLCanvasElement; return src?.native;
}
}
}
/**
* Extract active single tile at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
return this._renderer.extractTile(spec, e.tileId);
}
} }
} }
} }
@@ -596,15 +506,12 @@ export class ImageStorage implements IDisposable {
// TODO: Do we need some blob offloading tricks here to avoid early eviction? // TODO: Do we need some blob offloading tricks here to avoid early eviction?
// also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size
private _evictOldest(room: number): number { private _evictOldest(room: number): number {
const used = this._getStoredPixels(); const used = this._getVRAMUsage();
let current = used; let current = used;
while (this._pixelLimit < current + room && this._images.size) { while (this._byteLimit < current + room && this._images.size) {
const spec = this._images.get(++this._lowestId); const spec = this._images.get(++this._lowestId);
if (spec && spec.orig) { if (spec) {
current -= spec.orig.width * spec.orig.height; current -= spec.src.bytes;
if (spec.actual && spec.orig !== spec.actual) {
current -= spec.actual.width * spec.actual.height;
}
spec.marker?.dispose(); spec.marker?.dispose();
this._delImg(this._lowestId); this._delImg(this._lowestId);
} }
@@ -612,31 +519,31 @@ export class ImageStorage implements IDisposable {
return used - current; return used - current;
} }
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void { private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void {
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { const workCell = this._workCell;
const old = line._extendedAttrs[x]; line.loadCell(x, workCell);
if (old) { if (this._workCell.hasExtendedAttrs()) {
if (old.imageId !== undefined) { const old = workCell.extended.payload;
// found an old ExtendedAttrsImage, since we know that if (old instanceof ImageTileInfo) {
// they are always isolated instances (single cell usage), // found an old ExtendedAttrsImage, since we know that
// we can re-use it and just update their id entries // they are always isolated instances (single cell usage),
const oldSpec = this._images.get(old.imageId); // we can re-use it and just update their id entries
if (oldSpec) { const oldSpec = this._images.get(old.imageId);
// early eviction for in-viewport overwrites if (oldSpec) {
oldSpec.tileCount--; // early eviction for in-viewport overwrites
} oldSpec.tileCount--;
old.imageId = imageId;
old.tileId = tileId;
return;
} }
// found a plain ExtendedAttrs instance, clone it to new entry old.imageId = imageId;
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId); old.tileId = tileId;
return; return;
} }
} }
// fall-through: always create new ExtendedAttrsImage entry // fall-through: always create new ExtendedAttrs entry
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED; const extattr = workCell.extended.clone();
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId); extattr.payload = new ImageTileInfo(imageId, tileId);
workCell.extended = extattr;
workCell.updateExtended();
line.setCell(x, workCell);
} }
private _evictOnAlternate(): void { private _evictOnAlternate(): void {
@@ -649,18 +556,16 @@ export class ImageStorage implements IDisposable {
// re-count tiles on whole buffer // re-count tiles on whole buffer
const buffer = this._terminal._core.buffer; const buffer = this._terminal._core.buffer;
for (let y = 0; y < this._terminal.rows; ++y) { 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) { if (!line) {
continue; continue;
} }
for (let x = 0; x < this._terminal.cols; ++x) { for (let x = 0; x < this._terminal.cols; ++x) {
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { const payload = line.getExtended(x)?.payload;
const imgId = line._extendedAttrs[x]?.imageId; if (payload instanceof ImageTileInfo) {
if (imgId) { const spec = this._images.get(payload.imageId);
const spec = this._images.get(imgId); if (spec) {
if (spec) { spec.tileCount++;
spec.tileCount++;
}
} }
} }
} }
@@ -4,7 +4,8 @@
*/ */
import { assert } from 'chai'; import { assert } from 'chai';
import { imageType, IMetrics } from './IIPMetrics'; import { imageType } from './Metrics';
import type { IMetrics } from 'Types';
// fix missing nodejs decl // fix missing nodejs decl
declare const require: (s: string) => any; declare const require: (s: string) => any;
@@ -32,6 +33,28 @@ const TEST_IMAGES: [string, IMetrics][] = [
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }], ['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }], ['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }], ['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
// VP8
['1.webp', { mime: 'image/webp', width: 550, height: 368 }],
['2.webp', { mime: 'image/webp', width: 550, height: 404 }],
['3.webp', { mime: 'image/webp', width: 1280, height: 720 }],
['4.webp', { mime: 'image/webp', width: 1024, height: 772 }],
['5.webp', { mime: 'image/webp', width: 1024, height: 752 }],
// VP8X
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }],
// VP8L
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }],
// some AVIF test images
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }],
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }],
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }],
]; ];
+137
View File
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IMetrics } from './Types';
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
height: d[9] << 8 | d[8]
};
}
// QOI: qoif
if (d32[0] === 0x66696F71) {
return {
mime: 'image/qoi',
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
};
}
// WEBP: RIFF | xxxx | WEBP | VP8x
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
switch (d[15]) {
case 0x58: // Extended WebP VP8X --> "X"
return {
mime: 'image/webp',
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
};
case 0x4C: // Lossless WebP VP8L --> "L"
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
return {
mime: 'image/webp',
width: (dim & 0x3FFF) + 1,
height: (dim >>> 14 & 0x3FFF) + 1
};
case 0x20: // Lossy WebP VP8 --> " "
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
return {
mime: 'image/webp',
width: (d[26] | d[27] << 8) & 0x3FFF,
height: (d[28] | d[29] << 8) & 0x3FFF
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
// search for ispe box within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
break;
}
}
if (pos !== -1) {
// dimensions are in BE at +8 (width) at +12 (height)
const width =
d[pos + 8] << 24 |
d[pos + 9] << 16 |
d[pos + 10] << 8 |
d[pos + 11];
const height =
d[pos + 12] << 24 |
d[pos + 13] << 16 |
d[pos + 14] << 8 |
d[pos + 15];
if (width > 0 && height > 0) {
return { mime: 'image/avif', width, height };
}
}
return UNSUPPORTED_TYPE;
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDrawable } from './Types';
export function createCanvas(
localDocument: Document | undefined,
width: number,
height: number,
cssWidth?: number,
cssHeight?: number
): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
canvas.style.width = `${(cssWidth ?? width) | 0}px`;
canvas.style.height = `${(cssHeight ?? height) | 0}px`;
return canvas;
}
/**
* Drawable is a resource the ImageRenderer knows to draw on the screen.
*/
export class Drawable implements IDrawable {
public readonly width: number;
public readonly height: number;
// @readonly: only for memory tracking
public bytes: number;
constructor(public native: ImageBitmap | VideoFrame | HTMLCanvasElement) {
if (native instanceof VideoFrame) {
this.width = native.displayWidth;
this.height = native.displayHeight;
// NOTE: native.allocationSize() is too expensive
this.bytes = native.codedWidth * native.codedHeight * 4;
} else {
this.width = native.width;
this.height = native.height;
this.bytes = native.width * native.height * 4;
}
}
public close(): void {
if (this.bytes === 0) return;
this.bytes = 0;
try {
if (this.native instanceof HTMLCanvasElement) {
this.native.width = 0;
this.native.height = 0;
} else {
this.native.close?.();
}
} catch {}
this.native = null!;
}
}
+3 -3
View File
@@ -7,9 +7,9 @@ import { SixelImageStorage } from './SixelImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types'; import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors'; import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types'; import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
import { createCanvas, Drawable } from './Primitives';
// always free decoder ressources after decoding if it exceeds this limit // always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -98,12 +98,12 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
return true; return true;
} }
const canvas = ImageRenderer.createCanvas(undefined, width, height); const canvas = createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0); canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
this._dec.release(); this._dec.release();
} }
this._storage.addImage(canvas); this._storage.addImage(new Drawable(canvas), undefined, { width, height, mime: 'image/sixel' });
return true; return true;
} }
} }
+4 -7
View File
@@ -4,7 +4,7 @@
*/ */
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types'; import { IImageAddonOptions, ITerminalExt, IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageRenderer } from './ImageRenderer'; import { ImageRenderer } from './ImageRenderer';
/** /**
@@ -27,9 +27,9 @@ export class SixelImageStorage {
* Add a sixel image to storage. * Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80). * Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/ */
public addImage(img: HTMLCanvasElement | ImageBitmap): void { public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling; this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(img, this._addImageOpts); this._storage.addImage(src, data, metrics, this._addImageOpts);
} }
/** /**
@@ -39,10 +39,7 @@ export class SixelImageStorage {
*/ */
public advanceCursor(height: number): void { public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) { if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize; const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height); const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) { for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed(); this._terminal._core._inputHandler.lineFeed();
+60 -20
View File
@@ -10,7 +10,6 @@ import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/b
import type { AttributeData } from 'common/buffer/AttributeData'; import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types'; import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IInputHandler } from 'common/Types'; import type { IInputHandler } from 'common/Types';
import type { IBufferLine, IExtendedAttrs } from 'common/buffer/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types'; import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types'; import type { IRenderDimensions } from 'browser/renderer/shared/Types';
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
@@ -48,6 +47,7 @@ export interface IResetHandler {
reset(): void; reset(): void;
} }
/* eslint-disable */
/** /**
* Stub into private interfaces. * Stub into private interfaces.
* This should be kept in line with common libs. * This should be kept in line with common libs.
@@ -55,19 +55,6 @@ export interface IResetHandler {
* have a somewhat reliable testing against code changes in the core repo. * 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 { interface IInputHandlerExt extends IInputHandler {
_parser: IEscapeSequenceParser; _parser: IEscapeSequenceParser;
_curAttrData: AttributeData; _curAttrData: AttributeData;
@@ -78,6 +65,7 @@ interface IInputHandlerExt extends IInputHandler {
}; };
onRequestReset(handler: () => void): IDisposable; onRequestReset(handler: () => void): IDisposable;
} }
/* eslint-enable */
export interface ICoreTerminalExt extends ITerminal { export interface ICoreTerminalExt extends ITerminal {
_themeService: IThemeService | undefined; _themeService: IThemeService | undefined;
@@ -89,8 +77,6 @@ export interface ICoreTerminalExt extends ITerminal {
export interface ITerminalExt extends Terminal { export interface ITerminalExt extends Terminal {
_core: ICoreTerminalExt; _core: ICoreTerminalExt;
} }
/* eslint-enable */
/** /**
* Some storage definitions. * Some storage definitions.
@@ -108,16 +94,70 @@ export interface IAddImageOpts {
layer: ImageLayer; layer: ImageLayer;
zIndex: number; zIndex: number;
cursorPos: CursorPos; cursorPos: CursorPos;
prescaleX?: number;
prescaleY?: number;
}
export interface IDrawable {
native: ImageBitmap | VideoFrame | HTMLCanvasElement;
width: number;
height: number;
close(): void;
readonly bytes: number;
}
export type ImageType = 'unsupported'
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/qoi'
| 'image/webp'
| 'image/avif'
// sixel
| 'image/sixel'
// rgb|a blobs for kitty
| 'image/rgb'
| 'image/rgba';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
} }
export interface IImageSpec { export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined; /** entries for a later IImageSource type */
origCellSize: ICellSize; /** drawable for screen rendering */
actual: HTMLCanvasElement | ImageBitmap | undefined; src: IDrawable;
actualCellSize: ICellSize; /** image bytes as blob */
data: Blob | undefined;
/** metrics about image like mime and dimensions */
metrics: IMetrics;
/** entries for a later IPlacement type */
/** cell size at time of insert */
cellSize: ICellSize;
/** eviction marker */
marker: IMarker | undefined; marker: IMarker | undefined;
/** used tiles as eviction hint */
tileCount: number; tileCount: number;
/** buffer it was placed on */
bufferType: 'alternate' | 'normal'; bufferType: 'alternate' | 'normal';
/** layer it was placed on */
layer: ImageLayer; layer: ImageLayer;
zIndex: number; zIndex: number;
/** scaling factor on source dimensions, default = 1.0 */
prescaleX: number;
prescaleY: number;
/** crop offset into source, default = 0 (currently unused) */
precropX: number;
precropY: number;
/** output scaling, default = 1.0 (currently unused) */
scaleX: number;
scaleY: number;
/** output offset, default = 0 (currently unused) */
offsetX: number;
offsetY: number;
/** compositing mode on output, default = 'alpha' (currently unused) */
blendMode: 'overwrite' | 'alpha';
} }
@@ -19,6 +19,7 @@ import {
KittyPixelConstants, KittyPixelConstants,
parseKittyCommand parseKittyCommand
} from './KittyGraphicsTypes'; } from './KittyGraphicsTypes';
import { createCanvas } from '../Primitives';
const enum Constants { const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler) // Memory limit for base64 decoder (4MB, same as IIPHandler)
@@ -573,8 +574,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
bitmap = cropped; bitmap = cropped;
} }
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; const cw = this._renderer.dimensions?.device.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; const ch = this._renderer.dimensions?.device.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions. // Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio. // If only one of c/r is specified, compute the other from image aspect ratio.
@@ -636,7 +637,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// the natural cell dimensions. // the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset; const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset; const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH); const offsetCanvas = createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d'); const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) { if (!offsetCtx) {
throw new Error('Failed to create offset canvas context'); throw new Error('Failed to create offset canvas context');
@@ -700,7 +701,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return new Promise<ImageBitmap>((resolve, reject) => { return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => { img.addEventListener('load', () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height); const canvas = createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0); canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject); createImageBitmap(canvas).then(resolve).catch(reject);
}); });
@@ -7,6 +7,8 @@ import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage'; import { ImageStorage } from '../ImageStorage';
import { ImageLayer, IAddImageOpts } from '../Types'; import { ImageLayer, IAddImageOpts } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes'; import { IKittyImageData } from './KittyGraphicsTypes';
import { Drawable } from '../Primitives';
import { UNSUPPORTED_TYPE } from '../Metrics';
// Kitty-specific image storage controller. // Kitty-specific image storage controller.
// //
@@ -102,7 +104,8 @@ export class KittyImageStorage implements IDisposable {
this._addImageOpts.scrolling = scrolling; this._addImageOpts.scrolling = scrolling;
this._addImageOpts.layer = layer; this._addImageOpts.layer = layer;
this._addImageOpts.zIndex = zIndex; this._addImageOpts.zIndex = zIndex;
const storageId = this._storage.addImage(image, this._addImageOpts); // FIXME: store image data in blob, fill metrics correctly
const storageId = this._storage.addImage(new Drawable(image), undefined, UNSUPPORTED_TYPE, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId); this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId); this._storageIdToKittyId.set(storageId, kittyId);
} }
+51 -25
View File
@@ -294,10 +294,10 @@ test.describe('ImageAddon', () => {
test('size params is optional/informative', async () => { test('size params is optional/informative', async () => {
// too small size param // too small size param
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]); deepStrictEqual(await getImageSize(1), [640, 80]);
// no size aparm at all // no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`); await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]); deepStrictEqual(await getImageSize(2), [640, 80]);
}); });
test.skip('FilePart support - bytewise', async () => { test.skip('FilePart support - bytewise', async () => {
// opener // opener
@@ -308,7 +308,7 @@ test.describe('ImageAddon', () => {
} }
// finalizer // finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`); await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]); deepStrictEqual(await getImageSize(1), [640, 80]);
}); });
test('FilePart support - chunks', async () => { test('FilePart support - chunks', async () => {
// opener // opener
@@ -319,7 +319,7 @@ test.describe('ImageAddon', () => {
} }
// finalizer // finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`); await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]); deepStrictEqual(await getImageSize(1), [640, 80]);
}); });
test('FilePart support - edgecases', async () => { test('FilePart support - edgecases', async () => {
// multiple opener do not harm // multiple opener do not harm
@@ -347,39 +347,39 @@ test.describe('ImageAddon', () => {
// finalizer skipped // finalizer skipped
// write spinfox.png // write spinfox.png
await ctx.proxy.write(TESTDATA_IIP[1][0]); await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
}); });
}); });
test.describe('IIP support - testimages', () => { test.describe('IIP support - testimages', () => {
test('palette.png', async () => { test('palette.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[0][0]); await ctx.proxy.write(TESTDATA_IIP[0][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[0][1]);
}); });
test('spinfox.png', async () => { test('spinfox.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[1][0]); await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
}); });
test('w3c gif', async () => { test('w3c gif', async () => {
await ctx.proxy.write(TESTDATA_IIP[2][0]); await ctx.proxy.write(TESTDATA_IIP[2][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[2][1]);
}); });
test('w3c jpeg', async () => { test('w3c jpeg', async () => {
await ctx.proxy.write(TESTDATA_IIP[3][0]); await ctx.proxy.write(TESTDATA_IIP[3][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[3][1]);
}); });
test('w3c png', async () => { test('w3c png', async () => {
await ctx.proxy.write(TESTDATA_IIP[4][0]); await ctx.proxy.write(TESTDATA_IIP[4][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); deepStrictEqual(await getImageSize(1), TESTDATA_IIP[4][1]);
}); });
}); });
test.describe('IIP - QOI support', () => { test.describe('IIP - QOI support', () => {
test('palette should yield same bytes from PNG and QOI', async () => { test('palette should yield same bytes from PNG and QOI', async () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]); deepStrictEqual(await getImageSize(1), [640, 80]);
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`); await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]); deepStrictEqual(await getImageSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0); const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11); const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape); deepStrictEqual(qoiScrape, pngScrape);
@@ -406,20 +406,20 @@ test.describe('ImageAddon', () => {
const header = 'width=20;height=5;preserveAspectRatio=0'; const header = 'width=20;height=5;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions(); const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); deepStrictEqual(await getImageSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
}); });
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => { test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize // pixel based resize
const header = 'width=320px;height=160px;preserveAspectRatio=0'; const header = 'width=320px;height=160px;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
deepStrictEqual(await getOrigSize(1), [320, 160]); deepStrictEqual(await getImageSize(1), [320, 160]);
}); });
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => { test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize // % of viewport resize
const header = 'width=50%;height=30%;preserveAspectRatio=0'; const header = 'width=50%;height=30%;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions(); const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); deepStrictEqual(await getImageSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
}); });
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => { test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent // width provided in percent
@@ -427,14 +427,23 @@ test.describe('ImageAddon', () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions(); const dim = await getDimensions();
const width = Math.floor(dim.width * 0.5); const width = Math.floor(dim.width * 0.5);
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); deepStrictEqual(await getImageSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel // height provided in pixel
const header2 = 'height=200px'; const header2 = 'height=200px';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`); await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); deepStrictEqual(await getImageSize(2), [Math.floor(200 * 640 / 80), 200]);
}); });
} }
}); });
test.describe('text overwrite removes tiles', () => {
test('sixel', async () => {
await assertTextClearsTiles(SIXEL_SEQ_0);
});
test('iip', async () => {
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
});
});
}); });
/** /**
@@ -443,10 +452,10 @@ test.describe('ImageAddon', () => {
async function getDimensions(): Promise<IDimensions> { async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`); const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return { return {
cellWidth: Math.round(dimensions.css.cell.width), cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.css.cell.height), cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.css.canvas.width), width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.css.canvas.height) height: Math.round(dimensions.device.canvas.height)
}; };
} }
@@ -462,13 +471,30 @@ async function getScrollbackPlusRows(): Promise<number> {
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows'); return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
} }
async function getOrigSize(id: number): Promise<[number, number]> { async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[ return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width, window.imageAddon._storage._images.get(${id}).src.width * window.imageAddon._storage._images.get(${id}).prescaleX,
window.imageAddon._storage._images.get(${id}).orig.height window.imageAddon._storage._images.get(${id}).src.height * window.imageAddon._storage._images.get(${id}).prescaleY
]`); ]`).then(r => r.map(Math.floor));
} }
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> { async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
} }
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
}
+45 -26
View File
@@ -123,7 +123,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq); await ctx.proxy.write(seq);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [1, 1]); deepStrictEqual(await getImageSize(1), [1, 1]);
}); });
test('stores 3x1 RGB PNG with a=T', async () => { test('stores 3x1 RGB PNG with a=T', async () => {
@@ -131,7 +131,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq); await ctx.proxy.write(seq);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]); deepStrictEqual(await getImageSize(1), [3, 1]);
}); });
test('transmit only (a=t) does not display but stores in handler', async () => { test('transmit only (a=t) does not display but stores in handler', async () => {
@@ -1107,7 +1107,7 @@ test.describe('Kitty Graphics Protocol', () => {
await timeout(200); await timeout(200);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [20, 50]); deepStrictEqual(await getImageSize(1), [20, 50]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]); deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
}); });
@@ -1283,7 +1283,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor(); const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => { const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell; const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height]; return [d.width, d.height];
}); });
const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]); const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]);
@@ -1301,7 +1301,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor(); const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => { const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell; const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height]; return [d.width, d.height];
}); });
const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]); const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]);
@@ -1535,7 +1535,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200); await timeout(200);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
}); });
test('transmit only (a=t) stores 200x100 image without display', async () => { test('transmit only (a=t) stores 200x100 image without display', async () => {
@@ -1564,7 +1564,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
await timeout(200); await timeout(200);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
}); });
test('handles 3-chunk transmission', async () => { test('handles 3-chunk transmission', async () => {
@@ -1580,7 +1580,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
await timeout(200); await timeout(200);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
}); });
test('verifies chunked data assembles correctly', async () => { test('verifies chunked data assembles correctly', async () => {
@@ -1713,7 +1713,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200); await timeout(200);
deepStrictEqual(await getOrigSize(1), [20, 50]); deepStrictEqual(await getImageSize(1), [20, 50]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]); deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]); deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]);
}); });
@@ -1747,7 +1747,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200); await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]); deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]); deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
}); });
@@ -1756,7 +1756,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200); await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]); deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]); deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
}); });
@@ -1772,7 +1772,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200); await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]); deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]); deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
}); });
@@ -1893,28 +1893,28 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]); deepStrictEqual(await getImageSize(1), [3, 1]);
}); });
test('stores image with correct original dimensions (2x2)', async () => { test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]); deepStrictEqual(await getImageSize(1), [2, 2]);
}); });
test('stores image with correct original dimensions (5x1)', async () => { test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]); deepStrictEqual(await getImageSize(1), [5, 1]);
}); });
test('stores image with correct original dimensions (4x2)', async () => { test('stores image with correct original dimensions (4x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [4, 2]); deepStrictEqual(await getImageSize(1), [4, 2]);
}); });
}); });
@@ -2034,21 +2034,21 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]); deepStrictEqual(await getImageSize(1), [3, 1]);
}); });
test('stores image with correct original dimensions (2x2)', async () => { test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]); deepStrictEqual(await getImageSize(1), [2, 2]);
}); });
test('stores image with correct original dimensions (5x1)', async () => { test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`); await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
await timeout(100); await timeout(100);
strictEqual(await getImageStorageLength(), 1); strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]); deepStrictEqual(await getImageSize(1), [5, 1]);
}); });
}); });
@@ -2213,6 +2213,21 @@ test.describe('Kitty Graphics Protocol', () => {
await pollFor(ctx.page, 'window._imageAddedCount', 2); await pollFor(ctx.page, 'window._imageAddedCount', 2);
}); });
}); });
test.describe('text overwrite removes tiles', () => {
test('a=T placement', async () => {
await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
});
});
}); });
/** /**
@@ -2221,10 +2236,10 @@ test.describe('Kitty Graphics Protocol', () => {
async function getDimensions(): Promise<IDimensions> { async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`); const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return { return {
cellWidth: Math.round(dimensions.css.cell.width), cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.css.cell.height), cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.css.canvas.width), width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.css.canvas.height) height: Math.round(dimensions.device.canvas.height)
}; };
} }
@@ -2236,10 +2251,10 @@ async function getImageStorageLength(): Promise<number> {
return ctx.page.evaluate('window.imageAddon._storage._images.size'); return ctx.page.evaluate('window.imageAddon._storage._images.size');
} }
async function getOrigSize(id: number): Promise<[number, number]> { async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[ return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width, window.imageAddon._storage._images.get(${id}).src.width,
window.imageAddon._storage._images.get(${id}).orig.height window.imageAddon._storage._images.get(${id}).src.height
]`); ]`);
} }
@@ -2262,3 +2277,7 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb
return Array.from(ctx2d.getImageData(x, y, w, h).data); return Array.from(ctx2d.getImageData(x, y, w, h).data);
}, [col, row, x, y, w, h]); }, [col, row, x, y, w, h]);
} }
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
-5
View File
@@ -125,10 +125,5 @@ declare module '@xterm/addon-image' {
* Get original image canvas at buffer position. * Get original image canvas at buffer position.
*/ */
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined; public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
/**
* Extract single tile canvas at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
} }
} }
+8 -6
View File
@@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
} }
prevResult = result; prevResult = result;
results.push(prevResult); results.push(prevResult);
result = this._engine.find( const cols = this._terminal.cols;
term, let nextCol = prevResult.col + prevResult.size;
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row, let nextRow = prevResult.row;
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1, if (nextCol >= cols) {
searchOptions nextRow += Math.floor(nextCol / cols);
); nextCol = nextCol % cols;
}
result = this._engine.find(term, nextRow, nextCol, searchOptions);
} }
this._resultTracker.updateResults(results, this._highlightLimit); this._resultTracker.updateResults(results, this._highlightLimit);
@@ -414,6 +414,20 @@ test.describe('Search Tests', () => {
}); });
test.describe('Regression 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', () => { test.describe('#2444 wrapped line content not being found', () => {
let fixture: string; let fixture: string;
test.beforeAll(async () => { test.beforeAll(async () => {
+24 -3
View File
@@ -100,6 +100,8 @@ export class GlyphRenderer extends Disposable {
private readonly _attributesBuffer: WebGLBuffer; private readonly _attributesBuffer: WebGLBuffer;
private _atlas: ITextureAtlas | undefined; private _atlas: ITextureAtlas | undefined;
private _lastSeenPageLayoutVersion: number = -1;
private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0; private _activeBuffer: number = 0;
private readonly _vertices: IVertices = { private readonly _vertices: IVertices = {
count: 0, count: 0,
@@ -213,8 +215,19 @@ export class GlyphRenderer extends Disposable {
this.handleResize(); 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 { 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 { 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 // 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) // monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison. // 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) { if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i); this._bindAtlasPageTexture(gl, this._atlas, i);
} }
@@ -375,6 +394,7 @@ export class GlyphRenderer extends Disposable {
public setAtlas(atlas: ITextureAtlas): void { public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas; this._atlas = atlas;
this._lastSeenPageLayoutVersion = -1;
this.invalidateAtlasTextures(); this.invalidateAtlasTextures();
} }
@@ -389,8 +409,9 @@ export class GlyphRenderer extends Disposable {
gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture); 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_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_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.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; this._atlasTextures[i].version = atlas.pages[i].version;
} }
+56 -16
View File
@@ -132,12 +132,8 @@ export class TextureAtlas implements ITextureAtlas {
} }
} }
private _requestClearModel = false; private _pageLayoutVersion = 0;
public beginFrame(): boolean { public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
}
public clearTexture(): void { public clearTexture(): void {
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) { 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._cacheMap.clear();
this._cacheMapCombined.clear(); this._cacheMapCombined.clear();
this._didWarmUp = false; 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 { 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 // 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. // effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) { 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); const newPage = new AtlasPage(this._document, this._textureSize);
this._pages.push(newPage); this._pages.push(newPage);
this._activePages.push(newPage); this._activePages.push(newPage);
@@ -206,8 +209,8 @@ export class TextureAtlas implements ITextureAtlas {
// Add the new merged page to the end // Add the new merged page to the end
this.pages.push(mergedPage); this.pages.push(mergedPage);
// Request the model to be cleared to refresh all texture pages. // Invalidate renderer models so all texture pages are refreshed.
this._requestClearModel = true; this._pageLayoutVersion++;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); 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 { 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); 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 // Create a new page for oversized glyphs as they come up
if (rasterizedGlyph.size.x > this._textureSize) { if (rasterizedGlyph.size.x > this._textureSize) {
if (!this._overflowSizePage) { 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._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
this.pages.push(this._overflowSizePage); this.pages.push(this._overflowSizePage);
// Request the model to be cleared to refresh all texture pages. // Invalidate renderer models so all texture pages are refreshed.
this._requestClearModel = true; this._pageLayoutVersion++;
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas); this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
} }
activePage = this._overflowSizePage; activePage = this._overflowSizePage;
@@ -1080,17 +1104,33 @@ class AtlasPage {
size: number, size: number,
sourcePages?: AtlasPage[] sourcePages?: AtlasPage[]
) { ) {
if (sourcePages) {
for (const p of sourcePages) {
this._glyphs.push(...p.glyphs);
this._usedPixels += p._usedPixels;
}
}
this.canvas = createCanvas(document, size, size); this.canvas = createCanvas(document, size, size);
// The canvas needs alpha because we use clearColor to convert the background color to alpha. // 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 // It might also contain some characters with transparent backgrounds if allowTransparency is
// set. // set.
this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true })); 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 { public clear(): void {
+5 -3
View File
@@ -68,10 +68,12 @@ export interface ITextureAtlas extends IDisposable {
warmUp(): void; warmUp(): void;
/** /**
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room * Incremented whenever cached glyph texture page mappings may be stale, such as after atlas page
* for a new set of glyphs. * 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. * 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++) { for (y = start; y <= end; y++) {
row = y + terminal.buffer.ydisp; 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; let rowHasBlinkingCells = false;
this._model.lineLengths[y] = 0; this._model.lineLengths[y] = 0;
isCursorRow = cursorY === row; isCursorRow = cursorY === row;
@@ -587,13 +598,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Null out non-first cells // Null out non-first cells
for (x++; x <= lastCharX; x++) { for (x++; x <= lastCharX; x++) {
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
this._model.cells[j] = NULL_CELL_CODE;
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not // Don't re-resolve the cell color since multi-colored ligature backgrounds are not
// supported // supported
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg; this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext);
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
} }
x--; // Go back to the previous update cell for next iteration x--; // Go back to the previous update cell for next iteration
} }
@@ -607,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateTextBlinkState(); 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 { private _resetBlinkingRowState(): void {
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false); this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
this._rowHasBlinkingCellsCount = 0; 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 { customGlyphDefinitions } from './CustomGlyphDefinitions';
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types'; 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 * Try drawing a custom block element or box drawing character, returning whether it was
* successfully drawn. * successfully drawn.
@@ -458,9 +481,9 @@ function drawPatternChar(
if (!pattern) { if (!pattern) {
const width = charDefinition[0].length; const width = charDefinition[0].length;
const height = charDefinition.length; const height = charDefinition.length;
const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas'); // The atlas canvas can be adopted into another document, so temporary resources must not use
tmpCanvas.width = width; // its mutable ownerDocument.
tmpCanvas.height = height; const tmpCanvas = createPatternCanvas(width, height);
const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d')); const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d'));
const imageData = new ImageData(width, height); 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); dlIip.appendChild(dtIip);
this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)'); this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)');
this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)'); 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); container.appendChild(dlIip);
// Kitty demos // Kitty demos
@@ -173,11 +175,15 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
document.getElementById('image-demo2')!.addEventListener('click', document.getElementById('image-demo2')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel')); sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
document.getElementById('image-demo3')!.addEventListener('click', 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', 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', 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 // demo for image retrieval API
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => { this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
@@ -197,11 +203,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows); const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
const x = pos[0] - 1; const x = pos[0] - 1;
const y = pos[1] - 1; const y = pos[1] - 1;
const canvas = ev.shiftKey // ctrl+click: get original image
// ctrl+shift+click: get single tile const canvas = imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
? imageAddon.extractTileAtBufferCell(x, this._terminal.buffer.active.viewportY + y)
// ctrl+click: get original image
: imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
canvas?.toBlob(data => data && window.open(URL.createObjectURL(data), '_blank')); canvas?.toBlob(data => data && window.open(URL.createObjectURL(data), '_blank'));
}); });
} }
@@ -11,8 +11,35 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
public readonly label = 'webgl'; public readonly label = 'webgl';
private _textureAtlasContainer!: HTMLElement; private _textureAtlasContainer!: HTMLElement;
private _stressRunning = false;
private _scrollOutput: HTMLInputElement | undefined;
public build(container: HTMLElement): void { 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'); const zoomCheckbox = document.createElement('input');
zoomCheckbox.type = 'checkbox'; zoomCheckbox.type = 'checkbox';
zoomCheckbox.id = 'texture-atlas-zoom'; 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.width = `${canvas.width / dpr}px`;
canvas.style.height = `${canvas.height / 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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -680,9 +680,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -697,9 +697,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -714,9 +714,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -731,9 +731,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -748,9 +748,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -765,9 +765,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -782,9 +782,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -799,9 +799,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -816,9 +816,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -833,9 +833,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -850,9 +850,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -867,9 +867,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -884,9 +884,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -901,9 +901,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -918,9 +918,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -935,9 +935,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -952,9 +952,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -969,9 +969,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -986,9 +986,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1003,9 +1003,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1020,9 +1020,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1037,9 +1037,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1054,9 +1054,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1071,9 +1071,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1088,9 +1088,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3061,16 +3061,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@@ -3519,15 +3519,15 @@
} }
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.1", "version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "4.1.2", "chalk": "4.1.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.8.3", "shell-quote": "1.9.0",
"supports-color": "8.1.1", "supports-color": "8.1.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "17.7.2" "yargs": "17.7.2"
@@ -3869,9 +3869,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -3882,32 +3882,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.0", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.0", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.0", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.0", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.0", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.0", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.0", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.0", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.0", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.0" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@@ -4308,9 +4308,9 @@
} }
}, },
"node_modules/express-ws/node_modules/ws": { "node_modules/express-ws/node_modules/ws": {
"version": "7.5.10", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4351,9 +4351,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4749,9 +4749,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5795,9 +5795,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mocha/node_modules/brace-expansion": { "node_modules/mocha/node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7127,9 +7127,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7975,9 +7975,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.25.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8452,9 +8452,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.1", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+5 -8
View File
@@ -492,6 +492,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// https://issuetracker.google.com/issues/260170397 // https://issuetracker.google.com/issues/260170397
this.textarea.setAttribute('aria-multiline', 'false'); this.textarea.setAttribute('aria-multiline', 'false');
} }
this.textarea.setAttribute('autocomplete', 'off');
this.textarea.setAttribute('autocorrect', 'off'); this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false'); 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. * Clear the entire buffer, making the prompt line the new first line.
*/ */
public clear(): void { 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.clearAllMarkers();
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
this.buffer.lines.length = 1; this.buffer.lines.length = 1;
@@ -1140,13 +1137,13 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
switch (type) { switch (type) {
case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS: case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS:
const canvasWidth = this._renderService.dimensions.css.canvas.width.toFixed(0); const canvasWidth = this._renderService.dimensions.device.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.css.canvas.height.toFixed(0); const canvasHeight = this._renderService.dimensions.device.canvas.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`); this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`);
break; break;
case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS: case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS:
const cellWidth = this._renderService.dimensions.css.cell.width.toFixed(0); const cellWidth = this._renderService.dimensions.device.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.css.cell.height.toFixed(0); const cellHeight = this._renderService.dimensions.device.cell.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`); this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`);
break; break;
} }
+14 -4
View File
@@ -536,9 +536,14 @@ export class DomRenderer extends Disposable implements IRenderer {
for (let y = start; y <= end; y++) { for (let y = start; y <= end; y++) {
const row = y + buffer.ydisp; const row = y + buffer.ydisp;
const rowElement = this._rowElements[y]; const rowElement = this._rowElements[y];
if (!rowElement) {
continue;
}
const lineData = buffer.lines.get(row); const lineData = buffer.lines.get(row);
if (!rowElement || !lineData) { if (!lineData) {
break; rowElement.replaceChildren();
this._setRowBlinkState(y, false);
continue;
} }
rowElement.replaceChildren( rowElement.replaceChildren(
...this._rowFactory.createRow( ...this._rowFactory.createRow(
@@ -610,9 +615,14 @@ export class DomRenderer extends Disposable implements IRenderer {
for (let i = y; i <= y2; ++i) { for (let i = y; i <= y2; ++i) {
const row = i + buffer.ydisp; const row = i + buffer.ydisp;
const rowElement = this._rowElements[i]; const rowElement = this._rowElements[i];
if (!rowElement) {
continue;
}
const bufferline = buffer.lines.get(row); const bufferline = buffer.lines.get(row);
if (!rowElement || !bufferline) { if (!bufferline) {
break; rowElement.replaceChildren();
this._setRowBlinkState(i, false);
continue;
} }
rowElement.replaceChildren( rowElement.replaceChildren(
...this._rowFactory.createRow( ...this._rowFactory.createRow(
@@ -6,16 +6,14 @@
import jsdom = require('jsdom'); import jsdom = require('jsdom');
import { assert } from 'chai'; import { assert } from 'chai';
import { DomRendererRowFactory } from './DomRendererRowFactory'; 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 { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine';
import { BufferLineStringCache } from '../../../common/buffer/BufferLineStringCache';
import { IBufferLine } from '../../../common/buffer/Types'; import { IBufferLine } from '../../../common/buffer/Types';
import { CellData } from '../../../common/buffer/CellData'; import { CellData } from '../../../common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test'; import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test';
import { TestWidthCache } from './WidthCache.test'; import { TestWidthCache } from './WidthCache.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('DomRendererRowFactory', () => { describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM; let dom: jsdom.JSDOM;
@@ -517,7 +515,7 @@ describe('DomRendererRowFactory', () => {
} }
function createEmptyLineData(cols: number): IBufferLine { 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++) { for (let i = 0; i < cols; i++) {
lineData.setCell(i, NULL_CELL_DATA); lineData.setCell(i, NULL_CELL_DATA);
} }
@@ -64,6 +64,13 @@ describe('SelectionModel', () => {
assert.deepEqual(model.finalSelectionStart, undefined); assert.deepEqual(model.finalSelectionStart, undefined);
assert.deepEqual(model.finalSelectionEnd, 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', () => { describe('finalSelectionStart', () => {
+3 -2
View File
@@ -135,9 +135,10 @@ export class SelectionModel {
return true; 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) { if (this.selectionStart && this.selectionStart[1] < 0) {
this.selectionStart[1] = 0; this.selectionStart = [0, 0];
return true;
} }
return false; return false;
} }
@@ -7,12 +7,10 @@ import { assert } from 'chai';
import { ICharacterJoinerService } from './Services'; import { ICharacterJoinerService } from './Services';
import { CharacterJoinerService } from './CharacterJoinerService'; import { CharacterJoinerService } from './CharacterJoinerService';
import { BufferLine } from '../../common/buffer/BufferLine'; import { BufferLine } from '../../common/buffer/BufferLine';
import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache';
import { IBufferLine } from '../../common/buffer/Types'; import { IBufferLine } from '../../common/buffer/Types';
import { CellData } from '../../common/buffer/CellData'; import { CellData } from '../../common/buffer/CellData';
import { MockBufferService, createCellData } from '../../common/TestUtils.test'; import { MockBufferService, createCellData } from '../../common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('CharacterJoinerService', () => { describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService; let service: ICharacterJoinerService;
@@ -25,7 +23,7 @@ describe('CharacterJoinerService', () => {
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]])); lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
lines.set(3, lineData([['no joined ranges']])); 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]])); lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]); const line6 = lineData([['wi']]);
line6.resize(line6.length + 1, createCellData(0, '¥', 2)); line6.resize(line6.length + 1, createCellData(0, '¥', 2));
@@ -270,7 +268,7 @@ describe('CharacterJoinerService', () => {
type IPartialLineData = ([string] | [string, number]); type IPartialLineData = ([string] | [string, number]);
function lineData(data: IPartialLineData[]): IBufferLine { 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) { for (let i = 0; i < data.length; ++i) {
const line = data[i][0]; const line = data[i][0];
const attr = (data[i][1] || 0) as number; const attr = (data[i][1] || 0) as number;
@@ -100,6 +100,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
const ranges: [number, number][] = []; const ranges: [number, number][] = [];
const lineStr = line.translateToString(true); const lineStr = line.translateToString(true);
const trimmedLength = line.getTrimmedLength();
// Because some cells can be represented by multiple javascript characters, // Because some cells can be represented by multiple javascript characters,
// we track the cell and the string indexes separately. This allows us to // 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 rangeAttrFG = line.getFg(0);
let rangeAttrBG = line.getBg(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); line.loadCell(x, this._workCell);
if (this._workCell.getWidth() === 0) { if (this._workCell.getWidth() === 0) {
@@ -147,7 +148,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
} }
// Process any trailing ranges. // Process any trailing ranges.
if (this._bufferService.cols - rangeStartColumn > 1) { if (trimmedLength - rangeStartColumn > 1) {
const joinedRanges = this._getJoinedRanges( const joinedRanges = this._getJoinedRanges(
lineStr, lineStr,
rangeStartStringIndex, rangeStartStringIndex,
@@ -216,7 +217,8 @@ export class CharacterJoinerService implements ICharacterJoinerService {
return; 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 width = line.getWidth(x);
const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length; 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 // If there is still a range left at the end, it must extend all the way to
// the end of the line. // the end of the line.
if (currentRange) { 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)); 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 { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services';
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types'; import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types';
import { C0 } from '../../common/data/EscapeSequences'; 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 { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch'; import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch';
@@ -21,6 +21,8 @@ interface IMouseBindContext {
readonly target: IMouseServiceTarget; readonly target: IMouseServiceTarget;
readonly focus: () => void; readonly focus: () => void;
readonly requestedEvents: RequestedMouseEvents; readonly requestedEvents: RequestedMouseEvents;
readonly mouseupListener: MutableDisposable<IDisposable>;
readonly mousedragListener: MutableDisposable<IDisposable>;
} }
export class MouseService implements IMouseService { export class MouseService implements IMouseService {
@@ -61,7 +63,11 @@ export class MouseService implements IMouseService {
mousedrag: null, mousedrag: null,
mousemove: 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> = { const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), 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 // force initial onProtocolChange so we dont miss early mouse requests
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; 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. * "Always on" event listeners.
*/ */
@@ -199,12 +195,8 @@ export class MouseService implements IMouseService {
this._sendEvent(ctx, ev); this._sendEvent(ctx, ev);
if (!ev.buttons) { if (!ev.buttons) {
// if no other button is held remove global handlers // if no other button is held remove global handlers
if (ctx.requestedEvents.mouseup) { ctx.mouseupListener.clear();
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); ctx.mousedragListener.clear();
}
if (ctx.requestedEvents.mousedrag) {
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
}
} }
} }
@@ -246,11 +238,14 @@ export class MouseService implements IMouseService {
// of the terminal element. // of the terminal element.
// Note: Other emulators also do this for 'mousedown' while a button // Note: Other emulators also do this for 'mousedown' while a button
// is held, we currently limit 'mousedown' to the terminal only. // 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) { 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) { 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 { 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; const { requestedEvents } = ctx;
// apply global changes on events // apply global changes on events
if (events) { if (events) {
@@ -433,18 +428,14 @@ export class MouseService implements IMouseService {
} }
if (!(events & CoreMouseEventType.UP)) { if (!(events & CoreMouseEventType.UP)) {
if (requestedEvents.mouseup) { ctx.mouseupListener.clear();
document.removeEventListener('mouseup', requestedEvents.mouseup);
}
requestedEvents.mouseup = null; requestedEvents.mouseup = null;
} else { } else {
requestedEvents.mouseup ??= eventListeners.mouseup; requestedEvents.mouseup ??= eventListeners.mouseup;
} }
if (!(events & CoreMouseEventType.DRAG)) { if (!(events & CoreMouseEventType.DRAG)) {
if (requestedEvents.mousedrag) { ctx.mousedragListener.clear();
document.removeEventListener('mousemove', requestedEvents.mousedrag);
}
requestedEvents.mousedrag = null; requestedEvents.mousedrag = null;
} else { } else {
requestedEvents.mousedrag ??= eventListeners.mousedrag; requestedEvents.mousedrag ??= eventListeners.mousedrag;
@@ -9,13 +9,11 @@ import { SelectionModel } from '../selection/SelectionModel';
import { IBuffer, IBufferLine } from '../../common/buffer/Types'; import { IBuffer, IBufferLine } from '../../common/buffer/Types';
import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from '../../common/TestUtils.test'; import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from '../../common/TestUtils.test';
import { BufferLine } from '../../common/buffer/BufferLine'; import { BufferLine } from '../../common/buffer/BufferLine';
import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache';
import { IBufferService, IOptionsService } from '../../common/services/Services'; import { IBufferService, IOptionsService } from '../../common/services/Services';
import { MockCoreBrowserService, MockMouseService, MockRenderService } from '../TestUtils.test'; import { MockCoreBrowserService, MockMouseService, MockRenderService } from '../TestUtils.test';
import { CellData } from '../../common/buffer/CellData'; import { CellData } from '../../common/buffer/CellData';
import { IRenderService } from './Services'; import { IRenderService } from './Services';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestSelectionService extends SelectionService { class TestSelectionService extends SelectionService {
constructor( constructor(
@@ -60,7 +58,7 @@ describe('SelectionService', () => {
}); });
function stringToRow(text: string): IBufferLine { 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++) { for (let i = 0; i < text.length; i++) {
result.setCell(i, createCellData(0, text.charAt(i), 1)); result.setCell(i, createCellData(0, text.charAt(i), 1));
} }
@@ -68,7 +66,7 @@ describe('SelectionService', () => {
} }
function stringArrayToRow(chars: string[]): IBufferLine { 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))); chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line; return line;
} }
@@ -123,7 +121,7 @@ describe('SelectionService', () => {
[0, 'o', 1, 'o'.charCodeAt(0)], [0, 'o', 1, 'o'.charCodeAt(0)],
[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])); for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i]));
buffer.lines.set(0, line); buffer.lines.set(0, line);
// Ensure wide characters take up 2 columns // 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) { if (this._activeSelectionMode !== SelectionMode.COLUMN) {
this._model.selectionEnd[0] = this._bufferService.cols; 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 { } else {
if (this._activeSelectionMode !== SelectionMode.COLUMN) { if (this._activeSelectionMode !== SelectionMode.COLUMN) {
this._model.selectionEnd[0] = 0; this._model.selectionEnd[0] = 0;
+10 -15
View File
@@ -27,11 +27,13 @@ export class Emitter<T> {
} }
const entry = { fn: listener, thisArgs }; const entry = { fn: listener, thisArgs };
this._listeners = this._listeners.slice();
this._listeners.push(entry); this._listeners.push(entry);
const result = toDisposable(() => { const result = toDisposable(() => {
const idx = this._listeners.indexOf(entry); const idx = this._listeners.indexOf(entry);
if (idx !== -1) { if (idx !== -1) {
this._listeners = this._listeners.slice();
this._listeners.splice(idx, 1); this._listeners.splice(idx, 1);
} }
}); });
@@ -50,23 +52,16 @@ export class Emitter<T> {
} }
public fire(event: T): void { public fire(event: T): void {
if (this._disposed) { if (this._disposed || !this._listeners.length) {
return; return;
} }
switch (this._listeners.length) { if (this._listeners.length === 1) {
case 0: return; this._listeners[0].fn.call(this._listeners[0].thisArgs, event);
case 1: { return;
const { fn, thisArgs } = this._listeners[0]; }
fn.call(thisArgs, event); const listeners = this._listeners;
return; for (let i = 0, len = listeners.length; i < len; ++i) {
} listeners[i].fn.call(listeners[i].thisArgs, event);
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);
}
}
} }
} }
+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', () => { describe('DECSCA and DECSED/DECSEL', () => {
it('default is unprotected', async () => { it('default is unprotected', async () => {
await inputHandler.parseP('some text'); 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.lines.trimStart(scrollBackSize);
this._activeBuffer.ybase = Math.max(this._activeBuffer.ybase - scrollBackSize, 0); this._activeBuffer.ybase = Math.max(this._activeBuffer.ybase - scrollBackSize, 0);
this._activeBuffer.ydisp = Math.max(this._activeBuffer.ydisp - 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 // Force a scroll event to refresh viewport
this._onScroll.fire(0); this._onScroll.fire(0);
} }
+2 -1
View File
@@ -139,6 +139,7 @@ export class AttributeData implements IAttributeData {
*/ */
export class ExtendedAttrs implements IExtendedAttrs { export class ExtendedAttrs implements IExtendedAttrs {
private _ext: number = 0; private _ext: number = 0;
public payload: Object | undefined;
public get ext(): number { public get ext(): number {
if (this._urlId) { if (this._urlId) {
return ( return (
@@ -207,6 +208,6 @@ export class ExtendedAttrs implements IExtendedAttrs {
* that needs to be persistant in the buffer. * that needs to be persistant in the buffer.
*/ */
public isEmpty(): boolean { 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 { CircularList } from '../CircularList';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test'; import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData'; import { CellData } from './CellData';
import { ExtendedAttrs } from './AttributeData';
const INIT_COLS = 80; const INIT_COLS = 80;
const INIT_ROWS = 24; const INIT_ROWS = 24;
const INIT_SCROLLBACK = 1000; const INIT_SCROLLBACK = 1000;
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBuffer extends Buffer { class TestBuffer extends Buffer {}
public getStringCache(): BufferLineStringCache {
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
}
public getStringCacheClearTimeout(): unknown {
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
}
}
describe('Buffer', () => { describe('Buffer', () => {
let optionsService: MockOptionsService; let optionsService: MockOptionsService;
@@ -1202,7 +1192,7 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => { describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => { 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(0, createCellData(0, 'a', 1));
line.setCell(1, createCellData(0, 'b', 1)); line.setCell(1, createCellData(0, 'b', 1));
line.setCell(2, createCellData(0, 'c', 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', () => { 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(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0)); line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1)); 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', () => { 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(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0)); line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1)); line.setCell(2, createCellData(0, 'a', 1));
@@ -1242,7 +1232,7 @@ describe('Buffer', () => {
}); });
it('should handle single width emojis', () => { 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(0, createCellData(0, '😁', 1));
line.setCell(1, createCellData(0, 'a', 1)); line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line); buffer.lines.set(0, line);
@@ -1255,7 +1245,7 @@ describe('Buffer', () => {
}); });
it('should handle double width emojis', () => { 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(0, createCellData(0, '😁', 2));
line.setCell(1, createCellData(0, '', 0)); line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line); buffer.lines.set(0, line);
@@ -1266,7 +1256,7 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2); const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁'); assert.equal(str2, '😁');
const line2 = new BufferLine(TEST_STRING_CACHE, 3); const line2 = new BufferLine(3);
line2.setCell(0, createCellData(0, '😁', 2)); line2.setCell(0, createCellData(0, '😁', 2));
line2.setCell(1, createCellData(0, '', 0)); line2.setCell(1, createCellData(0, '', 0));
line2.setCell(2, createCellData(0, 'a', 1)); 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', () => { describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => { it('should realign memory from idle task execution', async () => {
buffer.fillViewportRows(); buffer.fillViewportRows();
+4 -9
View File
@@ -10,7 +10,6 @@ import { ICharset } from '../Types';
import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types'; import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types';
import { ExtendedAttrs } from './AttributeData'; import { ExtendedAttrs } from './AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
import { CellData } from './CellData'; 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'; 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 _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>; private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0; private _memoryCleanupPosition = 0;
private readonly _stringCache: BufferLineStringCache;
constructor( constructor(
private _hasScrollback: boolean, private _hasScrollback: boolean,
@@ -70,7 +68,6 @@ export class Buffer extends Disposable implements IBuffer {
this._memoryCleanupQueue = new IdleTaskQueue(this._logService); this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
this._register(toDisposable(() => this._memoryCleanupQueue.clear())); this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
this._register(toDisposable(() => this.clearAllMarkers())); this._register(toDisposable(() => this.clearAllMarkers()));
this._stringCache = this._register(new BufferLineStringCache());
} }
public getNullCell(attr?: IAttributeData): ICellData { public getNullCell(attr?: IAttributeData): ICellData {
@@ -100,7 +97,7 @@ export class Buffer extends Disposable implements IBuffer {
} }
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { 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 { 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. * Clears the buffer to its initial state, discarding all previous data.
*/ */
public clear(): void { public clear(): void {
this._stringCache.clear();
this.ydisp = 0; this.ydisp = 0;
this.ybase = 0; this.ybase = 0;
this.y = 0; this.y = 0;
@@ -164,7 +160,6 @@ export class Buffer extends Disposable implements IBuffer {
public resize(newCols: number, newRows: number): void { public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs // store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
this._stringCache.clear();
// count bufferlines with overly big memory to be cleaned afterwards // count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0; 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) { 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 // 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 // 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 { } else {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { 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, // 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 { } else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there // 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 // 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) { if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport // 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 { } else {
if (this.ydisp === this.ybase) { 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 { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants';
import { BufferLine } from './BufferLine'; import { BufferLine } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData'; import { CellData } from './CellData';
import { CharData, IBufferLine, ICellData } from './Types'; import { CharData, IBufferLine, ICellData } from './Types';
import { assert } from 'chai'; import { assert } from 'chai';
import { AttributeData } from './AttributeData'; import { AttributeData } from './AttributeData';
import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test'; import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBufferLine extends BufferLine { class TestBufferLine extends BufferLine {
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { 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} { public get combined(): {[index: number]: string} {
@@ -24,19 +21,19 @@ class TestBufferLine extends BufferLine {
} }
public get cachedString(): string | undefined { public get cachedString(): string | undefined {
return this._getStringCacheEntry(false)?.value; return this._cacheValid ? this._cache : undefined;
} }
public set cachedString(value: string | undefined) { public set cachedString(value: string | undefined) {
this._getStringCacheEntry(true)!.value = value; this._cache = value ?? '';
} }
public get isCachedStringTrimmed(): boolean { public get isCachedStringTrimmed(): boolean {
return this._getStringCacheEntry(false)?.isTrimmed ?? false; return this._cacheTrimmed;
} }
public set isCachedStringTrimmed(value: boolean) { public set isCachedStringTrimmed(value: boolean) {
this._getStringCacheEntry(true)!.isTrimmed = value; this._cacheTrimmed = value;
} }
public toArray(): CharData[] { public toArray(): CharData[] {
+73 -81
View File
@@ -8,7 +8,6 @@ import { AttributeData } from './AttributeData';
import { CellData } from './CellData'; 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 { 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 { stringFromCodePoint } from '../input/TextDecoder';
import { StringBuilder } from '../StringBuilder';
// Buffer memory layout: // Buffer memory layout:
// //
@@ -37,24 +36,20 @@ const enum Cell {
BG = 2 // currently unused BG = 2 // currently unused
} }
interface IExtendedAttrsExt extends IExtendedAttrs {
_ext: number;
_urlId: number;
}
export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection // Work variables to avoid garbage collection
let $startIndex = 0; let $startIndex = 0;
const $workCell = new CellData(); 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. * Typed array based bufferline implementation.
@@ -77,11 +72,14 @@ export class BufferLine implements IBufferLine {
protected _combined: {[index: number]: string} = {}; protected _combined: {[index: number]: string} = {};
/** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */ /** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
public length: number; public length: number;
/** line text cache */
protected _cacheValid = false;
protected _cache: string = '';
protected _cacheTrimmed = false;
constructor( constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number, cols: number,
fillCellData?: ICellData, fillCellData?: ICellData,
public isWrapped: boolean = false public isWrapped: boolean = false
@@ -118,7 +116,7 @@ export class BufferLine implements IBufferLine {
* @deprecated * @deprecated
*/ */
public set(index: number, value: CharData): void { 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]; this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) { if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1]; this._combined[index] = value[1];
@@ -210,21 +208,30 @@ export class BufferLine implements IBufferLine {
} else { } else {
cell.combinedData = ''; cell.combinedData = '';
} }
if (cell.bg & BgFlags.HAS_EXTENDED) { cell.extended = this.getExtended(index);
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();
}
return cell; 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`. * Set data at `index` to `cell`.
*/ */
public setCell(index: number, cell: ICellData): void { public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache(); this._cacheValid = false;
if (cell.content & Content.IS_COMBINED_MASK) { if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData; this._combined[index] = cell.combinedData;
} }
@@ -242,13 +249,14 @@ export class BufferLine implements IBufferLine {
* it gets an optimized access method. * it gets an optimized access method.
*/ */
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache(); this._cacheValid = false;
if (attrs.bg & BgFlags.HAS_EXTENDED) { if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended; this._extendedAttrs[index] = attrs.extended;
} }
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); const $idx = index * Constants.CELL_INDICIES;
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg; this._data[$idx + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; 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. * by the previous `setDataFromCodePoint` call, we can omit it here.
*/ */
public addCodepointToCell(index: number, codePoint: number, width: number): void { public addCodepointToCell(index: number, codePoint: number, width: number): void {
this._invalidateStringCache(); this._cacheValid = false;
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) { if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add // 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 { public insertCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache(); this._cacheValid = false;
pos %= this.length; pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char // 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 { public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache(); this._cacheValid = false;
pos %= this.length; pos %= this.length;
if (n < this.length - pos) { if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) { 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 { 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 // full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) { if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { 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). * excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
*/ */
public resize(cols: number, fillCellData: ICellData): boolean { public resize(cols: number, fillCellData: ICellData): boolean {
this._invalidateStringCache(); this._cacheValid = false;
if (cols === this.length) { if (cols === this.length) {
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; 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 */ /** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void { 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 // full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) { if (respectProtect) {
for (let i = 0; i < this.length; ++i) { for (let i = 0; i < this.length; ++i) {
@@ -458,8 +466,7 @@ export class BufferLine implements IBufferLine {
} }
/** alter to a full copy of line */ /** alter to a full copy of line */
public copyFrom(line: BufferLine): void { public copyFrom(line: BufferLine, blank?: boolean): void {
this._invalidateStringCache();
if (this.length !== line.length) { if (this.length !== line.length) {
this._data = new Uint32Array(line._data); this._data = new Uint32Array(line._data);
} else { } else {
@@ -467,16 +474,29 @@ export class BufferLine implements IBufferLine {
this._data.set(line._data); this._data.set(line._data);
} }
this.length = line.length; 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; this.isWrapped = line.isWrapped;
} }
/** create a new clone */ /** create a new clone */
public clone(): IBufferLine { public clone(blank?: boolean): IBufferLine {
const newLine = new BufferLine(this._stringCache, 0, undefined, false); const newLine = new BufferLine(0, undefined, false);
newLine._data = new Uint32Array(this._data); newLine._data = new Uint32Array(this._data);
newLine.length = this.length; 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; newLine.isWrapped = this.isWrapped;
return newLine; return newLine;
} }
@@ -500,7 +520,7 @@ export class BufferLine implements IBufferLine {
} }
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache(); this._cacheValid = false;
const srcData = src._data; const srcData = src._data;
if (applyInReverse) { if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) { 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. * returned string, the corresponding entries in `outColumns` will have the same column number.
*/ */
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
if (isCanonicalRequest) { if (isCanonical && this._cacheValid) {
this._stringCache.touch?.();
}
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
if (trimRight) { if (trimRight) {
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd(); return this._cacheTrimmed ? this._cache : this._cache.trimEnd();
} }
if (!stringCacheEntry.isTrimmed) { if (!this._cacheTrimmed) {
return stringCacheEntry.value; return this._cache;
} }
} }
startCol = startCol ?? 0; startCol = startCol ?? 0;
@@ -555,12 +571,12 @@ export class BufferLine implements IBufferLine {
if (outColumns) { if (outColumns) {
outColumns.length = 0; outColumns.length = 0;
} }
$translateToStringBuilder.reset(); const cellContents: string[] = [];
while (startCol < endCol) { while (startCol < endCol) {
const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK; const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
$translateToStringBuilder.append(chars); cellContents.push(chars);
if (outColumns) { if (outColumns) {
for (let i = 0; i < chars.length; ++i) { for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol); outColumns.push(startCol);
@@ -571,39 +587,15 @@ export class BufferLine implements IBufferLine {
if (outColumns) { if (outColumns) {
outColumns.push(startCol); outColumns.push(startCol);
} }
const result = $translateToStringBuilder.toString(); const result = cellContents.join('');
$translateToStringBuilder.reset(); if (isCanonical) {
if (isCanonicalRequest) { this._cache = result;
const cacheEntry = this._getStringCacheEntry(true)!; this._cacheValid = true;
cacheEntry.value = result; this._cacheTrimmed = !!trimRight;
cacheEntry.isTrimmed = !!trimRight;
} }
return result; 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. */ /** Copy sparse map entries for a single cell when `_data` flags require them. */
private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void { private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void {
const srcStart = srcCol * Constants.CELL_INDICIES; 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 { assert } from 'chai';
import { CircularList } from '../CircularList'; import { CircularList } from '../CircularList';
import { BufferLine } from './BufferLine'; import { BufferLine } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData'; import { CellData } from './CellData';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants';
import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
import { IBufferLine } from './Types'; import { IBufferLine } from './Types';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('BufferReflow', () => { describe('BufferReflow', () => {
describe('reflowSmallerGetNewLineLengths', () => { describe('reflowSmallerGetNewLineLengths', () => {
it('should return correct line lengths for a small line with wide characters', () => { 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(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]); line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -26,7 +24,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
}); });
it('should return correct line lengths for a large line with wide characters', () => { 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) { for (let i = 0; i < 12; i += 4) {
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(i + 2, [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: 汉, 语, 汉, 语, 汉, 语'); 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', () => { 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(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(2, [0, '', 0, 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'); 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', () => { 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(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line1.set(2, [0, '', 0, 0]); line1.set(2, [0, '', 0, 0]);
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]); line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
line1.set(4, [0, '', 0, 0]); line1.set(4, [0, '', 0, 0]);
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(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(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line2.set(2, [0, '', 0, 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'); 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', () => { 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(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]); line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -102,7 +100,7 @@ describe('BufferReflow', () => {
function createWrappedLines(chars: string): CircularList<IBufferLine> { function createWrappedLines(chars: string): CircularList<IBufferLine> {
const lines = new CircularList<IBufferLine>(chars.length); const lines = new CircularList<IBufferLine>(chars.length);
for (let i = 0; i < chars.length; i++) { 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.set(0, [0, chars[i], 1, chars.charCodeAt(i)]);
line.isWrapped = i > 0; line.isWrapped = i > 0;
lines.push(line); lines.push(line);
-41
View File
@@ -82,45 +82,4 @@ describe('BufferSet', () => {
assert.equal(bufferSet.alt.markers.length, 0); 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; underlineColor: number;
underlineVariantOffset: number; underlineVariantOffset: number;
urlId: number; urlId: number;
payload: Object | undefined;
clone(): IExtendedAttrs; clone(): IExtendedAttrs;
isEmpty(): boolean; isEmpty(): boolean;
} }
@@ -131,8 +132,8 @@ export interface IBufferLine {
resize(cols: number, fill: ICellData): boolean; resize(cols: number, fill: ICellData): boolean;
cleanupMemory(): number; cleanupMemory(): number;
fill(fillCellData: ICellData, respectProtect?: boolean): void; fill(fillCellData: ICellData, respectProtect?: boolean): void;
copyFrom(line: IBufferLine): void; copyFrom(line: IBufferLine, blank?: boolean): void;
clone(): IBufferLine; clone(blank?: boolean): IBufferLine;
getTrimmedLength(): number; getTrimmedLength(): number;
getNoBgTrimmedLength(): number; getNoBgTrimmedLength(): number;
translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string; translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string;
@@ -146,6 +147,7 @@ export interface IBufferLine {
getCodePoint(index: number): number; getCodePoint(index: number): number;
isCombined(index: number): number; isCombined(index: number): number;
getString(index: number): string; getString(index: number): string;
getExtended(index: number): IExtendedAttrs;
} }
export interface IMarker extends IDisposable { export interface IMarker extends IDisposable {
+45 -19
View File
@@ -79,7 +79,7 @@ function assertDecodedRange(
} }
const target = new Uint32Array(count); const target = new Uint32Array(count);
const length = decode(input, target); const length = decode(input, target);
assert.equal(length, count); assert.strictEqual(length, count);
let mismatchIndex = -1; let mismatchIndex = -1;
let index = 0; let index = 0;
for (let i = min; i < max; ++i) { for (let i = min; i < max; ++i) {
@@ -92,8 +92,8 @@ function assertDecodedRange(
} }
index++; index++;
} }
assert.equal(mismatchIndex, -1); assert.strictEqual(mismatchIndex, -1);
assert.equal(outputToString(target, length), input); assert.strictEqual(outputToString(target, length), input);
} }
const BATCH_SIZE = 8192; const BATCH_SIZE = 8192;
@@ -116,9 +116,9 @@ describe('text encodings', () => {
const data = new Uint32Array(s.length); const data = new Uint32Array(s.length);
for (let i = 0; i < s.length; ++i) { for (let i = 0; i < s.length; ++i) {
data[i] = s.charCodeAt(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', () => { describe('StringToUtf32 decoder', () => {
@@ -159,7 +159,7 @@ describe('text encodings', () => {
const decoder = new StringToUtf32(); const decoder = new StringToUtf32();
const target = new Uint32Array(5); const target = new Uint32Array(5);
const length = decoder.decode(String.fromCharCode(0xFEFF), target); const length = decoder.decode(String.fromCharCode(0xFEFF), target);
assert.equal(length, 0); assert.strictEqual(length, 0);
decoder.clear(); decoder.clear();
}); });
}); });
@@ -169,7 +169,7 @@ describe('text encodings', () => {
const target = new Uint32Array(500); const target = new Uint32Array(500);
for (let i = 0; i < TEST_STRINGS.length; ++i) { for (let i = 0; i < TEST_STRINGS.length; ++i) {
const length = decoder.decode(TEST_STRINGS[i], target); 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(); decoder.clear();
} }
}); });
@@ -184,7 +184,7 @@ describe('text encodings', () => {
const written = decoder.decode(input[i], target); const written = decoder.decode(input[i], target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
}); });
}); });
}); });
@@ -226,7 +226,7 @@ describe('text encodings', () => {
const target = new Uint32Array(5); const target = new Uint32Array(5);
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF)); const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
const length = decoder.decode(utf8Data, target); const length = decoder.decode(utf8Data, target);
assert.equal(length, 0); assert.strictEqual(length, 0);
decoder.clear(); decoder.clear();
}); });
}); });
@@ -237,7 +237,7 @@ describe('text encodings', () => {
for (let i = 0; i < TEST_STRINGS.length; ++i) { for (let i = 0; i < TEST_STRINGS.length; ++i) {
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]); const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
const length = decoder.decode(utf8Data, target); const length = decoder.decode(utf8Data, target);
assert.equal(toString(target, length), TEST_STRINGS[i]); assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
decoder.clear(); decoder.clear();
} }
}); });
@@ -252,7 +252,7 @@ describe('text encodings', () => {
const written = decoder.decode(utf8Data.slice(i, i + 1), target); const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'ÄÖÜßöäü'); assert.strictEqual(decoded, 'ÄÖÜßöäü');
}); });
it('2/3 byte sequences - advance by 1', () => { 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); const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'Āր܀߀ö€ä€ü'); assert.strictEqual(decoded, 'Āր܀߀ö€ä€ü');
}); });
it('2/3/4 byte sequences - advance by 1', () => { 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); const written = decoder.decode(utf8Data.slice(i, i + 1), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
}); });
it('2/3/4 byte sequences - advance by 2', () => { 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); const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
}); });
it('2/3/4 byte sequences - advance by 3', () => { 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); const written = decoder.decode(utf8Data.slice(i, i + 3), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
}); });
it('BOMs (3 byte sequences) - advance by 2', () => { 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); const written = decoder.decode(utf8Data.slice(i, i + 2), target);
decoded += toString(target, written); decoded += toString(target, written);
} }
assert.equal(decoded, ''); assert.strictEqual(decoded, '');
}); });
it('test break after 3 bytes - issue #2495', () => { it('test break after 3 bytes - issue #2495', () => {
@@ -320,11 +320,37 @@ describe('text encodings', () => {
const target = new Uint32Array(5); const target = new Uint32Array(5);
const utf8Data = fromByteString('\xf0\xa0\x9c\x8e'); const utf8Data = fromByteString('\xf0\xa0\x9c\x8e');
let written = decoder.decode(utf8Data.slice(0, 3), target); let written = decoder.decode(utf8Data.slice(0, 3), target);
assert.equal(written, 0); assert.strictEqual(written, 0);
written = decoder.decode(utf8Data.slice(3), target); written = decoder.decode(utf8Data.slice(3), target);
assert.equal(written, 1); assert.strictEqual(written, 1);
assert(toString(target, written), '𠜎'); 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); cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07);
let pos = 0; let pos = 0;
let tmp: number; let tmp: number;
while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) { while ((tmp = this.interim[++pos]) && pos < 4) {
cp <<= 6; cp <<= 6;
cp |= tmp; cp |= tmp & 0x3F;
} }
// missing bytes - read ahead from input // missing bytes - read ahead from input
const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4; 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 // Insert the line using the fastest method
if (bottomRow === buffer.lines.length - 1) { if (bottomRow === buffer.lines.length - 1) {
if (willBufferBeTrimmed) { if (willBufferBeTrimmed) {
buffer.lines.recycle().copyFrom(newLine); buffer.lines.recycle().copyFrom(newLine, true);
} else { } else {
buffer.lines.push(newLine.clone()); buffer.lines.push(newLine.clone(true));
} }
} else { } 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 // 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. // scrollback, instead we can just shift them in-place.
const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */;
buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); 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 // 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 { private _handleBufferLinesTrim(amount: number): void {
if (amount <= 0) { if (amount <= 0 || !this._decorationsByLine.size) {
return; return;
} }
const newMap = new Map<number, IInternalDecoration[]>(); 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. * Clear the entire buffer, making the prompt line the new first line.
*/ */
public clear(): void { 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.clearAllMarkers();
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
this.buffer.lines.length = 1; this.buffer.lines.length = 1;