diff --git a/lib/renderer.test.ts b/lib/renderer.test.ts index 73b620b6..4b673d4c 100644 --- a/lib/renderer.test.ts +++ b/lib/renderer.test.ts @@ -6,8 +6,8 @@ * Full visual tests are in examples/renderer-demo.html */ -import { describe, expect, test } from 'bun:test'; -import { DEFAULT_THEME } from './renderer'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { CanvasRenderer, DEFAULT_THEME } from './renderer'; describe('CanvasRenderer', () => { describe('Default Theme', () => { @@ -61,4 +61,122 @@ describe('CanvasRenderer', () => { expect(DEFAULT_THEME.cursor).toMatch(hexPattern); }); }); + + describe('Device Pixel Ratio Tracking', () => { + // Capture the listeners that the renderer registers on its matchMedia + // result so the test can fire a fake DPR-change event without depending + // on the test environment's actual matchMedia plumbing. + interface FakeMQL { + media: string; + listeners: Array<() => void>; + addEventListener: (type: string, cb: () => void) => void; + removeEventListener: (type: string, cb: () => void) => void; + } + let originalMatchMedia: typeof window.matchMedia | undefined; + let originalDpr: number; + let fakeMqls: FakeMQL[]; + + const setDpr = (value: number): void => { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value, + }); + }; + + beforeEach(() => { + originalMatchMedia = window.matchMedia; + originalDpr = window.devicePixelRatio; + fakeMqls = []; + (window as unknown as { matchMedia: (q: string) => FakeMQL }).matchMedia = ( + media: string + ): FakeMQL => { + const mql: FakeMQL = { + media, + listeners: [], + addEventListener: (type: string, cb: () => void) => { + if (type === 'change') mql.listeners.push(cb); + }, + removeEventListener: (type: string, cb: () => void) => { + if (type !== 'change') return; + const idx = mql.listeners.indexOf(cb); + if (idx !== -1) mql.listeners.splice(idx, 1); + }, + }; + fakeMqls.push(mql); + return mql; + }; + }); + + afterEach(() => { + if (originalMatchMedia) { + window.matchMedia = originalMatchMedia; + } + setDpr(originalDpr); + }); + + test('captures window.devicePixelRatio at construction', () => { + setDpr(2); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas); + expect(r.getDevicePixelRatio()).toBe(2); + r.dispose(); + }); + + test('honors the explicit devicePixelRatio option', () => { + setDpr(2); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas, { devicePixelRatio: 3 }); + expect(r.getDevicePixelRatio()).toBe(3); + r.dispose(); + }); + + test('subscribes to a matchMedia query for the current DPR', () => { + setDpr(2); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas); + expect(fakeMqls.length).toBe(1); + expect(fakeMqls[0].media).toBe('(resolution: 2dppx)'); + expect(fakeMqls[0].listeners.length).toBe(1); + r.dispose(); + }); + + test('does not subscribe when DPR is pinned via options', () => { + setDpr(2); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas, { devicePixelRatio: 1 }); + expect(fakeMqls.length).toBe(0); + r.dispose(); + }); + + test('updates DPR and re-pins the query on change', () => { + setDpr(1); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas); + expect(fakeMqls.length).toBe(1); + const firstMql = fakeMqls[0]; + expect(firstMql.media).toBe('(resolution: 1dppx)'); + + // Browser-driven DPR change: bump window.devicePixelRatio, fire the + // listener that the renderer registered, then verify the renderer + // both updated its field and re-registered on a query pinned to the + // new ratio. + setDpr(2); + firstMql.listeners[0](); + expect(r.getDevicePixelRatio()).toBe(2); + expect(firstMql.listeners.length).toBe(0); + expect(fakeMqls.length).toBe(2); + expect(fakeMqls[1].media).toBe('(resolution: 2dppx)'); + expect(fakeMqls[1].listeners.length).toBe(1); + r.dispose(); + }); + + test('removes the listener on dispose', () => { + setDpr(1); + const canvas = document.createElement('canvas'); + const r = new CanvasRenderer(canvas); + expect(fakeMqls[0].listeners.length).toBe(1); + r.dispose(); + expect(fakeMqls[0].listeners.length).toBe(0); + }); + }); }); diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..32a4e59d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -100,6 +100,14 @@ export class CanvasRenderer { private cursorBlink: boolean; private theme: Required; private devicePixelRatio: number; + // matchMedia query that fires when the page's effective DPR moves + // off the value we're currently rendering at (browser zoom, dragging + // between monitors with different scales, OS scale change). Each + // MediaQueryList is pinned to one DPR value, so we tear it down and + // re-create it on every change. Held so dispose() can remove the + // listener. + private dprMediaQuery?: MediaQueryList; + private dprChangeHandler?: () => void; private metrics: FontMetrics; private palette: string[]; @@ -153,6 +161,11 @@ export class CanvasRenderer { this.cursorBlink = options.cursorBlink ?? false; this.theme = { ...DEFAULT_THEME, ...options.theme }; this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1; + // Skip live DPR tracking when the caller pinned a value — they're + // explicitly opting out of browser-driven changes. + if (options.devicePixelRatio === undefined) { + this.observeDevicePixelRatio(); + } // Build color palette (16 ANSI colors) this.palette = [ @@ -997,5 +1010,77 @@ export class CanvasRenderer { */ public dispose(): void { this.stopCursorBlink(); + this.unobserveDevicePixelRatio(); + } + + // ========================================================================== + // Device Pixel Ratio Tracking + // ========================================================================== + + /** + * Current effective device pixel ratio. Exposed primarily for tests; the + * renderer manages this internally and rerenders on change. + */ + public getDevicePixelRatio(): number { + return this.devicePixelRatio; + } + + /** + * Listen for browser-driven DPR changes (zoom, monitor moves, OS scale + * change) and update `this.devicePixelRatio` so the next render() picks up + * the new value via its canvas-size mismatch check. + * + * MediaQueryList instances are pinned to the DPR value baked into the + * query string, so when the listener fires we have to tear down and + * re-create the query at the new ratio. + */ + private observeDevicePixelRatio(): void { + // Skip in environments without matchMedia (SSR / minimal test harnesses). + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const handler = (): void => { + const newDpr = window.devicePixelRatio || 1; + // The render loop's needsResize check (in render()) compares the + // canvas backing-store size against `cols * width * DPR`, so just + // updating the field is enough — the next frame detects the + // mismatch and forces a full resize+redraw. + this.devicePixelRatio = newDpr; + // Re-pin the listener to the new ratio. + this.unobserveDevicePixelRatio(); + this.observeDevicePixelRatio(); + }; + + const mql = window.matchMedia(`(resolution: ${this.devicePixelRatio}dppx)`); + // Browsers since 2018 expose addEventListener on MediaQueryList; the + // older addListener API is the fallback. Guard for both so we don't + // throw in older Safari or stripped-down test stubs. + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + } else if (typeof (mql as unknown as { addListener?: unknown }).addListener === 'function') { + (mql as unknown as { addListener: (cb: () => void) => void }).addListener(handler); + } else { + return; + } + + this.dprMediaQuery = mql; + this.dprChangeHandler = handler; + } + + private unobserveDevicePixelRatio(): void { + const mql = this.dprMediaQuery; + const handler = this.dprChangeHandler; + if (mql && handler) { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', handler); + } else if ( + typeof (mql as unknown as { removeListener?: unknown }).removeListener === 'function' + ) { + (mql as unknown as { removeListener: (cb: () => void) => void }).removeListener(handler); + } + } + this.dprMediaQuery = undefined; + this.dprChangeHandler = undefined; } }