diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f3dc6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +node_modules/ +*.swp +.lock-wscript +lib/ +out/ +out-test/ +out-worker/ +.nyc_output/ +Makefile.gyp +*.Makefile +*.target.gyp.mk +*.node +example/*.log +docs/ +npm-debug.log +/.idea/ +.env +build/ +.DS_Store +package-lock.json +yarn.lock + +# Keep bundled code out of Git +dist/ +demo/dist/ + +# dont commit benchmark folders +.benchmark/ +timeline/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f80debc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, 2020, 2021 Joerg Breitbart. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..73f1f30 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +## xterm-addon-image + +Image output in xterm.js. + + +![](fixture/example.png) + + +⚠️ This is an experimental addon, that is still under construction. ⚠️ + + +### Install + +```bash +npm install --save xterm-addon-image +``` + +### Usage + +```ts +import { Terminal } from 'xterm'; +import { ImageAddon, IImageAddonOptions } from 'xterm-addon-image'; + +const WORKER_PATH = '/path/to/xterm-addon-image-worker.js'; + +// customize as needed +const customSettings: IImageAddonOptions = { + sixelSupport: true, + ... +} + +// initialization +const terminal = new Terminal(); +const imageAddon = new ImageAddon(WORKER_PATH, customSettings); +terminal.loadAddon(imageAddon); +``` + +### General Notes + +- The image decoding is done with a worker, therefore the addon will only work, if you expose the worker file as well. The worker is distributed under `lib/xterm-addon-image-worker.js`. Place the exported worker path as the first argument of the addon constructor, e.g. `new ImageAddon('/your/path/to/image/worker')`. Additionally make sure, that your main integration has proper read and execution permissions on the worker file, otherwise the addon will log a worker error and disable itself on the first image decoding attempt (lazy evaluated). + +- By default the addon will activate these `windowOptions` reports on the terminal: + - getWinSizePixels (CSI 14 t) + - getCellSizePixels (CSI 16 t) + - getWinSizeChars (CSI 18 t) + + to help applications getting useful terminal metrics for their image preparations. Set `enableSizeReports` in the constructor options to `false`, if you dont want the addon to alter these terminal settings. This is especially useful, if you have very strict security needs not allowing any terminal reports, or deal with `windowOptions` by other means. + + +### Operation Modes + +- **SIXEL Support** + Set by default, change it with `{sixelSupport: true}`. + +- **Scrolling On | Off** + By default scrolling is on, thus an image will advance the cursor at the bottom if needed. + The cursor will move with the image and be placed either right to the image or in the next line + (see cursor positioning). + + If scrolling is off, the image gets painted from the top left of the current viewport + and might be truncated if the image exceeds the viewport size. + The cursor position does not change. + + You can customize this behavior with the constructor option `{sixelScrolling: false}` + or with `DECSET 80` (off, binary: `\x1b [ ? 80 h`) and + `DECRST 80` (on, binary: `\x1b [ ? 80 l`) during runtime. + +- **Cursor Positioning** + If scrolling is set, the cursor will be placed at the beginning of the next row by default. + You can change this behavior with the following terminal sequences: + - `DECSET 8452` (binary: `\x1b [ ? 8452 h`) + For images not overflowing to the right, the cursor will move to the next right cell of the last image cell. + Images overflowing to the right, move the cursor to the next line. + Same as the constructor option `{cursorRight: true}`. + + - `DECRST 8452` (binary: `\x1b [ ? 8452 l`) + Always moves the cursor to the next line (default). Same as the constructor option `{cursorRight: false}`. + + - `DECRST 7730` (binary: `\x1b [ ? 7730 l`) + Move the cursor on the next line to the image start offset instead of the beginning. + This setting only applies if the cursor will wrap to the next line (thus never for scrolling off, + for `DECSET 8452` only after overflowing to the right). Same as the constructor option `{cursorBelow: true}`. + + - `DECSET 7730` (binary: `\x1b [ ? 7730 h`) + Keep the cursor on the next line at the beginning (default). Same as the constructor option `{cursorBelow: false}`. + +- **SIXEL Palette Handling** + By default the addon limits the palette size to 256 registers (as demanded by the DEC specification). + The limit can be increased to a maximum of 4096 registers (via `sixelPaletteLimit`). + + If `sixelPrivatePalette` is set (default), images are initialized with their own private palette derived from the default palette (`'VT340-COLOR'`). If `sixelPrivatePalette` is not set, the palette of the previous image will be used as initial palette. + + Note that the underlying SIXEL library currently applies colors immediately to pixels (*printer mode*), + thus it is technically possible to use more colors in one image than the palette has color slots. + This feature is called *high-color* in libsixel. + + In contrast older terminals were always bound to the palette due hardware limitations. + This limitation is mimicked by xterm's shared palette mode, which will re-color previous images from palette changes + treating all sixel images as indexed images. This true shared-palette *terminal mode* is currently not supported by + xterm.js, as it always operates in *printer mode*. + +- **SIXEL Raster Attributes Handling** + If raster attributes were found in the SIXEL data (level 2), the image will always be limited to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification. + + Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel. + +### Storage and Drawing Settings + +The internal storage holds images up to `storageLimit` (in MB, calculated as 4-channel RBGA unpacked, default 100 MB). Once hit images get evicted by FIFO rules. Furthermore images on the alternate buffer will always be erased on buffer changes. + +The addon exposes two properties to interact with the storage limits at runtime: +- `storageLimit` + Change the value to your needs at runtime. This is especially useful, if you have multiple terminal + instances running, that all add to one upper memory limit. +- `storageUsage` + Inspect the current memory usage of the image storage. + +By default the addon will show a placeholder pattern for evicted images that are still part +of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`. + +### Image Data Retrieval + +The addon provides the following API endpoints to retrieve raw image data as canvas: + +- `getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined` + Returns the canvas containing the original image data (not resized) at the given buffer position. + The buffer position is the 0-based absolute index (including scrollback at top). + +- `extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined` + Returns a canvas containing the actual single tile image data (maybe resized) at the given buffer position. + The buffer position is the 0-based absolute index (including scrollback at top). + Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions. + +### Memory Usage + +The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps: +- Incomming image data chunk at `term.write` (terminal) + `term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time. +- Sequence Parser (terminal) + The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB). +- Sequence Handler - Chunk Processing (addon / mainthread) + Image data chunks are copied over and sent to the decoder worker as transferables with `postMessage`. To avoid a data congestion at the message port, allowed SIXEL data is hard limited by `sixelSizeLimit` (default 25 MB). The transport chunks are pooled, the pool cache may hold up to ~6 MB during active data transmission. +- Image Decoder (addon / worker) + The decoder works chunkwise allocating memory as needed. The allowed image size gets restricted by `pixelLimit` (default 16M pixels), the decoder holds 2 pixel buffers at maximum during decoding (RGBA, ~128 MB for 16M pixels). + After decoding the final pixel buffer is transferred back to the sequence handler. +- Sequence Handler - Image Finalization (addon / mainthread) + The pixel data gets written to a canvas of the same size (~64 MB for 16M pixels) and added to the storage. The pixel buffer is sent back to the worker to be used for the next image. +- Image Storage (addon / mainthread) + The image storage implements a FIFO cache, that will remove old images, if a new one arrives and `storageLimit` is hit (default 128 MB). The storage holds a canvas with the original image, and may additionally hold resized versions of images after a font rescaling. Both are accounted in `storageUsage` as a rough estimation of _pixels x 4 channels_. + +Following the steps above, a rough estimation of maximum memory usage by the addon can be calculated with these formulas (in bytes): +```typescript +// storage alone +const storageBytes = storageUsage * storageLimit * 1024 * 1024; +// decoding alone +const decodingBytes = sixelSizeLimit + 2 * (pixelLimit * 4); + +// totals +// inactive decoding +const totalInactive = storageBytes; +// active decoding +const totalActive = storageBytes + decodingBytes; +``` + +Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions. +Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%). + +_Why should I care about memory usage at all?_ +Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure. + +_How can I adjust the memory usage?_ +- `pixelLimit` + A constructor settings, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page. +- `storageLimit` + A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding. +- `sixelSizeLimit` + A constructor setting. This has only a small direct impact on peaking memory during decoding. It still will avoid processing of overly big or broken sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data. \ No newline at end of file diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..24639c9 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# clone xterm.js base repo +git clone --depth 1 --branch 4.16.0 https://github.com/xtermjs/xterm.js.git +cd xterm.js +rm -rf .git + +# clone addon +cd addons +git clone https://github.com/jerch/xterm-addon-image +cd .. + +# overwrite files in base repo to have full test integration +cp -avx overwrite/* . + +# init all +yarn diff --git a/fixture/example.png b/fixture/example.png new file mode 100644 index 0000000..3e04d92 Binary files /dev/null and b/fixture/example.png differ diff --git a/fixture/growing_rect.js b/fixture/growing_rect.js new file mode 100644 index 0000000..a06f75c --- /dev/null +++ b/fixture/growing_rect.js @@ -0,0 +1,44 @@ +const sixelEncode = require('../node_modules/sixel/lib/SixelEncoder').image2sixel; +const toRGBA8888 = require('../node_modules/sixel/lib/Colors').toRGBA8888; + +function createRect(size, color) { + const pixels = new Uint32Array(size * size); + pixels.fill(toRGBA8888(...color)); + return sixelEncode(new Uint8ClampedArray(pixels.buffer), size, size); +} + +function createRectMinusOne(size, color) { + const pixels = new Uint32Array(size * size); + if (size - 1) { + const sub = new Uint32Array(size - 1); + sub.fill(toRGBA8888(...color)); + const last = size * (size - 1); + for (let y = 0; y < last; y += size) { + pixels.set(sub, y); + } + } + return sixelEncode(new Uint8ClampedArray(pixels.buffer), size, size); +} + +async function main() { + // clear + cursor and sixelScrolling off + process.stdout.write('\x1b[2J\x1b[?25;80l'); + + for (let i = 1; i < 300; ++i) { + await new Promise(res => setTimeout(() => { + process.stdout.write(createRect(i, [0, 255, 0])); + res(); + }, 5)); + } + for (let i = 299; i >= 1; --i) { + await new Promise(res => setTimeout(() => { + process.stdout.write(createRectMinusOne(i, [0, 255, 0])); + res(); + }, 5)); + } + + // re-enable cursor and sixel scrolling + process.stdout.write('\x1b[2J\x1b[?25;80h'); +} + +main(); diff --git a/fixture/overdraw.sh b/fixture/overdraw.sh new file mode 100755 index 0000000..104a0ce --- /dev/null +++ b/fixture/overdraw.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +function smiling_smiley() { + echo -ne '\x1bP;2q"1;1;60;60 +#6!60~$- +!60~$- +!60~$ +!15?#1!4]!22?!4]$- +#6!60~$- +!60~$- +!60~$- +!60~$ +!15?#1!4~!22?!4~$- +#6!60~$ +!15?#1!30N$- +#6!60~$- +!60~$- +' + echo -ne '\x1b\\' +} + +function indifferent_smiley() { + echo -ne '\x1bP;2q"1;1;60;60 +#6!60~$- +!60~$- +!60~$ +!15?#1!4]!22?!4]$- +#6!60~$- +!60~$- +!60~$- +!60~$- +!60~$ +!15?#1!30N$- +#6!60~$- +!60~$- +' + echo -ne '\x1b\\' +} + +function sad_smiley() { + echo -ne '\x1bP;2q"1;1;60;60 +#6!60~$- +!60~$- +!60~$ +!15?#1!4]!22?!4]$- +#6!60~$- +!60~$- +!60~$- +!60~$- +!60~$ +!15?#1!30N$ +!15?#1!4o!22?!4o$- +#6!60~$ +!15?#1!4B!22?!4B$- +#6!60~$- +' + echo -ne '\x1b\\' +} + +function smiling_smiley_slim() { + echo -ne '\x1bP;1q"1;1;60;60 +$- +$- +$- +$- +$- +$- +!15?#1!4~!22?!4~$- +!15?#1!30N +' + echo -ne '\x1b\\' +} + +function indifferent_smiley_slim() { + echo -ne '\x1bP;1q"1;1;60;60 +$- +$- +$- +$- +$- +$- +!15?#6!4~!22?!4~$- +!15?!4o!22?!4o$ !15?#1!30N$- +!15?#6!4B!22?!4B +' + echo -ne '\x1b\\' +} + +function sad_smiley_slim() { + echo -ne '\x1bP;1q"1;1;60;60 +$- +$- +$- +$- +$- +$- +$- +!15?#1!30N$ +!15?#1!4o!22?!4o$- +!15?#1!4B!22?!4B$- +' + echo -ne '\x1b\\' +} + +function full() { + smiling_smiley + sleep .5 + indifferent_smiley + sleep .5 + sad_smiley + sleep .5 + indifferent_smiley + sleep .5 + smiling_smiley +} + +function slim() { + smiling_smiley + sleep .5 + indifferent_smiley_slim + sleep .5 + sad_smiley_slim + sleep .5 + indifferent_smiley_slim + sleep .5 + smiling_smiley_slim +} + +# clear screen and place cursor to 1;10 +echo -ne '\x1b[2J\x1b[10;1H' + +# switch sixel scrolling off +echo -ne '\x1b[?80h' + +case "$1" in + full ) full ;; + slim ) slim ;; +esac + +# re-enable sixel scrolling +echo -ne '\x1b[?80l' diff --git a/fixture/palette.png b/fixture/palette.png new file mode 100644 index 0000000..d58237b Binary files /dev/null and b/fixture/palette.png differ diff --git a/fixture/palette.sixel b/fixture/palette.sixel new file mode 100644 index 0000000..f12ce88 --- /dev/null +++ b/fixture/palette.sixel @@ -0,0 +1,14 @@ +P0;0;q"1;1;640;80#0;2;0;0;0#1;2;0;13;0#2;2;0;25;0#3;2;0;38;0#4;2;0;50;0#5;2;0;63;0#6;2;0;75;0#7;2;0;88;0#8;2;13;0;0#9;2;13;13;0#10;2;13;25;0#11;2;13;38;0#12;2;13;50;0#13;2;13;63;0#14;2;13;75;0#15;2;13;88;0#16;2;25;0;0#17;2;25;13;0#18;2;25;25;0#19;2;25;38;0#20;2;25;50;0#21;2;25;63;0#22;2;25;75;0#23;2;25;88;0#24;2;38;0;0#25;2;38;13;0#26;2;38;25;0#27;2;38;38;0#28;2;38;50;0#29;2;38;63;0#30;2;38;75;0#31;2;38;88;0#32;2;50;0;0#33;2;50;13;0#34;2;50;25;0#35;2;50;38;0#36;2;50;50;0#37;2;50;63;0#38;2;50;75;0#39;2;50;88;0#40;2;63;0;0#41;2;63;13;0#42;2;63;25;0#43;2;63;38;0#44;2;63;50;0#45;2;63;63;0#46;2;63;75;0#47;2;63;88;0#48;2;75;0;0#49;2;75;13;0#50;2;75;25;0#51;2;75;38;0#52;2;75;50;0#53;2;75;63;0#54;2;75;75;0#55;2;75;88;0#56;2;88;0;0#57;2;88;13;0#58;2;88;25;0#59;2;88;38;0#60;2;88;50;0#61;2;88;63;0#62;2;88;75;0#63;2;88;88;0#64;2;0;0;13#65;2;0;13;13#66;2;0;25;13#67;2;0;38;13#68;2;0;50;13#69;2;0;63;13#70;2;0;75;13#71;2;0;88;13#72;2;13;0;13#73;2;13;13;13#74;2;13;25;13#75;2;13;38;13#76;2;13;50;13#77;2;13;63;13#78;2;13;75;13#79;2;13;88;13#80;2;25;0;13#81;2;25;13;13#82;2;25;25;13#83;2;25;38;13#84;2;25;50;13#85;2;25;63;13#86;2;25;75;13#87;2;25;88;13#88;2;38;0;13#89;2;38;13;13#90;2;38;25;13#91;2;38;38;13#92;2;38;50;13#93;2;38;63;13#94;2;38;75;13#95;2;38;88;13#96;2;50;0;13#97;2;50;13;13#98;2;50;25;13#99;2;50;38;13#100;2;50;50;13#101;2;50;63;13#102;2;50;75;13#103;2;50;88;13#104;2;63;0;13#105;2;63;13;13#106;2;63;25;13#107;2;63;38;13#108;2;63;50;13#109;2;63;63;13#110;2;63;75;13#111;2;63;88;13#112;2;75;0;13#113;2;75;13;13#114;2;75;25;13#115;2;75;38;13#116;2;75;50;13#117;2;75;63;13#118;2;75;75;13#119;2;75;88;13#120;2;88;0;13#121;2;88;13;13#122;2;88;25;13#123;2;88;38;13#124;2;88;50;13#125;2;88;63;13#126;2;88;75;13#127;2;88;88;13#128;2;0;0;25#129;2;0;13;25#130;2;0;25;25#131;2;0;38;25#132;2;0;50;25#133;2;0;63;25#134;2;0;75;25#135;2;0;88;25#136;2;13;0;25#137;2;13;13;25#138;2;13;25;25#139;2;13;38;25#140;2;13;50;25#141;2;13;63;25#142;2;13;75;25#143;2;13;88;25#144;2;25;0;25#145;2;25;13;25#146;2;25;25;25#147;2;25;38;25#148;2;25;50;25#149;2;25;63;25#150;2;25;75;25#151;2;25;88;25#152;2;38;0;25#153;2;38;13;25#154;2;38;25;25#155;2;38;38;25#156;2;38;50;25#157;2;38;63;25#158;2;38;75;25#159;2;38;88;25#160;2;50;0;25#161;2;50;13;25#162;2;50;25;25#163;2;50;38;25#164;2;50;50;25#165;2;50;63;25#166;2;50;75;25#167;2;50;88;25#168;2;63;0;25#169;2;63;13;25#170;2;63;25;25#171;2;63;38;25#172;2;63;50;25#173;2;63;63;25#174;2;63;75;25#175;2;63;88;25#176;2;75;0;25#177;2;75;13;25#178;2;75;25;25#179;2;75;38;25#180;2;75;50;25#181;2;75;63;25#182;2;75;75;25#183;2;75;88;25#184;2;88;0;25#185;2;88;13;25#186;2;88;25;25#187;2;88;38;25#188;2;88;50;25#189;2;88;63;25#190;2;88;75;25#191;2;88;88;25#192;2;0;0;38#193;2;0;13;38#194;2;0;25;38#195;2;0;38;38#196;2;0;50;38#197;2;0;63;38#198;2;0;75;38#199;2;0;88;38#200;2;13;0;38#201;2;13;13;38#202;2;13;25;38#203;2;13;38;38#204;2;13;50;38#205;2;13;63;38#206;2;13;75;38#207;2;13;88;38#208;2;25;0;38#209;2;25;13;38#210;2;25;25;38#211;2;25;38;38#212;2;25;50;38#213;2;25;63;38#214;2;25;75;38#215;2;25;88;38#216;2;38;0;38#217;2;38;13;38#218;2;38;25;38#219;2;38;38;38#220;2;38;50;38#221;2;38;63;38#222;2;38;75;38#223;2;38;88;38#224;2;50;0;38#225;2;50;13;38#226;2;50;25;38#227;2;50;38;38#228;2;50;50;38#229;2;50;63;38#230;2;50;75;38#231;2;50;88;38#232;2;63;0;38#233;2;63;13;38#234;2;63;25;38#235;2;63;38;38#236;2;63;50;38#237;2;63;63;38#238;2;63;75;38#239;2;63;88;38#240;2;75;0;38#241;2;75;13;38#242;2;75;25;38#243;2;75;38;38#244;2;75;50;38#245;2;75;63;38#246;2;75;75;38#247;2;75;88;38#248;2;88;0;38#249;2;88;13;38#250;2;88;25;38#251;2;88;38;38#252;2;88;50;38#253;2;88;63;38#254;2;88;75;38#255;2;88;88;38#256;2;0;0;50#257;2;0;13;50#258;2;0;25;50#259;2;0;38;50#260;2;0;50;50#261;2;0;63;50#262;2;0;75;50#263;2;0;88;50#264;2;13;0;50#265;2;13;13;50#266;2;13;25;50#267;2;13;38;50#268;2;13;50;50#269;2;13;63;50#270;2;13;75;50#271;2;13;88;50#272;2;25;0;50#273;2;25;13;50#274;2;25;25;50#275;2;25;38;50#276;2;25;50;50#277;2;25;63;50#278;2;25;75;50#279;2;25;88;50#280;2;38;0;50#281;2;38;13;50#282;2;38;25;50#283;2;38;38;50#284;2;38;50;50#285;2;38;63;50#286;2;38;75;50#287;2;38;88;50#288;2;50;0;50#289;2;50;13;50#290;2;50;25;50#291;2;50;38;50#292;2;50;50;50#293;2;50;63;50#294;2;50;75;50#295;2;50;88;50#296;2;63;0;50#297;2;63;13;50#298;2;63;25;50#299;2;63;38;50#300;2;63;50;50#301;2;63;63;50#302;2;63;75;50#303;2;63;88;50#304;2;75;0;50#305;2;75;13;50#306;2;75;25;50#307;2;75;38;50#308;2;75;50;50#309;2;75;63;50#310;2;75;75;50#311;2;75;88;50#312;2;88;0;50#313;2;88;13;50#314;2;88;25;50#315;2;88;38;50#316;2;88;50;50#317;2;88;63;50#318;2;88;75;50#319;2;88;88;50#320;2;0;0;63#321;2;0;13;63#322;2;0;25;63#323;2;0;38;63#324;2;0;50;63#325;2;0;63;63#326;2;0;75;63#327;2;0;88;63#328;2;13;0;63#329;2;13;13;63#330;2;13;25;63#331;2;13;38;63#332;2;13;50;63#333;2;13;63;63#334;2;13;75;63#335;2;13;88;63#336;2;25;0;63#337;2;25;13;63#338;2;25;25;63#339;2;25;38;63#340;2;25;50;63#341;2;25;63;63#342;2;25;75;63#343;2;25;88;63#344;2;38;0;63#345;2;38;13;63#346;2;38;25;63#347;2;38;38;63#348;2;38;50;63#349;2;38;63;63#350;2;38;75;63#351;2;38;88;63#352;2;50;0;63#353;2;50;13;63#354;2;50;25;63#355;2;50;38;63#356;2;50;50;63#357;2;50;63;63#358;2;50;75;63#359;2;50;88;63#360;2;63;0;63#361;2;63;13;63#362;2;63;25;63#363;2;63;38;63#364;2;63;50;63#365;2;63;63;63#366;2;63;75;63#367;2;63;88;63#368;2;75;0;63#369;2;75;13;63#370;2;75;25;63#371;2;75;38;63#372;2;75;50;63#373;2;75;63;63#374;2;75;75;63#375;2;75;88;63#376;2;88;0;63#377;2;88;13;63#378;2;88;25;63#379;2;88;38;63#380;2;88;50;63#381;2;88;63;63#382;2;88;75;63#383;2;88;88;63#384;2;0;0;75#385;2;0;13;75#386;2;0;25;75#387;2;0;38;75#388;2;0;50;75#389;2;0;63;75#390;2;0;75;75#391;2;0;88;75#392;2;13;0;75#393;2;13;13;75#394;2;13;25;75#395;2;13;38;75#396;2;13;50;75#397;2;13;63;75#398;2;13;75;75#399;2;13;88;75#400;2;25;0;75#401;2;25;13;75#402;2;25;25;75#403;2;25;38;75#404;2;25;50;75#405;2;25;63;75#406;2;25;75;75#407;2;25;88;75#408;2;38;0;75#409;2;38;13;75#410;2;38;25;75#411;2;38;38;75#412;2;38;50;75#413;2;38;63;75#414;2;38;75;75#415;2;38;88;75#416;2;50;0;75#417;2;50;13;75#418;2;50;25;75#419;2;50;38;75#420;2;50;50;75#421;2;50;63;75#422;2;50;75;75#423;2;50;88;75#424;2;63;0;75#425;2;63;13;75#426;2;63;25;75#427;2;63;38;75#428;2;63;50;75#429;2;63;63;75#430;2;63;75;75#431;2;63;88;75#432;2;75;0;75#433;2;75;13;75#434;2;75;25;75#435;2;75;38;75#436;2;75;50;75#437;2;75;63;75#438;2;75;75;75#439;2;75;88;75#440;2;88;0;75#441;2;88;13;75#442;2;88;25;75#443;2;88;38;75#444;2;88;50;75#445;2;88;63;75#446;2;88;75;75#447;2;88;88;75#448;2;0;0;88#449;2;0;13;88#450;2;0;25;88#451;2;0;38;88#452;2;0;50;88#453;2;0;63;88#454;2;0;75;88#455;2;0;88;88#456;2;13;0;88#457;2;13;13;88#458;2;13;25;88#459;2;13;38;88#460;2;13;50;88#461;2;13;63;88#462;2;13;75;88#463;2;13;88;88#464;2;25;0;88#465;2;25;13;88#466;2;25;25;88#467;2;25;38;88#468;2;25;50;88#469;2;25;63;88#470;2;25;75;88#471;2;25;88;88#472;2;38;0;88#473;2;38;13;88#474;2;38;25;88#475;2;38;38;88#476;2;38;50;88#477;2;38;63;88#478;2;38;75;88#479;2;38;88;88#480;2;50;0;88#481;2;50;13;88#482;2;50;25;88#483;2;50;38;88#484;2;50;50;88#485;2;50;63;88#486;2;50;75;88#487;2;50;88;88#488;2;63;0;88#489;2;63;13;88#490;2;63;25;88#491;2;63;38;88#492;2;63;50;88#493;2;63;63;88#494;2;63;75;88#495;2;63;88;88#496;2;75;0;88#497;2;75;13;88#498;2;75;25;88#499;2;75;38;88#500;2;75;50;88#501;2;75;63;88#502;2;75;75;88#503;2;75;88;88#504;2;88;0;88#505;2;88;13;88#506;2;88;25;88#507;2;88;38;88#508;2;88;50;88#509;2;88;63;88#510;2;88;75;88#511;2;88;88;88#0!10~$#1!10?!10~$#2!20?!10~$#3!30?!10~$#4!40?!10~$#5!50?!10~$#6!60?!10~$#7!70?!10~$#8!80?!10~$#9!90?!10~$#10!100?!10~$#11!110?!10~$#12!120?!10~$#13!130?!10~$#14!140?!10~$#15!150?!10~$#16!160?!10~$#17!170?!10~$#18!180?!10~$#19!190?!10~$#20!200?!10~$#21!210?!10~$#22!220?!10~$#23!230?!10~$#24!240?!10~$#25!250?!10~$#26!260?!10~$#27!270?!10~$#28!280?!10~$#29!290?!10~$#30!300?!10~$#31!310?!10~$#32!320?!10~$#33!330?!10~$#34!340?!10~$#35!350?!10~$#36!360?!10~$#37!370?!10~$#38!380?!10~$#39!390?!10~$#40!400?!10~$#41!410?!10~$#42!420?!10~$#43!430?!10~$#44!440?!10~$#45!450?!10~$#46!460?!10~$#47!470?!10~$#48!480?!10~$#49!490?!10~$#50!500?!10~$#51!510?!10~$#52!520?!10~$#53!530?!10~$#54!540?!10~$#55!550?!10~$#56!560?!10~$#57!570?!10~$#58!580?!10~$#59!590?!10~$#60!600?!10~$#61!610?!10~$#62!620?!10~$#63!630?!10~$- +#0!10N$#64!10o$#1!10?!10N$#65!10?!10o$#2!20?!10N$#66!20?!10o$#3!30?!10N$#67!30?!10o$#4!40?!10N$#68!40?!10o$#5!50?!10N$#69!50?!10o$#6!60?!10N$#70!60?!10o$#7!70?!10N$#71!70?!10o$#8!80?!10N$#72!80?!10o$#9!90?!10N$#73!90?!10o$#10!100?!10N$#74!100?!10o$#11!110?!10N$#75!110?!10o$#12!120?!10N$#76!120?!10o$#13!130?!10N$#77!130?!10o$#14!140?!10N$#78!140?!10o$#15!150?!10N$#79!150?!10o$#16!160?!10N$#80!160?!10o$#17!170?!10N$#81!170?!10o$#18!180?!10N$#82!180?!10o$#19!190?!10N$#83!190?!10o$#20!200?!10N$#84!200?!10o$#21!210?!10N$#85!210?!10o$#22!220?!10N$#86!220?!10o$#23!230?!10N$#87!230?!10o$#24!240?!10N$#88!240?!10o$#25!250?!10N$#89!250?!10o$#26!260?!10N$#90!260?!10o$#27!270?!10N$#91!270?!10o$#28!280?!10N$#92!280?!10o$#29!290?!10N$#93!290?!10o$#30!300?!10N$#94!300?!10o$#31!310?!10N$#95!310?!10o$#32!320?!10N$#96!320?!10o$#33!330?!10N$#97!330?!10o$#34!340?!10N$#98!340?!10o$#35!350?!10N$#99!350?!10o$#36!360?!10N$#100!360?!10o$#37!370?!10N$#101!370?!10o$#38!380?!10N$#102!380?!10o$#39!390?!10N$#103!390?!10o$#40!400?!10N$#104!400?!10o$#41!410?!10N$#105!410?!10o$#42!420?!10N$#106!420?!10o$#43!430?!10N$#107!430?!10o$#44!440?!10N$#108!440?!10o$#45!450?!10N$#109!450?!10o$#46!460?!10N$#110!460?!10o$#47!470?!10N$#111!470?!10o$#48!480?!10N$#112!480?!10o$#49!490?!10N$#113!490?!10o$#50!500?!10N$#114!500?!10o$#51!510?!10N$#115!510?!10o$#52!520?!10N$#116!520?!10o$#53!530?!10N$#117!530?!10o$#54!540?!10N$#118!540?!10o$#55!550?!10N$#119!550?!10o$#56!560?!10N$#120!560?!10o$#57!570?!10N$#121!570?!10o$#58!580?!10N$#122!580?!10o$#59!590?!10N$#123!590?!10o$#60!600?!10N$#124!600?!10o$#61!610?!10N$#125!610?!10o$#62!620?!10N$#126!620?!10o$#63!630?!10N$#127!630?!10o$- +#64!10~$#65!10?!10~$#66!20?!10~$#67!30?!10~$#68!40?!10~$#69!50?!10~$#70!60?!10~$#71!70?!10~$#72!80?!10~$#73!90?!10~$#74!100?!10~$#75!110?!10~$#76!120?!10~$#77!130?!10~$#78!140?!10~$#79!150?!10~$#80!160?!10~$#81!170?!10~$#82!180?!10~$#83!190?!10~$#84!200?!10~$#85!210?!10~$#86!220?!10~$#87!230?!10~$#88!240?!10~$#89!250?!10~$#90!260?!10~$#91!270?!10~$#92!280?!10~$#93!290?!10~$#94!300?!10~$#95!310?!10~$#96!320?!10~$#97!330?!10~$#98!340?!10~$#99!350?!10~$#100!360?!10~$#101!370?!10~$#102!380?!10~$#103!390?!10~$#104!400?!10~$#105!410?!10~$#106!420?!10~$#107!430?!10~$#108!440?!10~$#109!450?!10~$#110!460?!10~$#111!470?!10~$#112!480?!10~$#113!490?!10~$#114!500?!10~$#115!510?!10~$#116!520?!10~$#117!530?!10~$#118!540?!10~$#119!550?!10~$#120!560?!10~$#121!570?!10~$#122!580?!10~$#123!590?!10~$#124!600?!10~$#125!610?!10~$#126!620?!10~$#127!630?!10~$- +#64!10B$#128!10{$#65!10?!10B$#129!10?!10{$#66!20?!10B$#130!20?!10{$#67!30?!10B$#131!30?!10{$#68!40?!10B$#132!40?!10{$#69!50?!10B$#133!50?!10{$#70!60?!10B$#134!60?!10{$#71!70?!10B$#135!70?!10{$#72!80?!10B$#136!80?!10{$#73!90?!10B$#137!90?!10{$#74!100?!10B$#138!100?!10{$#75!110?!10B$#139!110?!10{$#76!120?!10B$#140!120?!10{$#77!130?!10B$#141!130?!10{$#78!140?!10B$#142!140?!10{$#79!150?!10B$#143!150?!10{$#80!160?!10B$#144!160?!10{$#81!170?!10B$#145!170?!10{$#82!180?!10B$#146!180?!10{$#83!190?!10B$#147!190?!10{$#84!200?!10B$#148!200?!10{$#85!210?!10B$#149!210?!10{$#86!220?!10B$#150!220?!10{$#87!230?!10B$#151!230?!10{$#88!240?!10B$#152!240?!10{$#89!250?!10B$#153!250?!10{$#90!260?!10B$#154!260?!10{$#91!270?!10B$#155!270?!10{$#92!280?!10B$#156!280?!10{$#93!290?!10B$#157!290?!10{$#94!300?!10B$#158!300?!10{$#95!310?!10B$#159!310?!10{$#96!320?!10B$#160!320?!10{$#97!330?!10B$#161!330?!10{$#98!340?!10B$#162!340?!10{$#99!350?!10B$#163!350?!10{$#100!360?!10B$#164!360?!10{$#101!370?!10B$#165!370?!10{$#102!380?!10B$#166!380?!10{$#103!390?!10B$#167!390?!10{$#104!400?!10B$#168!400?!10{$#105!410?!10B$#169!410?!10{$#106!420?!10B$#170!420?!10{$#107!430?!10B$#171!430?!10{$#108!440?!10B$#172!440?!10{$#109!450?!10B$#173!450?!10{$#110!460?!10B$#174!460?!10{$#111!470?!10B$#175!470?!10{$#112!480?!10B$#176!480?!10{$#113!490?!10B$#177!490?!10{$#114!500?!10B$#178!500?!10{$#115!510?!10B$#179!510?!10{$#116!520?!10B$#180!520?!10{$#117!530?!10B$#181!530?!10{$#118!540?!10B$#182!540?!10{$#119!550?!10B$#183!550?!10{$#120!560?!10B$#184!560?!10{$#121!570?!10B$#185!570?!10{$#122!580?!10B$#186!580?!10{$#123!590?!10B$#187!590?!10{$#124!600?!10B$#188!600?!10{$#125!610?!10B$#189!610?!10{$#126!620?!10B$#190!620?!10{$#127!630?!10B$#191!630?!10{$- +#128!10~$#129!10?!10~$#130!20?!10~$#131!30?!10~$#132!40?!10~$#133!50?!10~$#134!60?!10~$#135!70?!10~$#136!80?!10~$#137!90?!10~$#138!100?!10~$#139!110?!10~$#140!120?!10~$#141!130?!10~$#142!140?!10~$#143!150?!10~$#144!160?!10~$#145!170?!10~$#146!180?!10~$#147!190?!10~$#148!200?!10~$#149!210?!10~$#150!220?!10~$#151!230?!10~$#152!240?!10~$#153!250?!10~$#154!260?!10~$#155!270?!10~$#156!280?!10~$#157!290?!10~$#158!300?!10~$#159!310?!10~$#160!320?!10~$#161!330?!10~$#162!340?!10~$#163!350?!10~$#164!360?!10~$#165!370?!10~$#166!380?!10~$#167!390?!10~$#168!400?!10~$#169!410?!10~$#170!420?!10~$#171!430?!10~$#172!440?!10~$#173!450?!10~$#174!460?!10~$#175!470?!10~$#176!480?!10~$#177!490?!10~$#178!500?!10~$#179!510?!10~$#180!520?!10~$#181!530?!10~$#182!540?!10~$#183!550?!10~$#184!560?!10~$#185!570?!10~$#186!580?!10~$#187!590?!10~$#188!600?!10~$#189!610?!10~$#190!620?!10~$#191!630?!10~$- +#192!10~$#193!10?!10~$#194!20?!10~$#195!30?!10~$#196!40?!10~$#197!50?!10~$#198!60?!10~$#199!70?!10~$#200!80?!10~$#201!90?!10~$#202!100?!10~$#203!110?!10~$#204!120?!10~$#205!130?!10~$#206!140?!10~$#207!150?!10~$#208!160?!10~$#209!170?!10~$#210!180?!10~$#211!190?!10~$#212!200?!10~$#213!210?!10~$#214!220?!10~$#215!230?!10~$#216!240?!10~$#217!250?!10~$#218!260?!10~$#219!270?!10~$#220!280?!10~$#221!290?!10~$#222!300?!10~$#223!310?!10~$#224!320?!10~$#225!330?!10~$#226!340?!10~$#227!350?!10~$#228!360?!10~$#229!370?!10~$#230!380?!10~$#231!390?!10~$#232!400?!10~$#233!410?!10~$#234!420?!10~$#235!430?!10~$#236!440?!10~$#237!450?!10~$#238!460?!10~$#239!470?!10~$#240!480?!10~$#241!490?!10~$#242!500?!10~$#243!510?!10~$#244!520?!10~$#245!530?!10~$#246!540?!10~$#247!550?!10~$#248!560?!10~$#249!570?!10~$#250!580?!10~$#251!590?!10~$#252!600?!10~$#253!610?!10~$#254!620?!10~$#255!630?!10~$- +#192!10N$#256!10o$#193!10?!10N$#257!10?!10o$#194!20?!10N$#258!20?!10o$#195!30?!10N$#259!30?!10o$#196!40?!10N$#260!40?!10o$#197!50?!10N$#261!50?!10o$#198!60?!10N$#262!60?!10o$#199!70?!10N$#263!70?!10o$#200!80?!10N$#264!80?!10o$#201!90?!10N$#265!90?!10o$#202!100?!10N$#266!100?!10o$#203!110?!10N$#267!110?!10o$#204!120?!10N$#268!120?!10o$#205!130?!10N$#269!130?!10o$#206!140?!10N$#270!140?!10o$#207!150?!10N$#271!150?!10o$#208!160?!10N$#272!160?!10o$#209!170?!10N$#273!170?!10o$#210!180?!10N$#274!180?!10o$#211!190?!10N$#275!190?!10o$#212!200?!10N$#276!200?!10o$#213!210?!10N$#277!210?!10o$#214!220?!10N$#278!220?!10o$#215!230?!10N$#279!230?!10o$#216!240?!10N$#280!240?!10o$#217!250?!10N$#281!250?!10o$#218!260?!10N$#282!260?!10o$#219!270?!10N$#283!270?!10o$#220!280?!10N$#284!280?!10o$#221!290?!10N$#285!290?!10o$#222!300?!10N$#286!300?!10o$#223!310?!10N$#287!310?!10o$#224!320?!10N$#288!320?!10o$#225!330?!10N$#289!330?!10o$#226!340?!10N$#290!340?!10o$#227!350?!10N$#291!350?!10o$#228!360?!10N$#292!360?!10o$#229!370?!10N$#293!370?!10o$#230!380?!10N$#294!380?!10o$#231!390?!10N$#295!390?!10o$#232!400?!10N$#296!400?!10o$#233!410?!10N$#297!410?!10o$#234!420?!10N$#298!420?!10o$#235!430?!10N$#299!430?!10o$#236!440?!10N$#300!440?!10o$#237!450?!10N$#301!450?!10o$#238!460?!10N$#302!460?!10o$#239!470?!10N$#303!470?!10o$#240!480?!10N$#304!480?!10o$#241!490?!10N$#305!490?!10o$#242!500?!10N$#306!500?!10o$#243!510?!10N$#307!510?!10o$#244!520?!10N$#308!520?!10o$#245!530?!10N$#309!530?!10o$#246!540?!10N$#310!540?!10o$#247!550?!10N$#311!550?!10o$#248!560?!10N$#312!560?!10o$#249!570?!10N$#313!570?!10o$#250!580?!10N$#314!580?!10o$#251!590?!10N$#315!590?!10o$#252!600?!10N$#316!600?!10o$#253!610?!10N$#317!610?!10o$#254!620?!10N$#318!620?!10o$#255!630?!10N$#319!630?!10o$- +#256!10~$#257!10?!10~$#258!20?!10~$#259!30?!10~$#260!40?!10~$#261!50?!10~$#262!60?!10~$#263!70?!10~$#264!80?!10~$#265!90?!10~$#266!100?!10~$#267!110?!10~$#268!120?!10~$#269!130?!10~$#270!140?!10~$#271!150?!10~$#272!160?!10~$#273!170?!10~$#274!180?!10~$#275!190?!10~$#276!200?!10~$#277!210?!10~$#278!220?!10~$#279!230?!10~$#280!240?!10~$#281!250?!10~$#282!260?!10~$#283!270?!10~$#284!280?!10~$#285!290?!10~$#286!300?!10~$#287!310?!10~$#288!320?!10~$#289!330?!10~$#290!340?!10~$#291!350?!10~$#292!360?!10~$#293!370?!10~$#294!380?!10~$#295!390?!10~$#296!400?!10~$#297!410?!10~$#298!420?!10~$#299!430?!10~$#300!440?!10~$#301!450?!10~$#302!460?!10~$#303!470?!10~$#304!480?!10~$#305!490?!10~$#306!500?!10~$#307!510?!10~$#308!520?!10~$#309!530?!10~$#310!540?!10~$#311!550?!10~$#312!560?!10~$#313!570?!10~$#314!580?!10~$#315!590?!10~$#316!600?!10~$#317!610?!10~$#318!620?!10~$#319!630?!10~$- +#256!10B$#320!10{$#257!10?!10B$#321!10?!10{$#258!20?!10B$#322!20?!10{$#259!30?!10B$#323!30?!10{$#260!40?!10B$#324!40?!10{$#261!50?!10B$#325!50?!10{$#262!60?!10B$#326!60?!10{$#263!70?!10B$#327!70?!10{$#264!80?!10B$#328!80?!10{$#265!90?!10B$#329!90?!10{$#266!100?!10B$#330!100?!10{$#267!110?!10B$#331!110?!10{$#268!120?!10B$#332!120?!10{$#269!130?!10B$#333!130?!10{$#270!140?!10B$#334!140?!10{$#271!150?!10B$#335!150?!10{$#272!160?!10B$#336!160?!10{$#273!170?!10B$#337!170?!10{$#274!180?!10B$#338!180?!10{$#275!190?!10B$#339!190?!10{$#276!200?!10B$#340!200?!10{$#277!210?!10B$#341!210?!10{$#278!220?!10B$#342!220?!10{$#279!230?!10B$#343!230?!10{$#280!240?!10B$#344!240?!10{$#281!250?!10B$#345!250?!10{$#282!260?!10B$#346!260?!10{$#283!270?!10B$#347!270?!10{$#284!280?!10B$#348!280?!10{$#285!290?!10B$#349!290?!10{$#286!300?!10B$#350!300?!10{$#287!310?!10B$#351!310?!10{$#288!320?!10B$#352!320?!10{$#289!330?!10B$#353!330?!10{$#290!340?!10B$#354!340?!10{$#291!350?!10B$#355!350?!10{$#292!360?!10B$#356!360?!10{$#293!370?!10B$#357!370?!10{$#294!380?!10B$#358!380?!10{$#295!390?!10B$#359!390?!10{$#296!400?!10B$#360!400?!10{$#297!410?!10B$#361!410?!10{$#298!420?!10B$#362!420?!10{$#299!430?!10B$#363!430?!10{$#300!440?!10B$#364!440?!10{$#301!450?!10B$#365!450?!10{$#302!460?!10B$#366!460?!10{$#303!470?!10B$#367!470?!10{$#304!480?!10B$#368!480?!10{$#305!490?!10B$#369!490?!10{$#306!500?!10B$#370!500?!10{$#307!510?!10B$#371!510?!10{$#308!520?!10B$#372!520?!10{$#309!530?!10B$#373!530?!10{$#310!540?!10B$#374!540?!10{$#311!550?!10B$#375!550?!10{$#312!560?!10B$#376!560?!10{$#313!570?!10B$#377!570?!10{$#314!580?!10B$#378!580?!10{$#315!590?!10B$#379!590?!10{$#316!600?!10B$#380!600?!10{$#317!610?!10B$#381!610?!10{$#318!620?!10B$#382!620?!10{$#319!630?!10B$#383!630?!10{$- +#320!10~$#321!10?!10~$#322!20?!10~$#323!30?!10~$#324!40?!10~$#325!50?!10~$#326!60?!10~$#327!70?!10~$#328!80?!10~$#329!90?!10~$#330!100?!10~$#331!110?!10~$#332!120?!10~$#333!130?!10~$#334!140?!10~$#335!150?!10~$#336!160?!10~$#337!170?!10~$#338!180?!10~$#339!190?!10~$#340!200?!10~$#341!210?!10~$#342!220?!10~$#343!230?!10~$#344!240?!10~$#345!250?!10~$#346!260?!10~$#347!270?!10~$#348!280?!10~$#349!290?!10~$#350!300?!10~$#351!310?!10~$#352!320?!10~$#353!330?!10~$#354!340?!10~$#355!350?!10~$#356!360?!10~$#357!370?!10~$#358!380?!10~$#359!390?!10~$#360!400?!10~$#361!410?!10~$#362!420?!10~$#363!430?!10~$#364!440?!10~$#365!450?!10~$#366!460?!10~$#367!470?!10~$#368!480?!10~$#369!490?!10~$#370!500?!10~$#371!510?!10~$#372!520?!10~$#373!530?!10~$#374!540?!10~$#375!550?!10~$#376!560?!10~$#377!570?!10~$#378!580?!10~$#379!590?!10~$#380!600?!10~$#381!610?!10~$#382!620?!10~$#383!630?!10~$- +#384!10~$#385!10?!10~$#386!20?!10~$#387!30?!10~$#388!40?!10~$#389!50?!10~$#390!60?!10~$#391!70?!10~$#392!80?!10~$#393!90?!10~$#394!100?!10~$#395!110?!10~$#396!120?!10~$#397!130?!10~$#398!140?!10~$#399!150?!10~$#400!160?!10~$#401!170?!10~$#402!180?!10~$#403!190?!10~$#404!200?!10~$#405!210?!10~$#406!220?!10~$#407!230?!10~$#408!240?!10~$#409!250?!10~$#410!260?!10~$#411!270?!10~$#412!280?!10~$#413!290?!10~$#414!300?!10~$#415!310?!10~$#416!320?!10~$#417!330?!10~$#418!340?!10~$#419!350?!10~$#420!360?!10~$#421!370?!10~$#422!380?!10~$#423!390?!10~$#424!400?!10~$#425!410?!10~$#426!420?!10~$#427!430?!10~$#428!440?!10~$#429!450?!10~$#430!460?!10~$#431!470?!10~$#432!480?!10~$#433!490?!10~$#434!500?!10~$#435!510?!10~$#436!520?!10~$#437!530?!10~$#438!540?!10~$#439!550?!10~$#440!560?!10~$#441!570?!10~$#442!580?!10~$#443!590?!10~$#444!600?!10~$#445!610?!10~$#446!620?!10~$#447!630?!10~$- +#384!10N$#448!10o$#385!10?!10N$#449!10?!10o$#386!20?!10N$#450!20?!10o$#387!30?!10N$#451!30?!10o$#388!40?!10N$#452!40?!10o$#389!50?!10N$#453!50?!10o$#390!60?!10N$#454!60?!10o$#391!70?!10N$#455!70?!10o$#392!80?!10N$#456!80?!10o$#393!90?!10N$#457!90?!10o$#394!100?!10N$#458!100?!10o$#395!110?!10N$#459!110?!10o$#396!120?!10N$#460!120?!10o$#397!130?!10N$#461!130?!10o$#398!140?!10N$#462!140?!10o$#399!150?!10N$#463!150?!10o$#400!160?!10N$#464!160?!10o$#401!170?!10N$#465!170?!10o$#402!180?!10N$#466!180?!10o$#403!190?!10N$#467!190?!10o$#404!200?!10N$#468!200?!10o$#405!210?!10N$#469!210?!10o$#406!220?!10N$#470!220?!10o$#407!230?!10N$#471!230?!10o$#408!240?!10N$#472!240?!10o$#409!250?!10N$#473!250?!10o$#410!260?!10N$#474!260?!10o$#411!270?!10N$#475!270?!10o$#412!280?!10N$#476!280?!10o$#413!290?!10N$#477!290?!10o$#414!300?!10N$#478!300?!10o$#415!310?!10N$#479!310?!10o$#416!320?!10N$#480!320?!10o$#417!330?!10N$#481!330?!10o$#418!340?!10N$#482!340?!10o$#419!350?!10N$#483!350?!10o$#420!360?!10N$#484!360?!10o$#421!370?!10N$#485!370?!10o$#422!380?!10N$#486!380?!10o$#423!390?!10N$#487!390?!10o$#424!400?!10N$#488!400?!10o$#425!410?!10N$#489!410?!10o$#426!420?!10N$#490!420?!10o$#427!430?!10N$#491!430?!10o$#428!440?!10N$#492!440?!10o$#429!450?!10N$#493!450?!10o$#430!460?!10N$#494!460?!10o$#431!470?!10N$#495!470?!10o$#432!480?!10N$#496!480?!10o$#433!490?!10N$#497!490?!10o$#434!500?!10N$#498!500?!10o$#435!510?!10N$#499!510?!10o$#436!520?!10N$#500!520?!10o$#437!530?!10N$#501!530?!10o$#438!540?!10N$#502!540?!10o$#439!550?!10N$#503!550?!10o$#440!560?!10N$#504!560?!10o$#441!570?!10N$#505!570?!10o$#442!580?!10N$#506!580?!10o$#443!590?!10N$#507!590?!10o$#444!600?!10N$#508!600?!10o$#445!610?!10N$#509!610?!10o$#446!620?!10N$#510!620?!10o$#447!630?!10N$#511!630?!10o$- +#448!10~$#449!10?!10~$#450!20?!10~$#451!30?!10~$#452!40?!10~$#453!50?!10~$#454!60?!10~$#455!70?!10~$#456!80?!10~$#457!90?!10~$#458!100?!10~$#459!110?!10~$#460!120?!10~$#461!130?!10~$#462!140?!10~$#463!150?!10~$#464!160?!10~$#465!170?!10~$#466!180?!10~$#467!190?!10~$#468!200?!10~$#469!210?!10~$#470!220?!10~$#471!230?!10~$#472!240?!10~$#473!250?!10~$#474!260?!10~$#475!270?!10~$#476!280?!10~$#477!290?!10~$#478!300?!10~$#479!310?!10~$#480!320?!10~$#481!330?!10~$#482!340?!10~$#483!350?!10~$#484!360?!10~$#485!370?!10~$#486!380?!10~$#487!390?!10~$#488!400?!10~$#489!410?!10~$#490!420?!10~$#491!430?!10~$#492!440?!10~$#493!450?!10~$#494!460?!10~$#495!470?!10~$#496!480?!10~$#497!490?!10~$#498!500?!10~$#499!510?!10~$#500!520?!10~$#501!530?!10~$#502!540?!10~$#503!550?!10~$#504!560?!10~$#505!570?!10~$#506!580?!10~$#507!590?!10~$#508!600?!10~$#509!610?!10~$#510!620?!10~$#511!630?!10~$- +#448!10B$#449!10?!10B$#450!20?!10B$#451!30?!10B$#452!40?!10B$#453!50?!10B$#454!60?!10B$#455!70?!10B$#456!80?!10B$#457!90?!10B$#458!100?!10B$#459!110?!10B$#460!120?!10B$#461!130?!10B$#462!140?!10B$#463!150?!10B$#464!160?!10B$#465!170?!10B$#466!180?!10B$#467!190?!10B$#468!200?!10B$#469!210?!10B$#470!220?!10B$#471!230?!10B$#472!240?!10B$#473!250?!10B$#474!260?!10B$#475!270?!10B$#476!280?!10B$#477!290?!10B$#478!300?!10B$#479!310?!10B$#480!320?!10B$#481!330?!10B$#482!340?!10B$#483!350?!10B$#484!360?!10B$#485!370?!10B$#486!380?!10B$#487!390?!10B$#488!400?!10B$#489!410?!10B$#490!420?!10B$#491!430?!10B$#492!440?!10B$#493!450?!10B$#494!460?!10B$#495!470?!10B$#496!480?!10B$#497!490?!10B$#498!500?!10B$#499!510?!10B$#500!520?!10B$#501!530?!10B$#502!540?!10B$#503!550?!10B$#504!560?!10B$#505!570?!10B$#506!580?!10B$#507!590?!10B$#508!600?!10B$#509!610?!10B$#510!620?!10B$#511!630?!10B$\ \ No newline at end of file diff --git a/overwrite/.eslintrc.json b/overwrite/.eslintrc.json new file mode 100644 index 0000000..501d452 --- /dev/null +++ b/overwrite/.eslintrc.json @@ -0,0 +1,163 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": [ + "src/browser/tsconfig.json", + "src/common/tsconfig.json", + "src/headless/tsconfig.json", + "test/api/tsconfig.json", + "test/benchmark/tsconfig.json", + "addons/tsconfig.eslint.addons.json" + ], + "sourceType": "module" + }, + "ignorePatterns": [ + "**/typings/*.d.ts", + "**/node_modules", + "**/*.js" + ], + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "no-extra-semi": "error", + "@typescript-eslint/array-type": [ + "warn", + { + "default": "array", + "readonly": "generic" + } + ], + "@typescript-eslint/consistent-type-assertions": "warn", + "@typescript-eslint/consistent-type-definitions": "warn", + "@typescript-eslint/explicit-function-return-type": [ + "warn", + { + "allowExpressions": true + } + ], + "@typescript-eslint/explicit-member-accessibility": [ + "warn", + { + "accessibility": "explicit", + "overrides": { + "constructors": "off" + } + } + ], + "@typescript-eslint/indent": [ + "warn", + 2 + ], + "@typescript-eslint/member-delimiter-style": [ + "warn", + { + "multiline": { + "delimiter": "semi", + "requireLast": true + }, + "singleline": { + "delimiter": "comma", + "requireLast": false + } + } + ], + "@typescript-eslint/naming-convention": [ + "warn", + { "selector": "default", "format": ["camelCase"] }, + // variableLike + { "selector": "variable", "format": ["camelCase", "UPPER_CASE"] }, + { "selector": "variable", "filter": "^I.+Service$", "format": ["PascalCase"], "prefix": ["I"] }, + // memberLike + { "selector": "memberLike", "modifiers": ["private"], "format": ["camelCase"], "leadingUnderscore": "require" }, + { "selector": "memberLike", "modifiers": ["protected"], "format": ["camelCase"], "leadingUnderscore": "require" }, + { "selector": "enumMember", "format": ["UPPER_CASE"] }, + // memberLike - Allow enum-like objects to use UPPER_CASE + { "selector": "property", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"] }, + { "selector": "method", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"] }, + // typeLike + { "selector": "typeLike", "format": ["PascalCase"] }, + { "selector": "interface", "format": ["PascalCase"], "prefix": ["I"] } + ], + "@typescript-eslint/prefer-namespace-keyword": "warn", + "@typescript-eslint/type-annotation-spacing": "warn", + "@typescript-eslint/quotes": [ + "warn", + "single", + { "allowTemplateLiterals": true } + ], + "@typescript-eslint/semi": [ + "warn", + "always" + ], + "comma-dangle": [ + "warn", + { + "objects": "never", + "arrays": "never", + "functions": "never" + } + ], + "curly": [ + "warn", + "multi-line" + ], + "eol-last": "warn", + "eqeqeq": [ + "warn", + "always" + ], + "keyword-spacing": "warn", + "new-parens": "warn", + "no-duplicate-imports": "warn", + "no-else-return": [ + "warn", + { + "allowElseIf": false + } + ], + "no-eval": "warn", + "no-irregular-whitespace": "warn", + "no-restricted-imports": [ + "warn", + { + "patterns": [ + ".*\\/out\\/.*" + ] + } + ], + "no-trailing-spaces": "warn", + "no-unsafe-finally": "warn", + "no-var": "warn", + "one-var": [ + "warn", + "never" + ], + "object-curly-spacing": [ + "warn", + "always" + ], + "prefer-const": "warn", + "spaced-comment": [ + "warn", + "always", + { + "markers": ["/"], + "exceptions": ["-"] + } + ] + }, + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "object-curly-spacing": "off" + } + } + ] +} diff --git a/overwrite/addons/tsconfig.eslint.addons.json b/overwrite/addons/tsconfig.eslint.addons.json new file mode 100644 index 0000000..bebfc54 --- /dev/null +++ b/overwrite/addons/tsconfig.eslint.addons.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2017", + "rootDir": ".", + "outDir": "../out-test", + "sourceMap": true, + "removeComments": true, + "strict": true, + "baseUrl": ".", + "paths": { + "browser/*": [ "../../../src/browser/*" ], + "common/*": [ "../../../src/common/*" ] + }, + "types": [ + "../node_modules/@types/mocha", + "../node_modules/@types/node", + "../out-test/api/TestUtils" + ] + }, + "include": [ + "../typings/xterm.d.ts", + "./**/src/*", + "./**/src-worker/*", + "./**/test/*", + "./**/benchmark/*" + ], + "references": [ + { "path": "../src/browser" }, + { "path": "../src/common" } + ] +} diff --git a/overwrite/demo/client.ts b/overwrite/demo/client.ts new file mode 100644 index 0000000..db9417f --- /dev/null +++ b/overwrite/demo/client.ts @@ -0,0 +1,601 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + * + * This file is the entry point for browserify. + */ + +/// + +// Use tsc version (yarn watch) +import { Terminal } from '../out/browser/public/Terminal'; +import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; +import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; +import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; +import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; +import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; +import { WebglAddon } from '../addons/xterm-addon-webgl/out/WebglAddon'; +import { ImageAddon, IImageAddonOptions } from '../addons/xterm-addon-image/out/ImageAddon'; +import { Unicode11Addon } from '../addons/xterm-addon-unicode11/out/Unicode11Addon'; +import { LigaturesAddon } from '../addons/xterm-addon-ligatures/out/LigaturesAddon'; + +// Use webpacked version (yarn package) +// import { Terminal } from '../lib/xterm'; +// import { AttachAddon } from 'xterm-addon-attach'; +// import { FitAddon } from 'xterm-addon-fit'; +// import { SearchAddon, ISearchOptions } from 'xterm-addon-search'; +// import { SerializeAddon } from 'xterm-addon-serialize'; +// import { WebLinksAddon } from 'xterm-addon-web-links'; +// import { WebglAddon } from 'xterm-addon-webgl'; +// import { Unicode11Addon } from 'xterm-addon-unicode11'; +// import { LigaturesAddon } from 'xterm-addon-ligatures'; + +// Pulling in the module's types relies on the above, it's looks a +// little weird here as we're importing "this" module +import { Terminal as TerminalType, ITerminalOptions } from 'xterm'; + +export interface IWindowWithTerminal extends Window { + term: TerminalType; + Terminal?: typeof TerminalType; + AttachAddon?: typeof AttachAddon; + FitAddon?: typeof FitAddon; + ImageAddon?: typeof ImageAddon; + SearchAddon?: typeof SearchAddon; + SerializeAddon?: typeof SerializeAddon; + WebLinksAddon?: typeof WebLinksAddon; + WebglAddon?: typeof WebglAddon; + Unicode11Addon?: typeof Unicode11Addon; + LigaturesAddon?: typeof LigaturesAddon; +} +declare let window: IWindowWithTerminal; + +let term; +let protocol; +let socketURL; +let socket; +let pid; + +type AddonType = 'attach' | 'fit' | 'image' | 'ligatures' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; + +interface IDemoAddon { + name: T; + canChange: boolean; + ctor: + T extends 'attach' ? typeof AttachAddon : + T extends 'fit' ? typeof FitAddon : + T extends 'image' ? typeof ImageAddon : + T extends 'search' ? typeof SearchAddon : + T extends 'serialize' ? typeof SerializeAddon : + T extends 'web-links' ? typeof WebLinksAddon : + T extends 'unicode11' ? typeof Unicode11Addon : + T extends 'ligatures' ? typeof LigaturesAddon : + typeof WebglAddon; + instance?: + T extends 'attach' ? AttachAddon : + T extends 'fit' ? FitAddon : + T extends 'image' ? ImageAddon : + T extends 'search' ? SearchAddon : + T extends 'serialize' ? SerializeAddon : + T extends 'web-links' ? WebLinksAddon : + T extends 'webgl' ? WebglAddon : + T extends 'unicode11' ? Unicode11Addon : + T extends 'ligatures' ? typeof LigaturesAddon : + never; +} + +const IMAGE_WORKER_PATH = '/workers/xterm-addon-image-worker.js'; + +const addons: { [T in AddonType]: IDemoAddon} = { + attach: { name: 'attach', ctor: AttachAddon, canChange: false }, + fit: { name: 'fit', ctor: FitAddon, canChange: false }, + image: { name: 'image', ctor: ImageAddon, canChange: true }, + ligatures: { name: 'ligatures', ctor: LigaturesAddon, canChange: true }, + search: { name: 'search', ctor: SearchAddon, canChange: true }, + serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true }, + 'web-links': { name: 'web-links', ctor: WebLinksAddon, canChange: true }, + webgl: { name: 'webgl', ctor: WebglAddon, canChange: true }, + unicode11: { name: 'unicode11', ctor: Unicode11Addon, canChange: true } +}; + +const terminalContainer = document.getElementById('terminal-container'); +const actionElements = { + findNext: document.querySelector('#find-next'), + findPrevious: document.querySelector('#find-previous') +}; +const paddingElement = document.querySelector('#padding'); + +function setPadding(): void { + term.element.style.padding = parseInt(paddingElement.value, 10).toString() + 'px'; + addons.fit.instance.fit(); +} + +function getSearchOptions(e: KeyboardEvent): ISearchOptions { + return { + regex: document.querySelector('#regex').checked, + wholeWord: document.querySelector('#whole-word').checked, + caseSensitive: document.querySelector('#case-sensitive').checked, + incremental: e.key !== `Enter` + }; +} + +const disposeRecreateButtonHandler = () => { + // If the terminal exists dispose of it, otherwise recreate it + if (term) { + term.dispose(); + term = null; + window.term = null; + socket = null; + addons.attach.instance = undefined; + addons.fit.instance = undefined; + addons.image.instance = undefined; + addons.search.instance = undefined; + addons.serialize.instance = undefined; + addons.unicode11.instance = undefined; + addons.ligatures.instance = undefined; + addons['web-links'].instance = undefined; + addons.webgl.instance = undefined; + document.getElementById('dispose').innerHTML = 'Recreate Terminal'; + } else { + createTerminal(); + document.getElementById('dispose').innerHTML = 'Dispose terminal'; + } +}; + +if (document.location.pathname === '/test') { + window.Terminal = Terminal; + window.AttachAddon = AttachAddon; + window.FitAddon = FitAddon; + window.ImageAddon = ImageAddon; + window.SearchAddon = SearchAddon; + window.SerializeAddon = SerializeAddon; + window.Unicode11Addon = Unicode11Addon; + window.LigaturesAddon = LigaturesAddon; + window.WebLinksAddon = WebLinksAddon; + window.WebglAddon = WebglAddon; +} else { + createTerminal(); + document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); + document.getElementById('serialize').addEventListener('click', serializeButtonHandler); + document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); + initImageAddonExposed(); + document.getElementById('load-test').addEventListener('click', loadTest); +} + +function createTerminal(): void { + // Clean terminal + while (terminalContainer.children.length) { + terminalContainer.removeChild(terminalContainer.children[0]); + } + + const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; + term = new Terminal({ + windowsMode: isWindows, + fontFamily: 'Fira Code, courier-new, courier, monospace' + } as ITerminalOptions); + + // Load addons + const typedTerm = term as TerminalType; + addons.search.instance = new SearchAddon(); + addons.serialize.instance = new SerializeAddon(); + addons.fit.instance = new FitAddon(); + addons.unicode11.instance = new Unicode11Addon(); + addons.image.instance = new ImageAddon(IMAGE_WORKER_PATH); + // TODO: Remove arguments when link provider API is the default + addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); + typedTerm.loadAddon(addons.fit.instance); + typedTerm.loadAddon(addons.search.instance); + typedTerm.loadAddon(addons.serialize.instance); + typedTerm.loadAddon(addons.unicode11.instance); + typedTerm.loadAddon(addons.image.instance); + typedTerm.loadAddon(addons['web-links'].instance); + + window.term = term; // Expose `term` to window for debugging purposes + term.onResize((size: { cols: number, rows: number }) => { + if (!pid) { + return; + } + const cols = size.cols; + const rows = size.rows; + const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows; + + fetch(url, {method: 'POST'}); + }); + protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'; + socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/terminals/'; + + term.open(terminalContainer); + addons.fit.instance!.fit(); + term.focus(); + + addDomListener(paddingElement, 'change', setPadding); + + addDomListener(actionElements.findNext, 'keyup', (e) => { + addons.search.instance.findNext(actionElements.findNext.value, getSearchOptions(e)); + }); + + addDomListener(actionElements.findPrevious, 'keyup', (e) => { + addons.search.instance.findPrevious(actionElements.findPrevious.value, getSearchOptions(e)); + }); + + // fit is called within a setTimeout, cols and rows need this. + setTimeout(() => { + initOptions(term); + // TODO: Clean this up, opt-cols/rows doesn't exist anymore + document.querySelector('#opt-cols').valueAsNumber = term.cols; + document.querySelector('#opt-rows').valueAsNumber = term.rows; + paddingElement.value = '0'; + + // Set terminal size again to set the specific dimensions on the demo + updateTerminalSize(); + + fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then((res) => { + res.text().then((processId) => { + pid = processId; + socketURL += processId; + socket = new WebSocket(socketURL); + socket.onopen = runRealTerminal; + socket.onclose = runFakeTerminal; + socket.onerror = runFakeTerminal; + }); + }); + }, 0); +} + +function runRealTerminal(): void { + addons.attach.instance = new AttachAddon(socket); + term.loadAddon(addons.attach.instance); + term._initialized = true; + initAddons(term); +} + +function runFakeTerminal(): void { + if (term._initialized) { + return; + } + + term._initialized = true; + initAddons(term); + + term.prompt = () => { + term.write('\r\n$ '); + }; + + term.writeln('Welcome to xterm.js'); + term.writeln('This is a local terminal emulation, without a real terminal in the back-end.'); + term.writeln('Type some keys and commands to play around.'); + term.writeln(''); + term.prompt(); + + term.onKey((e: { key: string, domEvent: KeyboardEvent }) => { + const ev = e.domEvent; + const printable = !ev.altKey && !ev.ctrlKey && !ev.metaKey; + + if (ev.keyCode === 13) { + term.prompt(); + } else if (ev.keyCode === 8) { + // Do not delete the prompt + if (term._core.buffer.x > 2) { + term.write('\b \b'); + } + } else if (printable) { + term.write(e.key); + } + }); +} + +function initOptions(term: TerminalType): void { + const blacklistedOptions = [ + // Internal only options + 'cancelEvents', + 'convertEol', + 'termName', + // Complex option + 'theme', + 'windowOptions' + ]; + const stringOptions = { + bellSound: null, + bellStyle: ['none', 'sound'], + cursorStyle: ['block', 'underline', 'bar'], + fastScrollModifier: ['alt', 'ctrl', 'shift', undefined], + fontFamily: null, + fontWeight: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], + fontWeightBold: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], + logLevel: ['debug', 'info', 'warn', 'error', 'off'], + rendererType: ['dom', 'canvas'], + wordSeparator: null + }; + const options = Object.getOwnPropertyNames(term.options); + const booleanOptions = []; + const numberOptions = []; + options.filter(o => blacklistedOptions.indexOf(o) === -1).forEach(o => { + switch (typeof term.options[o]) { + case 'boolean': + booleanOptions.push(o); + break; + case 'number': + numberOptions.push(o); + break; + default: + if (Object.keys(stringOptions).indexOf(o) === -1) { + console.warn(`Unrecognized option: "${o}"`); + } + } + }); + + let html = ''; + html += '
'; + booleanOptions.forEach(o => { + html += `
`; + }); + html += '
'; + numberOptions.forEach(o => { + html += `
`; + }); + html += '
'; + Object.keys(stringOptions).forEach(o => { + if (stringOptions[o]) { + html += `
`; + } else { + html += `
`; + } + }); + html += '
'; + + const container = document.getElementById('options-container'); + container.innerHTML = html; + + // Attach listeners + booleanOptions.forEach(o => { + const input = document.querySelector(`#opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.checked); + term.options[o] = input.checked; + }); + }); + numberOptions.forEach(o => { + const input = document.querySelector(`#opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.value); + if (o === 'cols' || o === 'rows') { + updateTerminalSize(); + } else if (o === 'lineHeight') { + term.options.lineHeight = parseFloat(input.value); + updateTerminalSize(); + } else if (o === 'scrollSensitivity') { + term.options.scrollSensitivity = parseFloat(input.value); + updateTerminalSize(); + } else if(o === 'scrollback') { + term.options.scrollback = parseInt(input.value); + setTimeout(() => updateTerminalSize(), 5); + } else { + term.options[o] = parseInt(input.value); + } + }); + }); + Object.keys(stringOptions).forEach(o => { + const input = document.querySelector(`#opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.value); + term.options[o] = input.value; + }); + }); +} + +function initAddons(term: TerminalType): void { + const fragment = document.createDocumentFragment(); + Object.keys(addons).forEach((name: AddonType) => { + const addon = addons[name]; + const checkbox = document.createElement('input') as HTMLInputElement; + checkbox.type = 'checkbox'; + checkbox.checked = !!addon.instance; + if (!addon.canChange) { + checkbox.disabled = true; + } + if(name === 'unicode11' && checkbox.checked) { + term.unicode.activeVersion = '11'; + } + addDomListener(checkbox, 'change', () => { + if (name === 'image') { + if (checkbox.checked) { + const ctorOptionsJson = document.querySelector('#image-options').value; + addon.instance = ctorOptionsJson + ? new addon.ctor(IMAGE_WORKER_PATH, JSON.parse(ctorOptionsJson)) + : new addon.ctor(IMAGE_WORKER_PATH); + term.loadAddon(addon.instance); + } else { + addon.instance!.dispose(); + addon.instance = undefined; + } + return; + } + if (checkbox.checked) { + addon.instance = new addon.ctor(); + term.loadAddon(addon.instance); + if (name === 'webgl') { + setTimeout(() => { + document.body.appendChild((addon.instance as WebglAddon).textureAtlas); + }, 0); + } else if (name === 'unicode11') { + term.unicode.activeVersion = '11'; + } + } else { + if (name === 'webgl') { + document.body.removeChild((addon.instance as WebglAddon).textureAtlas); + } else if (name === 'unicode11') { + term.unicode.activeVersion = '6'; + } + addon.instance!.dispose(); + addon.instance = undefined; + } + }); + const label = document.createElement('label'); + label.classList.add('addon'); + if (!addon.canChange) { + label.title = 'This addon is needed for the demo to operate'; + } + label.appendChild(checkbox); + label.appendChild(document.createTextNode(name)); + const wrapper = document.createElement('div'); + wrapper.classList.add('addon'); + wrapper.appendChild(label); + fragment.appendChild(wrapper); + }); + const container = document.getElementById('addons-container'); + container.innerHTML = ''; + container.appendChild(fragment); +} + +function addDomListener(element: HTMLElement, type: string, handler: (...args: any[]) => any): void { + element.addEventListener(type, handler); + term._core.register({ dispose: () => element.removeEventListener(type, handler) }); +} + +function updateTerminalSize(): void { + const cols = document.querySelector('#opt-cols').valueAsNumber; + const rows = document.querySelector('#opt-rows').valueAsNumber; + const width = (cols * term._core._renderService.dimensions.actualCellWidth + term._core.viewport.scrollBarWidth).toString() + 'px'; + const height = (rows * term._core._renderService.dimensions.actualCellHeight).toString() + 'px'; + terminalContainer.style.width = width; + terminalContainer.style.height = height; + addons.fit.instance.fit(); +} + +function serializeButtonHandler(): void { + const output = addons.serialize.instance.serialize(); + const outputString = JSON.stringify(output); + + document.querySelector('#serialize-output').innerText = outputString; + if (document.querySelector('#write-to-terminal').checked) { + term.reset(); + term.write(output); + } +} + +function writeCustomGlyphHandler() { + term.write('\n\r'); + term.write('\n\r'); + term.write('Box styles: ┎┰┒┍┯┑╓╥╖╒╤╕ ┏┳┓┌┲┓┌┬┐┏┱┐\n\r'); + term.write('┌─┬─┐ ┏━┳━┓ ╔═╦═╗ ┠╂┨┝┿┥╟╫╢╞╪╡ ┡╇┩├╊┫┢╈┪┣╉┤\n\r'); + term.write('│ │ │ ┃ ┃ ┃ ║ ║ ║ ┖┸┚┕┷┙╙╨╜╘╧╛ └┴┘└┺┛┗┻┛┗┹┘\n\r'); + term.write('├─┼─┤ ┣━╋━┫ ╠═╬═╣ ┏┱┐┌┲┓┌┬┐┌┬┐ ┏┳┓┌┮┓┌┬┐┏┭┐\n\r'); + term.write('│ │ │ ┃ ┃ ┃ ║ ║ ║ ┡╃┤├╄┩├╆┪┢╅┤ ┞╀┦├┾┫┟╁┧┣┽┤\n\r'); + term.write('└─┴─┘ ┗━┻━┛ ╚═╩═╝ └┴┘└┴┘└┺┛┗┹┘ └┴┘└┶┛┗┻┛┗┵┘\n\r'); + term.write('\n\r'); + term.write('Other:\n\r'); + term.write('╭─╮ ╲ ╱ ╷╻╎╏┆┇┊┋ ╺╾╴ ╌╌╌ ┄┄┄ ┈┈┈\n\r'); + term.write('│ │ ╳ ╽╿╎╏┆┇┊┋ ╶╼╸ ╍╍╍ ┅┅┅ ┉┉┉\n\r'); + term.write('╰─╯ ╱ ╲ ╹╵╎╏┆┇┊┋\n\r'); + term.write('\n\r'); + term.write('All box drawing characters:\n\r'); + term.write('─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏\n\r'); + term.write('┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟\n\r'); + term.write('┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯\n\r'); + term.write('┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿\n\r'); + term.write('╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏\n\r'); + term.write('═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟\n\r'); + term.write('╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯\n\r'); + term.write('╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿\n\r'); + term.write('Box drawing alignment tests:\x1b[31m █\n\r'); + term.write(' ▉\n\r'); + term.write(' ╔══╦══╗ ┌──┬──┐ ╭──┬──╮ ╭──┬──╮ ┏━━┳━━┓ ┎┒┏┑ ╷ ╻ ┏┯┓ ┌┰┐ ▊ ╱╲╱╲╳╳╳\n\r'); + term.write(' ║┌─╨─┐║ │╔═╧═╗│ │╒═╪═╕│ │╓─╁─╖│ ┃┌─╂─┐┃ ┗╃╄┙ ╶┼╴╺╋╸┠┼┨ ┝╋┥ ▋ ╲╱╲╱╳╳╳\n\r'); + term.write(' ║│╲ ╱│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╿ │┃ ┍╅╆┓ ╵ ╹ ┗┷┛ └┸┘ ▌ ╱╲╱╲╳╳╳\n\r'); + term.write(' ╠╡ ╳ ╞╣ ├╢ ╟┤ ├┼─┼─┼┤ ├╫─╂─╫┤ ┣┿╾┼╼┿┫ ┕┛┖┚ ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳\n\r'); + term.write(' ║│╱ ╲│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╽ │┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▎\n\r'); + term.write(' ║└─╥─┘║ │╚═╤═╝│ │╘═╪═╛│ │╙─╀─╜│ ┃└─╂─┘┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▏\n\r'); + term.write(' ╚══╩══╝ └──┴──┘ ╰──┴──╯ ╰──┴──╯ ┗━━┻━━┛ └╌╌┘ ╎ ┗╍╍┛ ┋ ▁▂▃▄▅▆▇█\n\r'); + term.write('Box drawing alignment tests:\x1b[32m █\n\r'); + term.write(' ▉\n\r'); + term.write(' ╔══╦══╗ ┌──┬──┐ ╭──┬──╮ ╭──┬──╮ ┏━━┳━━┓ ┎┒┏┑ ╷ ╻ ┏┯┓ ┌┰┐ ▊ ╱╲╱╲╳╳╳\n\r'); + term.write(' ║┌─╨─┐║ │╔═╧═╗│ │╒═╪═╕│ │╓─╁─╖│ ┃┌─╂─┐┃ ┗╃╄┙ ╶┼╴╺╋╸┠┼┨ ┝╋┥ ▋ ╲╱╲╱╳╳╳\n\r'); + term.write(' ║│╲ ╱│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╿ │┃ ┍╅╆┓ ╵ ╹ ┗┷┛ └┸┘ ▌ ╱╲╱╲╳╳╳\n\r'); + term.write(' ╠╡ ╳ ╞╣ ├╢ ╟┤ ├┼─┼─┼┤ ├╫─╂─╫┤ ┣┿╾┼╼┿┫ ┕┛┖┚ ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳\n\r'); + term.write(' ║│╱ ╲│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╽ │┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▎\n\r'); + term.write(' ║└─╥─┘║ │╚═╤═╝│ │╘═╪═╛│ │╙─╀─╜│ ┃└─╂─┘┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▏\n\r'); + term.write(' ╚══╩══╝ └──┴──┘ ╰──┴──╯ ╰──┴──╯ ┗━━┻━━┛ └╌╌┘ ╎ ┗╍╍┛ ┋ ▁▂▃▄▅▆▇█\n\r'); + window.scrollTo(0, 0); +} + +function initImageAddonExposed(): void { + const DEFAULT_OPTIONS: IImageAddonOptions = (addons.image.instance as any)._defaultOpts; + const limitStorageElement = document.querySelector('#image-storagelimit'); + limitStorageElement.valueAsNumber = addons.image.instance.storageLimit; + addDomListener(limitStorageElement, 'change', () => { + try { + addons.image.instance.storageLimit = limitStorageElement.valueAsNumber; + limitStorageElement.valueAsNumber = addons.image.instance.storageLimit; + console.log('changed storageLimit to', addons.image.instance.storageLimit); + } catch (e) { + limitStorageElement.valueAsNumber = addons.image.instance.storageLimit; + console.log('storageLimit at', addons.image.instance.storageLimit); + throw e; + } + }); + const showPlaceholderElement = document.querySelector('#image-showplaceholder'); + showPlaceholderElement.checked = addons.image.instance.showPlaceholder; + addDomListener(showPlaceholderElement, 'change', () => { + addons.image.instance.showPlaceholder = showPlaceholderElement.checked; + }); + const ctorOptionsElement = document.querySelector('#image-options'); + ctorOptionsElement.value = JSON.stringify(DEFAULT_OPTIONS, null, 2); + + const sixel_demo = (url: string) => () => fetch(url) + .then(resp => resp.arrayBuffer()) + .then(buffer => { + term.write('\r\n'); + term.write(new Uint8Array(buffer)) + }); + + document.getElementById('image-demo1').addEventListener('click', + sixel_demo('https://raw.githubusercontent.com/saitoha/libsixel/master/images/snake.six')); + document.getElementById('image-demo2').addEventListener('click', + sixel_demo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/biplane.six')); + + // demo for image retrieval API + term.element.addEventListener('click', (ev: MouseEvent) => { + if (!ev.ctrlKey || !addons.image.instance) return; + const pos = term._core._mouseService!.getCoords(ev, term._core.screenElement!, term.cols, term.rows); + const x = pos[0] - 1; + const y = pos[1] - 1; + const canvas = ev.shiftKey + // ctrl+shift+click: get single tile + ? addons.image.instance.extractTileAtBufferCell(x, term.buffer.active.viewportY + y) + // ctrl+click: get original image + : addons.image.instance.getImageAtBufferCell(x, term.buffer.active.viewportY + y); + canvas?.toBlob(data => window.open(URL.createObjectURL(data), '_blank')); + }); +} + +function loadTest() { + const isWebglEnabled = !!addons.webgl.instance; + const testData = []; + let byteCount = 0; + for (let i = 0; i < 50; i++) { + const count = 1 + Math.floor(Math.random() * 79); + byteCount += count + 2; + const data = new Uint8Array(count + 2); + data[0] = 0x0A; // \n + for (let i = 1; i < count + 1; i++) { + data[i] = 0x61 + Math.floor(Math.random() * (0x7A - 0x61)); + } + // End each line with \r so the cursor remains constant, this is what ls/tree do and improves + // performance significantly due to the cursor DOM element not needing to change + data[data.length - 1] = 0x0D; // \r + testData.push(data); + } + const start = performance.now(); + for (let i = 0; i < 1024; i++) { + for (const d of testData) { + 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 (${isWebglEnabled ? 'webgl' : 'canvas'} renderer)`); + // Send ^C to get a new prompt + term._core._onData.fire('\x03'); + }); +} diff --git a/overwrite/demo/index.html b/overwrite/demo/index.html new file mode 100644 index 0000000..85abf03 --- /dev/null +++ b/overwrite/demo/index.html @@ -0,0 +1,120 @@ + + + + xterm.js demo + + + + + + + + +

xterm.js: A terminal for the web

+
+
+
+
+
+
+ + + + +
+
+

Options

+

These options can be set in the Terminal constructor or by using the Terminal.setOption function.

+
+
+
+

Addons

+

Addons can be loaded and unloaded on a particular terminal to extend its functionality.

+
+

Addons Control

+

SearchAddon

+
+ + + + + +
+

SerializeAddon

+
+ + +
+
+

Image Addon

+
+ image addon settings +
+
+ +

+ +
+
+ + +
+
+

Style

+
+ + +
+
+
+

Test

+
+ + + +
+
+
+
+ + + + diff --git a/overwrite/demo/server.js b/overwrite/demo/server.js new file mode 100644 index 0000000..417fb14 --- /dev/null +++ b/overwrite/demo/server.js @@ -0,0 +1,144 @@ +/** + * WARNING: This demo is a barebones implementation designed for development and evaluation + * purposes only. It is definitely NOT production ready and does not aim to be so. Exposing the + * demo to the public as is would introduce security risks for the host. + **/ + +var express = require('express'); +var expressWs = require('express-ws'); +var os = require('os'); +var pty = require('node-pty'); + +// Whether to use binary transport. +const USE_BINARY = os.platform() !== "win32"; + +function startServer() { + var app = express(); + expressWs(app); + + var terminals = {}, + logs = {}; + + app.use('/xterm.css', express.static(__dirname + '/../css/xterm.css')); + app.get('/logo.png', (req, res) => { // lgtm [js/missing-rate-limiting] + res.sendFile(__dirname + '/logo.png'); + }); + + app.get('/', (req, res) => { // lgtm [js/missing-rate-limiting] + res.sendFile(__dirname + '/index.html'); + }); + + app.get('/test', (req, res) => { // lgtm [js/missing-rate-limiting] + res.sendFile(__dirname + '/test.html'); + }); + + app.get('/style.css', (req, res) => { // lgtm [js/missing-rate-limiting] + res.sendFile(__dirname + '/style.css'); + }); + + app.use('/dist', express.static(__dirname + '/dist')); + app.use('/src', express.static(__dirname + '/src')); + app.use('/workers', express.static(__dirname + '/workers')); + + app.post('/terminals', (req, res) => { + const env = Object.assign({}, process.env); + env['COLORTERM'] = 'truecolor'; + var cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : 'bash', [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd: process.platform === 'win32' ? undefined : env.PWD, + env: env, + encoding: USE_BINARY ? null : 'utf8' + }); + + console.log('Created terminal with PID: ' + term.pid); + terminals[term.pid] = term; + logs[term.pid] = ''; + term.on('data', function(data) { + logs[term.pid] += data; + }); + res.send(term.pid.toString()); + res.end(); + }); + + app.post('/terminals/:pid/size', (req, res) => { + var pid = parseInt(req.params.pid), + cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = terminals[pid]; + + term.resize(cols, rows); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + res.end(); + }); + + app.ws('/terminals/:pid', function (ws, req) { + var term = terminals[parseInt(req.params.pid)]; + console.log('Connected to terminal ' + term.pid); + ws.send(logs[term.pid]); + + // string message buffering + function buffer(socket, timeout) { + let s = ''; + let sender = null; + return (data) => { + s += data; + if (!sender) { + sender = setTimeout(() => { + socket.send(s); + s = ''; + sender = null; + }, timeout); + } + }; + } + // binary message buffering + function bufferUtf8(socket, timeout) { + let buffer = []; + let sender = null; + let length = 0; + return (data) => { + buffer.push(data); + length += data.length; + if (!sender) { + sender = setTimeout(() => { + socket.send(Buffer.concat(buffer, length)); + buffer = []; + sender = null; + length = 0; + }, timeout); + } + }; + } + const send = USE_BINARY ? bufferUtf8(ws, 5) : buffer(ws, 5); + + term.on('data', function(data) { + try { + send(data); + } catch (ex) { + // The WebSocket is not open, ignore + } + }); + ws.on('message', function(msg) { + term.write(msg); + }); + ws.on('close', function () { + term.kill(); + console.log('Closed terminal ' + term.pid); + // Clean things up + delete terminals[term.pid]; + delete logs[term.pid]; + }); + }); + + var port = process.env.PORT || 3000, + host = os.platform() === 'win32' ? '127.0.0.1' : '0.0.0.0'; + + console.log('App listening to http://127.0.0.1:' + port); + app.listen(port, host); +} + +module.exports = startServer; diff --git a/overwrite/demo/start.js b/overwrite/demo/start.js new file mode 100644 index 0000000..e77e5a5 --- /dev/null +++ b/overwrite/demo/start.js @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + * + * This file is the entry point for browserify. + */ + +const path = require('path'); +const webpack = require('webpack'); +const startServer = require('./server.js'); + +startServer(); + +/** + * This webpack config builds and watches the demo project. It works by taking the output from tsc + * (via `yarn watch`) which is put into `out/` and then webpacks it into `demo/dist/`. The aliases + * are used fix up the absolute paths output by tsc (because of `baseUrl` and `paths` in + * `tsconfig.json`. + * + * For production builds see `webpack.config.js` in the root directory. If that is built the demo + * can use that by switching out which `Terminal` is imported in `client.ts`, this is useful for + * validating that the packaged version works correctly. + */ +const clientConfig = { + entry: path.resolve(__dirname, 'client.ts'), + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ + }, + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: [ + 'node_modules', + path.resolve(__dirname, '..'), + path.resolve(__dirname, '../addons') + ], + extensions: [ '.tsx', '.ts', '.js' ], + alias: { + common: path.resolve('./out/common'), + browser: path.resolve('./out/browser') + }, + fallback: { + // The ligature modules contains fallbacks for node environments, we never want to browserify them + stream: false, + util: false, + os: false, + path: false, + fs: false + } + }, + output: { + filename: 'client-bundle.js', + path: path.resolve(__dirname, 'dist') + }, + mode: 'development', + watch: true +}; + +/** + * Blueprint to bundle addon workers. + * Expects entry under `./addons/xterm-addon-${addonName}/src-worker/main.ts`. + */ +function generateAddonWorker(addonName) { + return { + entry: `./addons/xterm-addon-${addonName}/src-worker/main.ts`, + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ + }, + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: [ + 'node_modules', + path.resolve(__dirname, '..'), + path.resolve(__dirname, '../addons') + ], + extensions: [ '.tsx', '.ts', '.js' ], + alias: { + common: path.resolve('./out/common'), + browser: path.resolve('./out/browser') + } + }, + output: { + filename: `xterm-addon-${addonName}-worker.js`, + path: path.resolve(__dirname, 'workers') + }, + mode: 'development', + watch: true + } +} + +const compiler = webpack([ + clientConfig, + generateAddonWorker('image') +]); + +compiler.watch({ + // Example watchOptions + aggregateTimeout: 300, + poll: undefined +}, (err, stats) => { + // Print watch/build result here... + console.log(stats.toString({ + colors: true + })); +}); diff --git a/overwrite/demo/tsconfig.json b/overwrite/demo/tsconfig.json new file mode 100644 index 0000000..018ddb4 --- /dev/null +++ b/overwrite/demo/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "rootDir": ".", + "sourceMap": true, + "baseUrl": ".", + "paths": { + "xterm-addon-attach": ["../addons/xterm-addon-attach"], + "xterm-addon-fit": ["../addons/xterm-addon-fit"], + "xterm-addon-image": ["../addons/xterm-addon-image"], + "xterm-addon-search": ["../addons/xterm-addon-search"], + "xterm-addon-serialize": ["../addons/xterm-addon-serialize"], + "xterm-addon-web-links": ["../addons/xterm-addon-web-links"], + "xterm-addon-webgl": ["../addons/xterm-addon-webgl"] + } + }, + "include": [ + "client.ts", + "../typings/xterm.d.ts" + ] +} diff --git a/overwrite/tsconfig.all.json b/overwrite/tsconfig.all.json new file mode 100644 index 0000000..7402006 --- /dev/null +++ b/overwrite/tsconfig.all.json @@ -0,0 +1,19 @@ +{ + "files": [], + "include": [], + "references": [ + { "path": "./src/browser" }, + { "path": "./src/headless" }, + { "path": "./test/api" }, + { "path": "./test/benchmark" }, + { "path": "./addons/xterm-addon-attach" }, + { "path": "./addons/xterm-addon-fit" }, + { "path": "./addons/xterm-addon-image" }, + { "path": "./addons/xterm-addon-ligatures" }, + { "path": "./addons/xterm-addon-search" }, + { "path": "./addons/xterm-addon-serialize" }, + { "path": "./addons/xterm-addon-unicode11" }, + { "path": "./addons/xterm-addon-web-links" }, + { "path": "./addons/xterm-addon-webgl" } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ccb385a --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "xterm-addon-image", + "version": "0.0.1", + "author": "Joerg Breitbart ", + "main": "lib/xterm-addon-image.js", + "types": "typings/xterm-addon-image.d.ts", + "repository": "https://github.com/xtermjs/xterm.js", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "~4.16.0" + }, + "dependencies": { + "sixel": "^0.15.0" + }, + "devDependencies": { + "png-ts": "^0.0.3" + } +} diff --git a/src-worker/main.ts b/src-worker/main.ts new file mode 100644 index 0000000..a2ad671 --- /dev/null +++ b/src-worker/main.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2021 Joerg Breitbart. + * @license MIT + */ + +import { AckPayload, IImageWorkerMessage, IPostMessage, MessageType, PaletteType } from '../src/WorkerTypes'; + +import { Decoder } from 'sixel/lib/Decoder'; +import { PALETTE_VT340_COLOR, PALETTE_VT340_GREY, PALETTE_ANSI_256 } from 'sixel/lib/Colors'; + + +// narrow types for postMessage to our protocol +declare const postMessage: IPostMessage; + + +let imageBuffer: ArrayBuffer | undefined; +let sizeExceeded = false; +let dec: Decoder; + +// setup options loaded from ACK +let pixelLimit = 0; + +// always free decoder ressources after decoding if it exceeds this limit +const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + + +function messageHandler(event: MessageEvent): void { + const data = event.data; + switch (data.type) { + case MessageType.SIXEL_PUT: + if (!sizeExceeded) { + dec.decode(new Uint8Array(data.payload.buffer, 0, data.payload.length)); + if (dec.height * dec.width > pixelLimit) { + sizeExceeded = true; + dec.release(); + console.warn('image worker: pixelLimit exceeded, aborting'); + postMessage({ type: MessageType.SIZE_EXCEEDED }); + } + } + postMessage({ type: MessageType.CHUNK_TRANSFER, payload: data.payload.buffer }, [data.payload.buffer]); + break; + case MessageType.SIXEL_END: + const success = data.payload; + if (success) { + if (!dec || !dec.width || !dec.height || sizeExceeded) { + postMessage({ type: MessageType.SIXEL_IMAGE, payload: null }); + } else { + const width = dec.width; + const height = dec.height; + const bytes = width * height * 4; + if (!imageBuffer || imageBuffer.byteLength < bytes) { + imageBuffer = new ArrayBuffer(bytes); + } + new Uint32Array(imageBuffer, 0, width * height).set(dec.data32); + postMessage({ + type: MessageType.SIXEL_IMAGE, + payload: { + buffer: imageBuffer, + width, + height + } + }, [imageBuffer]); + imageBuffer = undefined; + if (dec.memoryUsage > MEM_PERMA_LIMIT) { + dec.release(); + } + } + } + sizeExceeded = false; + break; + case MessageType.CHUNK_TRANSFER: + if (!imageBuffer) { + imageBuffer = data.payload; + } + break; + case MessageType.SIXEL_INIT: + sizeExceeded = false; + const { fillColor, paletteType, limit } = data.payload; + const palette = paletteType === PaletteType.SHARED + ? null + : paletteType === PaletteType.VT340_COLOR + ? PALETTE_VT340_COLOR + : paletteType === PaletteType.VT340_GREY + ? PALETTE_VT340_GREY + : PALETTE_ANSI_256; + dec.init(fillColor, palette, limit); + break; + case MessageType.ACK: + pixelLimit = data.options?.pixelLimit || 0; + dec = new Decoder({ memoryLimit: pixelLimit * 4 }); + postMessage({ type: MessageType.ACK, payload: AckPayload.ALIVE, options: null }); + break; + } +} +self.addEventListener('message', messageHandler, false); diff --git a/src-worker/tsconfig.json b/src-worker/tsconfig.json new file mode 100644 index 0000000..be4fe22 --- /dev/null +++ b/src-worker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES6", + "lib": ["webworker", "ES6"], + "module": "commonjs", + "sourceMap": true, + "outDir": "../out-worker", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "preserveWatchOutput": true, + "types": [ + "../../../node_modules/@types/mocha" + ] + }, + "include": [ + "./**/*" + ] +} \ No newline at end of file diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts new file mode 100644 index 0000000..a9d8cee --- /dev/null +++ b/src/ImageAddon.ts @@ -0,0 +1,328 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +import { ITerminalAddon, IDisposable } from 'xterm'; +import { ImageRenderer } from './ImageRenderer'; +import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; +import { SixelHandler } from './SixelHandler'; +import { ITerminalExt, IImageAddonOptions } from './Types'; +import { WorkerManager } from './WorkerManager'; + + +// default values of addon ctor options +const DEFAULT_OPTIONS: IImageAddonOptions = { + enableSizeReports: true, + pixelLimit: 16777216, // limit to 4096 * 4096 pixels + cursorRight: false, + cursorBelow: false, + sixelSupport: true, + sixelScrolling: true, + sixelPaletteLimit: 256, + sixelSizeLimit: 25000000, + sixelPrivatePalette: true, + sixelDefaultPalette: 'VT340-COLOR', + storageLimit: 128, + showPlaceholder: true +}; + +// max palette size supported by the sixel lib (compile time setting) +const MAX_SIXEL_PALETTE_SIZE = 4096; + +// definitions for _xtermGraphicsAttributes sequence +const enum GaItem { + COLORS = 1, + SIXEL_GEO = 2, + REGIS_GEO = 3 +} +const enum GaAction { + READ = 1, + SET_DEFAULT = 2, + SET = 3, + READ_MAX = 4 +} +const enum GaStatus { + SUCCESS = 0, + ITEM_ERROR = 1, + ACTION_ERROR = 2, + FAILURE = 3 +} + + +export class ImageAddon implements ITerminalAddon { + private _opts: IImageAddonOptions; + private _defaultOpts: IImageAddonOptions; + private _storage: ImageStorage | undefined; + private _renderer: ImageRenderer | undefined; + private _disposables: IDisposable[] = []; + private _terminal: ITerminalExt | undefined; + private _workerManager: WorkerManager; + + constructor(workerPath: string, opts: Partial) { + this._opts = Object.assign({}, DEFAULT_OPTIONS, opts); + this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts); + this._workerManager = new WorkerManager(workerPath, this._opts); + this._disposeLater(this._workerManager); + } + + public dispose(): void { + for (const obj of this._disposables) { + obj.dispose(); + } + this._disposables.length = 0; + } + + private _disposeLater(...args: IDisposable[]): void { + for (const obj of args) { + this._disposables.push(obj); + } + } + + public activate(terminal: ITerminalExt): void { + this._terminal = terminal; + + // internal data structures + this._renderer = new ImageRenderer(terminal, this._opts.showPlaceholder); + this._storage = new ImageStorage(terminal, this._renderer, this._opts); + + // enable size reports + if (this._opts.enableSizeReports) { + // const windowOptions = terminal.getOption('windowOptions'); + // windowOptions.getWinSizePixels = true; + // windowOptions.getCellSizePixels = true; + // windowOptions.getWinSizeChars = true; + // terminal.setOption('windowOptions', windowOptions); + const windowOps = terminal.options.windowOptions || {}; + windowOps.getWinSizePixels = true; + windowOps.getCellSizePixels = true; + windowOps.getWinSizeChars = true; + terminal.options.windowOptions = windowOps; + } + + this._disposeLater( + this._renderer, + this._storage, + + // DECSET/DECRST/DA1/XTSMGRAPHICS handlers + terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)), + terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)), + terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)), + terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)), + + // render hook + terminal.onRender(range => this._storage?.render(range)), + + /** + * reset handlers covered: + * - DECSTR + * - RIS + * - Terminal.reset() + */ + terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()), + terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()), + terminal._core._inputHandler.onRequestReset(() => this.reset()), + + // wipe canvas and delete alternate images on buffer switch + terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()), + + // extend images to the right on resize + terminal.onResize(metrics => this._storage?.viewportResize(metrics)) + ); + + // SIXEL handler + if (this._opts.sixelSupport) { + this._disposeLater( + terminal._core._inputHandler._parser.registerDcsHandler( + { final: 'q' }, new SixelHandler(this._opts, this._storage, terminal, this._workerManager)) + ); + } + } + + // Note: storageLimit is skipped here to not intoduce a surprising side effect. + public reset(): boolean { + // reset options customizable by sequences to defaults + this._opts.sixelScrolling = this._defaultOpts.sixelScrolling; + this._opts.cursorRight = this._defaultOpts.cursorRight; + this._opts.cursorBelow = this._defaultOpts.cursorBelow; + this._opts.sixelPrivatePalette = this._defaultOpts.sixelPrivatePalette; + this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit; + // also clear image storage + this._storage?.reset(); + return false; + } + + public get storageLimit(): number { + return this._storage?.getLimit() || -1; + } + + public set storageLimit(limit: number) { + this._storage?.setLimit(limit); + this._opts.storageLimit = limit; + } + + public get storageUsage(): number { + if (this._storage) { + return this._storage.getUsage(); + } + return -1; + } + + public get showPlaceholder(): boolean { + return this._opts.showPlaceholder; + } + + public set showPlaceholder(value: boolean) { + this._opts.showPlaceholder = value; + this._renderer?.showPlaceholder(value); + } + + public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { + return this._storage?.getImageAtBufferCell(x, y); + } + + public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { + return this._storage?.extractTileAtBufferCell(x, y); + } + + private _report(s: string): void { + this._terminal?._core.coreService.triggerDataEvent(s); + } + + private _decset(params: (number | number[])[]): boolean { + for (let i = 0; i < params.length; ++i) { + switch (params[i]) { + case 80: + this._opts.sixelScrolling = false; + break; + case 1070: + this._opts.sixelPrivatePalette = true; + break; + case 8452: + this._opts.cursorRight = true; + break; + case 7730: + this._opts.cursorBelow = false; + break; + } + } + return false; + } + + private _decrst(params: (number | number[])[]): boolean { + for (let i = 0; i < params.length; ++i) { + switch (params[i]) { + case 80: + this._opts.sixelScrolling = true; + break; + case 1070: + this._opts.sixelPrivatePalette = false; + break; + case 8452: + this._opts.cursorRight = false; + break; + case 7730: + this._opts.cursorBelow = true; + break; + } + } + return false; + } + + // overload DA to return something more appropriate + private _da1(params: (number | number[])[]): boolean { + if (params[0] > 0) { + return true; + } + // reported features: + // 62 - VT220 + // 4 - SIXEL support + // 9 - charsets + // 22 - ANSI colors + if (this._opts.sixelSupport && !this._workerManager.failed) { + this._report(`\x1b[?62;4;9;22c`); + return true; + } + return false; + } + + /** + * Implementation of xterm's graphics attribute sequence. + * + * Supported features: + * - read/change palette limits (max 4096 by sixel lib) + * - read SIXEL canvas geometry (reports current window canvas or + * squared pixelLimit if canvas > pixel limit) + * + * Everything else is deactivated. + */ + private _xtermGraphicsAttributes(params: (number | number[])[]): boolean { + if (params.length < 2) { + return true; + } + if (this._workerManager.failed) { + // on worker error report graphics caps as not supported + this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`); + return true; + } + if (params[0] === GaItem.COLORS) { + switch (params[1]) { + case GaAction.READ: + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); + return true; + case GaAction.SET_DEFAULT: + this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit; + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); + return true; + case GaAction.SET: + if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) { + this._opts.sixelPaletteLimit = params[2]; + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`); + } else { + this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); + } + return true; + case GaAction.READ_MAX: + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`); + return true; + default: + this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); + return true; + } + } + if (params[0] === GaItem.SIXEL_GEO) { + switch (params[1]) { + // we only implement read and read_max here + case GaAction.READ: + let width = this._renderer?.dimensions?.canvasWidth; + let height = this._renderer?.dimensions?.canvasHeight; + if (!width || !height) { + // for some reason we have no working image renderer + // --> fallback to default cell size + const cellSize = CELL_SIZE_DEFAULT; + width = (this._terminal?.cols || 80) * cellSize.width; + height = (this._terminal?.rows || 24) * cellSize.height; + } + if (width * height < this._opts.pixelLimit) { + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`); + } else { + // if we overflow pixelLimit report that squared instead + const x = Math.floor(Math.sqrt(this._opts.pixelLimit)); + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`); + } + return true; + case GaAction.READ_MAX: + // read_max returns pixelLimit as square area + const x = Math.floor(Math.sqrt(this._opts.pixelLimit)); + this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`); + return true; + default: + this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`); + return true; + } + } + // exit with error on ReGIS or any other requests + this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`); + return true; + } +} diff --git a/src/ImageRenderer.ts b/src/ImageRenderer.ts new file mode 100644 index 0000000..5e53c68 --- /dev/null +++ b/src/ImageRenderer.ts @@ -0,0 +1,352 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +import { toRGBA8888 } from 'sixel/lib/Colors'; +import { IDisposable } from 'xterm'; +import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types'; + + +const PLACEHOLDER_LENGTH = 4096; +const PLACEHOLDER_HEIGHT = 24; + +/** + * ImageRenderer - terminal frontend extension: + * - provide primitives for canvas, ImageData, Bitmap (static) + * - add canvas layer to DOM (browser only for now) + * - draw image tiles onRender + */ +export class ImageRenderer implements IDisposable { + public canvas: HTMLCanvasElement | undefined; + private _ctx: CanvasRenderingContext2D | null | undefined; + private _placeholder: HTMLCanvasElement | undefined; + private _placeholderBitmap: ImageBitmap | undefined; + private _optionsRefresh: IDisposable | undefined; + private _oldOpen: ((parent: HTMLElement) => void) | undefined; + private _renderService: IRenderService | undefined; + private _oldSetRenderer: ((renderer: any) => void) | undefined; + + // drawing primitive - canvas + public static createCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = width | 0; + canvas.height = height | 0; + return canvas; + } + + // drawing primitive - ImageData with optional buffer + public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData { + if (typeof ImageData !== 'function') { + const imgData = ctx.createImageData(width, height); + if (buffer) { + imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4)); + } + return imgData; + } + return buffer + ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height) + : new ImageData(width, height); + } + + // drawing primitive - ImageBitmap + public static createImageBitmap(img: ImageBitmapSource): Promise { + if (typeof createImageBitmap !== 'function') { + return Promise.resolve(undefined); + } + return createImageBitmap(img); + } + + + constructor(private _terminal: ITerminalExt, private _showPlaceholder: boolean) { + this._oldOpen = this._terminal._core.open; + this._terminal._core.open = (parent: HTMLElement): void => { + this._oldOpen?.call(this._terminal._core, parent); + this._open(); + }; + if (this._terminal._core.screenElement) { + this._open(); + } + // hack to spot fontSize changes + this._optionsRefresh = this._terminal._core.optionsService.onOptionChange(option => { + if (option === 'fontSize') { + this.rescaleCanvas(); + this._renderService?.refreshRows(0, this._terminal.rows); + } + }); + } + + + public dispose(): void { + this._optionsRefresh?.dispose(); + this._removeLayerFromDom(); + if (this._terminal._core && this._oldOpen) { + this._terminal._core.open = this._oldOpen; + this._oldOpen = undefined; + } + if (this._renderService && this._oldSetRenderer) { + this._renderService.setRenderer = this._oldSetRenderer; + this._oldSetRenderer = undefined; + } + this._renderService = undefined; + this.canvas = undefined; + this._ctx = undefined; + this._placeholderBitmap?.close(); + this._placeholderBitmap = undefined; + this._placeholder = undefined; + } + + /** + * Enable the placeholder. + */ + public showPlaceholder(value: boolean): void { + if (value) { + if (!this._placeholder && this.cellSize.height !== -1) { + this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT)); + } + } else { + this._placeholderBitmap?.close(); + this._placeholderBitmap = undefined; + this._placeholder = undefined; + } + this._renderService?.refreshRows(0, this._terminal.rows); + } + + /** + * Dimensions of the terminal. + * Forwarded from internal render service. + */ + public get dimensions(): IRenderDimensions | undefined { + return this._renderService?.dimensions; + } + + /** + * Current cell size (float). + */ + public get cellSize(): ICellSize { + return { + width: this.dimensions?.actualCellWidth || -1, + height: this.dimensions?.actualCellHeight || -1 + }; + } + + /** + * Clear a region of the image layer canvas. + */ + public clearLines(start: number, end: number): void { + this._ctx?.clearRect( + 0, + start * (this.dimensions?.actualCellHeight || 0), + this.dimensions?.canvasWidth || 0, + (++end - start) * (this.dimensions?.actualCellHeight || 0) + ); + } + + /** + * Clear whole image canvas. + */ + public clearAll(): void { + this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0); + } + + /** + * Draw neighboring tiles on the image layer canvas. + */ + public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void { + if (!this._ctx) { + return; + } + const { width, height } = this.cellSize; + + // Don't try to draw anything, if we cannot get valid renderer metrics. + if (width === -1 || height === -1) { + return; + } + + this._rescaleImage(imgSpec, width, height); + const img = imgSpec.actual!; + const cols = Math.ceil(img.width / width); + + const sx = (tileId % cols) * width; + const sy = Math.floor(tileId / cols) * height; + const dx = col * width; + const dy = row * height; + + // safari bug: never access image source out of bounds + const finalWidth = count * width + sx > img.width ? img.width - sx : count * width; + const finalHeight = sy + height > img.height ? img.height - sy : height; + + // Floor all pixel offsets to get stable tile mapping without any overflows. + // Note: For not pixel perfect aligned cells like in the DOM renderer + // this will move a tile slightly to the top/left (subpixel range, thus ignore it). + this._ctx.drawImage( + img, + Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight), + Math.floor(dx), Math.floor(dy), Math.floor(finalWidth), Math.floor(finalHeight) + ); + } + + /** + * Extract a single tile from an image. + */ + public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined { + const { width, height } = this.cellSize; + // Don't try to draw anything, if we cannot get valid renderer metrics. + if (width === -1 || height === -1) { + return; + } + this._rescaleImage(imgSpec, width, height); + const img = imgSpec.actual!; + const cols = Math.ceil(img.width / width); + const sx = (tileId % cols) * width; + const sy = Math.floor(tileId / cols) * height; + const finalWidth = width + sx > img.width ? img.width - sx : width; + const finalHeight = sy + height > img.height ? img.height - sy : height; + + const canvas = ImageRenderer.createCanvas(finalWidth, finalHeight); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage( + img, + Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight), + 0, 0, Math.floor(finalWidth), Math.floor(finalHeight) + ); + return canvas; + } + } + + /** + * Draw a line with placeholder on the image layer canvas. + */ + public drawPlaceholder(col: number, row: number, count: number = 1): void { + if ((this._placeholderBitmap || this._placeholder) && this._ctx) { + const { width, height } = this.cellSize; + + // Don't try to draw anything, if we cannot get valid renderer metrics. + if (width === -1 || height === -1) { + return; + } + + if (height >= this._placeholder!.height) { + this._createPlaceHolder(height + 1); + } + this._ctx.drawImage( + this._placeholderBitmap || this._placeholder!, + col * width, + (row * height) % 2 ? 0 : 1, // needs %2 offset correction + width * count, + height, + col * width, + row * height, + width * count, + height + ); + } + } + + /** + * Rescale image layer canvas if needed. + * Checked once from `ImageStorage.render`. + */ + public rescaleCanvas(): void { + if (!this.canvas) { + return; + } + if (this.canvas.width !== this.dimensions?.canvasWidth || this.canvas.height !== this.dimensions.canvasHeight) { + this.canvas.width = this.dimensions?.canvasWidth || 0; + this.canvas.height = this.dimensions?.canvasHeight || 0; + } + } + + /** + * Rescale image in storage if needed. + */ + private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void { + if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) { + return; + } + const { width: originalWidth, height: originalHeight } = spec.origCellSize; + if (currentWidth === originalWidth && currentHeight === originalHeight) { + spec.actual = spec.orig; + spec.actualCellSize.width = originalWidth; + spec.actualCellSize.height = originalHeight; + return; + } + const canvas = ImageRenderer.createCanvas( + Math.ceil(spec.orig!.width * currentWidth / originalWidth), + Math.ceil(spec.orig!.height * currentHeight / originalHeight) + ); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height); + spec.actual = canvas; + spec.actualCellSize.width = currentWidth; + spec.actualCellSize.height = currentHeight; + } + } + + /** + * Lazy init for the renderer. + */ + private _open(): void { + this._renderService = this._terminal._core._renderService; + this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService); + this._renderService.setRenderer = (renderer: any) => { + this._removeLayerFromDom(); + this._oldSetRenderer?.call(this._renderService, renderer); + this._insertLayerToDom(); + }; + this._insertLayerToDom(); + if (this._showPlaceholder) { + this._createPlaceHolder(); + } + } + + private _insertLayerToDom(): void { + this.canvas = ImageRenderer.createCanvas(this.dimensions?.canvasWidth || 0, this.dimensions?.canvasHeight || 0); + this.canvas.classList.add('xterm-image-layer'); + this._terminal._core.screenElement?.appendChild(this.canvas); + this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true }); + } + + private _removeLayerFromDom(): void { + this.canvas?.parentNode?.removeChild(this.canvas); + } + + private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void { + this._placeholderBitmap?.close(); + this._placeholderBitmap = undefined; + + // create blueprint to fill placeholder with + const bWidth = 32; // must be 2^n + const blueprint = ImageRenderer.createCanvas(bWidth, height); + const ctx = blueprint.getContext('2d', { alpha: false }); + if (!ctx) return; + const imgData = ImageRenderer.createImageData(ctx, bWidth, height); + const d32 = new Uint32Array(imgData.data.buffer); + const black = toRGBA8888(0, 0, 0); + const white = toRGBA8888(255, 255, 255); + d32.fill(black); + for (let y = 0; y < height; ++y) { + const shift = y % 2; + const offset = y * bWidth; + for (let x = 0; x < bWidth; x += 2) { + d32[offset + x + shift] = white; + } + } + ctx.putImageData(imgData, 0, 0); + + // create placeholder line, width aligned to blueprint width + const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH; + this._placeholder = ImageRenderer.createCanvas(width, height); + const ctx2 = this._placeholder.getContext('2d', { alpha: false }); + if (!ctx2) { + this._placeholder = undefined; + return; + } + for (let i = 0; i < width; i += bWidth) { + ctx2.drawImage(blueprint, i, 0); + } + ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap); + } +} diff --git a/src/ImageStorage.ts b/src/ImageStorage.ts new file mode 100644 index 0000000..db23d59 --- /dev/null +++ b/src/ImageStorage.ts @@ -0,0 +1,489 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ +import { IDisposable } from 'xterm'; +import { ImageRenderer } from './ImageRenderer'; +import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize } from './Types'; + + +// fallback default cell size +export const CELL_SIZE_DEFAULT: ICellSize = { + width: 7, + height: 14 +}; + +/** + * Extend extended attribute to also hold image tile information. + */ +export class ExtendedAttrsImage implements IExtendedAttrsImage { + constructor( + public underlineStyle = 0, + public underlineColor: number = -1, + public imageId = -1, + public tileId = -1 + ) { } + public clone(): ExtendedAttrsImage { + return new ExtendedAttrsImage(this.underlineStyle, this.underlineColor, this.imageId, this.tileId); + } + public isEmpty(): boolean { + return this.underlineStyle === 0 && this.imageId === -1; + } +} +const EMPTY_ATTRS = new ExtendedAttrsImage(); + + +/** + * ImageStorage - extension of CoreTerminal: + * - hold image data + * - write/read image data to/from buffer + * + * TODO: image composition for overwrites + */ +export class ImageStorage implements IDisposable { + // storage + private _images: Map = new Map(); + // last used id + private _lastId = 0; + // last evicted id + private _lowestId = 0; + // whether last render call has drawn anything + private _hasDrawn = false; + // hard limit of stored pixels (fallback limit of 10 MB) + private _pixelLimit: number = 2500000; + + private _viewportMetrics: { cols: number, rows: number }; + + constructor( + private _terminal: ITerminalExt, + private _renderer: ImageRenderer, + private _opts: IImageAddonOptions + ) { + try { + this.setLimit(this._opts.storageLimit); + } catch (e: any) { + console.error(e.message); + console.warn(`storageLimit is set to ${this.getLimit()} MB`); + } + this._viewportMetrics = { + cols: this._terminal.cols, + rows: this._terminal.rows + }; + } + + public dispose(): void { + this.reset(); + } + + public reset(): void { + for (const spec of this._images.values()) { + spec.marker?.dispose(); + } + this._images.clear(); + this._renderer.clearAll(); + } + + public getLimit(): number { + return this._pixelLimit * 4 / 1000000; + } + + public setLimit(value: number): void { + if (value < 1 || value > 1000) { + throw RangeError('invalid storageLimit, should be at least 1 MB and not exceed 1G'); + } + this._pixelLimit = (value / 4 * 1000000) >>> 0; + this._evictOldest(0); + } + + public getUsage(): number { + return this._getStoredPixels() * 4 / 1000000; + } + + private _getStoredPixels(): number { + let storedPixels = 0; + for (const spec of this._images.values()) { + if (spec.orig) { + storedPixels += spec.orig.width * spec.orig.height; + if (spec.actual && spec.actual !== spec.orig) { + storedPixels += spec.actual.width * spec.actual.height; + } + } + } + return storedPixels; + } + + /** + * Wipe canvas and images on alternate buffer. + */ + public wipeAlternate(): void { + // remove all alternate tagged images + const zero = []; + for (const [id, spec] of this._images.entries()) { + if (spec.bufferType === 'alternate') { + spec.marker?.dispose(); + zero.push(id); + } + } + for (const id of zero) { + this._images.delete(id); + } + // mark canvas to be wiped on next render + this._hasDrawn = true; + } + + /** + * Method to add an image to the storage. + */ + public addImage(img: HTMLCanvasElement): void { + // never allow storage to exceed memory limit + this._evictOldest(img.width * img.height); + + // calc rows x cols needed to display the image + let cellSize = this._renderer.cellSize; + if (cellSize.width === -1 || cellSize.height === -1) { + cellSize = CELL_SIZE_DEFAULT; + } + const cols = Math.ceil(img.width / cellSize.width); + const rows = Math.ceil(img.height / cellSize.height); + + const imageId = ++this._lastId; + + const buffer = this._terminal._core.buffer; + const termCols = this._terminal.cols; + const termRows = this._terminal.rows; + const originX = buffer.x; + const originY = buffer.y; + let offset = originX; + let tileCount = 0; + + if (!this._opts.sixelScrolling) { + this._terminal._core._dirtyRowService.markAllDirty(); + buffer.x = 0; + buffer.y = 0; + offset = 0; + } + + // TODO: how to go with origin mode / scroll margins here? + for (let row = 0; row < rows; ++row) { + const line = buffer.lines.get(buffer.y + buffer.ybase); + for (let col = 0; col < cols; ++col) { + if (offset + col >= termCols) break; + this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col); + tileCount++; + } + if (this._opts.sixelScrolling) { + if (row < rows - 1) this._terminal._core._inputHandler.lineFeed(); + } else { + if (++buffer.y >= termRows) break; + } + buffer.x = offset; + } + + // cursor positioning modes + if (this._opts.sixelScrolling) { + if (this._opts.cursorRight) { + buffer.x = offset + cols; + if (buffer.x >= termCols) { + this._terminal._core._inputHandler.lineFeed(); + buffer.x = (this._opts.cursorBelow) ? offset : 0; + } + } else { + this._terminal._core._inputHandler.lineFeed(); + buffer.x = (this._opts.cursorBelow) ? offset : 0; + } + } else { + buffer.x = originX; + buffer.y = originY; + } + + // deleted images with zero tile count + const zero = []; + for (const [id, spec] of this._images.entries()) { + if (spec.tileCount < 1) { + spec.marker?.dispose(); + zero.push(id); + } + } + for (const id of zero) { + this._images.delete(id); + } + + // eviction marker: + // delete the image when the marker gets disposed + const endMarker = this._terminal.registerMarker(0); + endMarker?.onDispose(() => { + const spec = this._images.get(imageId); + if (spec) { + this._images.delete(imageId); + } + }); + + // since markers do not work on alternate for some reason, + // we evict images here manually + if (this._terminal.buffer.active.type === 'alternate') { + this._evictOnAlternate(); + } + + // create storage entry + const imgSpec: IImageSpec = { + orig: img, + origCellSize: cellSize, + actual: img, + actualCellSize: { ...cellSize }, // clone needed, since later modified + marker: endMarker || undefined, + tileCount, + bufferType: this._terminal.buffer.active.type + }; + + // finally add the image + this._images.set(imageId, imgSpec); + } + + + /** + * Render method. Collects buffer information and triggers + * canvas updates. + */ + // TODO: Should we move this to the ImageRenderer? + public render(range: { start: number, end: number }): void { + // exit early if we dont have any images to test for + // FIXME: leaves garbage on screen for IL/DL + if (!this._images.size || !this._renderer.canvas) { + if (this._hasDrawn) { + this._renderer.clearAll(); + this._hasDrawn = false; + } + return; + } + + const { start, end } = range; + const buffer = this._terminal._core.buffer; + const cols = this._terminal._core.cols; + this._hasDrawn = false; + + // clear drawing area + this._renderer.clearLines(start, end); + // rescale if needed + this._renderer.rescaleCanvas(); + + // walk all cells in viewport and draw tiles found + for (let row = start; row <= end; ++row) { + const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt; + if (!line) return; + for (let col = 0; col < cols; ++col) { + if (line.getBg(col) & BgFlags.HAS_EXTENDED) { + let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS; + const imageId = e.imageId; + if (imageId === undefined || imageId === -1) { + continue; + } + const imgSpec = this._images.get(imageId); + if (e.tileId !== -1) { + const startTile = e.tileId; + const startCol = col; + let count = 1; + /** + * merge tiles to the right into a single draw call, if: + * - not at end of line + * - cell has same image id + * - cell has consecutive tile id + */ + while ( + ++col < cols + && (line.getBg(col) & BgFlags.HAS_EXTENDED) + && (e = line._extendedAttrs[col] || EMPTY_ATTRS) + && (e.imageId === imageId) + && (e.tileId === startTile + count) + ) { + count++; + } + col--; + if (imgSpec) { + if (imgSpec.actual) { + this._renderer.draw(imgSpec, startTile, startCol, row, count); + } + } else if (this._opts.showPlaceholder) { + this._renderer.drawPlaceholder(startCol, row, count); + } + this._hasDrawn = true; + } + } + } + } + } + + public viewportResize(metrics: { cols: number, rows: number }): void { + // exit early if we have nothing in storage + if (!this._images.size) { + this._viewportMetrics = metrics; + return; + } + + // handle only viewport width enlargements, exit all other cases + // TODO: needs patch for tile counter + if (this._viewportMetrics.cols >= metrics.cols) { + this._viewportMetrics = metrics; + return; + } + + // walk scrollbuffer at old col width to find all possible expansion matches + const buffer = this._terminal._core.buffer; + const rows = buffer.lines.length; + const oldCol = this._viewportMetrics.cols - 1; + for (let row = 0; row < rows; ++row) { + const line = buffer.lines.get(row) as IBufferLineExt; + if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) { + const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS; + const imageId = e.imageId; + if (imageId === undefined || imageId === -1) { + continue; + } + const imgSpec = this._images.get(imageId); + if (!imgSpec) { + continue; + } + // found an image tile at oldCol, check if it qualifies for right exapansion + const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width); + if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) { + continue; + } + // expand only if right side is empty (nothing got wrapped from below) + let hasData = false; + for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) { + if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) { + hasData = true; + break; + } + } + if (hasData) { + continue; + } + // do right expansion on terminal buffer + const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); + let lastTile = e.tileId; + for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { + this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile); + imgSpec.tileCount++; + } + } + } + // store new viewport metrics + this._viewportMetrics = metrics; + } + + /** + * Retrieve original canvas at buffer position. + */ + public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { + const buffer = this._terminal._core.buffer; + const line = buffer.lines.get(y) as IBufferLineExt; + if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { + const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS; + if (e.imageId && e.imageId !== -1) { + return this._images.get(e.imageId)?.orig; + } + } + } + + /** + * Extract active single tile at buffer position. + */ + public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { + const buffer = this._terminal._core.buffer; + const line = buffer.lines.get(y) as IBufferLineExt; + if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { + const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS; + if (e.imageId && e.imageId !== -1 && e.tileId !== -1) { + const spec = this._images.get(e.imageId); + if (spec) { + return this._renderer.extractTile(spec, e.tileId); + } + } + } + } + + // TODO: Do we need some blob offloading tricks here to avoid early eviction? + // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size + private _evictOldest(room: number): number { + const used = this._getStoredPixels(); + let current = used; + while (this._pixelLimit < current + room && this._images.size) { + const spec = this._images.get(++this._lowestId); + if (spec && spec.orig) { + current -= spec.orig.width * spec.orig.height; + if (spec.actual && spec.orig !== spec.actual) { + current -= spec.actual.width * spec.actual.height; + } + spec.marker?.dispose(); + this._images.delete(this._lowestId); + } + } + return used - current; + } + + private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void { + if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { + const old = line._extendedAttrs[x]; + if (old) { + if (old.imageId !== undefined) { + // found an old ExtendedAttrsImage, since we know that + // they are always isolated instances (single cell usage), + // we can re-use it and just update their id entries + const oldSpec = this._images.get(old.imageId); + if (oldSpec) { + // early eviction for in-viewport overwrites + oldSpec.tileCount--; + } + old.imageId = imageId; + old.tileId = tileId; + return; + } + // found a plain ExtendedAttrs instance, clone it to new entry + line._extendedAttrs[x] = new ExtendedAttrsImage(old.underlineStyle, old.underlineColor, imageId, tileId); + return; + } + } + // fall-through: always create new ExtendedAttrsImage entry + line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED; + line._extendedAttrs[x] = new ExtendedAttrsImage(0, -1, imageId, tileId); + } + + private _evictOnAlternate(): void { + // nullify tile count of all images on alternate buffer + for (const spec of this._images.values()) { + if (spec.bufferType === 'alternate') { + spec.tileCount = 0; + } + } + // re-count tiles on whole buffer + const buffer = this._terminal._core.buffer; + for (let y = 0; y < this._terminal.rows; ++y) { + const line = buffer.lines.get(y) as IBufferLineExt; + if (!line) { + continue; + } + for (let x = 0; x < this._terminal.cols; ++x) { + if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { + const imgId = line._extendedAttrs[x]?.imageId; + if (imgId) { + const spec = this._images.get(imgId); + if (spec) { + spec.tileCount++; + } + } + } + } + } + // deleted images with zero tile count + const zero = []; + for (const [id, spec] of this._images.entries()) { + if (spec.bufferType === 'alternate' && !spec.tileCount) { + spec.marker?.dispose(); + zero.push(id); + } + } + for (const id of zero) { + this._images.delete(id); + } + } +} diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts new file mode 100644 index 0000000..c382fa3 --- /dev/null +++ b/src/SixelHandler.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +import { ImageStorage } from './ImageStorage'; +import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IColorManager } from './Types'; +import { toRGBA8888, BIG_ENDIAN } from 'sixel/lib/Colors'; +import { RGBA8888 } from 'sixel/lib/Types'; +import { WorkerManager } from './WorkerManager'; +import { ImageRenderer } from './ImageRenderer'; +import { PaletteType } from 'WorkerTypes'; + + +export class SixelHandler implements IDcsHandler { + private _size = 0; + private _fillColor = 0; + private _aborted = false; + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: ImageStorage, + private readonly _coreTerminal: ITerminalExt, + private readonly _workerManager: WorkerManager + ) {} + + // called on new SIXEL DCS sequence + public hook(params: IParams): void { + // NOOP fall-through for all actions if worker is in non-working condition + this._aborted = this._workerManager.failed; + if (this._aborted) { + return; + } + this._fillColor = params.params[1] === 1 ? 0 : extractActiveBg( + this._coreTerminal._core._inputHandler._curAttrData, + this._coreTerminal._core._colorManager.colors); + // image palette is either shared (using previous one), or one of + // 'VT340-COLOR' | 'VT340-GREY' | 'ANSI256' (ANSI256 as fallthrough) + const palette = this._opts.sixelPrivatePalette === false + ? PaletteType.SHARED + : this._opts.sixelDefaultPalette === 'VT340-COLOR' + ? PaletteType.VT340_COLOR + : this._opts.sixelDefaultPalette === 'VT340-GREY' + ? PaletteType.VT340_GREY + : PaletteType.ANSI_256; + this._size = 0; + this._workerManager.sixelInit(this._fillColor, palette, this._opts.sixelPaletteLimit); + } + + // called for any SIXEL data chunk + public put(data: Uint32Array, start: number, end: number): void { + if (this._aborted || this._workerManager.failed) { + return; + } + if (this._workerManager.sizeExceeded) { + this._workerManager.sixelEnd(false); + this._aborted = true; + return; + } + this._size += end - start; + if (this._size > this._opts.sixelSizeLimit) { + console.warn(`SIXEL: too much data, aborting`); + this._workerManager.sixelEnd(false); + this._aborted = true; + return; + } + /** + * copy data over to worker: + * - narrow data from uint32 to uint8 (high codepoints are not valid for SIXELs) + * - push multiple buffer chunks until all data got written + * + * We cannot limit data flow at the PUT stage as async pausing is + * only implemented for UNHOOK in the parser. To avoid OOM from message flooding + * we have `sixelSizeLimit` above in place. + */ + let p = start; + while (p < end) { + const chunk = new Uint8Array(this._workerManager.getChunk()); + const length = Math.min(end - p, chunk.length); + chunk.set(data.subarray(p, p += length)); + this._workerManager.sixelPut(chunk, length); + } + } + + /** + * Called on finalizing the SIXEL DCS sequence. + * Some notes on control flow and return values: + * - worker is in non-working condition: NOOP with sync return + * - `sixelSizeLimit` exceeded: NOOP with sync return + * - `sixelEnd(false)`: NOOP with sync return + * - `sixelEnd(true)`: + * async path waiting for `Promise` + * from worker depending on decoding success, + * a valid image definition will be added + * to the terminal before finally returning + */ + public unhook(success: boolean): boolean | Promise { + if (this._aborted || this._workerManager.failed) { + return true; + } + const imgPromise = this._workerManager.sixelEnd(success); + if (!imgPromise) { + return true; + } + + return imgPromise.then(data => { + if (!data) { + return true; + } + const canvas = ImageRenderer.createCanvas(data.width, data.height); + const ctx = canvas.getContext('2d'); + if (ctx) { + const imageData = ImageRenderer.createImageData(ctx, data.width, data.height, data.buffer); + ctx.putImageData(imageData, 0, 0); // still taking pretty long for big images + this._storage.addImage(canvas); + } + this._workerManager.sixelSendBuffer(data.buffer); + return true; + }); + } +} + + +/** + * Some helpers to extract current terminal colors. + */ + +// get currently active background color from terminal +// also respect INVERSE setting +function extractActiveBg(attr: AttributeData, colors: IColorManager['colors']): RGBA8888 { + let bg = 0; + if (attr.isInverse()) { + if (attr.isFgDefault()) { + bg = convertLe(colors.foreground.rgba); + } else if (attr.isFgRGB()) { + const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor()); + bg = toRGBA8888(...t); + } else { + bg = convertLe(colors.ansi[attr.getFgColor()].rgba); + } + } else { + if (attr.isBgDefault()) { + bg = convertLe(colors.background.rgba); + } else if (attr.isBgRGB()) { + const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor()); + bg = toRGBA8888(...t); + } else { + bg = convertLe(colors.ansi[attr.getBgColor()].rgba); + } + } + return bg; +} + +// rgba values on the color managers are always in BE, thus convert to LE +function convertLe(color: number): RGBA8888 { + if (BIG_ENDIAN) return color; + return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF; +} diff --git a/src/Types.d.ts b/src/Types.d.ts new file mode 100644 index 0000000..dc9ff99 --- /dev/null +++ b/src/Types.d.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +import { IDisposable, IMarker, Terminal } from 'xterm'; + +// private imports from base repo we build against +import { BgFlags, Content } from 'common/buffer/Constants'; +import type { AttributeData } from 'common/buffer/AttributeData'; +import type { IParams, IDcsHandler, IEscapeSequenceParser } from 'common/parser/Types'; +import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types'; +import type { IDirtyRowService } from 'common/services/Services'; +import type { IColorManager, ITerminal } from 'browser/Types'; +import type { IRenderDimensions } from 'browser/renderer/Types'; +import type { IRenderService } from 'browser/services/Services'; + +export const enum Cell { + CONTENT = 0, // codepoint and wcwidth information (enum Content) + FG = 1, // foreground color in lower 3 bytes (rgb), attrs in 4th byte (enum FgFlags) + BG = 2, // background color in lower 3 bytes (rgb), attrs in 4th byte (enum BgFlags) + SIZE = 3 // size of single cell on buffer array +} + +// export some privates for local usage +export { AttributeData, IParams, IDcsHandler, BgFlags, IRenderDimensions, IRenderService, IColorManager, Content }; + +/** + * Plugin ctor options. + */ +export interface IImageAddonOptions { + enableSizeReports: boolean; + pixelLimit: number; + storageLimit: number; + showPlaceholder: boolean; + cursorRight: boolean; + cursorBelow: boolean; + sixelSupport: boolean; + sixelScrolling: boolean; + sixelPaletteLimit: number; + sixelSizeLimit: number; + sixelPrivatePalette: boolean; + sixelDefaultPalette: 'VT340-COLOR' | 'VT340-GREY' | 'ANSI256'; +} + +/** + * Stub into private interfaces. + * This should be kept in line with common libs. + * Any change made here should be replayed in the accessors test case to + * have a somewhat reliable testing against code changes in the core repo. + */ + +// overloaded IExtendedAttrs to hold image refs +export interface IExtendedAttrsImage extends IExtendedAttrs { + imageId: number; + tileId: number; +} + +/* eslint-disable */ +export interface IBufferLineExt extends IBufferLine { + _extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined}; + _data: Uint32Array; +} + +interface IInputHandlerExt extends IInputHandler { + _parser: IEscapeSequenceParser; + _curAttrData: AttributeData; + onRequestReset(handler: () => void): IDisposable; +} + +export interface ICoreTerminalExt extends ITerminal { + _dirtyRowService: IDirtyRowService; + _colorManager: IColorManager; + _inputHandler: IInputHandlerExt; + _renderService: IRenderService; +} + +export interface ITerminalExt extends Terminal { + _core: ICoreTerminalExt; +} +/* eslint-enable */ + + +/** + * Some storage definitions. + */ +export interface ICellSize { + width: number; + height: number; +} + +export interface IImageSpec { + orig: HTMLCanvasElement | undefined; + origCellSize: ICellSize; + actual: HTMLCanvasElement | undefined; + actualCellSize: ICellSize; + marker: IMarker | undefined; + tileCount: number; + bufferType: 'alternate' | 'normal'; +} diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts new file mode 100644 index 0000000..d2e30de --- /dev/null +++ b/src/WorkerManager.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2021 Joerg Breitbart. + * @license MIT + */ + +import { IImageAddonOptions } from './Types'; +import { IDisposable } from 'xterm'; +import { IImageWorkerMessage, IImagePixel, IImageWorker, MessageType, PaletteType, AckPayload } from './WorkerTypes'; + + + +// pool cleanup interval in ms +const CLEANUP_INTERVAL = 20000; + + +/** + * Manager to encapsulate certain worker aspects: + * - lazy worker loading + * - low level communication protocol with worker + * - promise based image dispatcher + * - mem pooling + */ +export class WorkerManager implements IDisposable { + private _worker: IImageWorker | undefined; + private _memPool: ArrayBuffer[] = []; + private _sixelResolver: ((img: IImagePixel | null) => void) | undefined; + private _failedToLoad = false; + private _poolCheckerInterval: number | undefined; + private _lastActive = 0; + public sizeExceeded = false; + + constructor( + public url: string, + private _opts: IImageAddonOptions, + public chunkSize: number = 65536 * 2, + public maxPoolSize: number = 50 + ) {} + + private _startupError: () => void = () => { + console.warn('ImageAddon worker failed to load, image output is disabled.'); + this._failedToLoad = true; + this.dispose(); + }; + + private _message: (msg: MessageEvent) => void = event => { + const data = event.data; + switch (data.type) { + case MessageType.CHUNK_TRANSFER: + this.storeChunk(data.payload); + break; + case MessageType.SIXEL_IMAGE: + if (this._sixelResolver) { + this._sixelResolver(data.payload); + this._sixelResolver = undefined; + } + break; + case MessageType.ACK: + this._worker?.removeEventListener('error', this._startupError); + break; + case MessageType.SIZE_EXCEEDED: + this.sizeExceeded = true; + break; + } + }; + + private _setSixelResolver(resolver?: (img: IImagePixel | null) => void): void { + if (this._sixelResolver) { + this._sixelResolver(null); + } + this._sixelResolver = resolver; + } + + public dispose(): void { + this._worker?.terminate(); + this._worker = undefined; + this._setSixelResolver(); + this.flushPool(); + if (this._poolCheckerInterval) { + clearInterval(this._poolCheckerInterval); + this._poolCheckerInterval = undefined; + } + } + + public get failed(): boolean { + return this._failedToLoad; + } + + public get worker(): IImageWorker | undefined { + if (!this._worker && !this._failedToLoad) { + this._worker = new Worker(this.url); + this._worker.addEventListener('message', this._message, false); + this._worker.addEventListener('error', this._startupError, false); + this._worker.postMessage({ + type: MessageType.ACK, + payload: AckPayload.PING, + options: { pixelLimit: this._opts.pixelLimit } + }); + } + return this._worker; + } + + public getChunk(): ArrayBuffer { + this._lastActive = Date.now(); + return this._memPool.pop() || new ArrayBuffer(this.chunkSize); + } + + public storeChunk(chunk: ArrayBuffer): void { + if (!this._poolCheckerInterval) { + this._poolCheckerInterval = setInterval(() => { + if (Date.now() - this._lastActive > CLEANUP_INTERVAL) { + this.flushPool(); + clearInterval(this._poolCheckerInterval); + this._poolCheckerInterval = undefined; + } + }, CLEANUP_INTERVAL); + } + if (this._memPool.length < this.maxPoolSize) { + this._memPool.push(chunk); + } + } + + public flushPool(): void { + this._memPool.length = 0; + } + + // SIXEL message interface + public sixelInit(fillColor: number, paletteType: PaletteType, limit: number): void { + this._setSixelResolver(); + this.sizeExceeded = false; + this.worker?.postMessage({ + type: MessageType.SIXEL_INIT, + payload: { fillColor, paletteType, limit } + }); + } + public sixelPut(data: Uint8Array, length: number): void { + this.worker?.postMessage({ + type: MessageType.SIXEL_PUT, + payload: { + buffer: data.buffer, + length + } + }, [data.buffer]); + } + public sixelEnd(success: boolean): Promise | void { + let result: Promise | undefined; + if (success && this.worker) { + result = new Promise(resolve => this._setSixelResolver(resolve)); + } + this.worker?.postMessage({ type: MessageType.SIXEL_END, payload: success }); + return result; + } + public sixelSendBuffer(buffer: ArrayBuffer): void { + this.worker?.postMessage({ type: MessageType.CHUNK_TRANSFER, payload: buffer }, [buffer]); + } +} diff --git a/src/WorkerTypes.d.ts b/src/WorkerTypes.d.ts new file mode 100644 index 0000000..07b7078 --- /dev/null +++ b/src/WorkerTypes.d.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2021 Joerg Breitbart. + * @license MIT + */ + +// setup options +export interface ISetupOptions { + pixelLimit: number; +} + +// pixel data from worker +export interface IImagePixel { + buffer: ArrayBuffer; + width: number; + height: number; +} + +// message types +export const enum MessageType { + ACK = 1, + SIXEL_INIT = 2, + SIXEL_PUT = 3, + SIXEL_END = 4, + SIXEL_IMAGE = 5, + CHUNK_TRANSFER = 6, + SIZE_EXCEEDED = 7 +} + +// palette types +export const enum PaletteType { + SHARED = 0, + VT340_COLOR = 1, + VT340_GREY = 2, + ANSI_256 = 3 +} + +// ACK payload +export const enum AckPayload { + PING = 0, + ALIVE = 1 +} + +/** + * Worker message protocol types (used on both ends). + */ +export interface IAckMessage { + type: MessageType.ACK; + payload: AckPayload; + options: ISetupOptions | null; +} +// outgoing +export interface ISixelInitMessage { + type: MessageType.SIXEL_INIT; + payload: { + fillColor: number; + paletteType: PaletteType; + limit: number; + }; +} +export interface ISixelPutMessage { + type: MessageType.SIXEL_PUT; + payload: { + buffer: ArrayBuffer; + length: number; + }; +} +export interface ISixelEndMessage { + type: MessageType.SIXEL_END; + payload: boolean; +} +// incoming +export interface ISixelImageMessage { + type: MessageType.SIXEL_IMAGE; + payload: IImagePixel | null; +} +export interface IChunkTransferMessage { + type: MessageType.CHUNK_TRANSFER; + payload: ArrayBuffer; +} +export interface ISizeExceededMessage { + type: MessageType.SIZE_EXCEEDED; +} + +export type IImageWorkerMessage = ( + IAckMessage | ISixelInitMessage | ISixelPutMessage | ISixelEndMessage | + ISixelImageMessage | IChunkTransferMessage | ISizeExceededMessage +); + +export interface IPostMessage { + (message: T, transfer: Transferable[]): void; + (message: T, options?: PostMessageOptions): void; +} + +export interface IImageWorker extends Worker { + postMessage: IPostMessage; +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..da519e1 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "sourceMap": true, + "outDir": "../out", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "preserveWatchOutput": true, + "types": [ + "../../../node_modules/@types/mocha" + ], + "baseUrl": ".", + "paths": { + "browser/*": [ "../../../src/browser/*" ], + "common/*": [ "../../../src/common/*" ] + } + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../../../src/browser" }, + { "path": "../../../src/common" } + ] +} diff --git a/test/ImageAddon.api.ts b/test/ImageAddon.api.ts new file mode 100644 index 0000000..c45ba6b --- /dev/null +++ b/test/ImageAddon.api.ts @@ -0,0 +1,397 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +import { assert } from 'chai'; +import { openTerminal, launchBrowser } from '../../../out-test/api/TestUtils'; +import { Browser, Page } from 'playwright'; +import { IImageAddonOptions } from '../src/Types'; +import { FINALIZER, introducer, sixelEncode } from 'sixel'; +import { readFileSync } from 'fs'; +import PNG from 'png-ts'; + +const APP = 'http://127.0.0.1:3001/test'; + +let browser: Browser; +let page: Page; +const width = 800; +const height = 600; + +// eslint-disable-next-line +declare const ImageAddon: { + new(workerPath: string, options?: Partial): any; +}; + +interface ITestData { + width: number; + height: number; + bytes: Uint8Array; + palette: number[]; + sixel: string; +} + +interface IDimensions { + cellWidth: number; + cellHeight: number; + width: number; + height: number; +} + +const IMAGE_WORKER_PATH = '/workers/xterm-addon-image-worker.js'; + +// image: 640 x 80, 512 color +const TESTDATA: ITestData = (() => { + const pngImage = PNG.load(readFileSync('./addons/xterm-addon-image/fixture/palette.png')); + const data8 = pngImage.decode(); + const data32 = new Uint32Array(data8.buffer); + const palette = new Set(); + for (let i = 0; i < data32.length; ++i) palette.add(data32[i]); + const sixel = sixelEncode(data8, pngImage.width, pngImage.height, [...palette]); + return { + width: pngImage.width, + height: pngImage.height, + bytes: data8, + palette: [...palette], + sixel + }; +})(); +const SIXEL_SEQ_0 = introducer(0) + TESTDATA.sixel + FINALIZER; +// const SIXEL_SEQ_1 = introducer(1) + TESTDATA.sixel + FINALIZER; +// const SIXEL_SEQ_2 = introducer(2) + TESTDATA.sixel + FINALIZER; + + +describe.only('ImageAddon', () => { + before(async () => { + browser = await launchBrowser(); + page = await (await browser.newContext()).newPage(); + await page.setViewportSize({ width, height }); + }); + + after(async () => { + await browser.close(); + }); + + beforeEach(async () => { + await page.goto(APP); + await openTerminal(page); + await page.evaluate(opts => { + (window as any).imageAddon = new ImageAddon(opts.workerPath, opts.opts); + (window as any).term.loadAddon((window as any).imageAddon); + }, { workerPath: IMAGE_WORKER_PATH, opts: { sixelPaletteLimit: 512 } }); + }); + + it('test for private accessors', async () => { + // terminal privates + const accessors = [ + '_core', + '_core._dirtyRowService', + '_core._renderService', + '_core._inputHandler', + '_core._inputHandler._parser', + '_core._inputHandler._curAttrData', + '_core._colorManager' + ]; + for (const prop of accessors) { + assert.equal( + await page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'), + true, `problem at ${prop}` + ); + } + // bufferline privates + assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true); + assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true); + // inputhandler privates + assert.equal(await page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), 'AttributeData'); + assert.equal(await page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser'); + }); + + describe('ctor options', () => { + it('empty settings should load defaults', async () => { + const DEFAULT_OPTIONS: IImageAddonOptions = { + enableSizeReports: true, + pixelLimit: 16777216, + cursorRight: false, + cursorBelow: false, + sixelSupport: true, + sixelScrolling: true, + sixelPaletteLimit: 512, // set to 512 to get example image working + sixelSizeLimit: 25000000, + sixelPrivatePalette: true, + sixelDefaultPalette: 'VT340-COLOR', + storageLimit: 128, + showPlaceholder: true + }; + assert.deepEqual(await page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS); + }); + it('custom settings should overload defaults', async () => { + const customSettings: IImageAddonOptions = { + enableSizeReports: false, + pixelLimit: 5, + cursorRight: true, + cursorBelow: true, + sixelSupport: false, + sixelScrolling: false, + sixelPaletteLimit: 1024, + sixelSizeLimit: 1000, + sixelPrivatePalette: false, + sixelDefaultPalette: 'VT340-GREY', + storageLimit: 10, + showPlaceholder: false + }; + await page.evaluate(opts => { + (window as any).imageAddonCustom = new ImageAddon(opts.workerPath, opts.opts); + (window as any).term.loadAddon((window as any).imageAddonCustom); + }, { workerPath: IMAGE_WORKER_PATH, opts: customSettings }); + assert.deepEqual(await page.evaluate(`window.imageAddonCustom._opts`), customSettings); + }); + }); + + describe('scrolling & cursor modes', () => { + it('testdata default (scrolling, cursor next line, beginning)', async () => { + const dim = await getDimensions(); + await writeToTerminal(SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, Math.ceil(TESTDATA.height/dim.cellHeight)]); + // moved to right by 10 cells + await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, Math.ceil(TESTDATA.height/dim.cellHeight) * 2]); + // await new Promise(res => setTimeout(res, 1000)); + }); + it('write testdata noScrolling', async () => { + await writeToTerminal('\x1b[?80h' + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, 0]); + // second draw does not change anything + await writeToTerminal(SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, 0]); + }); + it.skip('testdata cursor right', async () => { + const dim = await getDimensions(); + await writeToTerminal('\x1b[?8452h' + SIXEL_SEQ_0); + // currently failing on OSX firefox with AssertionError: expected [ 72, 4 ] to deeply equal [ 72, 5 ] + assert.deepEqual(await getCursor(), [Math.ceil(TESTDATA.width/dim.cellWidth), Math.floor(TESTDATA.height/dim.cellHeight)]); + }); + it('testdata cursor right with overflow beginning', async () => { + const dim = await getDimensions(); + await writeToTerminal('\x1b[?8452h' + '#'.repeat(30) + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, Math.ceil(TESTDATA.height/dim.cellHeight)]); + }); + it('testdata cursor right with overflow below', async () => { + const dim = await getDimensions(); + await writeToTerminal('\x1b[?8452h\x1b[?7730l' + '#'.repeat(30) + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [30, Math.ceil(TESTDATA.height/dim.cellHeight)]); + }); + it('testdata cursor always below', async () => { + const dim = await getDimensions(); + // offset 0 + await writeToTerminal('\x1b[?7730l' + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [0, Math.ceil(TESTDATA.height/dim.cellHeight)]); + // moved to right by 10 cells + await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [10, Math.ceil(TESTDATA.height/dim.cellHeight) * 2]); + // moved by 30 cells (+10 prev) + await writeToTerminal('#'.repeat(30) + SIXEL_SEQ_0); + assert.deepEqual(await getCursor(), [10 + 30, Math.ceil(TESTDATA.height/dim.cellHeight) * 3]); + }); + }); + + describe('image lifecycle & eviction', () => { + it('delete image once scrolled off', async () => { + await writeToTerminal(SIXEL_SEQ_0); + assert.equal(await getImageStorageLength(), 1); + // scroll to scrollback + rows - 1 + await page.evaluate( + scrollback => new Promise(res => (window as any).term.write('\n'.repeat(scrollback), res)), + (await getScrollbackPlusRows() - 1) + ); + assert.equal(await getImageStorageLength(), 1); + // scroll one further should delete the image + await page.evaluate(() => new Promise(res => (window as any).term.write('\n', res))); + assert.equal(await getImageStorageLength(), 0); + }); + it('get storageUsage', async () => { + assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); + await writeToTerminal(SIXEL_SEQ_0); + assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05); + }); + it('get/set storageLimit', async () => { + assert.equal(await page.evaluate('imageAddon.storageLimit'), 128); + assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1); + assert.equal(await page.evaluate('imageAddon.storageLimit'), 1); + }); + it('remove images by storage limit pressure', async () => { + assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1); + // never go beyond storage limit + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + const usage = await page.evaluate('imageAddon.storageUsage'); + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0); + assert.equal(await page.evaluate('imageAddon.storageUsage'), usage); + assert.equal(usage as number < 1, true); + }); + it('set storageLimit removes images synchronously', async () => { + await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); + const usage: number = await page.evaluate('imageAddon.storageUsage'); + const newUsage: number = await page.evaluate('imageAddon.storageLimit = 1; imageAddon.storageUsage'); + assert.equal(newUsage < usage, true); + assert.equal(newUsage < 1, true); + }); + it('clear alternate images on buffer change', async () => { + assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); + await writeToTerminal('\x1b[?1049h' + SIXEL_SEQ_0); + assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05); + await writeToTerminal('\x1b[?1049l'); + assert.equal(await page.evaluate('imageAddon.storageUsage'), 0); + }); + it('evict tiles by in-place overwrites (only full overwrite tested)', async () => { + await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); + const usage = await page.evaluate('imageAddon.storageUsage'); + await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); + await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); + await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H'); + assert.equal(await page.evaluate('imageAddon.storageUsage'), usage); + }); + it('manual eviction on alternate buffer must not miss images', async () => { + await writeToTerminal('\x1b[?1049h'); + await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); + const usage: number = await page.evaluate('imageAddon.storageUsage'); + await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); + await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0); + const newUsage: number = await page.evaluate('imageAddon.storageUsage'); + assert.equal(newUsage, usage); + }); + }); + + describe('worker integration & manager', () => { + async function execOnManager(prop?: string): Promise { + if (prop) { + return page.evaluate('window.imageAddon._workerManager.' + prop); + } + return page.evaluate('window.imageAddon._workerManager'); + } + it('gets URL from addon settings', async () => { + // hard coded default + assert.equal(await execOnManager('url'), '/workers/xterm-addon-image-worker.js'); + // custom + await page.evaluate(opts => { + (window as any).imageAddonCustom = new ImageAddon('xyz.js', opts); + (window as any).term.loadAddon((window as any).imageAddonCustom); + }, {}); + assert.equal(await page.evaluate(`window.imageAddonCustom._workerManager.url`), 'xyz.js'); + }); + it('timed chunk pooling', async () =>{ + // image fits into one chunk + await writeToTerminal(SIXEL_SEQ_0); + assert.equal(await execOnManager('_memPool.length'), 1); + assert.notEqual(await execOnManager('_poolCheckerInterval'), undefined); + const lastActive = await execOnManager('_lastActive'); + assert.notEqual(lastActive, 0); + }); + it.skip('max chunks with cleanup after 20s', async function (): Promise { + // Note: by default this test is skipped as it takes really long + this.timeout(30000); + // more than max chunks created (exceeding pooling) + const count = 100; // MAX_CHUNKS is 50 + const chunkLength = Math.ceil(SIXEL_SEQ_0.length/count); + for (let i = 0; i < count; ++i) { + const offset = i * chunkLength; + page.evaluate(data => (window as any).term.write(data), SIXEL_SEQ_0.slice(offset, offset + chunkLength)); + } + await writeToTerminal(''); // wait until all got consumed + assert.equal(await execOnManager('_memPool.length'), 50); + assert.notEqual(await execOnManager('_poolCheckerInterval'), undefined); + const lastActive = await execOnManager('_lastActive'); + assert.notEqual(lastActive, 0); + // should drop back to 0 after 20000 + await new Promise(res => setTimeout(async () => { + assert.equal(await execOnManager('_memPool.length'), 0); + assert.equal(await execOnManager('_poolCheckerInterval'), undefined); + res(); + }, 20000)); + }); + it('dispose should stop everything', async () => { + await writeToTerminal(SIXEL_SEQ_0); + const mustResolveWithDispose = execOnManager('sixelEnd(true)').then(() => 'yeah'); + await execOnManager('dispose()'); + // worker gone + assert.equal(await execOnManager('_worker'), undefined); + // pending resolver cleared + assert.equal(await mustResolveWithDispose, 'yeah'); + assert.equal(await execOnManager('_sixelResolver'), undefined); + // pool and checker cleared + assert.equal(await execOnManager('_memPool.length'), 0); + assert.equal(await execOnManager('_poolCheckerInterval'), undefined); + }); + describe('handle worker loading error gracefully', () => { + beforeEach(async () => { + await page.evaluate(opts => { + (window as any).imageAddonCustom = new ImageAddon('xyz.js', opts); + (window as any).term.loadAddon((window as any).imageAddonCustom); + }, {}); + }); + it('failed is set upon first worker access', async () => { + assert.equal(await page.evaluate(`window.imageAddonCustom._workerManager.failed`), false); + // We have to test it here with .endSixel as it is the only promised method + // we have implemented. This is needed to wait here for the full request-response + // cycling of the initial ACK message after the lazy worker loading. + assert.equal(await page.evaluate(`window.imageAddonCustom._workerManager.sixelEnd(true)`), null); + // Alternatively we could have waited for some time after the first `worker` access. + // await page.evaluate(`window.imageAddonCustom._workerManager.worker`); + // await new Promise(res => setTimeout(res, 50)); + assert.equal(await page.evaluate(`window.imageAddonCustom._workerManager.failed`), true); + // Note: For the sixel handler this means that early `sixelInit` and `sixelPut` API calls + // are still not a NOOP, as the worker instance in the manager still looks healthy. + // This is not really a problem, as those calls are only sending and not waiting for response. + // A minor optimization in the handler tests for the failed state on every action to spot it as + // early as possible. + }); + it('sequence turns into NOOP, handler does not block forever', async () => { + // dispose normal image addon + await page.evaluate(`window.imageAddon.dispose()`); + // proper SIXEL sequence + await writeToTerminal('#' + SIXEL_SEQ_0 + '#'); + assert.deepEqual(await getCursor(), [2, 0]); + // sequence with color definition but missing SIXEL bytes (0 pixel image) + await writeToTerminal('#' + '\x1bPq#14;2;0;100;100\x1b\\' + '#'); + assert.deepEqual(await getCursor(), [4, 0]); + // shortest possible sequence (no data bytes at all) + await writeToTerminal('#' + '\x1bPq\x1b\\' + '#'); + assert.deepEqual(await getCursor(), [6, 0]); + }); + }); + }); + +}); + +/** + * terminal access helpers. + */ +async function getDimensions(): Promise { + const dimensions: any = await page.evaluate(`term._core._renderService.dimensions`); + return { + cellWidth: Math.round(dimensions.actualCellWidth), + cellHeight: Math.round(dimensions.actualCellHeight), + width: Math.round(dimensions.canvasWidth), + height: Math.round(dimensions.canvasHeight) + }; +} + +async function getCursor(): Promise<[number, number]> { + return page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]'); +} + +async function getImageStorageLength(): Promise { + return page.evaluate('window.imageAddon._storage._images.size'); +} + +async function getScrollbackPlusRows(): Promise { + return page.evaluate('window.term.getOption(\'scrollback\') + window.term.rows'); +} + +async function writeToTerminal(d: string): Promise { + return page.evaluate(data => new Promise(res => (window as any).term.write(data, res)), d); +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..a8c25b5 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2017", + "rootDir": ".", + "outDir": "../out-test", + "sourceMap": true, + "removeComments": true, + "strict": true, + "baseUrl": ".", + "paths": { + "browser/*": [ "../../../src/browser/*" ], + "common/*": [ "../../../src/common/*" ] + }, + "types": [ + "../../../node_modules/@types/mocha", + "../../../node_modules/@types/node", + "../../../out-test/api/TestUtils" + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../../../src/browser" }, + { "path": "../../../src/common" } + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3488273 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "files": [], + "include": [], + "references": [ + { "path": "./src" }, + { "path": "./test" }, + { "path": "./src-worker" } + ] +} diff --git a/typings/xterm-addon-image.d.ts b/typings/xterm-addon-image.d.ts new file mode 100644 index 0000000..76a1918 --- /dev/null +++ b/typings/xterm-addon-image.d.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2012 Joerg Breitbart. + * @license MIT + */ + +import { Terminal, ITerminalAddon } from 'xterm'; + +declare module 'xterm-addon-image' { + export interface IImageAddonOptions { + /** + * Enable size reports in windowOptions: + * - getWinSizePixels (CSI 14 t) + * - getCellSizePixels (CSI 16 t) + * - getWinSizeChars (CSI 18 t) + * + * If `true` (default), the reports will be activated during addon loading. + * If `false`, no settings will be touched. Use `false`, if you have high + * security constraints and/or deal with windowOptions by other means. + * On addon disposal, the settings will not change. + */ + enableSizeReports?: boolean; + + /** + * Maximum pixels a single image may hold. Images exceeding this number will + * be discarded during processing with no changes to the terminal buffer + * (no cursor advance, no placeholder). + * This setting is mainly used to restrict images sizes during initial decoding + * including the final canvas creation. + * + * Note: The image worker decoder may hold additional memory up to + * `pixelLimit` * 4 bytes permanently, plus the same amount on top temporarily + * for pixel transfers, which should be taken into account under memory pressure conditions. + * + * Note: Browsers restrict allowed canvas dimensions further. We dont reflect those + * limits here, thus the construction of an oddly shaped image having most pixels + * in one dimension still can fail. + * + * Note: `storageLimit` bytes are calculated from images by multiplying the pixels with 4 + * (4 channels with one byte, images are stored as RGBA8888). + * + * Default is 2^16 (4096 x 4096 pixels). + */ + pixelLimit?: number; + + /** + * Storage limit in MB. + * The storage implements a FIFO cache removing old images, when the limit gets hit. + * Also exposed as addon property for runtime adjustments. + * Default is 128 MB. + */ + storageLimit?: number; + + /** + * Whether to show a placeholder for images removed from cache, default is true. + */ + showPlaceholder?: boolean; + + /** + * Leave cursor to right of image. + * This has no effect, if an image covers all cells to the right. + * Same as DECSET 8452, default is false. + */ + cursorRight?: boolean; + + /** + * Leave cursor below the first row of an image, scrolling if needed. + * If disabled, the cursor is left at the beginning of the next line. + * This settings is partially overwritten by `cursorRight`, if an image + * does not cover all cells to the right. + * Same as DECSET 7730, default is false. + */ + cursorBelow?: boolean; + + /** + * SIXEL settings + */ + + /** Whether SIXEL is enabled (default is true). */ + sixelSupport?: boolean; + /** Whether SIXEL scrolling is enabled (default is true). Same as DECSET 80. */ + sixelScrolling?: boolean; + /** Palette color limit (default 256). */ + sixelPaletteLimit?: number; + /** SIXEL image size limit in bytes (default 25000000 bytes). */ + sixelSizeLimit?: number; + /** Whether to use private palettes for SIXEL sequences (default is true). Same as DECSET 1070. */ + sixelPrivatePalette?: boolean; + /** Default start palette (default is 'VT340-COLOR'). */ + sixelDefaultPalette?: 'VT340-COLOR' | 'VT340-GREY' | 'ANSI256'; + } + + export class ImageAddon implements ITerminalAddon { + constructor(workerPath: string, options?: IImageAddonOptions); + public activate(terminal: Terminal): void; + public dispose(): void; + + /** + * Reset the image addon. + * + * This resets all runtime options to default values (as given to the ctor) + * and resets the image storage. + */ + public reset(): void; + + /** + * Getter/Setter for the storageLimit in MB. + * Synchronously deletes images if the stored data exceeds the new value. + */ + public storageLimit: number; + + /** + * Current memory usage of the stored images in MB. + */ + public readonly storageUsage: number; + + /** + * Getter/Setter whether the placeholder should be shown. + */ + public showPlaceholder: boolean; + + /** + * Get original image canvas at buffer position. + */ + public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined; + + /** + * Extract single tile canvas at buffer position. + */ + public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined; + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..022d59b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2020 Joerg Breitbart. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'ImageAddon'; +const mainFile = 'xterm-addon-image.js'; +const mainFileWorker = 'xterm-addon-image-worker.js'; +const workerName = 'main'; + +const addon = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; + +// worker target bundled as ./lib/xterm-addon-image-worker.js +const worker = { + entry: `./out-worker/${workerName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + output: { + filename: mainFileWorker, + path: path.resolve('./lib'), + libraryTarget: 'umd' + }, + mode: 'production' +}; + +module.exports = [addon, worker];