diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx index f197ec49d8..596a53f32e 100644 --- a/apps/desktop/src/components/pet/pet-sprite.test.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -35,6 +35,7 @@ const INFO = { let root: Root | null = null let container: HTMLDivElement | null = null let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null +let drawImage: ReturnType function render(ui: ReactNode) { container = document.createElement('div') @@ -122,6 +123,7 @@ describe('PetSprite RAF scheduling', () => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.useFakeTimers() setVisibility(false) + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 1 }) vi.spyOn(document, 'hasFocus').mockReturnValue(true) installWindowStateBridge() vi.stubGlobal( @@ -132,9 +134,10 @@ describe('PetSprite RAF scheduling', () => { src = '' } as unknown as typeof Image ) + drawImage = vi.fn() vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ clearRect: vi.fn(), - drawImage: vi.fn(), + drawImage, imageSmoothingEnabled: false } as unknown as CanvasRenderingContext2D) }) @@ -170,6 +173,27 @@ describe('PetSprite RAF scheduling', () => { expect(raf.request).toHaveBeenCalledTimes(2) }) + it('uses a DPR-sized backing store while preserving the CSS footprint', () => { + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }) + const raf = installRaf() + + render() + + const canvas = container?.querySelector('canvas') + + expect(canvas).not.toBeNull() + expect(canvas?.width).toBe(32) + expect(canvas?.height).toBe(32) + expect(canvas?.style.width).toBe('16px') + expect(canvas?.style.height).toBe('16px') + + act(() => { + raf.runNext(0) + }) + + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 16, 16, 0, 0, 32, 32) + }) + it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => { const raf = installRaf() diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index 9e79dc9953..4069f4ed25 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' import { $petState, type PetInfo, type PetState } from '@/store/pet' @@ -11,6 +11,47 @@ const DEFAULT_LOOP_MS = 1100 // the configured scale. const DEFAULT_SCALE = 0.33 +function readDevicePixelRatio(): number { + const ratio = window.devicePixelRatio + + return Number.isFinite(ratio) && ratio > 0 ? ratio : 1 +} + +/** + * Track the effective renderer pixel ratio. Electron page zoom and moving a + * window between displays can both change it without remounting the pet. + */ +function useDevicePixelRatio(): number { + const [ratio, setRatio] = useState(readDevicePixelRatio) + + useEffect(() => { + let resolutionQuery: MediaQueryList | null = null + + const update = () => { + resolutionQuery?.removeEventListener('change', update) + + const next = readDevicePixelRatio() + + setRatio(current => (current === next ? current : next)) + + resolutionQuery = typeof window.matchMedia === 'function' ? window.matchMedia(`(resolution: ${next}dppx)`) : null + resolutionQuery?.addEventListener('change', update) + } + + window.addEventListener('resize', update) + window.visualViewport?.addEventListener('resize', update) + update() + + return () => { + resolutionQuery?.removeEventListener('change', update) + window.removeEventListener('resize', update) + window.visualViewport?.removeEventListener('resize', update) + } + }, []) + + return ratio +} + // Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy). export const DEFAULT_STATE_ROWS = [ 'idle', @@ -145,9 +186,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUn const loopMs = info.loopMs ?? DEFAULT_LOOP_MS const scale = (info.scale ?? DEFAULT_SCALE) * zoom const rows = info.stateRows ?? DEFAULT_STATE_ROWS + const pixelRatio = useDevicePixelRatio() const drawW = Math.round(frameW * scale) const drawH = Math.round(frameH * scale) + const backingW = Math.max(1, Math.round(drawW * pixelRatio)) + const backingH = Math.max(1, Math.round(drawH * pixelRatio)) const image = useMemo(() => { if (!info.spritesheetBase64) { @@ -326,7 +370,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUn const sy = row * frameH ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.imageSmoothingEnabled = false - ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH) + ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, backingW, backingH) drawnFrame = frame drawnRow = row } @@ -353,15 +397,15 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUn pauseController?.dispose() unsubState() } - }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused]) + }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, backingW, backingH, rows, pauseWhenUnfocused]) return ( ) }