@xterm/addon-image 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Copyright (c) 2020 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+ import { IDisposable } from '@xterm/xterm';
7
+ import { ImageRenderer } from './ImageRenderer';
8
+ import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
9
+
10
+
11
+ // fallback default cell size
12
+ export const CELL_SIZE_DEFAULT: ICellSize = {
13
+ width: 7,
14
+ height: 14
15
+ };
16
+
17
+ /**
18
+ * Extend extended attribute to also hold image tile information.
19
+ *
20
+ * Object definition is copied from base repo to fully mimick its behavior.
21
+ * Image data is added as additional public properties `imageId` and `tileId`.
22
+ */
23
+ class ExtendedAttrsImage implements IExtendedAttrsImage {
24
+ private _ext: number = 0;
25
+ public get ext(): number {
26
+ if (this._urlId) {
27
+ return (
28
+ (this._ext & ~ExtFlags.UNDERLINE_STYLE) |
29
+ (this.underlineStyle << 26)
30
+ );
31
+ }
32
+ return this._ext;
33
+ }
34
+ public set ext(value: number) { this._ext = value; }
35
+
36
+ public get underlineStyle(): UnderlineStyle {
37
+ // Always return the URL style if it has one
38
+ if (this._urlId) {
39
+ return UnderlineStyle.DASHED;
40
+ }
41
+ return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
42
+ }
43
+ public set underlineStyle(value: UnderlineStyle) {
44
+ this._ext &= ~ExtFlags.UNDERLINE_STYLE;
45
+ this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
46
+ }
47
+
48
+ public get underlineColor(): number {
49
+ return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
50
+ }
51
+ public set underlineColor(value: number) {
52
+ this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
53
+ this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
54
+ }
55
+
56
+ private _urlId: number = 0;
57
+ public get urlId(): number {
58
+ return this._urlId;
59
+ }
60
+ public set urlId(value: number) {
61
+ this._urlId = value;
62
+ }
63
+
64
+ constructor(
65
+ ext: number = 0,
66
+ urlId: number = 0,
67
+ public imageId = -1,
68
+ public tileId = -1
69
+ ) {
70
+ this._ext = ext;
71
+ this._urlId = urlId;
72
+ }
73
+
74
+ public clone(): IExtendedAttrsImage {
75
+ /**
76
+ * Technically we dont need a clone variant of ExtendedAttrsImage,
77
+ * as we never clone a cell holding image data.
78
+ * Note: Clone is only meant to be used by the InputHandler for
79
+ * sticky attributes, which is never the case for image data.
80
+ * We still provide a proper clone method to reflect the full ext attr
81
+ * state in case there are future use cases for clone.
82
+ */
83
+ return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);
84
+ }
85
+
86
+ public isEmpty(): boolean {
87
+ return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;
88
+ }
89
+ }
90
+ const EMPTY_ATTRS = new ExtendedAttrsImage();
91
+
92
+
93
+ /**
94
+ * ImageStorage - extension of CoreTerminal:
95
+ * - hold image data
96
+ * - write/read image data to/from buffer
97
+ *
98
+ * TODO: image composition for overwrites
99
+ */
100
+ export class ImageStorage implements IDisposable {
101
+ // storage
102
+ private _images: Map<number, IImageSpec> = new Map();
103
+ // last used id
104
+ private _lastId = 0;
105
+ // last evicted id
106
+ private _lowestId = 0;
107
+ // whether a full clear happened before
108
+ private _fullyCleared = false;
109
+ // whether render should do a full clear
110
+ private _needsFullClear = false;
111
+ // hard limit of stored pixels (fallback limit of 10 MB)
112
+ private _pixelLimit: number = 2500000;
113
+
114
+ private _viewportMetrics: { cols: number, rows: number };
115
+
116
+ constructor(
117
+ private _terminal: ITerminalExt,
118
+ private _renderer: ImageRenderer,
119
+ private _opts: IImageAddonOptions
120
+ ) {
121
+ try {
122
+ this.setLimit(this._opts.storageLimit);
123
+ } catch (e: any) {
124
+ console.error(e.message);
125
+ console.warn(`storageLimit is set to ${this.getLimit()} MB`);
126
+ }
127
+ this._viewportMetrics = {
128
+ cols: this._terminal.cols,
129
+ rows: this._terminal.rows
130
+ };
131
+ }
132
+
133
+ public dispose(): void {
134
+ this.reset();
135
+ }
136
+
137
+ public reset(): void {
138
+ for (const spec of this._images.values()) {
139
+ spec.marker?.dispose();
140
+ }
141
+ // NOTE: marker.dispose above already calls ImageBitmap.close
142
+ // therefore we can just wipe the map here
143
+ this._images.clear();
144
+ this._renderer.clearAll();
145
+ }
146
+
147
+ public getLimit(): number {
148
+ return this._pixelLimit * 4 / 1000000;
149
+ }
150
+
151
+ public setLimit(value: number): void {
152
+ if (value < 0.5 || value > 1000) {
153
+ throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');
154
+ }
155
+ this._pixelLimit = (value / 4 * 1000000) >>> 0;
156
+ this._evictOldest(0);
157
+ }
158
+
159
+ public getUsage(): number {
160
+ return this._getStoredPixels() * 4 / 1000000;
161
+ }
162
+
163
+ private _getStoredPixels(): number {
164
+ let storedPixels = 0;
165
+ for (const spec of this._images.values()) {
166
+ if (spec.orig) {
167
+ storedPixels += spec.orig.width * spec.orig.height;
168
+ if (spec.actual && spec.actual !== spec.orig) {
169
+ storedPixels += spec.actual.width * spec.actual.height;
170
+ }
171
+ }
172
+ }
173
+ return storedPixels;
174
+ }
175
+
176
+ private _delImg(id: number): void {
177
+ const spec = this._images.get(id);
178
+ this._images.delete(id);
179
+ // FIXME: really ugly workaround to get bitmaps deallocated :(
180
+ if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
181
+ spec.orig.close();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Wipe canvas and images on alternate buffer.
187
+ */
188
+ public wipeAlternate(): void {
189
+ // remove all alternate tagged images
190
+ const zero = [];
191
+ for (const [id, spec] of this._images.entries()) {
192
+ if (spec.bufferType === 'alternate') {
193
+ spec.marker?.dispose();
194
+ zero.push(id);
195
+ }
196
+ }
197
+ for (const id of zero) {
198
+ this._delImg(id);
199
+ }
200
+ // mark canvas to be wiped on next render
201
+ this._needsFullClear = true;
202
+ this._fullyCleared = false;
203
+ }
204
+
205
+ /**
206
+ * Only advance text cursor.
207
+ * This is an edge case from empty sixels carrying only a height but no pixels.
208
+ * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
209
+ */
210
+ public advanceCursor(height: number): void {
211
+ if (this._opts.sixelScrolling) {
212
+ let cellSize = this._renderer.cellSize;
213
+ if (cellSize.width === -1 || cellSize.height === -1) {
214
+ cellSize = CELL_SIZE_DEFAULT;
215
+ }
216
+ const rows = Math.ceil(height / cellSize.height);
217
+ for (let i = 1; i < rows; ++i) {
218
+ this._terminal._core._inputHandler.lineFeed();
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Method to add an image to the storage.
225
+ */
226
+ public addImage(img: HTMLCanvasElement | ImageBitmap): void {
227
+ // never allow storage to exceed memory limit
228
+ this._evictOldest(img.width * img.height);
229
+
230
+ // calc rows x cols needed to display the image
231
+ let cellSize = this._renderer.cellSize;
232
+ if (cellSize.width === -1 || cellSize.height === -1) {
233
+ cellSize = CELL_SIZE_DEFAULT;
234
+ }
235
+ const cols = Math.ceil(img.width / cellSize.width);
236
+ const rows = Math.ceil(img.height / cellSize.height);
237
+
238
+ const imageId = ++this._lastId;
239
+
240
+ const buffer = this._terminal._core.buffer;
241
+ const termCols = this._terminal.cols;
242
+ const termRows = this._terminal.rows;
243
+ const originX = buffer.x;
244
+ const originY = buffer.y;
245
+ let offset = originX;
246
+ let tileCount = 0;
247
+
248
+ if (!this._opts.sixelScrolling) {
249
+ buffer.x = 0;
250
+ buffer.y = 0;
251
+ offset = 0;
252
+ }
253
+
254
+ this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
255
+ for (let row = 0; row < rows; ++row) {
256
+ const line = buffer.lines.get(buffer.y + buffer.ybase);
257
+ for (let col = 0; col < cols; ++col) {
258
+ if (offset + col >= termCols) break;
259
+ this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
260
+ tileCount++;
261
+ }
262
+ if (this._opts.sixelScrolling) {
263
+ if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
264
+ } else {
265
+ if (++buffer.y >= termRows) break;
266
+ }
267
+ buffer.x = offset;
268
+ }
269
+ this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
270
+
271
+ // cursor positioning modes
272
+ if (this._opts.sixelScrolling) {
273
+ buffer.x = offset;
274
+ } else {
275
+ buffer.x = originX;
276
+ buffer.y = originY;
277
+ }
278
+
279
+ // deleted images with zero tile count
280
+ const zero = [];
281
+ for (const [id, spec] of this._images.entries()) {
282
+ if (spec.tileCount < 1) {
283
+ spec.marker?.dispose();
284
+ zero.push(id);
285
+ }
286
+ }
287
+ for (const id of zero) {
288
+ this._delImg(id);
289
+ }
290
+
291
+ // eviction marker:
292
+ // delete the image when the marker gets disposed
293
+ const endMarker = this._terminal.registerMarker(0);
294
+ endMarker?.onDispose(() => {
295
+ const spec = this._images.get(imageId);
296
+ if (spec) {
297
+ this._delImg(imageId);
298
+ }
299
+ });
300
+
301
+ // since markers do not work on alternate for some reason,
302
+ // we evict images here manually
303
+ if (this._terminal.buffer.active.type === 'alternate') {
304
+ this._evictOnAlternate();
305
+ }
306
+
307
+ // create storage entry
308
+ const imgSpec: IImageSpec = {
309
+ orig: img,
310
+ origCellSize: cellSize,
311
+ actual: img,
312
+ actualCellSize: { ...cellSize }, // clone needed, since later modified
313
+ marker: endMarker || undefined,
314
+ tileCount,
315
+ bufferType: this._terminal.buffer.active.type
316
+ };
317
+
318
+ // finally add the image
319
+ this._images.set(imageId, imgSpec);
320
+ }
321
+
322
+
323
+ /**
324
+ * Render method. Collects buffer information and triggers
325
+ * canvas updates.
326
+ */
327
+ // TODO: Should we move this to the ImageRenderer?
328
+ public render(range: { start: number, end: number }): void {
329
+ // setup image canvas in case we have none yet, but have images in store
330
+ if (!this._renderer.canvas && this._images.size) {
331
+ this._renderer.insertLayerToDom();
332
+ // safety measure - in case we cannot spawn a canvas at all, just exit
333
+ if (!this._renderer.canvas) {
334
+ return;
335
+ }
336
+ }
337
+ // rescale if needed
338
+ this._renderer.rescaleCanvas();
339
+ // exit early if we dont have any images to test for
340
+ if (!this._images.size) {
341
+ if (!this._fullyCleared) {
342
+ this._renderer.clearAll();
343
+ this._fullyCleared = true;
344
+ this._needsFullClear = false;
345
+ }
346
+ if (this._renderer.canvas) {
347
+ this._renderer.removeLayerFromDom();
348
+ }
349
+ return;
350
+ }
351
+
352
+ // buffer switches force a full clear
353
+ if (this._needsFullClear) {
354
+ this._renderer.clearAll();
355
+ this._fullyCleared = true;
356
+ this._needsFullClear = false;
357
+ }
358
+
359
+ const { start, end } = range;
360
+ const buffer = this._terminal._core.buffer;
361
+ const cols = this._terminal._core.cols;
362
+
363
+ // clear drawing area
364
+ this._renderer.clearLines(start, end);
365
+
366
+ // walk all cells in viewport and draw tiles found
367
+ for (let row = start; row <= end; ++row) {
368
+ const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
369
+ if (!line) return;
370
+ for (let col = 0; col < cols; ++col) {
371
+ if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
372
+ let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
373
+ const imageId = e.imageId;
374
+ if (imageId === undefined || imageId === -1) {
375
+ continue;
376
+ }
377
+ const imgSpec = this._images.get(imageId);
378
+ if (e.tileId !== -1) {
379
+ const startTile = e.tileId;
380
+ const startCol = col;
381
+ let count = 1;
382
+ /**
383
+ * merge tiles to the right into a single draw call, if:
384
+ * - not at end of line
385
+ * - cell has same image id
386
+ * - cell has consecutive tile id
387
+ */
388
+ while (
389
+ ++col < cols
390
+ && (line.getBg(col) & BgFlags.HAS_EXTENDED)
391
+ && (e = line._extendedAttrs[col] || EMPTY_ATTRS)
392
+ && (e.imageId === imageId)
393
+ && (e.tileId === startTile + count)
394
+ ) {
395
+ count++;
396
+ }
397
+ col--;
398
+ if (imgSpec) {
399
+ if (imgSpec.actual) {
400
+ this._renderer.draw(imgSpec, startTile, startCol, row, count);
401
+ }
402
+ } else if (this._opts.showPlaceholder) {
403
+ this._renderer.drawPlaceholder(startCol, row, count);
404
+ }
405
+ this._fullyCleared = false;
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ public viewportResize(metrics: { cols: number, rows: number }): void {
413
+ // exit early if we have nothing in storage
414
+ if (!this._images.size) {
415
+ this._viewportMetrics = metrics;
416
+ return;
417
+ }
418
+
419
+ // handle only viewport width enlargements, exit all other cases
420
+ // TODO: needs patch for tile counter
421
+ if (this._viewportMetrics.cols >= metrics.cols) {
422
+ this._viewportMetrics = metrics;
423
+ return;
424
+ }
425
+
426
+ // walk scrollbuffer at old col width to find all possible expansion matches
427
+ const buffer = this._terminal._core.buffer;
428
+ const rows = buffer.lines.length;
429
+ const oldCol = this._viewportMetrics.cols - 1;
430
+ for (let row = 0; row < rows; ++row) {
431
+ const line = buffer.lines.get(row) as IBufferLineExt;
432
+ if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
433
+ const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
434
+ const imageId = e.imageId;
435
+ if (imageId === undefined || imageId === -1) {
436
+ continue;
437
+ }
438
+ const imgSpec = this._images.get(imageId);
439
+ if (!imgSpec) {
440
+ continue;
441
+ }
442
+ // found an image tile at oldCol, check if it qualifies for right exapansion
443
+ const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);
444
+ if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
445
+ continue;
446
+ }
447
+ // expand only if right side is empty (nothing got wrapped from below)
448
+ let hasData = false;
449
+ for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
450
+ if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
451
+ hasData = true;
452
+ break;
453
+ }
454
+ }
455
+ if (hasData) {
456
+ continue;
457
+ }
458
+ // do right expansion on terminal buffer
459
+ const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
460
+ let lastTile = e.tileId;
461
+ for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
462
+ this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
463
+ imgSpec.tileCount++;
464
+ }
465
+ }
466
+ }
467
+ // store new viewport metrics
468
+ this._viewportMetrics = metrics;
469
+ }
470
+
471
+ /**
472
+ * Retrieve original canvas at buffer position.
473
+ */
474
+ public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
475
+ const buffer = this._terminal._core.buffer;
476
+ const line = buffer.lines.get(y) as IBufferLineExt;
477
+ if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
478
+ const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
479
+ if (e.imageId && e.imageId !== -1) {
480
+ const orig = this._images.get(e.imageId)?.orig;
481
+ if (window.ImageBitmap && orig instanceof ImageBitmap) {
482
+ const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
483
+ canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);
484
+ return canvas;
485
+ }
486
+ return orig as HTMLCanvasElement;
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Extract active single tile at buffer position.
493
+ */
494
+ public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
495
+ const buffer = this._terminal._core.buffer;
496
+ const line = buffer.lines.get(y) as IBufferLineExt;
497
+ if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
498
+ const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
499
+ if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
500
+ const spec = this._images.get(e.imageId);
501
+ if (spec) {
502
+ return this._renderer.extractTile(spec, e.tileId);
503
+ }
504
+ }
505
+ }
506
+ }
507
+
508
+ // TODO: Do we need some blob offloading tricks here to avoid early eviction?
509
+ // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size
510
+ private _evictOldest(room: number): number {
511
+ const used = this._getStoredPixels();
512
+ let current = used;
513
+ while (this._pixelLimit < current + room && this._images.size) {
514
+ const spec = this._images.get(++this._lowestId);
515
+ if (spec && spec.orig) {
516
+ current -= spec.orig.width * spec.orig.height;
517
+ if (spec.actual && spec.orig !== spec.actual) {
518
+ current -= spec.actual.width * spec.actual.height;
519
+ }
520
+ spec.marker?.dispose();
521
+ this._delImg(this._lowestId);
522
+ }
523
+ }
524
+ return used - current;
525
+ }
526
+
527
+ private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
528
+ if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
529
+ const old = line._extendedAttrs[x];
530
+ if (old) {
531
+ if (old.imageId !== undefined) {
532
+ // found an old ExtendedAttrsImage, since we know that
533
+ // they are always isolated instances (single cell usage),
534
+ // we can re-use it and just update their id entries
535
+ const oldSpec = this._images.get(old.imageId);
536
+ if (oldSpec) {
537
+ // early eviction for in-viewport overwrites
538
+ oldSpec.tileCount--;
539
+ }
540
+ old.imageId = imageId;
541
+ old.tileId = tileId;
542
+ return;
543
+ }
544
+ // found a plain ExtendedAttrs instance, clone it to new entry
545
+ line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
546
+ return;
547
+ }
548
+ }
549
+ // fall-through: always create new ExtendedAttrsImage entry
550
+ line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
551
+ line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
552
+ }
553
+
554
+ private _evictOnAlternate(): void {
555
+ // nullify tile count of all images on alternate buffer
556
+ for (const spec of this._images.values()) {
557
+ if (spec.bufferType === 'alternate') {
558
+ spec.tileCount = 0;
559
+ }
560
+ }
561
+ // re-count tiles on whole buffer
562
+ const buffer = this._terminal._core.buffer;
563
+ for (let y = 0; y < this._terminal.rows; ++y) {
564
+ const line = buffer.lines.get(y) as IBufferLineExt;
565
+ if (!line) {
566
+ continue;
567
+ }
568
+ for (let x = 0; x < this._terminal.cols; ++x) {
569
+ if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
570
+ const imgId = line._extendedAttrs[x]?.imageId;
571
+ if (imgId) {
572
+ const spec = this._images.get(imgId);
573
+ if (spec) {
574
+ spec.tileCount++;
575
+ }
576
+ }
577
+ }
578
+ }
579
+ }
580
+ // deleted images with zero tile count
581
+ const zero = [];
582
+ for (const [id, spec] of this._images.entries()) {
583
+ if (spec.bufferType === 'alternate' && !spec.tileCount) {
584
+ spec.marker?.dispose();
585
+ zero.push(id);
586
+ }
587
+ }
588
+ for (const id of zero) {
589
+ this._delImg(id);
590
+ }
591
+ }
592
+ }