Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions lib/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
85 changes: 85 additions & 0 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export class CanvasRenderer {
private cursorBlink: boolean;
private theme: Required<ITheme>;
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[];

Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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;
}
}