Compare commits

..
2 Commits
Author SHA1 Message Date
Daniel Imms ee2da690d9 Update ghostty submodule 2026-02-09 03:49:36 -08:00
Daniel Imms b4529f317b . 2026-02-08 20:18:30 -08:00
503 changed files with 9348 additions and 17775 deletions
@@ -26,7 +26,6 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
- Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit) - Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit)
- Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work - Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work
- Integration tests by addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>` - Integration tests by addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>`
- Lint: `npm run lint` (oxlint with type-aware rules, then ESLint for `naming-convention` only), `npm run lint-api` for `typings/`, `npm run lint-fix` for oxlint auto-fix
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them - Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
## Addon Development Pattern ## Addon Development Pattern
@@ -58,13 +57,6 @@ export class MyAddon implements ITerminalAddon {
- Proposed APIs require `allowProposedApi: true` option - Proposed APIs require `allowProposedApi: true` option
- Constructor-only options (cols, rows) cannot be changed after instantiation - Constructor-only options (cols, rows) cannot be changed after instantiation
**Disposable Management**:
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
**TypeScript Constants**:
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
**Testing Utilities**: Use `TestUtils.ts` helpers: **Testing Utilities**: Use `TestUtils.ts` helpers:
- `openTerminal(ctx, options)` for setup - `openTerminal(ctx, options)` for setup
- `pollFor(page, fn, expectedValue)` for async assertions - `pollFor(page, fn, expectedValue)` for async assertions
@@ -99,15 +91,3 @@ const cell = line?.getCell(0);
## Writing unit tests ## Writing unit tests
- Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix. - Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix.
## Cursor Cloud specific instructions
**Demo server**: Start with `npm start` (port 3000). The demo server uses node-pty to spawn real shell sessions over WebSocket. Integration tests auto-start it via Playwright's `webServer` config, so you don't need to start it manually for `npm run test-integration`.
**Build before testing**: Always run `npm run build && npm run esbuild` before `npm run test-unit`. Integration tests also need `npm run esbuild-demo-client` and `npm run esbuild-demo-server`. The update script handles this automatically on session start.
**No external services**: This project has zero external dependencies (no databases, Docker, or APIs). Everything runs locally with Node.js.
**TypeScript compiler**: The project uses `tsgo` (native TypeScript compiler preview) rather than standard `tsc`. It's installed via the `@typescript/native-preview` package.
**Lint only changed files**: Prefer `npm run lint-changes` over `npm run lint` when iterating on code changes — it's significantly faster.
@@ -1,11 +1,7 @@
--- ---
name: benchmark applyTo: '**/*.benchmark.ts'
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
--- ---
# Benchmark run instructions
# Benchmark Run Instructions
## Running Benchmarks
- Full suite: `npm run benchmark` - Full suite: `npm run benchmark`
- Single benchmark file: - Single benchmark file:
@@ -15,14 +11,8 @@ description: Run and author benchmark tests in xterm.js. Use when editing benchm
- Use `-t` to get the path, then: - Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js` - `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
## Benchmark Case Selection When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
Notes:
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`. - Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script). - Keep `NODE_PATH=./out` (handled by the npm script).
@@ -1,11 +1,7 @@
--- ---
name: unit-test applyTo: '**/*.test.ts'
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
--- ---
When writing unit tests follow these rules:
# Unit Test Instructions
When writing unit tests, follow these rules:
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it. - When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not. - Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
+40 -47
View File
@@ -6,19 +6,14 @@ 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
@@ -74,7 +69,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
path: compressed-build.zip path: compressed-build.zip
@@ -84,11 +79,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
@@ -107,18 +100,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -138,7 +129,6 @@ jobs:
exit $EXIT_CODE exit $EXIT_CODE
test-unit: test-unit:
needs: build
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
matrix: matrix:
@@ -146,18 +136,21 @@ 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
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
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -173,7 +166,6 @@ 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:
@@ -182,11 +174,9 @@ jobs:
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }}.x node-version: ${{ matrix.node-version }}.x
cache: 'npm' cache: 'npm'
@@ -195,7 +185,12 @@ 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 }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
@@ -212,31 +207,31 @@ jobs:
- name: Build demo server - name: Build demo server
run: npm run esbuild-demo-server run: npm run esbuild-demo-server
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness - name: Integration tests (core) # Tests use 50% workers to reduce flakiness
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
- name: Integration tests (addon-attach) - name: Integration tests (addon-attach)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
- name: Integration tests (addon-clipboard) - name: Integration tests (addon-clipboard)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
- name: Integration tests (addon-fit) - name: Integration tests (addon-fit)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image) - name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-progress) - name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search) - name: Integration tests (addon-search)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
- name: Integration tests (addon-serialize) - name: Integration tests (addon-serialize)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
- name: Integration tests (addon-unicode-graphemes) - name: Integration tests (addon-unicode-graphemes)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11) - name: Integration tests (addon-unicode11)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11 run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
- name: Integration tests (addon-web-fonts) - name: Integration tests (addon-web-fonts)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links) - name: Integration tests (addon-web-links)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl) - name: Integration tests (addon-webgl)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
release-dry-run: release-dry-run:
needs: build needs: build
@@ -245,11 +240,9 @@ jobs:
matrix: matrix:
node-version: [22] node-version: [22]
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}.x - name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }}.x node-version: ${{ matrix.node-version }}.x
cache: 'npm' cache: 'npm'
@@ -258,7 +251,7 @@ jobs:
npm ci npm ci
- name: Install playwright - name: Install playwright
run: npx playwright install run: npx playwright install
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - uses: actions/download-artifact@v4
with: with:
name: build-artifacts name: build-artifacts
- name: Unzip artifacts - name: Unzip artifacts
+4 -11
View File
@@ -8,11 +8,6 @@ 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
@@ -29,14 +24,12 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 uses: actions/checkout@v3
with:
persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 uses: github/codeql-action/analyze@v2
+2 -7
View File
@@ -9,9 +9,6 @@ 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
@@ -21,11 +18,9 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
+2 -7
View File
@@ -6,18 +6,13 @@ 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@v3
with:
persist-credentials: false
- name: Use Node.js 22.x - name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: actions/setup-node@v3
with: with:
node-version: 22.x node-version: 22.x
cache: 'npm' cache: 'npm'
-5
View File
@@ -17,16 +17,11 @@ docs/
npm-debug.log npm-debug.log
/.idea/ /.idea/
.env .env
mise.toml
build/ build/
.DS_Store .DS_Store
yarn.lock yarn.lock
test-results/ test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git # Keep bundled code out of Git
dist/ dist/
demo/dist/ demo/dist/
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/ghostty"]
path = vendor/ghostty
url = https://github.com/ghostty-org/ghostty
-374
View File
@@ -1,374 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"import",
"typescript"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true,
"maxWarnings": 0
},
"env": {
"builtin": true
},
"ignorePatterns": [
"addons/*/src/third-party/*.ts",
"**/out/*",
"**/out-test/*",
"**/out-esbuild/*",
"**/out-esbuild-test/*",
"**/inwasm-sdks/*",
"**/typings/*.d.ts",
"**/node_modules",
"**/*.js",
"**/*.mjs"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
"import/no-cycle": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": [
"**/*.ts"
],
"jsPlugins": [
"@stylistic/eslint-plugin",
{
"name": "jsdoc-js",
"specifier": "eslint-plugin-jsdoc"
},
"oxlint-plugin-eslint"
],
"rules": {
"@stylistic/comma-spacing": [
"warn",
{
"before": false,
"after": true
}
],
"@stylistic/indent": [
"warn",
2
],
"@stylistic/semi": [
"warn",
"always"
],
"@stylistic/quotes": [
"warn",
"single",
{
"allowTemplateLiterals": "always"
}
],
"@stylistic/member-delimiter-style": [
"warn",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}
],
"@stylistic/type-annotation-spacing": "warn",
"@stylistic/space-unary-ops": "warn",
"@stylistic/keyword-spacing": "warn",
"@stylistic/max-len": [
"warn",
{
"code": 1000,
"comments": 100,
"ignoreTrailingComments": true,
"ignoreUrls": true,
"ignorePattern": "^ *((?<vt_comment>(//|\\*) @vt)|(?<table_comment>\\* \\| )|(?<commented_code>// ))"
}
],
"@stylistic/new-parens": "warn",
"@stylistic/no-extra-semi": "error",
"@stylistic/no-trailing-spaces": "warn",
"eslint-js/object-curly-spacing": [
"warn",
"always"
],
"@stylistic/spaced-comment": [
"warn",
"always",
{
"markers": [
"/"
],
"exceptions": [
"-"
]
}
],
"jsdoc-js/check-alignment": "warn",
"jsdoc-js/check-param-names": "warn",
"jsdoc-js/multiline-blocks": "warn",
"jsdoc-js/no-multi-asterisks": "warn",
"eslint-js/one-var": [
"warn",
"never"
],
"eslint-js/no-restricted-syntax": [
"warn",
{
"selector": "CallExpression[callee.name='requestAnimationFrame']",
"message": "The global requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression[callee.name='cancelAnimationFrame']",
"message": "The global cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='requestAnimationFrame']",
"message": "window.requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='cancelAnimationFrame']",
"message": "window.cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "MemberExpression[object.name='window'][property.name='devicePixelRatio']",
"message": "window.devicePixelRatio should be avoided, get it from ICoreBrowserService."
}
],
"curly": [
"warn",
"multi-line"
],
"eqeqeq": [
"warn",
"always"
],
"no-duplicate-imports": "warn",
"no-else-return": [
"warn",
{
"allowElseIf": false
}
],
"no-eval": "warn",
"no-irregular-whitespace": "warn",
"no-restricted-imports": [
"warn",
{
"patterns": [
".*\\/out\\/.*"
]
}
],
"no-unsafe-finally": "warn",
"no-unused-vars": "off",
"no-var": "warn",
"no-empty": "off",
"no-empty-pattern": "off",
"no-cond-assign": "off",
"no-case-declarations": "off",
"for-direction": "off",
"no-prototype-builtins": "off",
"no-useless-escape": "off",
"no-self-assign": "off",
"no-async-promise-executor": "off",
"prefer-rest-params": "off",
"no-control-regex": "off",
"no-fallthrough": "off",
"prefer-spread": "off",
"prefer-const": "warn",
"no-unused-expressions": "off",
"typescript/array-type": [
"warn",
{
"default": "array",
"readonly": "generic"
}
],
"typescript/consistent-type-assertions": "warn",
"typescript/consistent-type-definitions": "warn",
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"typescript/explicit-member-accessibility": [
"warn",
{
"accessibility": "explicit",
"overrides": {
"constructors": "off"
}
}
],
"typescript/no-confusing-void-expression": [
"warn",
{
"ignoreArrowShorthand": true
}
],
"typescript/no-useless-constructor": "warn",
"typescript/prefer-nullish-coalescing": [
"warn",
{
"ignorePrimitives": true
}
],
"typescript/prefer-namespace-keyword": "warn",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"args": "none"
}
],
"typescript/no-require-imports": "off",
"typescript/no-explicit-any": "off",
"typescript/no-wrapper-object-types": "off",
"typescript/no-empty-object-type": "off",
"typescript/no-unsafe-function-type": "off",
"typescript/no-this-alias": "off",
"typescript/no-namespace": "off",
"typescript/no-duplicate-enum-values": "off"
}
},
{
"files": [
"**/*.api.ts",
"**/*.test.ts"
],
"rules": {
"eslint-js/object-curly-spacing": "off",
"@stylistic/max-len": "off",
"typescript/no-unused-vars": "off",
"typescript/explicit-function-return-type": "off",
"typescript/explicit-member-accessibility": "off"
}
},
{
"files": [
"demo/**/*.ts",
"**/*.benchmark.ts"
],
"rules": {
"typescript/prefer-nullish-coalescing": "off"
}
}
]
}
-120
View File
@@ -1,120 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"options": {
"maxWarnings": 0
},
"env": {
"builtin": true
},
"overrides": [
{
"files": [
"typings/**/*.d.ts"
],
"plugins": [
"typescript"
],
"jsPlugins": [
"@stylistic/eslint-plugin",
{
"name": "jsdoc-js",
"specifier": "eslint-plugin-jsdoc"
},
"oxlint-plugin-eslint"
],
"rules": {
"@stylistic/indent": [
"warn",
2
],
"@stylistic/semi": [
"warn",
"always"
],
"@stylistic/quotes": [
"warn",
"single",
{
"allowTemplateLiterals": "always"
}
],
"@stylistic/member-delimiter-style": [
"warn",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}
],
"@stylistic/type-annotation-spacing": "warn",
"@stylistic/keyword-spacing": "warn",
"@stylistic/max-len": [
"warn",
{
"code": 1000,
"comments": 80,
"ignoreUrls": true,
"ignorePattern": "^ *(?<ps_description>\\* Ps=)"
}
],
"@stylistic/no-extra-semi": "error",
"@stylistic/no-trailing-spaces": "warn",
"eslint-js/object-curly-spacing": [
"warn",
"always"
],
"@stylistic/spaced-comment": [
"warn",
"always",
{
"markers": [
"/"
],
"exceptions": [
"-"
]
}
],
"@stylistic/comma-dangle": [
"warn",
{
"objects": "never",
"arrays": "never",
"functions": "never"
}
],
"@stylistic/eol-last": "warn",
"curly": [
"warn",
"multi-line"
],
"eqeqeq": [
"warn",
"always"
],
"jsdoc-js/check-alignment": "warn",
"jsdoc-js/check-param-names": "warn",
"no-irregular-whitespace": "warn",
"typescript/array-type": [
"warn",
{
"default": "array",
"readonly": "generic"
}
],
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"typescript/prefer-namespace-keyword": "warn"
}
}
]
}
+3
View File
@@ -15,6 +15,9 @@
"--recursive", "--recursive",
"${workspaceRoot}/out/**/*.test.js" "${workspaceRoot}/out/**/*.test.js"
], ],
"env": {
"NODE_PATH": "${workspaceRoot}/out"
},
"sourceMaps": true, "sourceMaps": true,
"outFiles": [ "${workspaceRoot}/out/**/*.js" ], "outFiles": [ "${workspaceRoot}/out/**/*.js" ],
"internalConsoleOptions": "openOnSessionStart" "internalConsoleOptions": "openOnSessionStart"
+1 -1
View File
@@ -39,7 +39,7 @@ The preferred way to report bugs or request features is to use
It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this: It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this:
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transferred there without further comment and core maintainers may or may not participate in discussions. - Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transfered there without further comment and core maintainers may or may not participate in discussions.
- Issues are strictly for well defined features or bugs that are _actionable_. - Issues are strictly for well defined features or bugs that are _actionable_.
- Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that. - Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that.
- If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion. - If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion.
+11 -43
View File
@@ -24,36 +24,7 @@ First, you need to install the module. We ship exclusively through [npm](https:/
npm install --save @xterm/xterm npm install --save @xterm/xterm
``` ```
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package. To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `<div id="terminal"></div>` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
```javascript
import { Terminal } from '@xterm/xterm';
```
Then instantiate a `Terminal` object, open it on an element and write to it:
```javascript
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
```
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
```javascript
pty.onData(data => term.write(data));
term.onData(data => pty.write(data));
```
Then make sure to also include the `css/xterm.css` file, for example in HTML:
```html
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
```
### Standalone example
Here is a complete standalone example in a HTML file:
```html ```html
<!doctype html> <!doctype html>
@@ -65,7 +36,7 @@ Here is a complete standalone example in a HTML file:
<body> <body>
<div id="terminal"></div> <div id="terminal"></div>
<script> <script>
const term = new Terminal(); var term = new Terminal();
term.open(document.getElementById('terminal')); term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ') term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script> </script>
@@ -73,6 +44,14 @@ Here is a complete standalone example in a HTML file:
</html> </html>
``` ```
### Importing
The recommended way to load xterm.js is via the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/xterm';
```
### Addons ### Addons
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project: Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
@@ -115,7 +94,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
### Node.js Support ### Node.js Support
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection. We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
## API ## API
@@ -250,17 +229,6 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system. - [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach. - [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch. - [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [**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.
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
+1 -1
View File
@@ -15,7 +15,7 @@ test.beforeAll(async ({ browser }) => {
}); });
test.afterAll(async () => await ctx.page.close()); test.afterAll(async () => await ctx.page.close());
test.describe('AttachAddon', () => { test.describe('Search Tests', () => {
test.beforeEach(async () => { test.beforeEach(async () => {
await ctx.proxy.reset(); await ctx.proxy.reset();
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+4 -1
View File
@@ -16,10 +16,13 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
"start": "node ../../demo/start" "start": "node ../../demo/start"
},
"dependencies": {
"js-base64": "^3.7.5"
} }
} }
+16 -38
View File
@@ -4,7 +4,8 @@
*/ */
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm'; import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard'; import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
import { Base64 as JSBase64 } from 'js-base64';
export class ClipboardAddon implements ITerminalAddon { export class ClipboardAddon implements ITerminalAddon {
private _terminal?: Terminal; private _terminal?: Terminal;
@@ -24,7 +25,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose(); return this._disposable?.dispose();
} }
private _readText(sel: string, data: string): void { private _readText(sel: ClipboardSelectionType, data: string): void {
const b64 = this._base64.encodeText(data); const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false); this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
} }
@@ -35,7 +36,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true; return true;
} }
const pc = args[0]; const pc = args[0] as ClipboardSelectionType;
const pd = args[1]; const pd = args[1];
if (pd === '?') { if (pd === '?') {
const text = this._provider.readText(pc); const text = this._provider.readText(pc);
@@ -69,53 +70,30 @@ export class ClipboardAddon implements ITerminalAddon {
} }
export class BrowserClipboardProvider implements IClipboardProvider { export class BrowserClipboardProvider implements IClipboardProvider {
public readText(selection: string): Promise<string> { public async readText(selection: ClipboardSelectionType): Promise<string> {
if (selection !== 'c') {
return Promise.resolve('');
}
return navigator.clipboard.readText(); return navigator.clipboard.readText();
} }
public writeText(selection: string, text: string): Promise<void> { public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
if (selection !== 'c') {
return Promise.resolve();
}
return navigator.clipboard.writeText(text); return navigator.clipboard.writeText(text);
} }
} }
/**
* 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 {
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64; return JSBase64.encode(data);
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) { const text = JSBase64.decode(data);
try { if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
} catch {}
return ''; return '';
} }
try { return text;
const bin = atob(data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
bytes[i] = bin.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch {}
return '';
} }
} }
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
test.beforeEach(async ({}, testInfo) => { test.beforeEach(async ({}, testInfo) => {
// DEBT: This test doesn't work since the migration to @playwright/test // DEBT: This test doesn't work since the migration to @playwright/test
if (ctx.browser.browserType().name() === 'webkit') { if (ctx.browser.browserType().name() !== 'chromium') {
testInfo.skip(); testInfo.skip();
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);
@@ -48,14 +48,6 @@ test.describe('ClipboardAddon', () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`); await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
}); });
test('primary selection', async () => {
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('empty selection (default)', async () => {
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('invalid base64 string', async () => { test('invalid base64 string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`); await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
@@ -68,46 +60,19 @@ test.describe('ClipboardAddon', () => {
}); });
test.describe('read data', async function (): Promise<any> { test.describe('read data', async function (): Promise<any> {
test.beforeAll(async () => { test('simple string', async () => {
await ctx.page.evaluate(` await ctx.page.evaluate(`
window.data = []; window.data = [];
window.term.onData(e => data.push(e)); window.term.onData(e => data.push(e));
`); `);
});
test('simple string', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world')); await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;c;?\x07`); await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]); deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
}); });
test('primary selection', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;p;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
});
test('clear clipboard', async () => { test('clear clipboard', async () => {
await ctx.proxy.write(`\x1b]52;c;!\x07`); await ctx.proxy.write(`\x1b]52;c;!\x07`);
await ctx.proxy.write(`\x1b]52;c;?\x07`); await ctx.proxy.write(`\x1b]52;c;?\x07`);
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`]);
});
});
}); });
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+21 -12
View File
@@ -30,6 +30,18 @@ declare module '@xterm/addon-clipboard' {
public dispose(): void public dispose(): void
} }
/**
* Clipboard selection type. This is used to specify which selection buffer to
* read or write to.
* - SYSTEM `c`: The system clipboard.
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
* with Linux X11.
*/
export const enum ClipboardSelectionType {
SYSTEM = 'c',
PRIMARY = 'p',
}
export interface IBase64 { export interface IBase64 {
/** /**
* Converts a utf-8 string to a base64 string. * Converts a utf-8 string to a base64 string.
@@ -66,39 +78,36 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider { export interface IClipboardProvider {
/** /**
* Gets the clipboard content. * Gets the clipboard content.
* @param selection The clipboard selection to read (see OSC 52 spec of xterm). * @param selection The clipboard selection to read.
* @returns A promise that resolves with clipboard selection data. * @returns A promise that resolves with clipboard selection data.
*/ */
readText(selection: string): string | Promise<string>; readText(selection: ClipboardSelectionType): string | Promise<string>;
/** /**
* Sets the clipboard content. * Sets the clipboard content.
* @param selection The clipboard selection to set (see OSC 52 spec of xterm). * @param selection The clipboard selection to set.
* @param data The clipboard text to write. * @param data The clipboard text to write.
*/ */
writeText(selection: string, text: string): void | Promise<void>; writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
} }
/** /**
* The clipboard provider interface that enables xterm.js to access the browser clipboard. * The clipboard provider interface that enables xterm.js to access the system clipboard.
*
* Note that this clipboard provider does not implement own cut buffers as xterm does,
* but redirect the selection parameter always to navigator.clipboard.
*/ */
export class BrowserClipboardProvider implements IClipboardProvider{ export class BrowserClipboardProvider implements IClipboardProvider{
/** /**
* Reads text from the clipboard. * Reads text from the clipboard.
* @param selection The selection type to read from (always refers to navigator.clipboard). * @param selection The selection type to read from.
* @returns A promise that resolves with the text from the clipboard. * @returns A promise that resolves with the text from the clipboard.
*/ */
public readText(selection: string): Promise<string>; public readText(selection: ClipboardSelectionType): Promise<string>;
/** /**
* Writes text to the clipboard. * Writes text to the clipboard.
* @param selection The selection type to write to (always refers to navigator.clipboard). * @param selection The selection type to write to.
* @param data The text to write to the clipboard. * @param data The text to write to the clipboard.
* @returns A promise that resolves when the text has been written to the clipboard. * @returns A promise that resolves when the text has been written to the clipboard.
*/ */
public writeText(selection: string, data: string): Promise<void>; public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
+14 -13
View File
@@ -19,10 +19,8 @@ interface ITerminalDimensions {
cols: number; cols: number;
} }
const enum Constants { const MINIMUM_COLS = 2;
MINIMUM_COLS = 2, const MINIMUM_ROWS = 1;
MINIMUM_ROWS = 1
}
function getWindow(e: Node): Window { function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) { if (e?.ownerDocument?.defaultView) {
@@ -50,7 +48,10 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return; return;
} }
this._terminal.resize(dims.cols, dims.rows); // Force a full render
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
this._terminal.resize(dims.cols, dims.rows);
}
} }
public proposeDimensions(): ITerminalDimensions | undefined { public proposeDimensions(): ITerminalDimensions | undefined {
@@ -74,22 +75,22 @@ export class FitAddon implements ITerminalAddon, IFitApi {
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); : (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement); const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
const parentElementHeight = Math.max(0, parseInt(parentElementStyle.getPropertyValue('height'), 10) || 0); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'), 10) || 0); const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
const elementStyle = _getComputedStyle(this._terminal.element); const elementStyle = _getComputedStyle(this._terminal.element);
const elementPadding = { const elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top'), 10) || 0, top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom'), 10) || 0, bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right'), 10) || 0, right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'), 10) || 0 left: parseInt(elementStyle.getPropertyValue('padding-left'))
}; };
const elementPaddingVer = elementPadding.top + elementPadding.bottom; const elementPaddingVer = elementPadding.top + elementPadding.bottom;
const elementPaddingHor = elementPadding.right + elementPadding.left; const elementPaddingHor = elementPadding.right + elementPadding.left;
const availableHeight = parentElementHeight - elementPaddingVer; const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth; const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = { const geometry = {
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)) rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
}; };
return geometry; return geometry;
} }
-11
View File
@@ -119,17 +119,6 @@ test.describe('FitAddon', () => {
strictEqual(await ctx.proxy.cols, 2); strictEqual(await ctx.proxy.cols, 2);
strictEqual(await ctx.proxy.rows, 1); strictEqual(await ctx.proxy.rows, 1);
}); });
test('same dimensions', async function(): Promise<any> {
await setDimensions();
await ctx.page.evaluate(`window.fit.fit()`);
const cols: number = await ctx.proxy.cols;
const rows: number = await ctx.proxy.rows;
// Calling fit() again at the same container size should not throw
await ctx.page.evaluate(`window.fit.fit()`);
strictEqual(await ctx.proxy.cols, cols);
strictEqual(await ctx.proxy.rows, rows);
});
}); });
}); });
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+42 -40
View File
@@ -1,7 +1,6 @@
## @xterm/addon-image ## @xterm/addon-image
Inline image output in xterm.js. Supports SIXEL, iTerm's inline image protocol (IIP) Inline image output in xterm.js. Supports SIXEL and iTerm's inline image protocol (IIP).
and partially Kitty's terminal graphics prtocol (TGP).
![](fixture/example.png) ![](fixture/example.png)
@@ -26,14 +25,12 @@ const customSettings: IImageAddonOptions = {
pixelLimit: 16777216, // max. pixel size of a single image pixelLimit: 16777216, // max. pixel size of a single image
sixelSupport: true, // enable sixel support sixelSupport: true, // enable sixel support
sixelScrolling: true, // whether to scroll on image output sixelScrolling: true, // whether to scroll on image output
sixelPaletteLimit: 4096, // initial sixel palette size sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence sixelSizeLimit: 25000000, // size limit of a single sixel sequence
storageLimit: 128, // FIFO storage limit in MB storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support iipSupport: true, // enable iTerm IIP support
iipSizeLimit: 33554432, // size limit of a single IIP sequence iipSizeLimit: 20000000 // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
} }
// initialization // initialization
@@ -44,6 +41,9 @@ terminal.loadAddon(imageAddon);
### General Notes ### General Notes
- *IMPORTANT:* The worker approach as done in previous releases got removed.
The addon contructor no longer expects a worker path as first argument.
- By default the addon will activate these `windowOptions` reports on the terminal: - By default the addon will activate these `windowOptions` reports on the terminal:
- getWinSizePixels (CSI 14 t) - getWinSizePixels (CSI 14 t)
- getCellSizePixels (CSI 16 t) - getCellSizePixels (CSI 16 t)
@@ -71,7 +71,7 @@ terminal.loadAddon(imageAddon);
- **Cursor Positioning** - **Cursor Positioning**
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode). If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
Other cursor positioning modes as used by xterm or mintty are not supported for sixel. Other cursor positioning modes as used by xterm or mintty are not supported.
- **SIXEL Palette Handling** - **SIXEL Palette Handling**
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification). By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
@@ -90,7 +90,7 @@ terminal.loadAddon(imageAddon);
therefore palette animations cannot be used. therefore palette animations cannot be used.
- **SIXEL Raster Attributes Handling** - **SIXEL Raster Attributes Handling**
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extent. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extent. Both is discouraged by the >30 years old specification. If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification.
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel. Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
@@ -98,31 +98,17 @@ 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, QOI, WEBP and AVIF - Supported formats: PNG, JPEG and GIF
- 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.
- Filename gets parsed but not used. - Filename gets parsed but not used.
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed). - Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
- Payload size may not exceed CEIL(sizeParameter * 4 / 3).
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`). - Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing). - Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
- Playload size is restricted by `iipSizeLimit`. - Size parameter is restricted by `iipSizeLimit`.
- size parameter is not mandatory anymore. - Cursor positioning behaves the same as for sixel (see above).
- Cursor gets positioned at the next cell of the last image line.
- ReportCellSize sequence is supported.
- Hi-res display support is currently limited (image gets blurred).
- **Note on IIP image format support**
For all image formats except QOI the addon only does the dimension extraction and leaves
the image decoding to the underlying browser engine. Note that some formats are implemented
and enabled with different constraints by browser vendors, especially newer formats
(e.g. under Firefox you might need to change the setting _image.avif.compliance_strictness_ to 0
to get broader AVIF support).
- **Kitty Graphics Support (TGP)**
Set by default, change it with `{kittySupport: true}`.
Note that the kitty graphics support is still WIP.
### Storage and Drawing Settings ### Storage and Drawing Settings
@@ -139,7 +125,6 @@ The addon exposes two properties to interact with the storage limits at runtime:
By default the addon will show a placeholder pattern for evicted images that are still part By default the addon will show a placeholder pattern for evicted images that are still part
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`. of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
### Image Data Retrieval ### Image Data Retrieval
The addon provides the following API endpoints to retrieve raw image data as canvas: The addon provides the following API endpoints to retrieve raw image data as canvas:
@@ -153,12 +138,11 @@ The addon provides the following API endpoints to retrieve raw image data as can
The buffer position is the 0-based absolute index (including scrollback at top). The buffer position is the 0-based absolute index (including scrollback at top).
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions. Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
### Memory Usage ### Memory Usage
The addon does most image processing in JavaScript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, let's look at the data flow and processing steps: The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
- Incoming image data chunk at `term.write` (terminal) - Incomming image data chunk at `term.write` (terminal)
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time. `term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
- Sequence Parser (terminal) - Sequence Parser (terminal)
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB). The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
- Sequence Handler - Chunk Decoding (addon) - Sequence Handler - Chunk Decoding (addon)
@@ -184,18 +168,18 @@ const totalActive = storageBytes + decodingBytes;
``` ```
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions. Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
Further note that the formulas above do not respect the JavaScript object's overhead. Compared to the raw buffer needs the bookkeeping by these objects is rather small (<<5%). Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%).
_Why should I care about memory usage at all?_ _Why should I care about memory usage at all?_
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure. Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
_How can I adjust the memory usage?_ _How can I adjust the memory usage?_
- `pixelLimit` - `pixelLimit`
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage only peaks temporarily, a rather high value should work even with multiple terminals in one page. A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
- `storageLimit` - `storageLimit`
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding. A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit` - `sixelSizeLimit`
Constructor settings. This has only a small impact on peak memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data. A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data.
### Terminal Interaction ### Terminal Interaction
@@ -225,5 +209,23 @@ _How can I adjust the memory usage?_
### Status ### Status
- Sixel and IIP support and image handling in xterm.js is considered beta quality. - Sixel support and image handling in xterm.js is considered beta quality.
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features. - IIP support is in alpha stage. Please file a bug for any awkwardities.
### Changelog
- 0.5.0 integrate with xtermjs base repo (at v0.4.3)
- 0.4.3 defer canvas creation
- 0.4.2 fix image canvas resize
- 0.4.1 compat release for xterm.js 5.2.0
- 0.4.0 IIP support
- 0.3.1 compat release for xterm.js 5.1.0
- 0.3.0 important change: worker removed from addon
- 0.2.0 compat release for xterm.js 5.0.0
- 0.1.3 bugfix: avoid striping
- 0.1.2 bugfix: reset clear flag
- 0.1.1 bugfixes:
- clear sticky image tiles on render
- create folder from bootstrap.sh
- fix peer dependency in package.json
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+2 -2
View File
@@ -18,13 +18,13 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .", "prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
"start": "node ../../demo/start" "start": "node ../../demo/start"
}, },
"devDependencies": { "devDependencies": {
"sixel": "^0.16.0", "sixel": "^0.16.0",
"xterm-wasm-parts": "^0.4.1" "xterm-wasm-parts": "^0.1.0"
} }
} }
+44 -124
View File
@@ -4,26 +4,16 @@
*/ */
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer'; import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage'; import { ImageStorage, 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 { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// Local const enum mirror - esbuild can't inline const enums from external packages // limit hold memory in base64 decoder
const enum DecoderConst { const KEEP_DATA = 4194304;
// Limit held memory in base64 decoder (encoded bytes).
KEEP_DATA = 4194304,
// Initial buffer allocation for the decoder.
INITIAL_DATA = 1048576,
// Local mirror of const enum (esbuild can't inline const enums from external packages)
OK = 0
}
// default IIP header values // default IIP header values
const DEFAULT_HEADER: IHeaderFields = { const DEFAULT_HEADER: IHeaderFields = {
type: SequenceType.INVALID,
name: 'Unnamed file', name: 'Unnamed file',
size: 0, size: 0,
width: 'auto', width: 'auto',
@@ -37,31 +27,22 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _aborted = false; private _aborted = false;
private _hp = new HeaderParser(); private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER; private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder; private _dec = new Base64Decoder(KEEP_DATA);
private _qoiDec: QoiDecoder; private _metrics = UNSUPPORTED_TYPE;
private _isMultipart = false;
private _abortMulti = false;
constructor( constructor(
private readonly _opts: IImageAddonOptions, private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer, private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage, private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt private readonly _coreTerminal: ITerminalExt
) { ) {}
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
}
public reset(): void { public reset(): void {}
this._hp.reset();
this._dec.release();
this._qoiDec.release();
}
public start(): void { public start(): void {
this._aborted = false; this._aborted = false;
this._header = DEFAULT_HEADER;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset(); this._hp.reset();
} }
@@ -69,7 +50,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (this._aborted) return; if (this._aborted) return;
if (this._hp.state === HeaderState.END) { if (this._hp.state === HeaderState.END) {
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) { if (this._dec.put(data, start, end)) {
this._dec.release(); this._dec.release();
this._aborted = true; this._aborted = true;
} }
@@ -80,27 +61,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return; return;
} }
if (dataPos > 0) { if (dataPos > 0) {
const seqType = this._hp.fields.type; this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
if (seqType === SequenceType.FILE) { if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
if (this._isMultipart) {
this._isMultipart = false;
this._abortMulti = false;
this._dec.release();
}
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
if (!this._header.inline) {
this._aborted = true;
return;
}
this._dec.init();
} else if (this._abortMulti) {
this._aborted = true; this._aborted = true;
return; return;
} }
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) { this._dec.init(this._header.size);
if (this._dec.put(data, dataPos, end)) {
this._dec.release(); this._dec.release();
this._aborted = true; this._aborted = true;
if (this._isMultipart) this._abortMulti = true;
} }
} }
} }
@@ -109,67 +78,22 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public end(success: boolean): boolean | Promise<boolean> { public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted) return true; if (this._aborted) return true;
if (this._hp.state !== HeaderState.END) {
if (this._hp.end()) return true;
}
const seqType = this._hp.fields.type;
if (seqType === SequenceType.FILEPART) return true;
if (seqType === SequenceType.REPORTCELLSIZE) {
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
let w = CELL_SIZE_DEFAULT.width;
let h = CELL_SIZE_DEFAULT.height;
if (this._renderer.dimensions) {
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
}
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
this._coreTerminal.input(report, false);
return true;
}
if (seqType === SequenceType.MULTIPARTFILE) {
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
this._isMultipart = true;
this._abortMulti = false;
this._dec.release();
this._dec.init();
return true;
}
if (seqType === SequenceType.FILEEND) {
if (!this._isMultipart) return true;
this._isMultipart = false;
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
}
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
let w = 0; let w = 0;
let h = 0; let h = 0;
// early exit condition chain // early exit condition chain
let cond: number | boolean; let cond: number | boolean = true;
let metrics = UNSUPPORTED_TYPE;
if (cond = success) { if (cond = success) {
if (cond = !this._dec.end()) { if (cond = !this._dec.end()) {
metrics = imageType(this._dec.data8); this._metrics = imageType(this._dec.data8);
if (cond = metrics.mime !== 'unsupported') { if (cond = this._metrics.mime !== 'unsupported') {
w = metrics.width; w = this._metrics.width;
h = metrics.height; h = this._metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) { if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor); [w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit; cond = w && h && w * h < this._opts.pixelLimit;
} else {
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
} }
} else {
console.warn('IIP: unsupported image type');
} }
} else {
console.warn('IIP: error during BASE64 decoding');
} }
} }
if (!cond) { if (!cond) {
@@ -177,35 +101,31 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true; return true;
} }
let blob: Blob | ImageData; // HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
if (metrics.mime === 'image/qoi') { const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
this._qoiDec.height
);
this._qoiDec.release();
if (w === this._qoiDec.width && h === this._qoiDec.height) {
// use fast-path if we don't need to rescale
this._dec.release();
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
canvas.getContext('2d')?.putImageData(blob, 0, 0);
this._storage.addImage(canvas);
return true;
}
} else {
blob = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release(); this._dec.release();
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<boolean>(r => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, w, h);
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
this._storage.addImage(canvas);
r(true);
});
img.src = url;
// sanity measure to avoid terminal blocking from dangling promise
// happens from corrupt data (onload never gets fired)
setTimeout(() => r(true), 1000);
});
}
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => { .then(bm => {
this._storage.addImage(bm); this._storage.addImage(bm);
return true; return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
return true;
}); });
} }
@@ -231,8 +151,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _dim(s: string, total: number, cdim: number): number { private _dim(s: string, total: number, cdim: number): number {
if (s === 'auto') return 0; if (s === 'auto') return 0;
if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100; if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10); if (s.endsWith('px')) return parseInt(s.slice(0, -2));
return parseInt(s, 10) * cdim; return parseInt(s) * cdim;
} }
} }
+7 -129
View File
@@ -4,20 +4,19 @@
*/ */
import { assert } from 'chai'; import { assert } from 'chai';
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser'; import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
const CASES: [string, IHeaderFields][] = [ const CASES: [string, IHeaderFields][] = [
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}], ['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}], ['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
// utf-8 encoding in name // utf-8 encoding in name
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}], ['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}], ['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
// full header spec // full header spec
[ [
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', 'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{ {
type: SequenceType.FILE,
inline: 1, inline: 1,
width: '10px', width: '10px',
height: '20%', height: '20%',
@@ -29,7 +28,6 @@ const CASES: [string, IHeaderFields][] = [
[ [
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', 'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{ {
type: SequenceType.FILE,
inline: 1, inline: 1,
width: 'auto', width: 'auto',
height: '20', height: '20',
@@ -103,7 +101,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length); let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length); assert.strictEqual(res, inp.length);
assert.strictEqual(hp.state, HeaderState.END); assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0}); assert.deepEqual(hp.fields, {name: 'test', size: 0});
hp.reset(); hp.reset();
inp = fromBs(CASES[0][0]); inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length); res = hp.parse(inp, 0, inp.length);
@@ -117,7 +115,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length); let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length); assert.strictEqual(res, inp.length);
assert.strictEqual(hp.state, HeaderState.END); assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456}); assert.deepEqual(hp.fields, {name: '', size: 123456});
hp.reset(); hp.reset();
inp = fromBs(CASES[0][0]); inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length); res = hp.parse(inp, 0, inp.length);
@@ -137,124 +135,4 @@ describe('IIPHeaderParser', () => {
assert.strictEqual(hp.state, HeaderState.END); assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, CASES[0][1]); assert.deepEqual(hp.fields, CASES[0][1]);
}); });
it('FilePart sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
const res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, 9);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
});
it('FilePart sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, 1);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
});
it('MultipartFile sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.VALUE);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(
hp.fields,
{
type: SequenceType.MULTIPARTFILE,
inline: 1,
width: '10px',
height: '20%',
preserveAspectRatio: 1,
size: 123456,
name: 'ümläutß'
}
);
});
it('MultipartFile sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.VALUE);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(
hp.fields,
{
type: SequenceType.MULTIPARTFILE,
inline: 1,
width: '10px',
height: '20%',
preserveAspectRatio: 1,
size: 123456,
name: 'ümläutß'
}
);
});
it('FileEnd sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('FileEnd');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
});
it('FileEnd sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('FileEnd');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
});
it('ReportCellSize sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('ReportCellSize');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
});
it('ReportCellSize sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('ReportCellSize');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
});
}); });
+11 -86
View File
@@ -6,27 +6,9 @@
// eslint-disable-next-line // eslint-disable-next-line
declare const Buffer: any; declare const Buffer: any;
export const enum HeaderState {
START = 0,
ABORT = 1,
KEY = 2,
VALUE = 3,
END = 4
}
export const enum SequenceType {
INVALID = 0,
FILE = 1,
MULTIPARTFILE = 2,
FILEPART = 3,
FILEEND = 4,
REPORTCELLSIZE = 5
}
export interface IHeaderFields { export interface IHeaderFields {
[key: string]: number | string | Uint32Array | null | undefined; [key: string]: number | string | Uint32Array | null | undefined;
// sequence type
type: SequenceType;
// base-64 encoded filename. Defaults to "Unnamed file". // base-64 encoded filename. Defaults to "Unnamed file".
name: string; name: string;
// File size in bytes. The file transfer will be canceled if this size is exceeded. // File size in bytes. The file transfer will be canceled if this size is exceeded.
@@ -47,6 +29,14 @@ export interface IHeaderFields {
inline?: number; inline?: number;
} }
export const enum HeaderState {
START = 0,
ABORT = 1,
KEY = 2,
VALUE = 3,
END = 4
}
// field value decoders // field value decoders
// ASCII bytes to string // ASCII bytes to string
@@ -102,19 +92,7 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
}; };
// sequence type markers
// File
const FILE_MARKER = [70, 105, 108, 101]; const FILE_MARKER = [70, 105, 108, 101];
// MultipartFile
const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];
// FilePart
const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];
// FileEnd
const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];
// ReportCellSize
const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];
// max allowed chars for sequence header
const MAX_FIELDCHARS = 1024; const MAX_FIELDCHARS = 1024;
@@ -133,43 +111,12 @@ export class HeaderParser {
this._key = ''; this._key = '';
} }
public end(): number {
if (this.state === HeaderState.START) {
if (this._position === FILEEND_MARKER.length) {
for (let k = 0; k < FILEEND_MARKER.length; ++k) {
if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILEEND;
this.state = HeaderState.END;
return 0;
}
if (this._position === REPORTCELLSIZE_MARKER.length) {
for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {
if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.REPORTCELLSIZE;
this.state = HeaderState.END;
return 0;
}
return this._a();
}
if (this.state === HeaderState.END) return 0;
if (this.state === HeaderState.VALUE
&& this.fields.type === SequenceType.MULTIPARTFILE
) {
if (!this._storeValue(this._position)) return this._a();
this.state = HeaderState.END;
return 0;
}
return this._a();
}
public parse(data: Uint32Array, start: number, end: number): number { public parse(data: Uint32Array, start: number, end: number): number {
let state = this.state; let state = this.state;
let pos = this._position; let pos = this._position;
const buffer = this._buffer; const buffer = this._buffer;
if (state === HeaderState.ABORT || state === HeaderState.END) return -1; if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
if (state === HeaderState.START && pos > 14) return -1; if (state === HeaderState.START && pos > 6) return -1;
for (let i = start; i < end; ++i) { for (let i = start; i < end; ++i) {
const c = data[i]; const c = data[i];
switch (c) { switch (c) {
@@ -180,29 +127,8 @@ export class HeaderParser {
break; break;
case 61: // = case 61: // =
if (state === HeaderState.START) { if (state === HeaderState.START) {
if (buffer[0] === 70) { for (let k = 0; k < FILE_MARKER.length; ++k) {
// 'File' or 'FilePart' if (buffer[k] !== FILE_MARKER[k]) return this._a();
let k = 0;
for (; k < FILE_MARKER.length; ++k) {
if (buffer[k] !== FILE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILE;
if (pos === FILEPART_MARKER.length) {
for (; k < FILEPART_MARKER.length; ++k) {
if (buffer[k] !== FILEPART_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILEPART;
this.state = HeaderState.END;
return i + 1;
}
} else if (buffer[0] === 77) {
// 'MultipartFile'
for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {
if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.MULTIPARTFILE;
} else {
return this._a();
} }
state = HeaderState.KEY; state = HeaderState.KEY;
pos = 0; pos = 0;
@@ -232,7 +158,6 @@ export class HeaderParser {
} }
private _a(): number { private _a(): number {
this.fields.type = SequenceType.INVALID;
this.state = HeaderState.ABORT; this.state = HeaderState.ABORT;
return -1; return -1;
} }
-28
View File
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IAddImageOpts } from './Types';
import { ImageStorage } from './ImageStorage';
/**
* IIP (iTerm Image Protocol) specific image storage controller.
*
* Wraps the shared ImageStorage with IIP protocol semantics:
* - Always uses scrolling mode (cursor advances with image)
*/
export class IIPImageStorage {
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
constructor(
private readonly _storage: ImageStorage
) {}
/**
* Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._storage.addImage(img, this._addImageOpts);
}
}
+1 -24
View File
@@ -30,30 +30,7 @@ const TEST_IMAGES: [string, IMetrics][] = [
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }], ['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }], ['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
['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 }],
// 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 }],
]; ];
+5 -71
View File
@@ -4,7 +4,7 @@
*/ */
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | ''; export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export interface IMetrics { export interface IMetrics {
mime: ImageType; mime: ImageType;
@@ -19,16 +19,16 @@ export const UNSUPPORTED_TYPE: IMetrics = {
}; };
export function imageType(d: Uint8Array): IMetrics { export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) { if (d.length < 24) {
return UNSUPPORTED_TYPE; return UNSUPPORTED_TYPE;
} }
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8); 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) // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR // + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) { if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return { return {
mime: 'image/png', mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19], width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23] height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
}; };
} }
@@ -41,79 +41,13 @@ export function imageType(d: Uint8Array): IMetrics {
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) { if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return { return {
mime: 'image/gif', mime: 'image/gif',
width: d[7] << 8 | d[6], width: d[7] << 8 | d[6],
height: d[9] << 8 | d[8] 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; return UNSUPPORTED_TYPE;
} }
function jpgSize(d: Uint8Array): [number, number] { function jpgSize(d: Uint8Array): [number, number] {
const len = d.length; const len = d.length;
let i = 4; let i = 4;
+11 -72
View File
@@ -5,72 +5,24 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm'; import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image'; import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
import { IIPHandler } from './IIPHandler'; import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer'; import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
import { KittyImageStorage } from './kitty/KittyImageStorage';
import { SixelHandler } from './SixelHandler'; import { SixelHandler } from './SixelHandler';
import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
/**
* Document VT features provided by this addon.
*
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
*
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
* - max. palette size of 4096 colors
* - max. pixel width of 16K
* - max. 25 MB per sixel sequence
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
*
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
* the following limitations:
* - sequence:
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
* no separator bytes allowed)
* - supported params: size, name, width, height, preserveAspectRatio
* - image formats: PNG, JPEG and GIF
* - no animation support (renders first image of a GIF)
* - no multipart support
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
*
* Kitty graphics support is provided by the addon @xterm/addon-image.
* Note that while basic image output already works, this is still work in progress.
*/
// default values of addon ctor options // default values of addon ctor options
const DEFAULT_OPTIONS: IImageAddonOptions = { const DEFAULT_OPTIONS: IImageAddonOptions = {
enableSizeReports: true, enableSizeReports: true,
pixelLimit: 16777216, // limit to 4096 * 4096 pixels pixelLimit: 16777216, // limit to 4096 * 4096 pixels
sixelSupport: true, sixelSupport: true,
sixelScrolling: true, sixelScrolling: true,
sixelPaletteLimit: 4096, sixelPaletteLimit: 256,
sixelSizeLimit: 33554432, sixelSizeLimit: 25000000,
storageLimit: 128, storageLimit: 128,
showPlaceholder: true, showPlaceholder: true,
iipSupport: true, iipSupport: true,
iipSizeLimit: 33554432, iipSizeLimit: 20000000
kittySupport: true,
kittySizeLimit: 33554432
}; };
// max palette size supported by the sixel lib (compile time setting) // max palette size supported by the sixel lib (compile time setting)
@@ -104,8 +56,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = []; private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined; private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map(); private _handlers: Map<String, IResetHandler> = new Map();
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
constructor(opts?: Partial<IImageAddonOptions>) { constructor(opts?: Partial<IImageAddonOptions>) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts); this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -118,7 +68,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
} }
this._disposables.length = 0; this._disposables.length = 0;
this._handlers.clear(); this._handlers.clear();
this._onImageAdded.dispose();
} }
private _disposeLater(...args: IDisposable[]): void { private _disposeLater(...args: IDisposable[]): void {
@@ -133,10 +82,14 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// internal data structures // internal data structures
this._renderer = new ImageRenderer(terminal); this._renderer = new ImageRenderer(terminal);
this._storage = new ImageStorage(terminal, this._renderer, this._opts); this._storage = new ImageStorage(terminal, this._renderer, this._opts);
this._storage.onImageAdded = () => this._onImageAdded.fire();
// enable size reports // enable size reports
if (this._opts.enableSizeReports) { if (this._opts.enableSizeReports) {
// const windowOptions = terminal.getOption('windowOptions');
// windowOptions.getWinSizePixels = true;
// windowOptions.getCellSizePixels = true;
// windowOptions.getWinSizeChars = true;
// terminal.setOption('windowOptions', windowOptions);
const windowOps = terminal.options.windowOptions ?? {}; const windowOps = terminal.options.windowOptions ?? {};
windowOps.getWinSizePixels = true; windowOps.getWinSizePixels = true;
windowOps.getCellSizePixels = true; windowOps.getCellSizePixels = true;
@@ -176,8 +129,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// SIXEL handler // SIXEL handler
if (this._opts.sixelSupport) { if (this._opts.sixelSupport) {
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal); const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
this._handlers.set('sixel', sixelHandler); this._handlers.set('sixel', sixelHandler);
this._disposeLater( this._disposeLater(
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler) terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
@@ -186,25 +138,12 @@ 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 iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
const iipHandler = new IIPHandler(this._opts, this._renderer!, 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)
); );
} }
// Kitty graphics handler
if (this._opts.kittySupport) {
const kittyStorage = new KittyImageStorage(this._storage!);
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
this._handlers.set('kitty', kittyHandler);
this._disposeLater(
kittyStorage,
kittyHandler,
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
}
} }
// Note: storageLimit is skipped here to not intoduce a surprising side effect. // Note: storageLimit is skipped here to not intoduce a surprising side effect.
@@ -255,7 +194,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
} }
private _report(s: string): void { private _report(s: string): void {
this._terminal?._core.input(s, false); this._terminal?._core.coreService.triggerDataEvent(s);
} }
private _decset(params: (number | number[])[]): boolean { private _decset(params: (number | number[])[]): boolean {
+50 -96
View File
@@ -5,13 +5,11 @@
import { toRGBA8888 } from 'sixel/lib/Colors'; 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 { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const enum Constants { const PLACEHOLDER_LENGTH = 4096;
PLACEHOLDER_LENGTH = 4096, const PLACEHOLDER_HEIGHT = 24;
PLACEHOLDER_HEIGHT = 24
}
/** /**
* ImageRenderer - terminal frontend extension: * ImageRenderer - terminal frontend extension:
@@ -20,9 +18,8 @@ const enum Constants {
* - draw image tiles onRender * - draw image tiles onRender
*/ */
export class ImageRenderer extends Disposable implements IDisposable { export class ImageRenderer extends Disposable implements IDisposable {
/** @deprecated Kept for backward compat — points to top layer canvas. */ public canvas: HTMLCanvasElement | undefined;
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; } private _ctx: CanvasRenderingContext2D | null | undefined;
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
private _placeholder: HTMLCanvasElement | undefined; private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined; private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable()); private _optionsRefresh = this._register(new MutableDisposable());
@@ -89,7 +86,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
}); });
this._register(toDisposable(() => { this._register(toDisposable(() => {
this.removeLayerFromDom(); this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
if (this._terminal._core && this._oldOpen) { if (this._terminal._core && this._oldOpen) {
this._terminal._core.open = this._oldOpen; this._terminal._core.open = this._oldOpen;
this._oldOpen = undefined; this._oldOpen = undefined;
@@ -99,7 +95,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined; this._oldSetRenderer = undefined;
} }
this._renderService = undefined; this._renderService = undefined;
this._layers.clear(); this.canvas = undefined;
this._ctx = undefined;
this._placeholderBitmap?.close(); this._placeholderBitmap?.close();
this._placeholderBitmap = undefined; this._placeholderBitmap = undefined;
this._placeholder = undefined; this._placeholder = undefined;
@@ -112,7 +109,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
public showPlaceholder(value: boolean): void { public showPlaceholder(value: boolean): void {
if (value) { if (value) {
if (!this._placeholder && this.cellSize.height !== -1) { if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT)); this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
} }
} else { } else {
this._placeholderBitmap?.close(); this._placeholderBitmap?.close();
@@ -143,38 +140,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
/** /**
* 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): void {
const y = start * (this.dimensions?.css.cell.height || 0); this._ctx?.clearRect(
const w = this.dimensions?.css.canvas.width || 0; 0,
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0); start * (this.dimensions?.css.cell.height || 0),
if (!layer || layer === 'top') { this.dimensions?.css.canvas.width || 0,
this._layers.get('top')?.clearRect(0, y, w, h); (++end - start) * (this.dimensions?.css.cell.height || 0)
} );
if (!layer || layer === 'bottom') {
this._layers.get('bottom')?.clearRect(0, y, w, h);
}
} }
/** /**
* Clear whole image canvas. * Clear whole image canvas.
*/ */
public clearAll(layer?: ImageLayer): void { public clearAll(): void {
if (!layer || layer === 'top') { this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
const ctx = this._layers.get('top');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
if (!layer || layer === 'bottom') {
const ctx = this._layers.get('bottom');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
} }
/** /**
* Draw neighboring tiles on the image layer canvas. * Draw neighboring tiles on the image layer canvas.
*/ */
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); if (!this._ctx) {
if (!ctx) {
return; return;
} }
const { width, height } = this.cellSize; const { width, height } = this.cellSize;
@@ -201,7 +187,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
// 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( this._ctx.drawImage(
img, img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight), Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight) Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
@@ -241,8 +227,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* 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'); if (this._ctx) {
if (ctx) {
const { width, height } = this.cellSize; const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics. // Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -251,12 +236,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
} }
if (!this._placeholder) { if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT)); this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) { } else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1); this._createPlaceHolder(height + 1);
} }
if (!this._placeholder) return; if (!this._placeholder) return;
ctx.drawImage( this._ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!, this._placeholderBitmap ?? this._placeholder!,
col * width, col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction (row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -275,13 +260,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Checked once from `ImageStorage.render`. * Checked once from `ImageStorage.render`.
*/ */
public rescaleCanvas(): void { public rescaleCanvas(): void {
const w = this.dimensions?.css.canvas.width || 0; if (!this.canvas) {
const h = this.dimensions?.css.canvas.height || 0; return;
for (const ctx of this._layers.values()) { }
if (ctx.canvas.width !== w || ctx.canvas.height !== h) { if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
ctx.canvas.width = w; this.canvas.width = this.dimensions!.css.canvas.width || 0;
ctx.canvas.height = h; this.canvas.height = this.dimensions!.css.canvas.height || 0;
}
} }
} }
@@ -320,68 +304,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._renderService = this._terminal._core._renderService; this._renderService = this._terminal._core._renderService;
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService); this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
this._renderService.setRenderer = (renderer: any) => { this._renderService.setRenderer = (renderer: any) => {
for (const key of [...this._layers.keys()]) { this.removeLayerFromDom();
this.removeLayerFromDom(key);
}
this._oldSetRenderer?.call(this._renderService, renderer); this._oldSetRenderer?.call(this._renderService, renderer);
}; };
} }
public insertLayerToDom(layer: ImageLayer = 'top'): void { public insertLayerToDom(): void {
// make sure that the terminal is attached to a document and to DOM // make sure that the terminal is attached to a document and to DOM
if (!this.document || !this._terminal._core.screenElement) { if (this.document && this._terminal._core.screenElement) {
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement'); if (!this.canvas) {
return; this.canvas = ImageRenderer.createCanvas(
} this.document, this.dimensions?.css.canvas.width || 0,
if (this._layers.has(layer)) { this.dimensions?.css.canvas.height || 0
return; );
} this.canvas.classList.add('xterm-image-layer');
const canvas = ImageRenderer.createCanvas( this._terminal._core.screenElement.appendChild(this.canvas);
this.document, this.dimensions?.css.canvas.width || 0, this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
this.dimensions?.css.canvas.height || 0 this.clearAll();
); }
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
// Use isolation to create a stacking context without overriding z-index,
// which would conflict with integrators (e.g. VS Code) that set their
// own z-index on the screen element.
screenElement.style.isolation = 'isolate';
if (layer === 'bottom') {
// Use z-index:-1 so it paints behind non-positioned text elements.
// The screen element needs to be a stacking context (via isolation)
// to contain the negative z-index, otherwise it would go behind the
// entire terminal.
canvas.style.zIndex = '-1';
screenElement.insertBefore(canvas, screenElement.firstChild);
} else { } else {
// Explicit z-index ensures the image canvas reliably stacks above console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
// the text layer (DOM renderer rows). z-index: 0 is below the
// selection overlay (z-index: 1).
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
}
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
return;
}
this._layers.set(layer, ctx);
this.clearAll(layer);
}
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
const ctx = this._layers.get(layer);
if (ctx) {
ctx.canvas.remove();
this._layers.delete(layer);
} }
} }
public hasLayer(layer: ImageLayer): boolean { public removeLayerFromDom(): void {
return this._layers.has(layer); if (this.canvas) {
this._ctx = undefined;
this.canvas.remove();
this.canvas = undefined;
}
} }
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void { private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close(); this._placeholderBitmap?.close();
this._placeholderBitmap = undefined; this._placeholderBitmap = undefined;
@@ -405,7 +359,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.putImageData(imgData, 0, 0); ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width // create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH; const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height); this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false }); const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) { if (!ctx2) {
+170 -147
View File
@@ -5,11 +5,8 @@
import { IDisposable } from '@xterm/xterm'; import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer'; import { ImageRenderer } from './ImageRenderer';
import type { import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts
} from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
// fallback default cell size // fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = { export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -17,12 +14,93 @@ 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:
@@ -44,11 +122,8 @@ export class ImageStorage implements IDisposable {
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 _pixelLimit: number = 2500000;
private _workCell: CellData = new CellData();
private _viewportMetrics: { cols: number, rows: number }; private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor( constructor(
private _terminal: ITerminalExt, private _terminal: ITerminalExt,
@@ -114,13 +189,11 @@ export class ImageStorage implements IDisposable {
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;
this._images.delete(id); this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :( // FIXME: really ugly workaround to get bitmaps deallocated :(
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) { if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close(); spec.orig.close();
} }
this.onImageDeleted?.(id);
} }
/** /**
@@ -144,29 +217,27 @@ export class ImageStorage implements IDisposable {
} }
/** /**
* Delete an image by its internal storage ID. * Only advance text cursor.
* Used by protocols that support explicit deletion (e.g. Kitty a=d). * This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/ */
public deleteImage(id: number): void { public advanceCursor(height: number): void {
const spec = this._images.get(id); if (this._opts.sixelScrolling) {
if (spec) { let cellSize = this._renderer.cellSize;
spec.marker?.dispose(); if (cellSize.width === -1 || cellSize.height === -1) {
this._delImg(id); cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
} }
} }
/** /**
* Method to add an image to the storage. * Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
* @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move.
* - layer: Which canvas layer to render on ('top' or 'bottom').
* - zIndex: Z-index for image layering within the same layer.
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
* @returns The internal image ID assigned to the stored image.
*/ */
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number { public addImage(img: HTMLCanvasElement | ImageBitmap): void {
// never allow storage to exceed memory limit // never allow storage to exceed memory limit
this._evictOldest(img.width * img.height); this._evictOldest(img.width * img.height);
@@ -188,7 +259,7 @@ export class ImageStorage implements IDisposable {
let offset = originX; let offset = originX;
let tileCount = 0; let tileCount = 0;
if (!opts.scrolling) { if (!this._opts.sixelScrolling) {
buffer.x = 0; buffer.x = 0;
buffer.y = 0; buffer.y = 0;
offset = 0; offset = 0;
@@ -196,13 +267,13 @@ 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, offset + col, imageId, row * cols + col); this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++; tileCount++;
} }
if (opts.scrolling) { if (this._opts.sixelScrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed(); if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else { } else {
if (++buffer.y >= termRows) break; if (++buffer.y >= termRows) break;
@@ -212,12 +283,8 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes // cursor positioning modes
if (opts.scrolling) { if (this._opts.sixelScrolling) {
if (opts.cursorPos === 'iip') { buffer.x = offset;
buffer.x = Math.min(offset + cols, termCols);
} else {
buffer.x = offset;
}
} else { } else {
buffer.x = originX; buffer.x = originX;
buffer.y = originY; buffer.y = originY;
@@ -259,15 +326,11 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined, marker: endMarker || undefined,
tileCount, tileCount,
bufferType: this._terminal.buffer.active.type, bufferType: this._terminal.buffer.active.type
layer: opts.layer,
zIndex: opts.zIndex
}; };
// finally add the image // finally add the image
this._images.set(imageId, imgSpec); this._images.set(imageId, imgSpec);
this.onImageAdded?.();
return imageId;
} }
@@ -277,30 +340,16 @@ export class ImageStorage implements IDisposable {
*/ */
// TODO: Should we move this to the ImageRenderer? // TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void { public render(range: { start: number, end: number }): void {
// Determine which layers have images // setup image canvas in case we have none yet, but have images in store
let hasTopImages = false; if (!this._renderer.canvas && this._images.size) {
let hasBottomImages = false; this._renderer.insertLayerToDom();
for (const spec of this._images.values()) { // safety measure - in case we cannot spawn a canvas at all, just exit
if (spec.layer === 'bottom') { if (!this._renderer.canvas) {
hasBottomImages = true; return;
} else {
hasTopImages = true;
} }
if (hasTopImages && hasBottomImages) break;
} }
// Lazily insert layers that are needed
if (hasTopImages && !this._renderer.hasLayer('top')) {
this._renderer.insertLayerToDom('top');
if (!this._renderer.hasLayer('top')) return;
}
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
this._renderer.insertLayerToDom('bottom');
}
// rescale if needed // rescale if needed
this._renderer.rescaleCanvas(); this._renderer.rescaleCanvas();
// exit early if we dont have any images to test for // exit early if we dont have any images to test for
if (!this._images.size) { if (!this._images.size) {
if (!this._fullyCleared) { if (!this._fullyCleared) {
@@ -308,25 +357,12 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true; this._fullyCleared = true;
this._needsFullClear = false; this._needsFullClear = false;
} }
if (this._renderer.hasLayer('top')) { if (this._renderer.canvas) {
this._renderer.removeLayerFromDom('top'); this._renderer.removeLayerFromDom();
}
if (this._renderer.hasLayer('bottom')) {
this._renderer.removeLayerFromDom('bottom');
} }
return; return;
} }
// Remove layers no longer needed
if (!hasTopImages && this._renderer.hasLayer('top')) {
this._renderer.clearAll('top');
this._renderer.removeLayerFromDom('top');
}
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
this._renderer.clearAll('bottom');
this._renderer.removeLayerFromDom('bottom');
}
// buffer switches force a full clear // buffer switches force a full clear
if (this._needsFullClear) { if (this._needsFullClear) {
this._renderer.clearAll(); this._renderer.clearAll();
@@ -341,17 +377,13 @@ export class ImageStorage implements IDisposable {
// clear drawing area // clear drawing area
this._renderer.clearLines(start, end); this._renderer.clearLines(start, end);
// Collect draw calls so we can sort by z-index (lower z drawn first). // walk all cells in viewport and draw tiles found
const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];
const placeholderCalls: { col: number, row: number, count: number }[] = [];
// walk all cells in viewport and collect tiles found
for (let row = start; row <= end; ++row) { for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp); const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
if (!line) return; if (!line) return;
for (let col = 0; col < cols; ++col) { for (let col = 0; col < cols; ++col) {
const e = line.getExtended(col)?.payload; if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
if (e instanceof ImageTileInfo) { let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
const imageId = e.imageId; const imageId = e.imageId;
if (imageId === undefined || imageId === -1) { if (imageId === undefined || imageId === -1) {
continue; continue;
@@ -367,39 +399,28 @@ export class ImageStorage implements IDisposable {
* - cell has same image id * - cell has same image id
* - cell has consecutive tile id * - cell has consecutive tile id
*/ */
while (++col < cols) { while (
const nextE = line.getExtended(col)?.payload; ++col < cols
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { && (line.getBg(col) & BgFlags.HAS_EXTENDED)
break; && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
} && (e.imageId === imageId)
&& (e.tileId === startTile + count)
) {
count++; count++;
} }
col--; col--;
if (imgSpec) { if (imgSpec) {
if (imgSpec.actual) { if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); this._renderer.draw(imgSpec, startTile, startCol, row, count);
} }
} else if (this._opts.showPlaceholder) { } else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count }); this._renderer.drawPlaceholder(startCol, row, count);
} }
this._fullyCleared = false; this._fullyCleared = false;
} }
} }
} }
} }
// Sort by z-index so lower z draws first (higher z renders on top)
drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);
// Draw placeholders first (lowest priority)
for (const call of placeholderCalls) {
this._renderer.drawPlaceholder(call.col, call.row, call.count);
}
// Draw images in z-index order
for (const call of drawCalls) {
this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);
}
} }
public viewportResize(metrics: { cols: number, rows: number }): void { public viewportResize(metrics: { cols: number, rows: number }): void {
@@ -421,9 +442,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)!; const line = buffer.lines.get(row) as IBufferLineExt;
const e = line.getExtended(oldCol)?.payload; if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
if (e instanceof ImageTileInfo) { 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;
@@ -440,7 +461,7 @@ export class ImageStorage implements IDisposable {
// 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.hasContent(rightCol)) { if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
hasData = true; hasData = true;
break; break;
} }
@@ -452,7 +473,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, expandCol, imageId, ++lastTile); this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
imgSpec.tileCount++; imgSpec.tileCount++;
} }
} }
@@ -466,10 +487,10 @@ 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); const line = buffer.lines.get(y) as IBufferLineExt;
if (line) { if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e = line.getExtended(x)?.payload; const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) { if (e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig; const orig = this._images.get(e.imageId)?.orig;
if (window.ImageBitmap && orig instanceof ImageBitmap) { if (window.ImageBitmap && orig instanceof ImageBitmap) {
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height); const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
@@ -486,10 +507,10 @@ export class ImageStorage implements IDisposable {
*/ */
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer; const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y); const line = buffer.lines.get(y) as IBufferLineExt;
if (line) { if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e = line.getExtended(x)?.payload; const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) { if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId); const spec = this._images.get(e.imageId);
if (spec) { if (spec) {
return this._renderer.extractTile(spec, e.tileId); return this._renderer.extractTile(spec, e.tileId);
@@ -517,31 +538,31 @@ export class ImageStorage implements IDisposable {
return used - current; return used - current;
} }
private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void { private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
const workCell = this._workCell; if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
line.loadCell(x, workCell); const old = line._extendedAttrs[x];
if (this._workCell.hasExtendedAttrs()) { if (old) {
const old = workCell.extended.payload; if (old.imageId !== undefined) {
if (old instanceof ImageTileInfo) { // found an old ExtendedAttrsImage, since we know that
// found an old ExtendedAttrsImage, since we know that // they are always isolated instances (single cell usage),
// they are always isolated instances (single cell usage), // we can re-use it and just update their id entries
// we can re-use it and just update their id entries const oldSpec = this._images.get(old.imageId);
const oldSpec = this._images.get(old.imageId); if (oldSpec) {
if (oldSpec) { // early eviction for in-viewport overwrites
// early eviction for in-viewport overwrites oldSpec.tileCount--;
oldSpec.tileCount--; }
old.imageId = imageId;
old.tileId = tileId;
return;
} }
old.imageId = imageId; // found a plain ExtendedAttrs instance, clone it to new entry
old.tileId = tileId; line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
return; return;
} }
} }
// fall-through: always create new ExtendedAttrs entry // fall-through: always create new ExtendedAttrsImage entry
const extattr = workCell.extended.clone(); line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
extattr.payload = new ImageTileInfo(imageId, tileId); line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
workCell.extended = extattr;
workCell.updateExtended();
line.setCell(x, workCell);
} }
private _evictOnAlternate(): void { private _evictOnAlternate(): void {
@@ -554,16 +575,18 @@ 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); const line = buffer.lines.get(y) as IBufferLineExt;
if (!line) { if (!line) {
continue; continue;
} }
for (let x = 0; x < this._terminal.cols; ++x) { for (let x = 0; x < this._terminal.cols; ++x) {
const payload = line.getExtended(x)?.payload; if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
if (payload instanceof ImageTileInfo) { const imgId = line._extendedAttrs[x]?.imageId;
const spec = this._images.get(payload.imageId); if (imgId) {
if (spec) { const spec = this._images.get(imgId);
spec.tileCount++; if (spec) {
spec.tileCount++;
}
} }
} }
} }
+3 -3
View File
@@ -3,7 +3,7 @@
* @license MIT * @license MIT
*/ */
import { SixelImageStorage } from './SixelImageStorage'; import { ImageStorage } from './ImageStorage';
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';
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
constructor( constructor(
private readonly _opts: IImageAddonOptions, private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage, private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt private readonly _coreTerminal: ITerminalExt
) { ) {
DecoderAsync({ DecoderAsync({
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const height = this._dec.height; const height = this._dec.height;
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37 // partial fix for https://github.com/jerch/xterm-addon-image/issues/37
if (!width || !height) { if (!width || ! height) {
if (height) { if (height) {
this._storage.advanceCursor(height); this._storage.advanceCursor(height);
} }
@@ -1,52 +0,0 @@
/**
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
import { ImageRenderer } from './ImageRenderer';
/**
* Sixel-specific image storage controller.
*
* Wraps the shared ImageStorage with sixel protocol semantics:
* - Cursor behavior governed by DECSET 80 (sixelScrolling option)
* - advanceCursor for empty sixels carrying only height
*/
export class SixelImageStorage {
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };
constructor(
private readonly _storage: ImageStorage,
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _terminal: ITerminalExt
) {}
/**
* Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(img, this._addImageOpts);
}
/**
* Only advance text cursor.
* This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
}
+18 -19
View File
@@ -8,8 +8,8 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
// private imports from base repo we build against // private imports from base repo we build against
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants'; import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
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, IEscapeSequenceParser } from 'common/parser/Types';
import type { IInputHandler } from 'common/Types'; import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/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';
@@ -22,7 +22,7 @@ export const enum Cell {
} }
// export some privates for local usage // export some privates for local usage
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet }; export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/** /**
* Plugin ctor options. * Plugin ctor options.
@@ -38,8 +38,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number; sixelSizeLimit: number;
iipSupport: boolean; iipSupport: boolean;
iipSizeLimit: number; iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
} }
export interface IResetHandler { export interface IResetHandler {
@@ -47,7 +45,6 @@ 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,6 +52,19 @@ 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;
@@ -65,7 +75,6 @@ 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;
@@ -77,6 +86,8 @@ 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.
@@ -86,16 +97,6 @@ export interface ICellSize {
height: number; height: number;
} }
export type ImageLayer = 'top' | 'bottom';
export type CursorPos = 'vt340' | 'iip';
export interface IAddImageOpts {
scrolling: boolean;
layer: ImageLayer;
zIndex: number;
cursorPos: CursorPos;
}
export interface IImageSpec { export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined; orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize; origCellSize: ICellSize;
@@ -104,6 +105,4 @@ export interface IImageSpec {
marker: IMarker | undefined; marker: IMarker | undefined;
tileCount: number; tileCount: number;
bufferType: 'alternate' | 'normal'; bufferType: 'alternate' | 'normal';
layer: ImageLayer;
zIndex: number;
} }
@@ -1,819 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
KittyAction,
KittyFormat,
KittyCompression,
IKittyCommand,
IPendingTransmission,
IKittyImageData,
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler)
DECODER_KEEP_DATA = 4194304,
DECODER_INITIAL_DATA = 4194304, // 4MB
// Local mirror of const enum (esbuild can't inline const enums from external packages)
DECODER_OK = 0,
// Maximum control data size
MAX_CONTROL_DATA_SIZE = 512,
// Semicolon codepoint
SEMICOLON = 0x3B
}
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
private _aborted = false;
private _decodeError = false;
private _activeDecoder: Base64Decoder | null = null;
private readonly _maxEncodedBytes: number;
private readonly _initialEncodedBytes: number;
// Streaming related states
// True while receiving control data (before semicolon).
private _inControlData = true;
// Buffer for control data.
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
private _controlLength = 0;
// Pre-calculated encoded size limit
private _encodedSizeLimit = 0;
private _totalEncodedSize = 0;
// Parsed command. These are the control data before semicolon.
private _parsedCommand: IKittyCommand | null = null;
// Storage related states
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
// Tracks the pending key of the most recently started chunked upload.
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
// When a chunk arrives with no i=, this key is used to find the pending upload.
private _lastPendingKey: number | undefined;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _kittyStorage: KittyImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
// Convert decoded size limit -> max encoded bytes.
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
// ensure we preallocate more than configured limit while using 4mb initial size.
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
}
public reset(): void {
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
this._kittyStorage.reset();
}
public dispose(): void {
this.reset();
}
private _removePendingEntry(key: number): void {
this._pendingTransmissions.delete(key);
if (this._lastPendingKey === key) {
this._lastPendingKey = undefined;
}
}
private _cleanupAllPending(): void {
for (const pending of this._pendingTransmissions.values()) {
pending.decoder.release();
}
this._pendingTransmissions.clear();
this._lastPendingKey = undefined;
}
public start(): void {
this._aborted = false;
this._decodeError = false;
this._inControlData = true;
this._controlLength = 0;
this._parsedCommand = null;
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
this._encodedSizeLimit = this._maxEncodedBytes;
this._totalEncodedSize = 0;
this._activeDecoder = null;
}
public put(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
if (!this._inControlData) {
this._streamPayload(data, start, end);
} else {
// Scan for semicolon
let controlEnd = end;
for (let i = start; i < end; i++) {
if (data[i] === Constants.SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
}
}
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
this._aborted = true;
return;
}
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
this._controlLength += copyLength;
if (!this._inControlData) {
// Found semicolon - parse control data early for validation
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
// Early validation: i+I conflict
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
this._aborted = true;
return;
}
// Delete action doesn't need payload - skip streaming
if (this._parsedCommand.action === KittyAction.DELETE) {
return;
}
// Stream remaining as payload
const payloadStart = controlEnd + 1;
if (payloadStart < end) {
this._streamPayload(data, payloadStart, end);
}
}
}
}
// Stream payload bytes into the base64 decoder.
private _streamPayload(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
// Check size limit (compare encoded bytes against pre-calculated limit)
// Include cumulative size from pending transmission for multi-chunk images.
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
const pending = this._pendingTransmissions.get(pendingKey);
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
this._totalEncodedSize += end - start;
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
if (cumulativeEncodedSize > this._encodedSizeLimit) {
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
if (decoderToRelease) {
decoderToRelease.release();
}
this._activeDecoder = null;
if (pending) {
this._removePendingEntry(pendingKey);
}
this._aborted = true;
return;
}
if (this._decodeError) return;
if (pending?.decoder && !this._activeDecoder) {
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
this._activeDecoder.release();
this._activeDecoder = null;
this._decodeError = true;
if (pending) {
this._removePendingEntry(pendingKey);
}
}
}
public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted || !success) {
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
return true;
}
// No semicolon = no payload (delete, capability query)
if (this._inControlData) {
return this._handleNoPayloadCommand();
}
// Use command parsed early in put() - i+I already validated there
const cmd = this._parsedCommand!;
// Delete action was handled by skipping payload - just execute
if (cmd.action === KittyAction.DELETE) {
return this._handleDelete(cmd);
}
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
const isMoreComing = cmd.more === 1;
const pending = this._pendingTransmissions.get(pendingKey);
if (isMoreComing) {
if (this._activeDecoder) {
if (pending) {
pending.totalEncodedSize += this._totalEncodedSize;
pending.decodeError = pending.decodeError || this._decodeError;
} else {
this._pendingTransmissions.set(pendingKey, {
cmd: { ...cmd },
decoder: this._activeDecoder,
totalEncodedSize: this._totalEncodedSize,
decodeError: this._decodeError
});
}
this._lastPendingKey = pendingKey;
this._activeDecoder = null;
}
return true;
}
// Final chunk received — clear the last pending key
if (pending) {
this._lastPendingKey = undefined;
}
let decodeError = this._decodeError;
let finalCmd = cmd;
let decoder = this._activeDecoder;
if (pending) {
finalCmd = pending.cmd;
decoder = pending.decoder;
decodeError = decodeError || pending.decodeError;
this._pendingTransmissions.delete(pendingKey);
}
let imageBytes = new Uint8Array(0);
if (decoder) {
if (decoder.end() !== DECODER_OK) {
decodeError = true;
}
imageBytes = decoder.data8;
}
this._activeDecoder = null;
// Handle command first — handlers create Blob/ImageData from imageBytes,
// which copies the data. Only then is it safe to release the decoder's
// wasm memory that imageBytes points into.
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
if (decoder) {
decoder.release();
}
return result;
}
// Command handling
private _parseControlDataString(): string {
let str = '';
for (let i = 0; i < this._controlLength; i++) {
str += String.fromCodePoint(this._controlData[i]);
}
return str;
}
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
const cmd = parseKittyCommand(this._parseControlDataString());
// Per spec: specifying both i and I is an error
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
return true;
}
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.DELETE:
return this._handleDelete(cmd);
case KittyAction.QUERY:
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
return true;
case KittyAction.PLACEMENT:
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.TRANSMIT: {
const result = this._handleTransmit(cmd, bytes, decodeError);
// Only send response when _handleTransmit didn't already respond
// (it handles unsupported transmission medium responses internally)
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
if (decodeError) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
} else if (bytes.length > 0) {
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
}
}
return result;
}
case KittyAction.TRANSMIT_DISPLAY:
return this._handleTransmitDisplay(cmd, bytes, decodeError);
case KittyAction.QUERY:
return this._handleQuery(cmd, bytes, decodeError);
case KittyAction.PLACEMENT:
// a=p ignores any payload — image data was already transmitted
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
if (cmd.id === undefined) {
return true;
}
const id = cmd.id;
const image = this._kittyStorage.getImage(id);
if (!image) {
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
const result = this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
return true;
});
}
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
// Currently only supports direct transmission (t=d, the default).
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
// 1. Check cmd.transmission (t key) before treating bytes as image data
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
// 3. For t=d: treat bytes as image data (current behavior)
// When implementing, also update _handleQuery to accept these transmission mediums.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
}
return true;
}
if (decodeError || bytes.length === 0) return true;
this._kittyStorage.storeImage(cmd.id, {
data: new Blob([bytes as BlobPart]),
width: cmd.width ?? 0,
height: cmd.height ?? 0,
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
compression: cmd.compression ?? ''
});
return true;
}
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
if (decodeError) {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
}
return true;
}
this._handleTransmit(cmd, bytes, decodeError);
const id = cmd.id ?? this._kittyStorage.lastImageId;
const image = this._kittyStorage.getImage(id);
if (image) {
const result = this._displayImage(image, cmd);
if (cmd.id !== undefined) {
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
return true;
});
}
return result.then(() => true);
}
return true;
}
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
const id = cmd.id ?? 0;
const quiet = cmd.quiet ?? 0;
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
return true;
}
// Check decode error first (invalid base64)
if (decodeError) {
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
return true;
}
// Capability query (no payload) - just respond OK
if (bytes.length === 0) {
this._sendResponse(id, 'OK', quiet);
return true;
}
const format = cmd.format ?? KittyFormat.RGBA;
if (format === KittyFormat.PNG) {
this._sendResponse(id, 'OK', quiet);
} else {
const width = cmd.width ?? 0;
const height = cmd.height ?? 0;
if (!width || !height) {
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
return true;
}
this._sendResponse(id, 'OK', quiet);
}
return true;
}
private _handleDelete(cmd: IKittyCommand): boolean {
// Per spec: default delete selector is 'a' (delete all visible placements)
const selector = cmd.deleteSelector ?? 'a';
// TODO: Distinguish lowercase (delete placements only) from uppercase
// (delete placements + free stored image data). Currently both variants
// free everything since we don't separate stored data from placements.
switch (selector) {
case 'a':
case 'A':
this._cleanupAllPending();
this._kittyStorage.deleteAll();
break;
case 'i':
case 'I':
// TODO: When placement id tracking is implemented (see TODO in
// KittyImageStorage), d=i with p=<pid> should delete only that
// specific placement, while d=i without p should delete all
// placements for the image.
if (cmd.id !== undefined) {
const pending = this._pendingTransmissions.get(cmd.id);
if (pending) {
pending.decoder.release();
}
this._removePendingEntry(cmd.id);
this._kittyStorage.deleteById(cmd.id);
}
break;
default:
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
break;
}
return true;
}
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
const isOk = message === 'OK';
if (isOk && quiet >= 1) return;
if (!isOk && quiet >= 2) return;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
this._coreTerminal._core.coreService.triggerDataEvent(response);
}
// Image display
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
return this._decodeAndDisplay(image, cmd)
.then(() => true)
.catch(() => false);
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
const maxCropW = Math.max(0, bitmap.width - cropX);
const maxCropH = Math.max(0, bitmap.height - cropY);
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
if (finalCropW === 0 || finalCropH === 0) {
throw new Error('invalid source rectangle');
}
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
bitmap.close();
bitmap = cropped;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
let imgCols: number;
let imgRows: number;
if (cmd.columns !== undefined && cmd.rows !== undefined) {
imgCols = cmd.columns;
imgRows = cmd.rows;
} else if (cmd.columns !== undefined) {
imgCols = cmd.columns;
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
} else if (cmd.rows !== undefined) {
imgRows = cmd.rows;
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
} else {
imgCols = Math.ceil(bitmap.width / cw);
imgRows = Math.ceil(bitmap.height / ch);
}
let w = bitmap.width;
let h = bitmap.height;
// Scale bitmap to fit placement rectangle when c/r are specified
if (cmd.columns !== undefined || cmd.rows !== undefined) {
w = Math.round(imgCols * cw);
h = Math.round(imgRows * ch);
}
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
// Save cursor position before addImage modifies it
const buffer = this._coreTerminal._core.buffer;
const savedX = buffer.x;
const savedY = buffer.y;
const savedYbase = buffer.ybase;
// Determine layer based on z-index: negative = behind text, 0+ = on top.
// When z<0 we always use the bottom layer even without allowTransparency —
// the image will simply be hidden behind the opaque text background, which
// is the correct behavior (client asked for "behind text").
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
if (w !== bitmap.width || h !== bitmap.height) {
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
bitmap = scaled;
}
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
if (xOffset !== 0 || yOffset !== 0) {
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
// total placement area remains c*cw × r*ch pixels and the offset image
// is clipped to fit. When c/r are unset, the padded canvas determines
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
}
offsetCtx.drawImage(bitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
bitmap.close();
bitmap = offsetBitmap;
w = bitmap.width;
h = bitmap.height;
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(bitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(bitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
// Kitty cursor movement
// Per spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
if (cmd.cursorMovement === 1) {
// C=1: restore cursor to position before image was placed
const scrolled = buffer.ybase - savedYbase;
buffer.x = savedX;
// Can't restore cursor to scrollback?
buffer.y = Math.max(savedY - scrolled, 0);
} else {
// Default (C=0): advance cursor horizontally past the image
// addImage already positioned cursor on the last row via lineFeeds
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
}
} catch (e) {
bitmap?.close();
throw e;
}
}
// Create ImageBitmap from already-decoded image data.
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
if (image.compression === KittyCompression.ZLIB) {
bytes = await this._decompressZlib(bytes);
}
if (image.format === KittyFormat.PNG) {
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
img.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
});
img.src = url;
});
}
return createImageBitmap(blob);
}
// Raw pixel data
const width = image.width;
const height = image.height;
if (!width || !height) {
throw new Error('Width and height required for raw pixel data');
}
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
throw new Error('Insufficient pixel data');
}
const pixelCount = width * height;
if (image.format === KittyFormat.RGBA) {
// RGBA: use bytes directly — no copy needed
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
}
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
// Assumes little-endian (all modern browsers/Node.js).
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
const dst32 = new Uint32Array(data.buffer);
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
let srcOffset = 0;
let dstOffset = 0;
for (let i = 0; i < alignedPixels; i += 4) {
const b0 = src32[srcOffset++];
const b1 = src32[srcOffset++];
const b2 = src32[srcOffset++];
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
dst32[dstOffset++] = 0xFF000000 | b0;
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
}
// Handle remaining 1–3 pixels
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
for (let i = alignedPixels; i < pixelCount; i++) {
data[dstByte] = bytes[srcByte];
data[dstByte + 1] = bytes[srcByte + 1];
data[dstByte + 2] = bytes[srcByte + 2];
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
}
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
} catch {
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
const ds = new DecompressionStream(format);
const writer = ds.writable.getWriter();
writer.write(compressed as BufferSource);
writer.close();
const chunks: Uint8Array[] = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._kittyStorage.images;
}
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyStorage.kittyIdToStorageId;
}
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
return this._pendingTransmissions;
}
}
@@ -1,149 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
describe('KittyGraphicsTypes', () => {
describe('parseKittyCommand', () => {
it('should parse control data with action and format', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.action, 'T');
assert.strictEqual(cmd.format, 100);
});
it('should parse control data with all options', () => {
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.id, 5);
assert.strictEqual(cmd.width, 10);
assert.strictEqual(cmd.height, 20);
assert.strictEqual(cmd.columns, 3);
assert.strictEqual(cmd.rows, 2);
assert.strictEqual(cmd.more, 1);
assert.strictEqual(cmd.quiet, 2);
});
it('should handle empty control data', () => {
const cmd = parseKittyCommand('');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, undefined);
});
it('should parse transmit action', () => {
const cmd = parseKittyCommand('a=t,f=100');
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
assert.strictEqual(cmd.format, KittyFormat.PNG);
});
it('should parse delete action', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.action, KittyAction.DELETE);
assert.strictEqual(cmd.id, 5);
});
it('should parse empty action as empty string', () => {
const cmd = parseKittyCommand('a=,f=100');
assert.strictEqual(cmd.action, '');
assert.strictEqual(cmd.format, 100);
});
it('should leave action undefined when key is not present', () => {
const cmd = parseKittyCommand('f=100,i=5');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, 100);
assert.strictEqual(cmd.id, 5);
});
it('should parse compression key', () => {
const cmd = parseKittyCommand('a=t,f=32,o=z');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.compression, 'z');
});
it('should parse cursor movement key', () => {
const cmd = parseKittyCommand('a=T,f=100,C=1');
assert.strictEqual(cmd.cursorMovement, 1);
});
it('should parse cursor movement key C=0', () => {
const cmd = parseKittyCommand('a=T,f=100,C=0');
assert.strictEqual(cmd.cursorMovement, 0);
});
it('should parse x and y offset', () => {
const cmd = parseKittyCommand('a=T,x=10,y=20');
assert.strictEqual(cmd.x, 10);
assert.strictEqual(cmd.y, 20);
});
it('should handle keys without values', () => {
const cmd = parseKittyCommand('a=t,f=,i=5');
assert.strictEqual(cmd.action, 't');
assert.ok(isNaN(cmd.format!));
assert.strictEqual(cmd.id, 5);
});
it('should parse z-index key with positive value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=10');
assert.strictEqual(cmd.zIndex, 10);
});
it('should parse z-index key with zero', () => {
const cmd = parseKittyCommand('a=T,f=100,z=0');
assert.strictEqual(cmd.zIndex, 0);
});
it('should parse z-index key with negative value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=-1');
assert.strictEqual(cmd.zIndex, -1);
});
it('should leave zIndex undefined when not specified', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.zIndex, undefined);
});
it('should parse delete selector key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.action, 'd');
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should parse uppercase delete selector', () => {
const cmd = parseKittyCommand('a=d,d=A');
assert.strictEqual(cmd.deleteSelector, 'A');
});
it('should parse delete selector d=a (all)', () => {
const cmd = parseKittyCommand('a=d,d=a');
assert.strictEqual(cmd.deleteSelector, 'a');
});
it('should leave deleteSelector undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.deleteSelector, undefined);
});
it('should parse placement id key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
assert.strictEqual(cmd.placementId, 3);
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should leave placementId undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.placementId, undefined);
});
it('should parse image number key', () => {
const cmd = parseKittyCommand('a=t,f=100,I=42');
assert.strictEqual(cmd.imageNumber, 42);
});
});
});
@@ -1,195 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*
* Kitty graphics protocol types, constants, and parsing utilities.
*/
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
// Kitty graphics protocol action types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
export const enum KittyAction {
TRANSMIT = 't',
TRANSMIT_DISPLAY = 'T',
QUERY = 'q',
PLACEMENT = 'p',
DELETE = 'd'
}
// Kitty graphics protocol format types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyFormat {
RGB = 24,
RGBA = 32,
PNG = 100
}
// Kitty graphics protocol compression types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
export const enum KittyCompression {
NONE = '',
ZLIB = 'z'
}
// Kitty graphics protocol control data keys.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyKey {
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
ACTION = 'a',
// Image format (24=RGB, 32=RGBA, 100=PNG)
FORMAT = 'f',
// Image ID for referencing stored images
ID = 'i',
// Image number (alternative to ID, terminal assigns ID)
IMAGE_NUMBER = 'I',
// Source image width in pixels
WIDTH = 's',
// Source image height in pixels
HEIGHT = 'v',
// The left edge (in pixels) of the image area to display
X_OFFSET = 'x',
// The top edge (in pixels) of the image area to display
Y_OFFSET = 'y',
// Width (in pixels) of the source rectangle to display
SOURCE_WIDTH = 'w',
// Height (in pixels) of the source rectangle to display
SOURCE_HEIGHT = 'h',
// Horizontal offset (in pixels) within the first cell
X_PLACEMENT_OFFSET = 'X',
// Vertical offset (in pixels) within the first cell
Y_PLACEMENT_OFFSET = 'Y',
// Number of terminal columns to display the image over
COLUMNS = 'c',
// Number of terminal rows to display the image over
ROWS = 'r',
// More data flag (1=more chunks coming, 0=final chunk)
MORE = 'm',
// Compression type (z=zlib). This is essential for chunking larger images.
COMPRESSION = 'o',
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
QUIET = 'q',
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
CURSOR_MOVEMENT = 'C',
// Z-index for image layering (negative = behind text, 0+ = on top)
Z_INDEX = 'z',
// Transmission medium (d=direct, f=file, t=temp file, s=shared memory)
TRANSMISSION = 't',
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
DELETE_SELECTOR = 'd',
// Placement ID for targeting specific placements
PLACEMENT_ID = 'p'
}
// Pixel format constants
export const enum KittyPixelConstants {
BYTES_PER_PIXEL_RGB = 3,
BYTES_PER_PIXEL_RGBA = 4,
ALPHA_OPAQUE = 255
}
// Parsed Kitty graphics command.
export interface IKittyCommand {
action?: string;
format?: number;
id?: number;
imageNumber?: number;
width?: number;
height?: number;
x?: number;
y?: number;
sourceWidth?: number;
sourceHeight?: number;
xOffset?: number;
yOffset?: number;
columns?: number;
rows?: number;
more?: number;
quiet?: number;
cursorMovement?: number;
zIndex?: number;
transmission?: string;
deleteSelector?: string;
placementId?: number;
compression?: string;
payload?: string;
}
// Pending chunked transmission state.
// Stores metadata from the first chunk while accumulating decoded payload data.
export interface IPendingTransmission {
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
cmd: IKittyCommand;
// Decoder used across chunked payloads
decoder: Base64Decoder;
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
totalEncodedSize: number;
// Whether any chunk has failed to decode
decodeError: boolean;
}
// Stored Kitty image data.
export interface IKittyImageData {
id: number;
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
data: Blob;
width: number;
height: number;
format: 24 | 32 | 100;
compression?: string;
}
// Parses Kitty graphics control data into a command object.
export function parseKittyCommand(data: string): IKittyCommand {
const cmd: IKittyCommand = {};
const parts = data.split(',');
for (const part of parts) {
const eqIdx = part.indexOf('=');
if (eqIdx === -1) continue;
const key = part.substring(0, eqIdx);
const value = part.substring(eqIdx + 1);
// Handle string keys first
if (key === KittyKey.ACTION) {
cmd.action = value;
continue;
}
if (key === KittyKey.COMPRESSION) {
cmd.compression = value;
continue;
}
if (key === KittyKey.TRANSMISSION) {
cmd.transmission = value;
continue;
}
if (key === KittyKey.DELETE_SELECTOR) {
cmd.deleteSelector = value;
continue;
}
const numValue = parseInt(value, 10);
switch (key) {
case KittyKey.FORMAT: cmd.format = numValue; break;
case KittyKey.ID: cmd.id = numValue; break;
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
case KittyKey.WIDTH: cmd.width = numValue; break;
case KittyKey.HEIGHT: cmd.height = numValue; break;
case KittyKey.X_OFFSET: cmd.x = numValue; break;
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
case KittyKey.COLUMNS: cmd.columns = numValue; break;
case KittyKey.ROWS: cmd.rows = numValue; break;
case KittyKey.MORE: cmd.more = numValue; break;
case KittyKey.QUIET: cmd.quiet = numValue; break;
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
}
}
return cmd;
}
@@ -1,155 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage';
import { ImageLayer, IAddImageOpts } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes';
// Kitty-specific image storage controller.
//
// Wraps shared ImageStorage with kitty protocol semantics:
// - tracks transmitted image payloads by kitty image id
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
// - mirrors shared-storage evictions into kitty maps
// - applies protocol-level undisplayed-image eviction policy
export class KittyImageStorage implements IDisposable {
private static readonly _maxStoredImages = 256;
private _nextImageId = 1;
private readonly _images: Map<number, IKittyImageData> = new Map();
// TODO: Support multiple placements per image. The kitty spec identifies
// placements by an (image id, placement id) pair — same i + different p
// values should coexist, and same i + same p should replace the prior
// placement. Currently we track only one storage entry per kitty image id,
// so multiple placements of the same image overwrite each other. Fixing
// this requires changing these maps to Map<number, Map<number, number>>
// (kittyId → placementId → storageId) and updating addImage/deleteById
// accordingly. The underlying shared ImageStorage would also need to
// support multiple entries per logical image.
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
private readonly _storageIdToKittyId: Map<number, number> = new Map();
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
private readonly _handleStorageImageDeleted = (storageId: number): void => {
const kittyId = this._storageIdToKittyId.get(storageId);
if (kittyId !== undefined) {
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
this._images.delete(kittyId);
}
};
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
constructor(
private readonly _storage: ImageStorage
) {
this._previousOnImageDeleted = this._storage.onImageDeleted;
this._wrappedOnImageDeleted = (storageId: number) => {
this._previousOnImageDeleted?.(storageId);
this._handleStorageImageDeleted(storageId);
};
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
}
public reset(): void {
this._nextImageId = 1;
this._images.clear();
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public dispose(): void {
this.reset();
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
this._storage.onImageDeleted = this._previousOnImageDeleted;
}
}
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
const imageId = id ?? this._nextImageId++;
const oldStorageId = this._kittyIdToStorageId.get(imageId);
if (oldStorageId !== undefined) {
this._storage.deleteImage(oldStorageId);
this._kittyIdToStorageId.delete(imageId);
this._storageIdToKittyId.delete(oldStorageId);
}
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
this._evictUndisplayedImages();
}
this._images.set(imageId, {
...imageData,
id: imageId
});
return imageId;
}
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
// Clean up stale reverse-mapping from a previous placement of the same
// kitty image. The old shared-storage entry is kept (it may still be
// visible on screen) but its reverse mapping is removed so that eviction
// of the old entry won't incorrectly delete the kitty image data.
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
if (oldStorageId !== undefined) {
this._storageIdToKittyId.delete(oldStorageId);
}
this._addImageOpts.scrolling = scrolling;
this._addImageOpts.layer = layer;
this._addImageOpts.zIndex = zIndex;
const storageId = this._storage.addImage(image, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
public getImage(kittyId: number): IKittyImageData | undefined {
return this._images.get(kittyId);
}
public deleteById(kittyId: number): void {
this._images.delete(kittyId);
const storageId = this._kittyIdToStorageId.get(kittyId);
if (storageId !== undefined) {
this._storage.deleteImage(storageId);
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
}
}
public deleteAll(): void {
this._images.clear();
for (const storageId of this._kittyIdToStorageId.values()) {
this._storage.deleteImage(storageId);
}
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._images;
}
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyIdToStorageId;
}
public get lastImageId(): number {
return this._nextImageId - 1;
}
private _evictUndisplayedImages(): void {
for (const [kittyId] of this._images) {
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
break;
}
if (!this._kittyIdToStorageId.has(kittyId)) {
this._images.delete(kittyId);
}
}
}
}
+2 -2
View File
@@ -11,11 +11,11 @@
"types": [ "types": [
"../../../node_modules/@types/mocha" "../../../node_modules/@types/mocha"
], ],
"baseUrl": ".",
"paths": { "paths": {
"browser/*": [ "../../../src/browser/*" ], "browser/*": [ "../../../src/browser/*" ],
"common/*": [ "../../../src/common/*" ], "common/*": [ "../../../src/common/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ], "@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
"*": [ "./*" ]
} }
}, },
"include": [ "include": [
+8 -179
View File
@@ -8,7 +8,6 @@ import { readFileSync } from 'fs';
import { FINALIZER, introducer, sixelEncode } from 'sixel'; import { FINALIZER, introducer, sixelEncode } from 'sixel';
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils'; import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
import { deepStrictEqual, ok, strictEqual } from 'assert'; import { deepStrictEqual, ok, strictEqual } from 'assert';
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
/** /**
* Plugin ctor options. * Plugin ctor options.
@@ -24,8 +23,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number; sixelSizeLimit: number;
iipSupport: boolean; iipSupport: boolean;
iipSizeLimit: number; iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
} }
// eslint-disable-next-line // eslint-disable-next-line
@@ -75,10 +72,6 @@ const TESTDATA_IIP: [string, [number, number]][] = [
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]], [readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]] [readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
]; ];
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
const qoiEnc = new QoiEncoder(1024*1024);
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
let ctx: ITestContext; let ctx: ITestContext;
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
@@ -90,6 +83,11 @@ test.afterAll(async () => await ctx.page.close());
test.describe('ImageAddon', () => { test.describe('ImageAddon', () => {
test.beforeEach(async ({}, testInfo) => { test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(` await ctx.page.evaluate(`
window.term.reset() window.term.reset()
window.imageAddon?.dispose(); window.imageAddon?.dispose();
@@ -132,13 +130,11 @@ test.describe('ImageAddon', () => {
sixelSupport: true, sixelSupport: true,
sixelScrolling: true, sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 33554432, sixelSizeLimit: 25000000,
storageLimit: 128, storageLimit: 128,
showPlaceholder: true, showPlaceholder: true,
iipSupport: true, iipSupport: true,
iipSizeLimit: 33554432, iipSizeLimit: 20000000
kittySupport: true,
kittySizeLimit: 33554432
}; };
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS); deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
}); });
@@ -153,9 +149,7 @@ test.describe('ImageAddon', () => {
storageLimit: 10, storageLimit: 10,
showPlaceholder: false, showPlaceholder: false,
iipSupport: false, iipSupport: false,
iipSizeLimit: 1000, iipSizeLimit: 1000
kittySupport: false,
kittySizeLimit: 1000
}; };
await ctx.page.evaluate(opts => { await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts); (window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -196,18 +190,6 @@ test.describe('ImageAddon', () => {
}); });
test.describe('image lifecycle & eviction', () => { test.describe('image lifecycle & eviction', () => {
test('onImageAdded fires for each image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 3);
});
test('delete image once scrolled off', async () => { test('delete image once scrolled off', async () => {
await ctx.proxy.write(SIXEL_SEQ_0); await ctx.proxy.write(SIXEL_SEQ_0);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1); pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
@@ -290,67 +272,6 @@ test.describe('ImageAddon', () => {
}); });
}); });
test.describe('IIP support', () => {
test('size params is optional/informative', async () => {
// too small size param
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
// no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
});
test.skip('FilePart support - bytewise', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// byte-wise
for (let i = 0; i < PALETTE_PNG_BASE64.length; ++i) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64[i]}\x07`);
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
});
test('FilePart support - chunks', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// chunks
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
});
test('FilePart support - edgecases', async () => {
// multiple opener do not harm
for (let i = 0; i < 10; ++i) {
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
}
// multiple finalizer do not harm
for (let i = 0; i < 5; ++i) {
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
}
// chunks w'o opener are ignored
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
});
test('FilePart support - other IIP sequence cancels chunked image', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// chunks
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
// finalizer skipped
// write spinfox.png
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(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]);
@@ -373,77 +294,6 @@ test.describe('ImageAddon', () => {
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
}); });
}); });
test.describe('IIP - QOI support', () => {
test('palette should yield same bytes from PNG and QOI', async () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape);
});
});
test.describe('IIP - resizing', () => {
/**
* The correct resize behavior is wonky and needs to be tested against iTerm2,
* especially for missing params to derive correct default behavior.
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
* ref: https://iterm2.com/documentation-images.html
* imgcat: https://iterm2.com/utilities/imgcat
*
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
*/
const images = [
['palette.png', 525, PALETTE_PNG_BASE64],
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
];
for (const [name, size, payload] of images) {
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
// cell based resize
const header = 'width=20;height=5;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
});
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
const header = 'width=320px;height=160px;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
deepStrictEqual(await getOrigSize(1), [320, 160]);
});
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
const header = 'width=50%;height=30%;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
});
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
const header = 'width=50%';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
const width = Math.floor(dim.width * 0.5);
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel
const header2 = 'height=200px';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
test.describe('text overwrite removes tiles', () => {
test('sixel', async () => {
await assertTextClearsTiles(SIXEL_SEQ_0);
});
test('iip', async () => {
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
});
});
}); });
/** /**
@@ -477,24 +327,3 @@ async function getOrigSize(id: number): Promise<[number, number]> {
window.imageAddon._storage._images.get(${id}).orig.height window.imageAddon._storage._images.get(${id}).orig.height
]`); ]`);
} }
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
}
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+1 -15
View File
@@ -3,7 +3,7 @@
* @license MIT * @license MIT
*/ */
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm'; import { Terminal, ITerminalAddon } from '@xterm/xterm';
declare module '@xterm/addon-image' { declare module '@xterm/addon-image' {
export interface IImageAddonOptions { export interface IImageAddonOptions {
@@ -76,15 +76,6 @@ declare module '@xterm/addon-image' {
iipSupport?: boolean; iipSupport?: boolean;
/** IIP sequence size limit (default 20000000 bytes). */ /** IIP sequence size limit (default 20000000 bytes). */
iipSizeLimit?: number; iipSizeLimit?: number;
/**
* Kitty graphics protocol settings
*/
/** Whether Kitty graphics protocol is enabled (default is true). */
kittySupport?: boolean;
/** Kitty image size limit in bytes (default 20000000 bytes). */
kittySizeLimit?: number;
} }
export class ImageAddon implements ITerminalAddon { export class ImageAddon implements ITerminalAddon {
@@ -116,11 +107,6 @@ declare module '@xterm/addon-image' {
*/ */
public showPlaceholder: boolean; public showPlaceholder: boolean;
/**
* Event fired whenever a new image is added to storage.
*/
public readonly onImageAdded: IEvent<void>;
/** /**
* Get original image canvas at buffer position. * Get original image canvas at buffer position.
*/ */
+11 -7
View File
@@ -14,13 +14,13 @@
"node": ">8.0.0" "node": ">8.0.0"
}, },
"scripts": { "scripts": {
"build": "tsgo -p src", "build": "tsc -p src",
"watch": "tsgo -w -p src", "watch": "tsc -w -p src",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "webpack", "package": "webpack",
"pretest": "npm run build", "pretest": "npm run build",
"test": "nyc mocha out/**/*.test.js", "test": "nyc mocha out/**/*.test.js",
"prepublishOnly": "npm run package" "prepublish": "npm run package"
}, },
"keywords": [ "keywords": [
"font", "font",
@@ -31,11 +31,15 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lru-cache": "^11.3.6", "lru-cache": "^6.0.0",
"opentype.js": "^2.0.0" "opentype.js": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/opentype.js": "^1.3.9", "@types/lru-cache": "^5.1.0",
"font-finder": "^1.1.0" "@types/opentype.js": "^0.7.0",
"axios": "^1.6.0",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^2.10.0"
} }
} }
@@ -1,5 +1,5 @@
import * as opentype from 'opentype.js'; import * as opentype from 'opentype.js';
import { LRUCache } from 'lru-cache'; import LRUCache = require('lru-cache');
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types'; import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
import mergeTrees from './merge'; import mergeTrees from './merge';
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
if (options.cacheSize > 0) { if (options.cacheSize > 0) {
this._cache = new LRUCache({ this._cache = new LRUCache({
maxSize: options.cacheSize, max: options.cacheSize,
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
}); });
} }
@@ -44,6 +44,7 @@ export default function mergeRange(ranges: [number, number][], newRangeStart: nu
// current range // current range
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]); ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
ranges.splice(i, 1); ranges.splice(i, 1);
inRange = false;
return ranges; return ranges;
} }
// Case 7: New range extends from previous range past the // Case 7: New range extends from previous range past the
+1 -1
View File
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
/** /**
* Enable ligature support for the provided Terminal instance. To function * Enable ligature support for the provided Terminal instance. To function
* properly, this must be called after `open()` is called on the terminal. If * properly, this must be called after `open()` is called on the therminal. If
* the font currently in use supports ligatures, the terminal will automatically * the font currently in use supports ligatures, the terminal will automatically
* start to render them. * start to render them.
* @param term Terminal instance from xterm.js * @param term Terminal instance from xterm.js
+1 -5
View File
@@ -9,12 +9,8 @@
"outDir": "../out-esbuild-test", "outDir": "../out-esbuild-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"strict": true, "strict": true,
"paths": {
"*": [
"./*"
]
},
"types": [ "types": [
"../../../node_modules/@types/mocha", "../../../node_modules/@types/mocha",
"../../../node_modules/@types/node" "../../../node_modules/@types/node"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
@@ -9,18 +9,15 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
let ctx: ITestContext; let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
ctx.page.setViewportSize({ width: 1024, height: 768 });
await openTerminal(ctx);
});
test.afterAll(async () => await ctx.page.close());
test.describe('ProgressAddon', () => { test.describe('ProgressAddon', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
await openTerminal(ctx);
});
test.afterAll(async () => {
await ctx?.page?.close();
});
test.beforeEach(async function(): Promise<any> { test.beforeEach(async function(): Promise<any> {
await ctx.page.evaluate(` await ctx.page.evaluate(`
window.progressStack = []; window.progressStack = [];
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .", "prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
"start": "node ../../demo/start" "start": "node ../../demo/start"
@@ -1,81 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DecorationManager } from './DecorationManager';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import type { IDecorationOptions } from '@xterm/xterm';
import { DisposableStore } from 'common/Lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('DecorationManager', () => {
let store: DisposableStore;
let terminal: Terminal;
let decorationManager: DecorationManager;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
decorationManager = store.add(new DecorationManager(terminal));
});
it('should split highlight decorations for a wrapped match', async () => {
await writeP(terminal, '0123456789abcde');
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
const match = searchEngine.find('9abc', 0, 0);
assert.ok(match);
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([match], options);
assert.strictEqual(decorationOptions.length, 2);
assert.strictEqual(decorationOptions[0].x, 9);
assert.strictEqual(decorationOptions[0].width, 1);
assert.strictEqual(decorationOptions[1].x, 0);
assert.strictEqual(decorationOptions[1].width, 3);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 2);
});
it('should only add one overview ruler marker per buffer line', async () => {
await writeP(terminal, 'abcdefghij');
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([
{ term: 'a', col: 0, row: 0, size: 1 },
{ term: 'f', col: 5, row: 0, size: 1 }
], options);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 1);
assert.strictEqual(withOverviewRuler[0].x, 0);
});
});
+6 -8
View File
@@ -142,14 +142,12 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
} }
prevResult = result; prevResult = result;
results.push(prevResult); results.push(prevResult);
const cols = this._terminal.cols; result = this._engine.find(
let nextCol = prevResult.col + prevResult.size; term,
let nextRow = prevResult.row; prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
if (nextCol >= cols) { prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
nextRow += Math.floor(nextCol / cols); searchOptions
nextCol = nextCol % cols; );
}
result = this._engine.find(term, nextRow, nextCol, searchOptions);
} }
this._resultTracker.updateResults(results, this._highlightLimit); this._resultTracker.updateResults(results, this._highlightLimit);
+7 -4
View File
@@ -60,10 +60,13 @@ export class SearchEngine {
this._terminal.clearSelection(); this._terminal.clearSelection();
return undefined; return undefined;
} }
if (startCol >= this._terminal.cols) { if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`); throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
} }
startRow = Math.max(0, startRow);
startCol = Math.max(0, startCol);
this._lineCache.initLinesCache(); this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = { const searchPosition: ISearchPosition = {
@@ -174,7 +177,7 @@ export class SearchEngine {
this._terminal.clearSelection(); this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1; let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
const startCol = this._terminal.cols; let startCol = this._terminal.cols;
const isReverseSearch = true; const isReverseSearch = true;
this._lineCache.initLinesCache(); this._lineCache.initLinesCache();
@@ -186,14 +189,14 @@ export class SearchEngine {
let result: ISearchResult | undefined; let result: ISearchResult | undefined;
if (prevSelectedPos) { if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y; searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = prevSelectedPos.start.x; searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (cachedSearchTerm !== term) { if (cachedSearchTerm !== term) {
// Try to expand selection to right first. // Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false); result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) { if (!result) {
// If selection was not able to be expanded to the right, then try reverse search // If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y; searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = prevSelectedPos.end.x; searchPosition.startCol = startCol = prevSelectedPos.end.x;
} }
} }
} }
+2 -16
View File
@@ -414,24 +414,10 @@ 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 () => {
fixture = (await new Promise<Buffer>((res, rej) => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => err ? rej(err) : res(data)))).toString(); fixture = (await new Promise<Buffer>(r => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => r(data)))).toString();
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
fixture = fixture.replace(/\n/g, '\n\r'); fixture = fixture.replace(/\n/g, '\n\r');
} }
@@ -515,7 +501,7 @@ test.describe('Search Tests', () => {
window.search.onDidChangeResults(e => window.calls.push(e)); window.search.onDidChangeResults(e => window.calls.push(e));
`); `);
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace // Move cursor forward 1 time to create a null character, as opposed to regular whitespace
await ctx.proxy.write('\x1b[CHi Hi'); await ctx.proxy.write('\\x1b[CHi Hi');
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [ deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 2, resultIndex: 1 } { resultCount: 2, resultIndex: 1 }
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
@@ -4,13 +4,13 @@
"dom", "dom",
"es2021" "es2021"
], ],
"rootDir": "..",
"outDir": "../out-benchmark", "outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"], "types": ["../../../node_modules/@types/node"],
"moduleResolution": "nodenext", "moduleResolution": "node",
"strict": false, "strict": false,
"target": "es2021", "target": "es2021",
"module": "nodenext", "module": "commonjs",
"baseUrl": ".",
"paths": { "paths": {
"common/*": ["../../../src/common/*"], "common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"], "browser/*": ["../../../src/browser/*"],
+4 -4
View File
@@ -16,13 +16,13 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
"start": "node ../../demo/start", "start": "node ../../demo/start",
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json", "benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js", "benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js" "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
} }
} }
+4 -5
View File
@@ -7,8 +7,7 @@
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm'; import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize'; import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
import { IColor } from 'common/Types'; import { IAttributeData, IColor } from 'common/Types';
import { IAttributeData } from 'common/buffer/Types';
import { DEFAULT_ANSI_COLORS } from 'browser/Types'; import { DEFAULT_ANSI_COLORS } from 'browser/Types';
import { UnderlineStyle } from 'common/buffer/Constants'; import { UnderlineStyle } from 'common/buffer/Constants';
@@ -173,7 +172,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell(); private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell(); private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
protected _rowEnd(row: number, isLastRow: boolean): void { protected _rowEnd(row: number, isLastRow: boolean): void {
// if there is colorful empty cell at line end, we must pad it back, or the color block // if there is colorful empty cell at line end, whe must pad it back, or the the color block
// will missing // will missing
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
// use clear right to set background. // use clear right to set background.
@@ -221,7 +220,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
// you can't use control sequence to move cursor to (x === row) // you can't use control sequence to move cursor to (x === row)
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) && (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE // change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simplify the process to determine line separator // so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar) equalBg(thisRowLastChar, nextRowFirstChar)
) { ) {
isValid = true; isValid = true;
@@ -233,7 +232,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
isNextRowFirstCharDoubleWidth && isNextRowFirstCharDoubleWidth &&
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) && (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE // change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simplify the process to determine line separator // so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar) && equalBg(thisRowLastChar, nextRowFirstChar) &&
equalBg(thisRowLastSecondChar, nextRowFirstChar) equalBg(thisRowLastSecondChar, nextRowFirstChar)
) { ) {
+1 -1
View File
@@ -10,6 +10,7 @@
"outDir": "../out", "outDir": "../out",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
@@ -22,7 +23,6 @@
] ]
}, },
"strict": true, "strict": true,
"skipLibCheck": true,
"types": [ "types": [
"../../../node_modules/@types/mocha" "../../../node_modules/@types/mocha"
] ]
@@ -49,14 +49,7 @@ test.describe('SerializeAddon', () => {
for (let i = 0; i < buffer.length; i++) { for (let i = 0; i < buffer.length; i++) {
// Do this intentionally to get content of underlining source // Do this intentionally to get content of underlining source
const bufferLine = buffer.getLine(i)._line; const bufferLine = buffer.getLine(i)._line;
lines.push(JSON.stringify(bufferLine, (key, value) => { lines.push(JSON.stringify(bufferLine));
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
} }
return { return {
x: buffer.cursorX, x: buffer.cursorX,
@@ -192,7 +185,7 @@ test.describe('SerializeAddon', () => {
const cols = 10; const cols = 10;
const line = '+'.repeat(cols); const line = '+'.repeat(cols);
const lines: string[] = [ const lines: string[] = [
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags at the end, serialize will use \x1b[0m to clear instead of the specific disable sequence sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence
sgr(INVERSE) + line, sgr(INVERSE) + line,
sgr(BOLD) + line, sgr(BOLD) + line,
sgr(UNDERLINED) + line, sgr(UNDERLINED) + line,
@@ -9,6 +9,7 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
@@ -1,13 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "es6"], "lib": ["dom", "es6"],
"rootDir": "..",
"outDir": "../out-benchmark", "outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"], "types": ["../../../node_modules/@types/node"],
"moduleResolution": "nodenext", "moduleResolution": "node",
"strict": false, "strict": false,
"target": "es2015", "target": "es2015",
"module": "nodenext", "module": "commonjs",
"baseUrl": ".",
"paths": { "paths": {
"common/*": ["../../../src/common/*"], "common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"], "browser/*": ["../../../src/browser/*"],
+4 -4
View File
@@ -16,13 +16,13 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
"start": "node ../../demo/start", "start": "node ../../demo/start",
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js", "benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js", "benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js" "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
} }
} }
@@ -2,7 +2,7 @@
* Copyright (c) 2023 The xterm.js authors. All rights reserved. * Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT * @license MIT
* *
* UnicodeVersionProvider for V15 with grapheme cluster handling. * UnicodeVersionProvider for V15 with grapeme cluster handleing.
*/ */
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm'; import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
@@ -11,6 +11,7 @@
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"strict": true, "strict": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
@@ -9,6 +9,7 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",
+1 -3
View File
@@ -11,15 +11,13 @@
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"strict": true, "strict": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"@xterm/addon-unicode11": [ "@xterm/addon-unicode11": [
"../typings/addon-unicode11.d.ts" "../typings/addon-unicode11.d.ts"
],
"*": [
"./*"
] ]
}, },
"types": [ "types": [
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test", "outDir": "../out-test",
"sourceMap": true, "sourceMap": true,
"removeComments": true, "removeComments": true,
"baseUrl": ".",
"paths": { "paths": {
"common/*": [ "common/*": [
"../../../src/common/*" "../../../src/common/*"
], ],
"browser/*": [ "browser/*": [
"../../../src/browser/*" "../../../src/browser/*"
],
"*": [
"./*"
] ]
}, },
"strict": true, "strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js" "xterm.js"
], ],
"scripts": { "scripts": {
"build": "../../node_modules/.bin/tsgo -p .", "build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build", "prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack", "package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package", "prepublishOnly": "npm run package",

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