From 21f51564d1713dab5179eaaf15b1d6005152ce83 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sat, 14 Feb 2026 20:55:55 -0800 Subject: [PATCH] Fix image layer stacking + tile survival across text write --- addons/addon-image/src/ImageRenderer.ts | 5 ++ addons/addon-image/src/ImageStorage.ts | 72 +++++++++++-------- .../src/kitty/KittyGraphicsHandler.ts | 7 +- addons/addon-image/test/KittyGraphics.test.ts | 4 +- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index 8e87e386..2d10a269 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -348,6 +348,11 @@ export class ImageRenderer extends Disposable implements IDisposable { screenElement.style.zIndex = '0'; screenElement.insertBefore(canvas, screenElement.firstChild); } else { + // Explicit z-index ensures the image canvas reliably stacks above + // the text layer (DOM renderer rows). z-index: 0 is below the + // selection overlay (z-index: 1). + canvas.style.zIndex = '0'; + screenElement.style.zIndex = '0'; screenElement.appendChild(canvas); } const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 2ad1c699..2809c0fb 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -415,46 +415,56 @@ export class ImageStorage implements IDisposable { const placeholderCalls: { col: number, row: number, count: number }[] = []; // walk all cells in viewport and collect tiles found + // Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag) + // because text writes clear the BG flag but leave image tile data intact. + // This lets top-layer images survive text overwrites (kitty C=1 behavior). 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) { + let e: IExtendedAttrsImage; if (line.getBg(col) & BgFlags.HAS_EXTENDED) { - let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS; - const imageId = e.imageId; - if (imageId === undefined || imageId === -1) { + e = line._extendedAttrs[col] ?? EMPTY_ATTRS; + } else { + const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; + if (!maybeImg || maybeImg.imageId === undefined || maybeImg.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++; + e = maybeImg; + } + 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 + * Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. + */ + while (++col < cols) { + const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; + if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { + break; } - col--; - if (imgSpec) { - if (imgSpec.actual) { - drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); - } - } else if (this._opts.showPlaceholder) { - placeholderCalls.push({ col: startCol, row, count }); - } - this._fullyCleared = false; + e = nextE; + count++; } + col--; + if (imgSpec) { + if (imgSpec.actual) { + drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); + } + } else if (this._opts.showPlaceholder) { + placeholderCalls.push({ col: startCol, row, count }); + } + this._fullyCleared = false; } } } diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 8fc096dc..a6313526 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -571,10 +571,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos const savedYbase = buffer.ybase; // Determine layer based on z-index: negative = behind text, 0+ = on top. - // Bottom layer only works when allowTransparency is enabled (otherwise text - // canvas background is opaque and hides the bottom canvas). Fall back to top. + // When z<0 we always use the bottom layer even without allowTransparency — + // the image will simply be hidden behind the opaque text background, which + // is the correct behavior (client asked for "behind text"). const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0; - const layer: ImageLayer = (wantsBottom && this._coreTerminal.options.allowTransparency) ? 'bottom' : 'top'; + const layer: ImageLayer = wantsBottom ? 'bottom' : 'top'; const zIndex = cmd.zIndex ?? 0; if (w !== bitmap.width || h !== bitmap.height) { diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index adccd9db..ced1f52d 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -1116,12 +1116,12 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 1); }); - test('z=-1 falls back to top layer when allowTransparency is disabled', async () => { + test('z=-1 uses bottom layer even when allowTransparency is disabled', async () => { await ctx.page.evaluate(`window.term.options.allowTransparency = false`); await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`); await timeout(100); strictEqual(await getImageStorageLength(), 1); - strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top'); + strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom'); strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1); });