mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): Alt+wheel to scale the pet, never cropped
Hold Alt/Option and scroll over the mascot to resize it (same on Mac and Windows); the modifier keeps a plain scroll passing through to the page. The gesture drives the same `display.pet.scale` path as the settings slider. The popped-out overlay grows its OS window to fit the pet at any scale (anchored bottom-center) so the sprite is never clipped by the window edge, and the in-window pet re-clamps against its actual size so growing near an edge can't crop it. Also makes the overlay click-through per-pixel: only solid sprite pixels (plus bubble / mail button) are interactive, transparent margins pass clicks through.
This commit is contained in:
parent
27c486e3b1
commit
dd980aaba1
8 changed files with 256 additions and 35 deletions
|
|
@ -6031,19 +6031,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {
|
|||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag: the overlay reports a new absolute screen position (it already knows the
|
||||
// pointer's screen coords), we just move the window.
|
||||
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
|
||||
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
|
||||
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
|
||||
// The window is created non-resizable (no stray edge-drag on the transparent
|
||||
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
|
||||
// sizing — so briefly flip resizable on whenever the size actually changes.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
const win = petOverlayWindow
|
||||
const width = Math.max(80, Math.round(bounds.width))
|
||||
const height = Math.max(80, Math.round(bounds.height))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ import {
|
|||
import { $paneOpen } from '../store/panes'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { setPetScale } from '../store/pet-gallery'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
|
|
@ -939,6 +940,8 @@ export function DesktopController() {
|
|||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
const requestGatewayRef = useRef(requestGateway)
|
||||
requestGatewayRef.current = requestGateway
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
|
|
@ -946,6 +949,9 @@ export function DesktopController() {
|
|||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Alt+wheel resize from the popped-out pet — persist it through this
|
||||
// window's gateway (the overlay has none) so it survives restart.
|
||||
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
|
||||
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
|
||||
// per session) so "most recent" is the right target. main.cjs already raised
|
||||
// the window before forwarding this.
|
||||
|
|
@ -960,6 +966,7 @@ export function DesktopController() {
|
|||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
setPetOverlayScaleHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
||||
import { Mail } from '@/lib/icons'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { overlayWindowSize } from '@/store/pet-overlay'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
// Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values.
|
||||
const DEFAULT_FRAME_W = 192
|
||||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_SCALE = 0.33
|
||||
|
||||
// A sprite pixel counts as "solid" (interactive) at/above this alpha (0-255).
|
||||
// Low enough to catch anti-aliased edges, high enough that the faint halo around
|
||||
// the art still clicks through.
|
||||
const ALPHA_HIT_THRESHOLD = 16
|
||||
|
||||
/**
|
||||
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
|
||||
* composer.
|
||||
|
|
@ -81,12 +93,53 @@ export function PetOverlayApp() {
|
|||
return off
|
||||
}, [])
|
||||
|
||||
// Click-through: make only the sprite (or an open composer) interactive. With
|
||||
// ignore+forward, the renderer still receives mousemove so we can re-enable
|
||||
// hit-testing the moment the cursor returns to the pet.
|
||||
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
|
||||
// button / open composer) interactive — clicks on the transparent rectangle
|
||||
// around the art pass through to whatever's behind. With ignore+forward, the
|
||||
// renderer still receives mousemove so we can re-arm the moment the cursor
|
||||
// returns to a solid pixel.
|
||||
useEffect(() => {
|
||||
setIgnore(true)
|
||||
|
||||
// True when the point sits on a solid sprite pixel or on the pet's other
|
||||
// interactive chrome (bubble, mail button). Over the canvas we sample the
|
||||
// rendered alpha; elsewhere inside the pet (bubble/button) we trust DOM
|
||||
// hit-testing. Anything else is transparent backdrop.
|
||||
const isInteractiveAt = (x: number, y: number): boolean => {
|
||||
const pet = petRef.current
|
||||
const target = document.elementFromPoint(x, y)
|
||||
|
||||
if (!pet || !target || !pet.contains(target)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLCanvasElement)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ctx = target.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return true
|
||||
}
|
||||
|
||||
const px = Math.floor((x - rect.left) * (target.width / rect.width))
|
||||
const py = Math.floor((y - rect.top) * (target.height / rect.height))
|
||||
|
||||
try {
|
||||
return ctx.getImageData(px, py, 1, 1).data[3] >= ALPHA_HIT_THRESHOLD
|
||||
} catch {
|
||||
// Tainted/zero-size read — fail open so the pet stays grabbable.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (dragRef.current || composerOpenRef.current) {
|
||||
setIgnore(false)
|
||||
|
|
@ -94,15 +147,7 @@ export function PetOverlayApp() {
|
|||
return
|
||||
}
|
||||
|
||||
const el = petRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect()
|
||||
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
|
||||
setIgnore(!over)
|
||||
setIgnore(!isInteractiveAt(ev.clientX, ev.clientY))
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
|
|
@ -231,6 +276,52 @@ export function PetOverlayApp() {
|
|||
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
|
||||
}
|
||||
|
||||
// Alt+wheel over the popped-out pet resizes it. The overlay has no gateway,
|
||||
// so paint the new scale locally for instant feedback, then ask the main
|
||||
// renderer to persist it (it pushes the reconciled scale back). The window
|
||||
// itself is grown to fit by the effect below.
|
||||
const onScale = useCallback((next: number) => {
|
||||
setPetInfo({ ...$petInfo.get(), scale: next })
|
||||
window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' })
|
||||
}, [])
|
||||
|
||||
usePetZoomGesture(petRef, onScale, Boolean(info.enabled && info.spritesheetBase64))
|
||||
|
||||
// Grow/shrink the OS overlay window to fit the pet at its current scale so the
|
||||
// sprite is never cropped — covers both the wheel gesture here and a scale
|
||||
// changed from the app's settings slider (pushed in as a state update). The
|
||||
// pet renders bottom-center, so anchor the window's bottom-center while
|
||||
// resizing (it stays put; only the transparent headroom changes). New bounds
|
||||
// are persisted so the pet reopens at the right size.
|
||||
useEffect(() => {
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return
|
||||
}
|
||||
|
||||
const { width, height } = overlayWindowSize(
|
||||
info.frameW ?? DEFAULT_FRAME_W,
|
||||
info.frameH ?? DEFAULT_FRAME_H,
|
||||
info.scale ?? DEFAULT_SCALE
|
||||
)
|
||||
|
||||
const curW = window.outerWidth
|
||||
const curH = window.outerHeight
|
||||
|
||||
if (width === curW && height === curH) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = {
|
||||
height,
|
||||
width,
|
||||
x: Math.round(window.screenX + (curW - width) / 2),
|
||||
y: Math.round(window.screenY + (curH - height))
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds(bounds)
|
||||
window.hermesDesktop?.petOverlay?.control({ bounds, type: 'bounds' })
|
||||
}, [info.enabled, info.spritesheetBase64, info.scale, info.frameW, info.frameH])
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet'
|
||||
import { resetPetGallery } from '@/store/pet-gallery'
|
||||
import { resetPetGallery, setPetScale } from '@/store/pet-gallery'
|
||||
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
|
@ -12,6 +12,7 @@ import { isSecondaryWindow } from '@/store/windows'
|
|||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
import { usePetZoomGesture } from './use-pet-zoom-gesture'
|
||||
|
||||
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
|
||||
// which dragged inverted). Bumping the key discards stale v1 coordinates.
|
||||
|
|
@ -105,6 +106,7 @@ export function FloatingPet() {
|
|||
// speech bubble (a container child) never renders flipped/backwards.
|
||||
const spriteWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
|
||||
const petH = (info.frameH ?? 208) * (info.scale ?? 0.33)
|
||||
// Soft contact shadow, sized off the pet so every scale/species grounds the
|
||||
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
|
||||
const shadowW = Math.round(petW * 0.55)
|
||||
|
|
@ -115,6 +117,16 @@ export function FloatingPet() {
|
|||
// state is only committed on release.
|
||||
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
|
||||
|
||||
// Keep the *whole* pet on-screen at its current size, so growing it near an
|
||||
// edge can't leave the window cropping it. Shared by drag + the reclamp effect.
|
||||
const clamp = useCallback(
|
||||
({ x, y }: Point): Point => ({
|
||||
x: Math.min(Math.max(0, x), Math.max(0, (window.innerWidth || 800) - petW)),
|
||||
y: Math.min(Math.max(0, y), Math.max(0, (window.innerHeight || 600) - petH))
|
||||
}),
|
||||
[petW, petH]
|
||||
)
|
||||
|
||||
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
|
||||
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
|
||||
// and row-count metadata replace the cached base64 payload.
|
||||
|
|
@ -235,12 +247,13 @@ export function FloatingPet() {
|
|||
restorePetOverlay()
|
||||
}, [active])
|
||||
|
||||
// A window resize must never strand the pet off-screen — re-clamp the
|
||||
// committed position (and persist it) whenever the viewport shrinks.
|
||||
// Never strand or crop the pet: re-clamp (and persist) whenever the viewport
|
||||
// shrinks or the pet's own size changes (wheel/slider). `clamp` carries the
|
||||
// current size, so depending on it covers both triggers.
|
||||
useEffect(() => {
|
||||
const onResize = () =>
|
||||
const reclamp = () =>
|
||||
setPosition(prev => {
|
||||
const next = clampToViewport(prev)
|
||||
const next = clamp(prev)
|
||||
|
||||
if (next.x === prev.x && next.y === prev.y) {
|
||||
return prev
|
||||
|
|
@ -251,10 +264,11 @@ export function FloatingPet() {
|
|||
return next
|
||||
})
|
||||
|
||||
window.addEventListener('resize', onResize)
|
||||
reclamp()
|
||||
window.addEventListener('resize', reclamp)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
return () => window.removeEventListener('resize', reclamp)
|
||||
}, [clamp])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = containerRef.current
|
||||
|
|
@ -289,7 +303,7 @@ export function FloatingPet() {
|
|||
return
|
||||
}
|
||||
|
||||
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
|
||||
const next = clamp({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
|
||||
drag.x = next.x
|
||||
drag.y = next.y
|
||||
// Mutate the DOM directly — no setState, so no re-render while dragging. The
|
||||
|
|
@ -302,7 +316,7 @@ export function FloatingPet() {
|
|||
spriteWrapRef.current.style.transform = facing(next.x, petW)
|
||||
}
|
||||
},
|
||||
[petW]
|
||||
[clamp, petW]
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
|
|
@ -323,6 +337,12 @@ export function FloatingPet() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Alt+wheel over the pet resizes it, persisting to `display.pet.scale` through
|
||||
// the same path as the settings slider, so the two agree. Growing it re-runs
|
||||
// the reclamp effect above (via `clamp`), so it never ends up cropped.
|
||||
const onScale = useCallback((next: number) => setPetScale(requestGateway, next), [requestGateway])
|
||||
usePetZoomGesture(containerRef, onScale, active && !overlayActive)
|
||||
|
||||
// While popped out, the desktop overlay window owns the mascot — hide the
|
||||
// in-window one so there aren't two.
|
||||
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {
|
||||
|
|
|
|||
|
|
@ -117,7 +117,9 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
return
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
// willReadFrequently: the pop-out overlay samples this canvas's alpha under
|
||||
// the cursor (per-pixel click-through), so opt into the CPU-readback path.
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
if (!ctx) {
|
||||
return
|
||||
|
|
|
|||
46
apps/desktop/src/components/pet/use-pet-zoom-gesture.ts
Normal file
46
apps/desktop/src/components/pet/use-pet-zoom-gesture.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { $petInfo } from '@/store/pet'
|
||||
import { nextScaleFromWheel } from '@/store/pet-gallery'
|
||||
|
||||
/**
|
||||
* Wire Alt/Option+wheel-to-scale onto a pet element: hold Alt and scroll up to
|
||||
* grow the pet, down to shrink — identical on Mac and Windows. The modifier is
|
||||
* required so a plain scroll over the pet still scrolls the page underneath
|
||||
* (we only `preventDefault` while Alt is held).
|
||||
*
|
||||
* Native + non-passive so the conditional `preventDefault` actually takes (and
|
||||
* keeps Electron from treating the gesture as a page zoom). Scale is read live
|
||||
* from `$petInfo` so rapid steps compound without re-binding the listener.
|
||||
*
|
||||
* `ready` must track whether the pet element is actually rendered (both callers
|
||||
* return null until a pet is enabled). It's a dependency so the listener
|
||||
* (re)binds the moment the element mounts — `ref.current` changing alone would
|
||||
* never re-run the effect.
|
||||
*/
|
||||
export function usePetZoomGesture<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
onScale: (scale: number) => void,
|
||||
ready: boolean
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (!el || !ready) {
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
onScale(nextScaleFromWheel($petInfo.get().scale, event.deltaY))
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [ref, onScale, ready])
|
||||
}
|
||||
|
|
@ -333,6 +333,21 @@ export const PET_SCALE_MAX = 3.0
|
|||
export const PET_SCALE_DEFAULT = 0.33
|
||||
export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n))
|
||||
|
||||
// Wheel → scale. Multiplicative so one notch feels the same at any size. Tuned
|
||||
// for a discrete mouse-wheel notch (deltaY ≈ ±100); trackpad two-finger scroll
|
||||
// (smaller deltas) just resizes more gently, which is fine.
|
||||
const WHEEL_SCALE_K = 0.0015
|
||||
|
||||
/**
|
||||
* Next pet scale for one mouse-wheel step over the pet. Scrolling up (deltaY < 0)
|
||||
* grows it, scrolling down shrinks it; the result is clamped to the slider's range.
|
||||
*/
|
||||
export function nextScaleFromWheel(current: number | undefined, deltaY: number): number {
|
||||
const base = current ?? PET_SCALE_DEFAULT
|
||||
|
||||
return clampPetScale(base * Math.exp(-deltaY * WHEEL_SCALE_K))
|
||||
}
|
||||
|
||||
let scalePersist: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export type PetOverlayControl =
|
|||
| { type: 'bounds'; bounds: PetOverlayBounds }
|
||||
| { type: 'open-app' }
|
||||
| { type: 'toggle-app' }
|
||||
| { type: 'scale'; scale: number }
|
||||
|
||||
// Persisted across restarts: was the pet popped out, and where on the desktop
|
||||
// did the user leave it. Keyed v1; bump if the bounds shape ever changes.
|
||||
|
|
@ -103,10 +104,24 @@ const OVERLAY_PAD_Y = 200
|
|||
const OVERLAY_MIN_W = 240
|
||||
const OVERLAY_MIN_H = 300
|
||||
|
||||
/**
|
||||
* Window bounds (width/height) that fully contain the pet at a given scale, plus
|
||||
* the padding for its bubble/composer/drag margins. The single source of truth
|
||||
* for both the initial pop-out size and the live wheel-to-scale resize, so the
|
||||
* sprite is never cropped by the window edge no matter how big it's scaled.
|
||||
*/
|
||||
export function overlayWindowSize(frameW: number, frameH: number, scale: number): { width: number; height: number } {
|
||||
return {
|
||||
width: Math.max(OVERLAY_MIN_W, Math.round(frameW * scale + OVERLAY_PAD_X)),
|
||||
height: Math.max(OVERLAY_MIN_H, Math.round(frameH * scale + OVERLAY_PAD_Y))
|
||||
}
|
||||
}
|
||||
|
||||
let stateUnsubs: Array<() => void> = []
|
||||
let controlUnsub: (() => void) | null = null
|
||||
let submitHandler: ((text: string) => void) | null = null
|
||||
let openAppHandler: (() => void) | null = null
|
||||
let scaleHandler: ((scale: number) => void) | null = null
|
||||
|
||||
function currentPayload(): PetOverlayStatePayload {
|
||||
return {
|
||||
|
|
@ -173,8 +188,10 @@ export function popOutPet(petRect: PetOverlayBounds): void {
|
|||
return
|
||||
}
|
||||
|
||||
const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X))
|
||||
const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y))
|
||||
// Size the window off the pet's scale (not the measured rect, which includes
|
||||
// the shadow) so it matches the live resize math exactly — no jump on open.
|
||||
const pet = $petInfo.get()
|
||||
const { width, height } = overlayWindowSize(pet.frameW ?? 192, pet.frameH ?? 208, pet.scale ?? 0.33)
|
||||
const x = Math.round(petRect.x - (width - petRect.width) / 2)
|
||||
const y = Math.round(petRect.y - (height - petRect.height) / 2)
|
||||
|
||||
|
|
@ -223,6 +240,11 @@ export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void {
|
|||
openAppHandler = fn
|
||||
}
|
||||
|
||||
/** Register the handler that persists a scale resized via the overlay's Alt+wheel gesture. */
|
||||
export function setPetOverlayScaleHandler(fn: ((scale: number) => void) | null): void {
|
||||
scaleHandler = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the overlay→renderer control channel once. Returns a disposer. Idempotent
|
||||
* — a second call while already wired is a no-op.
|
||||
|
|
@ -245,6 +267,11 @@ export function initPetOverlayBridge(): () => void {
|
|||
} else if (payload?.type === 'bounds' && payload.bounds) {
|
||||
// The user dragged the overlay to a new desktop spot — remember it.
|
||||
saveBounds(payload.bounds)
|
||||
} else if (payload?.type === 'scale' && typeof payload.scale === 'number') {
|
||||
// The user resized the popped-out pet (Alt+wheel) — persist it through
|
||||
// the main renderer's gateway; the new scale rides $petInfo back to the
|
||||
// overlay on the next push, keeping both surfaces in sync.
|
||||
scaleHandler?.(payload.scale)
|
||||
} else if (payload?.type === 'open-app') {
|
||||
// Mail icon: surface the app on the most recent thread (main.cjs already
|
||||
// focused the window before forwarding this) and mark it read.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue