Skip to content

Commit 091a780

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 091a780

2 files changed

Lines changed: 205 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ export class CanvasRenderer {
100100
private cursorBlink: boolean;
101101
private theme: Required<ITheme>;
102102
private devicePixelRatio: number;
103+
// matchMedia query that fires when the page's effective DPR moves
104+
// off the value we're currently rendering at (browser zoom, dragging
105+
// between monitors with different scales, OS scale change). Each
106+
// MediaQueryList is pinned to one DPR value, so we tear it down and
107+
// re-create it on every change. Held so dispose() can remove the
108+
// listener.
109+
private dprMediaQuery?: MediaQueryList;
110+
private dprChangeHandler?: () => void;
103111
private metrics: FontMetrics;
104112
private palette: string[];
105113

@@ -153,6 +161,11 @@ export class CanvasRenderer {
153161
this.cursorBlink = options.cursorBlink ?? false;
154162
this.theme = { ...DEFAULT_THEME, ...options.theme };
155163
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
164+
// Skip live DPR tracking when the caller pinned a value — they're
165+
// explicitly opting out of browser-driven changes.
166+
if (options.devicePixelRatio === undefined) {
167+
this.observeDevicePixelRatio();
168+
}
156169

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

0 commit comments

Comments
 (0)