diff --git a/src/deck/tile-renderer.ts b/src/deck/tile-renderer.ts index 735d80005..b28acfe46 100644 --- a/src/deck/tile-renderer.ts +++ b/src/deck/tile-renderer.ts @@ -184,6 +184,48 @@ export function iconLayout(w: number, h: number, count: number): Array<{ x: numb return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (size + ICON_GAP), y, size })) } +/** Intrinsic pixel size of a decoded bitmap. Prefers naturalWidth/naturalHeight + * (HTMLImageElement intrinsics) over width/height (layout/attribute values); + * returns 0x0 when unknown — dimensionless (viewBox-only) SVGs report 0. */ +function intrinsicIconSize(bitmap: CanvasImageSource): { width: number; height: number } { + const src = bitmap as { naturalWidth?: unknown; naturalHeight?: unknown; width?: unknown; height?: unknown } + const dim = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0) + const nw = dim(src.naturalWidth) + const nh = dim(src.naturalHeight) + if (nw > 0 && nh > 0) return { width: nw, height: nh } + return { width: dim(src.width), height: dim(src.height) } +} + +/** Aspect-preserving contain fit: the largest centered rect with the bitmap's + * intrinsic aspect ratio inside the square icon slot — icons only ever scale + * SYMMETRICALLY, never stretched to the square. Falls back to the full square + * when the intrinsic size is unknown (dimensionless SVGs report 0x0), so + * explicit destination dims are always available for drawImage. */ +export function containIconRect( + bitmap: CanvasImageSource, + x: number, + y: number, + size: number, +): { x: number; y: number; w: number; h: number } { + const { width, height } = intrinsicIconSize(bitmap) + if (width <= 0 || height <= 0 || width === height) return { x, y, w: size, h: size } + const scale = size / Math.max(width, height) + const w = Math.max(1, Math.round(width * scale)) + const h = Math.max(1, Math.round(height * scale)) + return { x: x + Math.round((size - w) / 2), y: y + Math.round((size - h) / 2), w, h } +} + +/** Draws an icon bitmap contain-fitted into its square slot. ALWAYS uses the + * 5-arg drawImage form with explicit destination width AND height: + * dimensionless (viewBox-only) SVGs draw blank without them (verified headless + * Chromium 145; the server serves dimensionless SVGs first-class - + * repo_icon_detect.rs:51-52). Never call the 3-arg drawImage(image, dx, dy) + * form anywhere in this module. */ +function drawIconContained(ctx: Ctx2D, bitmap: CanvasImageSource, x: number, y: number, size: number): void { + const rect = containIconRect(bitmap, x, y, size) + ctx.drawImage(bitmap, rect.x, rect.y, rect.w, rect.h) +} + /** Ring/border that follows the rounded key frame: a rounded-rect stroke of * `width` px, `inset` px inside the frame edge. (Replaces the old nested * 1px square fillRect frames — strokes keep the rounded shape at corners.) */ @@ -267,11 +309,10 @@ function drawIconsTab(ctx: Ctx2D, w: number, h: number, spec: Extract 0) { // The badge renders horizontally centered inside the row slot reserved diff --git a/test/unit/client/deck/tile-renderer.test.ts b/test/unit/client/deck/tile-renderer.test.ts index 222ad65d0..6714ef1ff 100644 --- a/test/unit/client/deck/tile-renderer.test.ts +++ b/test/unit/client/deck/tile-renderer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { MINI_CAPS, PLUS_CAPS } from '@/deck/fake-deck-device' import { + containIconRect, cropPreviewLines, drawRing, fitLabel, iconLayout, keyFrameGeometry, previewGeometry, renderKey, renderStrip, truncateTitle, APPROVE_COLOR, ACTIVE_COLOR, DISABLED_ACTION_COLOR, EMPTY_BG, PREVIEW_TEXT_COLOR, PREVIEW_BG, RING_COLORS, TILE_BG, TILE_FILL_GREEN, BANNER_FILL, BAR_TOP_BORDER, CONTROL_BG, CONTROL_DIM, STOP_COLOR, @@ -567,6 +568,56 @@ describe('agent pane icons (tab-bar presentation)', () => { }) }) +describe('aspect-preserving icon fit', () => { + // Single-icon slot on the 80x80 Mini: { x: 18, y: 28, size: 45 }. + const slot = iconLayout(80, 80, 1)[0] + + it('containIconRect letterboxes wide bitmaps and pillarboxes tall ones, centered in the slot', () => { + const wide = { width: 200, height: 100 } as unknown as CanvasImageSource + // scale = 45/200 -> 45x23 (round(22.5) = 23), vertically centered. + expect(containIconRect(wide, 10, 20, 45)).toEqual({ x: 10, y: 31, w: 45, h: 23 }) + const tall = { width: 100, height: 200 } as unknown as CanvasImageSource + expect(containIconRect(tall, 10, 20, 45)).toEqual({ x: 21, y: 20, w: 23, h: 45 }) + }) + + it('containIconRect keeps square AND dimensionless bitmaps at the full square (drawn-blank trap)', () => { + const square = { width: 64, height: 64 } as unknown as CanvasImageSource + expect(containIconRect(square, 10, 20, 45)).toEqual({ x: 10, y: 20, w: 45, h: 45 }) + // Dimensionless (viewBox-only) SVGs report 0x0 - explicit square dims MUST survive. + const dimensionless = { naturalWidth: 0, naturalHeight: 0 } as unknown as CanvasImageSource + expect(containIconRect(dimensionless, 10, 20, 45)).toEqual({ x: 10, y: 20, w: 45, h: 45 }) + expect(containIconRect({} as CanvasImageSource, 10, 20, 45)).toEqual({ x: 10, y: 20, w: 45, h: 45 }) + }) + + it('containIconRect prefers intrinsic naturalWidth/naturalHeight over layout width/height', () => { + const img = { naturalWidth: 200, naturalHeight: 100, width: 50, height: 50 } as unknown as CanvasImageSource + expect(containIconRect(img, 0, 0, 45)).toEqual({ x: 0, y: 11, w: 45, h: 23 }) + }) + + it('containIconRect never collapses below 1px on extreme aspect ratios', () => { + const sliver = { width: 1000, height: 10 } as unknown as CanvasImageSource + expect(containIconRect(sliver, 0, 0, 45)).toEqual({ x: 0, y: 22, w: 45, h: 1 }) + }) + + it('a wide repo icon draws contained in its slot, not stretched to the square', () => { + const bitmap = { naturalWidth: 200, naturalHeight: 100 } as unknown as CanvasImageSource + const { images } = renderTab( + tabSpec({ icons: [{ url: '/i/wide', letter: 'W', hue: 120, ready: true }] }), + (url) => (url === '/i/wide' ? bitmap : null), + ) + expect(images).toEqual([{ x: slot.x, y: slot.y + 11, w: 45, h: 23 }]) + }) + + it('a non-square agent pane icon draws contained in its slot', () => { + const bitmap = { width: 32, height: 16 } as unknown as CanvasImageSource + const { images } = renderTab( + tabSpec({ icons: [], paneIcons: [{ provider: 'claude', tint: 'green', ready: true }] }), + () => bitmap, + ) + expect(images).toEqual([{ x: slot.x, y: slot.y + 11, w: 45, h: 23 }]) + }) +}) + describe('ensureRoundRect', () => { it('installs roundRect delegate on stubs lacking it', () => { const recta: unknown[] = []