fix(desktop): render pet sprite at devicePixelRatio for sharp HiDPI output

DPR-sized canvas backing store separated from CSS footprint, tracking
zoom/display changes live. Rendering-fix subset of PR #75307; the
overlay-placement feature portion is out of scope here.

Covers the devicePixelRatio half of #83216.
This commit is contained in:
NorethSea
2026-08-14 23:02:52 -07:00
committed by Teknium
parent 18bac64044
commit 34e05c32ec
2 changed files with 74 additions and 6 deletions
@@ -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<typeof vi.fn>
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(<PetSprite info={INFO} />)
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()
+49 -5
View File
@@ -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 (
<canvas
aria-label={info.displayName ? `${info.displayName} pet` : 'pet'}
height={drawH}
height={backingH}
ref={canvasRef}
style={{ height: drawH, width: drawW }}
width={drawW}
width={backingW}
/>
)
}