fix(desktop): keep the dragged pet at the display edge across restarts

8a3ea62b taught dragging to clamp against the mascot rather than the whole
window, so a saved edge position deliberately leaves the transparent padding
off-screen. Recreating the window re-clamps that position against the whole
window again, and the reposition-on-restore step that undoes it was gated on
darwin -- so on Windows and Linux the mascot walked inwards by the padding
width on every hide/show, not just on restart. The gate only existed because
visibleDragRegion used to be darwin-only; the region now arrives everywhere,
so the repositioning runs everywhere.

e24d59fe restated the recorded window size on every drag tick to stop
setPosition from growing the window, but sampled that size from getBounds().
Chromium converts window rects between physical pixels and DIP with ceil in
both directions, so the sampled value has already been rounded up once and
restating it rounds up again: stable within a drag, a pixel per drag across
them. The window is created non-resizable at the nominal size, so restating
the constants is idempotent instead -- and heals a window that already drifted.
Both call sites now go through movePetWindow.

petWindowContentExtent fell back to the nominal constants when
getContentBounds returned an empty rect, which silently reinstates the exact
clamp it exists to avoid. Fall through to the live window box first.

The fake window in the tests returned one object for both getBounds and
getContentBounds, and modelled setBounds as inherently size-neutral -- so
neither "clamp to the content box" nor "size-neutral drag" was actually
proven: reverting either left all 33 tests green. It now models the ceil/ceil
DIP round trip and carries a distinct content box, and covers the previously
untested paths: cross-platform restore, multi-region shapes, the region
normalization bounds, and the getContentBounds fallbacks.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-25 15:56:39 +08:00
parent 5b4e224f2e
commit ced096c894
2 changed files with 85 additions and 52 deletions

View File

@ -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<typeof createFakeWindow> | 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()
}

View File

@ -137,19 +137,45 @@ export function clampPetWindowPosition(
type PetWindowExtent = { width: number; height: number }
function isPositiveExtent(extent: Partial<PetWindowExtent> | 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<typeof setInterval> | 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
}