diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index 22ba07ba..260db9ce 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -435,7 +435,7 @@ describe('Electron pet window service', () => { }) }) - it.each(['win32', 'linux'] as const)( + it.each(['darwin', 'win32', 'linux'] as const)( 'restores a %s edge position once the renderer reports the mascot region', async (platform) => { // Dragging clamps against the mascot, so a saved edge position puts the @@ -478,36 +478,6 @@ describe('Electron pet window service', () => { }, ) - it('restores an edge position after the renderer reports the visible mascot region', async () => { - let petWindow: ReturnType | undefined - const createWindow = vi.fn((bounds) => { - petWindow = createFakeWindow(bounds as { - x: number - y: number - width: number - height: number - }) - return petWindow - }) - const controller = new PetWindowController({ - createWindow: createWindow as never, - getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }), - getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }), - load: vi.fn().mockResolvedValue(undefined), - platform: 'darwin', - preloadPath: '/app/electron-dist/preload.cjs', - readPosition: () => ({ x: -136, y: 160 }), - }) - - await controller.show() - expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 0, y: 160 })) - controller.setInteractiveRegions(petWindow as never, [ - { x: 136, y: 240, width: 112, height: 128 }, - ]) - - expect(petWindow?.setPosition).toHaveBeenLastCalledWith(-136, 160, false) - }) - it('tracks the native cursor at 60 Hz without renderer move payloads', async () => { vi.useFakeTimers() try { @@ -805,7 +775,7 @@ describe('Electron pet window service', () => { try { const petWindow = createFakeWindow( { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, - { positionDriftPx: 1 }, + { scaleFactor: 1.15 }, ) let cursor = { x: 150, y: 180 } const controller = new PetWindowController({ @@ -818,6 +788,10 @@ describe('Electron pet window service', () => { preloadPath: '/app/electron-dist/preload.cjs', }) await controller.show() + // Chromium reports a fractionally scaled window back a pixel larger than + // it was created; that rounding is the engine's, not ours. What has to + // hold is that dragging never adds to it. + const { width, height } = petWindow.getBounds() controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) for (let tick = 0; tick < 60; tick += 1) { @@ -826,8 +800,46 @@ describe('Electron pet window service', () => { } controller.dragWindow(petWindow as never, { phase: 'end', ...cursor }) - expect(petWindow.getBounds().width).toBe(PET_WINDOW_WIDTH) - expect(petWindow.getBounds().height).toBe(PET_WINDOW_HEIGHT) + // Without a position assertion this test would also pass for a drag tick + // that moves nothing at all. + expect(petWindow.getBounds()).toEqual({ x: 220, y: 240, width, height }) + } finally { + vi.useRealTimers() + } + }) + + it('stays size-neutral across repeated drags on a fractionally scaled display', async () => { + // Restating a size that was itself read back through the DIP round trip + // re-applies the ceil, so the window grows a pixel per drag even though any + // single drag looks stable. The recorded size has to come from the nominal + // constants the window was created with. + vi.useFakeTimers() + try { + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { scaleFactor: 1.15 }, + ) + let cursor = { x: 150, y: 180 } + const controller = new PetWindowController({ + createWindow: vi.fn(() => petWindow) as never, + getCursorScreenPoint: () => cursor, + getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }), + getWorkAreaForPoint: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }), + load: vi.fn().mockResolvedValue(undefined), + platform: 'win32', + preloadPath: '/app/electron-dist/preload.cjs', + }) + await controller.show() + const { width, height } = petWindow.getBounds() + + for (let drag = 0; drag < 40; drag += 1) { + controller.dragWindow(petWindow as never, { phase: 'start', ...cursor }) + cursor = { x: cursor.x + 4, y: cursor.y + 4 } + vi.advanceTimersByTime(16) + controller.dragWindow(petWindow as never, { phase: 'end', ...cursor }) + } + + expect(petWindow.getBounds()).toMatchObject({ width, height }) } finally { vi.useRealTimers() } diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index c8297363..779a4704 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -137,19 +137,45 @@ export function clampPetWindowPosition( type PetWindowExtent = { width: number; height: number } +function isPositiveExtent(extent: Partial | undefined): boolean { + return typeof extent?.width === 'number' && extent.width > 0 + && typeof extent?.height === 'number' && extent.height > 0 +} + // Renderer regions are measured against the live viewport, so they have to be // clamped to the real content box. Clamping to the nominal constants slices off // whatever sits below it once the content area is taller than expected. +// +// getContentBounds returns an empty rect while the content view is detached, so +// fall through to the live window box rather than to the constants — falling +// back to those would silently reinstate the very clamp this exists to avoid. function petWindowContentExtent(window: PetWindow): PetWindowExtent { - const contentBounds = window.getContentBounds?.() - const width = contentBounds?.width - const height = contentBounds?.height + const candidates = [window.getContentBounds?.(), window.getBounds()] + const measured = candidates.find(isPositiveExtent) return { - width: typeof width === 'number' && width > 0 ? Math.round(width) : PET_WINDOW_WIDTH, - height: typeof height === 'number' && height > 0 ? Math.round(height) : PET_WINDOW_HEIGHT, + width: measured ? Math.round(measured.width) : PET_WINDOW_WIDTH, + height: measured ? Math.round(measured.height) : PET_WINDOW_HEIGHT, } } +/** + * Moves the window without touching its size. + * + * Electron implements setPosition as `SetBounds(Rect(position, GetSize()))`, and + * on Windows both halves of that DIP round trip round up — so on a fractional + * display scale every call grows the window by a pixel. Restating the nominal + * size is idempotent instead: the window is created non-resizable at exactly + * these dimensions, so re-applying them also heals a window that already drifted. + */ +function movePetWindow(window: PetWindow, position: PetWindowPosition): void { + window.setBounds({ + x: position.x, + y: position.y, + width: PET_WINDOW_WIDTH, + height: PET_WINDOW_HEIGHT, + }) +} + function normalizePetWindowRegion(region: Rectangle, extent: PetWindowExtent): Rectangle { const x = Math.max(0, Math.min(extent.width - 1, Math.round(region.x))) const y = Math.max(0, Math.min(extent.height - 1, Math.round(region.y))) @@ -249,7 +275,6 @@ export class PetWindowController { window: PetWindow pointerStart: PetWindowPosition windowStart: PetWindowPosition - size: PetWindowExtent lastPosition: PetWindowPosition } | null = null private dragTimer: ReturnType | null = null @@ -371,7 +396,12 @@ export class PetWindowController { : null if (dragRegion) this.visibleDragRegion = dragRegion - if (platform === 'darwin' && dragRegion) { + // A saved edge position leaves the transparent padding off-screen, so + // creating the window re-clamps it against the whole window and walks the + // mascot inwards by the padding width. Restoring it needs the reported + // region, which only arrives here — and it arrives on every platform, so + // this runs on every platform too. + if (dragRegion) { const requestedPosition = this.pendingRestoredPosition ?? window.getBounds() this.pendingRestoredPosition = null const anchor = { @@ -383,7 +413,7 @@ export class PetWindowController { const nextPosition = clampPetWindowPosition(requestedPosition, workArea, dragRegion) const bounds = window.getBounds() if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) { - window.setPosition(nextPosition.x, nextPosition.y, false) + movePetWindow(window, nextPosition) } } @@ -417,7 +447,6 @@ export class PetWindowController { window, pointerStart: { x: pointerStart.x, y: pointerStart.y }, windowStart: { x: bounds.x, y: bounds.y }, - size: { width: bounds.width, height: bounds.height }, lastPosition: { x: bounds.x, y: bounds.y }, } if (this.options.getCursorScreenPoint) { @@ -479,15 +508,7 @@ export class PetWindowController { && nextPosition.y === drag.lastPosition.y ) return - // Windows implements setPosition as getSize() + setBounds(), and that DIP - // round trip grows the window a pixel at a time on fractional display - // scaling. Restating the size every tick keeps the drag size-neutral. - drag.window.setBounds({ - x: nextPosition.x, - y: nextPosition.y, - width: drag.size.width, - height: drag.size.height, - }) + movePetWindow(drag.window, nextPosition) drag.lastPosition = nextPosition }