From e24d59fe92fbf6fea90688ea6cdeb2e140aa3f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 25 Jul 2026 02:12:10 +0800 Subject: [PATCH] fix(desktop): stop clipping the dragged pet on Windows #1099 #1104 The Windows shape clamp used the nominal PET_WINDOW_HEIGHT constant. Renderer regions are measured against the live viewport, so once the content area was taller than that constant the mascot -- which sits flush with the viewport bottom -- got sliced off from below, while the task badge kept its own unclamped rect and stayed visible. Clamp the shape to the real content box. Dragging also moved the window with setPosition, which Windows resolves as getSize() + setBounds(); that DIP round trip grows the window a pixel at a time on fractional display scaling, and the drag timer fires every 16ms. Restate the recorded size on every tick so dragging stays size-neutral. --- desktop/electron/services/petWindow.test.ts | 124 ++++++++++++++++---- desktop/electron/services/petWindow.ts | 36 +++++- 2 files changed, 131 insertions(+), 29 deletions(-) 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 }