mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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:
parent
5b4e224f2e
commit
ced096c894
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user