diff --git a/electron/recorder-window-sizing.test.ts b/electron/recorder-window-sizing.test.ts index 413b048..bbf2ddb 100644 --- a/electron/recorder-window-sizing.test.ts +++ b/electron/recorder-window-sizing.test.ts @@ -72,3 +72,61 @@ test("recorder fitting clamps content height and ignores invalid requests", () = fitRecorderHeight(win, 500); assert.deepEqual(win.size, size); }); + +class FractionalScalingWindow implements RecorderWindowSizingTarget { + destroyed = false; + resizable = false; + size: [number, number] = [401, 500]; + setSizeCalls = 0; + + constructor(private readonly heightRoundingError: number) {} + + isDestroyed(): boolean { + return this.destroyed; + } + + getContentSize(): [number, number] { + const frame: [number, number] = this.resizable ? [16, 39] : [2, 31]; + return [this.size[0] - frame[0], this.size[1] - frame[1]]; + } + + getSize(): [number, number] { + return [...this.size]; + } + + isResizable(): boolean { + return this.resizable; + } + + setResizable(resizable: boolean): void { + this.resizable = resizable; + } + + setSize(width: number, height: number): void { + this.setSizeCalls++; + this.size = [width + 2, height + this.heightRoundingError]; + } +} + +test("recorder fitting prevents width growth across repeated fractional-scale resizing", () => { + const win = new FractionalScalingWindow(3); + + fitRecorderHeight(win, 600); + const widthAfterFirstFit = win.size[0]; + + for (let i = 0; i < 10; i++) { + fitRecorderHeight(win, 600); + } + + assert.equal(win.setSizeCalls, 11); + assert.equal(win.size[0], widthAfterFirstFit); +}); + +test("recorder fitting accepts fractional-scale height differences within tolerance", () => { + const win = new FractionalScalingWindow(2); + + fitRecorderHeight(win, 600); + fitRecorderHeight(win, 600); + + assert.equal(win.setSizeCalls, 1); +}); diff --git a/electron/recorder-window-sizing.ts b/electron/recorder-window-sizing.ts index 27676ac..f0ccdd1 100644 --- a/electron/recorder-window-sizing.ts +++ b/electron/recorder-window-sizing.ts @@ -10,6 +10,9 @@ export interface RecorderWindowSizingTarget { setSize(width: number, height: number, animate?: boolean): void; } +const pinnedOuterWidths = new WeakMap(); +const HEIGHT_TOLERANCE = 2; + /** * Fit the recorder to its rendered content while preserving its fixed outer width. * Windows changes the non-client frame thickness when resizability is toggled, so @@ -26,14 +29,20 @@ export function fitRecorderHeight( Math.max(RECORDER_MIN_HEIGHT, Math.min(RECORDER_MAX_HEIGHT, contentHeight)), ); const [, currentContentHeight] = win.getContentSize(); - if (Math.abs(currentContentHeight - targetContentHeight) < 1) return; + if (Math.abs(currentContentHeight - targetContentHeight) <= HEIGHT_TOLERANCE) return; const [outerWidth, outerHeight] = win.getSize(); + let pinnedWidth = pinnedOuterWidths.get(win); + if (pinnedWidth === undefined) { + pinnedWidth = outerWidth; + pinnedOuterWidths.set(win, pinnedWidth); + } + const targetOuterHeight = outerHeight + targetContentHeight - currentContentHeight; const wasResizable = win.isResizable(); if (!wasResizable) win.setResizable(true); try { - win.setSize(outerWidth, targetOuterHeight); + win.setSize(pinnedWidth, targetOuterHeight); } finally { if (!wasResizable && !win.isDestroyed()) win.setResizable(false); }