程序员阿江(Relakkes) 0480d2f1ec fix(desktop): keep the pet task panel clear of the macOS menu bar #1140
Dragging clamps against the mascot alone, so the mascot can reach a
display edge through the window's transparent padding. At the top edge
that means asking for a negative window y on purpose -- and the activity
panel lives in exactly the padding that goes off-screen with it. Measured
against the shipped layout: of a 96px panel, 78px ends up above the work
area, leaving an 18px sliver under the menu bar.

This is not a regression in 8f3a2f092; it is that fix's other half. The
mascot reaching the menu bar and the panel following it off-screen are
the same negative y.

So the panel changes sides instead. The main process is the only side
that knows the window position and the work area, so it decides and the
renderer follows, the way the Codex overlay does it.

Three things that are load-bearing:

- The test is placement-independent -- panel height against the room
  above the mascot -- because the flip frees the very space a
  "does it still fit above?" test would measure next, and would then
  flip back once per frame. A 24px hysteresis covers the boundary.
- Flipping moves the mascot inside the window, so the window moves the
  opposite way to hold it still on screen. Mid-drag that has to rebase
  the drag's window origin too, or the next tick recomputes the pre-flip
  position. A restore needs the same treatment: a saved y belongs to the
  mascot offset it was saved with, and the renderer always starts the
  panel above, so restoring the bare window position would drop the
  mascot by the panel's height on the next launch.
- The renderer only sends drag start and end -- the cursor sampler in
  this process drives everything between -- so a flip decided mid-drag
  has no reply to ride back on and goes out as an event.

The panel box is the union of every reported region past the mascot,
which keeps the IPC payload shape unchanged.

Left and right are deliberately untouched. The panel is 352px wide in a
384px window, so it can only slide +/-16px before the window itself
clips it, while reaching a side edge needs about 120px. Those need the
window to grow or move, which is a different change.

Falsified each layer by reverting it: the placement test, the window
compensation, the drag rebase, and the restore anchor each turn their
own case red.
2026-07-29 17:51:08 +08:00

805 lines
28 KiB
TypeScript

import type {
BrowserWindow,
BrowserWindowConstructorOptions,
Menu,
MenuItemConstructorOptions,
Point,
Rectangle,
} from 'electron'
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
export const PET_WINDOW_WIDTH = 384
export const PET_WINDOW_HEIGHT = 400
export const PET_WINDOW_MARGIN = 24
export const PET_WINDOW_PARTITION = 'cc-haha-pet'
export const PET_WINDOW_STATE_FILE = 'pet-window.json'
const MAX_ABSOLUTE_SCREEN_COORDINATE = 1_000_000
const PET_WINDOW_DRAG_INTERVAL_MS = 16
const PET_WINDOW_SHAPE_PADDING = 12
const failedPetWindowStateWritePaths = new Set<string>()
type PetWindow = BrowserWindow
export type PetContextMenuFactory = {
buildFromTemplate(template: MenuItemConstructorOptions[]): Pick<Menu, 'popup'>
}
export type PetWindowPosition = Pick<Point, 'x' | 'y'>
/**
* A saved position deliberately leaves the transparent padding around the
* mascot off-screen, so it only means anything next to the mascot box it was
* clamped against. Persisting that box too lets the window reopen where the
* drag left it; clamping the bare position against the whole window instead
* lands it a padding-height away, and the renderer then visibly corrects it as
* soon as it reports the live region.
*/
export type PetWindowState = PetWindowPosition & {
region?: Rectangle
}
export type PetWindowDragPayload = PetWindowPosition & {
phase: 'start' | 'move' | 'end'
}
/**
* Which side of the mascot the renderer should hang the activity panel on.
*
* Dragging clamps against the mascot alone so it can reach every display edge
* through the window's transparent padding, which by construction pushes the
* rest of the window off-screen. At the top edge that padding is where the
* panel lives, so it ends up behind the menu bar. Only this process knows the
* window position and the work area, so it picks the side and the renderer
* follows.
*
* Left and right are deliberately not handled here. The panel is wider than the
* mascot by more than the padding that remains beside it, so sliding it back
* on-screen inside a fixed-size window just moves the clipping from the display
* edge to the window edge. Fixing those needs the window itself to grow or
* move, which is a different change.
*/
export type PetPanelPlacement = {
vertical: 'above' | 'below'
}
export const PET_PANEL_DEFAULT_PLACEMENT: PetPanelPlacement = Object.freeze({
vertical: 'above',
})
/** Gap the renderer keeps between the panel and the mascot. */
const PET_PANEL_GAP = 12
/**
* Flipping is sticky, because the flip itself moves the window: the panel
* leaves the space above the mascot, the mascot moves up inside the window, and
* the window drops by that much to hold the mascot still. A bare "does it fit"
* test would then find room again and flip straight back, once per frame.
*/
const PET_PANEL_FLIP_HYSTERESIS = 24
function isFiniteScreenCoordinate(value: unknown): value is number {
return typeof value === 'number'
&& Number.isFinite(value)
&& Math.abs(value) <= MAX_ABSOLUTE_SCREEN_COORDINATE
}
function isPetWindowPosition(value: unknown): value is PetWindowPosition {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
return isFiniteScreenCoordinate(record.x) && isFiniteScreenCoordinate(record.y)
}
function isPetWindowRegion(value: unknown): value is Rectangle {
if (!isPetWindowPosition(value)) return false
const record = value as Record<string, unknown>
return isFiniteScreenCoordinate(record.width)
&& isFiniteScreenCoordinate(record.height)
&& record.width > 0
&& record.height > 0
}
function roundPetWindowRegion(region: Rectangle): Rectangle {
return {
x: Math.round(region.x),
y: Math.round(region.y),
width: Math.round(region.width),
height: Math.round(region.height),
}
}
/**
* Centre of the mascot for the given saved state, used to pick the display the
* window belongs to. Without a region the window centre is the best guess.
*/
function petWindowAnchor(state: PetWindowState): Point {
const region = state.region ?? {
x: 0,
y: 0,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
}
return {
x: state.x + region.x + Math.floor(region.width / 2),
y: state.y + region.y + Math.floor(region.height / 2),
}
}
function resolveHomePath(input: string, homeDir: string): string {
if (input === '~') return homeDir
if (input.startsWith(`~${path.sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
return path.join(homeDir, input.slice(2))
}
return input
}
export function petWindowStatePath(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = os.homedir(),
): string {
const normalizedHome = path.resolve(homeDir)
const configuredRoot = env.CLAUDE_CONFIG_DIR?.trim()
const configRoot = configuredRoot
? path.resolve(resolveHomePath(configuredRoot, normalizedHome))
: path.join(normalizedHome, '.claude')
return path.join(configRoot, 'cc-haha', PET_WINDOW_STATE_FILE)
}
export function readPetWindowPosition(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = os.homedir(),
): PetWindowState | null {
const statePath = petWindowStatePath(env, homeDir)
if (!existsSync(statePath)) return null
try {
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as unknown
if (!isPetWindowPosition(parsed)) return null
// State written before the region was persisted still restores, just
// against the whole window the way it always did.
const region = (parsed as { region?: unknown }).region
return {
x: Math.round(parsed.x),
y: Math.round(parsed.y),
...(isPetWindowRegion(region) ? { region: roundPetWindowRegion(region) } : {}),
}
} catch (error) {
console.error(`[desktop] failed to read pet window state ${statePath}:`, error)
return null
}
}
export function writePetWindowPosition(
state: PetWindowState,
env: NodeJS.ProcessEnv = process.env,
homeDir: string = os.homedir(),
): void {
if (!isPetWindowPosition(state)) return
const statePath = petWindowStatePath(env, homeDir)
const temporaryPath = `${statePath}.${process.pid}.tmp`
try {
mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 })
writeFileSync(temporaryPath, `${JSON.stringify({
x: Math.round(state.x),
y: Math.round(state.y),
...(isPetWindowRegion(state.region)
? { region: roundPetWindowRegion(state.region) }
: {}),
}, null, 2)}\n`, { mode: 0o600 })
renameSync(temporaryPath, statePath)
failedPetWindowStateWritePaths.delete(statePath)
} catch (error) {
rmSync(temporaryPath, { force: true })
if (!failedPetWindowStateWritePaths.has(statePath)) {
failedPetWindowStateWritePaths.add(statePath)
console.error(`[desktop] failed to write pet window state ${statePath}:`, error)
}
}
}
export function clampPetWindowPosition(
position: PetWindowPosition,
workArea: Rectangle,
visibleRegion: Rectangle = {
x: 0,
y: 0,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
},
): PetWindowPosition {
const minX = workArea.x - visibleRegion.x
const minY = workArea.y - visibleRegion.y
const maxX = minX + Math.max(0, workArea.width - visibleRegion.width)
const maxY = minY + Math.max(0, workArea.height - visibleRegion.height)
return {
x: Math.min(Math.max(Math.round(position.x), minX), maxX),
y: Math.min(Math.max(Math.round(position.y), minY), maxY),
}
}
/**
* Bounding box of everything the renderer hangs off the mascot — the activity
* card, its collapse control, the task badge.
*
* The first region is the mascot itself: it is the drag anchor, so it is the
* one box that is *meant* to sit flush against a display edge. Everything after
* it has to stay on-screen to be readable, which is what this box represents.
*/
export function petPanelBounds(regions: Rectangle[]): Rectangle | null {
const attachments = regions.slice(1)
if (attachments.length === 0) return null
const left = Math.min(...attachments.map((region) => region.x))
const top = Math.min(...attachments.map((region) => region.y))
const right = Math.max(...attachments.map((region) => region.x + region.width))
const bottom = Math.max(...attachments.map((region) => region.y + region.height))
return { x: left, y: top, width: right - left, height: bottom - top }
}
/**
* The measurement is deliberately placement-independent: the panel's *height*
* is the same on either side of the mascot, and the mascot's screen position is
* held across a flip, so both sides of the comparison survive the flip they
* decide. Comparing against how much room the panel currently occupies above
* the mascot would instead be self-referential and oscillate.
*/
export function resolvePetPanelPlacement({
windowPosition,
workArea,
mascot,
panel,
previous,
}: {
windowPosition: PetWindowPosition
workArea: Rectangle
mascot: Rectangle
panel: Rectangle | null
previous: PetPanelPlacement
}): PetPanelPlacement {
if (!panel) return PET_PANEL_DEFAULT_PLACEMENT
const required = panel.height + PET_PANEL_GAP
const spaceAbove = windowPosition.y + mascot.y - workArea.y
const threshold = previous.vertical === 'above'
? required
: required + PET_PANEL_FLIP_HYSTERESIS
return { vertical: spaceAbove >= threshold ? 'above' : 'below' }
}
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 candidates = [window.getContentBounds?.(), window.getBounds()]
const measured = candidates.find(isPositiveExtent)
return {
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)))
const right = Math.max(x + 1, Math.min(extent.width, Math.round(region.x + region.width)))
const bottom = Math.max(y + 1, Math.min(extent.height, Math.round(region.y + region.height)))
return { x, y, width: right - x, height: bottom - y }
}
export function getPetWindowBounds(
workArea: Rectangle,
restoredPosition?: PetWindowState | null,
): Rectangle {
if (restoredPosition) {
return {
...clampPetWindowPosition(restoredPosition, workArea, restoredPosition.region),
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
}
}
return {
x: Math.max(
workArea.x,
workArea.x + workArea.width - PET_WINDOW_WIDTH - PET_WINDOW_MARGIN,
),
y: Math.max(
workArea.y,
workArea.y + workArea.height - PET_WINDOW_HEIGHT - PET_WINDOW_MARGIN,
),
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
}
}
export function petWindowOptions(
bounds: Rectangle,
preload: string,
platform: NodeJS.Platform = process.platform,
): BrowserWindowConstructorOptions {
return {
...bounds,
alwaysOnTop: true,
autoHideMenuBar: true,
backgroundColor: '#00000000',
frame: false,
fullscreenable: false,
hasShadow: false,
maximizable: false,
minimizable: false,
resizable: false,
show: false,
// macOS runs a visible window's frame through
// -[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the
// work area back down to its top edge. The mascot sits at the bottom of a
// mostly transparent window, so clamping against it deliberately asks for a
// negative y to push that padding off-screen — and AppKit silently refused,
// stranding the mascot a padding-height below the menu bar. This is the one
// switch that skips that method; clampPetWindowPosition stays the
// authoritative bound on every platform and every edge.
...(platform === 'darwin'
? { enableLargerThanScreen: true }
: { skipTaskbar: true }),
transparent: true,
type: platform === 'darwin' ? 'panel' : undefined,
webPreferences: {
preload,
partition: PET_WINDOW_PARTITION,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
}
}
function configurePetWindow(window: PetWindow, platform: NodeJS.Platform): void {
if (platform === 'darwin') {
window.setIgnoreMouseEvents(true, { forward: true })
} else {
window.setIgnoreMouseEvents(false)
window.setShape([{ x: 0, y: 0, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }])
}
if (platform !== 'darwin') return
window.setAlwaysOnTop(true, 'floating')
window.setVisibleOnAllWorkspaces(true, {
skipTransformProcessType: true,
visibleOnFullScreen: true,
})
}
export type PetWindowControllerOptions = {
createWindow(options: BrowserWindowConstructorOptions): PetWindow
getCursorScreenPoint?(): Point
getCurrentWorkArea(): Rectangle
getWorkAreaForPoint?(point: Point): Rectangle
load(window: PetWindow): Promise<void>
onCreated?(window: PetWindow): void
/**
* Dragging is driven by a cursor sampler in this process, not by renderer
* calls, so a placement that changes mid-drag has no reply to ride back on.
*/
onPanelPlacementChanged?(window: PetWindow, placement: PetPanelPlacement): void
platform?: NodeJS.Platform
preloadPath: string
readPosition?(): PetWindowState | null
writePosition?(state: PetWindowState): void
}
export class PetWindowController {
private window: PetWindow | null = null
private creating: Promise<PetWindow> | null = null
private drag: {
window: PetWindow
pointerStart: PetWindowPosition
windowStart: PetWindowPosition
lastPosition: PetWindowPosition
} | null = null
private dragTimer: ReturnType<typeof setInterval> | null = null
private visibleDragRegion: Rectangle | null = null
private panelBounds: Rectangle | null = null
private panelPlacement: PetPanelPlacement = PET_PANEL_DEFAULT_PLACEMENT
/**
* Screen y the mascot has to keep once the renderer reports its next layout.
*
* Set whenever the mascot is about to move inside the window while the user
* expects it to stay put on screen: a flip moves it by the panel's height, and
* a restart hands the renderer a saved position whose mascot offset belongs to
* whichever side the panel was on when it was saved. Both cases need the
* window to move the opposite way by the same amount.
*/
private pendingMascotAnchorScreenY: number | null = null
private pendingRestoredPosition: PetWindowState | null = null
private readonly options: PetWindowControllerOptions
constructor(options: PetWindowControllerOptions) {
this.options = options
}
private async create(): Promise<PetWindow> {
const restoredPosition = this.options.readPosition?.() ?? null
this.resetPanelState()
this.pendingRestoredPosition = restoredPosition
// A saved position is only meaningful next to the mascot box it was saved
// with, and that box moves when the panel changes sides. The renderer always
// starts the panel above the mascot, so a position saved with it below would
// otherwise drop the mascot by the panel's height on the next launch.
// Restoring where the *mascot* was survives that, and a resized mascot too.
if (restoredPosition?.region) {
this.pendingMascotAnchorScreenY = restoredPosition.y + restoredPosition.region.y
}
const currentWorkArea = restoredPosition && this.options.getWorkAreaForPoint
? this.options.getWorkAreaForPoint(petWindowAnchor(restoredPosition))
: this.options.getCurrentWorkArea()
const window = this.options.createWindow(petWindowOptions(
getPetWindowBounds(currentWorkArea, restoredPosition),
this.options.preloadPath,
this.options.platform,
))
this.window = window
window.on('closed', () => {
this.finishDrag(window)
if (this.window === window) {
this.window = null
this.resetPanelState()
this.pendingRestoredPosition = null
}
})
try {
configurePetWindow(window, this.options.platform ?? process.platform)
this.options.onCreated?.(window)
await this.options.load(window)
return window
} catch (error) {
if (!window.isDestroyed()) window.destroy()
if (this.window === window) {
this.window = null
this.resetPanelState()
this.pendingRestoredPosition = null
}
throw error
}
}
private ensureWindow(): Promise<PetWindow> {
if (this.window && !this.window.isDestroyed()) {
return Promise.resolve(this.window)
}
if (this.creating) return this.creating
const creating = this.create()
this.creating = creating
void creating.finally(() => {
if (this.creating === creating) this.creating = null
}).catch(() => undefined)
return creating
}
async show(): Promise<void> {
const window = await this.ensureWindow()
if (!window.isVisible()) {
if ((this.options.platform ?? process.platform) === 'darwin') {
window.setIgnoreMouseEvents(true, { forward: true })
}
window.showInactive()
if ((this.options.platform ?? process.platform) === 'darwin') {
window.setAlwaysOnTop(true, 'floating')
} else {
window.setAlwaysOnTop(true)
}
}
}
hide(): void {
const window = this.window
this.finishDrag(window ?? undefined)
if (!window || window.isDestroyed()) {
this.window = null
return
}
window.destroy()
this.window = null
this.resetPanelState()
this.pendingRestoredPosition = null
}
private resetPanelState(): void {
this.visibleDragRegion = null
this.panelBounds = null
this.panelPlacement = PET_PANEL_DEFAULT_PLACEMENT
this.pendingMascotAnchorScreenY = null
}
owns(window: PetWindow | null): boolean {
return window !== null && this.window === window && !window.isDestroyed()
}
setIgnoreMouseEvents(window: PetWindow, ignore: boolean): void {
if (!this.owns(window)) {
throw new Error('Pet window IPC sender does not own the companion window')
}
if ((this.options.platform ?? process.platform) !== 'darwin') return
window.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined)
}
setInteractiveRegions(window: PetWindow, regions: Rectangle[]): PetPanelPlacement {
if (!this.owns(window)) {
throw new Error('Pet window IPC sender does not own the companion window')
}
const platform = this.options.platform ?? process.platform
const extent = petWindowContentExtent(window)
const primaryRegion = regions[0]
// The window is mostly transparent padding around the mascot, so dragging
// has to clamp against the mascot on every platform. Clamping against the
// whole window stops the mascot short of the display edges.
const dragRegion = primaryRegion
? normalizePetWindowRegion(primaryRegion, extent)
: null
if (dragRegion) this.visibleDragRegion = dragRegion
const panel = petPanelBounds(regions)
this.panelBounds = panel ? normalizePetWindowRegion(panel, extent) : null
// 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 bounds = window.getBounds()
const restoredPosition = this.pendingRestoredPosition
this.pendingRestoredPosition = null
const requestedPosition = this.holdMascotAnchor(dragRegion, restoredPosition ?? bounds)
// The live region beats whatever was saved: the mascot may have been
// resized since, and this is the first measurement of the real one.
const anchor = petWindowAnchor({ ...requestedPosition, region: dragRegion })
const workArea = this.options.getWorkAreaForPoint?.(anchor)
?? this.options.getCurrentWorkArea()
const nextPosition = clampPetWindowPosition(requestedPosition, workArea, dragRegion)
if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) {
movePetWindow(window, nextPosition)
}
this.updatePanelPlacement(window, nextPosition, workArea, dragRegion)
}
if (platform === 'darwin') return this.panelPlacement
const shape = regions.map((region) => normalizePetWindowRegion({
x: region.x - PET_WINDOW_SHAPE_PADDING,
y: region.y - PET_WINDOW_SHAPE_PADDING,
width: region.width + PET_WINDOW_SHAPE_PADDING * 2,
height: region.height + PET_WINDOW_SHAPE_PADDING * 2,
}, extent))
if (shape.length > 0) window.setShape(shape)
return this.panelPlacement
}
/**
* Rebases the window on the screen position the mascot has to keep.
*
* The reported region is the first news of where the mascot actually sits
* inside the window, so this is the point where a flip or a restore can be
* turned into a window move that leaves the mascot where the user last saw it.
*/
private holdMascotAnchor(
mascot: Rectangle,
requestedPosition: PetWindowPosition,
): PetWindowPosition {
const anchorScreenY = this.pendingMascotAnchorScreenY
if (anchorScreenY === null) return requestedPosition
this.pendingMascotAnchorScreenY = null
const compensated = { x: requestedPosition.x, y: anchorScreenY - mascot.y }
const drag = this.drag
if (drag) {
// A drag maps pointer travel from a fixed window origin, so the origin has
// to absorb the flip too — otherwise the next tick recomputes the pre-flip
// position and drags the mascot straight back.
drag.windowStart = {
...drag.windowStart,
y: drag.windowStart.y + compensated.y - requestedPosition.y,
}
}
return compensated
}
private updatePanelPlacement(
window: PetWindow,
windowPosition: PetWindowPosition,
workArea: Rectangle,
mascot: Rectangle,
): void {
const previous = this.panelPlacement
const next = resolvePetPanelPlacement({
windowPosition,
workArea,
mascot,
panel: this.panelBounds,
previous,
})
if (next.vertical === previous.vertical) return
this.pendingMascotAnchorScreenY = windowPosition.y + mascot.y
this.panelPlacement = next
this.options.onPanelPlacementChanged?.(window, next)
}
dragWindow(window: PetWindow, payload: PetWindowDragPayload): PetPanelPlacement {
if (!this.owns(window)) {
throw new Error('Pet window IPC sender does not own the companion window')
}
if (!isFiniteScreenCoordinate(payload.x) || !isFiniteScreenCoordinate(payload.y)) {
throw new Error('Pet window drag coordinates must be finite screen coordinates')
}
if (payload.phase === 'start') {
this.finishDrag()
const bounds = window.getBounds()
const sampledPointer = this.options.getCursorScreenPoint?.()
const pointerStart = sampledPointer && isPetWindowPosition(sampledPointer)
? sampledPointer
: payload
this.drag = {
window,
pointerStart: { x: pointerStart.x, y: pointerStart.y },
windowStart: { x: bounds.x, y: bounds.y },
lastPosition: { x: bounds.x, y: bounds.y },
}
if (this.options.getCursorScreenPoint) {
this.dragTimer = setInterval(() => this.sampleDragPosition(), PET_WINDOW_DRAG_INTERVAL_MS)
}
return this.panelPlacement
}
const drag = this.drag
if (!drag || drag.window !== window) {
throw new Error('Pet window drag has not started')
}
const payloadPosition = { x: payload.x, y: payload.y }
const cursorPosition = payload.phase === 'end'
? this.readCursorScreenPoint() ?? payloadPosition
: payloadPosition
this.updateDragPosition(drag, cursorPosition)
if (payload.phase === 'end') {
this.finishDrag(window)
}
return this.panelPlacement
}
private readCursorScreenPoint(): PetWindowPosition | null {
const point = this.options.getCursorScreenPoint?.()
return point && isPetWindowPosition(point)
? { x: point.x, y: point.y }
: null
}
private sampleDragPosition(): void {
const drag = this.drag
if (!drag || drag.window.isDestroyed()) {
this.finishDrag(drag?.window)
return
}
const point = this.readCursorScreenPoint()
if (point) this.updateDragPosition(drag, point)
}
private updateDragPosition(
drag: NonNullable<PetWindowController['drag']>,
pointer: PetWindowPosition,
): void {
const requestedPosition = {
x: drag.windowStart.x + pointer.x - drag.pointerStart.x,
y: drag.windowStart.y + pointer.y - drag.pointerStart.y,
}
const workArea = this.options.getWorkAreaForPoint?.(pointer)
?? this.options.getCurrentWorkArea()
const nextPosition = clampPetWindowPosition(
requestedPosition,
workArea,
this.visibleDragRegion ?? undefined,
)
if (
nextPosition.x === drag.lastPosition.x
&& nextPosition.y === drag.lastPosition.y
) return
movePetWindow(drag.window, nextPosition)
drag.lastPosition = nextPosition
// Dragging is the only way the mascot reaches an edge, so it is also where
// the panel runs out of room. Most ticks here come from the cursor sampler
// rather than a renderer call, so this reaches the renderer as an event.
if (this.visibleDragRegion) {
this.updatePanelPlacement(drag.window, nextPosition, workArea, this.visibleDragRegion)
}
}
private finishDrag(window?: PetWindow): void {
const drag = this.drag
if (window && drag && drag.window !== window) return
if (this.dragTimer) {
clearInterval(this.dragTimer)
this.dragTimer = null
}
if (!drag) return
this.drag = null
// The saved position only reconstructs where the mascot was if the box it
// was clamped against travels with it.
this.options.writePosition?.({
...drag.lastPosition,
...(this.visibleDragRegion ? { region: this.visibleDragRegion } : {}),
})
}
showContextMenu(
window: PetWindow,
closeLabel: string,
menuFactory: PetContextMenuFactory,
): Promise<boolean> {
if (!this.owns(window)) {
return Promise.reject(new Error('Pet window IPC sender does not own the companion window'))
}
return new Promise<boolean>((resolve) => {
let settled = false
const settle = (selected: boolean) => {
if (settled) return
settled = true
resolve(selected)
}
const menu = menuFactory.buildFromTemplate([{
label: closeLabel,
click: () => settle(true),
}])
menu.popup({
window,
callback: () => settle(false),
})
})
}
dispose(): void {
this.finishDrag()
if (this.window && !this.window.isDestroyed()) this.window.destroy()
this.window = null
}
}