diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index feeb9c8e..204d6a39 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed inline image rendering to cap portrait images by height instead of always scaling them to the configured maximum width. + ## [0.74.0] - 2026-05-07 ## [0.73.1] - 2026-05-07 diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 6b25df4e..eb68757d 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -1,6 +1,7 @@ import { allocateImageId, getCapabilities, + getCellDimensions, getImageDimensions, type ImageDimensions, imageFallback, @@ -61,7 +62,10 @@ export class Image implements Component { return this.cachedLines; } - const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60); + const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60)); + const cellDimensions = getCellDimensions(); + const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx)); + const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight; const caps = getCapabilities(); let lines: string[]; @@ -72,6 +76,7 @@ export class Image implements Component { } const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth, + maxHeightCells: maxHeight, imageId: this.imageId, moveCursor: false, }); diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 6a84716c..b355f0f3 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -211,16 +211,43 @@ export function encodeITerm2( return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; } +export interface ImageCellSize { + columns: number; + rows: number; +} + +export function calculateImageCellSize( + imageDimensions: ImageDimensions, + maxWidthCells: number, + maxHeightCells?: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): ImageCellSize { + const maxWidth = Math.max(1, Math.floor(maxWidthCells)); + const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); + const imageWidth = Math.max(1, imageDimensions.widthPx); + const imageHeight = Math.max(1, imageDimensions.heightPx); + + const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth; + const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight; + const scale = Math.min(widthScale, heightScale); + + const scaledWidthPx = imageWidth * scale; + const scaledHeightPx = imageHeight * scale; + const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); + const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + + return { + columns: Math.max(1, Math.min(maxWidth, columns)), + rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), + }; +} + export function calculateImageRows( imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, ): number { - const targetWidthPx = targetWidthCells * cellDimensions.widthPx; - const scale = targetWidthPx / imageDimensions.widthPx; - const scaledHeightPx = imageDimensions.heightPx * scale; - const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); - return Math.max(1, rows); + return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows; } export function getPngDimensions(base64Data: string): ImageDimensions | null { @@ -376,25 +403,25 @@ export function renderImage( } const maxWidth = options.maxWidthCells ?? 80; - const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); + const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); if (caps.images === "kitty") { const sequence = encodeKitty(base64Data, { - columns: maxWidth, - rows, + columns: size.columns, + rows: size.rows, imageId: options.imageId, moveCursor: options.moveCursor, }); - return { sequence, rows, imageId: options.imageId }; + return { sequence, rows: size.rows, imageId: options.imageId }; } if (caps.images === "iterm2") { const sequence = encodeITerm2(base64Data, { - width: maxWidth, + width: size.columns, height: "auto", preserveAspectRatio: options.preserveAspectRatio ?? true, }); - return { sequence, rows }; + return { sequence, rows: size.rows }; } return null; diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index e483b64f..68c576bd 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -312,6 +312,40 @@ describe("Kitty image cursor movement", () => { } }); + it("honors maxHeightCells by reducing rendered width", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 }); + assert.ok(result); + assert.strictEqual(result.rows, 5); + assert.ok(result.sequence.includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("caps Image component height to a square pixel box by default", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 20 }); + try { + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 10 }, + { widthPx: 10, heightPx: 100 }, + ); + const lines = image.render(12); + assert.strictEqual(lines.length, 5); + assert.ok(lines[0].includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + it("places image sequence on first line with empty padding rows", () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCellDimensions({ widthPx: 10, heightPx: 10 });