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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user