mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): zoom the pet toward the cursor
Alt+wheel now scales about the pixel under the pointer instead of growing from a corner, so the pet stays put under the cursor instead of running away. In-window shifts its top-left; the overlay repositions its OS window (cursor-anchored on wheel, bottom-center for slider-driven changes).
This commit is contained in:
parent
dd980aaba1
commit
7d1b72a15d
3 changed files with 83 additions and 33 deletions
|
|
@ -3,7 +3,7 @@ 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 { type PetZoomAnchor, 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'
|
||||
|
|
@ -14,6 +14,10 @@ const DEFAULT_FRAME_W = 192
|
|||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_SCALE = 0.33
|
||||
|
||||
// Must match the root's paddingBottom — the sprite renders bottom-centered, this
|
||||
// many px above the window's bottom edge. Used to anchor the resize.
|
||||
const PET_PADDING_BOTTOM = 24
|
||||
|
||||
// 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.
|
||||
|
|
@ -63,6 +67,9 @@ export function PetOverlayApp() {
|
|||
const [unread, setUnread] = useState(false)
|
||||
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
// Last Alt+wheel anchor, consumed by the resize effect to zoom toward the
|
||||
// cursor; null means a non-wheel scale change (slider) → anchor bottom-center.
|
||||
const zoomAnchorRef = useRef<PetZoomAnchor | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
|
|
@ -278,9 +285,10 @@ export function PetOverlayApp() {
|
|||
|
||||
// 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) => {
|
||||
// renderer to persist it (it pushes the reconciled scale back). Stash the
|
||||
// cursor anchor for the resize effect; the window itself is grown to fit there.
|
||||
const onScale = useCallback((next: number, anchor: PetZoomAnchor) => {
|
||||
zoomAnchorRef.current = anchor
|
||||
setPetInfo({ ...$petInfo.get(), scale: next })
|
||||
window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' })
|
||||
}, [])
|
||||
|
|
@ -289,10 +297,10 @@ export function PetOverlayApp() {
|
|||
|
||||
// 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.
|
||||
// changed from the app's settings slider (pushed in as a state update). With a
|
||||
// wheel anchor we zoom toward the cursor (keep the pixel under it fixed);
|
||||
// otherwise we anchor the bottom-center (the pet's feet stay planted). New
|
||||
// bounds are persisted so the pet reopens at the right size.
|
||||
useEffect(() => {
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return
|
||||
|
|
@ -308,14 +316,28 @@ export function PetOverlayApp() {
|
|||
const curH = window.outerHeight
|
||||
|
||||
if (width === curW && height === curH) {
|
||||
zoomAnchorRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = zoomAnchorRef.current
|
||||
zoomAnchorRef.current = null
|
||||
|
||||
// The sprite's bottom-center sits at (curW/2, curH - paddingBottom) and
|
||||
// scales about that point. Solve for the new window origin that holds either
|
||||
// the cursor pixel (wheel) or that bottom-center (slider) at a fixed spot.
|
||||
const dx = anchor ? anchor.clientX - curW / 2 : 0
|
||||
const dy = anchor ? anchor.clientY - (curH - PET_PADDING_BOTTOM) : 0
|
||||
const ratio = anchor?.ratio ?? 1
|
||||
const anchorX = anchor?.clientX ?? curW / 2
|
||||
const anchorY = anchor?.clientY ?? curH - PET_PADDING_BOTTOM
|
||||
|
||||
const bounds = {
|
||||
height,
|
||||
width,
|
||||
x: Math.round(window.screenX + (curW - width) / 2),
|
||||
y: Math.round(window.screenY + (curH - height))
|
||||
x: Math.round(window.screenX + anchorX - dx * ratio - width / 2),
|
||||
y: Math.round(window.screenY + anchorY - dy * ratio - (height - PET_PADDING_BOTTOM))
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds(bounds)
|
||||
|
|
@ -342,7 +364,7 @@ export function PetOverlayApp() {
|
|||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 24,
|
||||
paddingBottom: PET_PADDING_BOTTOM,
|
||||
userSelect: 'none',
|
||||
width: '100vw'
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ import { isSecondaryWindow } from '@/store/windows'
|
|||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
import { usePetZoomGesture } from './use-pet-zoom-gesture'
|
||||
import { type PetZoomAnchor, 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.
|
||||
const POSITION_KEY = 'hermes.desktop.pet-position.v2'
|
||||
|
||||
// Stand-in pet size for the pre-load clamp (real size flows in with `info`).
|
||||
const NOMINAL_PET_PX = 96
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
|
|
@ -42,11 +45,13 @@ function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
function clampToViewport({ x, y }: Point): Point {
|
||||
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
|
||||
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
|
||||
|
||||
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
|
||||
// Keep a w×h box fully inside the viewport. Pre-pet-load callers pass a nominal
|
||||
// size; the live size flows in once `info` arrives.
|
||||
function clampPoint(x: number, y: number, w: number, h: number): Point {
|
||||
return {
|
||||
x: Math.min(Math.max(0, x), Math.max(0, (window.innerWidth || 800) - w)),
|
||||
y: Math.min(Math.max(0, y), Math.max(0, (window.innerHeight || 600) - h))
|
||||
}
|
||||
}
|
||||
|
||||
// The sprite art faces left by default, so mirror it when the pet's center sits
|
||||
|
|
@ -63,7 +68,7 @@ function loadPosition(): Point {
|
|||
const parsed = JSON.parse(raw) as Point
|
||||
|
||||
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
|
||||
return clampToViewport(parsed)
|
||||
return clampPoint(parsed.x, parsed.y, NOMINAL_PET_PX, NOMINAL_PET_PX)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -71,7 +76,7 @@ function loadPosition(): Point {
|
|||
}
|
||||
|
||||
// Default: lower-left corner (top/left anchored).
|
||||
return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 })
|
||||
return clampPoint(24, (window.innerHeight || 600) - 220, NOMINAL_PET_PX, NOMINAL_PET_PX)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,13 +124,7 @@ export function FloatingPet() {
|
|||
|
||||
// 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]
|
||||
)
|
||||
const clamp = useCallback(({ x, y }: Point): Point => clampPoint(x, y, petW, 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
|
||||
|
|
@ -337,10 +336,27 @@ 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])
|
||||
// Alt+wheel over the pet resizes it (persisted via the same path as the
|
||||
// settings slider). Zoom toward the cursor — shift the top-left so the pixel
|
||||
// under the pointer stays put — so the pet grows in place instead of running
|
||||
// off. The reclamp effect (via `clamp`) still guarantees it stays on-screen.
|
||||
const onScale = useCallback(
|
||||
(next: number, { clientX, clientY, ratio }: PetZoomAnchor) => {
|
||||
setPetScale(requestGateway, next)
|
||||
setPosition(prev => {
|
||||
const at = clampPoint(
|
||||
clientX - (clientX - prev.x) * ratio,
|
||||
clientY - (clientY - prev.y) * ratio,
|
||||
(info.frameW ?? 192) * next,
|
||||
(info.frameH ?? 208) * next
|
||||
)
|
||||
persistString(POSITION_KEY, JSON.stringify(at))
|
||||
|
||||
return at
|
||||
})
|
||||
},
|
||||
[requestGateway, info.frameW, info.frameH]
|
||||
)
|
||||
usePetZoomGesture(containerRef, onScale, active && !overlayActive)
|
||||
|
||||
// While popped out, the desktop overlay window owns the mascot — hide the
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { $petInfo } from '@/store/pet'
|
||||
import { nextScaleFromWheel } from '@/store/pet-gallery'
|
||||
import { nextScaleFromWheel, PET_SCALE_DEFAULT } from '@/store/pet-gallery'
|
||||
|
||||
/** Where the gesture happened + how much it scaled — lets callers zoom toward the
|
||||
* cursor (keep the pixel under it fixed) instead of growing from a corner. */
|
||||
export interface PetZoomAnchor {
|
||||
clientX: number
|
||||
clientY: number
|
||||
/** new / old scale, clamp-aware (1 at a bound, so the pet doesn't drift). */
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire Alt/Option+wheel-to-scale onto a pet element: hold Alt and scroll up to
|
||||
|
|
@ -20,7 +29,7 @@ import { nextScaleFromWheel } from '@/store/pet-gallery'
|
|||
*/
|
||||
export function usePetZoomGesture<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
onScale: (scale: number) => void,
|
||||
onScale: (scale: number, anchor: PetZoomAnchor) => void,
|
||||
ready: boolean
|
||||
): void {
|
||||
useEffect(() => {
|
||||
|
|
@ -36,7 +45,10 @@ export function usePetZoomGesture<T extends HTMLElement>(
|
|||
}
|
||||
|
||||
event.preventDefault()
|
||||
onScale(nextScaleFromWheel($petInfo.get().scale, event.deltaY))
|
||||
|
||||
const base = $petInfo.get().scale ?? PET_SCALE_DEFAULT
|
||||
const next = nextScaleFromWheel(base, event.deltaY)
|
||||
onScale(next, { clientX: event.clientX, clientY: event.clientY, ratio: next / base })
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue