diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5d1f35ba450..9974ead119a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index d36e63fe74f..c1e731f8d68 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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) } }, []) diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index 1fcd21169f0..062001fbb49 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -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 } diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 2ceba5ac0c8..424a55c4e3d 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -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(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 ` 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) { diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index 455f6a956aa..fe2e0a6daaf 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -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 diff --git a/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts new file mode 100644 index 00000000000..0b10864e3ea --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts @@ -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( + ref: RefObject, + 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]) +} diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index d5aa9ea7d52..40d629552fe 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -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 | undefined /** diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 3fda5e83b3c..1ab1b64ad23 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -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.