Skip to content

Commit 55ee6e5

Browse files
committed
feat(desktop): improve floating pet controls
1 parent 971d765 commit 55ee6e5

21 files changed

Lines changed: 375 additions & 20 deletions

File tree

apps/electron/src/renderer/components/pet/DesktopPet.tsx

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@ import {
33
useEffect,
44
useRef,
55
useState,
6+
type MouseEvent as ReactMouseEvent,
67
type PointerEvent as ReactPointerEvent,
78
} from 'react';
8-
import { ChevronDown, ChevronUp } from 'lucide-react';
9+
import { useTranslation } from 'react-i18next';
10+
import { ChevronDown, ChevronUp, Maximize2 } from 'lucide-react';
911
import { usePetCompanion } from '@/pets/usePetCompanion';
1012
import { usePetActivityState } from '@/pets/usePetActivityState';
1113
import { usePetNotifications } from '@/pets/usePetNotifications';
14+
import { normalizePetSize } from '@/pets/pet-size';
1215
import { PetNotifications } from './PetNotifications';
1316
import { QwenPet } from './QwenPet';
1417

1518
function ignoreDragError(promise: Promise<void> | undefined): void {
1619
void promise?.catch(() => {});
1720
}
1821

22+
type ResizeState = {
23+
pointerId: number;
24+
startScreenX: number;
25+
startScreenY: number;
26+
startSize: number;
27+
};
28+
1929
/**
2030
* Fills the transparent, always-on-top pet window. Everything is clustered at
2131
* the bottom-right: notification cards stack just above a small toggle, which
@@ -27,13 +37,22 @@ function ignoreDragError(promise: Promise<void> | undefined): void {
2737
* and the toggle are interactive; everything else passes through to the desktop.
2838
*/
2939
export function DesktopPet() {
30-
const { selectedPet, petEnabled } = usePetCompanion();
40+
const { t } = useTranslation();
41+
const { selectedPet, petEnabled, petSize, setPetEnabled, setPetSize } =
42+
usePetCompanion();
3143
const state = usePetActivityState();
3244
const { items, dismiss } = usePetNotifications();
3345
const [collapsed, setCollapsed] = useState(false);
46+
const [resizePreview, setResizePreview] = useState<number | null>(null);
47+
const [contextMenu, setContextMenu] = useState<{
48+
x: number;
49+
y: number;
50+
} | null>(null);
3451

3552
const ignoringRef = useRef(true);
3653
const draggingRef = useRef(false);
54+
const resizingRef = useRef<ResizeState | null>(null);
55+
const resizePreviewRef = useRef<number | null>(null);
3756

3857
const setIgnore = useCallback((ignore: boolean) => {
3958
if (ignore === ignoringRef.current) return;
@@ -44,21 +63,23 @@ export function DesktopPet() {
4463
useEffect(() => {
4564
setIgnore(true);
4665
const onMove = (event: MouseEvent) => {
47-
if (draggingRef.current) return;
66+
if (draggingRef.current || resizingRef.current) return;
4867
const el = document.elementFromPoint(event.clientX, event.clientY);
4968
const interactive = !!el?.closest?.('[data-pet-interactive]');
69+
if (contextMenu && !interactive) setContextMenu(null);
5070
setIgnore(!interactive);
5171
};
5272
window.addEventListener('mousemove', onMove);
5373
return () => {
5474
window.removeEventListener('mousemove', onMove);
5575
ignoreDragError(window.electronAPI?.petWindowSetIgnoreMouse?.(false));
5676
};
57-
}, [setIgnore]);
77+
}, [contextMenu, setIgnore]);
5878

5979
const onPointerDown = useCallback(
6080
(event: ReactPointerEvent<HTMLDivElement>) => {
6181
if (event.button !== 0) return;
82+
setContextMenu(null);
6283
draggingRef.current = true;
6384
event.currentTarget.setPointerCapture(event.pointerId);
6485
ignoreDragError(
@@ -89,8 +110,84 @@ export function DesktopPet() {
89110
[],
90111
);
91112

113+
const updateResizePreview = useCallback((size: number) => {
114+
const normalized = normalizePetSize(size);
115+
resizePreviewRef.current = normalized;
116+
setResizePreview(normalized);
117+
}, []);
118+
119+
const onResizePointerDown = useCallback(
120+
(event: ReactPointerEvent<HTMLButtonElement>) => {
121+
if (event.button !== 0) return;
122+
event.preventDefault();
123+
event.stopPropagation();
124+
setContextMenu(null);
125+
resizingRef.current = {
126+
pointerId: event.pointerId,
127+
startScreenX: event.screenX,
128+
startScreenY: event.screenY,
129+
startSize: resizePreviewRef.current ?? petSize,
130+
};
131+
event.currentTarget.setPointerCapture(event.pointerId);
132+
updateResizePreview(resizePreviewRef.current ?? petSize);
133+
},
134+
[petSize, updateResizePreview],
135+
);
136+
137+
const onPetContextMenu = useCallback(
138+
(event: ReactMouseEvent<HTMLDivElement>) => {
139+
event.preventDefault();
140+
event.stopPropagation();
141+
setContextMenu({
142+
x: Math.min(Math.max(8, event.clientX), window.innerWidth - 100),
143+
y: Math.min(Math.max(8, event.clientY), window.innerHeight - 44),
144+
});
145+
},
146+
[],
147+
);
148+
149+
const onClosePet = useCallback(() => {
150+
setContextMenu(null);
151+
setPetEnabled(false);
152+
ignoreDragError(window.electronAPI?.setPetWindowEnabled?.(false));
153+
}, [setPetEnabled]);
154+
155+
const onResizePointerMove = useCallback(
156+
(event: ReactPointerEvent<HTMLButtonElement>) => {
157+
const resize = resizingRef.current;
158+
if (!resize || resize.pointerId !== event.pointerId) return;
159+
event.preventDefault();
160+
event.stopPropagation();
161+
const deltaX = event.screenX - resize.startScreenX;
162+
const deltaY = event.screenY - resize.startScreenY;
163+
const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
164+
updateResizePreview(resize.startSize + delta);
165+
},
166+
[updateResizePreview],
167+
);
168+
169+
const onResizePointerEnd = useCallback(
170+
(event: ReactPointerEvent<HTMLButtonElement>) => {
171+
const resize = resizingRef.current;
172+
if (!resize || resize.pointerId !== event.pointerId) return;
173+
event.preventDefault();
174+
event.stopPropagation();
175+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
176+
event.currentTarget.releasePointerCapture(event.pointerId);
177+
}
178+
const nextSize = resizePreviewRef.current ?? resize.startSize;
179+
resizingRef.current = null;
180+
resizePreviewRef.current = null;
181+
setResizePreview(null);
182+
setPetSize(nextSize);
183+
},
184+
[setPetSize],
185+
);
186+
92187
if (!petEnabled) return null;
93188

189+
const displayPetSize = resizePreview ?? petSize;
190+
94191
return (
95192
<div className="pointer-events-none fixed inset-0 flex flex-col items-end justify-end gap-1.5 p-2.5">
96193
{items.length > 0 && !collapsed && (
@@ -120,20 +217,55 @@ export function DesktopPet() {
120217

121218
<div
122219
data-pet-interactive
123-
className="pointer-events-auto cursor-grab active:cursor-grabbing"
220+
className="group relative pointer-events-auto cursor-grab active:cursor-grabbing"
124221
title={selectedPet.displayName}
125222
onPointerDown={onPointerDown}
126223
onPointerMove={onPointerMove}
127224
onPointerUp={onPointerEnd}
128225
onPointerCancel={onPointerEnd}
226+
onContextMenu={onPetContextMenu}
129227
>
130228
<QwenPet
131229
spritesheetUrl={selectedPet.spritesheetUrl}
132230
state={state}
133-
size={96}
231+
size={displayPetSize}
134232
className="drop-shadow-[0_3px_6px_rgba(0,0,0,0.35)]"
135233
/>
234+
<button
235+
type="button"
236+
data-pet-interactive
237+
aria-label="resize pet"
238+
title="Resize pet"
239+
onPointerDown={onResizePointerDown}
240+
onPointerMove={onResizePointerMove}
241+
onPointerUp={onResizePointerEnd}
242+
onPointerCancel={onResizePointerEnd}
243+
className={`absolute -bottom-0.5 -right-0.5 flex h-6 w-6 cursor-nwse-resize items-center justify-center rounded-lg border border-white/15 bg-neutral-900/55 text-white shadow-[0_3px_10px_rgba(0,0,0,0.28)] backdrop-blur transition-opacity hover:bg-neutral-900/70 focus-visible:opacity-100 ${
244+
resizePreview == null
245+
? 'opacity-0 group-hover:opacity-100'
246+
: 'opacity-100'
247+
}`}
248+
>
249+
<Maximize2 className="h-3 w-3" />
250+
</button>
136251
</div>
252+
253+
{contextMenu && (
254+
<div
255+
data-pet-interactive
256+
className="pointer-events-auto absolute z-50 rounded-md border border-neutral-200 bg-white p-0.5 text-neutral-800 shadow-[0_5px_16px_rgba(0,0,0,0.16)]"
257+
style={{ left: contextMenu.x, top: contextMenu.y }}
258+
onContextMenu={(event) => event.preventDefault()}
259+
>
260+
<button
261+
type="button"
262+
onClick={onClosePet}
263+
className="flex h-6 min-w-20 items-center rounded px-2 text-xs hover:bg-neutral-100"
264+
>
265+
{t('pet.menu.close')}
266+
</button>
267+
</div>
268+
)}
137269
</div>
138270
);
139271
}

apps/electron/src/renderer/components/pet/PetWindowController.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { usePetCompanion } from '@/pets/usePetCompanion';
77
* selection change so the main process can reload the window with the new pet.
88
*/
99
export function PetWindowController() {
10-
const { petEnabled, selectedPetId } = usePetCompanion();
10+
const { petEnabled, petSettingsLoaded, selectedPetId } = usePetCompanion();
1111

1212
useEffect(() => {
13+
if (!petSettingsLoaded) return;
1314
void window.electronAPI?.setPetWindowEnabled?.(petEnabled);
14-
}, [petEnabled, selectedPetId]);
15+
}, [petEnabled, petSettingsLoaded, selectedPetId]);
1516

1617
return null;
1718
}

apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopo
1616
import { useTheme } from '@/context/ThemeContext'
1717
import { useAppShellContext } from '@/context/AppShellContext'
1818
import { routes } from '@/lib/navigate'
19-
import { Monitor, Sun, Moon } from 'lucide-react'
19+
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
2020
import type { DetailsPageMeta } from '@/lib/navigation-registry'
2121
import type { ToolIconMapping } from '../../../shared/types'
2222

@@ -154,6 +154,14 @@ export default function AppearanceSettingsPage() {
154154
setPetsFolder(`${home}/.qwen/pets`),
155155
)
156156
}, [])
157+
const handleOpenPetsFolder = useCallback(async () => {
158+
try {
159+
const path = await window.electronAPI?.openPetsFolder?.()
160+
if (path) setPetsFolder(path)
161+
} catch {
162+
// Keep the settings page usable if the OS folder open request fails.
163+
}
164+
}, [])
157165

158166
// Load preset themes on mount
159167
useEffect(() => {
@@ -416,16 +424,34 @@ export default function AppearanceSettingsPage() {
416424
))}
417425
{petEnabled && (
418426
<SettingsRow
419-
label={t("settings.appearance.petCustom")}
427+
label={
428+
<div className="space-y-1">
429+
<div>{t("settings.appearance.petCustom")}</div>
430+
<div className="text-xs font-normal leading-4 text-muted-foreground">
431+
{t("settings.appearance.petCustomHint")}
432+
</div>
433+
</div>
434+
}
420435
description={petsFolder ?? '~/.qwen/pets'}
421436
action={
422-
<button
423-
type="button"
424-
onClick={() => { void refreshCustomPets() }}
425-
className="inline-flex items-center h-8 px-3 text-sm rounded-lg bg-background shadow-minimal hover:bg-foreground/[0.02] transition-colors"
426-
>
427-
{t("settings.appearance.petRefresh")}
428-
</button>
437+
<div className="flex items-center gap-2">
438+
<button
439+
type="button"
440+
onClick={handleOpenPetsFolder}
441+
className="inline-flex items-center h-8 gap-1.5 px-3 text-sm rounded-lg bg-background shadow-minimal hover:bg-foreground/[0.02] transition-colors"
442+
>
443+
<FolderOpen className="h-3.5 w-3.5" />
444+
{t("settings.appearance.petOpenFolder")}
445+
</button>
446+
<button
447+
type="button"
448+
onClick={() => { void refreshCustomPets() }}
449+
className="inline-flex items-center h-8 gap-1.5 px-3 text-sm rounded-lg bg-background shadow-minimal hover:bg-foreground/[0.02] transition-colors"
450+
>
451+
<RefreshCw className="h-3.5 w-3.5" />
452+
{t("settings.appearance.petRefresh")}
453+
</button>
454+
</div>
429455
}
430456
/>
431457
)}

apps/electron/src/renderer/pets/pet-atoms.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import { atom } from 'jotai';
33
import type { CustomPetEntry } from '@craft-agent/shared/config';
44
import { DEFAULT_PET_ID } from './registry';
5+
import { DEFAULT_PET_SIZE } from './pet-size';
56

67
export const selectedPetIdAtom = atom<string>(DEFAULT_PET_ID);
78
export const petEnabledAtom = atom<boolean>(true);
9+
export const petSettingsLoadedAtom = atom<boolean>(false);
10+
export const petSizeAtom = atom<number>(DEFAULT_PET_SIZE);
811
export const customPetsAtom = atom<CustomPetEntry[]>([]);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const DEFAULT_PET_SIZE = 96;
2+
export const MIN_PET_SIZE = 64;
3+
export const MAX_PET_SIZE = 240;
4+
5+
export function normalizePetSize(size: number): number {
6+
return Math.round(Math.min(MAX_PET_SIZE, Math.max(MIN_PET_SIZE, size)));
7+
}

0 commit comments

Comments
 (0)