Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3556f16138 | ||
|
|
c2ecfe7446 | ||
|
|
5656443163 | ||
|
|
38174907f3 | ||
|
|
91fcb46a1a | ||
|
|
1d569f873d | ||
|
|
e89fbf2979 | ||
|
|
db244ea5a8 | ||
|
|
fdcd1c2720 | ||
|
|
57b48f6048 | ||
|
|
ec0c93fd5a | ||
|
|
be339bf796 | ||
|
|
7294124736 | ||
|
|
2c3fad39af | ||
|
|
edb4f89dd8 | ||
|
|
224c113b64 | ||
|
|
3e0c7d18e5 | ||
|
|
95d9224b8c | ||
|
|
7499a2181d | ||
|
|
7dc820f5fa | ||
|
|
e59a6c1f7b | ||
|
|
f3e2c08a5d | ||
|
|
b138bf38d0 | ||
|
|
c6e4627b45 | ||
|
|
d91045dc5f | ||
|
|
41dfdba2cf | ||
|
|
ba33decb68 | ||
|
|
17e3b84bcc | ||
|
|
182ff2f635 | ||
|
|
bcb4e5298b | ||
|
|
42facac2d9 | ||
|
|
2ab0f10a26 | ||
|
|
280dc0a36a | ||
|
|
ebe8cb749a | ||
|
|
c28f3c8d73 | ||
|
|
3bd7ac083a | ||
|
|
30b42838fd | ||
|
|
80e832e417 | ||
|
|
470947abd8 | ||
|
|
bb49a80ace | ||
|
|
65b87e0f40 | ||
|
|
39fc0f259e | ||
|
|
b787ac8b1b | ||
|
|
457efbf55d | ||
|
|
42270a0b68 | ||
|
|
090b77f354 | ||
|
|
534ce1365b | ||
|
|
f757c965ee | ||
|
|
4aa02f3fd9 | ||
|
|
f43273b3f0 | ||
|
|
c06c3025f2 | ||
|
|
1064e4f783 | ||
|
|
fad4821fd9 | ||
|
|
306c3d73ec | ||
|
|
8f2bb32c9c | ||
|
|
7b6c814566 | ||
|
|
adc9dea083 | ||
|
|
d565a2750e | ||
|
|
c5ab83d697 | ||
|
|
c62e6dd765 | ||
|
|
29a07f1d8f | ||
|
|
88cef97049 | ||
|
|
0e823004e6 | ||
|
|
df3422fb0b | ||
|
|
4ec85e67ba | ||
|
|
1a67b6f4b3 | ||
|
|
afaed108e4 | ||
|
|
c27d6bb462 | ||
|
|
9c4f0f96ca | ||
|
|
18c370a154 | ||
|
|
850f4ad738 | ||
|
|
b665f6259a | ||
|
|
d195dc9a3b | ||
|
|
4e5994d8a2 | ||
|
|
4a0cb5ae6b | ||
|
|
9e72a4b6d3 | ||
|
|
f484584b70 | ||
|
|
e749cb6125 | ||
|
|
8df996f9e7 | ||
|
|
51f6186352 | ||
|
|
ae5c41c518 | ||
|
|
17e7dafa17 | ||
|
|
df9fb7e473 | ||
|
|
41d9c4080f | ||
|
|
f100e93ba5 | ||
|
|
1d3825d341 | ||
|
|
57315e6cc9 | ||
|
|
2be4fc68e5 | ||
|
|
66fabfc232 | ||
|
|
3e2a42eb29 | ||
|
|
6a47d09b4c | ||
|
|
b36001ee7b | ||
|
|
e64f25a0dd | ||
|
|
3bcb57545e | ||
|
|
dc726a2ae6 | ||
|
|
06e5ac7ab4 | ||
|
|
c6e3492a4a | ||
|
|
86291b6e96 | ||
|
|
c8c8b3a7fe | ||
|
|
50e90ddf8a | ||
|
|
7059a07c09 | ||
|
|
c5c6fc2a64 | ||
|
|
e0add7fe4b | ||
|
|
b0f5bc2d28 | ||
|
|
c5a11220c1 | ||
|
|
6f55150485 | ||
|
|
c8dddddbbb | ||
|
|
6ba731d73b | ||
|
|
2d3909be2c | ||
|
|
1e189625fa | ||
|
|
80debafa11 | ||
|
|
1023e40428 | ||
|
|
5c47e6dd9a | ||
|
|
4ebb3fffe9 | ||
|
|
093e81b9a7 | ||
|
|
e04dd2501f | ||
|
|
d911b40a47 | ||
|
|
3dbfa7ce3e | ||
|
|
6287c6ddfe | ||
|
|
52d4b2719c | ||
|
|
7615cd210a | ||
|
|
4d4b994c35 | ||
|
|
7fa8b7a069 | ||
|
|
a2e51e8d36 | ||
|
|
f89253d24b | ||
|
|
7aeee95346 | ||
|
|
66fcdb2583 | ||
|
|
2a18600d18 | ||
|
|
5ef4f86543 | ||
|
|
874e767b40 | ||
|
|
605a7c6e30 | ||
|
|
802decb046 | ||
|
|
79a95dbfd8 | ||
|
|
75ba88c51b | ||
|
|
f9cea732f2 | ||
|
|
4dc934ce7c | ||
|
|
b2eff5ac67 | ||
|
|
31c53bc9c2 | ||
|
|
0d2975884f | ||
|
|
e8b5fd3c23 | ||
|
|
7abcca7e73 | ||
|
|
92339ac9dd | ||
|
|
ad4a6e8cb9 | ||
|
|
a094340afe | ||
|
|
cc7e3bbbf7 | ||
|
|
73d87b2c20 | ||
|
|
c5bf395b10 | ||
|
|
9d24099aa1 | ||
|
|
066c227436 | ||
|
|
32ad3a18bf | ||
|
|
d43b19c260 | ||
|
|
7dd0dac859 | ||
|
|
7e70d6ad78 | ||
|
|
893cdd494a | ||
|
|
2794e32b3f | ||
|
|
7d69e730ca | ||
|
|
da51d9608c | ||
|
|
3a84bcb7ae | ||
|
|
d9082215e7 | ||
|
|
41938b307e | ||
|
|
785c3cb336 | ||
|
|
ea7e958959 | ||
|
|
915b0b81a1 | ||
|
|
4e9abe7d96 | ||
|
|
84aca0b912 | ||
|
|
d82d42ef9a | ||
|
|
4d2f2e0d8e | ||
|
|
56986cb7eb | ||
|
|
4946436997 | ||
|
|
bf83ea06e4 | ||
|
|
b7e535e2ec | ||
|
|
8e12e69e4e | ||
|
|
849a9a53ca | ||
|
|
ee7d84bdd3 | ||
|
|
9c8de633e9 | ||
|
|
9c5420371b | ||
|
|
22778b5b49 | ||
|
|
cbe59a17ae | ||
|
|
76671cecb6 | ||
|
|
a11fcd2e0b | ||
|
|
b28b5d0c2b | ||
|
|
685434ca84 | ||
|
|
7fe89a1c1a | ||
|
|
eaebe2ab15 | ||
|
|
32553b4135 | ||
|
|
6c2465d745 | ||
|
|
5922123b9e | ||
|
|
eb65910c1b | ||
|
|
f0aeb70c92 | ||
|
|
9deec7aabf | ||
|
|
18cd9858ed | ||
|
|
6fd7ecc812 | ||
|
|
ff72c00219 | ||
|
|
0911688295 | ||
|
|
62ce318a07 | ||
|
|
08ae141ef7 | ||
|
|
ba7e775a17 | ||
|
|
487b27f512 | ||
|
|
5d120de3d5 | ||
|
|
3ab299af7f | ||
|
|
6e375cb588 | ||
|
|
7a6e724bfe | ||
|
|
69ee4ad23b | ||
|
|
253d1e9beb | ||
|
|
4b77e601cd | ||
|
|
34e0179355 | ||
|
|
bd722e7b07 | ||
|
|
cccdd447a0 | ||
|
|
446bcb1f75 | ||
|
|
4316263457 | ||
|
|
4a35a4880d | ||
|
|
ad730f2d54 | ||
|
|
9c7c163dd4 | ||
|
|
8674bc5e82 | ||
|
|
d552be8c45 | ||
|
|
082a51127f | ||
|
|
89100f6a3d | ||
|
|
8149d4481f | ||
|
|
2d9b21f5c7 | ||
|
|
f43985c422 | ||
|
|
9f6c7d7ced | ||
|
|
aa7716a333 | ||
|
|
1432e20082 | ||
|
|
f1b0a96094 | ||
|
|
49096a21fd | ||
|
|
63035678ec | ||
|
|
503abee8ae | ||
|
|
4350536685 | ||
|
|
9feb963cc9 | ||
|
|
f4042876c6 | ||
|
|
c294695444 | ||
|
|
fe0952bb83 | ||
|
|
5c2b19f2c8 | ||
|
|
5e9b2cbc14 | ||
|
|
3a59c8ec2b | ||
|
|
fb25eb8f79 | ||
|
|
e7a4578933 | ||
|
|
a05a3321d5 | ||
|
|
26ce9bd4d3 | ||
|
|
1c1aac0da8 | ||
|
|
beeebca1a4 | ||
|
|
fd1e53c63f | ||
|
|
18262c87d9 | ||
|
|
2285ac6ab8 | ||
|
|
5302e62c90 | ||
|
|
af6731b533 | ||
|
|
5b9b531012 | ||
|
|
b0118dfd3a | ||
|
|
6813fc175e | ||
|
|
409f049f39 | ||
|
|
9e1b89651a | ||
|
|
89980fc565 | ||
|
|
16b5da5c4c | ||
|
|
4812abb24f | ||
|
|
5f3a4646be | ||
|
|
d63445be94 | ||
|
|
1400398abb | ||
|
|
2331abe6bd | ||
|
|
b66128b239 | ||
|
|
c727240f16 | ||
|
|
649ce22081 | ||
|
|
73adfdfd56 | ||
|
|
1fc8a4687d | ||
|
|
c29e48c917 | ||
|
|
70fe22993b | ||
|
|
4f3a7331bd | ||
|
|
6f3ce7b0fe | ||
|
|
fdb7346f3a | ||
|
|
783c4a34d2 | ||
|
|
8a1760b924 | ||
|
|
211db9d067 | ||
|
|
ec6ad20e33 | ||
|
|
47235f47fe | ||
|
|
8d6ff3a27b | ||
|
|
55281022a0 | ||
|
|
ea50fbdd1e | ||
|
|
87bf81cf96 | ||
|
|
f155c65e5a | ||
|
|
413c87cbf9 | ||
|
|
1620f8f5a8 | ||
|
|
e769f80a14 | ||
|
|
6651c4ca23 | ||
|
|
cb78a53f57 | ||
|
|
1c7b9f6d79 | ||
|
|
0d535100e4 | ||
|
|
5e19624e71 | ||
|
|
671425bf5a | ||
|
|
df5393ec45 | ||
|
|
b2553c17b4 | ||
|
|
0cea95d3b3 | ||
|
|
163868ff60 | ||
|
|
f4166a87ba | ||
|
|
f7a353cd34 | ||
|
|
7ec41471da | ||
|
|
f5e81afba7 | ||
|
|
a12061c899 | ||
|
|
d6c872bb2c | ||
|
|
a2cb36e6dd | ||
|
|
114c66c86c | ||
|
|
fe9650ce16 | ||
|
|
dedf3b1595 | ||
|
|
2158827b25 | ||
|
|
b406797dc7 | ||
|
|
6e35b44f02 | ||
|
|
375be97615 | ||
|
|
c66b0ed168 | ||
|
|
38913e4ae1 |
@@ -1,7 +1,11 @@
|
||||
---
|
||||
applyTo: '**/*.benchmark.ts'
|
||||
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
|
||||
|
||||
# Benchmark Run Instructions
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
- Full suite: `npm run benchmark`
|
||||
- Single benchmark file:
|
||||
@@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts'
|
||||
- Use `-t` to get the path, then:
|
||||
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
||||
|
||||
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||
## Benchmark Case Selection
|
||||
|
||||
When writing benchmark instructions:
|
||||
|
||||
- Use `RuntimeCase` to measure pure runtime in ms.
|
||||
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||
|
||||
## Notes
|
||||
|
||||
Notes:
|
||||
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
||||
- Keep `NODE_PATH=./out` (handled by the npm script).
|
||||
@@ -1,7 +1,11 @@
|
||||
---
|
||||
applyTo: '**/*.test.ts'
|
||||
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.
|
||||
---
|
||||
When writing unit tests follow these rules:
|
||||
|
||||
# Unit Test Instructions
|
||||
|
||||
When writing unit tests, follow these rules:
|
||||
|
||||
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
|
||||
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npm run agent:setup-repo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -207,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
|
||||
|
||||
@@ -22,6 +22,10 @@ build/
|
||||
yarn.lock
|
||||
test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
.cursor/*.log
|
||||
|
||||
# Keep bundled code out of Git
|
||||
dist/
|
||||
demo/dist/
|
||||
|
||||
@@ -57,6 +57,13 @@ 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
|
||||
@@ -24,7 +24,36 @@ First, you need to install the module. We ship exclusively through [npm](https:/
|
||||
npm install --save @xterm/xterm
|
||||
```
|
||||
|
||||
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`.
|
||||
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:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
@@ -36,7 +65,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
var term = new Terminal();
|
||||
const term = new Terminal();
|
||||
term.open(document.getElementById('terminal'));
|
||||
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
|
||||
</script>
|
||||
@@ -44,14 +73,6 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
|
||||
</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:
|
||||
@@ -94,7 +115,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
|
||||
|
||||
@@ -229,6 +250,10 @@ 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.
|
||||
- [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.
|
||||
|
||||
@@ -21,8 +21,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
|
||||
import { Base64 as JSBase64 } from 'js-base64';
|
||||
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
|
||||
|
||||
export class ClipboardAddon implements ITerminalAddon {
|
||||
private _terminal?: Terminal;
|
||||
@@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
return this._disposable?.dispose();
|
||||
}
|
||||
|
||||
private _readText(sel: ClipboardSelectionType, data: string): void {
|
||||
private _readText(sel: string, data: string): void {
|
||||
const b64 = this._base64.encodeText(data);
|
||||
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
|
||||
}
|
||||
@@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pc = args[0] as ClipboardSelectionType;
|
||||
const pc = args[0];
|
||||
const pd = args[1];
|
||||
if (pd === '?') {
|
||||
const text = this._provider.readText(pc);
|
||||
@@ -70,30 +69,23 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
}
|
||||
|
||||
export class BrowserClipboardProvider implements IClipboardProvider {
|
||||
public async readText(selection: ClipboardSelectionType): Promise<string> {
|
||||
if (selection !== 'c') {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
public readText(selection: string): Promise<string> {
|
||||
return navigator.clipboard.readText();
|
||||
}
|
||||
|
||||
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
|
||||
if (selection !== 'c') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
public writeText(selection: string, text: string): Promise<void> {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
export class Base64 implements IBase64 {
|
||||
public encodeText(data: string): string {
|
||||
return JSBase64.encode(data);
|
||||
return btoa(data);
|
||||
}
|
||||
public decodeText(data: string): string {
|
||||
const text = JSBase64.decode(data);
|
||||
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
|
||||
return '';
|
||||
}
|
||||
return text;
|
||||
try {
|
||||
return atob(data);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() !== 'chromium') {
|
||||
if (ctx.browser.browserType().name() === 'webkit') {
|
||||
testInfo.skip();
|
||||
return;
|
||||
}
|
||||
@@ -48,6 +48,14 @@ 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()), '');
|
||||
@@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => {
|
||||
});
|
||||
|
||||
test.describe('read data', async function (): Promise<any> {
|
||||
test('simple string', async () => {
|
||||
test.beforeAll(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`);
|
||||
|
||||
+12
-21
@@ -30,18 +30,6 @@ 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.
|
||||
@@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' {
|
||||
export interface IClipboardProvider {
|
||||
/**
|
||||
* Gets the clipboard content.
|
||||
* @param selection The clipboard selection to read.
|
||||
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
|
||||
* @returns A promise that resolves with clipboard selection data.
|
||||
*/
|
||||
readText(selection: ClipboardSelectionType): string | Promise<string>;
|
||||
readText(selection: string): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Sets the clipboard content.
|
||||
* @param selection The clipboard selection to set.
|
||||
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
|
||||
* @param data The clipboard text to write.
|
||||
*/
|
||||
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
|
||||
writeText(selection: string, text: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard provider interface that enables xterm.js to access the system clipboard.
|
||||
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
|
||||
*
|
||||
* Note this this clipboard provider does not implement own cut buffers as xterm does,
|
||||
* but redirect the selection parameter always to navigator.clipboard.
|
||||
*/
|
||||
export class BrowserClipboardProvider implements IClipboardProvider{
|
||||
/**
|
||||
* Reads text from the clipboard.
|
||||
* @param selection The selection type to read from.
|
||||
* @param selection The selection type to read from (always refers to navigator.clipboard).
|
||||
* @returns A promise that resolves with the text from the clipboard.
|
||||
*/
|
||||
public readText(selection: ClipboardSelectionType): Promise<string>;
|
||||
public readText(selection: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Writes text to the clipboard.
|
||||
* @param selection The selection type to write to.
|
||||
* @param selection The selection type to write to (always refers to navigator.clipboard).
|
||||
* @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: ClipboardSelectionType, data: string): Promise<void>;
|
||||
public writeText(selection: string, data: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ interface ITerminalDimensions {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const MINIMUM_COLS = 2;
|
||||
const MINIMUM_ROWS = 1;
|
||||
const enum Constants {
|
||||
MINIMUM_COLS = 2,
|
||||
MINIMUM_ROWS = 1
|
||||
}
|
||||
|
||||
function getWindow(e: Node): Window {
|
||||
if (e?.ownerDocument?.defaultView) {
|
||||
@@ -48,10 +50,7 @@ export class FitAddon implements ITerminalAddon, IFitApi {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force a full render
|
||||
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
}
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
@@ -89,8 +88,8 @@ export class FitAddon implements ITerminalAddon, IFitApi {
|
||||
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||
const geometry = {
|
||||
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
||||
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
|
||||
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))
|
||||
};
|
||||
return geometry;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,17 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ 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: 256, // initial sixel palette size
|
||||
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
|
||||
sixelPaletteLimit: 4096, // initial sixel palette size
|
||||
sixelSizeLimit: 33554432, // 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: 20000000, // size limit of a single IIP sequence
|
||||
iipSizeLimit: 33554432, // size limit of a single IIP sequence
|
||||
kittySupport: true, // enable Kitty graphics support
|
||||
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
|
||||
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
|
||||
}
|
||||
|
||||
// initialization
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"sixel": "^0.16.0",
|
||||
"xterm-wasm-parts": "^0.3.0"
|
||||
"xterm-wasm-parts": "^0.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
@@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _hp = new HeaderParser();
|
||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||
private _dec: Base64Decoder;
|
||||
private _qoiDec: QoiDecoder;
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
|
||||
constructor(
|
||||
@@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
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 {}
|
||||
@@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
|
||||
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
|
||||
this._dec.release();
|
||||
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<boolean>(r => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
||||
this._storage.addImage(canvas);
|
||||
r(true);
|
||||
});
|
||||
img.src = url;
|
||||
// sanity measure to avoid terminal blocking from dangling promise
|
||||
// happens from corrupt data (onload never gets fired)
|
||||
setTimeout(() => r(true), 1000);
|
||||
});
|
||||
let blob: Blob | ImageData;
|
||||
if (this._metrics.mime === 'image/qoi') {
|
||||
const data = this._qoiDec.decode(this._dec.data8);
|
||||
blob = new ImageData(
|
||||
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||
this._qoiDec.width,
|
||||
this._qoiDec.height
|
||||
);
|
||||
this._qoiDec.release();
|
||||
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||
// use fast-path if we don't need to rescale
|
||||
this._dec.release();
|
||||
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||
this._storage.addImage(canvas);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
}
|
||||
this._dec.release();
|
||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
@@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics {
|
||||
height: d[9] << 8 | d[8]
|
||||
};
|
||||
}
|
||||
// QOI: qoif
|
||||
if (d32[0] === 0x66696F71) {
|
||||
return {
|
||||
mime: 'image/qoi',
|
||||
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
|
||||
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
|
||||
};
|
||||
}
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
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';
|
||||
@@ -15,20 +16,61 @@ 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: 256,
|
||||
sixelSizeLimit: 25000000,
|
||||
sixelPaletteLimit: 4096,
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -62,6 +104,8 @@ 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);
|
||||
@@ -74,6 +118,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
}
|
||||
this._disposables.length = 0;
|
||||
this._handlers.clear();
|
||||
this._onImageAdded.dispose();
|
||||
}
|
||||
|
||||
private _disposeLater(...args: IDisposable[]): void {
|
||||
@@ -88,6 +133,7 @@ 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) {
|
||||
@@ -161,7 +207,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
this._disposeLater(
|
||||
kittyStorage,
|
||||
kittyHandler,
|
||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
||||
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm';
|
||||
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const PLACEHOLDER_HEIGHT = 24;
|
||||
const enum Constants {
|
||||
PLACEHOLDER_LENGTH = 4096,
|
||||
PLACEHOLDER_HEIGHT = 24
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageRenderer - terminal frontend extension:
|
||||
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public showPlaceholder(value: boolean): void {
|
||||
if (value) {
|
||||
if (!this._placeholder && this.cellSize.height !== -1) {
|
||||
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
|
||||
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||
}
|
||||
} else {
|
||||
this._placeholderBitmap?.close();
|
||||
@@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
|
||||
if (!this._placeholder) {
|
||||
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
|
||||
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||
} else if (height >= this._placeholder!.height) {
|
||||
this._createPlaceHolder(height + 1);
|
||||
}
|
||||
@@ -358,7 +360,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
canvas.style.zIndex = '0';
|
||||
screenElement.appendChild(canvas);
|
||||
}
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) {
|
||||
canvas.remove();
|
||||
return;
|
||||
@@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
return this._layers.has(layer);
|
||||
}
|
||||
|
||||
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
||||
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
|
||||
@@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
// create placeholder line, width aligned to blueprint width
|
||||
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
|
||||
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
|
||||
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
|
||||
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
|
||||
if (!ctx2) {
|
||||
|
||||
@@ -124,6 +124,7 @@ export class ImageStorage implements IDisposable {
|
||||
private _pixelLimit: number = 2500000;
|
||||
|
||||
private _viewportMetrics: { cols: number, rows: number };
|
||||
public onImageAdded: (() => void) | undefined;
|
||||
public onImageDeleted: ((storageId: number) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
@@ -336,6 +337,7 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
// finally add the image
|
||||
this._images.set(imageId, imgSpec);
|
||||
this.onImageAdded?.();
|
||||
return imageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -16,24 +16,23 @@ import {
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
BYTES_PER_PIXEL_RGB,
|
||||
BYTES_PER_PIXEL_RGBA,
|
||||
ALPHA_OPAQUE,
|
||||
KittyPixelConstants,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
const DECODER_KEEP_DATA = 4194304;
|
||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
||||
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
|
||||
}
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
|
||||
// Maximum control data size
|
||||
const MAX_CONTROL_DATA_SIZE = 512;
|
||||
|
||||
// Semicolon codepoint
|
||||
const 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 {
|
||||
@@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
private _inControlData = true;
|
||||
|
||||
// Buffer for control data.
|
||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
||||
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||
private _controlLength = 0;
|
||||
|
||||
// Pre-calculated encoded size limit
|
||||
@@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
// 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(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
@@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
// Scan for semicolon
|
||||
let controlEnd = end;
|
||||
for (let i = start; i < end; i++) {
|
||||
if (data[i] === SEMICOLON) {
|
||||
if (data[i] === Constants.SEMICOLON) {
|
||||
this._inControlData = false;
|
||||
controlEnd = i;
|
||||
break;
|
||||
@@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
// Copy control data
|
||||
const copyLength = controlEnd - start;
|
||||
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
||||
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
@@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
this._activeDecoder = pending.decoder;
|
||||
}
|
||||
if (!this._activeDecoder) {
|
||||
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder.init();
|
||||
}
|
||||
|
||||
@@ -377,14 +376,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
if (cmd.id === undefined) {
|
||||
return true;
|
||||
}
|
||||
const image = this._kittyStorage.getImage(cmd.id);
|
||||
const id = cmd.id;
|
||||
const image = this._kittyStorage.getImage(id);
|
||||
if (!image) {
|
||||
this._sendResponse(cmd.id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
||||
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(cmd.id!, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
||||
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -483,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
return true;
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
@@ -511,6 +511,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
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) {
|
||||
@@ -529,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
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;
|
||||
if (isOk && quiet >= 1) return;
|
||||
if (!isOk && quiet >= 2) return;
|
||||
|
||||
const pPart = placementId ? `,p=${placementId}` : '';
|
||||
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||
@@ -546,135 +550,137 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
let bitmap = await this._createBitmap(image);
|
||||
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
|
||||
|
||||
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);
|
||||
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));
|
||||
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) {
|
||||
bitmap.close();
|
||||
throw new Error('invalid source rectangle');
|
||||
}
|
||||
|
||||
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||
bitmap.close();
|
||||
bitmap = cropped;
|
||||
}
|
||||
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
|
||||
// Per spec: c/r default to image's natural cell dimensions.
|
||||
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||
let imgCols: number;
|
||||
let imgRows: number;
|
||||
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = cmd.rows;
|
||||
} else if (cmd.columns !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
||||
} else if (cmd.rows !== undefined) {
|
||||
imgRows = cmd.rows;
|
||||
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
||||
} else {
|
||||
imgCols = Math.ceil(bitmap.width / cw);
|
||||
imgRows = Math.ceil(bitmap.height / ch);
|
||||
}
|
||||
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
|
||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||
w = Math.round(imgCols * cw);
|
||||
h = Math.round(imgRows * ch);
|
||||
}
|
||||
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
bitmap.close();
|
||||
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';
|
||||
|
||||
let finalBitmap = bitmap;
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
bitmap.close();
|
||||
}
|
||||
|
||||
// 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) : finalBitmap.width + xOffset;
|
||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : finalBitmap.height + yOffset;
|
||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCtx = offsetCanvas.getContext('2d');
|
||||
if (!offsetCtx) {
|
||||
finalBitmap.close();
|
||||
throw new Error('Failed to create offset canvas context');
|
||||
if (finalCropW === 0 || finalCropH === 0) {
|
||||
throw new Error('invalid source rectangle');
|
||||
}
|
||||
|
||||
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||
bitmap.close();
|
||||
bitmap = cropped;
|
||||
}
|
||||
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
|
||||
// Per spec: c/r default to image's natural cell dimensions.
|
||||
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||
let imgCols: number;
|
||||
let imgRows: number;
|
||||
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = cmd.rows;
|
||||
} else if (cmd.columns !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
||||
} else if (cmd.rows !== undefined) {
|
||||
imgRows = cmd.rows;
|
||||
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
||||
} else {
|
||||
imgCols = Math.ceil(bitmap.width / cw);
|
||||
imgRows = Math.ceil(bitmap.height / ch);
|
||||
}
|
||||
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
|
||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||
w = Math.round(imgCols * cw);
|
||||
h = Math.round(imgRows * ch);
|
||||
}
|
||||
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
|
||||
|
||||
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
||||
offsetCanvas.width = offsetCanvas.height = 0;
|
||||
finalBitmap.close();
|
||||
finalBitmap = offsetBitmap;
|
||||
w = finalBitmap.width;
|
||||
h = finalBitmap.height;
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
finalBitmap.close();
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
if (cmd.columns === undefined) {
|
||||
imgCols = Math.ceil(finalBitmap.width / cw);
|
||||
}
|
||||
if (cmd.rows === undefined) {
|
||||
imgRows = Math.ceil(finalBitmap.height / ch);
|
||||
}
|
||||
}
|
||||
|
||||
const zIndex = cmd.zIndex ?? 0;
|
||||
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
||||
// When z<0 we always use the bottom layer even without allowTransparency —
|
||||
// the image will simply be hidden behind the opaque text background, which
|
||||
// is the correct behavior (client asked for "behind text").
|
||||
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
||||
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
|
||||
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
bitmap.close();
|
||||
bitmap = scaled;
|
||||
}
|
||||
|
||||
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
|
||||
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
|
||||
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
|
||||
if (xOffset !== 0 || yOffset !== 0) {
|
||||
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
|
||||
// total placement area remains c*cw × r*ch pixels and the offset image
|
||||
// is clipped to fit. When c/r are unset, the padded canvas determines
|
||||
// the natural cell dimensions.
|
||||
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
|
||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
|
||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCtx = offsetCanvas.getContext('2d');
|
||||
if (!offsetCtx) {
|
||||
throw new Error('Failed to create offset canvas context');
|
||||
}
|
||||
offsetCtx.drawImage(bitmap, xOffset, yOffset);
|
||||
|
||||
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
||||
offsetCanvas.width = offsetCanvas.height = 0;
|
||||
bitmap.close();
|
||||
bitmap = offsetBitmap;
|
||||
w = bitmap.width;
|
||||
h = bitmap.height;
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
if (cmd.columns === undefined) {
|
||||
imgCols = Math.ceil(bitmap.width / cw);
|
||||
}
|
||||
if (cmd.rows === undefined) {
|
||||
imgRows = Math.ceil(bitmap.height / ch);
|
||||
}
|
||||
}
|
||||
|
||||
const zIndex = cmd.zIndex ?? 0;
|
||||
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
|
||||
bitmap = undefined; // ownership transferred to storage
|
||||
|
||||
// Kitty cursor movement
|
||||
// Per spec: cursor placed at first column after last image column,
|
||||
// on the last row of the image. C=1 means don't move cursor.
|
||||
if (cmd.cursorMovement === 1) {
|
||||
// C=1: restore cursor to position before image was placed
|
||||
const scrolled = buffer.ybase - savedYbase;
|
||||
buffer.x = savedX;
|
||||
// Can't restore cursor to scrollback?
|
||||
buffer.y = Math.max(savedY - scrolled, 0);
|
||||
} else {
|
||||
// Default (C=0): advance cursor horizontally past the image
|
||||
// addImage already positioned cursor on the last row via lineFeeds
|
||||
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
||||
}
|
||||
} catch (e) {
|
||||
bitmap?.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
throw new Error('Width and height required for raw pixel data');
|
||||
}
|
||||
|
||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
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) {
|
||||
@@ -727,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
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 * BYTES_PER_PIXEL_RGBA), width, height));
|
||||
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 * BYTES_PER_PIXEL_RGBA);
|
||||
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
|
||||
@@ -745,22 +751,22 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
const b1 = src32[srcOffset++];
|
||||
const b2 = src32[srcOffset++];
|
||||
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
||||
dst32[dstOffset++] = (b0 & 0x00FFFFFF) | 0xFF000000;
|
||||
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
|
||||
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
|
||||
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
|
||||
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 * BYTES_PER_PIXEL_RGB;
|
||||
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
|
||||
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] = ALPHA_OPAQUE;
|
||||
srcByte += BYTES_PER_PIXEL_RGB;
|
||||
dstByte += BYTES_PER_PIXEL_RGBA;
|
||||
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));
|
||||
|
||||
@@ -82,9 +82,11 @@ export const enum KittyKey {
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const ALPHA_OPAQUE = 255;
|
||||
export const enum KittyPixelConstants {
|
||||
BYTES_PER_PIXEL_RGB = 3,
|
||||
BYTES_PER_PIXEL_RGBA = 4,
|
||||
ALPHA_OPAQUE = 255
|
||||
}
|
||||
|
||||
// Parsed Kitty graphics command.
|
||||
export interface IKittyCommand {
|
||||
|
||||
@@ -20,6 +20,15 @@ export class KittyImageStorage implements IDisposable {
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
@@ -74,6 +75,10 @@ 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 }) => {
|
||||
@@ -85,11 +90,6 @@ 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 +132,13 @@ test.describe('ImageAddon', () => {
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||
sixelSizeLimit: 25000000,
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -196,6 +196,18 @@ 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);
|
||||
@@ -300,6 +312,68 @@ test.describe('ImageAddon', () => {
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP - QOI support', () => {
|
||||
test('palette should yield same bytes from PNG and QOI', async () => {
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||
const pngScrape = await getImageAtBufferCell(0, 0);
|
||||
const qoiScrape = await getImageAtBufferCell(0, 11);
|
||||
deepStrictEqual(qoiScrape, pngScrape);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP - resizing', () => {
|
||||
/**
|
||||
* The correct resize behavior is wonky and needs to be tested against iTerm2,
|
||||
* especially for missing params to derive correct default behavior.
|
||||
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
|
||||
* ref: https://iterm2.com/documentation-images.html
|
||||
* imgcat: https://iterm2.com/utilities/imgcat
|
||||
*
|
||||
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
|
||||
*/
|
||||
const images = [
|
||||
['palette.png', 525, PALETTE_PNG_BASE64],
|
||||
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
|
||||
];
|
||||
for (const [name, size, payload] of images) {
|
||||
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
|
||||
// cell based resize
|
||||
const header = 'width=20;height=5;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
|
||||
});
|
||||
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
|
||||
// pixel based resize
|
||||
const header = 'width=320px;height=160px;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [320, 160]);
|
||||
});
|
||||
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
|
||||
// % of viewport resize
|
||||
const header = 'width=50%;height=30%;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
|
||||
});
|
||||
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
|
||||
// width provided in percent
|
||||
const header = 'width=50%';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
const width = Math.floor(dim.width * 0.5);
|
||||
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
|
||||
// height provided in pixel
|
||||
const header2 = 'height=200px';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -333,3 +407,7 @@ async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
]`);
|
||||
}
|
||||
|
||||
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
|
||||
}
|
||||
|
||||
@@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
|
||||
|
||||
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();
|
||||
@@ -622,6 +617,18 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('suppresses OK response when q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('suppresses error response when q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
@@ -802,6 +809,30 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('a=t OK suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('a=T OK suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transmission medium rejection', () => {
|
||||
@@ -1151,6 +1182,22 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
});
|
||||
|
||||
test('OK response suppressed by q=2', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
});
|
||||
|
||||
test('ENOENT error suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
@@ -2159,6 +2206,19 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('onImageAdded callback', () => {
|
||||
test('onImageAdded fires for each kitty image', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window._imageAddedCount = 0;
|
||||
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
|
||||
`);
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await pollFor(ctx.page, 'window._imageAddedCount', 1);
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
||||
await pollFor(ctx.page, 'window._imageAddedCount', 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+6
-1
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
||||
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
|
||||
|
||||
declare module '@xterm/addon-image' {
|
||||
export interface IImageAddonOptions {
|
||||
@@ -116,6 +116,11 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"package": "webpack",
|
||||
"pretest": "npm run build",
|
||||
"test": "nyc mocha out/**/*.test.js",
|
||||
"prepublish": "npm run package"
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"keywords": [
|
||||
"font",
|
||||
@@ -37,9 +37,9 @@
|
||||
"devDependencies": {
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/opentype.js": "^0.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.15.2",
|
||||
"font-finder": "^1.1.0",
|
||||
"mkdirp": "0.5.5",
|
||||
"yauzl": "^2.10.0"
|
||||
"yauzl": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,18 @@ 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 = [];
|
||||
|
||||
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
// Do this intentionally to get content of underlining source
|
||||
const bufferLine = buffer.getLine(i)._line;
|
||||
lines.push(JSON.stringify(bufferLine));
|
||||
lines.push(JSON.stringify(bufferLine, (key, value) => {
|
||||
// BufferLine caches are internal/transient and can legitimately differ
|
||||
// across equivalent terminal states.
|
||||
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
}
|
||||
return {
|
||||
x: buffer.cursorX,
|
||||
|
||||
@@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => {
|
||||
test.describe('font loading at runtime', () => {
|
||||
test('loadFonts (JS)', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
||||
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||
loadFonts([ff1, ff2]);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||
});
|
||||
test('loadFonts (CSS, unquoted)', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||
loadFonts(['Kongtext', 'BPdots']);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||
@@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => {
|
||||
test('loadFonts (CSS, quoted)', async ({ browser }) => {
|
||||
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
|
||||
await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||
loadFonts(['Kongtext', 'BPdots']);
|
||||
`);
|
||||
if (browser.browserType().name() === 'firefox') {
|
||||
@@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => {
|
||||
test('FontFace hashing', async () => {
|
||||
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
|
||||
await ctx.page.evaluate(`
|
||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
||||
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||
loadFonts([ff1, ff2]);
|
||||
loadFonts([ff1, ff2]);
|
||||
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
|
||||
@@ -76,7 +76,7 @@ test.describe('WebFontsAddon', () => {
|
||||
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
|
||||
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
|
||||
const data = await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
|
||||
@@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => {
|
||||
|
||||
// safari loads the font, firefox & chrome dont
|
||||
if (browser.browserType().name() === 'webkit') {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
try {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
|
||||
} catch (e) {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
}
|
||||
} else {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
@@ -103,7 +107,10 @@ test.describe('WebFontsAddon', () => {
|
||||
window.helperTerm.open(term.element);
|
||||
`);
|
||||
await timeout(100);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
if (browser.browserType().name() !== 'webkit') {
|
||||
// safari sometimes reports 'unloadeed'?
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
}
|
||||
|
||||
// cleanup this messy test case
|
||||
await ctx.page.evaluate(`
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { configEquals } from './CharAtlasUtils';
|
||||
import { ICharAtlasConfig } from './Types';
|
||||
import { NULL_COLOR } from 'common/Color';
|
||||
import { IColor } from 'common/Types';
|
||||
|
||||
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
|
||||
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
|
||||
const contrastCache = {
|
||||
clear: () => {},
|
||||
setCss: () => {},
|
||||
getCss: () => undefined,
|
||||
setColor: () => {},
|
||||
getColor: () => undefined
|
||||
};
|
||||
const colors = {
|
||||
foreground: color,
|
||||
background: color,
|
||||
cursor: NULL_COLOR,
|
||||
cursorAccent: NULL_COLOR,
|
||||
selectionForeground: undefined,
|
||||
selectionBackgroundTransparent: NULL_COLOR,
|
||||
selectionBackgroundOpaque: NULL_COLOR,
|
||||
selectionInactiveBackgroundTransparent: NULL_COLOR,
|
||||
selectionInactiveBackgroundOpaque: NULL_COLOR,
|
||||
overviewRulerBorder: NULL_COLOR,
|
||||
scrollbarSliderBackground: NULL_COLOR,
|
||||
scrollbarSliderHoverBackground: NULL_COLOR,
|
||||
scrollbarSliderActiveBackground: NULL_COLOR,
|
||||
ansi: new Array(256).fill(color),
|
||||
contrastCache,
|
||||
halfContrastCache: contrastCache
|
||||
};
|
||||
return {
|
||||
customGlyphs: true,
|
||||
devicePixelRatio: 1,
|
||||
deviceMaxTextureSize: 4096,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
fontSize: 15,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
deviceCellWidth: 10,
|
||||
deviceCellHeight: 20,
|
||||
deviceCharWidth: 8,
|
||||
deviceCharHeight: 16,
|
||||
allowTransparency: false,
|
||||
drawBoldTextInBrightColors: true,
|
||||
minimumContrastRatio: 1,
|
||||
colors,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CharAtlasUtils', () => {
|
||||
describe('configEquals', () => {
|
||||
it('should return true for identical configs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig();
|
||||
assert.ok(configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceMaxTextureSize differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellWidth differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellWidth: 11 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellHeight differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellHeight: 21 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
}
|
||||
}
|
||||
return a.devicePixelRatio === b.devicePixelRatio &&
|
||||
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
|
||||
a.customGlyphs === b.customGlyphs &&
|
||||
a.lineHeight === b.lineHeight &&
|
||||
a.letterSpacing === b.letterSpacing &&
|
||||
@@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
a.fontWeight === b.fontWeight &&
|
||||
a.fontWeightBold === b.fontWeightBold &&
|
||||
a.allowTransparency === b.allowTransparency &&
|
||||
a.deviceCellWidth === b.deviceCellWidth &&
|
||||
a.deviceCellHeight === b.deviceCellHeight &&
|
||||
a.deviceCharWidth === b.deviceCharWidth &&
|
||||
a.deviceCharHeight === b.deviceCharHeight &&
|
||||
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
||||
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import type { IOptionsService } from 'common/services/Services';
|
||||
import type { ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
interface IVertices {
|
||||
@@ -78,9 +78,11 @@ void main() {
|
||||
}`);
|
||||
}
|
||||
|
||||
const INDICES_PER_CELL = 11;
|
||||
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
|
||||
const CELL_POSITION_INDICES = 2;
|
||||
const enum Constants {
|
||||
INDICES_PER_CELL = 11,
|
||||
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
|
||||
CELL_POSITION_INDICES = 2
|
||||
}
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $i = 0;
|
||||
@@ -112,7 +114,8 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _optionsService: IOptionsService
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -125,7 +128,7 @@ export class GlyphRenderer extends Disposable {
|
||||
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
||||
}
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
@@ -134,8 +137,9 @@ export class GlyphRenderer extends Disposable {
|
||||
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
@@ -160,22 +164,22 @@ export class GlyphRenderer extends Disposable {
|
||||
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
|
||||
|
||||
// Setup static uniforms
|
||||
@@ -222,12 +226,12 @@ export class GlyphRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||
$i = (y * this._terminal.cols + x) * INDICES_PER_CELL;
|
||||
$i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL;
|
||||
|
||||
// Exit early if this is a null character, allow space character to continue as it may have
|
||||
// underline/strikethrough styles
|
||||
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
|
||||
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
|
||||
array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,7 +292,7 @@ export class GlyphRenderer extends Disposable {
|
||||
|
||||
public clear(): void {
|
||||
const terminal = this._terminal;
|
||||
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
|
||||
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
|
||||
|
||||
// Clear vertices
|
||||
if (this._vertices.count !== newCount) {
|
||||
@@ -310,7 +314,7 @@ export class GlyphRenderer extends Disposable {
|
||||
for (let x = 0; x < terminal.cols; x++) {
|
||||
this._vertices.attributes[i + 9] = x / terminal.cols;
|
||||
this._vertices.attributes[i + 10] = y / terminal.rows;
|
||||
i += INDICES_PER_CELL;
|
||||
i += Constants.INDICES_PER_CELL;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,8 +350,8 @@ export class GlyphRenderer extends Disposable {
|
||||
// - So we don't send vertices for all the line-ending whitespace to the GPU
|
||||
let bufferLength = 0;
|
||||
for (let y = 0; y < renderModel.lineLengths.length; y++) {
|
||||
const si = y * this._terminal.cols * INDICES_PER_CELL;
|
||||
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
|
||||
const si = y * this._terminal.cols * Constants.INDICES_PER_CELL;
|
||||
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
|
||||
activeBuffer.set(sub, bufferLength);
|
||||
bufferLength += sub.length;
|
||||
}
|
||||
@@ -356,7 +360,9 @@ export class GlyphRenderer extends Disposable {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
|
||||
|
||||
// Bind the atlas page texture if they have changed
|
||||
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
|
||||
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||
// is detected by the same comparison.
|
||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
||||
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
|
||||
this._bindAtlasPageTexture(gl, this._atlas, i);
|
||||
@@ -364,11 +370,15 @@ export class GlyphRenderer extends Disposable {
|
||||
}
|
||||
|
||||
// Draw the viewport
|
||||
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
|
||||
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
|
||||
}
|
||||
|
||||
public setAtlas(atlas: ITextureAtlas): void {
|
||||
this._atlas = atlas;
|
||||
this.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
public invalidateAtlasTextures(): void {
|
||||
for (const glTexture of this._atlasTextures) {
|
||||
glTexture.version = -1;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IColor } from 'common/Types';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
|
||||
import { RenderModelConstants } from './RenderModel';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
||||
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
const enum VertexAttribLocations {
|
||||
POSITION = 0,
|
||||
@@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable {
|
||||
private _terminal: Terminal,
|
||||
private _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _themeService: IThemeService
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
const gl = this._gl;
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
@@ -217,9 +220,9 @@ export class RectangleRenderer extends Disposable {
|
||||
currentFg = 0;
|
||||
currentInverse = false;
|
||||
for (x = 0; x < terminal.cols; x++) {
|
||||
modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET];
|
||||
fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET];
|
||||
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
|
||||
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
|
||||
inverse = !!(fg & FgFlags.INVERSE);
|
||||
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
|
||||
// A rectangle needs to be drawn if going from non-default to another color
|
||||
@@ -336,8 +339,9 @@ export class RectangleRenderer extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.attributes.length < offset + 4) {
|
||||
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
|
||||
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
|
||||
// +1 for the viewport-clear rectangle at offset 0.
|
||||
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
|
||||
}
|
||||
$x1 = startX * this._dimensions.device.cell.width;
|
||||
$y1 = y * this._dimensions.device.cell.height;
|
||||
|
||||
@@ -7,10 +7,12 @@ import { ICursorRenderModel, IRenderModel } from './Types';
|
||||
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
|
||||
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
|
||||
|
||||
export const RENDER_MODEL_INDICIES_PER_CELL = 4;
|
||||
export const RENDER_MODEL_BG_OFFSET = 1;
|
||||
export const RENDER_MODEL_FG_OFFSET = 2;
|
||||
export const RENDER_MODEL_EXT_OFFSET = 3;
|
||||
export const enum RenderModelConstants {
|
||||
INDICIES_PER_CELL = 4,
|
||||
BG_OFFSET = 1,
|
||||
FG_OFFSET = 2,
|
||||
EXT_OFFSET = 3
|
||||
}
|
||||
|
||||
export const COMBINED_CHAR_BIT_MASK = 0x80000000;
|
||||
|
||||
@@ -27,7 +29,7 @@ export class RenderModel implements IRenderModel {
|
||||
}
|
||||
|
||||
public resize(cols: number, rows: number): void {
|
||||
const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL;
|
||||
if (indexCount !== this.cells.length) {
|
||||
this.cells = new Uint32Array(indexCount);
|
||||
this.lineLengths = new Uint32Array(rows);
|
||||
|
||||
@@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._onAddTextureAtlasCanvas.dispose();
|
||||
this._onRemoveTextureAtlasCanvas.dispose();
|
||||
}
|
||||
|
||||
public warmUp(): void {
|
||||
@@ -133,7 +134,9 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
|
||||
private _requestClearModel = false;
|
||||
public beginFrame(): boolean {
|
||||
return this._requestClearModel;
|
||||
const result = this._requestClearModel;
|
||||
this._requestClearModel = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
public clearTexture(): void {
|
||||
@@ -193,7 +196,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
|
||||
// Merge into the new page
|
||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||
mergedPage.version++;
|
||||
mergedPage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Delete the pages, shifting glyph texture pages as needed
|
||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||
@@ -251,7 +254,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
for (const g of adjustingPage.glyphs) {
|
||||
g.texturePage--;
|
||||
}
|
||||
adjustingPage.version++;
|
||||
adjustingPage.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +530,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
let customGlyph = false;
|
||||
if (this._config.customGlyphs !== false) {
|
||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
|
||||
}
|
||||
|
||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||
@@ -937,7 +940,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
rasterizedGlyph.size.y
|
||||
);
|
||||
activePage.addGlyph(rasterizedGlyph);
|
||||
activePage.version++;
|
||||
activePage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
return rasterizedGlyph;
|
||||
}
|
||||
@@ -1047,9 +1050,13 @@ class AtlasPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to check whether the canvas of the atlas page has changed.
|
||||
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
|
||||
* unit at a given index needs to be re-uploaded — both for content changes within the same
|
||||
* page and for a page object swap at the same index (which happens after a page merge,
|
||||
* where a per-page counter could coincide with the previously-bound page's value).
|
||||
*/
|
||||
public version = 0;
|
||||
public static nextVersion: number = 0;
|
||||
public version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Texture atlas current positioning data. The texture packing strategy used is to fill from
|
||||
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
|
||||
@@ -1092,7 +1099,7 @@ class AtlasPage {
|
||||
this.currentRow.y = 0;
|
||||
this.currentRow.height = 0;
|
||||
this.fixedRows.length = 0;
|
||||
this.version++;
|
||||
this.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ICursorRenderModel {
|
||||
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
||||
vertexAttribDivisor(index: number, divisor: number): void;
|
||||
createVertexArray(): IWebGLVertexArrayObject;
|
||||
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
|
||||
import { ITerminal } from 'browser/Types';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { getSafariVersion, isSafari } from 'common/Platform';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { IWebGL2RenderingContext } from './Types';
|
||||
import { WebglRenderer } from './WebglRenderer';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
@@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||
const logService: ILogService = unsafeCore._logService;
|
||||
const themeService: IThemeService = unsafeCore._themeService;
|
||||
|
||||
this._renderer = this._register(new WebglRenderer(
|
||||
@@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
coreBrowserService,
|
||||
coreService,
|
||||
decorationService,
|
||||
logService,
|
||||
optionsService,
|
||||
themeService,
|
||||
this._customGlyphs,
|
||||
|
||||
@@ -15,11 +15,11 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
import { RectangleRenderer } from './RectangleRenderer';
|
||||
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel';
|
||||
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
|
||||
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
|
||||
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
|
||||
import { IRenderLayer } from './renderLayer/Types';
|
||||
@@ -28,9 +28,13 @@ import { addDisposableListener } from 'browser/Dom';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
const enum Constants {
|
||||
MERGE_RETRY_LIMIT = 32
|
||||
}
|
||||
|
||||
export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private _renderLayers: IRenderLayer[];
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
|
||||
private _textBlinkStateManager: TextBlinkStateManager;
|
||||
private _charAtlasDisposable = this._register(new MutableDisposable());
|
||||
private _charAtlas: ITextureAtlas | undefined;
|
||||
@@ -73,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
private readonly _coreService: ICoreService,
|
||||
private readonly _decorationService: IDecorationService,
|
||||
private readonly _logService: ILogService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _customGlyphs: boolean = true,
|
||||
@@ -118,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
||||
console.log('webglcontextlost event received');
|
||||
this._logService.debug('webglcontextlost event received');
|
||||
// Prevent the default behavior in order to enable WebGL context restoration.
|
||||
e.preventDefault();
|
||||
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
||||
// If not, dispatch the onContextLoss notification to observers.
|
||||
this._contextRestorationTimeout = setTimeout(() => {
|
||||
this._contextRestorationTimeout = undefined;
|
||||
console.warn('webgl context not restored; firing onContextLoss');
|
||||
this._logService.warn('webgl context not restored; firing onContextLoss');
|
||||
this._onContextLoss.fire(e);
|
||||
}, 3000 /* ms */);
|
||||
}));
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
||||
console.warn('webglcontextrestored event received');
|
||||
this._logService.warn('webglcontextrestored event received');
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
// The texture atlas and glyph renderer must be fully reinitialized
|
||||
@@ -154,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._isAttached = this._core.screenElement!.isConnected;
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
for (const l of this._renderLayers) {
|
||||
l.dispose();
|
||||
}
|
||||
@@ -270,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
* Initializes members dependent on WebGL context state.
|
||||
*/
|
||||
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
|
||||
|
||||
// Update dimensions and acquire char atlas
|
||||
this.handleCharSizeChanged();
|
||||
@@ -375,6 +382,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateModel(start, end);
|
||||
}
|
||||
|
||||
// A mid-update atlas page merge invalidates vertex data and may not bump the host
|
||||
// page's version, so re-run the update and force a full texture rebind.
|
||||
let merged = false;
|
||||
let mergeRetries = 0;
|
||||
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
|
||||
merged = true;
|
||||
this._clearModel(true);
|
||||
this._updateModel(0, this._terminal.rows - 1);
|
||||
}
|
||||
if (merged) {
|
||||
this._glyphRenderer.value.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
// Render
|
||||
this._rectangleRenderer.value.renderBackgrounds();
|
||||
this._glyphRenderer.value.render(this._model);
|
||||
@@ -493,7 +513,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
chars = cell.getChars();
|
||||
code = cell.getCode();
|
||||
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
|
||||
if (!rowHasBlinkingCells && cell.isBlink()) {
|
||||
rowHasBlinkingCells = true;
|
||||
@@ -538,9 +558,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Nothing has changed, no updates needed
|
||||
if (this._model.cells[i] === code &&
|
||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg &&
|
||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg &&
|
||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
||||
this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg &&
|
||||
this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg &&
|
||||
this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -553,9 +573,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Cache the results in the model
|
||||
this._model.cells[i] = code;
|
||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
|
||||
width = cell.getWidth();
|
||||
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
|
||||
@@ -566,14 +586,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Null out non-first cells
|
||||
for (x++; x <= lastCharX; x++) {
|
||||
j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[j] = NULL_CELL_CODE;
|
||||
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
|
||||
// supported
|
||||
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
}
|
||||
x--; // Go back to the previous update cell for next iteration
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
/**
|
||||
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
||||
@@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([
|
||||
-1, 1, 0, 1
|
||||
]);
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
|
||||
const program = throwIfFalsy(gl.createProgram());
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
|
||||
gl.linkProgram(program);
|
||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
if (success) {
|
||||
return program;
|
||||
}
|
||||
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
logService.error(gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
}
|
||||
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
|
||||
const shader = throwIfFalsy(gl.createShader(type));
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
@@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
|
||||
return shader;
|
||||
}
|
||||
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
logService.error(gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||
|
||||
@@ -22,6 +23,7 @@ export function tryDrawCustomGlyph(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): boolean {
|
||||
@@ -30,7 +32,7 @@ export function tryDrawCustomGlyph(
|
||||
// Normalize to array for uniform handling
|
||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||
for (const part of parts) {
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ function drawDefinitionPart(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
@@ -79,7 +82,7 @@ function drawDefinitionPart(
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH:
|
||||
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
@@ -88,7 +91,7 @@ function drawDefinitionPart(
|
||||
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.BRAILLE:
|
||||
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
@@ -510,6 +513,7 @@ function drawPathFunctionCharacter(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
strokeWidth?: number
|
||||
): void {
|
||||
ctx.save();
|
||||
@@ -536,7 +540,7 @@ function drawPathFunctionCharacter(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
@@ -598,7 +602,8 @@ function drawVectorShape(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService
|
||||
): void {
|
||||
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
||||
const clipRegion = new Path2D();
|
||||
@@ -619,7 +624,7 @@ function drawVectorShape(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
|
||||
@@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => {
|
||||
await openTerminal(ctx);
|
||||
ctxWrapper.value = ctx;
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
try {
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
} catch (e) {}
|
||||
`);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
@@ -24,15 +26,18 @@ test.afterAll(async () => await ctx.page.close());
|
||||
test.describe('WebGL Renderer Integration Tests', async () => {
|
||||
// HACK: webgl2 is often not supported in headless firefox on Linux
|
||||
// https://github.com/microsoft/playwright/issues/11566
|
||||
// also disable safari due to #5852
|
||||
if (platform() === 'linux') {
|
||||
test.skip(({ browserName }) => browserName === 'firefox');
|
||||
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
|
||||
}
|
||||
|
||||
injectSharedRendererTests(ctxWrapper);
|
||||
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
try {
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
} catch (e) {}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
|
||||
{
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
args: ['--enable-unsafe-swiftshader']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,6 +57,9 @@ xterm.js version: {{version}}
|
||||
{{#CSI.length}}
|
||||
- [CSI](#csi)
|
||||
{{/CSI.length}}
|
||||
{{#APC.length}}
|
||||
- [APC](#apc)
|
||||
{{/APC.length}}
|
||||
{{#DCS.length}}
|
||||
- [DCS](#dcs)
|
||||
{{/DCS.length}}
|
||||
@@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g
|
||||
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
|
||||
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
|
||||
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
|
||||
- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit)
|
||||
|
||||
Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
||||
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
||||
any sequence of these types will be silently ignored. They are also not hookable by the API.
|
||||
|
||||
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
|
||||
@@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does:
|
||||
{{/CSI.length}}
|
||||
|
||||
|
||||
{{#APC.length}}
|
||||
## APC
|
||||
|
||||
| Mnemonic | Name | Sequence | Short Description | Support |
|
||||
| -------- | ---- | -------- | ----------------- | ------- |
|
||||
{{#APC}}
|
||||
| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} |
|
||||
{{/APC}}
|
||||
|
||||
{{#APC.hasLongDescriptions}}
|
||||
{{#APC}}
|
||||
{{#longDescription.length}}
|
||||
<section class="sequence-details">
|
||||
|
||||
### {{name}}
|
||||
{{#longDescription}}
|
||||
{{{.}}}
|
||||
{{/longDescription}}
|
||||
|
||||
</section>
|
||||
{{/longDescription.length}}
|
||||
{{/APC}}
|
||||
{{/APC.hasLongDescriptions}}
|
||||
|
||||
{{/APC.length}}
|
||||
|
||||
|
||||
{{#DCS.length}}
|
||||
## DCS
|
||||
|
||||
|
||||
@@ -617,8 +617,12 @@ function addDomListener(element: HTMLElement, type: string, handler: (...args: a
|
||||
}
|
||||
|
||||
function updateTerminalSize(): void {
|
||||
const showScrollbar = term!.options.scrollbar?.showScrollbar ?? true;
|
||||
const scrollBarWidth = (term!.options.scrollback === 0 || !showScrollbar)
|
||||
? 0
|
||||
: (term!.options.scrollbar?.width ?? 14);
|
||||
const width = optionsWindow.autoResize ? '100%'
|
||||
: ((term as any).dimensions.css.canvas.width + (term as any)._core.viewport.scrollBarWidth).toString() + 'px';
|
||||
: ((term as any).dimensions.css.canvas.width + scrollBarWidth).toString() + 'px';
|
||||
const height = optionsWindow.autoResize ? '100%'
|
||||
: ((term as any).dimensions.css.canvas.height).toString() + 'px';
|
||||
terminalContainer!.style.width = width;
|
||||
|
||||
@@ -85,7 +85,7 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow {
|
||||
|
||||
terminal.element.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
const core = (terminal as any)._core;
|
||||
const coords = core._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
|
||||
const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
|
||||
if (!coords) {
|
||||
this._clearDisplay();
|
||||
return;
|
||||
|
||||
@@ -116,6 +116,7 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
'linkHandler',
|
||||
'logger',
|
||||
'quirks',
|
||||
'scrollbar',
|
||||
'theme',
|
||||
'vtExtensions',
|
||||
'windowOptions',
|
||||
@@ -251,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
input.value = 'auto';
|
||||
this._handlers.updateTerminalSize();
|
||||
}
|
||||
const screenElement = document.querySelector('.xterm-screen');
|
||||
if (screenElement instanceof HTMLElement) {
|
||||
screenElement.style.outline = this._autoResize ? ''
|
||||
: 'solid thin red';
|
||||
}
|
||||
} else if (o === 'theme') {
|
||||
value = this._getTheme();
|
||||
}
|
||||
|
||||
@@ -705,13 +705,13 @@ function loadTest(term: Terminal, addons: AddonCollection): void {
|
||||
term.write('', () => {
|
||||
const time = Math.round(performance.now() - start);
|
||||
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
||||
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
|
||||
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
|
||||
// Send ^C to get a new prompt
|
||||
(term as any)._core._onData.fire('\x03');
|
||||
});
|
||||
}
|
||||
|
||||
function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
||||
async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise<void> {
|
||||
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
|
||||
const testData = [];
|
||||
let byteCount = 0;
|
||||
@@ -729,16 +729,23 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
||||
testData.push(data);
|
||||
}
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1024 * 50; i++) {
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
for (const d of testData) {
|
||||
term.write(d);
|
||||
try {
|
||||
term.write(d);
|
||||
} catch {
|
||||
// Flush events when cap is hit and try again(workaround for not having flow control in
|
||||
// demo)
|
||||
await new Promise<void>(r => term.write('', () => r()));
|
||||
term.write(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wait for all data to be parsed before evaluating time
|
||||
term.write('', () => {
|
||||
const time = Math.round(performance.now() - start);
|
||||
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
||||
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
|
||||
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
|
||||
// Send ^C to get a new prompt
|
||||
(term as any)._core._onData.fire('\x03');
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ export default tseslint.config(
|
||||
singleline: { delimiter: 'comma', requireLast: false }
|
||||
}],
|
||||
'@stylistic/type-annotation-spacing': 'warn',
|
||||
'@stylistic/space-unary-ops': 'warn',
|
||||
|
||||
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
@@ -85,6 +86,7 @@ export default tseslint.config(
|
||||
'eqeqeq': ['warn', 'always'],
|
||||
'jsdoc/check-alignment': 'warn',
|
||||
'jsdoc/check-param-names': 'warn',
|
||||
'jsdoc/multiline-blocks': 'warn',
|
||||
'jsdoc/no-multi-asterisks': 'warn',
|
||||
'keyword-spacing': 'warn',
|
||||
'max-len': ['warn', {
|
||||
|
||||
+5
-5
@@ -2,17 +2,17 @@
|
||||
|
||||
⚠ This package is experimental
|
||||
|
||||
`xterm-headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
|
||||
`@xterm/headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/@xterm/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, you need to install the module, we ship exclusively through npm, so you need that installed and then add xterm.js as a dependency by running:
|
||||
|
||||
```sh
|
||||
npm install xterm-headless
|
||||
npm install @xterm/headless
|
||||
```
|
||||
|
||||
Then import as you would a regular node package. The recommended way to load `xterm-headless` is with TypeScript and the ES6 module syntax:
|
||||
Then import as you would a regular node package. The recommended way to load `@xterm/headless` is with TypeScript and the ES6 module syntax:
|
||||
|
||||
```javascript
|
||||
import { Terminal } from '@xterm/headless';
|
||||
@@ -20,12 +20,12 @@ import { Terminal } from '@xterm/headless';
|
||||
|
||||
## API
|
||||
|
||||
The full API for `xterm-headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
|
||||
The full API for `@xterm/headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
|
||||
|
||||
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
|
||||
|
||||
### Addons
|
||||
|
||||
Addons in `xterm-headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
|
||||
Addons in `@xterm/headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
|
||||
|
||||
Currently no official addons are packaged on npm.
|
||||
|
||||
Generated
+132
-794
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -29,6 +29,7 @@
|
||||
"scripts": {
|
||||
"presetup": "npm run build",
|
||||
"setup": "npm run esbuild",
|
||||
"setup-full": "node bin/setup-full.mjs",
|
||||
"postsetup": "npm run esbuild-demo-server",
|
||||
"start": "node demo/start",
|
||||
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
|
||||
@@ -55,7 +56,6 @@
|
||||
"test-unit": "node ./bin/test_unit.js",
|
||||
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
|
||||
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
|
||||
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
|
||||
"test-integration": "node ./bin/test_integration.js --workers=75%",
|
||||
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
|
||||
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
|
||||
@@ -65,30 +65,25 @@
|
||||
"benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
|
||||
"benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
|
||||
"clean": "rm -rf lib out addons/*/lib addons/*/out",
|
||||
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
|
||||
"vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts",
|
||||
"prepackage": "npm run build",
|
||||
"package": "webpack",
|
||||
"postpackage": "npm run esbuild-package",
|
||||
"prepackage-headless": "npm run esbuild-package-headless-only",
|
||||
"package-headless": "webpack --config ./webpack.config.headless.js",
|
||||
"postpackage-headless": "node ./bin/package_headless.js",
|
||||
"prepublishOnly": "npm run package",
|
||||
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lunapaint/png-codec": "^0.2.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@stylistic/eslint-plugin": "^4.4.1",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/express": "4",
|
||||
"@types/express-ws": "^3.0.1",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/trusted-types": "^1.0.6",
|
||||
"@types/utf8": "^3.0.0",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/ws": "^8.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
@@ -96,8 +91,6 @@
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
|
||||
"chai": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"deep-equal": "^2.0.5",
|
||||
"esbuild": "~0.25.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-jsdoc": "^50.8.0",
|
||||
@@ -113,7 +106,6 @@
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"utf8": "^3.0.0",
|
||||
"webpack": "^5.61.0",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"ws": "^8.2.3",
|
||||
|
||||
@@ -12,7 +12,9 @@ import { IBuffer } from 'common/buffer/Types';
|
||||
import { IInstantiationService } from 'common/services/Services';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
const MAX_ROWS_TO_READ = 20;
|
||||
const enum Constants {
|
||||
MAX_ROWS_TO_READ = 20
|
||||
}
|
||||
|
||||
const enum BoundaryPosition {
|
||||
TOP,
|
||||
@@ -137,7 +139,7 @@ export class AccessibilityManager extends Disposable {
|
||||
}
|
||||
|
||||
private _handleChar(char: string): void {
|
||||
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
|
||||
if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) {
|
||||
if (this._charsToConsume.length > 0) {
|
||||
// Have the screen reader ignore the char if it was just input
|
||||
const shiftedChar = this._charsToConsume.shift();
|
||||
@@ -150,7 +152,7 @@ export class AccessibilityManager extends Disposable {
|
||||
|
||||
if (char === '\n') {
|
||||
this._liveRegionLineCount++;
|
||||
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
|
||||
if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) {
|
||||
this._liveRegion.textContent = Strings.tooMuchOutput.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +36,17 @@ import { CharSizeService } from 'browser/services/CharSizeService';
|
||||
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
|
||||
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
|
||||
import { LinkProviderService } from 'browser/services/LinkProviderService';
|
||||
import { MouseCoordsService } from 'browser/services/MouseCoordsService';
|
||||
import { MouseService } from 'browser/services/MouseService';
|
||||
import { RenderService } from 'browser/services/RenderService';
|
||||
import { SelectionService } from 'browser/services/SelectionService';
|
||||
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
|
||||
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
|
||||
import { ThemeService } from 'browser/services/ThemeService';
|
||||
import { KeyboardService } from 'browser/services/KeyboardService';
|
||||
import { channels, color, rgb } from 'common/Color';
|
||||
import { CoreTerminal } from 'common/CoreTerminal';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
|
||||
import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
|
||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
import { C0, C1ESCAPED } from 'common/data/EscapeSequences';
|
||||
@@ -77,7 +78,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
public browser: IBrowser = Browser as any;
|
||||
|
||||
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
|
||||
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
|
||||
|
||||
// Browser services
|
||||
private readonly _decorationService: DecorationService;
|
||||
@@ -87,6 +87,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
// Optional browser services
|
||||
private _charSizeService: ICharSizeService | undefined;
|
||||
private _coreBrowserService: ICoreBrowserService | undefined;
|
||||
private _mouseCoordsService: IMouseCoordsService | undefined;
|
||||
private _mouseService: IMouseService | undefined;
|
||||
private _renderService: IRenderService | undefined;
|
||||
private _themeService: IThemeService | undefined;
|
||||
@@ -413,7 +414,16 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
|
||||
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
|
||||
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
|
||||
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => this._compositionHelper!.compositionstart()));
|
||||
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => {
|
||||
// Ensure the textarea is synced to the latest cursor location before composition begins. This
|
||||
// is to workaround a problem where highly dynamic TUIs like agentic CLIs reprint agressively
|
||||
// would cause the IME to appear in the wrong position. The theory is that when the IME is
|
||||
// triggered during a partial render the textarea position becomes locked and will not move
|
||||
// until it is hidden and a custom move occurs.
|
||||
this._syncTextArea();
|
||||
this._compositionHelper!.compositionstart();
|
||||
this._compositionHelper!.updateCompositionElements();
|
||||
}));
|
||||
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
||||
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
||||
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
||||
@@ -543,8 +553,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
||||
this._helperContainer.appendChild(this._compositionView);
|
||||
|
||||
this._mouseService = this._instantiationService.createInstance(MouseService);
|
||||
this._instantiationService.setService(IMouseService, this._mouseService);
|
||||
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
|
||||
this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
|
||||
|
||||
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
|
||||
|
||||
@@ -553,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
try {
|
||||
this._onWillOpen.fire(this.element);
|
||||
} catch (e) {
|
||||
this._logService.error('onWillOpen handler threw an exception', e);
|
||||
}
|
||||
catch { /* fails to load addon for some reason */ }
|
||||
if (!this._renderService.hasRenderer()) {
|
||||
this._renderService.setRenderer(this._createRenderer());
|
||||
}
|
||||
@@ -563,7 +574,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._renderService!.handleCursorMove();
|
||||
this._syncTextArea();
|
||||
}));
|
||||
this._register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows)));
|
||||
this._register(this.onResize(() => {
|
||||
this._renderService!.handleResize(this.cols, this.rows);
|
||||
this._syncTextArea();
|
||||
}));
|
||||
this._register(this.onBlur(() => this._renderService!.handleBlur()));
|
||||
this._register(this.onFocus(() => this._renderService!.handleFocus()));
|
||||
|
||||
@@ -579,6 +593,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
linkifier
|
||||
));
|
||||
this._instantiationService.setService(ISelectionService, this._selectionService);
|
||||
this._mouseService = this._instantiationService.createInstance(MouseService);
|
||||
this._instantiationService.setService(IMouseService, this._mouseService);
|
||||
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
|
||||
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
|
||||
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
|
||||
@@ -602,7 +618,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
|
||||
|
||||
// apply mouse event classes set by escape codes before terminal was attached
|
||||
if (this.coreMouseService.areMouseEventsActive) {
|
||||
if (this.mouseStateService.areMouseEventsActive) {
|
||||
this._selectionService.disable();
|
||||
this.element.classList.add('enable-mouse-events');
|
||||
} else {
|
||||
@@ -638,285 +654,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
// Listen for mouse events and translate
|
||||
// them into terminal mouse protocols.
|
||||
this.bindMouse();
|
||||
this._mouseService.bindMouse({
|
||||
element: this.element!,
|
||||
screenElement: this.screenElement!,
|
||||
document: this._document!,
|
||||
handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
|
||||
}, disposable => this._register(disposable), () => this.focus());
|
||||
}
|
||||
|
||||
private _createRenderer(): IRenderer {
|
||||
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind certain mouse events to the terminal.
|
||||
* By default only 3 button + wheel up/down is ativated. For higher buttons
|
||||
* no mouse report will be created. Typically the standard actions will be active.
|
||||
*
|
||||
* There are several reasons not to enable support for higher buttons/wheel:
|
||||
* - Button 4 and 5 are typically used for history back and forward navigation,
|
||||
* there is no straight forward way to supress/intercept those standard actions.
|
||||
* - Support for higher buttons does not work in some platform/browser combinations.
|
||||
* - Left/right wheel was not tested.
|
||||
* - Emulators vary in mouse button support, typically only 3 buttons and
|
||||
* wheel up/down work reliable.
|
||||
*
|
||||
* TODO: Move mouse event code into its own file.
|
||||
*/
|
||||
public bindMouse(): void {
|
||||
const self = this;
|
||||
const el = this.element!;
|
||||
|
||||
// send event to CoreMouseService
|
||||
function sendEvent(ev: MouseEvent | WheelEvent): boolean {
|
||||
// Get mouse coordinates
|
||||
const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!);
|
||||
if (!pos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let but: CoreMouseButton;
|
||||
let action: CoreMouseAction | undefined;
|
||||
switch ((ev as any).overrideType || ev.type) {
|
||||
case 'mousemove':
|
||||
action = CoreMouseAction.MOVE;
|
||||
if (ev.buttons === undefined) {
|
||||
// buttons is not supported on macOS, try to get a value from button instead
|
||||
but = CoreMouseButton.NONE;
|
||||
if (ev.button !== undefined) {
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
}
|
||||
} else {
|
||||
// according to MDN buttons only reports up to button 5 (AUX2)
|
||||
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
|
||||
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
|
||||
ev.buttons & 2 ? CoreMouseButton.RIGHT :
|
||||
CoreMouseButton.NONE; // fallback to NONE
|
||||
}
|
||||
break;
|
||||
case 'mouseup':
|
||||
action = CoreMouseAction.UP;
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
break;
|
||||
case 'mousedown':
|
||||
action = CoreMouseAction.DOWN;
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
break;
|
||||
case 'wheel':
|
||||
if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) {
|
||||
return false;
|
||||
}
|
||||
const deltaY = (ev as WheelEvent).deltaY;
|
||||
if (deltaY === 0) {
|
||||
return false;
|
||||
}
|
||||
const lines = self.coreMouseService.consumeWheelEvent(
|
||||
ev as WheelEvent,
|
||||
self._renderService?.dimensions?.device?.cell?.height,
|
||||
self._coreBrowserService?.dpr
|
||||
);
|
||||
if (lines === 0) {
|
||||
return false;
|
||||
}
|
||||
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
|
||||
but = CoreMouseButton.WHEEL;
|
||||
break;
|
||||
default:
|
||||
// dont handle other event types by accident
|
||||
return false;
|
||||
}
|
||||
|
||||
// exit if we cannot determine valid button/action values
|
||||
// do nothing for higher buttons than wheel
|
||||
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self.coreMouseService.triggerMouseEvent({
|
||||
col: pos.col,
|
||||
row: pos.row,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: but,
|
||||
action,
|
||||
ctrl: ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener state handling.
|
||||
* We listen to the onProtocolChange event of CoreMouseService and put
|
||||
* requested listeners in `requestedEvents`. With this the listeners
|
||||
* have all bits to do the event listener juggling.
|
||||
* Note: 'mousedown' currently is "always on" and not managed
|
||||
* by onProtocolChange.
|
||||
*/
|
||||
const requestedEvents: { [key: string]: ((ev: MouseEvent | WheelEvent) => void) | null } = {
|
||||
mouseup: null,
|
||||
wheel: null,
|
||||
mousedrag: null,
|
||||
mousemove: null
|
||||
};
|
||||
const eventListeners: { [key: string]: (ev: any) => void | boolean } = {
|
||||
mouseup: (ev: MouseEvent) => {
|
||||
sendEvent(ev);
|
||||
if (!ev.buttons) {
|
||||
// if no other button is held remove global handlers
|
||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
|
||||
if (requestedEvents.mousedrag) {
|
||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
},
|
||||
wheel: (ev: WheelEvent) => {
|
||||
sendEvent(ev);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
mousedrag: (ev: MouseEvent) => {
|
||||
// deal only with move while a button is held
|
||||
if (ev.buttons) {
|
||||
sendEvent(ev);
|
||||
}
|
||||
},
|
||||
mousemove: (ev: MouseEvent) => {
|
||||
// deal only with move without any button
|
||||
if (!ev.buttons) {
|
||||
sendEvent(ev);
|
||||
}
|
||||
}
|
||||
};
|
||||
this._register(this.coreMouseService.onProtocolChange(events => {
|
||||
// apply global changes on events
|
||||
if (events) {
|
||||
if (this.optionsService.rawOptions.logLevel === 'debug') {
|
||||
this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events));
|
||||
}
|
||||
this.element!.classList.add('enable-mouse-events');
|
||||
this._selectionService!.disable();
|
||||
} else {
|
||||
this._logService.debug('Unbinding from mouse events.');
|
||||
this.element!.classList.remove('enable-mouse-events');
|
||||
this._selectionService!.enable();
|
||||
}
|
||||
|
||||
// add/remove handlers from requestedEvents
|
||||
|
||||
if (!(events & CoreMouseEventType.MOVE)) {
|
||||
el.removeEventListener('mousemove', requestedEvents.mousemove!);
|
||||
requestedEvents.mousemove = null;
|
||||
} else if (!requestedEvents.mousemove) {
|
||||
el.addEventListener('mousemove', eventListeners.mousemove);
|
||||
requestedEvents.mousemove = eventListeners.mousemove;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.WHEEL)) {
|
||||
el.removeEventListener('wheel', requestedEvents.wheel!);
|
||||
requestedEvents.wheel = null;
|
||||
} else if (!requestedEvents.wheel) {
|
||||
el.addEventListener('wheel', eventListeners.wheel, { passive: false });
|
||||
requestedEvents.wheel = eventListeners.wheel;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.UP)) {
|
||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
|
||||
requestedEvents.mouseup = null;
|
||||
} else {
|
||||
requestedEvents.mouseup ??= eventListeners.mouseup;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.DRAG)) {
|
||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!);
|
||||
requestedEvents.mousedrag = null;
|
||||
} else {
|
||||
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
||||
}
|
||||
}));
|
||||
// force initial onProtocolChange so we dont miss early mouse requests
|
||||
this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol;
|
||||
|
||||
// Ensure document-level listeners are removed on dispose
|
||||
this._register(toDisposable(() => {
|
||||
if (requestedEvents.mouseup) {
|
||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
if (requestedEvents.mousedrag) {
|
||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* "Always on" event listeners.
|
||||
*/
|
||||
this._register(addDisposableListener(el, 'mousedown', (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
this.focus();
|
||||
|
||||
// Don't send the mouse button to the pty if mouse events are disabled or
|
||||
// if the selection manager is having selection forced (ie. a modifier is
|
||||
// held).
|
||||
if (!this.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendEvent(ev);
|
||||
|
||||
// Register additional global handlers which should keep reporting outside
|
||||
// of the terminal element.
|
||||
// Note: Other emulators also do this for 'mousedown' while a button
|
||||
// is held, we currently limit 'mousedown' to the terminal only.
|
||||
if (requestedEvents.mouseup) {
|
||||
this._document!.addEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
if (requestedEvents.mousedrag) {
|
||||
this._document!.addEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(addDisposableListener(el, 'wheel', (ev: WheelEvent) => {
|
||||
// do nothing, if app side handles wheel itself
|
||||
if (requestedEvents.wheel) return;
|
||||
|
||||
if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.buffer.hasScrollback) {
|
||||
// Convert wheel events into up/down events when the buffer does not have scrollback, this
|
||||
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
|
||||
// events are not enabled.
|
||||
// This used implementation used get the actual lines/partial lines scrolled from the
|
||||
// viewport but since moving to the new viewport implementation has been simplified to
|
||||
// simply send a single up or down sequence.
|
||||
|
||||
// Do nothing if there's no vertical scroll
|
||||
const deltaY = (ev as WheelEvent).deltaY;
|
||||
if (deltaY === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = self.coreMouseService.consumeWheelEvent(
|
||||
ev as WheelEvent,
|
||||
self._renderService?.dimensions?.device?.cell?.height,
|
||||
self._coreBrowserService?.dpr
|
||||
);
|
||||
if (lines === 0) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Construct and send sequences
|
||||
const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
|
||||
this.coreService.triggerDataEvent(sequence, true);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}, { passive: false }));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
|
||||
* opportunity.
|
||||
@@ -990,7 +739,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
}
|
||||
|
||||
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
|
||||
this._customWheelEventHandler = customWheelEventHandler;
|
||||
this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
|
||||
}
|
||||
|
||||
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
|
||||
@@ -1360,6 +1109,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
this._setup();
|
||||
super.reset();
|
||||
this._mouseService?.reset();
|
||||
this._selectionService?.reset();
|
||||
this._decorationService.reset();
|
||||
|
||||
|
||||
@@ -43,6 +43,20 @@ describe('Linkifier2', () => {
|
||||
},
|
||||
activate: () => { }
|
||||
};
|
||||
const multiLineLink: ILink = {
|
||||
text: 'foo',
|
||||
range: {
|
||||
start: {
|
||||
x: 2,
|
||||
y: 1
|
||||
},
|
||||
end: {
|
||||
x: 4,
|
||||
y: 2
|
||||
}
|
||||
},
|
||||
activate: () => { }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new jsdom.JSDOM();
|
||||
@@ -86,4 +100,30 @@ describe('Linkifier2', () => {
|
||||
linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any);
|
||||
});
|
||||
|
||||
it('onShowLinkUnderline event range is correct for wrapped links', done => {
|
||||
linkifier.onShowLinkUnderline(e => {
|
||||
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||
});
|
||||
|
||||
it('onHideLinkUnderline event range is correct for wrapped links', done => {
|
||||
linkifier.onHideLinkUnderline(e => {
|
||||
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifie
|
||||
import { Disposable, dispose, toDisposable } from 'common/Lifecycle';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { IBufferService } from 'common/services/Services';
|
||||
import { ILinkProviderService, IMouseService, IRenderService } from './services/Services';
|
||||
import { ILinkProviderService, IMouseCoordsService, IRenderService } from './services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
@@ -30,7 +30,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
|
||||
constructor(
|
||||
private readonly _element: HTMLElement,
|
||||
@IMouseService private readonly _mouseService: IMouseService,
|
||||
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
|
||||
@IRenderService private readonly _renderService: IRenderService,
|
||||
@IBufferService private readonly _bufferService: IBufferService,
|
||||
@ILinkProviderService private readonly _linkProviderService: ILinkProviderService
|
||||
@@ -60,7 +60,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
private _handleMouseMove(event: MouseEvent): void {
|
||||
this._lastMouseEvent = event;
|
||||
|
||||
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
|
||||
const position = this._positionFromMouseEvent(event, this._element);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
|
||||
const position = this._positionFromMouseEvent(event, this._element);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
|
||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
|
||||
|
||||
if (!position) {
|
||||
return;
|
||||
@@ -312,7 +312,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
this._clearCurrentLink(start, end);
|
||||
if (this._lastMouseEvent) {
|
||||
// re-eval previously active link after changes
|
||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!);
|
||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
|
||||
if (position) {
|
||||
this._askForLink(position, false);
|
||||
}
|
||||
@@ -378,8 +378,8 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
* Get the buffer position from a mouse event
|
||||
* @param event
|
||||
*/
|
||||
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined {
|
||||
const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
|
||||
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined {
|
||||
const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
|
||||
if (!coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { OscLinkProvider } from 'browser/OscLinkProvider';
|
||||
import { ILink } from 'browser/Types';
|
||||
import { createCellData, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
|
||||
import { IBufferService, IOscLinkService } from 'common/services/Services';
|
||||
import { IBufferLine, IOscLinkData } from 'common/Types';
|
||||
|
||||
class TestOscLinkService implements IOscLinkService {
|
||||
public serviceBrand: any;
|
||||
public registerLink(_linkData: IOscLinkData): number { return 0; }
|
||||
public addLineToLink(_linkId: number, _y: number): void { }
|
||||
public getLinkData(linkId: number): IOscLinkData | undefined {
|
||||
return { uri: `https://example.com/${linkId}` };
|
||||
}
|
||||
}
|
||||
|
||||
function setText(line: IBufferLine | undefined, x: number, text: string): void {
|
||||
if (!line) {
|
||||
throw new Error('Missing buffer line');
|
||||
}
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
line.setCell(x + i, createCellData(0, text[i], 1));
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void {
|
||||
if (!line) {
|
||||
throw new Error('Missing buffer line');
|
||||
}
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const cell = createCellData(0, text[i], 1);
|
||||
cell.extended.urlId = linkId;
|
||||
cell.updateExtended();
|
||||
line.setCell(x + i, cell);
|
||||
}
|
||||
}
|
||||
|
||||
function getLinks(provider: OscLinkProvider, y: number): Promise<ILink[]> {
|
||||
return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? [])));
|
||||
}
|
||||
|
||||
describe('OscLinkProvider', () => {
|
||||
let bufferService: IBufferService;
|
||||
let provider: OscLinkProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
const optionsService = new MockOptionsService();
|
||||
bufferService = new MockBufferService(5, 5, optionsService);
|
||||
provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService());
|
||||
});
|
||||
|
||||
it('expands a wrapped link range backward to the previous line', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setText(line1, 0, 'aa');
|
||||
setUrl(line1, 2, 'bbb', 1);
|
||||
setUrl(line2, 0, 'cccc', 1);
|
||||
setText(line2, 4, 'x');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 2);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 3, y: 1 },
|
||||
end: { x: 4, y: 2 }
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a wrapped link range forward when a link ends at line boundary', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setUrl(line1, 0, 'aaaaa', 1);
|
||||
setUrl(line2, 0, 'bb', 1);
|
||||
setText(line2, 2, 'ccc');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 1);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 2, y: 2 }
|
||||
});
|
||||
});
|
||||
|
||||
it('does not merge wrapped links with different url ids', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setUrl(line1, 0, 'aaaaa', 1);
|
||||
setUrl(line2, 0, 'bbb', 2);
|
||||
setText(line2, 3, 'cc');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 1);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 5, y: 1 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { IBufferRange, ILink } from 'browser/Types';
|
||||
import { ILinkProvider } from 'browser/services/Services';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
|
||||
|
||||
export class OscLinkProvider implements ILinkProvider {
|
||||
@@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider {
|
||||
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
|
||||
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
|
||||
if (text) {
|
||||
// These ranges are 1-based
|
||||
const range: IBufferRange = {
|
||||
start: {
|
||||
x: currentStart + 1,
|
||||
y
|
||||
},
|
||||
end: {
|
||||
// Offset end x if it's a link that ends on the last cell in the line
|
||||
x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
|
||||
y
|
||||
}
|
||||
};
|
||||
|
||||
const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0);
|
||||
const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId);
|
||||
let ignoreLink = false;
|
||||
if (!linkHandler?.allowNonHttpProtocols) {
|
||||
try {
|
||||
@@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider {
|
||||
// id
|
||||
callback(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id.
|
||||
*/
|
||||
private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange {
|
||||
let startY = y;
|
||||
let finalStartX = startX;
|
||||
let endY = y;
|
||||
let finalEndX = endX;
|
||||
|
||||
// Expand upward only when this segment starts at column 0 and the current line is wrapped.
|
||||
while (finalStartX === 0) {
|
||||
const currentLine = this._bufferService.buffer.lines.get(startY - 1);
|
||||
if (!currentLine?.isWrapped) {
|
||||
break;
|
||||
}
|
||||
const previousLine = this._bufferService.buffer.lines.get(startY - 2);
|
||||
if (!previousLine) {
|
||||
break;
|
||||
}
|
||||
const previousLineLength = previousLine.getTrimmedLength();
|
||||
if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) {
|
||||
break;
|
||||
}
|
||||
let previousStartX = previousLineLength - 1;
|
||||
while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) {
|
||||
previousStartX--;
|
||||
}
|
||||
startY--;
|
||||
finalStartX = previousStartX;
|
||||
}
|
||||
|
||||
// Expand downward only when this segment reaches trimmed EOL and the next line is wrapped.
|
||||
while (true) {
|
||||
const currentLine = this._bufferService.buffer.lines.get(endY - 1);
|
||||
if (!currentLine) {
|
||||
break;
|
||||
}
|
||||
const currentLineLength = currentLine.getTrimmedLength();
|
||||
if (finalEndX !== currentLineLength) {
|
||||
break;
|
||||
}
|
||||
const nextLine = this._bufferService.buffer.lines.get(endY);
|
||||
if (!nextLine?.isWrapped) {
|
||||
break;
|
||||
}
|
||||
const nextLineLength = nextLine.getTrimmedLength();
|
||||
if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) {
|
||||
break;
|
||||
}
|
||||
let nextEndX = 1;
|
||||
while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) {
|
||||
nextEndX++;
|
||||
}
|
||||
endY++;
|
||||
finalEndX = nextEndX;
|
||||
}
|
||||
|
||||
// IBufferRange uses 1-based coordinates.
|
||||
return {
|
||||
start: {
|
||||
x: finalStartX + 1,
|
||||
y: startY
|
||||
},
|
||||
end: {
|
||||
x: finalEndX,
|
||||
y: endY
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean {
|
||||
const cell = this._workCell;
|
||||
line.loadCell(x, cell);
|
||||
return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultActivate(e: MouseEvent, uri: string): void {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IBrowser } from 'browser/Types';
|
||||
import { assert } from 'chai';
|
||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { MockUnicodeService } from 'common/TestUtils.test';
|
||||
import { MockUnicodeService, createCellData } from 'common/TestUtils.test';
|
||||
import { IMarker } from 'common/Types';
|
||||
|
||||
const INIT_COLS = 80;
|
||||
@@ -440,8 +440,8 @@ describe('Terminal', () => {
|
||||
describe('scroll() function', () => {
|
||||
describe('when scrollback > 0', () => {
|
||||
it('should create a new line and scroll', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
assert.equal(term.buffer.lines.length, INIT_ROWS + 1);
|
||||
@@ -451,9 +451,9 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
term.buffer.scrollTop = 1;
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
@@ -463,11 +463,11 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||
term.buffer.y = 3;
|
||||
term.buffer.scrollBottom = 3;
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
@@ -481,11 +481,11 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
term.buffer.scrollTop = 1;
|
||||
term.buffer.scrollBottom = 3;
|
||||
@@ -506,9 +506,9 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should create a new line and shift everything up', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
assert.equal(term.buffer.lines.length, INIT_ROWS);
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
@@ -521,9 +521,9 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
term.buffer.scrollTop = 1;
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
@@ -533,11 +533,11 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||
term.buffer.y = 3;
|
||||
term.buffer.scrollBottom = 3;
|
||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||
@@ -550,11 +550,11 @@ describe('Terminal', () => {
|
||||
});
|
||||
|
||||
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||
term.buffer.scrollTop = 1;
|
||||
term.buffer.scrollBottom = 3;
|
||||
@@ -871,7 +871,7 @@ describe('Terminal', () => {
|
||||
assert.equal(cell.getWidth(), 1);
|
||||
});
|
||||
it('multiple combined é', async () => {
|
||||
await term.writeP(Array(100).join('e\u0301'));
|
||||
await term.writeP('e\u0301'.repeat(99));
|
||||
for (let i = 0; i < term.cols; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
assert.equal(cell.getChars(), 'e\u0301');
|
||||
@@ -884,7 +884,7 @@ describe('Terminal', () => {
|
||||
assert.equal(cell.getWidth(), 1);
|
||||
});
|
||||
it('multiple surrogate with combined', async () => {
|
||||
await term.writeP(Array(100).join('\uD800\uDC00\u0301'));
|
||||
await term.writeP('\uD800\uDC00\u0301'.repeat(99));
|
||||
for (let i = 0; i < term.cols; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
assert.equal(cell.getChars(), '\uD800\uDC00\u0301');
|
||||
@@ -912,7 +912,7 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.x, 3);
|
||||
});
|
||||
it('line of ¥ even', async () => {
|
||||
await term.writeP(Array(50).join('¥'));
|
||||
await term.writeP('¥'.repeat(49));
|
||||
for (let i = 0; i < term.cols; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (i % 2) {
|
||||
@@ -932,7 +932,7 @@ describe('Terminal', () => {
|
||||
});
|
||||
it('line of ¥ odd', async () => {
|
||||
term.buffer.x = 1;
|
||||
await term.writeP(Array(50).join('¥'));
|
||||
await term.writeP('¥'.repeat(49));
|
||||
for (let i = 1; i < term.cols - 1; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (!(i % 2)) {
|
||||
@@ -956,7 +956,7 @@ describe('Terminal', () => {
|
||||
});
|
||||
it('line of ¥ with combining odd', async () => {
|
||||
term.buffer.x = 1;
|
||||
await term.writeP(Array(50).join('¥\u0301'));
|
||||
await term.writeP('¥\u0301'.repeat(49));
|
||||
for (let i = 1; i < term.cols - 1; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (!(i % 2)) {
|
||||
@@ -979,7 +979,7 @@ describe('Terminal', () => {
|
||||
assert.equal(cell.getWidth(), 2);
|
||||
});
|
||||
it('line of ¥ with combining even', async () => {
|
||||
await term.writeP(Array(50).join('¥\u0301'));
|
||||
await term.writeP('¥\u0301'.repeat(49));
|
||||
for (let i = 0; i < term.cols; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (i % 2) {
|
||||
@@ -999,7 +999,7 @@ describe('Terminal', () => {
|
||||
});
|
||||
it('line of surrogate fullwidth with combining odd', async () => {
|
||||
term.buffer.x = 1;
|
||||
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
|
||||
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
|
||||
for (let i = 1; i < term.cols - 1; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (!(i % 2)) {
|
||||
@@ -1022,7 +1022,7 @@ describe('Terminal', () => {
|
||||
assert.equal(cell.getWidth(), 2);
|
||||
});
|
||||
it('line of surrogate fullwidth with combining even', async () => {
|
||||
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
|
||||
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
|
||||
for (let i = 0; i < term.cols; ++i) {
|
||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||
if (i % 2) {
|
||||
@@ -1045,7 +1045,7 @@ describe('Terminal', () => {
|
||||
describe('insert mode', () => {
|
||||
const cell = new CellData();
|
||||
it('halfwidth - all', async () => {
|
||||
await term.writeP(Array(9).join('0123456789').slice(-80));
|
||||
await term.writeP('0123456789'.repeat(8).slice(-80));
|
||||
term.buffer.x = 10;
|
||||
term.buffer.y = 0;
|
||||
term.write('\x1b[4h');
|
||||
@@ -1057,7 +1057,7 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '4');
|
||||
});
|
||||
it('fullwidth - insert', async () => {
|
||||
await term.writeP(Array(9).join('0123456789').slice(-80));
|
||||
await term.writeP('0123456789'.repeat(8).slice(-80));
|
||||
term.buffer.x = 10;
|
||||
term.buffer.y = 0;
|
||||
term.write('\x1b[4h');
|
||||
@@ -1070,7 +1070,7 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '3');
|
||||
});
|
||||
it('fullwidth - right border', async () => {
|
||||
await term.writeP(Array(41).join('¥'));
|
||||
await term.writeP('¥'.repeat(40));
|
||||
term.buffer.x = 10;
|
||||
term.buffer.y = 0;
|
||||
term.write('\x1b[4h');
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, I
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { CoreBrowserTerminal } from 'browser/CoreBrowserTerminal';
|
||||
import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService } from 'common/services/Services';
|
||||
import { IUnicodeService, IOptionsService, ICoreService, IMouseStateService } from 'common/services/Services';
|
||||
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
|
||||
@@ -51,7 +51,7 @@ export class MockTerminal implements ITerminal {
|
||||
public dimensions: IRenderDimensionsApi | undefined;
|
||||
public markers!: IMarker[];
|
||||
public linkifier: ILinkifier2 | undefined;
|
||||
public coreMouseService!: ICoreMouseService;
|
||||
public mouseStateService!: IMouseStateService;
|
||||
public coreService!: ICoreService;
|
||||
public optionsService!: IOptionsService;
|
||||
public unicodeService!: IUnicodeService;
|
||||
@@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal {
|
||||
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
|
||||
@@ -258,6 +258,15 @@ export class MockBuffer implements IBuffer {
|
||||
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
|
||||
return Buffer.prototype.getBlankLine.apply(this, arguments as any);
|
||||
}
|
||||
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
|
||||
return Buffer.prototype.attachWrappedRow.apply(this, arguments as any);
|
||||
}
|
||||
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void {
|
||||
Buffer.prototype.clearWrappedRow.apply(this, arguments as any);
|
||||
}
|
||||
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
|
||||
return Buffer.prototype.createScrollWrappedLine.apply(this, arguments as any);
|
||||
}
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
@@ -392,6 +401,9 @@ export class MockMouseService implements IMouseService {
|
||||
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public bindMouse(): void { }
|
||||
public reset(): void { }
|
||||
}
|
||||
|
||||
export class MockRenderService implements IRenderService {
|
||||
|
||||
+24
-12
@@ -6,14 +6,13 @@
|
||||
import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
import { ViewportConstants } from 'browser/shared/Constants';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services';
|
||||
import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services';
|
||||
import { CoreMouseEventType } from 'common/Types';
|
||||
import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom';
|
||||
import { scheduleAtNextAnimationFrame } from 'browser/Dom';
|
||||
import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement';
|
||||
import type { IScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
import { Scrollable, ScrollbarVisibility, type IScrollEvent } from 'browser/scrollable/scrollable';
|
||||
import { Gesture, EventType as GestureEventType, type IGestureEvent } from 'browser/scrollable/touch';
|
||||
|
||||
export class Viewport extends Disposable {
|
||||
|
||||
@@ -28,13 +27,15 @@ export class Viewport extends Disposable {
|
||||
private _isSyncing: boolean = false;
|
||||
private _isHandlingScroll: boolean = false;
|
||||
private _suppressOnScrollHandler: boolean = false;
|
||||
private _needsSyncOnRender: boolean = false;
|
||||
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
screenElement: HTMLElement,
|
||||
@IBufferService private readonly _bufferService: IBufferService,
|
||||
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
|
||||
@ICoreMouseService coreMouseService: ICoreMouseService,
|
||||
@ICoreService private readonly _coreService: ICoreService,
|
||||
@IMouseStateService mouseStateService: IMouseStateService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||
@IRenderService private readonly _renderService: IRenderService
|
||||
@@ -65,7 +66,7 @@ export class Viewport extends Disposable {
|
||||
'scrollbar'
|
||||
], () => this._scrollableElement.updateOptions(this._getChangeOptions())));
|
||||
// Don't handle mouse wheel if wheel events are supported by the current mouse prototcol
|
||||
this._register(coreMouseService.onProtocolChange(type => {
|
||||
this._register(mouseStateService.onProtocolChange(type => {
|
||||
this._scrollableElement.updateOptions({
|
||||
handleMouseWheel: !(type & CoreMouseEventType.WHEEL)
|
||||
});
|
||||
@@ -105,11 +106,18 @@ export class Viewport extends Disposable {
|
||||
}));
|
||||
this._register(this._bufferService.onScroll(() => this._sync()));
|
||||
|
||||
// Flush deferred viewport sync after a render completes (e.g. after ESU ends
|
||||
// synchronized output mode). This ensures DOM scroll position updates atomically
|
||||
// with the canvas render.
|
||||
this._register(this._renderService.onRender(() => {
|
||||
if (this._needsSyncOnRender) {
|
||||
this._needsSyncOnRender = false;
|
||||
this._sync();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._scrollableElement.onScroll(e => this._handleScroll(e)));
|
||||
|
||||
// Touch/gesture scrolling support
|
||||
this._register(Gesture.addTarget(screenElement));
|
||||
this._register(addDisposableListener(screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleGestureChange(e)));
|
||||
}
|
||||
|
||||
public scrollLines(disp: number): void {
|
||||
@@ -165,6 +173,12 @@ export class Viewport extends Disposable {
|
||||
if (!this._renderService || this._isSyncing) {
|
||||
return;
|
||||
}
|
||||
// Defer DOM scroll updates during synchronized output to prevent visible
|
||||
// scroll position flickering while the canvas content is frozen.
|
||||
if (this._coreService.decPrivateModes.synchronizedOutput) {
|
||||
this._needsSyncOnRender = true;
|
||||
return;
|
||||
}
|
||||
this._isSyncing = true;
|
||||
|
||||
// Ignore any onScroll event that happens as a result of dimensions changing as this should
|
||||
@@ -204,12 +218,10 @@ export class Viewport extends Disposable {
|
||||
this._isHandlingScroll = false;
|
||||
}
|
||||
|
||||
private _handleGestureChange(e: IGestureEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
public handleTouchScroll(translationY: number): void {
|
||||
const pos = this._scrollableElement.getScrollPosition();
|
||||
this._scrollableElement.setScrollPosition({
|
||||
scrollTop: pos.scrollTop - e.translationY
|
||||
scrollTop: pos.scrollTop - translationY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true)));
|
||||
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
|
||||
this._register(this._themeService.onChangeColors(() => this._queueRefresh()));
|
||||
this._register(toDisposable(() => {
|
||||
if (this._animationFrame !== undefined) {
|
||||
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
|
||||
this._animationFrame = undefined;
|
||||
}
|
||||
}));
|
||||
this._queueRefresh(true);
|
||||
}
|
||||
|
||||
@@ -136,6 +142,9 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _refreshCanvasDimensions(): void {
|
||||
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
|
||||
return;
|
||||
}
|
||||
const cssCanvasHeight = this._renderService.dimensions.css.canvas.height;
|
||||
const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height;
|
||||
this._canvas.style.width = `${this._width}px`;
|
||||
@@ -147,6 +156,9 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _refreshDecorations(): void {
|
||||
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldUpdateDimensions) {
|
||||
this._refreshCanvasDimensions();
|
||||
}
|
||||
@@ -200,13 +212,18 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._shouldUpdateDimensions = updateCanvasDimensions || this._shouldUpdateDimensions;
|
||||
this._shouldUpdateAnchor = updateAnchor || this._shouldUpdateAnchor;
|
||||
if (this._animationFrame !== undefined) {
|
||||
return;
|
||||
}
|
||||
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
|
||||
this._refreshDecorations();
|
||||
if (!this._store.isDisposed) {
|
||||
this._refreshDecorations();
|
||||
}
|
||||
this._animationFrame = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,5 +233,31 @@ describe('CompositionHelper', () => {
|
||||
}, 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
it('Should insert middle composition and subsequent input without appending existing trailing text', (done) => {
|
||||
textarea.value = '一二';
|
||||
// screenReaderMode keeps textarea content/selection for assistive technologies (eg. screen
|
||||
// readers), so the caret can be moved within the textarea (eg. via arrow keys) before
|
||||
// starting composition.
|
||||
textarea.selectionStart = 1;
|
||||
textarea.selectionEnd = 1;
|
||||
|
||||
compositionHelper.compositionstart();
|
||||
compositionHelper.compositionupdate({ data: '一' });
|
||||
textarea.value = '一一二';
|
||||
// After the composed text is inserted, the caret typically moves to after it.
|
||||
textarea.selectionStart = 2;
|
||||
textarea.selectionEnd = 2;
|
||||
|
||||
setTimeout(() => { // wait for any textarea updates
|
||||
compositionHelper.compositionend();
|
||||
// Second character '1' (a non-composition character)
|
||||
textarea.value = '一一1二';
|
||||
setTimeout(() => { // wait for any textarea updates
|
||||
assert.equal(handledText, '一1');
|
||||
done();
|
||||
}, 0);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,12 @@ export class CompositionHelper {
|
||||
*/
|
||||
private _compositionPosition: IPosition;
|
||||
|
||||
/**
|
||||
* Text that existed after the composing range when composition started.
|
||||
* This is used to avoid treating existing trailing text as new input.
|
||||
*/
|
||||
private _compositionSuffix: string;
|
||||
|
||||
/**
|
||||
* Whether a composition is in the process of being sent, setting this to false will cancel any
|
||||
* in-progress composition.
|
||||
@@ -57,6 +63,7 @@ export class CompositionHelper {
|
||||
this._isComposing = false;
|
||||
this._isSendingComposition = false;
|
||||
this._compositionPosition = { start: 0, end: 0 };
|
||||
this._compositionSuffix = '';
|
||||
this._dataAlreadySent = '';
|
||||
}
|
||||
|
||||
@@ -65,7 +72,13 @@ export class CompositionHelper {
|
||||
*/
|
||||
public compositionstart(): void {
|
||||
this._isComposing = true;
|
||||
this._compositionPosition.start = this._textarea.value.length;
|
||||
// It's important to use the selection here instead of textarea length to avoid conflicts with
|
||||
// screen reader mode
|
||||
const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
||||
const end = this._textarea.selectionEnd ?? start;
|
||||
this._compositionPosition.start = Math.min(start, end);
|
||||
this._compositionPosition.end = Math.max(start, end);
|
||||
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
|
||||
this._compositionView.textContent = '';
|
||||
this._dataAlreadySent = '';
|
||||
this._compositionView.classList.add('active');
|
||||
@@ -76,10 +89,13 @@ export class CompositionHelper {
|
||||
* @param ev The event.
|
||||
*/
|
||||
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
|
||||
this._compositionView.textContent = ev.data;
|
||||
// Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
|
||||
// compositions
|
||||
this._compositionView.textContent = `\u200E${ev.data}\u200E`;
|
||||
this.updateCompositionElements();
|
||||
setTimeout(() => {
|
||||
this._compositionPosition.end = this._textarea.value.length;
|
||||
const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||||
this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -146,6 +162,7 @@ export class CompositionHelper {
|
||||
start: this._compositionPosition.start,
|
||||
end: this._compositionPosition.end
|
||||
};
|
||||
const currentCompositionSuffix = this._compositionSuffix;
|
||||
|
||||
// Since composition* events happen before the changes take place in the textarea on most
|
||||
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
|
||||
@@ -169,10 +186,14 @@ export class CompositionHelper {
|
||||
// if a new composition has started.
|
||||
input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
|
||||
} else {
|
||||
// Don't use the end position here in order to pick up any characters after the
|
||||
// composition has finished, for example when typing a non-composition character
|
||||
// (eg. 2) after a composition character.
|
||||
input = this._textarea.value.substring(currentCompositionPosition.start);
|
||||
// Keep support for non-composition characters typed immediately after composition end
|
||||
// while avoiding re-sending the trailing text that was already present
|
||||
// before composition started.
|
||||
const value = this._textarea.value;
|
||||
const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix)
|
||||
? value.length - currentCompositionSuffix.length
|
||||
: value.length;
|
||||
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
|
||||
}
|
||||
if (input.length > 0) {
|
||||
this._coreService.triggerDataEvent(input, true);
|
||||
@@ -239,6 +260,12 @@ export class CompositionHelper {
|
||||
this._compositionView.style.lineHeight = cellHeight + 'px';
|
||||
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
|
||||
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
|
||||
// Limit the composition view width to the space between the cursor and
|
||||
// the terminal's right edge, preventing it from overflowing the terminal.
|
||||
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
|
||||
this._compositionView.style.maxWidth = maxWidth + 'px';
|
||||
this._compositionView.style.overflow = 'hidden';
|
||||
this._compositionView.style.direction = 'rtl';
|
||||
// Sync the textarea to the exact position of the composition view so the IME knows where the
|
||||
// text is.
|
||||
const compositionViewBounds = this._compositionView.getBoundingClientRect();
|
||||
|
||||
@@ -103,7 +103,7 @@ export class Terminal extends Disposable implements ITerminalApi {
|
||||
public get modes(): IModes {
|
||||
const m = this._core.coreService.decPrivateModes;
|
||||
let mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' = 'none';
|
||||
switch (this._core.coreMouseService.activeProtocol) {
|
||||
switch (this._core.mouseStateService.activeProtocol) {
|
||||
case 'X10': mouseTrackingMode = 'x10'; break;
|
||||
case 'VT200': mouseTrackingMode = 'vt200'; break;
|
||||
case 'DRAG': mouseTrackingMode = 'drag'; break;
|
||||
|
||||
@@ -19,13 +19,15 @@ import { Emitter } from 'common/Event';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
|
||||
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
|
||||
const ROW_CONTAINER_CLASS = 'xterm-rows';
|
||||
const FG_CLASS_PREFIX = 'xterm-fg-';
|
||||
const BG_CLASS_PREFIX = 'xterm-bg-';
|
||||
const FOCUS_CLASS = 'xterm-focus';
|
||||
const SELECTION_CLASS = 'xterm-selection';
|
||||
const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle';
|
||||
const enum Constants {
|
||||
TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-',
|
||||
ROW_CONTAINER_CLASS = 'xterm-rows',
|
||||
FG_CLASS_PREFIX = 'xterm-fg-',
|
||||
BG_CLASS_PREFIX = 'xterm-bg-',
|
||||
FOCUS_CLASS = 'xterm-focus',
|
||||
SELECTION_CLASS = 'xterm-selection',
|
||||
CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'
|
||||
}
|
||||
|
||||
let nextTerminalId = 1;
|
||||
|
||||
@@ -45,6 +47,9 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
private _selectionContainer: HTMLElement;
|
||||
private _widthCache: WidthCache;
|
||||
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
|
||||
private _lastSelectionStart: [number, number] | undefined;
|
||||
private _lastSelectionEnd: [number, number] | undefined;
|
||||
private _lastSelectionColumnMode: boolean = false;
|
||||
private _cursorBlinkStateManager: CursorBlinkStateManager;
|
||||
private _textBlinkStateManager: TextBlinkStateManager;
|
||||
private _rowHasBlinkingCells: boolean[] = [];
|
||||
@@ -73,12 +78,12 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
) {
|
||||
super();
|
||||
this._rowContainer = this._document.createElement('div');
|
||||
this._rowContainer.classList.add(ROW_CONTAINER_CLASS);
|
||||
this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS);
|
||||
this._rowContainer.style.lineHeight = 'normal';
|
||||
this._rowContainer.setAttribute('aria-hidden', 'true');
|
||||
this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
|
||||
this._selectionContainer = this._document.createElement('div');
|
||||
this._selectionContainer.classList.add(SELECTION_CLASS);
|
||||
this._selectionContainer.classList.add(Constants.SELECTION_CLASS);
|
||||
this._selectionContainer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.dimensions = createRenderDimensions();
|
||||
@@ -90,7 +95,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
|
||||
this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);
|
||||
|
||||
this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._screenElement.appendChild(this._rowContainer);
|
||||
this._screenElement.appendChild(this._selectionContainer);
|
||||
|
||||
@@ -107,7 +112,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
));
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
|
||||
// Outside influences such as React unmounts may manipulate the DOM before our disposal.
|
||||
// https://github.com/xtermjs/xterm.js/issues/2960
|
||||
@@ -157,7 +162,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
const styles =
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
|
||||
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
|
||||
` height: 100%;` +
|
||||
` vertical-align: top;` +
|
||||
@@ -178,19 +183,22 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Base CSS
|
||||
let styles =
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` +
|
||||
// Disabling pointer events circumvents a browser behavior that prevents `click` events from
|
||||
// being delivered if the target element is replaced during the click. This happened due to
|
||||
// refresh() being called during the mousedown handler to start a selection.
|
||||
` pointer-events: none;` +
|
||||
` color: ${colors.foreground.css};` +
|
||||
`}`;
|
||||
styles +=
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
|
||||
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
|
||||
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
|
||||
` font-kerning: none;` +
|
||||
` white-space: pre` +
|
||||
`}`;
|
||||
styles +=
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` +
|
||||
` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
|
||||
`}`;
|
||||
// Text styles
|
||||
@@ -236,70 +244,70 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
`}`;
|
||||
// Cursor
|
||||
styles +=
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
` animation: ${blinkAnimationBarId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
// Disable cursor blinking when idle
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
|
||||
` animation: none !important;` +
|
||||
`}` +
|
||||
// !important helps fix an issue where the cursor will not render on top of the selection,
|
||||
// however it's very hard to fix this issue and retain the blink animation without the use of
|
||||
// !important. So this edge case fails when cursor blink is on.
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
` background-color: ${colors.cursor.css};` +
|
||||
` color: ${colors.cursorAccent.css};` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
|
||||
` background-color: ${colors.cursor.css} !important;` +
|
||||
` color: ${colors.cursorAccent.css} !important;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
|
||||
` outline: 1px solid ${colors.cursor.css};` +
|
||||
` outline-offset: -1px;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
` border-bottom: 1px ${colors.cursor.css};` +
|
||||
` border-bottom-style: solid;` +
|
||||
` height: calc(100% - 1px);` +
|
||||
`}`;
|
||||
// Selection
|
||||
styles +=
|
||||
`${this._terminalSelector} .${SELECTION_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.SELECTION_CLASS} {` +
|
||||
` position: absolute;` +
|
||||
` top: 0;` +
|
||||
` left: 0;` +
|
||||
` z-index: 1;` +
|
||||
` pointer-events: none;` +
|
||||
`}` +
|
||||
`${this._terminalSelector}.focus .${SELECTION_CLASS} div {` +
|
||||
`${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` +
|
||||
` position: absolute;` +
|
||||
` background-color: ${colors.selectionBackgroundOpaque.css};` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${SELECTION_CLASS} div {` +
|
||||
`${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` +
|
||||
` position: absolute;` +
|
||||
` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
|
||||
`}`;
|
||||
// Colors
|
||||
for (const [i, c] of colors.ansi.entries()) {
|
||||
styles +=
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
|
||||
}
|
||||
styles +=
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
|
||||
|
||||
this._themeStyleElement.textContent = styles;
|
||||
}
|
||||
@@ -355,13 +363,13 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._rowContainer.classList.remove(FOCUS_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.FOCUS_CLASS);
|
||||
this._cursorBlinkStateManager.pause();
|
||||
this.renderRows(0, this._bufferService.rows - 1);
|
||||
}
|
||||
|
||||
public handleFocus(): void {
|
||||
this._rowContainer.classList.add(FOCUS_CLASS);
|
||||
this._rowContainer.classList.add(Constants.FOCUS_CLASS);
|
||||
this._cursorBlinkStateManager.resume();
|
||||
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
|
||||
}
|
||||
@@ -371,51 +379,89 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
|
||||
const rows = this._bufferService.rows;
|
||||
|
||||
// Remove all selections
|
||||
this._selectionContainer.replaceChildren();
|
||||
this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);
|
||||
this.renderRows(0, this._bufferService.rows - 1);
|
||||
|
||||
// Selection does not exist
|
||||
// Determine old selection viewport band
|
||||
let oldViewportStart = 0;
|
||||
let oldViewportEnd = -1;
|
||||
if (this._lastSelectionStart && this._lastSelectionEnd) {
|
||||
this._selectionRenderModel.update(this._terminal, this._lastSelectionStart, this._lastSelectionEnd, this._lastSelectionColumnMode);
|
||||
if (this._selectionRenderModel.hasSelection) {
|
||||
oldViewportStart = this._selectionRenderModel.viewportCappedStartRow;
|
||||
oldViewportEnd = this._selectionRenderModel.viewportCappedEndRow;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine new selection viewport band and create overlays
|
||||
let newViewportStart = 0;
|
||||
let newViewportEnd = -1;
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectionRenderModel.update(this._terminal, start, end, columnSelectMode);
|
||||
if (!this._selectionRenderModel.hasSelection) {
|
||||
return;
|
||||
}
|
||||
if (this._selectionRenderModel.hasSelection) {
|
||||
const viewportStartRow = this._selectionRenderModel.viewportStartRow;
|
||||
const viewportEndRow = this._selectionRenderModel.viewportEndRow;
|
||||
const viewportCappedStartRow = this._selectionRenderModel.viewportCappedStartRow;
|
||||
const viewportCappedEndRow = this._selectionRenderModel.viewportCappedEndRow;
|
||||
|
||||
// Translate from buffer position to viewport position
|
||||
const viewportStartRow = this._selectionRenderModel.viewportStartRow;
|
||||
const viewportEndRow = this._selectionRenderModel.viewportEndRow;
|
||||
const viewportCappedStartRow = this._selectionRenderModel.viewportCappedStartRow;
|
||||
const viewportCappedEndRow = this._selectionRenderModel.viewportCappedEndRow;
|
||||
newViewportStart = viewportCappedStartRow;
|
||||
newViewportEnd = viewportCappedEndRow;
|
||||
|
||||
// Create the selections
|
||||
const documentFragment = this._document.createDocumentFragment();
|
||||
// Create the selections
|
||||
const documentFragment = this._document.createDocumentFragment();
|
||||
|
||||
if (columnSelectMode) {
|
||||
const isXFlipped = start[0] > end[0];
|
||||
documentFragment.appendChild(
|
||||
this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
|
||||
);
|
||||
} else {
|
||||
// Draw first row
|
||||
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
|
||||
const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
|
||||
// Draw middle rows
|
||||
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
|
||||
// Draw final row
|
||||
if (viewportCappedStartRow !== viewportCappedEndRow) {
|
||||
// Only draw viewportEndRow if it's not the same as viewporttartRow
|
||||
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol));
|
||||
if (columnSelectMode) {
|
||||
const isXFlipped = start[0] > end[0];
|
||||
documentFragment.appendChild(
|
||||
this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
|
||||
);
|
||||
} else {
|
||||
// Draw first row
|
||||
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
|
||||
const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
|
||||
// Draw middle rows
|
||||
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
|
||||
// Draw final row
|
||||
if (viewportCappedStartRow !== viewportCappedEndRow) {
|
||||
// Only draw viewportEndRow if it's not the same as viewporttartRow
|
||||
const finalEndCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
|
||||
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, finalEndCol));
|
||||
}
|
||||
}
|
||||
this._selectionContainer.appendChild(documentFragment);
|
||||
}
|
||||
this._selectionContainer.appendChild(documentFragment);
|
||||
|
||||
// Compute minimal row range to redraw
|
||||
let renderStartRow = Math.min(oldViewportStart, newViewportStart);
|
||||
let renderEndRow = Math.max(oldViewportEnd, newViewportEnd);
|
||||
|
||||
if (renderEndRow >= 0) {
|
||||
// Clamp to viewport
|
||||
renderStartRow = Math.max(renderStartRow, 0);
|
||||
renderEndRow = Math.min(renderEndRow, rows - 1);
|
||||
|
||||
// Ensure cursor row is included when a selection is present
|
||||
const buffer = this._bufferService.buffer;
|
||||
const cursorViewportRow = buffer.y;
|
||||
if (this._selectionRenderModel.hasSelection && cursorViewportRow >= 0 && cursorViewportRow < rows) {
|
||||
renderStartRow = Math.min(renderStartRow, cursorViewportRow);
|
||||
renderEndRow = Math.max(renderEndRow, cursorViewportRow);
|
||||
}
|
||||
|
||||
this.renderRows(renderStartRow, renderEndRow);
|
||||
}
|
||||
|
||||
// Update last selection state
|
||||
this._lastSelectionStart = start;
|
||||
this._lastSelectionEnd = end;
|
||||
this._lastSelectionColumnMode = columnSelectMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -517,7 +563,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
private get _terminalSelector(): string {
|
||||
return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
|
||||
return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
|
||||
}
|
||||
|
||||
private _handleLinkHover(e: ILinkifierEvent): void {
|
||||
@@ -623,7 +669,7 @@ class CursorBlinkStateManager {
|
||||
|
||||
public restartBlinkAnimation(): void {
|
||||
if (this._isIdlePaused) {
|
||||
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
}
|
||||
this._resetIdleTimer();
|
||||
}
|
||||
@@ -635,7 +681,7 @@ class CursorBlinkStateManager {
|
||||
|
||||
public resume(): void {
|
||||
this._isIdlePaused = false;
|
||||
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
this._resetIdleTimer();
|
||||
}
|
||||
|
||||
@@ -655,7 +701,7 @@ class CursorBlinkStateManager {
|
||||
}
|
||||
|
||||
private _stopBlinkingDueToIdle(): void {
|
||||
this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
this._isIdlePaused = true;
|
||||
this._idleTimeout = undefined;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import { assert } from 'chai';
|
||||
import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test';
|
||||
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
|
||||
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
|
||||
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
|
||||
|
||||
const dom = new jsdom.JSDOM('');
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('DomRendererRowFactory', () => {
|
||||
let dom: jsdom.JSDOM;
|
||||
@@ -48,9 +48,9 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
it('should set correct attributes for double width characters', () => {
|
||||
widthCache.setWidths({ '語': 10 });
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, '語', 2));
|
||||
// There should be no element for the following "empty" cell
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0]));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, '', 0));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>語</span>'
|
||||
@@ -117,7 +117,7 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
describe('attributes', () => {
|
||||
it('should add class for bold', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -127,7 +127,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add class for italic', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -137,7 +137,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add class for dim', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -148,7 +148,7 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
describe('underline', () => {
|
||||
it('should add class for straight underline style', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
|
||||
@@ -159,7 +159,7 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
it('should add class for double underline style', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DOUBLE;
|
||||
@@ -170,7 +170,7 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
it('should add class for curly underline style', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
@@ -181,7 +181,7 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
it('should add class for double dotted style', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
|
||||
@@ -192,7 +192,7 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
it('should add class for dashed underline style', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DASHED;
|
||||
@@ -205,7 +205,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add class for overline', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -215,7 +215,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add class for strikethrough', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -225,7 +225,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should hide blinking text when blink is off', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BLINK | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
|
||||
@@ -241,7 +241,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add classes for 256 foreground colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= Attributes.CM_P256;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
cell.fg &= ~Attributes.PCOLOR_MASK;
|
||||
@@ -255,7 +255,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add classes for 256 background colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.bg |= Attributes.CM_P256;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
cell.bg &= ~Attributes.PCOLOR_MASK;
|
||||
@@ -269,7 +269,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should correctly invert colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE;
|
||||
cell.bg |= Attributes.CM_P16 | 1;
|
||||
lineData.setCell(0, cell);
|
||||
@@ -280,7 +280,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should correctly invert default fg color', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= FgFlags.INVERSE;
|
||||
cell.bg |= Attributes.CM_P16 | 1;
|
||||
lineData.setCell(0, cell);
|
||||
@@ -291,7 +291,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should correctly invert default bg color', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -301,7 +301,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should turn bold fg text bright', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= FgFlags.BOLD | Attributes.CM_P16;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
cell.fg &= ~Attributes.PCOLOR_MASK;
|
||||
@@ -315,7 +315,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should set style attribute for RBG', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3;
|
||||
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
|
||||
lineData.setCell(0, cell);
|
||||
@@ -326,7 +326,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should correctly invert RGB colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(0, 'a', 1);
|
||||
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE;
|
||||
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
|
||||
lineData.setCell(0, cell);
|
||||
@@ -339,8 +339,8 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
describe('selectionForeground', () => {
|
||||
it('should force selected cells with content to be rendered above the background', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
rowFactory.handleSelectionChanged([1, 0], [2, 0], false);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
@@ -348,7 +348,7 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
it('should force whitespace cells to be rendered above the background', () => {
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
rowFactory.handleSelectionChanged([0, 0], [2, 0], false);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
@@ -374,9 +374,9 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('can merge codepoints for equal spacing', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>abc</span>'
|
||||
@@ -385,9 +385,9 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
it('should not merge codepoints with different spacing', () => {
|
||||
widthCache.setWidths({ '€': 2 });
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c</span>'
|
||||
@@ -395,9 +395,9 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should not merge on FG change', () => {
|
||||
const aColor1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
const aColor1 = createCellData(DEFAULT_ATTR, 'a', 1);
|
||||
aColor1.fg |= Attributes.CM_P16 | 1;
|
||||
const bColor2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]);
|
||||
const bColor2 = createCellData(DEFAULT_ATTR, 'b', 1);
|
||||
bColor2.fg |= Attributes.CM_P16 | 2;
|
||||
lineData.setCell(0, aColor1);
|
||||
lineData.setCell(1, aColor1);
|
||||
@@ -410,11 +410,11 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should not merge cursor cell', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)]));
|
||||
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'X', 1));
|
||||
lineData.setCell(3, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span class="xterm-cursor xterm-cursor-block">X</span><span>bb</span>'
|
||||
@@ -453,7 +453,7 @@ describe('DomRendererRowFactory', () => {
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span>'
|
||||
);
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span><span>a</span>'
|
||||
@@ -462,9 +462,9 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
it('should apply correct positive or negative spacing', () => {
|
||||
widthCache.setWidths({ '€': 2, '語': 10, '𝄞': 7 }); // €: too small (+3px), 語: exact, 𝄞: too wide (-2px)
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
|
||||
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)]));
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
@@ -474,13 +474,13 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should not merge across link borders', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
|
||||
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
|
||||
lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
|
||||
lineData.setCell(3, createCellData(DEFAULT_ATTR, 'x', 1));
|
||||
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
|
||||
lineData.setCell(5, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
lineData.setCell(6, createCellData(DEFAULT_ATTR, 'b', 1));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span style="text-decoration: underline;">xxx</span><span>bb</span>'
|
||||
@@ -488,10 +488,10 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('empty cells included in link underline', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
|
||||
lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
|
||||
lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span style="text-decoration: underline;">x x</span>'
|
||||
@@ -500,7 +500,7 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
it('link range gets capped to actual line borders', () => {
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(i, createCellData(DEFAULT_ATTR, 'a', 1));
|
||||
}
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -100, 100);
|
||||
assert.equal(extractHtml(spans),
|
||||
@@ -517,9 +517,9 @@ describe('DomRendererRowFactory', () => {
|
||||
}
|
||||
|
||||
function createEmptyLineData(cols: number): IBufferLine {
|
||||
const lineData = new BufferLine(cols);
|
||||
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]));
|
||||
lineData.setCell(i, NULL_CELL_DATA);
|
||||
}
|
||||
return lineData;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import * as platform from 'common/Platform';
|
||||
import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable';
|
||||
// import 'vs/css!./media/scrollbars';
|
||||
|
||||
const HIDE_TIMEOUT = 500;
|
||||
const SCROLL_WHEEL_SENSITIVITY = 50;
|
||||
const enum Constants {
|
||||
HIDE_TIMEOUT = 500,
|
||||
SCROLL_WHEEL_SENSITIVITY = 50
|
||||
}
|
||||
|
||||
class MouseWheelClassifierItem {
|
||||
public timestamp: number;
|
||||
@@ -404,12 +406,12 @@ export class SmoothScrollableElement extends Widget {
|
||||
|
||||
let desiredScrollPosition: INewScrollPosition = {};
|
||||
if (deltaY) {
|
||||
const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY;
|
||||
const deltaScrollTop = Constants.SCROLL_WHEEL_SENSITIVITY * deltaY;
|
||||
const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop));
|
||||
this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop);
|
||||
}
|
||||
if (deltaX) {
|
||||
const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX;
|
||||
const deltaScrollLeft = Constants.SCROLL_WHEEL_SENSITIVITY * deltaX;
|
||||
const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft));
|
||||
this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft);
|
||||
}
|
||||
@@ -533,7 +535,7 @@ export class SmoothScrollableElement extends Widget {
|
||||
|
||||
private _scheduleHide(): void {
|
||||
if (!this._mouseIsOver && !this._isDragging) {
|
||||
this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT);
|
||||
this._hideTimeout.cancelAndSet(() => this._hide(), Constants.HIDE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,8 @@ export interface IGestureEvent extends MouseEvent {
|
||||
translationY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
tapCount: number;
|
||||
}
|
||||
|
||||
@@ -459,6 +461,8 @@ export class Gesture extends Disposable {
|
||||
evt.translationY = touch.pageY - tail(data.rollingPageY)!;
|
||||
evt.pageX = touch.pageX;
|
||||
evt.pageY = touch.pageY;
|
||||
evt.clientX = touch.clientX;
|
||||
evt.clientY = touch.clientY;
|
||||
this._dispatchEvent(evt);
|
||||
|
||||
if (data.rollingPageX.length > 3) {
|
||||
|
||||
@@ -7,9 +7,12 @@ import { assert } from 'chai';
|
||||
import { ICharacterJoinerService } from 'browser/services/Services';
|
||||
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { MockBufferService } from 'common/TestUtils.test';
|
||||
import { MockBufferService, createCellData } from 'common/TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('CharacterJoinerService', () => {
|
||||
let service: ICharacterJoinerService;
|
||||
@@ -22,20 +25,20 @@ describe('CharacterJoinerService', () => {
|
||||
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
|
||||
|
||||
lines.set(3, lineData([['no joined ranges']]));
|
||||
lines.set(4, new BufferLine(0));
|
||||
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
|
||||
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
|
||||
const line6 = lineData([['wi']]);
|
||||
line6.resize(line6.length + 1, CellData.fromCharData([0, '¥', 2, '¥'.charCodeAt(0)]));
|
||||
line6.resize(line6.length + 1, CellData.fromCharData([0, '', 0, 0]));
|
||||
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
|
||||
line6.resize(line6.length + 1, createCellData(0, '', 0));
|
||||
let sub = lineData([['deemo']]);
|
||||
let oldSize = line6.length;
|
||||
line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
|
||||
line6.resize(oldSize + sub.length, createCellData(0, '', 0));
|
||||
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
|
||||
line6.resize(line6.length + 1, CellData.fromCharData([0, '\xf0\x9f\x98\x81', 1, 128513]));
|
||||
line6.resize(line6.length + 1, CellData.fromCharData([0, ' ', 1, ' '.charCodeAt(0)]));
|
||||
line6.resize(line6.length + 1, createCellData(0, ' ', 1));
|
||||
sub = lineData([['jiabc']]);
|
||||
oldSize = line6.length;
|
||||
line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
|
||||
line6.resize(oldSize + sub.length, createCellData(0, '', 0));
|
||||
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
|
||||
lines.set(6, line6);
|
||||
|
||||
@@ -267,13 +270,13 @@ describe('CharacterJoinerService', () => {
|
||||
type IPartialLineData = ([string] | [string, number]);
|
||||
|
||||
function lineData(data: IPartialLineData[]): IBufferLine {
|
||||
const tline = new BufferLine(0);
|
||||
const tline = new BufferLine(TEST_STRING_CACHE, 0);
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
const line = data[i][0];
|
||||
const attr = (data[i][1] || 0) as number;
|
||||
const offset = tline.length;
|
||||
tline.resize(tline.length + line.split('').length, CellData.fromCharData([0, '', 0, 0]));
|
||||
line.split('').map((char, idx) => tline.setCell(idx + offset, CellData.fromCharData([attr, char, 1, char.charCodeAt(0)])));
|
||||
tline.resize(tline.length + line.split('').length, createCellData(0, '', 0));
|
||||
line.split('').map((char, idx) => tline.setCell(idx + offset, createCellData(attr, char, 1)));
|
||||
}
|
||||
return tline;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class KeyboardService implements IKeyboardService {
|
||||
}
|
||||
const kittyFlags = this._coreService.kittyKeyboard.flags;
|
||||
return this.useKitty
|
||||
? this._getKittyKeyboard().evaluate(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS)
|
||||
? this._getKittyKeyboard().evaluate(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS, isMac && this._optionsService.rawOptions.macOptionIsMeta)
|
||||
: evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, isMac, this._optionsService.rawOptions.macOptionIsMeta);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class KeyboardService implements IKeyboardService {
|
||||
}
|
||||
const kittyFlags = this._coreService.kittyKeyboard.flags;
|
||||
if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) {
|
||||
return this._getKittyKeyboard().evaluate(event, kittyFlags, KittyKeyboardEventType.RELEASE);
|
||||
return this._getKittyKeyboard().evaluate(event, kittyFlags, KittyKeyboardEventType.RELEASE, isMac && this._optionsService.rawOptions.macOptionIsMeta);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { getWindow } from 'browser/Dom';
|
||||
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
|
||||
import { ICharSizeService, IMouseCoordsService, IRenderService } from 'browser/services/Services';
|
||||
|
||||
export class MouseCoordsService implements IMouseCoordsService {
|
||||
public serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@ICharSizeService private readonly _charSizeService: ICharSizeService,
|
||||
@IRenderService private readonly _renderService: IRenderService
|
||||
) {
|
||||
}
|
||||
|
||||
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
|
||||
return getCoords(
|
||||
getWindow(element),
|
||||
event,
|
||||
element,
|
||||
colCount,
|
||||
rowCount,
|
||||
this._charSizeService.hasValidSize,
|
||||
this._renderService.dimensions.css.cell.width,
|
||||
this._renderService.dimensions.css.cell.height,
|
||||
isSelection
|
||||
);
|
||||
}
|
||||
|
||||
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
|
||||
const coords = getCoordsRelativeToElement(getWindow(element), event, element);
|
||||
if (!this._charSizeService.hasValidSize) {
|
||||
return undefined;
|
||||
}
|
||||
coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
|
||||
coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
|
||||
return {
|
||||
col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
|
||||
row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
|
||||
x: Math.floor(coords[0]),
|
||||
y: Math.floor(coords[1])
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { MouseService } from 'browser/services/MouseService';
|
||||
import { MouseStateService } from 'common/services/MouseStateService';
|
||||
import { CoreMouseAction, CoreMouseButton } from 'common/Types';
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { MockCoreBrowserService, MockRenderService, MockSelectionService } from 'browser/TestUtils.test';
|
||||
|
||||
function toBytes(s: string | undefined): number[] {
|
||||
if (!s) {
|
||||
return [];
|
||||
}
|
||||
const res: number[] = [];
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
res.push(s.charCodeAt(i));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Minimal mocks for deps that MouseService touches in these tests
|
||||
const bufferService: IBufferService = {
|
||||
buffer: { hasScrollback: true } as any,
|
||||
cols: 500,
|
||||
rows: 500
|
||||
} as any;
|
||||
|
||||
const optionsService: IOptionsService = {
|
||||
rawOptions: {
|
||||
logLevel: 'info',
|
||||
fastScrollSensitivity: 1,
|
||||
scrollSensitivity: 1
|
||||
}
|
||||
} as any;
|
||||
|
||||
const logService: ILogService = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {}
|
||||
} as any;
|
||||
|
||||
describe('MouseService _triggerMouseEvent', () => {
|
||||
let mouseService: MouseService;
|
||||
let mouseStateService: MouseStateService;
|
||||
let coreService: ICoreService;
|
||||
let reports: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
reports = [];
|
||||
mouseStateService = new MouseStateService();
|
||||
coreService = {
|
||||
triggerDataEvent: (data: string) => reports.push(data),
|
||||
triggerBinaryEvent: (data: string) => reports.push(data),
|
||||
decPrivateModes: { applicationCursorKeys: false }
|
||||
} as any;
|
||||
|
||||
mouseService = new MouseService(
|
||||
new MockRenderService(),
|
||||
{
|
||||
getMouseReportCoords: (_ev: MouseEvent, _el: HTMLElement) => ({ col: 0, row: 0, x: 0, y: 0 })
|
||||
} as any,
|
||||
mouseStateService,
|
||||
coreService,
|
||||
bufferService,
|
||||
optionsService,
|
||||
new MockSelectionService(),
|
||||
logService,
|
||||
new MockCoreBrowserService()
|
||||
);
|
||||
});
|
||||
|
||||
function trigger(e: Parameters<any>[0]): boolean {
|
||||
return (mouseService as any)._triggerMouseEvent(e);
|
||||
}
|
||||
|
||||
it('NONE', () => {
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
|
||||
});
|
||||
|
||||
it('X10', () => {
|
||||
mouseStateService.activeProtocol = 'X10';
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
|
||||
});
|
||||
|
||||
it('VT200', () => {
|
||||
mouseStateService.activeProtocol = 'VT200';
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
|
||||
});
|
||||
|
||||
it('DRAG', () => {
|
||||
mouseStateService.activeProtocol = 'DRAG';
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
|
||||
});
|
||||
|
||||
it('ANY', () => {
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), true);
|
||||
// should not report in any case
|
||||
// invalid button + action combinations
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.MOVE }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.UP }), false);
|
||||
// invalid coords
|
||||
assert.equal(trigger({ col: -1, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 500, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: -1, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
|
||||
assert.equal(trigger({ col: 0, row: 500, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
|
||||
});
|
||||
|
||||
describe('coords', () => {
|
||||
it('DEFAULT encoding', () => {
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
for (let i = 0; i < bufferService.cols; ++i) {
|
||||
assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
if (i > 222) {
|
||||
// supress mouse reports if we are out of addressible range (max. 222)
|
||||
assert.deepEqual(toBytes(reports.pop()), []);
|
||||
} else {
|
||||
assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('SGR encoding', () => {
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
mouseStateService.activeEncoding = 'SGR';
|
||||
for (let i = 0; i < bufferService.cols; ++i) {
|
||||
assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`);
|
||||
}
|
||||
});
|
||||
|
||||
it('SGR_PIXELS encoding', () => {
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
mouseStateService.activeEncoding = 'SGR_PIXELS';
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
assert.equal(trigger({ col: 0, row: 0, x: i, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
|
||||
assert.deepEqual(reports.pop(), `\x1b[<0;${i};0M`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('eventCodes with modifiers (DEFAULT encoding)', () => {
|
||||
// TODO: implement AUX button tests
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
mouseStateService.activeEncoding = 'DEFAULT';
|
||||
// all buttons + down + no modifer
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']);
|
||||
reports = [];
|
||||
|
||||
// all buttons + up + no modifier
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']);
|
||||
reports = [];
|
||||
|
||||
// all buttons + move + no modifier
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
|
||||
assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']);
|
||||
reports = [];
|
||||
|
||||
// button none + move + modifiers
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: false, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: true }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: false }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: true }), true);
|
||||
assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: true }), true);
|
||||
assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']);
|
||||
reports = [];
|
||||
});
|
||||
});
|
||||
@@ -3,45 +3,537 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { getWindow } from 'browser/Dom';
|
||||
import { ICharSizeService, IRenderService, IMouseService } from './Services';
|
||||
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types';
|
||||
import { C0 } from 'common/data/EscapeSequences';
|
||||
import { toDisposable } from 'common/Lifecycle';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
|
||||
import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/scrollable/touch';
|
||||
|
||||
type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>;
|
||||
|
||||
interface IMouseBindContext {
|
||||
readonly target: IMouseServiceTarget;
|
||||
readonly focus: () => void;
|
||||
readonly requestedEvents: RequestedMouseEvents;
|
||||
}
|
||||
|
||||
export class MouseService implements IMouseService {
|
||||
public serviceBrand: undefined;
|
||||
|
||||
private _lastEvent: ICoreMouseEvent | null = null;
|
||||
private _wheelPartialScroll: number = 0;
|
||||
private _touchScrollAccumulator: number = 0;
|
||||
|
||||
constructor(
|
||||
@IRenderService private readonly _renderService: IRenderService,
|
||||
@ICharSizeService private readonly _charSizeService: ICharSizeService
|
||||
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
|
||||
@IMouseStateService private readonly _mouseStateService: IMouseStateService,
|
||||
@ICoreService private readonly _coreService: ICoreService,
|
||||
@IBufferService private readonly _bufferService: IBufferService,
|
||||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||
@ISelectionService private readonly _selectionService: ISelectionService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
|
||||
) {
|
||||
}
|
||||
|
||||
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
|
||||
return getCoords(
|
||||
window,
|
||||
event,
|
||||
element,
|
||||
colCount,
|
||||
rowCount,
|
||||
this._charSizeService.hasValidSize,
|
||||
this._renderService.dimensions.css.cell.width,
|
||||
this._renderService.dimensions.css.cell.height,
|
||||
isSelection
|
||||
);
|
||||
public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void {
|
||||
const { element, document } = target;
|
||||
|
||||
/**
|
||||
* Event listener state handling.
|
||||
* We listen to the onProtocolChange event of MouseStateService and put
|
||||
* requested listeners in `requestedEvents`. With this the listeners
|
||||
* have all bits to do the event listener juggling.
|
||||
* Note: 'mousedown' currently is "always on" and not managed
|
||||
* by onProtocolChange.
|
||||
*/
|
||||
const requestedEvents: RequestedMouseEvents = {
|
||||
mouseup: null,
|
||||
wheel: null,
|
||||
mousedrag: null,
|
||||
mousemove: null
|
||||
};
|
||||
const ctx: IMouseBindContext = { target, focus, requestedEvents };
|
||||
const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
|
||||
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
|
||||
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
|
||||
mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent),
|
||||
mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent)
|
||||
};
|
||||
register(this._mouseStateService.onProtocolChange(events => {
|
||||
this._handleProtocolChange(ctx, eventListeners, events);
|
||||
}));
|
||||
// force initial onProtocolChange so we dont miss early mouse requests
|
||||
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
|
||||
|
||||
// Ensure document-level listeners are removed on dispose
|
||||
register(toDisposable(() => {
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* "Always on" event listeners.
|
||||
*/
|
||||
register(addDisposableListener(element, 'mousedown', (ev: MouseEvent) => this._handleMouseDown(ctx, ev)));
|
||||
register(addDisposableListener(element, 'wheel', (ev: WheelEvent) => this._handlePassiveWheel(ctx, ev), { passive: false }));
|
||||
register(Gesture.addTarget(target.screenElement));
|
||||
register(addDisposableListener(target.screenElement, GestureEventType.START, () => this._handleTouchStart()));
|
||||
register(addDisposableListener(target.screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleTouchChange(ctx, e)));
|
||||
}
|
||||
|
||||
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
|
||||
const coords = getCoordsRelativeToElement(getWindow(element), event, element);
|
||||
if (!this._charSizeService.hasValidSize) {
|
||||
return undefined;
|
||||
private _sendEvent(ctx: IMouseBindContext, ev: MouseEvent | WheelEvent): boolean {
|
||||
// Get mouse coordinates
|
||||
const pos = this._mouseCoordsService.getMouseReportCoords(ev as MouseEvent, ctx.target.screenElement);
|
||||
if (!pos) {
|
||||
return false;
|
||||
}
|
||||
coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
|
||||
coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
|
||||
|
||||
let but: CoreMouseButton;
|
||||
let action: CoreMouseAction | undefined;
|
||||
switch ((ev as MouseEvent & { overrideType?: string }).overrideType || ev.type) {
|
||||
case 'mousemove':
|
||||
action = CoreMouseAction.MOVE;
|
||||
if (ev.buttons === undefined) {
|
||||
// buttons is not supported on macOS, try to get a value from button instead
|
||||
but = CoreMouseButton.NONE;
|
||||
if (ev.button !== undefined) {
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
}
|
||||
} else {
|
||||
// according to MDN buttons only reports up to button 5 (AUX2)
|
||||
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
|
||||
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
|
||||
ev.buttons & 2 ? CoreMouseButton.RIGHT :
|
||||
CoreMouseButton.NONE; // fallback to NONE
|
||||
}
|
||||
break;
|
||||
case 'mouseup':
|
||||
action = CoreMouseAction.UP;
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
break;
|
||||
case 'mousedown':
|
||||
action = CoreMouseAction.DOWN;
|
||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
||||
break;
|
||||
case 'wheel':
|
||||
if (!this._mouseStateService.allowCustomWheelEvent(ev as WheelEvent)) {
|
||||
return false;
|
||||
}
|
||||
const deltaY = (ev as WheelEvent).deltaY;
|
||||
if (deltaY === 0) {
|
||||
return false;
|
||||
}
|
||||
const lines = this._consumeWheelEvent(
|
||||
ev as WheelEvent,
|
||||
this._renderService?.dimensions?.device?.cell?.height,
|
||||
this._coreBrowserService?.dpr
|
||||
);
|
||||
if (lines === 0) {
|
||||
return false;
|
||||
}
|
||||
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
|
||||
but = CoreMouseButton.WHEEL;
|
||||
break;
|
||||
default:
|
||||
// dont handle other event types by accident
|
||||
return false;
|
||||
}
|
||||
|
||||
// exit if we cannot determine valid button/action values
|
||||
// do nothing for higher buttons than wheel
|
||||
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this._triggerMouseEvent({
|
||||
col: pos.col,
|
||||
row: pos.row,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: but,
|
||||
action,
|
||||
ctrl: ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseUp(ctx: IMouseBindContext, ev: MouseEvent): void {
|
||||
this._sendEvent(ctx, ev);
|
||||
if (!ev.buttons) {
|
||||
// if no other button is held remove global handlers
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleWheel(ctx: IMouseBindContext, ev: WheelEvent): false {
|
||||
this._sendEvent(ctx, ev);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
private _handleMouseDrag(ctx: IMouseBindContext, ev: MouseEvent): void {
|
||||
// deal only with move while a button is held
|
||||
if (ev.buttons) {
|
||||
this._sendEvent(ctx, ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseMove(ctx: IMouseBindContext, ev: MouseEvent): void {
|
||||
// deal only with move without any button
|
||||
if (!ev.buttons) {
|
||||
this._sendEvent(ctx, ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseDown(ctx: IMouseBindContext, ev: MouseEvent): void {
|
||||
ev.preventDefault();
|
||||
ctx.focus();
|
||||
|
||||
// Don't send the mouse button to the pty if mouse events are disabled or
|
||||
// if the selection manager is having selection forced (ie. a modifier is
|
||||
// held).
|
||||
if (!this._mouseStateService.areMouseEventsActive || this._selectionService.shouldForceSelection(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendEvent(ctx, ev);
|
||||
|
||||
// Register additional global handlers which should keep reporting outside
|
||||
// of the terminal element.
|
||||
// Note: Other emulators also do this for 'mousedown' while a button
|
||||
// is held, we currently limit 'mousedown' to the terminal only.
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void {
|
||||
// do nothing, if app side handles wheel itself
|
||||
if (ctx.requestedEvents.wheel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._mouseStateService.allowCustomWheelEvent(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this._bufferService.buffer.hasScrollback) {
|
||||
// Convert wheel events into up/down events when the buffer does not have scrollback, this
|
||||
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
|
||||
// events are not enabled.
|
||||
// This used implementation used get the actual lines/partial lines scrolled from the
|
||||
// viewport but since moving to the new viewport implementation has been simplified to
|
||||
// simply send a single up or down sequence.
|
||||
|
||||
// Do nothing if there's no vertical scroll
|
||||
const deltaY = ev.deltaY;
|
||||
if (deltaY === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = this._consumeWheelEvent(
|
||||
ev,
|
||||
this._renderService?.dimensions?.device?.cell?.height,
|
||||
this._coreBrowserService?.dpr
|
||||
);
|
||||
if (lines === 0) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Construct and send sequences
|
||||
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
|
||||
this._coreService.triggerDataEvent(sequence, true);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTouchStart(): void {
|
||||
this._touchScrollAccumulator = 0;
|
||||
}
|
||||
|
||||
private _handleTouchChange(ctx: IMouseBindContext, e: IGestureEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// When mouse protocol has wheel events active, send as mouse wheel events.
|
||||
if (ctx.requestedEvents.wheel) {
|
||||
this._handleTouchScrollAsWheel(ctx, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// When in alt buffer (no scrollback), send up/down key sequences.
|
||||
if (!this._bufferService.buffer.hasScrollback) {
|
||||
this._handleTouchScrollAsKeys(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal scrollback: delegate to viewport scrolling when available.
|
||||
ctx.target.handleTouchScroll?.(e.translationY);
|
||||
}
|
||||
|
||||
private _handleTouchScrollAsKeys(e: IGestureEvent): void {
|
||||
const cellHeight = this._renderService?.dimensions.css.cell.height;
|
||||
if (!cellHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchScrollAccumulator -= e.translationY;
|
||||
const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
|
||||
if (lines === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchScrollAccumulator -= lines * cellHeight;
|
||||
const sequence = C0.ESC
|
||||
+ (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[')
|
||||
+ (lines < 0 ? 'A' : 'B');
|
||||
for (let i = 0; i < Math.abs(lines); i++) {
|
||||
this._coreService.triggerDataEvent(sequence, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTouchScrollAsWheel(ctx: IMouseBindContext, e: IGestureEvent): void {
|
||||
const cellHeight = this._renderService?.dimensions.css.cell.height;
|
||||
if (!cellHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchScrollAccumulator -= e.translationY;
|
||||
const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
|
||||
if (lines === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchScrollAccumulator -= lines * cellHeight;
|
||||
const pos = this._mouseCoordsService.getMouseReportCoords(e, ctx.target.screenElement);
|
||||
if (!pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < Math.abs(lines); i++) {
|
||||
this._triggerMouseEvent({
|
||||
col: pos.col,
|
||||
row: pos.row,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: CoreMouseButton.WHEEL,
|
||||
action: lines < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._lastEvent = null;
|
||||
this._wheelPartialScroll = 0;
|
||||
this._touchScrollAccumulator = 0;
|
||||
}
|
||||
|
||||
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
|
||||
const { element, document } = ctx.target;
|
||||
const { requestedEvents } = ctx;
|
||||
// apply global changes on events
|
||||
if (events) {
|
||||
if (this._optionsService.rawOptions.logLevel === 'debug') {
|
||||
this._logService.debug('Binding to mouse events:', this._explainEvents(events));
|
||||
}
|
||||
element.classList.add('enable-mouse-events');
|
||||
this._selectionService.disable();
|
||||
} else {
|
||||
this._logService.debug('Unbinding from mouse events.');
|
||||
element.classList.remove('enable-mouse-events');
|
||||
this._selectionService.enable();
|
||||
}
|
||||
|
||||
// add/remove handlers from requestedEvents
|
||||
if (!(events & CoreMouseEventType.MOVE)) {
|
||||
if (requestedEvents.mousemove) {
|
||||
element.removeEventListener('mousemove', requestedEvents.mousemove);
|
||||
}
|
||||
requestedEvents.mousemove = null;
|
||||
} else if (!requestedEvents.mousemove) {
|
||||
element.addEventListener('mousemove', eventListeners.mousemove);
|
||||
requestedEvents.mousemove = eventListeners.mousemove;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.WHEEL)) {
|
||||
if (requestedEvents.wheel) {
|
||||
element.removeEventListener('wheel', requestedEvents.wheel);
|
||||
}
|
||||
requestedEvents.wheel = null;
|
||||
} else if (!requestedEvents.wheel) {
|
||||
element.addEventListener('wheel', eventListeners.wheel, { passive: false });
|
||||
requestedEvents.wheel = eventListeners.wheel;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.UP)) {
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
requestedEvents.mouseup = null;
|
||||
} else {
|
||||
requestedEvents.mouseup ??= eventListeners.mouseup;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.DRAG)) {
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
requestedEvents.mousedrag = null;
|
||||
} else {
|
||||
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
||||
}
|
||||
}
|
||||
|
||||
private _applyScrollModifier(amount: number, ev: WheelEvent): number {
|
||||
// Multiply the scroll speed when the modifier key is pressed
|
||||
if (ev.altKey || ev.ctrlKey || ev.shiftKey) {
|
||||
return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity;
|
||||
}
|
||||
return amount * this._optionsService.rawOptions.scrollSensitivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls.
|
||||
* This prevents hyper-sensitive scrolling in alt buffer.
|
||||
*/
|
||||
private _consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number {
|
||||
// Do nothing if it's not a vertical scroll event
|
||||
if (ev.deltaY === 0 || ev.shiftKey) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cellHeight === undefined || dpr === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const targetWheelEventPixels = cellHeight / dpr;
|
||||
let amount = this._applyScrollModifier(ev.deltaY, ev);
|
||||
|
||||
if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
||||
amount /= (targetWheelEventPixels + 0.0); // Prevent integer division
|
||||
|
||||
const isLikelyTrackpad = Math.abs(ev.deltaY) < 50;
|
||||
if (isLikelyTrackpad) {
|
||||
amount *= 0.3;
|
||||
}
|
||||
|
||||
this._wheelPartialScroll += amount;
|
||||
amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1);
|
||||
this._wheelPartialScroll %= 1;
|
||||
} else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
amount *= this._bufferService.rows;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a mouse event to be sent.
|
||||
*
|
||||
* Returns true if the event passed all protocol restrictions and a report
|
||||
* was sent, otherwise false. The return value may be used to decide whether
|
||||
* the default event action in the browser component should be omitted.
|
||||
*
|
||||
* Note: The method will change values of the given event object
|
||||
* to fulfill protocol and encoding restrictions.
|
||||
*/
|
||||
private _triggerMouseEvent(e: ICoreMouseEvent): boolean {
|
||||
// range check for col/row
|
||||
if (e.col < 0 || e.col >= this._bufferService.cols
|
||||
|| e.row < 0 || e.row >= this._bufferService.rows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// filter nonsense combinations of button + action
|
||||
if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) {
|
||||
return false;
|
||||
}
|
||||
if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) {
|
||||
return false;
|
||||
}
|
||||
if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// report 1-based coords
|
||||
e.col++;
|
||||
e.row++;
|
||||
|
||||
// debounce move events at grid or pixel level
|
||||
if (e.action === CoreMouseAction.MOVE
|
||||
&& this._lastEvent
|
||||
&& this._equalEvents(this._lastEvent, e, this._mouseStateService.isPixelEncoding)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// apply protocol restrictions
|
||||
if (!this._mouseStateService.restrictMouseEvent(e)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// encode report and send
|
||||
const report = this._mouseStateService.encodeMouseEvent(e);
|
||||
if (report) {
|
||||
if (this._mouseStateService.isDefaultEncoding) {
|
||||
this._coreService.triggerBinaryEvent(report);
|
||||
} else {
|
||||
this._coreService.triggerDataEvent(report, true);
|
||||
}
|
||||
}
|
||||
|
||||
this._lastEvent = e;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
|
||||
return {
|
||||
col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
|
||||
row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
|
||||
x: Math.floor(coords[0]),
|
||||
y: Math.floor(coords[1])
|
||||
down: !!(events & CoreMouseEventType.DOWN),
|
||||
up: !!(events & CoreMouseEventType.UP),
|
||||
drag: !!(events & CoreMouseEventType.DRAG),
|
||||
move: !!(events & CoreMouseEventType.MOVE),
|
||||
wheel: !!(events & CoreMouseEventType.WHEEL)
|
||||
};
|
||||
}
|
||||
|
||||
private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean {
|
||||
if (pixels) {
|
||||
if (e1.x !== e2.x) return false;
|
||||
if (e1.y !== e2.y) return false;
|
||||
} else {
|
||||
if (e1.col !== e2.col) return false;
|
||||
if (e1.row !== e2.row) return false;
|
||||
}
|
||||
if (e1.button !== e2.button) return false;
|
||||
if (e1.action !== e2.action) return false;
|
||||
if (e1.ctrl !== e2.ctrl) return false;
|
||||
if (e1.alt !== e2.alt) return false;
|
||||
if (e1.shift !== e2.shift) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
private _renderDebouncer: IRenderDebouncerWithCallback;
|
||||
private _pausedResizeTask: DebouncedIdleTask;
|
||||
private _observerDisposable = this._register(new MutableDisposable());
|
||||
private _intersectionObserver: IntersectionObserver | undefined;
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
private _needsFullRefresh: boolean = false;
|
||||
@@ -68,7 +69,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
) {
|
||||
super();
|
||||
|
||||
this._pausedResizeTask = new DebouncedIdleTask(this._logService);
|
||||
this._pausedResizeTask = this._register(new DebouncedIdleTask(this._logService));
|
||||
|
||||
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
|
||||
this._register(this._renderDebouncer);
|
||||
@@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
// and resume based on terminal visibility if so
|
||||
if ('IntersectionObserver' in w) {
|
||||
const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
|
||||
this._observerDisposable.value = toDisposable(() => {
|
||||
this._intersectionObserver?.disconnect();
|
||||
this._intersectionObserver = undefined;
|
||||
});
|
||||
this._intersectionObserver = observer;
|
||||
observer.observe(screenElement);
|
||||
this._observerDisposable.value = toDisposable(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,17 @@ import { assert } from 'chai';
|
||||
import { SelectionService, SelectionMode } from './SelectionService';
|
||||
import { SelectionModel } from 'browser/selection/SelectionModel';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test';
|
||||
import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { IBufferService, IOptionsService } from 'common/services/Services';
|
||||
import { MockCoreBrowserService, MockMouseService, MockRenderService } from 'browser/TestUtils.test';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
import { IRenderService } from 'browser/services/Services';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
class TestSelectionService extends SelectionService {
|
||||
constructor(
|
||||
bufferService: IBufferService,
|
||||
@@ -55,16 +58,16 @@ describe('SelectionService', () => {
|
||||
});
|
||||
|
||||
function stringToRow(text: string): IBufferLine {
|
||||
const result = new BufferLine(text.length);
|
||||
const result = new BufferLine(TEST_STRING_CACHE, text.length);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result.setCell(i, CellData.fromCharData([0, text.charAt(i), 1, text.charCodeAt(i)]));
|
||||
result.setCell(i, createCellData(0, text.charAt(i), 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringArrayToRow(chars: string[]): IBufferLine {
|
||||
const line = new BufferLine(chars.length);
|
||||
chars.map((c, idx) => line.setCell(idx, CellData.fromCharData([0, c, 1, c.charCodeAt(0)])));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
|
||||
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
|
||||
return line;
|
||||
}
|
||||
|
||||
@@ -118,7 +121,7 @@ describe('SelectionService', () => {
|
||||
[0, 'o', 1, 'o'.charCodeAt(0)],
|
||||
[0, 'o', 1, 'o'.charCodeAt(0)]
|
||||
];
|
||||
const line = new BufferLine(data.length);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, data.length);
|
||||
for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i]));
|
||||
buffer.lines.set(0, line);
|
||||
// Ensure wide characters take up 2 columns
|
||||
|
||||
@@ -8,8 +8,8 @@ import { getCoordsRelativeToElement } from 'browser/input/Mouse';
|
||||
import { moveToCellSequence } from 'browser/input/MoveToCell';
|
||||
import { SelectionModel } from 'browser/selection/SelectionModel';
|
||||
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
|
||||
import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
|
||||
import { getRangeLength } from 'common/buffer/BufferRange';
|
||||
@@ -18,27 +18,26 @@ import { IBuffer } from 'common/buffer/Types';
|
||||
import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
/**
|
||||
* The number of pixels the mouse needs to be above or below the viewport in
|
||||
* order to scroll at the maximum speed.
|
||||
*/
|
||||
const DRAG_SCROLL_MAX_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* The maximum scrolling speed
|
||||
*/
|
||||
const DRAG_SCROLL_MAX_SPEED = 15;
|
||||
|
||||
/**
|
||||
* The number of milliseconds between drag scroll updates.
|
||||
*/
|
||||
const DRAG_SCROLL_INTERVAL = 50;
|
||||
|
||||
/**
|
||||
* The maximum amount of time that can have elapsed for an alt click to move the
|
||||
* cursor.
|
||||
*/
|
||||
const ALT_CLICK_MOVE_CURSOR_TIME = 500;
|
||||
const enum Constants {
|
||||
/**
|
||||
* The number of pixels the mouse needs to be above or below the viewport in
|
||||
* order to scroll at the maximum speed.
|
||||
*/
|
||||
DRAG_SCROLL_MAX_THRESHOLD = 50,
|
||||
/**
|
||||
* The maximum scrolling speed
|
||||
*/
|
||||
DRAG_SCROLL_MAX_SPEED = 15,
|
||||
/**
|
||||
* The number of milliseconds between drag scroll updates.
|
||||
*/
|
||||
DRAG_SCROLL_INTERVAL = 50,
|
||||
/**
|
||||
* The maximum amount of time that can have elapsed for an alt click to move the
|
||||
* cursor.
|
||||
*/
|
||||
ALT_CLICK_MOVE_CURSOR_TIME = 500
|
||||
}
|
||||
|
||||
const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
|
||||
const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');
|
||||
@@ -103,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
|
||||
private _mouseMoveListener: EventListener;
|
||||
private _mouseUpListener: EventListener;
|
||||
private _trimListener: IDisposable;
|
||||
private readonly _trimListener = this._register(new MutableDisposable<IDisposable>());
|
||||
private _workCell: CellData = new CellData();
|
||||
|
||||
private _mouseDownTimeStamp: number = 0;
|
||||
@@ -126,7 +125,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
private readonly _linkifier: ILinkifier2,
|
||||
@IBufferService private readonly _bufferService: IBufferService,
|
||||
@ICoreService private readonly _coreService: ICoreService,
|
||||
@IMouseService private readonly _mouseService: IMouseService,
|
||||
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
|
||||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||
@IRenderService private readonly _renderService: IRenderService,
|
||||
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
|
||||
@@ -141,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
this.clearSelection();
|
||||
}
|
||||
});
|
||||
this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e)));
|
||||
|
||||
this.enable();
|
||||
@@ -395,7 +394,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
* @param event The mouse event.
|
||||
*/
|
||||
private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined {
|
||||
const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
|
||||
const coords = this._mouseCoordsService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
|
||||
if (!coords) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -424,9 +423,9 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
offset -= terminalHeight;
|
||||
}
|
||||
|
||||
offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
|
||||
offset /= DRAG_SCROLL_MAX_THRESHOLD;
|
||||
return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
|
||||
offset = Math.min(Math.max(offset, -Constants.DRAG_SCROLL_MAX_THRESHOLD), Constants.DRAG_SCROLL_MAX_THRESHOLD);
|
||||
offset /= Constants.DRAG_SCROLL_MAX_THRESHOLD;
|
||||
return (offset / Math.abs(offset)) + Math.round(offset * (Constants.DRAG_SCROLL_MAX_SPEED - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,7 +499,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
|
||||
this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
|
||||
}
|
||||
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
|
||||
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), Constants.DRAG_SCROLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,7 +674,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the
|
||||
* The callback that occurs every Constants.DRAG_SCROLL_INTERVAL ms that does the
|
||||
* scrolling of the viewport.
|
||||
*/
|
||||
private _dragScroll(): void {
|
||||
@@ -713,9 +712,9 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
|
||||
this._removeMouseDownListeners();
|
||||
|
||||
if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
|
||||
if (this.selectionText.length <= 1 && timeElapsed < Constants.ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
|
||||
if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) {
|
||||
const coordinates = this._mouseService.getCoords(
|
||||
const coordinates = this._mouseCoordsService.getCoords(
|
||||
event,
|
||||
this._element,
|
||||
this._bufferService.cols,
|
||||
@@ -770,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
// reverseIndex) and delete in a splice is only ever used when the same
|
||||
// number of elements was just added. Given this is could actually be
|
||||
// beneficial to leave the selection as is for these cases.
|
||||
this._trimListener.dispose();
|
||||
this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,14 +49,28 @@ export interface ICoreBrowserService {
|
||||
readonly dpr: number;
|
||||
}
|
||||
|
||||
export const IMouseService = createDecorator<IMouseService>('MouseService');
|
||||
export interface IMouseService {
|
||||
export const IMouseCoordsService = createDecorator<IMouseCoordsService>('MouseCoordsService');
|
||||
export interface IMouseCoordsService {
|
||||
serviceBrand: undefined;
|
||||
|
||||
getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined;
|
||||
getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined;
|
||||
}
|
||||
|
||||
export const IMouseService = createDecorator<IMouseService>('MouseService');
|
||||
export interface IMouseService {
|
||||
serviceBrand: undefined;
|
||||
|
||||
bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void;
|
||||
reset(): void;
|
||||
}
|
||||
export interface IMouseServiceTarget {
|
||||
element: HTMLElement;
|
||||
screenElement: HTMLElement;
|
||||
document: Document;
|
||||
handleTouchScroll?(amount: number): void;
|
||||
}
|
||||
|
||||
export const IRenderService = createDecorator<IRenderService>('RenderService');
|
||||
export interface IRenderService extends IDisposable {
|
||||
serviceBrand: undefined;
|
||||
|
||||
@@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
|
||||
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
|
||||
* run yet.
|
||||
*/
|
||||
export class MicrotaskTimer implements IDisposable {
|
||||
private _isScheduled = false;
|
||||
private _isDisposed = false;
|
||||
|
||||
public dispose(): void {
|
||||
this.cancel();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this._isScheduled = false;
|
||||
}
|
||||
|
||||
public set(runner: () => void): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling set on a disposed MicrotaskTimer');
|
||||
}
|
||||
if (this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
if (!this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = false;
|
||||
runner();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class IntervalTimer implements IDisposable {
|
||||
private _disposable: IDisposable | undefined;
|
||||
private _isDisposed = false;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { clone } from 'common/Clone';
|
||||
|
||||
describe('clone', () => {
|
||||
it('should clone simple objects', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), { a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('should clone nested objects', () => {
|
||||
const test = {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone array values', () => {
|
||||
const test = {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop mutation from occuring on the original object', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a = 5;
|
||||
test.c.foo = 'barbaz';
|
||||
|
||||
assert.deepEqual(cloned, {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone to a maximum depth of 5 by default', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 'foo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a.b.c.d.e.f = 'bar';
|
||||
|
||||
// The values at a greater depth then 5 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c.d.e.f, 'bar');
|
||||
});
|
||||
|
||||
it('should allow an optional maximum depth to be set', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: 'foo'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test, 2);
|
||||
|
||||
test.a.b.c = 'bar';
|
||||
|
||||
// The values at a greater depth then 2 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c, 'bar');
|
||||
});
|
||||
|
||||
it('should not throw when cloning a recursive reference', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test.a.b.c = test;
|
||||
|
||||
assert.doesNotThrow(() => clone(test));
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*
|
||||
* A simple utility for cloning values
|
||||
*/
|
||||
export function clone<T>(val: T, depth: number = 5): T {
|
||||
if (typeof val !== 'object') {
|
||||
return val;
|
||||
}
|
||||
|
||||
// If we're cloning an array, use an array as the base, otherwise use an object
|
||||
const clonedObject: any = Array.isArray(val) ? [] : {};
|
||||
|
||||
for (const key in val) {
|
||||
// Recursively clone eack item unless we're at the maximum depth
|
||||
clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1));
|
||||
}
|
||||
|
||||
return clonedObject as T;
|
||||
}
|
||||
+13
-13
@@ -21,14 +21,14 @@
|
||||
* http://linux.die.net/man/7/urxvt
|
||||
*/
|
||||
|
||||
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
|
||||
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
|
||||
import { InstantiationService } from 'common/services/InstantiationService';
|
||||
import { LogService } from 'common/services/LogService';
|
||||
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
|
||||
import { BufferService, BufferServiceConstants } from 'common/services/BufferService';
|
||||
import { OptionsService } from 'common/services/OptionsService';
|
||||
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types';
|
||||
import { CoreService } from 'common/services/CoreService';
|
||||
import { CoreMouseService } from 'common/services/CoreMouseService';
|
||||
import { MouseStateService } from 'common/services/MouseStateService';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
import { CharsetService } from 'common/services/CharsetService';
|
||||
import { updateWindowsModeWrappedState } from 'common/WindowsMode';
|
||||
@@ -50,7 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
protected readonly _charsetService: ICharsetService;
|
||||
protected readonly _oscLinkService: IOscLinkService;
|
||||
|
||||
public readonly coreMouseService: ICoreMouseService;
|
||||
public readonly mouseStateService: IMouseStateService;
|
||||
public readonly coreService: ICoreService;
|
||||
public readonly unicodeService: IUnicodeService;
|
||||
public readonly optionsService: IOptionsService;
|
||||
@@ -113,8 +113,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
this._instantiationService.setService(IBufferService, this._bufferService);
|
||||
this.coreService = this._register(this._instantiationService.createInstance(CoreService));
|
||||
this._instantiationService.setService(ICoreService, this.coreService);
|
||||
this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService));
|
||||
this._instantiationService.setService(ICoreMouseService, this.coreMouseService);
|
||||
this.mouseStateService = this._register(this._instantiationService.createInstance(MouseStateService));
|
||||
this._instantiationService.setService(IMouseStateService, this.mouseStateService);
|
||||
this.unicodeService = this._register(this._instantiationService.createInstance(UnicodeService));
|
||||
this._instantiationService.setService(IUnicodeService, this.unicodeService);
|
||||
this._charsetService = this._instantiationService.createInstance(CharsetService);
|
||||
@@ -124,7 +124,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
|
||||
|
||||
// Register input handler and handle/forward events
|
||||
this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService));
|
||||
this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.mouseStateService, this.unicodeService));
|
||||
this._register(EventUtils.forward(this._inputHandler.onLineFeed, this._onLineFeed));
|
||||
|
||||
// Setup listeners
|
||||
@@ -173,8 +173,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
return;
|
||||
}
|
||||
|
||||
x = Math.max(x, MINIMUM_COLS);
|
||||
y = Math.max(y, MINIMUM_ROWS);
|
||||
x = Math.max(x, BufferServiceConstants.MINIMUM_COLS);
|
||||
y = Math.max(y, BufferServiceConstants.MINIMUM_ROWS);
|
||||
|
||||
// Flush pending writes before resize to avoid race conditions where async
|
||||
// writes are processed with incorrect dimensions
|
||||
@@ -243,8 +243,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
}
|
||||
|
||||
/** Add handler for APC escape sequence. See xterm.d.ts for details. */
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._inputHandler.registerApcHandler(ident, callback);
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._inputHandler.registerApcHandler(id, callback);
|
||||
}
|
||||
|
||||
protected _setup(): void {
|
||||
@@ -256,14 +256,14 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
this._bufferService.reset();
|
||||
this._charsetService.reset();
|
||||
this.coreService.reset();
|
||||
this.coreMouseService.reset();
|
||||
this.mouseStateService.reset();
|
||||
}
|
||||
|
||||
|
||||
private _handleWindowsPtyOptionChange(): void {
|
||||
let value = false;
|
||||
const windowsPty = this.optionsService.rawOptions.windowsPty;
|
||||
if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) {
|
||||
if (windowsPty && windowsPty.backend !== undefined && windowsPty.buildNumber !== undefined) {
|
||||
value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376);
|
||||
}
|
||||
if (value) {
|
||||
|
||||
@@ -11,10 +11,9 @@ import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
import { Params } from 'common/parser/Params';
|
||||
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
|
||||
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test';
|
||||
import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { BufferService } from 'common/services/BufferService';
|
||||
import { CoreService } from 'common/services/CoreService';
|
||||
import { OscLinkService } from 'common/services/OscLinkService';
|
||||
@@ -70,7 +69,7 @@ describe('InputHandler', () => {
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
oscLinkService = new OscLinkService(bufferService);
|
||||
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockCoreMouseService(), new MockUnicodeService());
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockMouseStateService(), new MockUnicodeService());
|
||||
});
|
||||
|
||||
describe('SL/SR/DECIC/DECDC', () => {
|
||||
@@ -227,32 +226,32 @@ describe('InputHandler', () => {
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, undefined);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([1]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([2]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([3]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([4]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([5]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([6]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
@@ -261,7 +260,7 @@ describe('InputHandler', () => {
|
||||
describe('setMode', () => {
|
||||
it('should toggle bracketedPasteMode', () => {
|
||||
const coreService = new MockCoreService();
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
// Set bracketed paste mode
|
||||
inputHandler.setModePrivate(Params.fromArray([2004]));
|
||||
assert.equal(coreService.decPrivateModes.bracketedPasteMode, true);
|
||||
@@ -272,7 +271,7 @@ describe('InputHandler', () => {
|
||||
it('should toggle colorSchemeUpdates (DECSET 2031)', () => {
|
||||
const coreService = new MockCoreService();
|
||||
const optionsService = new MockOptionsService();
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
// Set color scheme updates mode (default colorSchemeQuery=true)
|
||||
inputHandler.setModePrivate(Params.fromArray([2031]));
|
||||
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, true);
|
||||
@@ -284,7 +283,7 @@ describe('InputHandler', () => {
|
||||
const coreService = new MockCoreService();
|
||||
const optionsService = new MockOptionsService();
|
||||
optionsService.rawOptions.vtExtensions = { colorSchemeQuery: false };
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
// Attempt to set color scheme updates mode
|
||||
inputHandler.setModePrivate(Params.fromArray([2031]));
|
||||
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, false);
|
||||
@@ -306,42 +305,42 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
|
||||
// insert some data in first and second line
|
||||
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
|
||||
await inputHandler.parseP('1234567890');
|
||||
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
|
||||
await inputHandler.parseP('1234567890');
|
||||
const line1: IBufferLine = bufferService.buffer.lines.get(0)!;
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '1234567890');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '1234567890');
|
||||
|
||||
// insert one char from params = [0]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.insertChars(Params.fromArray([0]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 123456789');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 123456789');
|
||||
|
||||
// insert one char from params = [1]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.insertChars(Params.fromArray([1]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 12345678');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 12345678');
|
||||
|
||||
// insert two chars from params = [2]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.insertChars(Params.fromArray([2]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' 123456');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' 123456');
|
||||
|
||||
// insert 10 chars from params = [10]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.insertChars(Params.fromArray([10]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' ');
|
||||
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a'));
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' ');
|
||||
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10));
|
||||
});
|
||||
it('deleteChars', async () => {
|
||||
const bufferService = new MockBufferService(80, 30);
|
||||
@@ -352,46 +351,46 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
|
||||
// insert some data in first and second line
|
||||
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
|
||||
await inputHandler.parseP('1234567890');
|
||||
await inputHandler.parseP(Array(bufferService.cols - 9).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols - 10));
|
||||
await inputHandler.parseP('1234567890');
|
||||
const line1: IBufferLine = bufferService.buffer.lines.get(0)!;
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '1234567890');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '1234567890');
|
||||
|
||||
// delete one char from params = [0]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.deleteChars(Params.fromArray([0]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '234567890 ');
|
||||
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '234567890');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '234567890 ');
|
||||
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '234567890');
|
||||
|
||||
// insert one char from params = [1]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.deleteChars(Params.fromArray([1]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '34567890 ');
|
||||
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '34567890');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '34567890 ');
|
||||
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '34567890');
|
||||
|
||||
// insert two chars from params = [2]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.deleteChars(Params.fromArray([2]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + '567890 ');
|
||||
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a') + '567890');
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + '567890 ');
|
||||
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10) + '567890');
|
||||
|
||||
|
||||
// insert 10 chars from params = [10]
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.deleteChars(Params.fromArray([10]));
|
||||
assert.equal(line1.translateToString(false), Array(bufferService.cols - 9).join('a') + ' ');
|
||||
assert.equal(line1.translateToString(true), Array(bufferService.cols - 9).join('a'));
|
||||
assert.equal(line1.translateToString(false), 'a'.repeat(bufferService.cols - 10) + ' ');
|
||||
assert.equal(line1.translateToString(true), 'a'.repeat(bufferService.cols - 10));
|
||||
});
|
||||
it('eraseInLine', async () => {
|
||||
const bufferService = new MockBufferService(80, 30);
|
||||
@@ -402,32 +401,32 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
|
||||
// fill 6 lines to test 3 different states
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params[0] - right erase
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.eraseInLine(Params.fromArray([0]));
|
||||
assert.equal(bufferService.buffer.lines.get(0)!.translateToString(false), Array(71).join('a') + ' ');
|
||||
assert.equal(bufferService.buffer.lines.get(0)!.translateToString(false), 'a'.repeat(70) + ' ');
|
||||
|
||||
// params[1] - left erase
|
||||
bufferService.buffer.y = 1;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.eraseInLine(Params.fromArray([1]));
|
||||
assert.equal(bufferService.buffer.lines.get(1)!.translateToString(false), Array(71).join(' ') + ' aaaaaaaaa');
|
||||
assert.equal(bufferService.buffer.lines.get(1)!.translateToString(false), ' '.repeat(70) + ' aaaaaaaaa');
|
||||
|
||||
// params[1] - left erase
|
||||
bufferService.buffer.y = 2;
|
||||
bufferService.buffer.x = 70;
|
||||
inputHandler.eraseInLine(Params.fromArray([2]));
|
||||
assert.equal(bufferService.buffer.lines.get(2)!.translateToString(false), Array(bufferService.cols + 1).join(' '));
|
||||
assert.equal(bufferService.buffer.lines.get(2)!.translateToString(false), ' '.repeat(bufferService.cols));
|
||||
|
||||
});
|
||||
it('eraseInLine reflow', async () => {
|
||||
@@ -439,7 +438,7 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
|
||||
@@ -447,9 +446,9 @@ describe('InputHandler', () => {
|
||||
// reset and add a wrapped line
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 0;
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
|
||||
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// confirm precondition that line 2 is wrapped
|
||||
assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, true);
|
||||
@@ -488,10 +487,10 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService({ scrollOnEraseInDisplay: true }),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
const aLine = Array(bufferService.cols + 1).join('a');
|
||||
const aLine = 'a'.repeat(bufferService.cols);
|
||||
// add 2 full lines of text.
|
||||
await inputHandler.parseP(aLine);
|
||||
await inputHandler.parseP(aLine);
|
||||
@@ -520,53 +519,53 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
|
||||
// fill display with a's
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params [0] - right and below erase
|
||||
bufferService.buffer.y = 5;
|
||||
bufferService.buffer.x = 40;
|
||||
inputHandler.eraseInDisplay(Params.fromArray([0]));
|
||||
assert.deepEqual(termContent(bufferService, false), [
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(40 + 1).join('a') + Array(bufferService.cols - 40 + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' ')
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(40) + ' '.repeat(bufferService.cols - 40),
|
||||
' '.repeat(bufferService.cols)
|
||||
]);
|
||||
assert.deepEqual(termContent(bufferService, true), [
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a'),
|
||||
Array(40 + 1).join('a'),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(bufferService.cols),
|
||||
'a'.repeat(40),
|
||||
''
|
||||
]);
|
||||
|
||||
// reset
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 0;
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params [1] - left and above
|
||||
bufferService.buffer.y = 5;
|
||||
bufferService.buffer.x = 40;
|
||||
inputHandler.eraseInDisplay(Params.fromArray([1]));
|
||||
assert.deepEqual(termContent(bufferService, false), [
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(41 + 1).join(' ') + Array(bufferService.cols - 41 + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a')
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(41) + 'a'.repeat(bufferService.cols - 41),
|
||||
'a'.repeat(bufferService.cols)
|
||||
]);
|
||||
assert.deepEqual(termContent(bufferService, true), [
|
||||
'',
|
||||
@@ -574,27 +573,27 @@ describe('InputHandler', () => {
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
Array(41 + 1).join(' ') + Array(bufferService.cols - 41 + 1).join('a'),
|
||||
Array(bufferService.cols + 1).join('a')
|
||||
' '.repeat(41) + 'a'.repeat(bufferService.cols - 41),
|
||||
'a'.repeat(bufferService.cols)
|
||||
]);
|
||||
|
||||
// reset
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 0;
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
for (let i = 0; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params [2] - whole screen
|
||||
bufferService.buffer.y = 5;
|
||||
bufferService.buffer.x = 40;
|
||||
inputHandler.eraseInDisplay(Params.fromArray([2]));
|
||||
assert.deepEqual(termContent(bufferService, false), [
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' '),
|
||||
Array(bufferService.cols + 1).join(' ')
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols),
|
||||
' '.repeat(bufferService.cols)
|
||||
]);
|
||||
assert.deepEqual(termContent(bufferService, true), [
|
||||
'',
|
||||
@@ -609,9 +608,9 @@ describe('InputHandler', () => {
|
||||
// reset and add a wrapped line
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 0;
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
|
||||
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params[1] left and above with wrap
|
||||
// confirm precondition that line 2 is wrapped
|
||||
@@ -624,9 +623,9 @@ describe('InputHandler', () => {
|
||||
// reset and add a wrapped line
|
||||
bufferService.buffer.y = 0;
|
||||
bufferService.buffer.x = 0;
|
||||
await inputHandler.parseP(Array(bufferService.cols + 1).join('a')); // line 0
|
||||
await inputHandler.parseP(Array(bufferService.cols + 10).join('a')); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP(Array(bufferService.cols + 1).join('a'));
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols)); // line 0
|
||||
await inputHandler.parseP('a'.repeat(bufferService.cols + 9)); // line 1 and 2
|
||||
for (let i = 3; i < bufferService.rows; ++i) await inputHandler.parseP('a'.repeat(bufferService.cols));
|
||||
|
||||
// params[1] left and above with wrap
|
||||
// confirm precondition that line 2 is wrapped
|
||||
@@ -646,7 +645,7 @@ describe('InputHandler', () => {
|
||||
new MockLogService(),
|
||||
new MockOptionsService(),
|
||||
new MockOscLinkService(),
|
||||
new MockCoreMouseService(),
|
||||
new MockMouseStateService(),
|
||||
new MockUnicodeService()
|
||||
);
|
||||
const container = new Uint32Array(10);
|
||||
@@ -674,7 +673,7 @@ describe('InputHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
bufferService = new MockBufferService(80, 30);
|
||||
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
});
|
||||
it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => {
|
||||
await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST');
|
||||
@@ -887,7 +886,7 @@ describe('InputHandler', () => {
|
||||
describe('colon notation', () => {
|
||||
let inputHandler2: TestInputHandler;
|
||||
beforeEach(() => {
|
||||
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
});
|
||||
describe('should equal to semicolon', () => {
|
||||
it('CSI 38:2::50:100:150 m', async () => {
|
||||
@@ -2002,15 +2001,15 @@ describe('InputHandler', () => {
|
||||
|
||||
// eAttrs in buffer pos 0 and 1 should be the same object
|
||||
assert.equal(
|
||||
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[0],
|
||||
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[1]
|
||||
extendedAttributes(bufferService.buffer!.lines.get(0)!, 0),
|
||||
extendedAttributes(bufferService.buffer!.lines.get(0)!, 1)
|
||||
);
|
||||
// should not have written eAttr for pos 2 in the buffer
|
||||
assert.equal((bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[2], undefined);
|
||||
assert.equal(extendedAttributes(bufferService.buffer!.lines.get(0)!, 2), undefined);
|
||||
// eAttrs in buffer pos 1 and pos 3 must be different objs
|
||||
assert.notEqual(
|
||||
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[1],
|
||||
(bufferService.buffer!.lines.get(0)! as any)._extendedAttrs[3]
|
||||
extendedAttributes(bufferService.buffer!.lines.get(0)!, 1),
|
||||
extendedAttributes(bufferService.buffer!.lines.get(0)!, 3)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2461,7 +2460,7 @@ describe('InputHandler', () => {
|
||||
bufferService = new BufferService(optionsService, new MockLogService());
|
||||
bufferService.resize(80, 30);
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
});
|
||||
|
||||
describe('stack limit', () => {
|
||||
@@ -2513,7 +2512,7 @@ describe('InputHandler', () => {
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
coreService.onData(data => { console.log(data); });
|
||||
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
|
||||
});
|
||||
|
||||
it('async CUP with CPR check', async () => {
|
||||
|
||||
+84
-85
@@ -11,11 +11,13 @@ import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { getWrappedRangeEnd } from 'common/buffer/BufferLineUtils';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
|
||||
import { ICoreService, IBufferService, IOptionsService, ILogService, IMouseStateService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
import { OscHandler } from 'common/parser/OscParser';
|
||||
import { DcsHandler } from 'common/parser/DcsParser';
|
||||
@@ -30,28 +32,9 @@ import { XTERM_VERSION } from 'common/Version';
|
||||
*/
|
||||
const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2 };
|
||||
|
||||
/**
|
||||
* VT commands done by the parser - FIXME: move this to the parser?
|
||||
*/
|
||||
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
|
||||
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
|
||||
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
|
||||
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
|
||||
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
|
||||
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
|
||||
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
|
||||
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
|
||||
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
|
||||
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
|
||||
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
|
||||
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
|
||||
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
|
||||
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
|
||||
|
||||
/**
|
||||
* Document xterm VT features here that are currently unsupported
|
||||
*/
|
||||
// @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
|
||||
// @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps \| Pt ST" "Definitions for user-defined keys."
|
||||
// @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String."
|
||||
// @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data."
|
||||
@@ -60,12 +43,13 @@ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-':
|
||||
/**
|
||||
* Max length of the UTF32 input buffer. Real memory consumption is 4 times higher.
|
||||
*/
|
||||
const MAX_PARSEBUFFER_LENGTH = 131072;
|
||||
|
||||
/**
|
||||
* Limit length of title and icon name stacks.
|
||||
*/
|
||||
const STACK_LIMIT = 10;
|
||||
const enum Constants {
|
||||
MAX_PARSEBUFFER_LENGTH = 131072,
|
||||
/** Limit length of title and icon name stacks. */
|
||||
STACK_LIMIT = 10,
|
||||
// create a warning log if an async handler takes longer than the limit (in ms)
|
||||
SLOW_ASYNC_LIMIT = 5000
|
||||
}
|
||||
|
||||
// map params to window option
|
||||
function paramToWindowOption(n: number, opts: IWindowOptions): boolean {
|
||||
@@ -104,9 +88,6 @@ export enum WindowsOptionsReportType {
|
||||
GET_CELL_SIZE_PIXELS = 1
|
||||
}
|
||||
|
||||
// create a warning log if an async handler takes longer than the limit (in ms)
|
||||
const SLOW_ASYNC_LIMIT = 5000;
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $temp = 0;
|
||||
|
||||
@@ -178,7 +159,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
private readonly _logService: ILogService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _oscLinkService: IOscLinkService,
|
||||
private readonly _coreMouseService: ICoreMouseService,
|
||||
private readonly _mouseStateService: IMouseStateService,
|
||||
private readonly _unicodeService: IUnicodeService,
|
||||
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
|
||||
) {
|
||||
@@ -211,6 +192,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
}
|
||||
this._logService.debug('Unknown DCS code: ', { identifier: this._parser.identToString(ident), action, payload });
|
||||
});
|
||||
this._parser.setApcHandlerFallback((ident, action, payload) => {
|
||||
this._logService.debug('Unknown APC code: ', { identifier: this._parser.identToString(ident), action, payload });
|
||||
});
|
||||
|
||||
/**
|
||||
* print handler
|
||||
@@ -409,7 +393,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (this._logService.logLevel <= LogLevelEnum.WARN) {
|
||||
let slowTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const slowPromise = new Promise<never>((_res, rej) => {
|
||||
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), SLOW_ASYNC_LIMIT);
|
||||
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), Constants.SLOW_ASYNC_LIMIT);
|
||||
});
|
||||
Promise.race([p, slowPromise])
|
||||
.then(() => {
|
||||
@@ -423,7 +407,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (err !== '#SLOW_TIMEOUT') {
|
||||
throw err;
|
||||
}
|
||||
console.warn(`async parser handler taking longer than ${SLOW_ASYNC_LIMIT} ms`);
|
||||
console.warn(`async parser handler taking longer than ${Constants.SLOW_ASYNC_LIMIT} ms`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -461,8 +445,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
cursorStartX = this._parseStack.cursorStartX;
|
||||
cursorStartY = this._parseStack.cursorStartY;
|
||||
this._parseStack.paused = false;
|
||||
if (data.length > MAX_PARSEBUFFER_LENGTH) {
|
||||
start = this._parseStack.position + MAX_PARSEBUFFER_LENGTH;
|
||||
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
start = this._parseStack.position + Constants.MAX_PARSEBUFFER_LENGTH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,8 +463,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
|
||||
// resize input buffer if needed
|
||||
if (this._parseBuffer.length < data.length) {
|
||||
if (this._parseBuffer.length < MAX_PARSEBUFFER_LENGTH) {
|
||||
this._parseBuffer = new Uint32Array(Math.min(data.length, MAX_PARSEBUFFER_LENGTH));
|
||||
if (this._parseBuffer.length < Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
this._parseBuffer = new Uint32Array(Math.min(data.length, Constants.MAX_PARSEBUFFER_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,9 +475,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
}
|
||||
|
||||
// process big data in smaller chunks
|
||||
if (data.length > MAX_PARSEBUFFER_LENGTH) {
|
||||
for (let i = start; i < data.length; i += MAX_PARSEBUFFER_LENGTH) {
|
||||
const end = i + MAX_PARSEBUFFER_LENGTH < data.length ? i + MAX_PARSEBUFFER_LENGTH : data.length;
|
||||
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
for (let i = start; i < data.length; i += Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
const end = i + Constants.MAX_PARSEBUFFER_LENGTH < data.length ? i + Constants.MAX_PARSEBUFFER_LENGTH : data.length;
|
||||
const len = (typeof data === 'string')
|
||||
? this._stringDecoder.decode(data.substring(i, end), this._parseBuffer)
|
||||
: this._utf8Decoder.decode(data.subarray(i, end), this._parseBuffer);
|
||||
@@ -585,8 +569,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (screenReaderMode) {
|
||||
this._onA11yChar.fire(stringFromCodePoint(code));
|
||||
}
|
||||
if (this._getCurrentLinkId()) {
|
||||
this._oscLinkService.addLineToLink(this._getCurrentLinkId(), this._activeBuffer.ybase + this._activeBuffer.y);
|
||||
const linkId = this._getCurrentLinkId();
|
||||
if (linkId) {
|
||||
this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y);
|
||||
}
|
||||
|
||||
// goto next line if ch would overflow
|
||||
@@ -607,8 +592,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (this._activeBuffer.y >= this._bufferService.rows) {
|
||||
this._activeBuffer.y = this._bufferService.rows - 1;
|
||||
}
|
||||
// The line already exists (eg. the initial viewport), mark it as a
|
||||
// wrapped line
|
||||
// The line already exists (eg. the initial viewport), mark it as a wrapped line
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true;
|
||||
}
|
||||
// row changed, get it again
|
||||
@@ -729,8 +713,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
/**
|
||||
* Forward registerApcHandler from parser.
|
||||
*/
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._parser.registerApcHandler(ident, new ApcHandler(callback));
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._parser.registerApcHandler(id, new ApcHandler(callback));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,7 +757,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
// reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics
|
||||
// can mess with this so windowsMode should be disabled, which is recommended on Windows build
|
||||
// 21376 and above.
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
|
||||
}
|
||||
// If the end of the line is hit, prevent this action from wrapping around to the next line.
|
||||
if (this._activeBuffer.x >= this._bufferService.cols) {
|
||||
@@ -837,7 +821,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
&& this._activeBuffer.y > this._activeBuffer.scrollTop
|
||||
&& this._activeBuffer.y <= this._activeBuffer.scrollBottom
|
||||
&& this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) {
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
|
||||
this._activeBuffer.y--;
|
||||
this._activeBuffer.x = this._bufferService.cols - 1;
|
||||
// find last taken cell - last cell can have 3 different states:
|
||||
@@ -1201,21 +1185,43 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
respectProtect
|
||||
);
|
||||
if (clearWrap) {
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _unwrapBufferRow(y: number, preserveContent: boolean): void {
|
||||
const line = this._activeBuffer.lines.get(y);
|
||||
if (!line?.isWrapped) {
|
||||
return;
|
||||
}
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
if (preserveContent) {
|
||||
this._activeBuffer.lines.set(y, line.materializeToStandalone(this._activeBuffer.getNullCell(this._eraseAttrData())));
|
||||
} else {
|
||||
this._activeBuffer.clearWrappedRow(y, this._activeBuffer.getNullCell(this._eraseAttrData()), false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
line.isWrapped = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to reset cells in a terminal row. The cell gets replaced with the eraseChar of
|
||||
* the terminal and the isWrapped property is set to false.
|
||||
* @param y row index
|
||||
*/
|
||||
private _resetBufferLine(y: number, respectProtect: boolean = false): void {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y);
|
||||
const absY = this._activeBuffer.ybase + y;
|
||||
const nullCell = this._activeBuffer.getNullCell(this._eraseAttrData());
|
||||
const last = getWrappedRangeEnd(this._activeBuffer.lines, absY);
|
||||
for (let i = last; i > absY; i--) {
|
||||
this._activeBuffer.clearWrappedRow(i, nullCell);
|
||||
}
|
||||
const line = this._activeBuffer.lines.get(absY);
|
||||
if (line) {
|
||||
line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect);
|
||||
this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y);
|
||||
line.isWrapped = false;
|
||||
line.fill(nullCell, respectProtect);
|
||||
this._bufferService.buffer.clearMarkers(absY);
|
||||
this._activeBuffer.clearWrappedRow(absY, nullCell);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,10 +1269,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect);
|
||||
if (this._activeBuffer.x + 1 >= this._bufferService.cols) {
|
||||
// Deleted entire previous line. This next line can no longer be wrapped.
|
||||
const nextLine = this._activeBuffer.lines.get(j + 1);
|
||||
if (nextLine) {
|
||||
nextLine.isWrapped = false;
|
||||
}
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + j + 1, true);
|
||||
}
|
||||
while (j--) {
|
||||
this._resetBufferLine(j, respectProtect);
|
||||
@@ -1530,7 +1533,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1563,7 +1566,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1586,7 +1589,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1609,7 +1612,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1987,19 +1990,19 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
break;
|
||||
case 9: // X10 Mouse
|
||||
// no release, no motion, no wheel, no modifiers.
|
||||
this._coreMouseService.activeProtocol = 'X10';
|
||||
this._mouseStateService.activeProtocol = 'X10';
|
||||
break;
|
||||
case 1000: // vt200 mouse
|
||||
// no motion.
|
||||
this._coreMouseService.activeProtocol = 'VT200';
|
||||
this._mouseStateService.activeProtocol = 'VT200';
|
||||
break;
|
||||
case 1002: // button event mouse
|
||||
this._coreMouseService.activeProtocol = 'DRAG';
|
||||
this._mouseStateService.activeProtocol = 'DRAG';
|
||||
break;
|
||||
case 1003: // any event mouse
|
||||
// any event - sends motion events,
|
||||
// even if there is no button held down.
|
||||
this._coreMouseService.activeProtocol = 'ANY';
|
||||
this._mouseStateService.activeProtocol = 'ANY';
|
||||
break;
|
||||
case 1004: // send focusin/focusout events
|
||||
// focusin: ^[[I
|
||||
@@ -2011,13 +2014,13 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
this._logService.debug('DECSET 1005 not supported (see #2507)');
|
||||
break;
|
||||
case 1006: // sgr ext mode mouse
|
||||
this._coreMouseService.activeEncoding = 'SGR';
|
||||
this._mouseStateService.activeEncoding = 'SGR';
|
||||
break;
|
||||
case 1015: // urxvt ext mode mouse - removed in #2507
|
||||
this._logService.debug('DECSET 1015 not supported (see #2507)');
|
||||
break;
|
||||
case 1016: // sgr pixels mode mouse
|
||||
this._coreMouseService.activeEncoding = 'SGR_PIXELS';
|
||||
this._mouseStateService.activeEncoding = 'SGR_PIXELS';
|
||||
break;
|
||||
case 25: // show cursor
|
||||
this._coreService.isCursorHidden = false;
|
||||
@@ -2248,7 +2251,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
case 1000: // vt200 mouse
|
||||
case 1002: // button event mouse
|
||||
case 1003: // any event mouse
|
||||
this._coreMouseService.activeProtocol = 'NONE';
|
||||
this._mouseStateService.activeProtocol = 'NONE';
|
||||
break;
|
||||
case 1004: // send focusin/focusout events
|
||||
this._coreService.decPrivateModes.sendFocus = false;
|
||||
@@ -2257,13 +2260,13 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
this._logService.debug('DECRST 1005 not supported (see #2507)');
|
||||
break;
|
||||
case 1006: // sgr ext mode mouse
|
||||
this._coreMouseService.activeEncoding = 'DEFAULT';
|
||||
this._mouseStateService.activeEncoding = 'DEFAULT';
|
||||
break;
|
||||
case 1015: // urxvt ext mode mouse - removed in #2507
|
||||
this._logService.debug('DECRST 1015 not supported (see #2507)');
|
||||
break;
|
||||
case 1016: // sgr pixels mode mouse
|
||||
this._coreMouseService.activeEncoding = 'DEFAULT';
|
||||
this._mouseStateService.activeEncoding = 'DEFAULT';
|
||||
break;
|
||||
case 25: // hide cursor
|
||||
this._coreService.isCursorHidden = true;
|
||||
@@ -2357,7 +2360,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
|
||||
// access helpers
|
||||
const dm = this._coreService.decPrivateModes;
|
||||
const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._coreMouseService;
|
||||
const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._mouseStateService;
|
||||
const cs = this._coreService;
|
||||
const { buffers, cols } = this._bufferService;
|
||||
const { active, alt } = buffers;
|
||||
@@ -2415,7 +2418,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
color &= ~Attributes.RGB_MASK;
|
||||
color |= AttributeData.fromColorRGB([c1, c2, c3]);
|
||||
} else if (mode === 5) {
|
||||
color &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
color &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
color |= Attributes.CM_P256 | (c1 & 0xff);
|
||||
}
|
||||
return color;
|
||||
@@ -2608,10 +2611,6 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
* | 3 | CMY color. | #N |
|
||||
* | 4 | CMYK color. | #N |
|
||||
* | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y |
|
||||
*
|
||||
*
|
||||
* FIXME: blinking is implemented in attrs, but not working in renderers?
|
||||
* FIXME: remove dead branch for p=100
|
||||
*/
|
||||
public charAttributes(params: IParams): boolean {
|
||||
// Optimize a single SGR0.
|
||||
@@ -2628,19 +2627,19 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
p = params.params[i];
|
||||
if (p >= 30 && p <= 37) {
|
||||
// fg color 8
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= Attributes.CM_P16 | (p - 30);
|
||||
} else if (p >= 40 && p <= 47) {
|
||||
// bg color 8
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= Attributes.CM_P16 | (p - 40);
|
||||
} else if (p >= 90 && p <= 97) {
|
||||
// fg color 16
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= Attributes.CM_P16 | (p - 90) | 8;
|
||||
} else if (p >= 100 && p <= 107) {
|
||||
// bg color 16
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= Attributes.CM_P16 | (p - 100) | 8;
|
||||
} else if (p === 0) {
|
||||
// default
|
||||
@@ -2700,11 +2699,11 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
} else if (p === 39) {
|
||||
// reset fg
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= DEFAULT_ATTR_DATA.fg & Attributes.RGB_MASK;
|
||||
} else if (p === 49) {
|
||||
// reset bg
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= DEFAULT_ATTR_DATA.bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= DEFAULT_ATTR_DATA.bg & Attributes.RGB_MASK;
|
||||
} else if (p === 38 || p === 48 || p === 58) {
|
||||
// fg color 256 and RGB
|
||||
i += this._extractColor(params, i, attr);
|
||||
@@ -2971,13 +2970,13 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
case 22: // PushTitle
|
||||
if (second === 0 || second === 2) {
|
||||
this._windowTitleStack.push(this._windowTitle);
|
||||
if (this._windowTitleStack.length > STACK_LIMIT) {
|
||||
if (this._windowTitleStack.length > Constants.STACK_LIMIT) {
|
||||
this._windowTitleStack.shift();
|
||||
}
|
||||
}
|
||||
if (second === 0 || second === 1) {
|
||||
this._iconNameStack.push(this._iconName);
|
||||
if (this._iconNameStack.length > STACK_LIMIT) {
|
||||
if (this._iconNameStack.length > Constants.STACK_LIMIT) {
|
||||
this._iconNameStack.shift();
|
||||
}
|
||||
}
|
||||
@@ -3497,7 +3496,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
const line = this._activeBuffer.lines.get(row);
|
||||
if (line) {
|
||||
line.fill(cell);
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(row, true);
|
||||
}
|
||||
}
|
||||
this._dirtyRowTracker.markAllDirty();
|
||||
@@ -3570,7 +3569,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
return true;
|
||||
}
|
||||
const flags = params.params[0] || 0;
|
||||
const mode = params.params[1] || 1;
|
||||
const mode = params.length > 1 ? (params.params[1] || 1) : 1;
|
||||
const state = this._coreService.kittyKeyboard;
|
||||
|
||||
switch (mode) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder';
|
||||
|
||||
describe('StringBuilder', () => {
|
||||
it('should start empty', () => {
|
||||
const builder = new StringBuilder();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should append a single chunk', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'hello');
|
||||
});
|
||||
|
||||
it('should join multiple chunks in order', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('foo');
|
||||
builder.append('bar');
|
||||
builder.append('baz');
|
||||
assert.strictEqual(builder.length, 9);
|
||||
assert.strictEqual(builder.toString(), 'foobarbaz');
|
||||
});
|
||||
|
||||
it('should handle empty chunks', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('');
|
||||
builder.append('a');
|
||||
builder.append('');
|
||||
assert.strictEqual(builder.length, 1);
|
||||
assert.strictEqual(builder.toString(), 'a');
|
||||
});
|
||||
|
||||
it('should reset accumulated data', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending after reset', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('old');
|
||||
builder.reset();
|
||||
builder.append('new');
|
||||
assert.strictEqual(builder.toString(), 'new');
|
||||
});
|
||||
|
||||
it('should accumulate many small chunks without quadratic concatenation', () => {
|
||||
const builder = new StringBuilder();
|
||||
const chunk = 'x';
|
||||
const count = 10000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
builder.append(chunk);
|
||||
}
|
||||
assert.strictEqual(builder.length, count);
|
||||
assert.strictEqual(builder.toString(), 'x'.repeat(count));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LimitedStringBuilder', () => {
|
||||
it('should expose the configured limit', () => {
|
||||
const builder = new LimitedStringBuilder(42);
|
||||
assert.strictEqual(builder.limit, 42);
|
||||
});
|
||||
|
||||
it('should start empty', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should accept data up to the limit', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.append('12345'), false);
|
||||
assert.strictEqual(builder.append('67890'), false);
|
||||
assert.strictEqual(builder.length, 10);
|
||||
assert.strictEqual(builder.toString(), '1234567890');
|
||||
});
|
||||
|
||||
it('should accept a single chunk exactly at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
assert.strictEqual(builder.append('abcde'), false);
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'abcde');
|
||||
});
|
||||
|
||||
it('should reject data exceeding the limit and clear the buffer', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
builder.append('abc');
|
||||
assert.strictEqual(builder.append('def'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject a single chunk larger than the limit', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('toolong'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending again after reset following a limit breach', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('abcd'), true);
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.append('ab'), false);
|
||||
assert.strictEqual(builder.toString(), 'ab');
|
||||
});
|
||||
|
||||
it('should accumulate many chunks before hitting the limit', () => {
|
||||
const limit = 100;
|
||||
const builder = new LimitedStringBuilder(limit);
|
||||
const chunk = 'A';
|
||||
for (let i = 0; i < limit; i++) {
|
||||
assert.strictEqual(builder.append(chunk), false);
|
||||
}
|
||||
assert.strictEqual(builder.toString(), 'A'.repeat(limit));
|
||||
assert.strictEqual(builder.append('B'), true);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject when limit is zero and any data is appended', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append('a'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
});
|
||||
|
||||
it('should allow zero-length appends at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append(''), false);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accumulates string data from multiple chunks without O(n²) string concatenation.
|
||||
*/
|
||||
export class StringBuilder {
|
||||
private _chunks: string[] = [];
|
||||
private _length = 0;
|
||||
|
||||
public get length(): number {
|
||||
return this._length;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._chunks.length = 0;
|
||||
this._length = 0;
|
||||
}
|
||||
|
||||
public append(chunk: string): void {
|
||||
this._chunks.push(chunk);
|
||||
this._length += chunk.length;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._chunks.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String builder that rejects payloads larger than a fixed limit.
|
||||
*/
|
||||
export class LimitedStringBuilder {
|
||||
private readonly _builder = new StringBuilder();
|
||||
|
||||
constructor(private readonly _limit: number) { }
|
||||
|
||||
public get length(): number {
|
||||
return this._builder.length;
|
||||
}
|
||||
|
||||
public get limit(): number {
|
||||
return this._limit;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._builder.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the limit was exceeded (buffer is cleared in that case)
|
||||
*/
|
||||
public append(chunk: string): boolean {
|
||||
this._builder.append(chunk);
|
||||
if (this._builder.length > this._limit) {
|
||||
this._builder.reset();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { isNode } from 'common/Platform';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
interface ITaskQueue {
|
||||
@@ -148,7 +147,7 @@ class IdleTaskQueueInternal extends TaskQueue {
|
||||
* This reverts to a {@link PriorityTaskQueue} if the environment does not support idle callbacks.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? IdleTaskQueueInternal : PriorityTaskQueue;
|
||||
export const IdleTaskQueue = ('requestIdleCallback' in globalThis) ? IdleTaskQueueInternal : PriorityTaskQueue;
|
||||
|
||||
/**
|
||||
* An object that tracks a single debounced task that will run on the next idle frame. When called
|
||||
@@ -169,4 +168,8 @@ export class DebouncedIdleTask {
|
||||
public flush(): void {
|
||||
this._queue.flush();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,29 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { IBufferSet, IBuffer } from 'common/buffer/Types';
|
||||
import { BufferSet } from 'common/buffer/BufferSet';
|
||||
import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData, IDisposable } from 'common/Types';
|
||||
import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData, IDisposable, IBufferLine, IExtendedAttrs } from 'common/Types';
|
||||
import { UnicodeV6 } from 'common/input/UnicodeV6';
|
||||
import { IDecorationOptions, IDecoration } from '@xterm/xterm';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
|
||||
export function createCellData(attr: number, char: string, width: number): CellData {
|
||||
return CellData.fromCharData([attr, char, width, char.length === 0 ? 0 : char.charCodeAt(0)]);
|
||||
}
|
||||
|
||||
export function extendedAttributes(line: IBufferLine, index: number): IExtendedAttrs | undefined {
|
||||
const cell = new CellData();
|
||||
line.loadCell(index, cell);
|
||||
return cell.hasExtendedAttrs() !== 0 ? cell.extended : undefined;
|
||||
}
|
||||
|
||||
export const NULL_CELL_DATA = Object.freeze(createCellData(DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH));
|
||||
|
||||
export class MockBufferService implements IBufferService {
|
||||
public serviceBrand: any;
|
||||
@@ -58,22 +71,21 @@ export class MockBufferService implements IBufferService {
|
||||
public reset(): void { }
|
||||
}
|
||||
|
||||
export class MockCoreMouseService implements ICoreMouseService {
|
||||
export class MockMouseStateService implements IMouseStateService {
|
||||
public serviceBrand: any;
|
||||
public areMouseEventsActive: boolean = false;
|
||||
public activeEncoding: string = '';
|
||||
public activeProtocol: string = '';
|
||||
public isDefaultEncoding: boolean = true;
|
||||
public isPixelEncoding: boolean = false;
|
||||
public addEncoding(name: string): void { }
|
||||
public addProtocol(name: string): void { }
|
||||
public reset(): void { }
|
||||
public triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; }
|
||||
public onProtocolChange: IEvent<CoreMouseEventType> = new Emitter<CoreMouseEventType>().event;
|
||||
public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
public consumeWheelEvent(ev: WheelEvent, cellHeight: number, dpr: number): number {
|
||||
return 1;
|
||||
}
|
||||
public restrictMouseEvent(event: ICoreMouseEvent): boolean { return true; }
|
||||
public encodeMouseEvent(event: ICoreMouseEvent): string { return ''; }
|
||||
public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void { }
|
||||
public allowCustomWheelEvent(ev: WheelEvent): boolean { return true; }
|
||||
}
|
||||
|
||||
export class MockCharsetService implements ICharsetService {
|
||||
@@ -144,7 +156,7 @@ export class MockLogService implements ILogService {
|
||||
|
||||
export class MockOptionsService implements IOptionsService {
|
||||
public serviceBrand: any;
|
||||
public readonly rawOptions: Required<ITerminalOptions> = clone(DEFAULT_OPTIONS);
|
||||
public readonly rawOptions: Required<ITerminalOptions> = structuredClone(DEFAULT_OPTIONS);
|
||||
public options: Required<ITerminalOptions> = this.rawOptions;
|
||||
public onOptionChange: IEvent<keyof ITerminalOptions> = new Emitter<keyof ITerminalOptions>().event;
|
||||
constructor(testOptions?: Partial<ITerminalOptions>) {
|
||||
|
||||
+26
-26
@@ -7,12 +7,12 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
|
||||
import { UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { IBufferSet } from 'common/buffer/Types';
|
||||
import { IParams } from 'common/parser/Types';
|
||||
import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
|
||||
import { IMouseStateService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
|
||||
import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from '@xterm/xterm';
|
||||
import type { Emitter, IEvent } from 'common/Event';
|
||||
|
||||
export interface ICoreTerminal {
|
||||
coreMouseService: ICoreMouseService;
|
||||
mouseStateService: IMouseStateService;
|
||||
coreService: ICoreService;
|
||||
optionsService: IOptionsService;
|
||||
unicodeService: IUnicodeService;
|
||||
@@ -22,7 +22,7 @@ export interface ICoreTerminal {
|
||||
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
|
||||
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
|
||||
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
}
|
||||
|
||||
export interface IDisposable {
|
||||
@@ -101,13 +101,13 @@ export interface ICharset {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export type CharData = [number, string, number, number];
|
||||
export type CharData = [attr: number, char: string, width: number, code: number];
|
||||
|
||||
export interface IColor {
|
||||
readonly css: string;
|
||||
readonly rgba: number; // 32-bit int with rgba in each byte
|
||||
}
|
||||
export type IColorRGB = [number, number, number];
|
||||
export type IColorRGB = [red: number, green: number, blue: number];
|
||||
|
||||
export interface IExtendedAttrs {
|
||||
ext: number;
|
||||
@@ -343,7 +343,7 @@ export interface ICoreMouseEvent {
|
||||
* it is not possible to report multiple buttons at once.
|
||||
* Wheel is treated as a button.
|
||||
* There are invalid combinations of buttons and actions possible
|
||||
* (like move + wheel), those are silently ignored by the CoreMouseService.
|
||||
* (like move + wheel), those are silently ignored by the MouseStateService.
|
||||
*/
|
||||
button: CoreMouseButton;
|
||||
action: CoreMouseAction;
|
||||
@@ -360,7 +360,7 @@ export interface ICoreMouseEvent {
|
||||
* CoreMouseEventType
|
||||
* To be reported to the browser component which events a mouse
|
||||
* protocol wants to be catched and forwarded as an ICoreMouseEvent
|
||||
* to CoreMouseService.
|
||||
* to MouseStateService.
|
||||
*/
|
||||
export const enum CoreMouseEventType {
|
||||
NONE = 0,
|
||||
@@ -378,7 +378,7 @@ export const enum CoreMouseEventType {
|
||||
|
||||
/**
|
||||
* Mouse protocol interface.
|
||||
* A mouse protocol can be registered and activated at the CoreMouseService.
|
||||
* A mouse protocol can be registered and activated at the MouseStateService.
|
||||
* `events` should contain a list of needed events as a hint for the browser component
|
||||
* to install/remove the appropriate event handlers.
|
||||
* `restrict` applies further protocol specific restrictions like not allowed
|
||||
@@ -391,7 +391,7 @@ export interface ICoreMouseProtocol {
|
||||
|
||||
/**
|
||||
* CoreMouseEncoding
|
||||
* The tracking encoding can be registered and activated at the CoreMouseService.
|
||||
* The tracking encoding can be registered and activated at the MouseStateService.
|
||||
* If a ICoreMouseEvent passes all procotol restrictions it will be encoded
|
||||
* with the active encoding and sent out.
|
||||
* Note: Returning an empty string will supress sending a mouse report,
|
||||
@@ -476,7 +476,7 @@ export interface IInputHandler {
|
||||
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
|
||||
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
|
||||
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
|
||||
/** C0 BEL */ bell(): boolean;
|
||||
/** C0 LF */ lineFeed(): boolean;
|
||||
@@ -528,8 +528,8 @@ export interface IInputHandler {
|
||||
/** CSI ' } */ insertColumns(params: IParams): boolean;
|
||||
/** CSI ' ~ */ deleteColumns(params: IParams): boolean;
|
||||
|
||||
/** OSC 0
|
||||
OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 0 */
|
||||
/** OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 4 */ setOrReportIndexedColor(data: string): boolean;
|
||||
/** OSC 10 */ setOrReportFgColor(data: string): boolean;
|
||||
/** OSC 11 */ setOrReportBgColor(data: string): boolean;
|
||||
@@ -542,24 +542,24 @@ export interface IInputHandler {
|
||||
/** ESC E */ nextLine(): boolean;
|
||||
/** ESC = */ keypadApplicationMode(): boolean;
|
||||
/** ESC > */ keypadNumericMode(): boolean;
|
||||
/** ESC % G
|
||||
ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C
|
||||
ESC ) C
|
||||
ESC * C
|
||||
ESC + C
|
||||
ESC - C
|
||||
ESC . C
|
||||
ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC % G */
|
||||
/** ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C */
|
||||
/** ESC ) C */
|
||||
/** ESC * C */
|
||||
/** ESC + C */
|
||||
/** ESC - C */
|
||||
/** ESC . C */
|
||||
/** ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC D */ index(): boolean;
|
||||
/** ESC H */ tabSet(): boolean;
|
||||
/** ESC M */ reverseIndex(): boolean;
|
||||
/** ESC c */ fullReset(): boolean;
|
||||
/** ESC n
|
||||
ESC o
|
||||
ESC |
|
||||
ESC }
|
||||
ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC n */
|
||||
/** ESC o */
|
||||
/** ESC | */
|
||||
/** ESC } */
|
||||
/** ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC # 8 */ screenAlignmentPattern(): boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,24 +6,36 @@
|
||||
import { assert } from 'chai';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { CircularList } from 'common/CircularList';
|
||||
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
|
||||
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
|
||||
const INIT_COLS = 80;
|
||||
const INIT_ROWS = 24;
|
||||
const INIT_SCROLLBACK = 1000;
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
class TestBuffer extends Buffer {
|
||||
public getStringCache(): BufferLineStringCache {
|
||||
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
|
||||
}
|
||||
|
||||
public getStringCacheClearTimeout(): unknown {
|
||||
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Buffer', () => {
|
||||
let optionsService: MockOptionsService;
|
||||
let bufferService: MockBufferService;
|
||||
let buffer: Buffer;
|
||||
let buffer: TestBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
|
||||
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
|
||||
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -151,7 +163,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('no scrollback', () => {
|
||||
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
buffer.y = INIT_ROWS - 1;
|
||||
buffer.fillViewportRows();
|
||||
@@ -1054,7 +1066,7 @@ describe('Buffer', () => {
|
||||
describe('buffer marked to have no scrollback', () => {
|
||||
it('should always have a scrollback of 0', () => {
|
||||
// Test size on initialization
|
||||
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
// Test size on buffer increase
|
||||
@@ -1068,7 +1080,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('addMarker', () => {
|
||||
it('should adjust a marker line when the buffer is trimmed', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
const marker = buffer.addMarker(buffer.lines.length - 1);
|
||||
assert.equal(marker.line, buffer.lines.length - 1);
|
||||
@@ -1076,7 +1088,7 @@ describe('Buffer', () => {
|
||||
assert.equal(marker.line, buffer.lines.length - 2);
|
||||
});
|
||||
it('should dispose of a marker if it is trimmed off the buffer', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
@@ -1088,7 +1100,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
it('should call onDispose', () => {
|
||||
const eventStack: string[] = [];
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
@@ -1104,11 +1116,11 @@ describe('Buffer', () => {
|
||||
|
||||
describe ('translateBufferLineToString', () => {
|
||||
it('should handle selecting a section of ascii text', () => {
|
||||
const line = new BufferLine(4);
|
||||
line.setCell(0, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([ 0, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([ 0, 'c', 1, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([ 0, 'd', 1, 'd'.charCodeAt(0)]));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 4);
|
||||
line.setCell(0, createCellData(0, 'a', 1));
|
||||
line.setCell(1, createCellData(0, 'b', 1));
|
||||
line.setCell(2, createCellData(0, 'c', 1));
|
||||
line.setCell(3, createCellData(0, 'd', 1));
|
||||
buffer.lines.set(0, line);
|
||||
|
||||
const str = buffer.translateBufferLineToString(0, true, 0, 2);
|
||||
@@ -1116,10 +1128,10 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a cut-off double width character by including it', () => {
|
||||
const line = new BufferLine(3);
|
||||
line.setCell(0, CellData.fromCharData([ 0, '語', 2, 35486 ]));
|
||||
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
|
||||
line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line);
|
||||
|
||||
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
|
||||
@@ -1127,10 +1139,10 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a zero width character in the middle of the string by not including it', () => {
|
||||
const line = new BufferLine(3);
|
||||
line.setCell(0, CellData.fromCharData([ 0, '語', 2, '語'.charCodeAt(0) ]));
|
||||
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
|
||||
line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line);
|
||||
|
||||
const str0 = buffer.translateBufferLineToString(0, true, 0, 1);
|
||||
@@ -1144,9 +1156,9 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle single width emojis', () => {
|
||||
const line = new BufferLine(2);
|
||||
line.setCell(0, CellData.fromCharData([ 0, '😁', 1, '😁'.charCodeAt(0) ]));
|
||||
line.setCell(1, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
line.setCell(0, createCellData(0, '😁', 1));
|
||||
line.setCell(1, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line);
|
||||
|
||||
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
|
||||
@@ -1157,9 +1169,9 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle double width emojis', () => {
|
||||
const line = new BufferLine(2);
|
||||
line.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
|
||||
line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
line.setCell(0, createCellData(0, '😁', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
buffer.lines.set(0, line);
|
||||
|
||||
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
|
||||
@@ -1168,10 +1180,10 @@ describe('Buffer', () => {
|
||||
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
|
||||
assert.equal(str2, '😁');
|
||||
|
||||
const line2 = new BufferLine(3);
|
||||
line2.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
|
||||
line2.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
|
||||
line2.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line2.setCell(0, createCellData(0, '😁', 2));
|
||||
line2.setCell(1, createCellData(0, '', 0));
|
||||
line2.setCell(2, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line2);
|
||||
|
||||
const str3 = buffer.translateBufferLineToString(0, true, 0, 3);
|
||||
@@ -1179,6 +1191,99 @@ describe('Buffer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('line string cache cleanup', () => {
|
||||
it('should clear shared cache entries with a single timer', () => {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const originalDateNow = Date.now;
|
||||
let timeoutId = 0;
|
||||
let now = 0;
|
||||
const clearedTimeouts: number[] = [];
|
||||
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
|
||||
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
|
||||
const id = ++timeoutId;
|
||||
scheduledTimeouts.set(id, {
|
||||
delay: timeout ?? 0,
|
||||
fire: () => {
|
||||
scheduledTimeouts.delete(id);
|
||||
handler();
|
||||
}
|
||||
});
|
||||
return id as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
|
||||
const numericId = id as unknown as number;
|
||||
clearedTimeouts.push(numericId);
|
||||
scheduledTimeouts.delete(numericId);
|
||||
}) as typeof clearTimeout;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 2);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
|
||||
const initialTimerCreationCount = timeoutId;
|
||||
|
||||
now = 5000;
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(timeoutId, initialTimerCreationCount);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.deepEqual(clearedTimeouts, []);
|
||||
|
||||
now = 15000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
assert.equal(timeoutId, initialTimerCreationCount + 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
|
||||
|
||||
now = 20000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('should reset line string cache state on clear and resize', () => {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
|
||||
buffer.clear();
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
|
||||
buffer.resize(INIT_COLS - 1, INIT_ROWS);
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory cleanup after shrinking', () => {
|
||||
it('should realign memory from idle task execution', async () => {
|
||||
buffer.fillViewportRows();
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
*/
|
||||
|
||||
import { CircularList, IInsertEvent } from 'common/CircularList';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IdleTaskQueue } from 'common/TaskQueue';
|
||||
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { attachWrappedRow, clearWrappedRow, createScrollWrappedLine } from 'common/buffer/BufferLineUtils';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
@@ -25,7 +28,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
|
||||
* - cursor position
|
||||
* - scroll position
|
||||
*/
|
||||
export class Buffer implements IBuffer {
|
||||
export class Buffer extends Disposable implements IBuffer {
|
||||
public lines: CircularList<IBufferLine>;
|
||||
public ydisp: number = 0;
|
||||
public ybase: number = 0;
|
||||
@@ -50,6 +53,7 @@ export class Buffer implements IBuffer {
|
||||
private _isClearing: boolean = false;
|
||||
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
|
||||
private _memoryCleanupPosition = 0;
|
||||
private readonly _stringCache: BufferLineStringCache;
|
||||
|
||||
constructor(
|
||||
private _hasScrollback: boolean,
|
||||
@@ -57,6 +61,7 @@ export class Buffer implements IBuffer {
|
||||
private _bufferService: IBufferService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
this._cols = this._bufferService.cols;
|
||||
this._rows = this._bufferService.rows;
|
||||
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
|
||||
@@ -64,6 +69,9 @@ export class Buffer implements IBuffer {
|
||||
this.scrollBottom = this._rows - 1;
|
||||
this.setupTabStops();
|
||||
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
|
||||
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
|
||||
this._register(toDisposable(() => this.clearAllMarkers()));
|
||||
this._stringCache = this._register(new BufferLineStringCache());
|
||||
}
|
||||
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
@@ -93,7 +101,19 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
|
||||
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
|
||||
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
|
||||
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), !!isWrapped);
|
||||
}
|
||||
|
||||
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
|
||||
return attachWrappedRow(this.lines, y, this._stringCache, this._cols, fillCellData);
|
||||
}
|
||||
|
||||
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent: boolean = true): void {
|
||||
clearWrappedRow(this.lines, y, fillCellData, this._stringCache, this._cols, preserveContent);
|
||||
}
|
||||
|
||||
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
|
||||
return createScrollWrappedLine(lineAbove, this._stringCache, this._cols, this.getNullCell(attr), attr);
|
||||
}
|
||||
|
||||
public get hasScrollback(): boolean {
|
||||
@@ -138,6 +158,7 @@ export class Buffer implements IBuffer {
|
||||
* Clears the buffer to it's initial state, discarding all previous data.
|
||||
*/
|
||||
public clear(): void {
|
||||
this._stringCache.clear();
|
||||
this.ydisp = 0;
|
||||
this.ybase = 0;
|
||||
this.y = 0;
|
||||
@@ -156,6 +177,7 @@ export class Buffer implements IBuffer {
|
||||
public resize(newCols: number, newRows: number): void {
|
||||
// store reference to null cell with default attrs
|
||||
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
|
||||
this._stringCache.clear();
|
||||
|
||||
// count bufferlines with overly big memory to be cleaned afterwards
|
||||
let dirtyMemoryLines = 0;
|
||||
@@ -190,7 +212,7 @@ export class Buffer implements IBuffer {
|
||||
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
|
||||
// Just add the new missing rows on Windows as conpty reprints the screen with it's
|
||||
// view of the world. Once a line enters scrollback for conpty it remains there
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
} else {
|
||||
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
|
||||
// There is room above the buffer and there are no empty elements below the line,
|
||||
@@ -204,7 +226,7 @@ export class Buffer implements IBuffer {
|
||||
} else {
|
||||
// Add a blank line if there is no buffer left at the top to scroll to, or if there
|
||||
// are blank lines after the cursor
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +367,7 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
if (this.lines.length < newRows) {
|
||||
// Add an extra row at the bottom of the viewport
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
}
|
||||
} else {
|
||||
if (this.ydisp === this.ybase) {
|
||||
|
||||
@@ -4,17 +4,41 @@
|
||||
*/
|
||||
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
|
||||
import { BufferLine } from 'common/buffer//BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { CharData, IBufferLine } from '../Types';
|
||||
import { CharData, IBufferLine, ICellData } from '../Types';
|
||||
import { assert } from 'chai';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
|
||||
class TestBufferLine extends BufferLine {
|
||||
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
|
||||
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
|
||||
}
|
||||
|
||||
public get combined(): {[index: number]: string} {
|
||||
return this._combined;
|
||||
}
|
||||
|
||||
public get cachedString(): string | undefined {
|
||||
return this._getStringCacheEntry(false)?.value;
|
||||
}
|
||||
|
||||
public set cachedString(value: string | undefined) {
|
||||
this._getStringCacheEntry(true)!.value = value;
|
||||
}
|
||||
|
||||
public get isCachedStringTrimmed(): boolean {
|
||||
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
|
||||
}
|
||||
|
||||
public set isCachedStringTrimmed(value: boolean) {
|
||||
this._getStringCacheEntry(true)!.isTrimmed = value;
|
||||
}
|
||||
|
||||
public toArray(): CharData[] {
|
||||
const result = [];
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
@@ -173,78 +197,78 @@ describe('BufferLine', function(): void {
|
||||
assert.equal(line.length, 10);
|
||||
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
assert.equal(line.isWrapped, true);
|
||||
line = new TestBufferLine(10, CellData.fromCharData([123, 'a', 456, 'a'.charCodeAt(0)]), true);
|
||||
line = new TestBufferLine(10, createCellData(123, 'a', 456), true);
|
||||
assert.equal(line.length, 10);
|
||||
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [123, 'a', 456, 'a'.charCodeAt(0)]);
|
||||
assert.equal(line.isWrapped, true);
|
||||
});
|
||||
it('insertCells', function(): void {
|
||||
const line = new TestBufferLine(3);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.insertCells(1, 3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.insertCells(1, 3, createCellData(4, 'd', 1));
|
||||
assert.deepEqual(line.toArray(), [
|
||||
[1, 'a', 0, 'a'.charCodeAt(0)],
|
||||
[4, 'd', 0, 'd'.charCodeAt(0)],
|
||||
[4, 'd', 0, 'd'.charCodeAt(0)]
|
||||
[1, 'a', 1, 'a'.charCodeAt(0)],
|
||||
[4, 'd', 1, 'd'.charCodeAt(0)],
|
||||
[4, 'd', 1, 'd'.charCodeAt(0)]
|
||||
]);
|
||||
});
|
||||
it('deleteCells', function(): void {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
line.deleteCells(1, 2, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.setCell(3, createCellData(4, 'd', 1));
|
||||
line.setCell(4, createCellData(5, 'e', 1));
|
||||
line.deleteCells(1, 2, createCellData(6, 'f', 1));
|
||||
assert.deepEqual(line.toArray(), [
|
||||
[1, 'a', 0, 'a'.charCodeAt(0)],
|
||||
[4, 'd', 0, 'd'.charCodeAt(0)],
|
||||
[5, 'e', 0, 'e'.charCodeAt(0)],
|
||||
[6, 'f', 0, 'f'.charCodeAt(0)],
|
||||
[6, 'f', 0, 'f'.charCodeAt(0)]
|
||||
[1, 'a', 1, 'a'.charCodeAt(0)],
|
||||
[4, 'd', 1, 'd'.charCodeAt(0)],
|
||||
[5, 'e', 1, 'e'.charCodeAt(0)],
|
||||
[6, 'f', 1, 'f'.charCodeAt(0)],
|
||||
[6, 'f', 1, 'f'.charCodeAt(0)]
|
||||
]);
|
||||
});
|
||||
it('replaceCells', function(): void {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
line.replaceCells(2, 4, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.setCell(3, createCellData(4, 'd', 1));
|
||||
line.setCell(4, createCellData(5, 'e', 1));
|
||||
line.replaceCells(2, 4, createCellData(6, 'f', 1));
|
||||
assert.deepEqual(line.toArray(), [
|
||||
[1, 'a', 0, 'a'.charCodeAt(0)],
|
||||
[2, 'b', 0, 'b'.charCodeAt(0)],
|
||||
[6, 'f', 0, 'f'.charCodeAt(0)],
|
||||
[6, 'f', 0, 'f'.charCodeAt(0)],
|
||||
[5, 'e', 0, 'e'.charCodeAt(0)]
|
||||
[1, 'a', 1, 'a'.charCodeAt(0)],
|
||||
[2, 'b', 1, 'b'.charCodeAt(0)],
|
||||
[6, 'f', 1, 'f'.charCodeAt(0)],
|
||||
[6, 'f', 1, 'f'.charCodeAt(0)],
|
||||
[5, 'e', 1, 'e'.charCodeAt(0)]
|
||||
]);
|
||||
});
|
||||
it('fill', function(): void {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
line.fill(CellData.fromCharData([123, 'z', 0, 'z'.charCodeAt(0)]));
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.setCell(3, createCellData(4, 'd', 1));
|
||||
line.setCell(4, createCellData(5, 'e', 1));
|
||||
line.fill(createCellData(123, 'z', 1));
|
||||
assert.deepEqual(line.toArray(), [
|
||||
[123, 'z', 0, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 0, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 0, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 0, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 0, 'z'.charCodeAt(0)]
|
||||
[123, 'z', 1, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 1, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 1, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 1, 'z'.charCodeAt(0)],
|
||||
[123, 'z', 1, 'z'.charCodeAt(0)]
|
||||
]);
|
||||
});
|
||||
it('clone', function(): void {
|
||||
const line = new TestBufferLine(5, undefined, true);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.setCell(3, createCellData(4, 'd', 1));
|
||||
line.setCell(4, createCellData(5, 'e', 1));
|
||||
const line2 = line.clone();
|
||||
assert.deepEqual(TestBufferLine.prototype.toArray.apply(line2), line.toArray());
|
||||
assert.equal(line2.length, line.length);
|
||||
@@ -252,12 +276,12 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('copyFrom', function(): void {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
|
||||
const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), true);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(2, 'b', 1));
|
||||
line.setCell(2, createCellData(3, 'c', 1));
|
||||
line.setCell(3, createCellData(4, 'd', 1));
|
||||
line.setCell(4, createCellData(5, 'e', 1));
|
||||
const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
|
||||
line2.copyFrom(line);
|
||||
assert.deepEqual(line2.toArray(), line.toArray());
|
||||
assert.equal(line2.length, line.length);
|
||||
@@ -267,9 +291,9 @@ describe('BufferLine', function(): void {
|
||||
// CHAR_DATA_CODE_INDEX resembles current behavior in InputHandler.print
|
||||
// --> set code to the last charCodeAt value of the string
|
||||
// Note: needs to be fixed once the string pointer is in place
|
||||
const line = new TestBufferLine(2, CellData.fromCharData([1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]));
|
||||
assert.deepEqual(line.toArray(), [[1, 'e\u0301', 0, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]]);
|
||||
const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, '\u0301'.charCodeAt(0)]), true);
|
||||
const line = new TestBufferLine(2, createCellData(1, 'e\u0301', 1));
|
||||
assert.deepEqual(line.toArray(), [[1, 'e\u0301', 1, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]]);
|
||||
const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
|
||||
line2.copyFrom(line);
|
||||
assert.deepEqual(line2.toArray(), line.toArray());
|
||||
const line3 = line.clone();
|
||||
@@ -277,72 +301,72 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
describe('resize', function(): void {
|
||||
it('enlarge(false)', function(): void {
|
||||
const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
|
||||
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
|
||||
line.resize(10, createCellData(1, 'a', 1));
|
||||
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
});
|
||||
it('enlarge(true)', function(): void {
|
||||
const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
|
||||
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
|
||||
line.resize(10, createCellData(1, 'a', 1));
|
||||
assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
});
|
||||
it('shrink(true) - should apply new size', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
|
||||
line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
|
||||
line.resize(5, createCellData(1, 'a', 1));
|
||||
assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
});
|
||||
it('shrink to 0 length', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
|
||||
line.resize(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
|
||||
line.resize(0, createCellData(1, 'a', 1));
|
||||
assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
});
|
||||
it('should remove combining data on replaced cells after shrinking then enlarging', () => {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
|
||||
const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
|
||||
line.set(2, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
|
||||
line.set(9, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
|
||||
assert.equal(line.translateToString(), 'aa😁aaaaaa😁');
|
||||
assert.equal(Object.keys(line.combined).length, 2);
|
||||
line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.resize(5, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aa😁aa');
|
||||
line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
|
||||
line.resize(10, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aa😁aaaaaaa');
|
||||
assert.equal(Object.keys(line.combined).length, 1);
|
||||
});
|
||||
});
|
||||
describe('getTrimLength', function(): void {
|
||||
it('empty line', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
assert.equal(line.getTrimmedLength(), 0);
|
||||
});
|
||||
it('ASCII', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, 'a', 1));
|
||||
assert.equal(line.getTrimmedLength(), 3);
|
||||
});
|
||||
it('surrogate', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, '𝄞', 1));
|
||||
assert.equal(line.getTrimmedLength(), 3);
|
||||
});
|
||||
it('combining', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, 'e\u0301', 1));
|
||||
assert.equal(line.getTrimmedLength(), 3);
|
||||
});
|
||||
it('fullwidth', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, '1', 2));
|
||||
line.setCell(3, createCellData(0, '', 0));
|
||||
assert.equal(line.getTrimmedLength(), 4); // also counts null cell after fullwidth
|
||||
});
|
||||
});
|
||||
describe('translateToString with and w\'o trimming', function(): void {
|
||||
it('empty line', function(): void {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
const columns: number[] = [];
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
@@ -351,11 +375,11 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('ASCII', function(): void {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, 'a', 1));
|
||||
line.setCell(4, createCellData(1, 'a', 1));
|
||||
line.setCell(5, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa');
|
||||
@@ -372,11 +396,11 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('surrogate', function(): void {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
|
||||
line.setCell(5, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, '𝄞', 1));
|
||||
line.setCell(4, createCellData(1, '𝄞', 1));
|
||||
line.setCell(5, createCellData(1, '𝄞', 1));
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 𝄞 𝄞𝄞 ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 𝄞 𝄞𝄞');
|
||||
@@ -392,11 +416,11 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('combining', function(): void {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
|
||||
line.setCell(5, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, 'e\u0301', 1));
|
||||
line.setCell(4, createCellData(1, 'e\u0301', 1));
|
||||
line.setCell(5, createCellData(1, 'e\u0301', 1));
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301 ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301');
|
||||
@@ -412,14 +436,14 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('fullwidth', function(): void {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
|
||||
line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
|
||||
line.setCell(5, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
|
||||
line.setCell(6, CellData.fromCharData([0, '', 0, 0]));
|
||||
line.setCell(7, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
|
||||
line.setCell(8, CellData.fromCharData([0, '', 0, 0]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, '1', 2));
|
||||
line.setCell(3, createCellData(0, '', 0));
|
||||
line.setCell(5, createCellData(1, '1', 2));
|
||||
line.setCell(6, createCellData(0, '', 0));
|
||||
line.setCell(7, createCellData(1, '1', 2));
|
||||
line.setCell(8, createCellData(0, '', 0));
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 1 11 ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 4, 5, 7, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 1 11');
|
||||
@@ -441,12 +465,12 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('space at end', function(): void {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.setCell(6, CellData.fromCharData([1, ' ', 1, ' '.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(2, createCellData(1, 'a', 1));
|
||||
line.setCell(4, createCellData(1, 'a', 1));
|
||||
line.setCell(5, createCellData(1, 'a', 1));
|
||||
line.setCell(6, createCellData(1, ' ', 1));
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa ');
|
||||
@@ -457,7 +481,7 @@ describe('BufferLine', function(): void {
|
||||
// sanity check - broken line with invalid out of bound null width cells
|
||||
// this can atm happen with deleting/inserting chars in inputhandler by "breaking"
|
||||
// fullwidth pairs --> needs to be fixed after settling BufferLine impl
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
|
||||
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(line.translateToString(true, undefined, undefined, columns), '');
|
||||
@@ -465,15 +489,15 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('should work with endCol=0', () => {
|
||||
const columns: number[] = [];
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(true, 0, 0, columns), '');
|
||||
assert.deepEqual(columns, [0]);
|
||||
});
|
||||
});
|
||||
describe('addCharToCell', () => {
|
||||
it('should set width to 1 for empty cell', () => {
|
||||
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
|
||||
line.addCodepointToCell(0, '\u0301'.charCodeAt(0), 0);
|
||||
const cell = line.loadCell(0, new CellData());
|
||||
// chars contains single combining char
|
||||
@@ -483,7 +507,7 @@ describe('BufferLine', function(): void {
|
||||
assert.equal(cell.isCombined(), 0);
|
||||
});
|
||||
it('should add char to combining string in cell', () => {
|
||||
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
|
||||
const cell = line .loadCell(0, new CellData());
|
||||
cell.setFromCharData([123, 'e\u0301', 1, 'e\u0301'.charCodeAt(1)]);
|
||||
line.setCell(0, cell);
|
||||
@@ -496,7 +520,7 @@ describe('BufferLine', function(): void {
|
||||
assert.equal(cell.isCombined(), Content.IS_COMBINED_MASK);
|
||||
});
|
||||
it('should create combining string on taken cell', () => {
|
||||
const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(3, NULL_CELL_DATA, false);
|
||||
const cell = line .loadCell(0, new CellData());
|
||||
cell.setFromCharData([123, 'e', 1, 'e'.charCodeAt(1)]);
|
||||
line.setCell(0, cell);
|
||||
@@ -511,98 +535,98 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
describe('correct fullwidth handling', () => {
|
||||
function populate(line: BufferLine): void {
|
||||
const cell = CellData.fromCharData([1, '¥', 2, '¥'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, '¥', 2);
|
||||
for (let i = 0; i < line.length; i += 2) {
|
||||
line.setCell(i, cell);
|
||||
}
|
||||
}
|
||||
it('insert - wide char at pos', () => {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.insertCells(9, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(9, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), '¥¥¥¥ a');
|
||||
line.insertCells(8, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(8, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), '¥¥¥¥a ');
|
||||
line.insertCells(1, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(1, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' a ¥¥¥a');
|
||||
});
|
||||
it('insert - wide char at end', () => {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.insertCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(0, 3, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaa¥¥¥ ');
|
||||
line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(4, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaa a ¥¥');
|
||||
line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.insertCells(4, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaa aa ¥ ');
|
||||
});
|
||||
it('delete', () => {
|
||||
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
const line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.deleteCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.deleteCells(0, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' ¥¥¥¥a');
|
||||
line.deleteCells(5, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.deleteCells(5, 2, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' ¥¥¥aaa');
|
||||
line.deleteCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.deleteCells(0, 2, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' ¥¥aaaaa');
|
||||
});
|
||||
it('replace - start at 0', () => {
|
||||
let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
let line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 1, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'a ¥¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 2, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aa¥¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 3, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaa ¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 8, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaaaaaaa¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 9, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaaaaaaaa ');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(0, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(0, 10, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), 'aaaaaaaaaa');
|
||||
});
|
||||
it('replace - start at 1', () => {
|
||||
let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
let line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 2, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' a¥¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 3, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' aa ¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 4, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' aaa¥¥¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 8, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' aaaaaaa¥');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 9, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' aaaaaaaa ');
|
||||
line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
|
||||
line = new TestBufferLine(10, NULL_CELL_DATA, false);
|
||||
populate(line);
|
||||
line.replaceCells(1, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
line.replaceCells(1, 10, createCellData(1, 'a', 1));
|
||||
assert.equal(line.translateToString(), ' aaaaaaaaa');
|
||||
});
|
||||
});
|
||||
describe('extended attributes', () => {
|
||||
it('setCells', function(): void {
|
||||
const line = new TestBufferLine(5);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
// no eAttrs
|
||||
line.setCell(0, cell);
|
||||
|
||||
@@ -612,7 +636,7 @@ describe('BufferLine', function(): void {
|
||||
line.setCell(1, cell);
|
||||
|
||||
// same eAttr, different codepoint
|
||||
cell.content = 65; // 'A'
|
||||
cell.content = createCellData(1, 'A', 1).content;
|
||||
line.setCell(2, cell);
|
||||
|
||||
// different eAttr
|
||||
@@ -625,25 +649,25 @@ describe('BufferLine', function(): void {
|
||||
line.setCell(4, cell);
|
||||
|
||||
assert.deepEqual(line.toArray(), [
|
||||
[1, 'a', 0, 'a'.charCodeAt(0)],
|
||||
[1, 'a', 0, 'a'.charCodeAt(0)],
|
||||
[1, 'A', 0, 'A'.charCodeAt(0)],
|
||||
[1, 'A', 0, 'A'.charCodeAt(0)],
|
||||
[1, 'A', 0, 'A'.charCodeAt(0)]
|
||||
[1, 'a', 1, 'a'.charCodeAt(0)],
|
||||
[1, 'a', 1, 'a'.charCodeAt(0)],
|
||||
[1, 'A', 1, 'A'.charCodeAt(0)],
|
||||
[1, 'A', 1, 'A'.charCodeAt(0)],
|
||||
[1, 'A', 1, 'A'.charCodeAt(0)]
|
||||
]);
|
||||
assert.equal((line as any)._extendedAttrs[0], undefined);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal((line as any)._extendedAttrs[4], undefined);
|
||||
assert.equal(extendedAttributes(line, 0), undefined);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal(extendedAttributes(line, 4)?.underlineStyle, undefined);
|
||||
// should be ref to the same object
|
||||
assert.equal((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[2]);
|
||||
assert.equal(extendedAttributes(line, 1), extendedAttributes(line, 2));
|
||||
// should be a different obj
|
||||
assert.notEqual((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[3]);
|
||||
assert.notEqual(extendedAttributes(line, 1), extendedAttributes(line, 3));
|
||||
});
|
||||
it('loadCell', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
// no eAttrs
|
||||
line.setCell(0, cell);
|
||||
|
||||
@@ -686,65 +710,65 @@ describe('BufferLine', function(): void {
|
||||
});
|
||||
it('fill', () => {
|
||||
const line = new TestBufferLine(3);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
cell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
cell.bg |= BgFlags.HAS_EXTENDED;
|
||||
line.fill(cell);
|
||||
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
});
|
||||
it('insertCells', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
cell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
cell.bg |= BgFlags.HAS_EXTENDED;
|
||||
line.insertCells(1, 3, cell);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[4], undefined);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 4), undefined);
|
||||
cell.extended = cell.extended.clone();
|
||||
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
|
||||
line.insertCells(2, 2, cell);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
|
||||
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
});
|
||||
it('deleteCells', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const fillCell = createCellData(1, 'a', 1);
|
||||
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
fillCell.bg |= BgFlags.HAS_EXTENDED;
|
||||
line.fill(fillCell);
|
||||
fillCell.extended = fillCell.extended.clone();
|
||||
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
|
||||
line.deleteCells(1, 3, fillCell);
|
||||
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.DOUBLE);
|
||||
});
|
||||
it('replaceCells', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const fillCell = createCellData(1, 'a', 1);
|
||||
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
fillCell.bg |= BgFlags.HAS_EXTENDED;
|
||||
line.fill(fillCell);
|
||||
fillCell.extended = fillCell.extended.clone();
|
||||
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
|
||||
line.replaceCells(1, 3, fillCell);
|
||||
assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
|
||||
assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
|
||||
});
|
||||
it('clone', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
// no eAttrs
|
||||
line.setCell(0, cell);
|
||||
|
||||
@@ -767,15 +791,15 @@ describe('BufferLine', function(): void {
|
||||
line.setCell(4, cell);
|
||||
|
||||
const nLine = line.clone();
|
||||
assert.equal((nLine as any)._extendedAttrs[0], (line as any)._extendedAttrs[0]);
|
||||
assert.equal((nLine as any)._extendedAttrs[1], (line as any)._extendedAttrs[1]);
|
||||
assert.equal((nLine as any)._extendedAttrs[2], (line as any)._extendedAttrs[2]);
|
||||
assert.equal((nLine as any)._extendedAttrs[3], (line as any)._extendedAttrs[3]);
|
||||
assert.equal((nLine as any)._extendedAttrs[4], (line as any)._extendedAttrs[4]);
|
||||
assert.equal(extendedAttributes(nLine, 0), extendedAttributes(line, 0));
|
||||
assert.equal(extendedAttributes(nLine, 1), extendedAttributes(line, 1));
|
||||
assert.equal(extendedAttributes(nLine, 2), extendedAttributes(line, 2));
|
||||
assert.equal(extendedAttributes(nLine, 3), extendedAttributes(line, 3));
|
||||
assert.equal(extendedAttributes(nLine, 4), extendedAttributes(line, 4));
|
||||
});
|
||||
it('copyFrom', () => {
|
||||
const initial = new TestBufferLine(5);
|
||||
const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
|
||||
const cell = createCellData(1, 'a', 1);
|
||||
// no eAttrs
|
||||
initial.setCell(0, cell);
|
||||
|
||||
@@ -798,13 +822,89 @@ describe('BufferLine', function(): void {
|
||||
initial.setCell(4, cell);
|
||||
|
||||
const line = new TestBufferLine(5);
|
||||
line.fill(CellData.fromCharData([1, 'b', 0, 'b'.charCodeAt(0)]));
|
||||
line.fill(createCellData(1, 'b', 1));
|
||||
line.copyFrom(initial);
|
||||
assert.equal((line as any)._extendedAttrs[0], (initial as any)._extendedAttrs[0]);
|
||||
assert.equal((line as any)._extendedAttrs[1], (initial as any)._extendedAttrs[1]);
|
||||
assert.equal((line as any)._extendedAttrs[2], (initial as any)._extendedAttrs[2]);
|
||||
assert.equal((line as any)._extendedAttrs[3], (initial as any)._extendedAttrs[3]);
|
||||
assert.equal((line as any)._extendedAttrs[4], (initial as any)._extendedAttrs[4]);
|
||||
assert.equal(extendedAttributes(line, 0), extendedAttributes(initial, 0));
|
||||
assert.equal(extendedAttributes(line, 1), extendedAttributes(initial, 1));
|
||||
assert.equal(extendedAttributes(line, 2), extendedAttributes(initial, 2));
|
||||
assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
|
||||
assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
|
||||
});
|
||||
|
||||
it('should cache canonical string translations', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(1, 'b', 1));
|
||||
line.setCell(2, createCellData(1, 'c', 1));
|
||||
|
||||
// Trimmed-only canonical request should cache the trimmed value.
|
||||
const trimmed = line.translateToString(true, undefined, undefined, undefined);
|
||||
assert.equal(trimmed, 'abc');
|
||||
assert.equal(line.cachedString, 'abc');
|
||||
assert.equal(line.isCachedStringTrimmed, true);
|
||||
|
||||
// Non-trimmed canonical request should refresh cache with the full value.
|
||||
const translated = line.translateToString(false, undefined, undefined, undefined);
|
||||
assert.equal(translated, 'abc ');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
// Once non-trimmed is cached, trimmed should be derived via trimEnd().
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
line.cachedString = 'cached-non-trimmed ';
|
||||
line.isCachedStringTrimmed = false;
|
||||
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed ');
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed');
|
||||
|
||||
line.cachedString = 'cached-trimmed';
|
||||
line.isCachedStringTrimmed = true;
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed');
|
||||
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc ');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
// Any optional translation argument should bypass cache.
|
||||
assert.equal(line.translateToString(false, 0, 2, undefined), 'ab');
|
||||
assert.equal(line.translateToString(true, 0, 2, undefined), 'ab');
|
||||
});
|
||||
|
||||
it('should invalidate cached canonical strings on line mutations', () => {
|
||||
const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => {
|
||||
const line = new TestBufferLine(5);
|
||||
line.fill(createCellData(1, 'a', 1));
|
||||
line.translateToString(true, undefined, undefined, undefined);
|
||||
assert.equal(line.cachedString, 'aaaaa');
|
||||
assert.equal(line.isCachedStringTrimmed, true);
|
||||
line.translateToString(false, undefined, undefined, undefined);
|
||||
assert.equal(line.cachedString, 'aaaaa');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
mutate(line);
|
||||
assert.equal(line.cachedString, undefined);
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
};
|
||||
|
||||
assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0));
|
||||
assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => {
|
||||
const src = new TestBufferLine(5);
|
||||
src.fill(createCellData(1, 'x', 1));
|
||||
line.copyFrom(src);
|
||||
});
|
||||
assertCacheInvalidated(line => {
|
||||
const src = new TestBufferLine(5);
|
||||
src.fill(createCellData(1, 'x', 1));
|
||||
line.copyCellsFrom(src, 0, 0, 2, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+369
-99
@@ -8,26 +8,28 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
|
||||
import { stringFromCodePoint } from 'common/input/TextDecoder';
|
||||
import { StringBuilder } from 'common/StringBuilder';
|
||||
|
||||
/**
|
||||
* buffer memory layout:
|
||||
*
|
||||
* | uint32_t | uint32_t | uint32_t |
|
||||
* | `content` | `FG` | `BG` |
|
||||
* | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) |
|
||||
*/
|
||||
// Buffer memory layout:
|
||||
//
|
||||
// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21)
|
||||
// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8)
|
||||
// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8)
|
||||
|
||||
|
||||
/** typed array slots taken by one cell */
|
||||
const CELL_SIZE = 3;
|
||||
const enum Constants {
|
||||
/** The number of 32 bit array indices taken by one cell. */
|
||||
CELL_INDICIES = 3,
|
||||
/** Factor when to cleanup underlying array buffer after shrinking. */
|
||||
CLEANUP_THRESHOLD = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell member indices.
|
||||
*
|
||||
* Direct access:
|
||||
* `content = data[column * CELL_SIZE + Cell.CONTENT];`
|
||||
* `fg = data[column * CELL_SIZE + Cell.FG];`
|
||||
* `bg = data[column * CELL_SIZE + Cell.BG];`
|
||||
* `content = data[column * Constants.CELL_INDICIES + Cell.CONTENT];`
|
||||
* `fg = data[column * Constants.CELL_INDICIES + Cell.FG];`
|
||||
* `bg = data[column * Constants.CELL_INDICIES + Cell.BG];`
|
||||
*/
|
||||
const enum Cell {
|
||||
CONTENT = 0,
|
||||
@@ -40,9 +42,19 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
|
||||
// Work variables to avoid garbage collection
|
||||
let $startIndex = 0;
|
||||
const $workCell = new CellData();
|
||||
const $translateToStringBuilder = new StringBuilder();
|
||||
|
||||
/** Factor when to cleanup underlying array buffer after shrinking. */
|
||||
const CLEANUP_THRESHOLD = 2;
|
||||
export interface IBufferLineStringCacheEntry {
|
||||
value: string | undefined;
|
||||
isTrimmed: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface IBufferLineStringCache {
|
||||
generation: number;
|
||||
allocateEntry(): IBufferLineStringCacheEntry;
|
||||
touch?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed array based bufferline implementation.
|
||||
@@ -63,15 +75,107 @@ export class BufferLine implements IBufferLine {
|
||||
protected _data: Uint32Array;
|
||||
protected _combined: {[index: number]: string} = {};
|
||||
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
|
||||
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
|
||||
/** When set, this row is a viewport slice of the logical line owned by this buffer line. */
|
||||
protected _logicalHead: BufferLine | undefined;
|
||||
protected _segmentStart: number = 0;
|
||||
protected _logicalCellCount: number = 0;
|
||||
protected _overflowRowCount: number = 0;
|
||||
protected _fillCellData: ICellData;
|
||||
public length: number;
|
||||
public isWrapped: boolean = false;
|
||||
|
||||
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
|
||||
this._data = new Uint32Array(cols * CELL_SIZE);
|
||||
constructor(
|
||||
protected readonly _stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData?: ICellData,
|
||||
isWrapped: boolean = false,
|
||||
logicalHead?: BufferLine,
|
||||
segmentStart?: number
|
||||
) {
|
||||
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
this._fillCellData = cell;
|
||||
if (logicalHead) {
|
||||
this._logicalHead = logicalHead;
|
||||
this._segmentStart = segmentStart ?? 0;
|
||||
this._data = logicalHead._data;
|
||||
this._combined = logicalHead._combined;
|
||||
this._extendedAttrs = logicalHead._extendedAttrs;
|
||||
this.length = cols;
|
||||
this.isWrapped = true;
|
||||
return;
|
||||
}
|
||||
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
|
||||
this._logicalCellCount = cols;
|
||||
for (let i = 0; i < cols; ++i) {
|
||||
this.setCell(i, cell);
|
||||
}
|
||||
this.length = cols;
|
||||
this.isWrapped = isWrapped;
|
||||
}
|
||||
|
||||
public get logicalCellCount(): number {
|
||||
return this._logicalHead ? this._logicalHead._logicalCellCount : this._logicalCellCount;
|
||||
}
|
||||
|
||||
public registerOverflowRow(): void {
|
||||
if (this._logicalHead) {
|
||||
this._logicalHead.registerOverflowRow();
|
||||
} else {
|
||||
this._overflowRowCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterOverflowRow(): void {
|
||||
if (this._logicalHead) {
|
||||
this._logicalHead.unregisterOverflowRow();
|
||||
} else if (this._overflowRowCount > 0) {
|
||||
this._overflowRowCount--;
|
||||
}
|
||||
}
|
||||
|
||||
public clearOverflowRows(): void {
|
||||
if (!this._logicalHead) {
|
||||
this._overflowRowCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** After reflow merges wrapped rows into the head, reset overflow tracking. */
|
||||
public clearReflowMergedState(): void {
|
||||
if (!this._logicalHead) {
|
||||
this._overflowRowCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public ensureLogicalCapacity(minCells: number, fillCellData: ICellData): void {
|
||||
const head = this._logicalHead ?? this;
|
||||
if (minCells <= head._logicalCellCount) {
|
||||
return;
|
||||
}
|
||||
head._invalidateStringCache();
|
||||
const uint32Cells = minCells * Constants.CELL_INDICIES;
|
||||
if (head._data.buffer.byteLength >= uint32Cells * 4) {
|
||||
head._data = new Uint32Array(head._data.buffer, 0, uint32Cells);
|
||||
} else {
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(head._data);
|
||||
head._data = data;
|
||||
}
|
||||
for (let i = head._logicalCellCount; i < minCells; ++i) {
|
||||
const start = i * Constants.CELL_INDICIES;
|
||||
head._data[start + Cell.CONTENT] = fillCellData.content;
|
||||
head._data[start + Cell.FG] = fillCellData.fg;
|
||||
head._data[start + Cell.BG] = fillCellData.bg;
|
||||
}
|
||||
head._logicalCellCount = minCells;
|
||||
}
|
||||
|
||||
protected _absoluteColumn(column: number): number {
|
||||
return this._segmentStart + column;
|
||||
}
|
||||
|
||||
protected _cellStartIndex(column: number): number {
|
||||
return this._absoluteColumn(column) * Constants.CELL_INDICIES;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,16 +183,18 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public get(index: number): CharData {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
const content = this._data[start + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
return [
|
||||
this._data[index * CELL_SIZE + Cell.FG],
|
||||
this._data[start + Cell.FG],
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? this._combined[index]
|
||||
? this._combined[abs]
|
||||
: (cp) ? stringFromCodePoint(cp) : '',
|
||||
content >> Content.WIDTH_SHIFT,
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? this._combined[index].charCodeAt(this._combined[index].length - 1)
|
||||
? this._combined[abs].charCodeAt(this._combined[abs].length - 1)
|
||||
: cp
|
||||
];
|
||||
}
|
||||
@@ -98,12 +204,15 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public set(index: number, value: CharData): void {
|
||||
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
this._data[start + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
|
||||
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
|
||||
this._combined[index] = value[1];
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
this._combined[abs] = value[1];
|
||||
this._data[start + Cell.CONTENT] = abs | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
} else {
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
this._data[start + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,22 +221,22 @@ export class BufferLine implements IBufferLine {
|
||||
* use these when only one value is needed, otherwise use `loadCell`
|
||||
*/
|
||||
public getWidth(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT;
|
||||
return this._data[this._cellStartIndex(index) + Cell.CONTENT] >> Content.WIDTH_SHIFT;
|
||||
}
|
||||
|
||||
/** Test whether content has width. */
|
||||
public hasWidth(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK;
|
||||
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.WIDTH_MASK;
|
||||
}
|
||||
|
||||
/** Get FG cell component. */
|
||||
public getFg(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.FG];
|
||||
return this._data[this._cellStartIndex(index) + Cell.FG];
|
||||
}
|
||||
|
||||
/** Get BG cell component. */
|
||||
public getBg(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.BG];
|
||||
return this._data[this._cellStartIndex(index) + Cell.BG];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +245,7 @@ export class BufferLine implements IBufferLine {
|
||||
* from real empty cells.
|
||||
*/
|
||||
public hasContent(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
|
||||
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,23 +254,25 @@ export class BufferLine implements IBufferLine {
|
||||
* a single UTF32 codepoint or the last codepoint of a combined string.
|
||||
*/
|
||||
public getCodePoint(index: number): number {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(index);
|
||||
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index].charCodeAt(this._combined[index].length - 1);
|
||||
return this._combined[abs].charCodeAt(this._combined[abs].length - 1);
|
||||
}
|
||||
return content & Content.CODEPOINT_MASK;
|
||||
}
|
||||
|
||||
/** Test whether the cell contains a combined string. */
|
||||
public isCombined(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK;
|
||||
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.IS_COMBINED_MASK;
|
||||
}
|
||||
|
||||
/** Returns the string content of the cell. */
|
||||
public getString(index: number): string {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(index);
|
||||
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index];
|
||||
return this._combined[abs];
|
||||
}
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
|
||||
@@ -172,7 +283,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** Get state of protected flag. */
|
||||
public isProtected(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED;
|
||||
return this._data[this._cellStartIndex(index) + Cell.BG] & BgFlags.PROTECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,15 +291,16 @@ export class BufferLine implements IBufferLine {
|
||||
* to GC as it significantly reduced the amount of new objects/references needed.
|
||||
*/
|
||||
public loadCell(index: number, cell: ICellData): ICellData {
|
||||
$startIndex = index * CELL_SIZE;
|
||||
const abs = this._absoluteColumn(index);
|
||||
$startIndex = this._cellStartIndex(index);
|
||||
cell.content = this._data[$startIndex + Cell.CONTENT];
|
||||
cell.fg = this._data[$startIndex + Cell.FG];
|
||||
cell.bg = this._data[$startIndex + Cell.BG];
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
cell.combinedData = this._combined[index];
|
||||
cell.combinedData = this._combined[abs];
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
cell.extended = this._extendedAttrs[index]!;
|
||||
cell.extended = this._extendedAttrs[abs]!;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
@@ -197,15 +309,18 @@ export class BufferLine implements IBufferLine {
|
||||
* Set data at `index` to `cell`.
|
||||
*/
|
||||
public setCell(index: number, cell: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._combined[index] = cell.combinedData;
|
||||
this._combined[abs] = cell.combinedData;
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = cell.extended;
|
||||
this._extendedAttrs[abs] = cell.extended;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content;
|
||||
this._data[index * CELL_SIZE + Cell.FG] = cell.fg;
|
||||
this._data[index * CELL_SIZE + Cell.BG] = cell.bg;
|
||||
this._data[start + Cell.CONTENT] = cell.content;
|
||||
this._data[start + Cell.FG] = cell.fg;
|
||||
this._data[start + Cell.BG] = cell.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,12 +329,15 @@ export class BufferLine implements IBufferLine {
|
||||
* it gets an optimized access method.
|
||||
*/
|
||||
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
if (attrs.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = attrs.extended;
|
||||
this._extendedAttrs[abs] = attrs.extended;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[index * CELL_SIZE + Cell.FG] = attrs.fg;
|
||||
this._data[index * CELL_SIZE + Cell.BG] = attrs.bg;
|
||||
this._data[start + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[start + Cell.FG] = attrs.fg;
|
||||
this._data[start + Cell.BG] = attrs.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,16 +347,19 @@ export class BufferLine implements IBufferLine {
|
||||
* by the previous `setDataFromCodePoint` call, we can omit it here.
|
||||
*/
|
||||
public addCodepointToCell(index: number, codePoint: number, width: number): void {
|
||||
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
let content = this._data[start + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
// we already have a combined string, simply add
|
||||
this._combined[index] += stringFromCodePoint(codePoint);
|
||||
this._combined[abs] += stringFromCodePoint(codePoint);
|
||||
} else {
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
// normal case for combining chars:
|
||||
// - move current leading char + new one into combined string
|
||||
// - set combined flag
|
||||
this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
|
||||
this._combined[abs] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
|
||||
content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0
|
||||
content |= Content.IS_COMBINED_MASK;
|
||||
} else {
|
||||
@@ -251,10 +372,11 @@ export class BufferLine implements IBufferLine {
|
||||
content &= ~Content.WIDTH_MASK;
|
||||
content |= width << Content.WIDTH_SHIFT;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = content;
|
||||
this._data[start + Cell.CONTENT] = content;
|
||||
}
|
||||
|
||||
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
pos %= this.length;
|
||||
|
||||
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
|
||||
@@ -282,6 +404,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
pos %= this.length;
|
||||
if (n < this.length - pos) {
|
||||
for (let i = 0; i < this.length - pos - n; ++i) {
|
||||
@@ -308,6 +431,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
|
||||
@@ -344,23 +468,32 @@ export class BufferLine implements IBufferLine {
|
||||
* The underlying array buffer will not change if there is still enough space
|
||||
* to hold the new buffer line data.
|
||||
* Returns a boolean indicating, whether a `cleanupMemory` call would free
|
||||
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
|
||||
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
|
||||
*/
|
||||
public resize(cols: number, fillCellData: ICellData): boolean {
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
const uint32Cells = cols * CELL_SIZE;
|
||||
if (cols > this.length) {
|
||||
if (this._data.buffer.byteLength >= uint32Cells * 4) {
|
||||
// optimization: avoid alloc and data copy if buffer has enough room
|
||||
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
|
||||
} else {
|
||||
// slow path: new alloc and full data copy
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
this._invalidateStringCache();
|
||||
if (this._logicalHead) {
|
||||
if (cols > this.length) {
|
||||
this._logicalHead.ensureLogicalCapacity(this._segmentStart + cols, fillCellData);
|
||||
}
|
||||
this.length = cols;
|
||||
return false;
|
||||
}
|
||||
if (this._overflowRowCount > 0) {
|
||||
if (cols > this.length) {
|
||||
this.ensureLogicalCapacity(Math.max(this._logicalCellCount, this._segmentStart + cols), fillCellData);
|
||||
}
|
||||
this.length = cols;
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
const uint32Cells = cols * Constants.CELL_INDICIES;
|
||||
if (cols > this.length) {
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(this._data.subarray(0, this.length * Constants.CELL_INDICIES));
|
||||
this._data = data;
|
||||
for (let i = this.length; i < cols; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
@@ -385,20 +518,23 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
}
|
||||
this.length = cols;
|
||||
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
this._logicalCellCount = cols;
|
||||
return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup underlying array buffer.
|
||||
* A cleanup will be triggered if the array buffer exceeds the actual used
|
||||
* memory by a factor of CLEANUP_THRESHOLD.
|
||||
* memory by a factor of Constants.CLEANUP_THRESHOLD.
|
||||
* Returns 0 or 1 indicating whether a cleanup happened.
|
||||
*/
|
||||
public cleanupMemory(): number {
|
||||
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
|
||||
const data = new Uint32Array(this._data.length);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
const head = this._logicalHead ?? this;
|
||||
const usedCells = head._logicalCellCount * Constants.CELL_INDICIES;
|
||||
if (usedCells * 4 * Constants.CLEANUP_THRESHOLD < head._data.buffer.byteLength) {
|
||||
const data = new Uint32Array(usedCells);
|
||||
data.set(head._data.subarray(0, usedCells));
|
||||
head._data = data;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
@@ -406,6 +542,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** fill a line with fillCharData */
|
||||
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
@@ -415,8 +552,10 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
if (!this._logicalHead) {
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
}
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
@@ -424,29 +563,72 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** alter to a full copy of line */
|
||||
public copyFrom(line: BufferLine): void {
|
||||
if (this._logicalHead) {
|
||||
this.materializeFromLogicalHead(this._fillCellData);
|
||||
}
|
||||
this._invalidateStringCache();
|
||||
this._logicalHead = undefined;
|
||||
this._segmentStart = 0;
|
||||
this._overflowRowCount = 0;
|
||||
if (line._logicalHead) {
|
||||
const standalone = new BufferLine(this._stringCache, line.length, this._fillCellData, false);
|
||||
standalone.copyCellsFrom(line, 0, 0, line.length, false);
|
||||
line = standalone;
|
||||
}
|
||||
if (this.length !== line.length) {
|
||||
this._data = new Uint32Array(line._data);
|
||||
this._data = new Uint32Array(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
|
||||
} else {
|
||||
// use high speed copy if lengths are equal
|
||||
this._data.set(line._data);
|
||||
this._data.set(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
|
||||
}
|
||||
this.length = line.length;
|
||||
this._logicalCellCount = line._logicalCellCount || line.length;
|
||||
this._combined = {};
|
||||
for (const el in line._combined) {
|
||||
this._combined[el] = line._combined[el];
|
||||
const key = parseInt(el, 10);
|
||||
if (key < this._logicalCellCount) {
|
||||
this._combined[key] = line._combined[el];
|
||||
}
|
||||
}
|
||||
this._extendedAttrs = {};
|
||||
for (const el in line._extendedAttrs) {
|
||||
this._extendedAttrs[el] = line._extendedAttrs[el];
|
||||
const key = parseInt(el, 10);
|
||||
if (key < this._logicalCellCount) {
|
||||
this._extendedAttrs[key] = line._extendedAttrs[el];
|
||||
}
|
||||
}
|
||||
this.isWrapped = line.isWrapped;
|
||||
}
|
||||
|
||||
public materializeFromLogicalHead(fillCellData: ICellData): BufferLine {
|
||||
if (!this._logicalHead) {
|
||||
return this;
|
||||
}
|
||||
const standalone = new BufferLine(this._stringCache, this.length, fillCellData, false);
|
||||
standalone.copyCellsFrom(this._logicalHead, this._segmentStart, 0, this.length, false);
|
||||
this._logicalHead.unregisterOverflowRow();
|
||||
this._data = standalone._data;
|
||||
this._combined = standalone._combined;
|
||||
this._extendedAttrs = standalone._extendedAttrs;
|
||||
this._logicalHead = undefined;
|
||||
this._segmentStart = 0;
|
||||
this._logicalCellCount = this.length;
|
||||
this.isWrapped = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** create a new clone */
|
||||
public clone(): IBufferLine {
|
||||
const newLine = new BufferLine(0);
|
||||
newLine._data = new Uint32Array(this._data);
|
||||
if (this._logicalHead) {
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, true, this._logicalHead, this._segmentStart);
|
||||
newLine.length = this.length;
|
||||
return newLine;
|
||||
}
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
|
||||
newLine._data = new Uint32Array(this._data.subarray(0, this._logicalCellCount * Constants.CELL_INDICIES));
|
||||
newLine.length = this.length;
|
||||
newLine._logicalCellCount = this._logicalCellCount;
|
||||
newLine._overflowRowCount = this._overflowRowCount;
|
||||
for (const el in this._combined) {
|
||||
newLine._combined[el] = this._combined[el];
|
||||
}
|
||||
@@ -459,8 +641,34 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
public getTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
const start = this._cellStartIndex(i);
|
||||
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Creates a standalone line containing the full logical line content. */
|
||||
public toStandaloneLogicalLine(cols: number, fillCellData: ICellData, stringCache: IBufferLineStringCache): BufferLine {
|
||||
const head = this._logicalHead ?? this;
|
||||
const trimmed = head.getLogicalTrimmedLength();
|
||||
const flat = new BufferLine(stringCache, cols, fillCellData, false);
|
||||
if (trimmed > 0) {
|
||||
flat.copyCellsFrom(head, 0, 0, trimmed, false);
|
||||
flat._logicalCellCount = trimmed;
|
||||
}
|
||||
head.clearReflowMergedState();
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** Trimmed length across the entire logical line (all segments). */
|
||||
public getLogicalTrimmedLength(): number {
|
||||
const head = this._logicalHead ?? this;
|
||||
for (let i = head._logicalCellCount - 1; i >= 0; --i) {
|
||||
const start = i * Constants.CELL_INDICIES;
|
||||
if ((head._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (head._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
@@ -468,31 +676,43 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
public getNoBgTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
|
||||
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
const start = this._cellStartIndex(i);
|
||||
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[start + Cell.BG] & Attributes.CM_MASK)) {
|
||||
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
|
||||
this._invalidateStringCache();
|
||||
const srcData = src._data;
|
||||
const srcOffset = src._segmentStart;
|
||||
const destOffset = this._segmentStart;
|
||||
if (applyInReverse) {
|
||||
for (let cell = length - 1; cell >= 0; cell--) {
|
||||
for (let i = 0; i < CELL_SIZE; i++) {
|
||||
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
|
||||
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
|
||||
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[destStart + i] = srcData[srcStart + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const absSrc = srcOffset + srcCol + cell;
|
||||
const absDest = destOffset + destCol + cell;
|
||||
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let cell = 0; cell < length; cell++) {
|
||||
for (let i = 0; i < CELL_SIZE; i++) {
|
||||
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
|
||||
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
|
||||
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[destStart + i] = srcData[srcStart + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const absSrc = srcOffset + srcCol + cell;
|
||||
const absDest = destOffset + destCol + cell;
|
||||
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,14 +721,16 @@ export class BufferLine implements IBufferLine {
|
||||
const srcCombinedKeys = Object.keys(src._combined);
|
||||
for (let i = 0; i < srcCombinedKeys.length; i++) {
|
||||
const key = parseInt(srcCombinedKeys[i], 10);
|
||||
if (key >= srcCol) {
|
||||
this._combined[key - srcCol + destCol] = src._combined[key];
|
||||
const srcAbsCol = srcOffset + srcCol;
|
||||
if (key >= srcAbsCol && key < srcAbsCol + length) {
|
||||
this._combined[key - srcCol + destOffset + destCol] = src._combined[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the buffer line to a string.
|
||||
* Translates the buffer line to a string. Caching only applies to canonical full-line translation
|
||||
* requests (regardless of `trimRight` value).
|
||||
*
|
||||
* @param trimRight Whether to trim any empty cells on the right.
|
||||
* @param startCol The column to start the string (0-based inclusive).
|
||||
@@ -521,6 +743,19 @@ export class BufferLine implements IBufferLine {
|
||||
* returned string, the corresponding entries in `outColumns` will have the same column number.
|
||||
*/
|
||||
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
|
||||
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
|
||||
if (isCanonicalRequest) {
|
||||
this._stringCache.touch?.();
|
||||
}
|
||||
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
|
||||
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
|
||||
if (trimRight) {
|
||||
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
|
||||
}
|
||||
if (!stringCacheEntry.isTrimmed) {
|
||||
return stringCacheEntry.value;
|
||||
}
|
||||
}
|
||||
startCol = startCol ?? 0;
|
||||
endCol = endCol ?? this.length;
|
||||
if (trimRight) {
|
||||
@@ -529,12 +764,16 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.length = 0;
|
||||
}
|
||||
let result = '';
|
||||
$translateToStringBuilder.reset();
|
||||
while (startCol < endCol) {
|
||||
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(startCol);
|
||||
const start = this._cellStartIndex(startCol);
|
||||
const content = this._data[start + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
result += chars;
|
||||
const chars = (content & Content.IS_COMBINED_MASK)
|
||||
? (this._combined[abs] ?? '')
|
||||
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
$translateToStringBuilder.append(chars);
|
||||
if (outColumns) {
|
||||
for (let i = 0; i < chars.length; ++i) {
|
||||
outColumns.push(startCol);
|
||||
@@ -545,6 +784,37 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
const result = $translateToStringBuilder.toString();
|
||||
$translateToStringBuilder.reset();
|
||||
if (isCanonicalRequest) {
|
||||
const cacheEntry = this._getStringCacheEntry(true)!;
|
||||
cacheEntry.value = result;
|
||||
cacheEntry.isTrimmed = !!trimRight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
|
||||
const cachedEntry = this._stringCacheEntryRef?.deref();
|
||||
if (cachedEntry) {
|
||||
if (cachedEntry.generation === this._stringCache.generation) {
|
||||
return cachedEntry;
|
||||
}
|
||||
}
|
||||
if (!createIfNeeded) {
|
||||
return undefined;
|
||||
}
|
||||
const cacheEntry = this._stringCache.allocateEntry();
|
||||
this._stringCacheEntryRef = new WeakRef(cacheEntry);
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
protected _invalidateStringCache(): void {
|
||||
const head = this._logicalHead ?? this;
|
||||
const cacheEntry = head._getStringCacheEntry(false);
|
||||
if (cacheEntry) {
|
||||
cacheEntry.value = undefined;
|
||||
cacheEntry.isTrimmed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle';
|
||||
|
||||
const enum Constants {
|
||||
CACHE_TTL_MS = 15000
|
||||
}
|
||||
|
||||
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
|
||||
public generation: number = 0;
|
||||
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
|
||||
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
|
||||
private _lastAccessTimestamp: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._register(toDisposable(() => this.entries.clear()));
|
||||
}
|
||||
|
||||
public touch(): void {
|
||||
this._scheduleClear();
|
||||
}
|
||||
|
||||
public allocateEntry(): IBufferLineStringCacheEntry {
|
||||
const entry: IBufferLineStringCacheEntry = {
|
||||
value: undefined,
|
||||
isTrimmed: false,
|
||||
generation: this.generation
|
||||
};
|
||||
this.entries.add(entry);
|
||||
this._scheduleClear();
|
||||
return entry;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._clearTimeout.clear();
|
||||
this._lastAccessTimestamp = 0;
|
||||
this.generation++;
|
||||
for (const entry of this.entries) {
|
||||
entry.value = undefined;
|
||||
entry.isTrimmed = false;
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
private _scheduleClear(): void {
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (this._clearTimeout.value) {
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
private _scheduleClearTimeout(timeoutMs: number): void {
|
||||
this._clearTimeout.value = disposableTimeout(() => {
|
||||
const elapsed = Date.now() - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.CACHE_TTL_MS) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { IAttributeData, IBufferLine, ICellData } from 'common/Types';
|
||||
import { IBufferLineStringCache } from 'common/buffer/BufferLine';
|
||||
|
||||
export function getLogicalHead(line: IBufferLine): BufferLine {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
return line.head;
|
||||
}
|
||||
return line as BufferLine;
|
||||
}
|
||||
|
||||
export function isBufferOverflowLine(line: IBufferLine): line is BufferOverflowLine {
|
||||
return line instanceof BufferOverflowLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logical cell offset for the start of a buffer row.
|
||||
*/
|
||||
export function getSegmentStart(line: IBufferLine): number {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
return line.segmentStart;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the row at `y` as a wrapped continuation of the logical line above.
|
||||
* Extends the logical line storage and replaces the row with a {@link BufferOverflowLine}.
|
||||
*/
|
||||
export function attachWrappedRow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
|
||||
y: number,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData
|
||||
): IBufferLine {
|
||||
const headY = y > 0 ? getWrappedRangeStart(lines, y - 1) : y;
|
||||
const head = getLogicalHead(lines.get(headY)!);
|
||||
const segmentStart = head.logicalCellCount;
|
||||
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
|
||||
|
||||
const existingLine = lines.get(y);
|
||||
if (existingLine && !(existingLine instanceof BufferOverflowLine) && existingLine.getTrimmedLength() > 0) {
|
||||
head.copyCellsFrom(existingLine as BufferLine, 0, segmentStart, existingLine.length, false);
|
||||
}
|
||||
|
||||
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
|
||||
lines.set(y, overflowLine);
|
||||
head.registerOverflowRow();
|
||||
return overflowLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the wrapped state for a row, detaching overflow rows from their logical line.
|
||||
*/
|
||||
export function clearWrappedRow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
|
||||
y: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache?: IBufferLineStringCache,
|
||||
cols?: number,
|
||||
preserveContent: boolean = true
|
||||
): void {
|
||||
const line = lines.get(y);
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
if (line.isWrapped) {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
if (preserveContent) {
|
||||
lines.set(y, line.materializeToStandalone(fillCellData));
|
||||
} else if (stringCache !== undefined && cols !== undefined) {
|
||||
line.head.unregisterOverflowRow();
|
||||
lines.set(y, new BufferLine(stringCache, cols, fillCellData, false));
|
||||
} else {
|
||||
lines.set(y, line.materializeToStandalone(fillCellData));
|
||||
}
|
||||
} else if (line instanceof BufferLine) {
|
||||
(line as BufferLine).materializeFromLogicalHead(fillCellData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const head = line as BufferLine;
|
||||
head.clearOverflowRows();
|
||||
}
|
||||
|
||||
export function getWrappedRangeStart(lines: { get(index: number): IBufferLine | undefined }, y: number): number {
|
||||
let first = y;
|
||||
while (first > 0 && lines.get(first)?.isWrapped) {
|
||||
first--;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
export function getWrappedRangeEnd(lines: { get(index: number): IBufferLine | undefined; length: number }, y: number): number {
|
||||
let last = y;
|
||||
while (last + 1 < lines.length && lines.get(last + 1)?.isWrapped) {
|
||||
last++;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a logical line group into a single standalone buffer row and removes overflow rows.
|
||||
* Used before reflow so existing reflow algorithms can operate on independent lines.
|
||||
*/
|
||||
export function flattenLogicalGroup(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
|
||||
headY: number,
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache: IBufferLineStringCache
|
||||
): void {
|
||||
const last = getWrappedRangeEnd(lines, headY);
|
||||
if (last <= headY) {
|
||||
return;
|
||||
}
|
||||
const head = getLogicalHead(lines.get(headY)!);
|
||||
lines.set(headY, head.toStandaloneLogicalLine(cols, fillCellData, stringCache));
|
||||
for (let i = headY + 1; i <= last; i++) {
|
||||
const line = lines.get(i);
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
const standalone = line.materializeToStandalone(fillCellData);
|
||||
standalone.isWrapped = true;
|
||||
lines.set(i, standalone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeLogicalLinesForReflow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache: IBufferLineStringCache
|
||||
): void {
|
||||
let y = 0;
|
||||
while (y < lines.length) {
|
||||
const line = lines.get(y);
|
||||
const next = lines.get(y + 1);
|
||||
if (line && next instanceof BufferOverflowLine && next.head === getLogicalHead(line)) {
|
||||
flattenLogicalGroup(lines, y, cols, fillCellData, stringCache);
|
||||
y++;
|
||||
continue;
|
||||
}
|
||||
y++;
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollWrappedLine(
|
||||
lineAbove: IBufferLine | undefined,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
attr: IAttributeData
|
||||
): IBufferLine {
|
||||
if (!lineAbove) {
|
||||
return new BufferLine(stringCache, cols, fillCellData, true);
|
||||
}
|
||||
const head = getLogicalHead(lineAbove);
|
||||
const segmentStart = head.logicalCellCount;
|
||||
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
|
||||
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
|
||||
head.registerOverflowRow();
|
||||
return overflowLine;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { attachWrappedRow } from 'common/buffer/BufferLineUtils';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
const NULL_CELL = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
|
||||
class LineList {
|
||||
private _lines: IBufferLine[] = [];
|
||||
public get(index: number): IBufferLine | undefined { return this._lines[index]; }
|
||||
public set(index: number, line: IBufferLine): void { this._lines[index] = line; }
|
||||
public get length(): number { return this._lines.length; }
|
||||
}
|
||||
|
||||
describe('BufferLogicalLine', () => {
|
||||
it('should share storage across wrapped rows', () => {
|
||||
const cols = 5;
|
||||
const lines = new LineList();
|
||||
const head = new BufferLine(TEST_STRING_CACHE, cols, NULL_CELL, false);
|
||||
lines.set(0, head);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
head.setCellFromCodepoint(i, 'a'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
attachWrappedRow(lines, 1, TEST_STRING_CACHE, cols, NULL_CELL);
|
||||
const overflow = lines.get(1)!;
|
||||
assert.instanceOf(overflow, BufferOverflowLine);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
(overflow as BufferLine).setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
assert.equal(head.translateToString(true), 'abcde');
|
||||
assert.equal(overflow.translateToString(true), 'fghij');
|
||||
assert.equal(head.getLogicalTrimmedLength(), 10);
|
||||
});
|
||||
|
||||
it('should reflow unwrap without copying when logical content fits', () => {
|
||||
const lines = new LineList();
|
||||
const head = new BufferLine(TEST_STRING_CACHE, 5, NULL_CELL, false);
|
||||
lines.set(0, head);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
head.setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
attachWrappedRow(lines, 1, TEST_STRING_CACHE, 2, NULL_CELL);
|
||||
const overflow = lines.get(1)! as BufferOverflowLine;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
overflow.setCellFromCodepoint(i, 'k'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
assert.equal(head.getLogicalTrimmedLength(), 7);
|
||||
assert.equal(overflow.translateToString(true), 'kl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ICellData } from 'common/Types';
|
||||
import { BufferLine, IBufferLineStringCache } from 'common/buffer/BufferLine';
|
||||
|
||||
/**
|
||||
* A viewport row that is a view into a slice of a parent {@link BufferLine}'s logical storage.
|
||||
*/
|
||||
export class BufferOverflowLine extends BufferLine {
|
||||
public readonly head: BufferLine;
|
||||
public readonly segmentStart: number;
|
||||
|
||||
constructor(
|
||||
head: BufferLine,
|
||||
segmentStart: number,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData
|
||||
) {
|
||||
super(stringCache, cols, fillCellData, true, head, segmentStart);
|
||||
this.head = head;
|
||||
this.segmentStart = segmentStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies this row's segment into a standalone {@link BufferLine}.
|
||||
*/
|
||||
public materializeToStandalone(fillCellData: ICellData): BufferLine {
|
||||
return this.materializeFromLogicalHead(fillCellData);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('BufferReflow', () => {
|
||||
describe('reflowSmallerGetNewLineLengths', () => {
|
||||
it('should return correct line lengths for a small line with wide characters', () => {
|
||||
const line = new BufferLine(4);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 4);
|
||||
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(1, [0, '', 0, 0]);
|
||||
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
@@ -20,7 +23,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
|
||||
});
|
||||
it('should return correct line lengths for a large line with wide characters', () => {
|
||||
const line = new BufferLine(12);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 12);
|
||||
for (let i = 0; i < 12; i += 4) {
|
||||
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
@@ -42,7 +45,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语');
|
||||
});
|
||||
it('should return correct line lengths for a string with wide and single characters', () => {
|
||||
const line = new BufferLine(6);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 6);
|
||||
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(2, [0, '', 0, 0]);
|
||||
@@ -56,14 +59,14 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b');
|
||||
});
|
||||
it('should return correct line lengths for a wrapped line with wide and single characters', () => {
|
||||
const line1 = new BufferLine(6);
|
||||
const line1 = new BufferLine(TEST_STRING_CACHE, 6);
|
||||
line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line1.set(2, [0, '', 0, 0]);
|
||||
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
line1.set(4, [0, '', 0, 0]);
|
||||
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]);
|
||||
const line2 = new BufferLine(6, undefined, true);
|
||||
const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true);
|
||||
line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line2.set(2, [0, '', 0, 0]);
|
||||
@@ -78,7 +81,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b');
|
||||
});
|
||||
it('should work on lines ending in null space', () => {
|
||||
const line = new BufferLine(5);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 5);
|
||||
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(1, [0, '', 0, 0]);
|
||||
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user