Compare commits

..
616 changed files with 29087 additions and 26213 deletions
-28
View File
@@ -1,28 +0,0 @@
---
name: benchmark
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
---
# Benchmark Run Instructions
## Running Benchmarks
- Full suite: `npm run benchmark`
- Single benchmark file:
- Tree: `npm run benchmark -- -t out-test/benchmark/Event.benchmark.js`
- Run file: `npm run benchmark -- -s "out-test/benchmark/Event.benchmark.js" out-test/benchmark/Event.benchmark.js`
- Single context/case:
- Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
## Benchmark Case Selection
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script).
+2 -22
View File
@@ -22,11 +22,10 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
**Testing**:
- Unit tests: `npm run test-unit` (Mocha)
- Unit tests filtering to file: `npm run test-unit -- **/fileName.ts
- Per-addon unit tests: `npm run test-unit -- addons/addon-image/out-esbuild/*.test.js`
- Per-addon unit tests: `npm run test-unit addons/addon-image/out-esbuild/*.test.js`
- 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 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
- Integration tests by addon: `npm run test-integration --suite=addon-search`. Suites always follow the format `addon-<something>`
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
## Addon Development Pattern
@@ -58,13 +57,6 @@ export class MyAddon implements ITerminalAddon {
- Proposed APIs require `allowProposedApi: true` option
- Constructor-only options (cols, rows) cannot be changed after instantiation
**Disposable Management**:
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
**TypeScript Constants**:
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
**Testing Utilities**: Use `TestUtils.ts` helpers:
- `openTerminal(ctx, options)` for setup
- `pollFor(page, fn, expectedValue)` for async assertions
@@ -99,15 +91,3 @@ const cell = line?.getCell(0);
## Writing unit tests
- 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: unit-test
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
applyTo: '**/*.test.ts'
---
# Unit Test Instructions
When writing unit tests, follow these rules:
When writing unit tests follow these rules:
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
+40 -47
View File
@@ -6,19 +6,14 @@ on:
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
@@ -74,7 +69,7 @@ jobs:
./addons/addon-webgl/out/* \
./addons/addon-webgl/out-*st/*
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: compressed-build.zip
@@ -84,11 +79,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
@@ -107,18 +100,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: |
npm ci
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
@@ -138,7 +129,6 @@ jobs:
exit $EXIT_CODE
test-unit:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -146,18 +136,21 @@ jobs:
runs-on: [ubuntu, macos, windows]
runs-on: ${{ matrix.runs-on }}-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
- name: Install dependencies
run: |
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:
name: build-artifacts
- name: Unzip artifacts
@@ -173,7 +166,6 @@ jobs:
run: npm run test-unit --forbid-only
test-integration:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -182,11 +174,9 @@ jobs:
browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -195,7 +185,12 @@ jobs:
npm ci
- name: Install playwright
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:
name: build-artifacts
- name: Unzip artifacts
@@ -212,31 +207,31 @@ jobs:
- name: Build demo server
run: npm run esbuild-demo-server
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
- name: Integration tests (addon-attach)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
- name: Integration tests (addon-clipboard)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
- name: Integration tests (addon-fit)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
- name: Integration tests (addon-serialize)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
- name: Integration tests (addon-unicode-graphemes)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
- name: Integration tests (addon-web-fonts)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
release-dry-run:
needs: build
@@ -245,11 +240,9 @@ jobs:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -258,7 +251,7 @@ jobs:
npm ci
- name: Install playwright
run: npx playwright install
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
+4 -11
View File
@@ -8,11 +8,6 @@ on:
schedule:
- cron: '41 17 * * 0'
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -29,14 +24,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/autobuild@v2
- 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:
- .github/workflows/copilot-setup-steps.yml
permissions:
contents: read
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
@@ -21,11 +18,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
+2 -7
View File
@@ -6,18 +6,13 @@ on:
# without running tests to save time to publish
branches: [ "master" ]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
-5
View File
@@ -17,16 +17,11 @@ docs/
npm-debug.log
/.idea/
.env
mise.toml
build/
.DS_Store
yarn.lock
test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git
dist/
demo/dist/
-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",
"${workspaceRoot}/out/**/*.test.js"
],
"env": {
"NODE_PATH": "${workspaceRoot}/out"
},
"sourceMaps": true,
"outFiles": [ "${workspaceRoot}/out/**/*.js" ],
"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:
- 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_.
- 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.
+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
```
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package.
```javascript
import { Terminal } from '@xterm/xterm';
```
Then instantiate a `Terminal` object, open it on an element and write to it:
```javascript
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
```
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
```javascript
pty.onData(data => term.write(data));
term.onData(data => pty.write(data));
```
Then make sure to also include the `css/xterm.css` file, for example in HTML:
```html
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
```
### Standalone example
Here is a complete standalone example in a HTML file:
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`.
```html
<!doctype html>
@@ -65,7 +36,7 @@ Here is a complete standalone example in a HTML file:
<body>
<div id="terminal"></div>
<script>
const term = new Terminal();
var term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>
@@ -73,6 +44,14 @@ Here is a complete standalone example in a HTML file:
</html>
```
### Importing
The recommended way to load xterm.js is via the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/xterm';
```
### Addons
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
@@ -115,7 +94,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
### Node.js Support
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
## API
@@ -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.
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty.
- [**OmnyShell PWA**](https://github.com/OmnyGrid/omnyshell_web): Web client for [**OmnyShell**](https://github.com/OmnyGrid/omnyshell), featuring remote terminals, a built-in IDE, AI-assisted workflows, and detachable/resumable sessions for seamless browser-based access to nodes.
- [**kmux**](https://github.com/kkd927/kmux): A keyboard-centric terminal workspace for running parallel AI coding agents side by side on macOS and Linux.
- [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management.
- [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone.
- [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal.
- [**BitFun**](https://github.com/GCWing/BitFun): Open-source desktop AI agent whose integrated terminals and read-only terminal output views use xterm.js with the Fit, Web Links, and WebGL addons.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -1
View File
@@ -12,7 +12,7 @@ interface IAttachOptions {
bidirectional?: boolean;
}
export class AttachAddon implements ITerminalAddon, IAttachApi {
export class AttachAddon implements ITerminalAddon , IAttachApi {
private _socket: WebSocket;
private _bidirectional: boolean;
private _disposables: IDisposable[] = [];
+1 -1
View File
@@ -15,7 +15,7 @@ test.beforeAll(async ({ browser }) => {
});
test.afterAll(async () => await ctx.page.close());
test.describe('AttachAddon', () => {
test.describe('Search Tests', () => {
test.beforeEach(async () => {
await ctx.proxy.reset();
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+4 -1
View File
@@ -16,10 +16,13 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"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 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 {
private _terminal?: Terminal;
@@ -24,7 +25,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose();
}
private _readText(sel: string, data: string): void {
private _readText(sel: ClipboardSelectionType, data: string): void {
const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
}
@@ -35,7 +36,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true;
}
const pc = args[0];
const pc = args[0] as ClipboardSelectionType;
const pd = args[1];
if (pd === '?') {
const text = this._provider.readText(pc);
@@ -69,53 +70,30 @@ export class ClipboardAddon implements ITerminalAddon {
}
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();
}
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);
}
}
/**
* TODO: Once the base64 handling on Uint8Arrays is more common,
* remove the btoa/atob fallbacks below.
*/
interface IUint8ArrayB64 extends Uint8Array {
toBase64(): string;
}
interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor {
fromBase64(s: string): IUint8ArrayB64;
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64;
if (bytes.toBase64 !== undefined) {
return bytes.toBase64();
}
let bin = '';
for (let i = 0; i < bytes.length; i++) {
bin += String.fromCharCode(bytes[i]);
}
return btoa(bin);
return JSBase64.encode(data);
}
public decodeText(data: string): string {
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
try {
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
} catch {}
const text = JSBase64.decode(data);
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return '';
}
try {
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 '';
return text;
}
}
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test doesn't work since the migration to @playwright/test
if (ctx.browser.browserType().name() === 'webkit') {
if (ctx.browser.browserType().name() !== 'chromium') {
testInfo.skip();
return;
}
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
}
await ctx.page.evaluate(`
window.term.reset();
window.term.reset()
window.clipboard?.dispose();
window.clipboard = new ClipboardAddon();
window.term.loadAddon(window.clipboard);
@@ -48,14 +48,6 @@ test.describe('ClipboardAddon', () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('primary selection', async () => {
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('empty selection (default)', async () => {
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('invalid base64 string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
@@ -68,46 +60,19 @@ test.describe('ClipboardAddon', () => {
});
test.describe('read data', async function (): Promise<any> {
test.beforeAll(async () => {
test('simple string', async () => {
await ctx.page.evaluate(`
window.data = [];
window.term.onData(e => data.push(e));
`);
});
test('simple string', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
});
test('primary selection', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;p;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
});
test('clear clipboard', async () => {
await ctx.proxy.write(`\x1b]52;c;!\x07`);
await ctx.proxy.write(`\x1b]52;c;?\x07`);
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`]);
});
});
});
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+21 -12
View File
@@ -30,6 +30,18 @@ declare module '@xterm/addon-clipboard' {
public dispose(): void
}
/**
* Clipboard selection type. This is used to specify which selection buffer to
* read or write to.
* - SYSTEM `c`: The system clipboard.
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
* with Linux X11.
*/
export const enum ClipboardSelectionType {
SYSTEM = 'c',
PRIMARY = 'p',
}
export interface IBase64 {
/**
* Converts a utf-8 string to a base64 string.
@@ -66,39 +78,36 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider {
/**
* 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.
*/
readText(selection: string): string | Promise<string>;
readText(selection: ClipboardSelectionType): string | Promise<string>;
/**
* 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.
*/
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.
*
* Note that this clipboard provider does not implement own cut buffers as xterm does,
* but redirect the selection parameter always to navigator.clipboard.
* The clipboard provider interface that enables xterm.js to access the system clipboard.
*/
export class BrowserClipboardProvider implements IClipboardProvider{
/**
* 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.
*/
public readText(selection: string): Promise<string>;
public readText(selection: ClipboardSelectionType): Promise<string>;
/**
* 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.
* @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"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+17 -17
View File
@@ -19,10 +19,8 @@ interface ITerminalDimensions {
cols: number;
}
const enum Constants {
MINIMUM_COLS = 2,
MINIMUM_ROWS = 1
}
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) {
@@ -35,7 +33,7 @@ function _getComputedStyle(el: HTMLElement): CSSStyleDeclaration {
return getWindow(el).getComputedStyle(el, null);
}
export class FitAddon implements ITerminalAddon, IFitApi {
export class FitAddon implements ITerminalAddon , IFitApi {
private _terminal: Terminal | undefined;
public activate(terminal: Terminal): void {
@@ -50,7 +48,10 @@ export class FitAddon implements ITerminalAddon, IFitApi {
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 {
@@ -68,28 +69,27 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return undefined;
}
const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true;
const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar
const scrollbarWidth = (this._terminal.options.scrollback === 0
? 0
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
: (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
const parentElementHeight = Math.max(0, parseInt(parentElementStyle.getPropertyValue('height'), 10) || 0);
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'), 10) || 0);
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
const elementStyle = _getComputedStyle(this._terminal.element);
const elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top'), 10) || 0,
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom'), 10) || 0,
right: parseInt(elementStyle.getPropertyValue('padding-right'), 10) || 0,
left: parseInt(elementStyle.getPropertyValue('padding-left'), 10) || 0
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
const elementPaddingHor = elementPadding.right + elementPadding.left;
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
return geometry;
}
-11
View File
@@ -119,17 +119,6 @@ test.describe('FitAddon', () => {
strictEqual(await ctx.proxy.cols, 2);
strictEqual(await ctx.proxy.rows, 1);
});
test('same dimensions', async function(): Promise<any> {
await setDimensions();
await ctx.page.evaluate(`window.fit.fit()`);
const cols: number = await ctx.proxy.cols;
const rows: number = await ctx.proxy.rows;
// Calling fit() again at the same container size should not throw
await ctx.page.evaluate(`window.fit.fit()`);
strictEqual(await ctx.proxy.cols, cols);
strictEqual(await ctx.proxy.rows, rows);
});
});
});
+3 -2
View File
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+42 -40
View File
@@ -1,7 +1,6 @@
## @xterm/addon-image
Inline image output in xterm.js. Supports SIXEL, iTerm's inline image protocol (IIP)
and partially Kitty's terminal graphics prtocol (TGP).
Inline image output in xterm.js. Supports SIXEL and iTerm's inline image protocol (IIP).
![](fixture/example.png)
@@ -26,14 +25,12 @@ const customSettings: IImageAddonOptions = {
pixelLimit: 16777216, // max. pixel size of a single image
sixelSupport: true, // enable sixel support
sixelScrolling: true, // whether to scroll on image output
sixelPaletteLimit: 4096, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support
iipSizeLimit: 33554432, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
iipSizeLimit: 20000000 // size limit of a single IIP sequence
}
// initialization
@@ -44,6 +41,9 @@ terminal.loadAddon(imageAddon);
### 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:
- getWinSizePixels (CSI 14 t)
- getCellSizePixels (CSI 16 t)
@@ -71,7 +71,7 @@ terminal.loadAddon(imageAddon);
- **Cursor Positioning**
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**
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.
- **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.
@@ -98,31 +98,17 @@ terminal.loadAddon(imageAddon);
Set by default, change it with `{iipSupport: true}`.
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
- Supported formats: PNG, JPEG, GIF, QOI, WEBP and AVIF
- Supported formats: PNG, JPEG and GIF
- No animation support.
- Image type hinting is not supported (always deducted from data header).
- File download is not supported.
- Filename gets parsed but not used.
- 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 pixel size is restricted by `pixelLimit` (pre- and post resizing).
- Playload size is restricted by `iipSizeLimit`.
- size parameter is not mandatory anymore.
- 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.
- Size parameter is restricted by `iipSizeLimit`.
- Cursor positioning behaves the same as for sixel (see above).
### 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
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
### Image Data Retrieval
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).
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
### 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:
- Incoming image data chunk at `term.write` (terminal)
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
- Incomming image data chunk at `term.write` (terminal)
`term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
- Sequence Parser (terminal)
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
- 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.
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?_
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
_How can I adjust the memory usage?_
- `pixelLimit`
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage 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`
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding.
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
Constructor settings. This has only a small impact on 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 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`
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
@@ -225,5 +209,23 @@ _How can I adjust the memory usage?_
### Status
- Sixel and IIP 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.
- Sixel support and image handling in xterm.js is considered beta quality.
- 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"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.4.1"
"xterm-wasm-parts": "^0.1.0"
}
}
+63 -139
View File
@@ -3,27 +3,21 @@
* @license MIT
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './Metrics';
import { Drawable } from './Primitives';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
// 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
}
// eslint-disable-next-line
declare const Buffer: any;
// limit hold memory in base64 decoder
const KEEP_DATA = 4194304;
// default IIP header values
const DEFAULT_HEADER: IHeaderFields = {
type: SequenceType.INVALID,
name: 'Unnamed file',
size: 0,
width: 'auto',
@@ -37,30 +31,22 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _qoiDec: QoiDecoder;
private _isMultipart = false;
private _abortMulti = false;
private _dec = new Base64Decoder(KEEP_DATA);
private _metrics = UNSUPPORTED_TYPE;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: IIPImageStorage,
private readonly _renderer: ImageRenderer,
private readonly _storage: ImageStorage,
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 {
this._hp.reset();
this._dec.release();
this._qoiDec.release();
}
public reset(): void {}
public start(): void {
this._aborted = false;
this._header = DEFAULT_HEADER;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset();
}
@@ -68,7 +54,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (this._aborted) return;
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._aborted = true;
}
@@ -79,27 +65,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return;
}
if (dataPos > 0) {
const seqType = this._hp.fields.type;
if (seqType === SequenceType.FILE) {
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._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
this._aborted = true;
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._aborted = true;
if (this._isMultipart) this._abortMulti = true;
}
}
}
@@ -108,61 +82,22 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public end(success: boolean): boolean | Promise<boolean> {
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
// IMPORTANT: ReportCellSize uses logical points (CSS pixels)
let w = CELL_SIZE_DEFAULT.width;
let h = CELL_SIZE_DEFAULT.height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
w = dimensions.css.canvas.width / this._coreTerminal.cols;
h = dimensions.css.canvas.height / this._coreTerminal.rows;
}
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
const report = `\x1b]1337;ReportCellSize=${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 h = 0;
// early exit condition chain
let cond: number | boolean;
let metrics = UNSUPPORTED_TYPE;
let cond: number | boolean = true;
if (cond = success) {
if (cond = !this._dec.end()) {
metrics = imageType(this._dec.data8);
if (cond = metrics.mime !== 'unsupported') {
if (!(cond = metrics.width && metrics.height && metrics.width * metrics.height < this._opts.pixelLimit)) {
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
this._metrics = imageType(this._dec.data8);
if (cond = this._metrics.mime !== 'unsupported') {
w = this._metrics.width;
h = this._metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit;
}
} else {
console.warn('IIP: unsupported image type');
}
} else {
console.warn('IIP: error during BASE64 decoding');
}
}
if (!cond) {
@@ -170,52 +105,41 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
let bmSrc: Blob | ImageData;
let imgBlob: Blob;
if (metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
bmSrc = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
this._qoiDec.height
);
this._qoiDec.release();
} else {
imgBlob = bmSrc = new Blob([this._dec.data8], { type: metrics.mime });
}
const blob = new Blob([new Uint8Array(this._dec.data8)], { type: this._metrics.mime });
this._dec.release();
return createImageBitmap(bmSrc)
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 })
.then(bm => {
const [w, h] = this._resize(metrics.width, metrics.height);
this._storage.addImage(new Drawable(bm), imgBlob, metrics, w / metrics.width, h / metrics.height);
return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
this._storage.addImage(bm);
return true;
});
}
private _resize(w: number, h: number): [number, number] {
let cw;
let ch;
let width;
let height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
width = dimensions.device.canvas.width;
height = dimensions.device.canvas.height;
cw = width / this._coreTerminal.cols;
ch = height / this._coreTerminal.rows;
} else {
cw = CELL_SIZE_DEFAULT.width;
ch = CELL_SIZE_DEFAULT.height;
width = cw * this._coreTerminal.cols;
height = ch * this._coreTerminal.rows;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;
const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;
const rw = this._dim(this._header.width ?? '', width, cw);
const rh = this._dim(this._header.height ?? '', height, ch);
const rw = this._dim(this._header.width!, width, cw);
const rh = this._dim(this._header.height!, height, ch);
if (!rw && !rh) {
const wf = width / w; // TODO: should this respect initial cursor offset?
const hf = (height - ch) / h; // TODO: fix offset issues from float cell height
@@ -230,8 +154,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _dim(s: string, total: number, cdim: number): number {
if (s === 'auto') return 0;
if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;
if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);
return parseInt(s, 10) * cdim;
if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
if (s.endsWith('px')) return parseInt(s.slice(0, -2));
return parseInt(s) * cdim;
}
}
+7 -129
View File
@@ -4,20 +4,19 @@
*/
import { assert } from 'chai';
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
const CASES: [string, IHeaderFields][] = [
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, 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}],
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
// utf-8 encoding in name
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, 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}],
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
// full header spec
[
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{
type: SequenceType.FILE,
inline: 1,
width: '10px',
height: '20%',
@@ -29,7 +28,6 @@ const CASES: [string, IHeaderFields][] = [
[
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{
type: SequenceType.FILE,
inline: 1,
width: 'auto',
height: '20',
@@ -103,7 +101,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length);
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();
inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length);
@@ -117,7 +115,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length);
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();
inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length);
@@ -137,124 +135,4 @@ describe('IIPHeaderParser', () => {
assert.strictEqual(hp.state, HeaderState.END);
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 });
});
});
+13 -89
View File
@@ -6,27 +6,8 @@
// eslint-disable-next-line
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 {
[key: string]: number | string | Uint32Array | null | undefined;
// sequence type
type: SequenceType;
// base-64 encoded filename. Defaults to "Unnamed file".
name: string;
// File size in bytes. The file transfer will be canceled if this size is exceeded.
@@ -47,6 +28,14 @@ export interface IHeaderFields {
inline?: number;
}
export const enum HeaderState {
START = 0,
ABORT = 1,
KEY = 2,
VALUE = 3,
END = 4
}
// field value decoders
// ASCII bytes to string
@@ -92,7 +81,7 @@ function toName(data: Uint32Array): string {
return new TextDecoder().decode(b);
}
const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
inline: toInt,
size: toInt,
name: toName,
@@ -102,19 +91,7 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
};
// sequence type markers
// File
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;
@@ -123,7 +100,7 @@ export class HeaderParser {
private _buffer = new Uint32Array(MAX_FIELDCHARS);
private _position = 0;
private _key = '';
public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};
public fields: {[key: string]: any} = {};
public reset(): void {
this._buffer.fill(0);
@@ -133,43 +110,12 @@ export class HeaderParser {
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 {
let state = this.state;
let pos = this._position;
const buffer = this._buffer;
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) {
const c = data[i];
switch (c) {
@@ -180,29 +126,8 @@ export class HeaderParser {
break;
case 61: // =
if (state === HeaderState.START) {
if (buffer[0] === 70) {
// 'File' or 'FilePart'
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();
for (let k = 0; k < FILE_MARKER.length; ++k) {
if (buffer[k] !== FILE_MARKER[k]) return this._a();
}
state = HeaderState.KEY;
pos = 0;
@@ -232,7 +157,6 @@ export class HeaderParser {
}
private _a(): number {
this.fields.type = SequenceType.INVALID;
this.state = HeaderState.ABORT;
return -1;
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IAddImageOpts, IDrawable, IMetrics } 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(src: IDrawable, data: Blob | undefined, metrics: IMetrics, prescaleX: number, prescaleY: number): void {
this._addImageOpts.prescaleX = prescaleX;
this._addImageOpts.prescaleY = prescaleY;
this._storage.addImage(src, data, metrics, this._addImageOpts);
}
}
@@ -4,8 +4,7 @@
*/
import { assert } from 'chai';
import { imageType } from './Metrics';
import type { IMetrics } from 'Types';
import { imageType, IMetrics } from './IIPMetrics';
// fix missing nodejs decl
declare const require: (s: string) => any;
@@ -31,30 +30,7 @@ const TEST_IMAGES: [string, IMetrics][] = [
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
// 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 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
];
+76
View File
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 24) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
height: d[9] << 8 | d[8]
};
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+19 -79
View File
@@ -5,72 +5,24 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
import { KittyImageStorage } from './kitty/KittyImageStorage';
import { SixelHandler } from './SixelHandler';
import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
/**
* Document VT features provided by this addon.
*
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
*
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
* - max. palette size of 4096 colors
* - max. pixel width of 16K
* - max. 25 MB per sixel sequence
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
*
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
* the following limitations:
* - sequence:
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
* no separator bytes allowed)
* - supported params: size, name, width, height, preserveAspectRatio
* - image formats: PNG, JPEG and GIF
* - no animation support (renders first image of a GIF)
* - no multipart support
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
*
* Kitty graphics support is provided by the addon @xterm/addon-image.
* Note that while basic image output already works, this is still work in progress.
*/
// default values of addon ctor options
const DEFAULT_OPTIONS: IImageAddonOptions = {
enableSizeReports: true,
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 4096,
sixelSizeLimit: 33554432,
sixelPaletteLimit: 256,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -96,7 +48,7 @@ const enum GaStatus {
}
export class ImageAddon implements ITerminalAddon, IImageApi {
export class ImageAddon implements ITerminalAddon , IImageApi {
private _opts: IImageAddonOptions;
private _defaultOpts: IImageAddonOptions;
private _storage: ImageStorage | undefined;
@@ -104,8 +56,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
constructor(opts?: Partial<IImageAddonOptions>) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -118,7 +68,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
this._disposables.length = 0;
this._handlers.clear();
this._onImageAdded.dispose();
}
private _disposeLater(...args: IDisposable[]): void {
@@ -133,20 +82,21 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// internal data structures
this._renderer = new ImageRenderer(terminal);
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
this._storage.onImageAdded = () => this._onImageAdded.fire();
// enable size reports
if (this._opts.enableSizeReports) {
const windowOps = terminal.options.windowOptions ?? {};
// const windowOptions = terminal.getOption('windowOptions');
// windowOptions.getWinSizePixels = true;
// windowOptions.getCellSizePixels = true;
// windowOptions.getWinSizeChars = true;
// terminal.setOption('windowOptions', windowOptions);
const windowOps = terminal.options.windowOptions || {};
windowOps.getWinSizePixels = true;
windowOps.getCellSizePixels = true;
windowOps.getWinSizeChars = true;
terminal.options.windowOptions = windowOps;
}
// apply placeholder setting
this._renderer.showPlaceholder(this._opts.showPlaceholder);
this._disposeLater(
this._renderer,
this._storage,
@@ -179,8 +129,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// SIXEL handler
if (this._opts.sixelSupport) {
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
this._handlers.set('sixel', sixelHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
@@ -189,25 +138,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, iipStorage, terminal);
const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
this._handlers.set('iip', iipHandler);
this._disposeLater(
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.
@@ -253,8 +189,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
return this._storage?.getImageAtBufferCell(x, y);
}
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
return this._storage?.extractTileAtBufferCell(x, y);
}
private _report(s: string): void {
this._terminal?._core.input(s, false);
this._terminal?._core.coreService.triggerDataEvent(s);
}
private _decset(params: (number | number[])[]): boolean {
@@ -343,8 +283,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
switch (params[1]) {
// we only implement read and read_max here
case GaAction.READ:
let width = this._renderer?.dimensions?.device.canvas.width;
let height = this._renderer?.dimensions?.device.canvas.height;
let width = this._renderer?.dimensions?.css.canvas.width;
let height = this._renderer?.dimensions?.css.canvas.height;
if (!width || !height) {
// for some reason we have no working image renderer
// --> fallback to default cell size
+241 -182
View File
@@ -3,30 +3,69 @@
* @license MIT
*/
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import type { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createCanvas } from './Primitives';
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
const PLACEHOLDER_LENGTH = 4096;
const PLACEHOLDER_HEIGHT = 24;
/**
* ImageRenderer - terminal frontend extension:
* - provide primitives for canvas, ImageData, Bitmap (static)
* - add canvas layer to DOM (browser only for now)
* - draw image tiles onRender
*/
export class ImageRenderer extends Disposable implements IDisposable {
/** @deprecated Kept for backward compat — points to top layer canvas. */
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
public canvas: HTMLCanvasElement | undefined;
private _ctx: CanvasRenderingContext2D | null | undefined;
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
private _oldOpen: ((parent: HTMLElement) => void) | undefined;
private _renderService: IRenderService | undefined;
private _oldSetRenderer: ((renderer: any) => void) | undefined;
// some local variables for faster access
private _dimensions: IRenderDimensions | undefined;
private _cellSize: ICellSize | undefined;
// drawing primitive - canvas
public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument || document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
return canvas;
}
// drawing primitive - ImageData with optional buffer
public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {
if (typeof ImageData !== 'function') {
const imgData = ctx.createImageData(width, height);
if (buffer) {
imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));
}
return imgData;
}
return buffer
? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)
: new ImageData(width, height);
}
// drawing primitive - ImageBitmap
public static createImageBitmap(img: ImageBitmapSource): Promise<ImageBitmap | undefined> {
if (typeof createImageBitmap !== 'function') {
return Promise.resolve(undefined);
}
return createImageBitmap(img);
}
constructor(private _terminal: ITerminalExt) {
super();
@@ -47,7 +86,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
});
this._register(toDisposable(() => {
this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
if (this._terminal._core && this._oldOpen) {
this._terminal._core.open = this._oldOpen;
this._oldOpen = undefined;
@@ -57,7 +95,10 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined;
}
this._renderService = undefined;
this._layers.clear();
this.canvas = undefined;
this._ctx = undefined;
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}));
}
@@ -66,7 +107,15 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Enable the placeholder.
*/
public showPlaceholder(value: boolean): void {
this._placeholder = value ? this._createPlaceHolder() : undefined;
if (value) {
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}
this._renderService?.refreshRows(0, this._terminal.rows);
}
@@ -79,151 +128,172 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
/**
* Current cell size.
* Current cell size (float).
*/
public getCellSize(dimensions?: IRenderDimensions): ICellSize | undefined {
dimensions ??= this._terminal.dimensions;
if (dimensions) {
return {
width: dimensions.device.canvas.width / this._terminal.cols,
height: dimensions.device.canvas.height / this._terminal.rows,
};
}
public get cellSize(): ICellSize {
return {
width: this.dimensions?.css.cell.width || -1,
height: this.dimensions?.css.cell.height || -1
};
}
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const deviceGrid = this._cellSize;
if (!deviceGrid || !this._dimensions) return;
const y = Math.floor(start * deviceGrid.height);
const w = this._dimensions.device.canvas.width;
const h = Math.ceil((end + 1 - start) * deviceGrid.height);
if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h);
}
if (!layer || layer === 'bottom') {
this._layers.get('bottom')?.clearRect(0, y, w, h);
}
public clearLines(start: number, end: number): void {
this._ctx?.clearRect(
0,
start * (this.dimensions?.css.cell.height || 0),
this.dimensions?.css.canvas.width || 0,
(++end - start) * (this.dimensions?.css.cell.height || 0)
);
}
/**
* Clear whole image canvas.
*/
public clearAll(layer?: ImageLayer): void {
if (!layer || layer === 'top') {
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);
}
public clearAll(): void {
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
}
/**
* Draw neighboring tiles on the image layer canvas.
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer);
const initialGrid = imgSpec.cellSize;
const deviceGrid = this._cellSize;
if (!this._ctx) {
return;
}
const { width, height } = this.cellSize;
if (!ctx || !imgSpec.src.native || !deviceGrid) {
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
const img = imgSpec.src;
const cols = Math.ceil(img.width * imgSpec.prescaleX / initialGrid.width);
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const gridWidth = initialGrid.width / imgSpec.prescaleX;
const gridHeight = initialGrid.height / imgSpec.prescaleY;
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const dx = col * width;
const dy = row * height;
// crops are initialGrid px
const sx = (tileId % cols) * gridWidth + imgSpec.precropX;
const sy = Math.floor(tileId / cols) * gridHeight + imgSpec.precropY;
const dx = Math.floor(col * deviceGrid.width + imgSpec.offsetX);
const dy = Math.floor(row * deviceGrid.height + imgSpec.offsetY);
// safari bug: never access image source out of bounds, thus we clamp its dimensions
const sWidth = Math.min(count * gridWidth, img.width - sx);
const sHeight = Math.min(gridHeight, img.height - sy);
const dWidth = Math.ceil(sWidth / gridWidth * deviceGrid.width * imgSpec.scaleX);
const dHeight = Math.ceil(sHeight / gridHeight * deviceGrid.height * imgSpec.scaleY);
if (imgSpec.blendMode === 'overwrite') {
ctx.clearRect(dx, dy, dWidth, dHeight);
}
// safari bug: never access image source out of bounds
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage(
img.native,
Math.floor(sx), Math.floor(sy), Math.ceil(sWidth), Math.ceil(sHeight),
dx, dy, dWidth, dHeight
this._ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
);
}
/**
* Extract a single tile from an image.
*/
public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const finalWidth = width + sx > img.width ? img.width - sx : width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
);
return canvas;
}
}
/**
* Draw a line with placeholder on the image layer canvas.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top');
const deviceGrid = this._cellSize;
if (!ctx || !deviceGrid) {
return;
if (this._ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
this._ctx.drawImage(
this._placeholderBitmap || this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
width * count,
height,
col * width,
row * height,
width * count,
height
);
}
this._placeholder ??= this._createPlaceHolder();
if (!this._placeholder) return;
ctx.drawImage(
this._placeholder,
0, 0, 1, 1,
col * deviceGrid.width,
row * deviceGrid.height,
count * deviceGrid.width,
deviceGrid.height
);
}
/**
* Rescale image layer canvas if needed.
* Checked once from `ImageStorage.render`.
* NOTE: This method updates dimensions on instance properties
* for faster access during draw and clear calls.
* So make sure to always call this before doing any draws.
*/
public rescaleCanvas(force: boolean = false): void {
const dimensions = this._terminal.dimensions;
if (dimensions) {
const cssW = dimensions.css.canvas.width;
const cssH = dimensions.css.canvas.height;
const devW = dimensions.device.canvas.width;
const devH = dimensions.device.canvas.height;
let recalc = force;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== devW
|| ctx.canvas.height !== devH
|| ctx.canvas.style.width !== `${cssW}px`
|| ctx.canvas.style.height !== `${cssH}px`
) {
ctx.canvas.width = devW;
ctx.canvas.height = devH;
ctx.canvas.style.width = `${cssW}px`;
ctx.canvas.style.height = `${cssH}px`;
recalc = true;
}
}
if (recalc) {
this._dimensions = dimensions;
this._cellSize = this.getCellSize(dimensions);
}
} else {
// we were not able to get render dimensions:
// set cellsize to undefined so we don't try to draw anything
this._cellSize = this.getCellSize();
public rescaleCanvas(): void {
if (!this.canvas) {
return;
}
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
this.canvas.width = this.dimensions!.css.canvas.width || 0;
this.canvas.height = this.dimensions!.css.canvas.height || 0;
}
}
/**
* Rescale image in storage if needed.
*/
private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {
if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {
return;
}
const { width: originalWidth, height: originalHeight } = spec.origCellSize;
if (currentWidth === originalWidth && currentHeight === originalHeight) {
spec.actual = spec.orig;
spec.actualCellSize.width = originalWidth;
spec.actualCellSize.height = originalHeight;
return;
}
const canvas = ImageRenderer.createCanvas(
this.document,
Math.ceil(spec.orig!.width * currentWidth / originalWidth),
Math.ceil(spec.orig!.height * currentHeight / originalHeight)
);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
spec.actual = canvas;
spec.actualCellSize.width = currentWidth;
spec.actualCellSize.height = currentHeight;
}
}
@@ -234,83 +304,72 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._renderService = this._terminal._core._renderService;
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
this._renderService.setRenderer = (renderer: any) => {
for (const key of [...this._layers.keys()]) {
this.removeLayerFromDom(key);
}
this.removeLayerFromDom();
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
if (!this.document || !this._terminal._core.screenElement) {
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
return;
}
if (this._layers.has(layer)) {
return;
}
const canvas = createCanvas(
this.document,
this.dimensions?.device.canvas.width || 0,
this.dimensions?.device.canvas.height || 0,
this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0,
);
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
// 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);
if (this.document && this._terminal._core.screenElement) {
if (!this.canvas) {
this.canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
this.canvas.classList.add('xterm-image-layer');
this._terminal._core.screenElement.appendChild(this.canvas);
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
this.clearAll();
}
} else {
// Explicit z-index ensures the image canvas reliably stacks above
// the text layer (DOM renderer rows). z-index: 0 is below the
// selection overlay (z-index: 1).
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
}
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
}
public removeLayerFromDom(): void {
if (this.canvas) {
this._ctx = undefined;
this.canvas.remove();
this.canvas = undefined;
}
}
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
// create blueprint to fill placeholder with
const bWidth = 32; // must be 2^n
const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);
const ctx = blueprint.getContext('2d', { alpha: false });
if (!ctx) return;
const imgData = ImageRenderer.createImageData(ctx, bWidth, height);
const d32 = new Uint32Array(imgData.data.buffer);
const black = toRGBA8888(0, 0, 0);
const white = toRGBA8888(255, 255, 255);
d32.fill(black);
for (let y = 0; y < height; ++y) {
const shift = y % 2;
const offset = y * bWidth;
for (let x = 0; x < bWidth; x += 2) {
d32[offset + x + shift] = white;
}
}
ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
this._placeholder = undefined;
return;
}
this._layers.set(layer, ctx);
// force rescaling to update stored cellSize (maybe not be populated yet)
this.rescaleCanvas(true);
this.clearAll(layer);
}
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
const ctx = this._layers.get(layer);
if (ctx) {
ctx.canvas.remove();
this._layers.delete(layer);
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
}
public hasLayer(layer: ImageLayer): boolean {
return this._layers.has(layer);
}
/**
* Create a semi-transparent gray 1x1 placeholder
*/
private _createPlaceHolder(): HTMLCanvasElement | undefined {
const imgData = new ImageData(1, 1);
new Uint32Array(imgData.data.buffer).fill(128 << 24 | 128 << 16 | 128 << 8 | 128);
const canvas = createCanvas(this.document, 1, 1);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
ctx.putImageData(imgData, 0, 0);
return canvas;
ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
}
public get document(): Document | undefined {
+228 -209
View File
@@ -5,14 +5,8 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import type {
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts,
IDrawable,
IMetrics
} from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
import { createCanvas } from './Primitives';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
// fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -20,12 +14,93 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
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(
ext: number = 0,
urlId: number = 0,
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:
@@ -46,12 +121,9 @@ export class ImageStorage implements IDisposable {
// whether render should do a full clear
private _needsFullClear = false;
// hard limit of stored pixels (fallback limit of 10 MB)
private _byteLimit: number = 10000000;
private _workCell: CellData = new CellData();
private _pixelLimit: number = 2500000;
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor(
private _terminal: ITerminalExt,
@@ -60,10 +132,8 @@ export class ImageStorage implements IDisposable {
) {
try {
this.setLimit(this._opts.storageLimit);
} catch (e: unknown) {
if (e instanceof Error) {
console.error(e.message);
}
} catch (e: any) {
console.error(e.message);
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
}
this._viewportMetrics = {
@@ -86,55 +156,42 @@ export class ImageStorage implements IDisposable {
this._renderer.clearAll();
}
/**
* FIXME: below we need to introduce separation of:
* - RAM usage
* - VRAM usage
* - Blob unsage
*/
public getLimit(): number {
return this._byteLimit / 1000000;
return this._pixelLimit * 4 / 1000000;
}
public setLimit(value: number): void {
if (value < 0.5 || value > 1000) {
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1GB');
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');
}
this._byteLimit = (value * 1000000) >>> 0;
this._pixelLimit = (value / 4 * 1000000) >>> 0;
this._evictOldest(0);
}
public getUsage(): number {
return this._getVRAMUsage() / 1000000;
return this._getStoredPixels() * 4 / 1000000;
}
private _getVRAMUsage(): number {
let bytes = 0;
private _getStoredPixels(): number {
let storedPixels = 0;
for (const spec of this._images.values()) {
bytes += spec.src.bytes;
}
return bytes;
}
public getBlobUsage(): number {
let size = 0;
for (const spec of this._images.values()) {
if (spec.data instanceof Blob) {
size += spec.data.size;
if (spec.orig) {
storedPixels += spec.orig.width * spec.orig.height;
if (spec.actual && spec.actual !== spec.orig) {
storedPixels += spec.actual.width * spec.actual.height;
}
}
}
return size;
return storedPixels;
}
private _delImg(id: number): void {
const spec = this._images.get(id);
if (!spec) return;
this._images.delete(id);
spec.src.close();
spec.data = undefined;
this.onImageDeleted?.(id);
// FIXME: really ugly workaround to get bitmaps deallocated :(
if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
}
/**
@@ -158,40 +215,37 @@ export class ImageStorage implements IDisposable {
}
/**
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
* 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 deleteImage(id: number): void {
const spec = this._images.get(id);
if (spec) {
spec.marker?.dispose();
this._delImg(id);
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();
}
}
}
/**
* Method to add an drawable to the storage.
* @param src - The drawable to add.
* @param data - Blob or Uint8Array of the original image data.
* @param metrics - IMetrics of the original image.
* @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move.
* - 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.
* Method to add an image to the storage.
*/
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, opts: IAddImageOpts): number {
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
// never allow storage to exceed memory limit
this._evictOldest(src.bytes);
this._evictOldest(img.width * img.height);
// calc rows x cols needed to display the image
const prescaleX = opts.prescaleX ?? 1.0;
const prescaleY = opts.prescaleY ?? 1.0;
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const cols = Math.ceil(src.width * prescaleX / cellSize.width);
const rows = Math.ceil(src.height * prescaleY / cellSize.height);
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const cols = Math.ceil(img.width / cellSize.width);
const rows = Math.ceil(img.height / cellSize.height);
const imageId = ++this._lastId;
@@ -203,7 +257,7 @@ export class ImageStorage implements IDisposable {
let offset = originX;
let tileCount = 0;
if (!opts.scrolling) {
if (!this._opts.sixelScrolling) {
buffer.x = 0;
buffer.y = 0;
offset = 0;
@@ -211,13 +265,13 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(buffer.y + buffer.ybase)!;
const line = buffer.lines.get(buffer.y + buffer.ybase);
for (let col = 0; col < cols; ++col) {
if (offset + col >= termCols) break;
this._writeToCell(line, offset + col, imageId, row * cols + col);
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++;
}
if (opts.scrolling) {
if (this._opts.sixelScrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else {
if (++buffer.y >= termRows) break;
@@ -227,12 +281,8 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes
if (opts.scrolling) {
if (opts.cursorPos === 'iip') {
buffer.x = Math.min(offset + cols, termCols);
} else {
buffer.x = offset;
}
if (this._opts.sixelScrolling) {
buffer.x = offset;
} else {
buffer.x = originX;
buffer.y = originY;
@@ -268,30 +318,17 @@ export class ImageStorage implements IDisposable {
// create storage entry
const imgSpec: IImageSpec = {
src,
data,
metrics,
cellSize,
orig: img,
origCellSize: cellSize,
actual: img,
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer: opts.layer,
zIndex: opts.zIndex,
prescaleX,
prescaleY,
precropX: 0,
precropY: 0,
scaleX: 1.0,
scaleY: 1.0,
offsetX: 0,
offsetY: 0,
blendMode: 'alpha'
bufferType: this._terminal.buffer.active.type
};
// finally add the image
this._images.set(imageId, imgSpec);
this.onImageAdded?.();
return imageId;
}
@@ -301,30 +338,16 @@ export class ImageStorage implements IDisposable {
*/
// TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void {
// Determine which layers have images
let hasTopImages = false;
let hasBottomImages = false;
for (const spec of this._images.values()) {
if (spec.layer === 'bottom') {
hasBottomImages = true;
} else {
hasTopImages = true;
// setup image canvas in case we have none yet, but have images in store
if (!this._renderer.canvas && this._images.size) {
this._renderer.insertLayerToDom();
// safety measure - in case we cannot spawn a canvas at all, just exit
if (!this._renderer.canvas) {
return;
}
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
this._renderer.rescaleCanvas();
// exit early if we dont have any images to test for
if (!this._images.size) {
if (!this._fullyCleared) {
@@ -332,25 +355,12 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true;
this._needsFullClear = false;
}
if (this._renderer.hasLayer('top')) {
this._renderer.removeLayerFromDom('top');
}
if (this._renderer.hasLayer('bottom')) {
this._renderer.removeLayerFromDom('bottom');
if (this._renderer.canvas) {
this._renderer.removeLayerFromDom();
}
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
if (this._needsFullClear) {
this._renderer.clearAll();
@@ -365,17 +375,13 @@ export class ImageStorage implements IDisposable {
// clear drawing area
this._renderer.clearLines(start, end);
// Collect draw calls so we can sort by z-index (lower z drawn first).
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
// walk all cells in viewport and draw tiles found
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;
for (let col = 0; col < cols; ++col) {
const e = line.getExtended(col)?.payload;
if (e instanceof ImageTileInfo) {
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
@@ -391,37 +397,28 @@ export class ImageStorage implements IDisposable {
* - cell has same image id
* - cell has consecutive tile id
*/
while (++col < cols) {
const nextE = line.getExtended(col)?.payload;
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
}
while (
++col < cols
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
&& (e.imageId === imageId)
&& (e.tileId === startTile + count)
) {
count++;
}
col--;
if (imgSpec) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
if (imgSpec.actual) {
this._renderer.draw(imgSpec, startTile, startCol, row, count);
}
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
this._renderer.drawPlaceholder(startCol, row, count);
}
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 {
@@ -443,9 +440,9 @@ export class ImageStorage implements IDisposable {
const rows = buffer.lines.length;
const oldCol = this._viewportMetrics.cols - 1;
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row)!;
const e = line.getExtended(oldCol)?.payload;
if (e instanceof ImageTileInfo) {
const line = buffer.lines.get(row) as IBufferLineExt;
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
@@ -455,14 +452,14 @@ export class ImageStorage implements IDisposable {
continue;
}
// found an image tile at oldCol, check if it qualifies for right exapansion
const tilesPerRow = Math.ceil(imgSpec.src.width * imgSpec.prescaleX / imgSpec.cellSize.width);
const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
continue;
}
// expand only if right side is empty (nothing got wrapped from below)
let hasData = false;
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
if (line.hasContent(rightCol)) {
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
hasData = true;
break;
}
@@ -474,7 +471,7 @@ export class ImageStorage implements IDisposable {
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
let lastTile = e.tileId;
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
this._writeToCell(line, expandCol, imageId, ++lastTile);
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
imgSpec.tileCount++;
}
}
@@ -488,17 +485,34 @@ export class ImageStorage implements IDisposable {
*/
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y);
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
const src = this._images.get(e.imageId)?.src;
if (src?.native instanceof ImageBitmap || src?.native instanceof VideoFrame) {
const canvas = createCanvas(window.document, src.width, src.height);
canvas.getContext('2d')?.drawImage(src.native, 0, 0, src.width, src.height);
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig;
if (window.ImageBitmap && orig instanceof ImageBitmap) {
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);
return canvas;
}
return src?.native;
return orig as HTMLCanvasElement;
}
}
}
/**
* Extract active single tile at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
return this._renderer.extractTile(spec, e.tileId);
}
}
}
}
@@ -506,12 +520,15 @@ export class ImageStorage implements IDisposable {
// TODO: Do we need some blob offloading tricks here to avoid early eviction?
// also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size
private _evictOldest(room: number): number {
const used = this._getVRAMUsage();
const used = this._getStoredPixels();
let current = used;
while (this._byteLimit < current + room && this._images.size) {
while (this._pixelLimit < current + room && this._images.size) {
const spec = this._images.get(++this._lowestId);
if (spec) {
current -= spec.src.bytes;
if (spec && spec.orig) {
current -= spec.orig.width * spec.orig.height;
if (spec.actual && spec.orig !== spec.actual) {
current -= spec.actual.width * spec.actual.height;
}
spec.marker?.dispose();
this._delImg(this._lowestId);
}
@@ -519,31 +536,31 @@ export class ImageStorage implements IDisposable {
return used - current;
}
private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void {
const workCell = this._workCell;
line.loadCell(x, workCell);
if (this._workCell.hasExtendedAttrs()) {
const old = workCell.extended.payload;
if (old instanceof ImageTileInfo) {
// found an old ExtendedAttrsImage, since we know that
// they are always isolated instances (single cell usage),
// we can re-use it and just update their id entries
const oldSpec = this._images.get(old.imageId);
if (oldSpec) {
// early eviction for in-viewport overwrites
oldSpec.tileCount--;
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
const old = line._extendedAttrs[x];
if (old) {
if (old.imageId !== undefined) {
// found an old ExtendedAttrsImage, since we know that
// they are always isolated instances (single cell usage),
// we can re-use it and just update their id entries
const oldSpec = this._images.get(old.imageId);
if (oldSpec) {
// early eviction for in-viewport overwrites
oldSpec.tileCount--;
}
old.imageId = imageId;
old.tileId = tileId;
return;
}
old.imageId = imageId;
old.tileId = tileId;
// found a plain ExtendedAttrs instance, clone it to new entry
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
return;
}
}
// fall-through: always create new ExtendedAttrs entry
const extattr = workCell.extended.clone();
extattr.payload = new ImageTileInfo(imageId, tileId);
workCell.extended = extattr;
workCell.updateExtended();
line.setCell(x, workCell);
// fall-through: always create new ExtendedAttrsImage entry
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
}
private _evictOnAlternate(): void {
@@ -556,16 +573,18 @@ export class ImageStorage implements IDisposable {
// re-count tiles on whole buffer
const buffer = this._terminal._core.buffer;
for (let y = 0; y < this._terminal.rows; ++y) {
const line = buffer.lines.get(y);
const line = buffer.lines.get(y) as IBufferLineExt;
if (!line) {
continue;
}
for (let x = 0; x < this._terminal.cols; ++x) {
const payload = line.getExtended(x)?.payload;
if (payload instanceof ImageTileInfo) {
const spec = this._images.get(payload.imageId);
if (spec) {
spec.tileCount++;
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
const imgId = line._extendedAttrs[x]?.imageId;
if (imgId) {
const spec = this._images.get(imgId);
if (spec) {
spec.tileCount++;
}
}
}
}
-137
View File
@@ -1,137 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IMetrics } from './Types';
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
height: d[9] << 8 | d[8]
};
}
// QOI: qoif
if (d32[0] === 0x66696F71) {
return {
mime: 'image/qoi',
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
};
}
// WEBP: RIFF | xxxx | WEBP | VP8x
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
switch (d[15]) {
case 0x58: // Extended WebP VP8X --> "X"
return {
mime: 'image/webp',
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
};
case 0x4C: // Lossless WebP VP8L --> "L"
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
return {
mime: 'image/webp',
width: (dim & 0x3FFF) + 1,
height: (dim >>> 14 & 0x3FFF) + 1
};
case 0x20: // Lossy WebP VP8 --> " "
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
return {
mime: 'image/webp',
width: (d[26] | d[27] << 8) & 0x3FFF,
height: (d[28] | d[29] << 8) & 0x3FFF
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
// search for ispe box within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
break;
}
}
if (pos !== -1) {
// dimensions are in BE at +8 (width) at +12 (height)
const width =
d[pos + 8] << 24 |
d[pos + 9] << 16 |
d[pos + 10] << 8 |
d[pos + 11];
const height =
d[pos + 12] << 24 |
d[pos + 13] << 16 |
d[pos + 14] << 8 |
d[pos + 15];
if (width > 0 && height > 0) {
return { mime: 'image/avif', width, height };
}
}
return UNSUPPORTED_TYPE;
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
-68
View File
@@ -1,68 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDrawable } from './Types';
export function createCanvas(
localDocument: Document | undefined,
width: number,
height: number,
cssWidth?: number,
cssHeight?: number
): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
canvas.style.width = `${(cssWidth ?? width) | 0}px`;
canvas.style.height = `${(cssHeight ?? height) | 0}px`;
return canvas;
}
/**
* Drawable is a resource the ImageRenderer knows to draw on the screen.
*/
export class Drawable implements IDrawable {
public readonly width: number;
public readonly height: number;
// @readonly: only for memory tracking
public bytes: number;
constructor(public native: ImageBitmap | VideoFrame | HTMLCanvasElement) {
if (native instanceof VideoFrame) {
this.width = native.displayWidth;
this.height = native.displayHeight;
// NOTE: native.allocationSize() is too expensive
this.bytes = native.codedWidth * native.codedHeight * 4;
} else {
this.width = native.width;
this.height = native.height;
this.bytes = native.width * native.height * 4;
}
}
public close(): void {
if (this.bytes === 0) return;
this.bytes = 0;
try {
if (this.native instanceof HTMLCanvasElement) {
this.native.width = 0;
this.native.height = 0;
} else {
this.native.close?.();
}
} catch {}
this.native = null!;
}
}
+6 -6
View File
@@ -3,13 +3,13 @@
* @license MIT
*/
import { SixelImageStorage } from './SixelImageStorage';
import { ImageStorage } from './ImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
import { createCanvas, Drawable } from './Primitives';
// always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
DecoderAsync({
@@ -91,19 +91,19 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const height = this._dec.height;
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
if (!width || !height) {
if (!width || ! height) {
if (height) {
this._storage.advanceCursor(height);
}
return true;
}
const canvas = createCanvas(undefined, width, height);
const canvas = ImageRenderer.createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
this._dec.release();
}
this._storage.addImage(new Drawable(canvas), undefined, { width, height, mime: 'image/sixel' });
this._storage.addImage(canvas);
return true;
}
}
@@ -1,49 +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, IDrawable, IMetrics } 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(src: IDrawable, data: Blob | undefined, metrics: IMetrics): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(src, data, metrics, 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) {
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
}
+22 -77
View File
@@ -8,8 +8,8 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
// private imports from base repo we build against
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IInputHandler } from 'common/Types';
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
@@ -22,7 +22,7 @@ export const enum Cell {
}
// 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.
@@ -38,8 +38,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -47,7 +45,6 @@ export interface IResetHandler {
reset(): void;
}
/* eslint-disable */
/**
* Stub into private interfaces.
* 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.
*/
// overloaded IExtendedAttrs to hold image refs
export interface IExtendedAttrsImage extends IExtendedAttrs {
imageId: number;
tileId: number;
clone(): IExtendedAttrsImage;
}
/* eslint-disable */
export interface IBufferLineExt extends IBufferLine {
_extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined};
_data: Uint32Array;
}
interface IInputHandlerExt extends IInputHandler {
_parser: IEscapeSequenceParser;
_curAttrData: AttributeData;
@@ -65,7 +75,6 @@ interface IInputHandlerExt extends IInputHandler {
};
onRequestReset(handler: () => void): IDisposable;
}
/* eslint-enable */
export interface ICoreTerminalExt extends ITerminal {
_themeService: IThemeService | undefined;
@@ -77,6 +86,8 @@ export interface ICoreTerminalExt extends ITerminal {
export interface ITerminalExt extends Terminal {
_core: ICoreTerminalExt;
}
/* eslint-enable */
/**
* Some storage definitions.
@@ -86,78 +97,12 @@ export interface ICellSize {
height: number;
}
export type ImageLayer = 'top' | 'bottom';
export type CursorPos = 'vt340' | 'iip';
export interface IAddImageOpts {
scrolling: boolean;
layer: ImageLayer;
zIndex: number;
cursorPos: CursorPos;
prescaleX?: number;
prescaleY?: number;
}
export interface IDrawable {
native: ImageBitmap | VideoFrame | HTMLCanvasElement;
width: number;
height: number;
close(): void;
readonly bytes: number;
}
export type ImageType = 'unsupported'
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/qoi'
| 'image/webp'
| 'image/avif'
// sixel
| 'image/sixel'
// rgb|a blobs for kitty
| 'image/rgb'
| 'image/rgba';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export interface IImageSpec {
/** entries for a later IImageSource type */
/** drawable for screen rendering */
src: IDrawable;
/** image bytes as blob */
data: Blob | undefined;
/** metrics about image like mime and dimensions */
metrics: IMetrics;
/** entries for a later IPlacement type */
/** cell size at time of insert */
cellSize: ICellSize;
/** eviction marker */
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
actual: HTMLCanvasElement | ImageBitmap | undefined;
actualCellSize: ICellSize;
marker: IMarker | undefined;
/** used tiles as eviction hint */
tileCount: number;
/** buffer it was placed on */
bufferType: 'alternate' | 'normal';
/** layer it was placed on */
layer: ImageLayer;
zIndex: number;
/** scaling factor on source dimensions, default = 1.0 */
prescaleX: number;
prescaleY: number;
/** crop offset into source, default = 0 (currently unused) */
precropX: number;
precropY: number;
/** output scaling, default = 1.0 (currently unused) */
scaleX: number;
scaleY: number;
/** output offset, default = 0 (currently unused) */
offsetX: number;
offsetY: number;
/** compositing mode on output, default = 'alpha' (currently unused) */
blendMode: 'overwrite' | 'alpha';
}
@@ -1,820 +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';
import { createCanvas } from '../Primitives';
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?.device.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.device.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
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 = 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 = 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,158 +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';
import { Drawable } from '../Primitives';
import { UNSUPPORTED_TYPE } from '../Metrics';
// 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;
// FIXME: store image data in blob, fill metrics correctly
const storageId = this._storage.addImage(new Drawable(image), undefined, UNSUPPORTED_TYPE, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
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);
}
}
}
}
+5 -3
View File
@@ -11,11 +11,12 @@
"types": [
"../../../node_modules/@types/mocha"
],
"baseUrl": ".",
"paths": {
"browser/*": [ "../../../src/browser/*" ],
"common/*": [ "../../../src/common/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
"*": [ "./*" ]
"vs/*": [ "../../../src/vs/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
}
},
"include": [
@@ -24,6 +25,7 @@
],
"references": [
{ "path": "../../../src/browser" },
{ "path": "../../../src/common" }
{ "path": "../../../src/common" },
{ "path": "../../../src/vs" }
]
}
+21 -192
View File
@@ -8,7 +8,6 @@ import { readFileSync } from 'fs';
import { FINALIZER, introducer, sixelEncode } from 'sixel';
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
import { deepStrictEqual, ok, strictEqual } from 'assert';
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
/**
* Plugin ctor options.
@@ -24,8 +23,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// 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_png.iip', { encoding: 'utf-8' }), [72, 48]]
];
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
const qoiEnc = new QoiEncoder(1024*1024);
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
@@ -90,6 +83,11 @@ test.afterAll(async () => await ctx.page.close());
test.describe('ImageAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(`
window.term.reset()
window.imageAddon?.dispose();
@@ -132,13 +130,11 @@ test.describe('ImageAddon', () => {
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 33554432,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -153,9 +149,7 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
iipSizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -196,18 +190,6 @@ test.describe('ImageAddon', () => {
});
test.describe('image lifecycle & eviction', () => {
test('onImageAdded fires for each image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 3);
});
test('delete image once scrolled off', async () => {
await ctx.proxy.write(SIXEL_SEQ_0);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
@@ -290,158 +272,26 @@ 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 getImageSize(1), [640, 80]);
// no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getImageSize(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 getImageSize(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 getImageSize(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 getImageSize(1), TESTDATA_IIP[1][1]);
});
});
test.describe('IIP support - testimages', () => {
test('palette.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[0][0]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[0][1]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
});
test('spinfox.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
});
test('w3c gif', async () => {
await ctx.proxy.write(TESTDATA_IIP[2][0]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[2][1]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
});
test('w3c jpeg', async () => {
await ctx.proxy.write(TESTDATA_IIP[3][0]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[3][1]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
});
test('w3c png', async () => {
await ctx.proxy.write(TESTDATA_IIP[4][0]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[4][1]);
});
});
test.describe('IIP - QOI support', () => {
test('palette should yield same bytes from PNG and QOI', async () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getImageSize(1), [640, 80]);
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getImageSize(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 getImageSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
});
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
const header = 'width=320px;height=160px;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
deepStrictEqual(await getImageSize(1), [320, 160]);
});
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
const header = 'width=50%;height=30%;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getImageSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
});
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
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 getImageSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel
const header2 = 'height=200px';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
deepStrictEqual(await getImageSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
test.describe('text overwrite removes tiles', () => {
test('sixel', async () => {
await assertTextClearsTiles(SIXEL_SEQ_0);
});
test('iip', async () => {
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
});
});
});
@@ -452,10 +302,10 @@ test.describe('ImageAddon', () => {
async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return {
cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.device.canvas.height)
cellWidth: Math.round(dimensions.css.cell.width),
cellHeight: Math.round(dimensions.css.cell.height),
width: Math.round(dimensions.css.canvas.width),
height: Math.round(dimensions.css.canvas.height)
};
}
@@ -471,30 +321,9 @@ async function getScrollbackPlusRows(): Promise<number> {
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
}
async function getImageSize(id: number): Promise<[number, number]> {
async function getOrigSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).src.width * window.imageAddon._storage._images.get(${id}).prescaleX,
window.imageAddon._storage._images.get(${id}).src.height * window.imageAddon._storage._images.get(${id}).prescaleY
]`).then(r => r.map(Math.floor));
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
strictEqual(await hasTileAtBufferCell(x, 1), false);
}
ok(await hasTileAtBufferCell(0, 1));
ok(await hasTileAtBufferCell(13, 1));
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
window.imageAddon._storage._images.get(${id}).orig.width,
window.imageAddon._storage._images.get(${id}).orig.height
]`);
}
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+6 -15
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
import { Terminal, ITerminalAddon } from '@xterm/xterm';
declare module '@xterm/addon-image' {
export interface IImageAddonOptions {
@@ -76,15 +76,6 @@ declare module '@xterm/addon-image' {
iipSupport?: boolean;
/** IIP sequence size limit (default 20000000 bytes). */
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 {
@@ -116,14 +107,14 @@ declare module '@xterm/addon-image' {
*/
public showPlaceholder: boolean;
/**
* Event fired whenever a new image is added to storage.
*/
public readonly onImageAdded: IEvent<void>;
/**
* Get original image canvas at buffer position.
*/
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
/**
* Extract single tile canvas at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
}
}
+11 -7
View File
@@ -14,13 +14,13 @@
"node": ">8.0.0"
},
"scripts": {
"build": "tsgo -p src",
"watch": "tsgo -w -p src",
"build": "tsc -p src",
"watch": "tsc -w -p src",
"prepackage": "npm run build",
"package": "webpack",
"pretest": "npm run build",
"test": "nyc mocha out/**/*.test.js",
"prepublishOnly": "npm run package"
"prepublish": "npm run package"
},
"keywords": [
"font",
@@ -31,11 +31,15 @@
],
"license": "MIT",
"dependencies": {
"lru-cache": "^11.3.6",
"opentype.js": "^2.0.0"
"lru-cache": "^6.0.0",
"opentype.js": "^0.8.0"
},
"devDependencies": {
"@types/opentype.js": "^1.3.9",
"font-finder": "^1.1.0"
"@types/lru-cache": "^5.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"
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ export interface ITerminalAddon {
dispose(): void;
}
export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
private readonly _fallbackLigatures: string[];
private readonly _fontFeatureSettings?: string;
@@ -22,7 +22,7 @@ export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
constructor(options?: Partial<ILigatureOptions>) {
// Source: calt set from https://github.com/be5invis/Iosevka?tab=readme-ov-file#ligations
this._fallbackLigatures = (options?.fallbackLigatures ?? [
this._fallbackLigatures = (options?.fallbackLigatures || [
'<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->',
'<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=',
'<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::',
+9 -11
View File
@@ -42,11 +42,11 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
if (status && status.state !== 'granted') {
throw new Error('Permission to access local fonts not granted.');
}
} catch (err: unknown) {
} catch (err: any) {
// A `TypeError` indicates the 'local-fonts'
// permission is not yet implemented, so
// only `throw` if this is _not_ the problem.
if (err instanceof Error && err.name !== 'TypeError') {
if (err.name !== 'TypeError') {
throw err;
}
}
@@ -60,10 +60,8 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
fonts[metadata.family].push(metadata);
}
fontsPromise = Promise.resolve(fonts);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err.name, err.message);
}
} catch (err: any) {
console.error(err.name, err.message);
}
}
// Latest proposal https://bugs.chromium.org/p/chromium/issues/detail?id=1312603
@@ -78,13 +76,13 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
fonts[metadata.family].push(metadata);
}
fontsPromise = Promise.resolve(fonts);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err.name, err.message);
}
} catch (err: any) {
console.error(err.name, err.message);
}
}
fontsPromise ??= Promise.resolve({});
if (!fontsPromise) {
fontsPromise = Promise.resolve({});
}
}
const fonts = await fontsPromise;
@@ -1,5 +1,5 @@
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 mergeTrees from './merge';
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
if (options.cacheSize > 0) {
this._cache = new LRUCache({
maxSize: options.cacheSize,
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
max: options.cacheSize,
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
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
ranges.splice(i, 1);
inRange = false;
return ranges;
}
// Case 7: New range extends from previous range past the
@@ -52,10 +52,12 @@ export function processLookaheadPosition(
}
processedEntries.add(currentEntry.entry);
currentEntry.entry.forward ??= {
individual: {},
range: []
};
if (!currentEntry.entry.forward) {
currentEntry.entry.forward = {
individual: {},
range: []
};
}
// All glyphs at this position share ONE entry - lookahead just needs to match,
// all paths lead to the same result
@@ -95,10 +97,12 @@ export function processBacktrackPosition(
}
processedEntries.add(currentEntry.entry);
currentEntry.entry.reverse ??= {
individual: {},
range: []
};
if (!currentEntry.entry.reverse) {
currentEntry.entry.reverse = {
individual: {},
range: []
};
}
// All glyphs at this position share ONE entry - backtrack just needs to match,
// all paths lead to the same result
@@ -54,6 +54,9 @@ export function getIndividualSubstitutionGlyph(table: SubstitutionTable, glyphId
return (glyphId + table.deltaGlyphId) % (2 ** 16);
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2
case 2:
return table.substitute[coverageIndex] ?? null;
// eslint-disable-next-line eqeqeq
return table.substitute[coverageIndex] != null
? table.substitute[coverageIndex]
: null;
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
/**
* Enable ligature support for the provided Terminal instance. To function
* properly, this must be called after `open()` is called on the 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
* start to render them.
* @param term Terminal instance from xterm.js
@@ -30,7 +30,7 @@ export function enableLigatures(term: Terminal, fallbackLigatures: string[] = []
let currentFontName: string | undefined = undefined;
let font: Font | undefined = undefined;
let loadingState: LoadingState = LoadingState.UNLOADED;
let loadError: unknown = undefined;
let loadError: any | undefined = undefined;
return term.registerCharacterJoiner((text: string): [number, number][] => {
// If the font hasn't been loaded yet, load it and return an empty result
+1 -5
View File
@@ -9,12 +9,8 @@
"outDir": "../out-esbuild-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"strict": true,
"paths": {
"*": [
"./*"
]
},
"types": [
"../../../node_modules/@types/mocha",
"../../../node_modules/@types/node"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+2 -2
View File
@@ -5,7 +5,7 @@
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
import type { Emitter, IEvent } from 'common/Event';
import type { Emitter, Event } from 'vs/base/common/event';
const enum ProgressType {
@@ -39,7 +39,7 @@ export class ProgressAddon implements ITerminalAddon, IProgressApi {
private _pr = 0;
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
private _onChange!: Emitter<IProgressState>;
public onChange!: IEvent<IProgressState>;
public onChange!: Event<IProgressState>;
public dispose(): void {
this._seqHandler?.dispose();
+4 -4
View File
@@ -15,12 +15,12 @@
"../../../node_modules/@types/mocha"
],
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"vs/*": [
"../../../src/vs/*"
],
"@xterm/addon-progress": [
"../typings/addon-progress.d.ts"
]
@@ -35,7 +35,7 @@
"path": "../../../src/browser"
},
{
"path": "../../../src/common"
"path": "../../../src/vs"
}
]
}
@@ -9,18 +9,15 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
ctx.page.setViewportSize({ width: 1024, height: 768 });
await openTerminal(ctx);
});
test.afterAll(async () => await ctx.page.close());
test.describe('ProgressAddon', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
await openTerminal(ctx);
});
test.afterAll(async () => {
await ctx?.page?.close();
});
test.beforeEach(async function(): Promise<any> {
await ctx.page.evaluate(`
window.progressStack = [];
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"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);
});
});
+1 -1
View File
@@ -5,7 +5,7 @@
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
+12 -14
View File
@@ -4,10 +4,10 @@
*/
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
import { Emitter, type IEvent } from 'common/Event';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { disposableTimeout } from 'common/Async';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
import { SearchLineCache } from './SearchLineCache';
import { SearchState } from './SearchState';
import { SearchEngine, type ISearchResult } from './SearchEngine';
@@ -47,7 +47,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
private readonly _onBeforeSearch = this._register(new Emitter<void>());
public readonly onBeforeSearch = this._onBeforeSearch.event;
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> {
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
return this._resultTracker.onDidChangeResults;
}
@@ -142,14 +142,12 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
}
prevResult = result;
results.push(prevResult);
const cols = this._terminal.cols;
let nextCol = prevResult.col + prevResult.size;
let nextRow = prevResult.row;
if (nextCol >= cols) {
nextRow += Math.floor(nextCol / cols);
nextCol = nextCol % cols;
}
result = this._engine.find(term, nextRow, nextCol, searchOptions);
result = this._engine.find(
term,
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
searchOptions
);
}
this._resultTracker.updateResults(results, this._highlightLimit);
@@ -224,7 +222,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
* @param result The result to select.
* @returns Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean {
if (!this._terminal || !this._decorationManager) {
return false;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchOptions } from '@xterm/addon-search';
import { DisposableStore } from 'common/Lifecycle';
import { DisposableStore } from 'vs/base/common/lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
+7 -5
View File
@@ -60,7 +60,7 @@ export class SearchEngine {
this._terminal.clearSelection();
return undefined;
}
if (startCol >= this._terminal.cols) {
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
@@ -174,7 +174,7 @@ export class SearchEngine {
this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
const startCol = this._terminal.cols;
let startCol = this._terminal.cols;
const isReverseSearch = true;
this._lineCache.initLinesCache();
@@ -186,19 +186,21 @@ export class SearchEngine {
let result: ISearchResult | undefined;
if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = prevSelectedPos.start.x;
searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (cachedSearchTerm !== term) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) {
// If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = prevSelectedPos.end.x;
searchPosition.startCol = startCol = prevSelectedPos.end.x;
}
}
}
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (!result) {
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
}
// Search from startRow - 1 to top
if (!result) {
@@ -5,7 +5,7 @@
import { assert } from 'chai';
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import { timeout } from 'common/Async';
import { timeout } from 'vs/base/common/async';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
+2 -2
View File
@@ -4,8 +4,8 @@
*/
import type { Terminal } from '@xterm/xterm';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { disposableTimeout } from 'common/Async';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
export type LineCacheEntry = [
/**

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