diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index 25e7e659..997c0a6c 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -6,6 +6,7 @@ import { PET_WINDOW_HEIGHT, PET_WINDOW_WIDTH, PetWindowController, + type PetWindowPosition, clampPetWindowPosition, getPetWindowBounds, petWindowStatePath, @@ -20,12 +21,17 @@ const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts' const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8') .replace(/\r\n/g, '\n') -function createFakeWindow(initialBounds = { - x: 100, - y: 100, - width: PET_WINDOW_WIDTH, - height: PET_WINDOW_HEIGHT, -}) { +function createFakeWindow( + initialBounds = { + x: 100, + y: 100, + width: PET_WINDOW_WIDTH, + height: PET_WINDOW_HEIGHT, + }, + // Windows resolves setPosition as getSize() + setBounds(), and that DIP round + // trip grows the window by a pixel per call on fractional display scaling. + { positionDriftPx = 0 }: { positionDriftPx?: number } = {}, +) { const handlers = new Map void>() let visible = false let destroyed = false @@ -49,8 +55,12 @@ function createFakeWindow(initialBounds = { setIgnoreMouseEvents: vi.fn(), setShape: vi.fn(), getBounds: vi.fn(() => ({ ...bounds })), + getContentBounds: vi.fn(() => ({ ...bounds })), + setBounds: vi.fn((next: Partial) => { + bounds = { ...bounds, ...next } + }), setPosition: vi.fn((x: number, y: number) => { - bounds = { ...bounds, x, y } + bounds = { ...bounds, x, y, height: bounds.height + positionDriftPx } }), on: vi.fn((event: string, handler: () => void) => { handlers.set(event, handler) @@ -58,6 +68,15 @@ function createFakeWindow(initialBounds = { } } +// Dragging moves the window with setBounds so the size is restated every tick. +function lastDragBounds(window: ReturnType) { + return window.setBounds.mock.calls.at(-1)?.[0] +} + +function draggedTo(position: PetWindowPosition) { + return { ...position, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT } +} + describe('Electron pet window service', () => { it('places fixed companion bounds inside the current display work area', () => { expect(getPetWindowBounds({ x: 1440, y: 25, width: 1920, height: 1055 })).toEqual({ @@ -283,15 +302,14 @@ describe('Electron pet window service', () => { controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) controller.dragWindow(petWindow as never, { phase: 'move', x: 190, y: 220 }) - expect(petWindow.setPosition).toHaveBeenLastCalledWith(140, 160, false) + expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 140, y: 160 })) controller.dragWindow(petWindow as never, { phase: 'end', x: 1_000, y: 900 }) expect(getWorkAreaForPoint).toHaveBeenLastCalledWith({ x: 1_000, y: 900 }) - expect(petWindow.setPosition).toHaveBeenLastCalledWith( - 800 - PET_WINDOW_WIDTH, - 25 + 575 - PET_WINDOW_HEIGHT, - false, - ) + expect(lastDragBounds(petWindow)).toEqual(draggedTo({ + x: 800 - PET_WINDOW_WIDTH, + y: 25 + 575 - PET_WINDOW_HEIGHT, + })) expect(writePosition).toHaveBeenCalledOnce() expect(writePosition).toHaveBeenCalledWith({ x: 800 - PET_WINDOW_WIDTH, @@ -327,11 +345,7 @@ describe('Electron pet window service', () => { controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) controller.dragWindow(petWindow as never, { phase: 'end', ...pointerEnd }) - expect(petWindow.setPosition).toHaveBeenLastCalledWith( - expectedPosition.x, - expectedPosition.y, - false, - ) + expect(lastDragBounds(petWindow)).toEqual(draggedTo(expectedPosition)) }, ) @@ -392,7 +406,7 @@ describe('Electron pet window service', () => { cursor = { x: 203, y: 227 } vi.advanceTimersByTime(16) - expect(petWindow.setPosition).toHaveBeenLastCalledWith(153, 167, false) + expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 153, y: 167 })) expect(writePosition).not.toHaveBeenCalled() controller.dragWindow(petWindow as never, { phase: 'end', x: 203, y: 227 }) @@ -400,10 +414,10 @@ describe('Electron pet window service', () => { expect(writePosition).toHaveBeenCalledWith({ x: 153, y: 167 }) expect(vi.getTimerCount()).toBe(0) - const setPositionCalls = petWindow.setPosition.mock.calls.length + const setBoundsCalls = petWindow.setBounds.mock.calls.length cursor = { x: 260, y: 280 } vi.advanceTimersByTime(32) - expect(petWindow.setPosition).toHaveBeenCalledTimes(setPositionCalls) + expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls) } finally { vi.useRealTimers() } @@ -437,7 +451,7 @@ describe('Electron pet window service', () => { expect(vi.getTimerCount()).toBe(1) cursor = { x: 180, y: 200 } vi.advanceTimersByTime(16) - expect(petWindow.setPosition).toHaveBeenLastCalledWith(130, 140, false) + expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 130, y: 140 })) if (action === 'hide') controller.hide() if (action === 'closed') petWindow.handlers.get('closed')?.() @@ -446,10 +460,10 @@ describe('Electron pet window service', () => { expect(vi.getTimerCount()).toBe(0) expect(writePosition).toHaveBeenCalledOnce() expect(writePosition).toHaveBeenCalledWith({ x: 130, y: 140 }) - const setPositionCalls = petWindow.setPosition.mock.calls.length + const setBoundsCalls = petWindow.setBounds.mock.calls.length cursor = { x: 260, y: 280 } vi.advanceTimersByTime(32) - expect(petWindow.setPosition).toHaveBeenCalledTimes(setPositionCalls) + expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls) } finally { vi.useRealTimers() } @@ -535,6 +549,68 @@ describe('Electron pet window service', () => { ])).toThrow('does not own') }) + it('shapes the Windows pet against the real content size, not the nominal height', async () => { + // A Windows content area can end up taller than the nominal height (DPI + // rounding, invisible frame). The mascot sits flush with the viewport + // bottom, so clamping the shape to the constant would slice its legs off. + const contentHeight = PET_WINDOW_HEIGHT + 40 + const petWindow = createFakeWindow({ + x: 100, + y: 120, + width: PET_WINDOW_WIDTH, + height: contentHeight, + }) + const controller = new PetWindowController({ + createWindow: vi.fn(() => petWindow) as never, + getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }), + load: vi.fn().mockResolvedValue(undefined), + platform: 'win32', + preloadPath: '/app/electron-dist/preload.cjs', + }) + + await controller.show() + controller.setInteractiveRegions(petWindow as never, [ + { x: 144, y: contentHeight - 114, width: 96, height: 104 }, + ]) + + expect(petWindow.setShape).toHaveBeenLastCalledWith([ + { x: 132, y: contentHeight - 126, width: 120, height: 126 }, + ]) + }) + + it('keeps the window size fixed across drag ticks so DIP rounding cannot grow it', async () => { + vi.useFakeTimers() + try { + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { positionDriftPx: 1 }, + ) + 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() + + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + for (let tick = 0; tick < 60; tick += 1) { + cursor = { x: cursor.x + 2, y: cursor.y + 2 } + vi.advanceTimersByTime(16) + } + controller.dragWindow(petWindow as never, { phase: 'end', ...cursor }) + + expect(petWindow.getBounds().width).toBe(PET_WINDOW_WIDTH) + expect(petWindow.getBounds().height).toBe(PET_WINDOW_HEIGHT) + } finally { + vi.useRealTimers() + } + }) + it('returns whether the native pet context menu close item was selected', async () => { const petWindow = createFakeWindow() const controller = new PetWindowController({ diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index fe96aae4..ba202b12 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -135,6 +135,21 @@ export function clampPetWindowPosition( } } +type PetWindowExtent = { width: number; height: number } + +// Renderer regions are measured against the live viewport, so the shape has 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. +function petWindowContentExtent(window: PetWindow): PetWindowExtent { + const contentBounds = window.getContentBounds?.() + const width = contentBounds?.width + const height = contentBounds?.height + 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, + } +} + function normalizePetWindowRegion(region: Rectangle): Rectangle { const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, Math.round(region.x))) const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, Math.round(region.y))) @@ -234,6 +249,7 @@ export class PetWindowController { window: PetWindow pointerStart: PetWindowPosition windowStart: PetWindowPosition + size: PetWindowExtent lastPosition: PetWindowPosition } | null = null private dragTimer: ReturnType | null = null @@ -369,15 +385,16 @@ export class PetWindowController { if (platform === 'darwin') return + const extent = petWindowContentExtent(window) const shape = regions.flatMap((region) => { const requestedLeft = Math.round(region.x) - PET_WINDOW_SHAPE_PADDING const requestedTop = Math.round(region.y) - PET_WINDOW_SHAPE_PADDING const requestedRight = Math.round(region.x + region.width) + PET_WINDOW_SHAPE_PADDING const requestedBottom = Math.round(region.y + region.height) + PET_WINDOW_SHAPE_PADDING - const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, requestedLeft)) - const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, requestedTop)) - const right = Math.max(x + 1, Math.min(PET_WINDOW_WIDTH, requestedRight)) - const bottom = Math.max(y + 1, Math.min(PET_WINDOW_HEIGHT, requestedBottom)) + const x = Math.max(0, Math.min(extent.width - 1, requestedLeft)) + const y = Math.max(0, Math.min(extent.height - 1, requestedTop)) + const right = Math.max(x + 1, Math.min(extent.width, requestedRight)) + const bottom = Math.max(y + 1, Math.min(extent.height, requestedBottom)) return [{ x, y, width: right - x, height: bottom - y }] }) if (shape.length > 0) window.setShape(shape) @@ -402,6 +419,7 @@ 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) { @@ -463,7 +481,15 @@ export class PetWindowController { && nextPosition.y === drag.lastPosition.y ) return - drag.window.setPosition(nextPosition.x, nextPosition.y, false) + // 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, + }) drag.lastPosition = nextPosition }