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.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-25 02:12:10 +08:00
parent cc768b87c2
commit e24d59fe92
2 changed files with 131 additions and 29 deletions

View File

@ -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<string, () => 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<typeof bounds>) => {
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<typeof createFakeWindow>) {
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({

View File

@ -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<typeof setInterval> | 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
}