Skip to content

Commit 90e0c4c

Browse files
sauyonclaude
andcommitted
feat(renderer): track devicePixelRatio changes at runtime
The renderer captured window.devicePixelRatio once in its constructor and never re-checked it. Browser zoom, window drags between monitors with different scales, and OS scale changes all leave the canvas backing store sized to the old ratio — blurry one way, oversharp the other. Subscribe to a matchMedia query pinned to the current DPR and update the field on change. Since each MediaQueryList is bound to one ratio, re-create the query on every change. The render loop's needsResize check picks up the new DPR and forces a full resize+redraw on the next frame, so no explicit render call is needed. Skip the subscription when the caller pinned a DPR via options — they opted out of browser-driven changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6a1a50d commit 90e0c4c

2 files changed

Lines changed: 208 additions & 2 deletions

File tree

lib/renderer.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* Full visual tests are in examples/renderer-demo.html
77
*/
88

9-
import { describe, expect, test } from 'bun:test';
10-
import { DEFAULT_THEME } from './renderer';
9+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10+
import { CanvasRenderer, DEFAULT_THEME } from './renderer';
1111

1212
describe('CanvasRenderer', () => {
1313
describe('Default Theme', () => {
@@ -61,4 +61,122 @@ describe('CanvasRenderer', () => {
6161
expect(DEFAULT_THEME.cursor).toMatch(hexPattern);
6262
});
6363
});
64+
65+
describe('Device Pixel Ratio Tracking', () => {
66+
// Capture the listeners that the renderer registers on its matchMedia
67+
// result so the test can fire a fake DPR-change event without depending
68+
// on the test environment's actual matchMedia plumbing.
69+
interface FakeMQL {
70+
media: string;
71+
listeners: Array<() => void>;
72+
addEventListener: (type: string, cb: () => void) => void;
73+
removeEventListener: (type: string, cb: () => void) => void;
74+
}
75+
let originalMatchMedia: typeof window.matchMedia | undefined;
76+
let originalDpr: number;
77+
let fakeMqls: FakeMQL[];
78+
79+
const setDpr = (value: number): void => {
80+
Object.defineProperty(window, 'devicePixelRatio', {
81+
configurable: true,
82+
value,
83+
});
84+
};
85+
86+
beforeEach(() => {
87+
originalMatchMedia = window.matchMedia;
88+
originalDpr = window.devicePixelRatio;
89+
fakeMqls = [];
90+
(window as unknown as { matchMedia: (q: string) => FakeMQL }).matchMedia = (
91+
media: string
92+
): FakeMQL => {
93+
const mql: FakeMQL = {
94+
media,
95+
listeners: [],
96+
addEventListener: (type: string, cb: () => void) => {
97+
if (type === 'change') mql.listeners.push(cb);
98+
},
99+
removeEventListener: (type: string, cb: () => void) => {
100+
if (type !== 'change') return;
101+
const idx = mql.listeners.indexOf(cb);
102+
if (idx !== -1) mql.listeners.splice(idx, 1);
103+
},
104+
};
105+
fakeMqls.push(mql);
106+
return mql;
107+
};
108+
});
109+
110+
afterEach(() => {
111+
if (originalMatchMedia) {
112+
window.matchMedia = originalMatchMedia;
113+
}
114+
setDpr(originalDpr);
115+
});
116+
117+
test('captures window.devicePixelRatio at construction', () => {
118+
setDpr(2);
119+
const canvas = document.createElement('canvas');
120+
const r = new CanvasRenderer(canvas);
121+
expect(r.getDevicePixelRatio()).toBe(2);
122+
r.dispose();
123+
});
124+
125+
test('honors the explicit devicePixelRatio option', () => {
126+
setDpr(2);
127+
const canvas = document.createElement('canvas');
128+
const r = new CanvasRenderer(canvas, { devicePixelRatio: 3 });
129+
expect(r.getDevicePixelRatio()).toBe(3);
130+
r.dispose();
131+
});
132+
133+
test('subscribes to a matchMedia query for the current DPR', () => {
134+
setDpr(2);
135+
const canvas = document.createElement('canvas');
136+
const r = new CanvasRenderer(canvas);
137+
expect(fakeMqls.length).toBe(1);
138+
expect(fakeMqls[0].media).toBe('(resolution: 2dppx)');
139+
expect(fakeMqls[0].listeners.length).toBe(1);
140+
r.dispose();
141+
});
142+
143+
test('does not subscribe when DPR is pinned via options', () => {
144+
setDpr(2);
145+
const canvas = document.createElement('canvas');
146+
const r = new CanvasRenderer(canvas, { devicePixelRatio: 1 });
147+
expect(fakeMqls.length).toBe(0);
148+
r.dispose();
149+
});
150+
151+
test('updates DPR and re-pins the query on change', () => {
152+
setDpr(1);
153+
const canvas = document.createElement('canvas');
154+
const r = new CanvasRenderer(canvas);
155+
expect(fakeMqls.length).toBe(1);
156+
const firstMql = fakeMqls[0];
157+
expect(firstMql.media).toBe('(resolution: 1dppx)');
158+
159+
// Browser-driven DPR change: bump window.devicePixelRatio, fire the
160+
// listener that the renderer registered, then verify the renderer
161+
// both updated its field and re-registered on a query pinned to the
162+
// new ratio.
163+
setDpr(2);
164+
firstMql.listeners[0]();
165+
expect(r.getDevicePixelRatio()).toBe(2);
166+
expect(firstMql.listeners.length).toBe(0);
167+
expect(fakeMqls.length).toBe(2);
168+
expect(fakeMqls[1].media).toBe('(resolution: 2dppx)');
169+
expect(fakeMqls[1].listeners.length).toBe(1);
170+
r.dispose();
171+
});
172+
173+
test('removes the listener on dispose', () => {
174+
setDpr(1);
175+
const canvas = document.createElement('canvas');
176+
const r = new CanvasRenderer(canvas);
177+
expect(fakeMqls[0].listeners.length).toBe(1);
178+
r.dispose();
179+
expect(fakeMqls[0].listeners.length).toBe(0);
180+
});
181+
});
64182
});

lib/renderer.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ export class CanvasRenderer {
100100
private cursorBlink: boolean;
101101
private theme: Required<ITheme>;
102102
private devicePixelRatio: number;
103+
// True when the caller pinned a DPR via options. We skip live DPR
104+
// tracking in that case — the caller is explicitly opting out of
105+
// browser-driven changes.
106+
private devicePixelRatioPinned: boolean;
107+
// matchMedia query that fires when the page's effective DPR moves
108+
// off the value we're currently rendering at (browser zoom, dragging
109+
// between monitors with different scales, OS scale change). Each
110+
// MediaQueryList is pinned to one DPR value, so we tear it down and
111+
// re-create it on every change. Held so dispose() can remove the
112+
// listener.
113+
private dprMediaQuery?: MediaQueryList;
114+
private dprChangeHandler?: () => void;
103115
private metrics: FontMetrics;
104116
private palette: string[];
105117

@@ -152,7 +164,11 @@ export class CanvasRenderer {
152164
this.cursorStyle = options.cursorStyle ?? 'block';
153165
this.cursorBlink = options.cursorBlink ?? false;
154166
this.theme = { ...DEFAULT_THEME, ...options.theme };
167+
this.devicePixelRatioPinned = options.devicePixelRatio !== undefined;
155168
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
169+
if (!this.devicePixelRatioPinned) {
170+
this.observeDevicePixelRatio();
171+
}
156172

157173
// Build color palette (16 ANSI colors)
158174
this.palette = [
@@ -997,5 +1013,77 @@ export class CanvasRenderer {
9971013
*/
9981014
public dispose(): void {
9991015
this.stopCursorBlink();
1016+
this.unobserveDevicePixelRatio();
1017+
}
1018+
1019+
// ==========================================================================
1020+
// Device Pixel Ratio Tracking
1021+
// ==========================================================================
1022+
1023+
/**
1024+
* Current effective device pixel ratio. Exposed primarily for tests; the
1025+
* renderer manages this internally and rerenders on change.
1026+
*/
1027+
public getDevicePixelRatio(): number {
1028+
return this.devicePixelRatio;
1029+
}
1030+
1031+
/**
1032+
* Listen for browser-driven DPR changes (zoom, monitor moves, OS scale
1033+
* change) and update `this.devicePixelRatio` so the next render() picks up
1034+
* the new value via its canvas-size mismatch check.
1035+
*
1036+
* MediaQueryList instances are pinned to the DPR value baked into the
1037+
* query string, so when the listener fires we have to tear down and
1038+
* re-create the query at the new ratio.
1039+
*/
1040+
private observeDevicePixelRatio(): void {
1041+
// Skip in environments without matchMedia (SSR / minimal test harnesses).
1042+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
1043+
return;
1044+
}
1045+
1046+
const handler = (): void => {
1047+
const newDpr = window.devicePixelRatio || 1;
1048+
// The render loop's needsResize check (in render()) compares the
1049+
// canvas backing-store size against `cols * width * DPR`, so just
1050+
// updating the field is enough — the next frame detects the
1051+
// mismatch and forces a full resize+redraw.
1052+
this.devicePixelRatio = newDpr;
1053+
// Re-pin the listener to the new ratio.
1054+
this.unobserveDevicePixelRatio();
1055+
this.observeDevicePixelRatio();
1056+
};
1057+
1058+
const mql = window.matchMedia(`(resolution: ${this.devicePixelRatio}dppx)`);
1059+
// Browsers since 2018 expose addEventListener on MediaQueryList; the
1060+
// older addListener API is the fallback. Guard for both so we don't
1061+
// throw in older Safari or stripped-down test stubs.
1062+
if (typeof mql.addEventListener === 'function') {
1063+
mql.addEventListener('change', handler);
1064+
} else if (typeof (mql as unknown as { addListener?: unknown }).addListener === 'function') {
1065+
(mql as unknown as { addListener: (cb: () => void) => void }).addListener(handler);
1066+
} else {
1067+
return;
1068+
}
1069+
1070+
this.dprMediaQuery = mql;
1071+
this.dprChangeHandler = handler;
1072+
}
1073+
1074+
private unobserveDevicePixelRatio(): void {
1075+
const mql = this.dprMediaQuery;
1076+
const handler = this.dprChangeHandler;
1077+
if (mql && handler) {
1078+
if (typeof mql.removeEventListener === 'function') {
1079+
mql.removeEventListener('change', handler);
1080+
} else if (
1081+
typeof (mql as unknown as { removeListener?: unknown }).removeListener === 'function'
1082+
) {
1083+
(mql as unknown as { removeListener: (cb: () => void) => void }).removeListener(handler);
1084+
}
1085+
}
1086+
this.dprMediaQuery = undefined;
1087+
this.dprChangeHandler = undefined;
10001088
}
10011089
}

0 commit comments

Comments
 (0)