From b3ba52b7290234675a919c977e261a21c9e4d1a3 Mon Sep 17 00:00:00 2001 From: cxuanAI <33996561+crisxuan@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:48:17 -0700 Subject: [PATCH] fix: keep pet motion and saved positions inside monitor work areas --- CHANGELOG.md | 7 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/tray.rs | 1 + src-tauri/src/window_cmd.rs | 91 +++++++++++++ src-tauri/tests/command_logic.rs | 78 ++++++++++- src/lib/petMotion.ts | 18 +++ src/lib/tauriApi.ts | 2 + src/lib/tauriWebviewApi.test.ts | 85 ++++++++++++ src/lib/tauriWebviewApi.ts | 37 +++-- src/windows/PetWindow.test.tsx | 200 ++++++++++++++++++++++++++-- src/windows/PetWindow.tsx | 135 ++++++++++--------- 12 files changed, 574 insertions(+), 82 deletions(-) create mode 100644 src/lib/petMotion.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66daf49..4f7a40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Pet dragging and inertia use physical coordinates consistently on high-DPI displays, preventing the pet from drifting off-screen on Retina Macs. +- Restore saved pet positions inside the nearest available monitor work area on startup and when opening the pet from the tray or shortcut, including after a monitor is disconnected. + ## [0.2.0] - 2026-09-03 ### Fixed diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bb146e2..9facb44 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "core:window:allow-set-position", + "core:window:allow-cursor-position", "core:window:allow-set-size", "core:window:allow-set-min-size", "core:window:allow-set-max-size", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4fb4c5c..97977f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,6 +52,7 @@ pub fn run() { window_cmd::show_chat_near_pet, window_cmd::hide_chat, window_cmd::hide_pet, + window_cmd::restore_pet_position, window_cmd::show_settings, window_cmd::place_window_bottom_center, window_cmd::place_window_centered, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 922b044..a54c60a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -50,6 +50,7 @@ fn open_home_shortcut(cfg: &AppConfig) -> String { } pub fn open_pet(app: &AppHandle) -> Result<(), String> { + crate::window_cmd::ensure_pet_on_screen(app)?; let pet = app .get_webview_window("pet") .ok_or_else(|| "pet window not found".to_string())?; diff --git a/src-tauri/src/window_cmd.rs b/src-tauri/src/window_cmd.rs index 46cb2ba..b4586d8 100644 --- a/src-tauri/src/window_cmd.rs +++ b/src-tauri/src/window_cmd.rs @@ -18,6 +18,97 @@ pub fn should_hide_on_close(label: &str) -> bool { matches!(label, "chat" | "settings") } +pub struct PetWorkArea { + pub position: (i32, i32), + pub size: (u32, u32), + pub scale_factor: f64, +} + +/// Keep the whole pet inside the closest available work area, in physical pixels. +pub fn pet_position( + position: (i32, i32), + logical_size: f64, + work_areas: &[PetWorkArea], +) -> Option<(i32, i32)> { + work_areas + .iter() + .map(|area| { + let left = i64::from(area.position.0); + let top = i64::from(area.position.1); + let size = (logical_size * area.scale_factor).round() as i64; + let max_x = (left + i64::from(area.size.0) - size).max(left); + let max_y = (top + i64::from(area.size.1) - size).max(top); + ( + i64::from(position.0).clamp(left, max_x) as i32, + i64::from(position.1).clamp(top, max_y) as i32, + ) + }) + .min_by_key(|&(x, y)| { + let dx = i128::from(x) - i128::from(position.0); + let dy = i128::from(y) - i128::from(position.1); + dx * dx + dy * dy + }) +} + +fn position_pet_on_screen( + app: &AppHandle, + requested: Option>, +) -> Result, String> { + let pet = app + .get_webview_window("pet") + .ok_or_else(|| "pet window not found".to_string())?; + let current = pet + .outer_position() + .map_err(|error| format!("failed to read pet position: {error}"))?; + let position = requested.unwrap_or(current); + let size = pet + .inner_size() + .map_err(|error| format!("failed to read pet size: {error}"))?; + let scale = pet + .scale_factor() + .map_err(|error| format!("failed to read pet scale factor: {error}"))?; + let work_areas = pet + .available_monitors() + .map_err(|error| format!("failed to find pet monitors: {error}"))? + .iter() + .map(|monitor| { + let area = monitor.work_area(); + PetWorkArea { + position: (area.position.x, area.position.y), + size: (area.size.width, area.size.height), + scale_factor: monitor.scale_factor(), + } + }) + .collect::>(); + let (x, y) = pet_position( + (position.x, position.y), + f64::from(size.width) / scale, + &work_areas, + ) + .ok_or_else(|| "no available monitor for pet window".to_string())?; + let corrected = PhysicalPosition::new(x, y); + if current != corrected { + pet.set_position(corrected) + .map_err(|error| format!("failed to position pet window: {error}"))?; + } + crate::config_cmd::patch_config(app.clone(), serde_json::json!({ "petX": x, "petY": y }))?; + Ok(corrected) +} + +pub fn ensure_pet_on_screen(app: &AppHandle) -> Result<(), String> { + position_pet_on_screen(app, None).map(|_| ()) +} + +#[tauri::command] +pub fn restore_pet_position(app: AppHandle) -> Result, String> { + let cfg = crate::config_cmd::load_config(app.clone())?; + let requested = cfg + .pet_x + .zip(cfg.pet_y) + .map(|(x, y)| PhysicalPosition::new(x.round() as i32, y.round() as i32)); + position_pet_on_screen(&app, requested) +} + /// Chat and settings hide on click-away only when the setting is off. /// The pet never hides this way. pub fn should_hide_on_unfocus(label: &str, keep_windows_visible: bool) -> bool { diff --git a/src-tauri/tests/command_logic.rs b/src-tauri/tests/command_logic.rs index ddcc087..9c72989 100644 --- a/src-tauri/tests/command_logic.rs +++ b/src-tauri/tests/command_logic.rs @@ -12,8 +12,9 @@ use octop_pet_lib::{ }, window_cmd::{ bottom_anchored_position, bottom_centered_position, centered_position, chat_position, - home_url, should_hide_on_close, should_hide_on_unfocus, transparent_chrome_target, - TransparentChromeTarget, CHAT_BOTTOM_GAP_LOGICAL, RESIZE_ANIMATION_DURATION, + home_url, pet_position, should_hide_on_close, should_hide_on_unfocus, + transparent_chrome_target, PetWorkArea, TransparentChromeTarget, CHAT_BOTTOM_GAP_LOGICAL, + RESIZE_ANIMATION_DURATION, }, }; @@ -233,6 +234,79 @@ fn chat_position_is_clamped_to_monitor_work_area() { ); } +#[test] +fn pet_restores_off_screen_retina_position_inside_the_work_area() { + let area = PetWorkArea { + position: (0, 78), + size: (3600, 2160), + scale_factor: 2.0, + }; + assert_eq!( + pet_position((7278, 3250), 160.0, &[area]), + Some((3280, 1918)) + ); +} + +#[test] +fn pet_preserves_visible_positions_on_negative_coordinate_monitors() { + let areas = [ + PetWorkArea { + position: (0, 78), + size: (3600, 2160), + scale_factor: 2.0, + }, + PetWorkArea { + position: (-1920, -400), + size: (1920, 1080), + scale_factor: 1.0, + }, + ]; + assert_eq!( + pet_position((-1800, -200), 160.0, &areas), + Some((-1800, -200)) + ); +} + +#[test] +fn pet_uses_the_nearest_remaining_monitor_after_disconnect() { + let area = PetWorkArea { + position: (0, 40), + size: (1920, 1040), + scale_factor: 1.0, + }; + assert_eq!(pet_position((-1800, 300), 160.0, &[area]), Some((0, 300))); +} + +#[test] +fn pet_uses_each_monitors_scale_when_clamping_the_whole_window() { + let areas = [ + PetWorkArea { + position: (0, 0), + size: (1920, 1080), + scale_factor: 1.0, + }, + PetWorkArea { + position: (1920, 0), + size: (3600, 2160), + scale_factor: 2.0, + }, + ]; + assert_eq!( + pet_position((5500, 2140), 160.0, &areas), + Some((5200, 1840)) + ); +} + +#[test] +fn pet_clamps_to_the_origin_when_larger_than_the_work_area() { + let area = PetWorkArea { + position: (100, 40), + size: (200, 200), + scale_factor: 2.0, + }; + assert_eq!(pet_position((500, -400), 160.0, &[area]), Some((100, 40))); +} + #[test] fn windows_open_bottom_centered_with_gap() { assert_eq!( diff --git a/src/lib/petMotion.ts b/src/lib/petMotion.ts new file mode 100644 index 0000000..eb9e8a4 --- /dev/null +++ b/src/lib/petMotion.ts @@ -0,0 +1,18 @@ +export function clampPetPosition( + position: { x: number; y: number }, + physicalSize: number, + workArea: { + position: { x: number; y: number }; + size: { width: number; height: number }; + } | null, +): { x: number; y: number } { + if (!workArea) return position; + const { x: left, y: top } = workArea.position; + const size = Math.round(physicalSize); + const maxX = Math.max(left, left + workArea.size.width - size); + const maxY = Math.max(top, top + workArea.size.height - size); + return { + x: Math.min(maxX, Math.max(left, position.x)), + y: Math.min(maxY, Math.max(top, position.y)), + }; +} diff --git a/src/lib/tauriApi.ts b/src/lib/tauriApi.ts index e1c3477..0308691 100644 --- a/src/lib/tauriApi.ts +++ b/src/lib/tauriApi.ts @@ -16,6 +16,8 @@ export const tauriApi = { showChatNearPet: () => invoke("show_chat_near_pet"), hideChat: () => invoke("hide_chat"), hidePet: () => invoke("hide_pet"), + restorePetPosition: () => + invoke<{ x: number; y: number }>("restore_pet_position"), showSettings: () => invoke("show_settings"), placeWindowBottomCenter: (label: string) => invoke("place_window_bottom_center", { label }), diff --git a/src/lib/tauriWebviewApi.test.ts b/src/lib/tauriWebviewApi.test.ts index fe8fe84..5f3cc27 100644 --- a/src/lib/tauriWebviewApi.test.ts +++ b/src/lib/tauriWebviewApi.test.ts @@ -1,3 +1,5 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { availableMonitors, cursorPosition } from "@tauri-apps/api/window"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("@tauri-apps/api/webviewWindow", () => ({ @@ -5,6 +7,8 @@ vi.mock("@tauri-apps/api/webviewWindow", () => ({ })); vi.mock("@tauri-apps/api/window", () => ({ + availableMonitors: vi.fn(), + cursorPosition: vi.fn(), LogicalPosition: class { constructor( public x: number, @@ -27,8 +31,10 @@ vi.mock("@tauri-apps/api/window", () => ({ import { clearPetWebviewChrome, + getPetPointerState, petSupportsManualMotion, petUsesManualDrag, + setPetWebviewPosition, } from "./tauriWebviewApi"; describe("petUsesManualDrag", () => { @@ -89,3 +95,82 @@ describe("clearPetWebviewChrome", () => { expect(win.setShadow).toHaveBeenCalledWith(false); }); }); + +describe("pet physical positioning", () => { + it("matches the physical Retina cursor against physical monitor bounds", async () => { + vi.mocked(getCurrentWebviewWindow).mockReturnValue({ + scaleFactor: async () => 2, + } as never); + vi.mocked(cursorPosition).mockResolvedValue({ x: 3440, y: 2178 } as never); + const workArea = { + position: { x: 0, y: 78 }, + size: { width: 3600, height: 2260 }, + }; + vi.mocked(availableMonitors).mockResolvedValue([ + { + position: { x: 0, y: 0 }, + size: { width: 3600, height: 2338 }, + workArea, + }, + ] as never); + expect(await getPetPointerState()).toEqual({ + cursor: { x: 3440, y: 2178 }, + scaleFactor: 2, + workArea, + }); + }); + + it("selects a secondary monitor with a negative physical origin", async () => { + vi.mocked(getCurrentWebviewWindow).mockReturnValue({ + scaleFactor: async () => 1, + } as never); + vi.mocked(cursorPosition).mockResolvedValue({ x: -900, y: -200 } as never); + const workArea = { + position: { x: -1920, y: -400 }, + size: { width: 1920, height: 1080 }, + }; + vi.mocked(availableMonitors).mockResolvedValue([ + { + position: { x: 0, y: 0 }, + size: { width: 3600, height: 2338 }, + workArea: {}, + }, + { + position: { x: -1920, y: -400 }, + size: { width: 1920, height: 1080 }, + workArea, + }, + ] as never); + expect((await getPetPointerState()).workArea).toEqual(workArea); + }); + + it("sends integer physical pixels to the native API for fractional cursor and inertia coordinates", async () => { + const native = { setPosition: vi.fn() }; + vi.mocked(getCurrentWebviewWindow).mockReturnValue(native as never); + // Native PhysicalPosition rejects fractional JSON coordinates. + native.setPosition.mockImplementation( + async (position: { x: number; y: number }) => { + if (!Number.isInteger(position.x) || !Number.isInteger(position.y)) { + throw new Error("invalid physical position"); + } + }, + ); + const errors = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + try { + await setPetWebviewPosition(1223.7734375, 1643.6328125); + expect(errors).not.toHaveBeenCalled(); + expect(native.setPosition).toHaveBeenLastCalledWith( + expect.objectContaining({ x: 1224, y: 1644 }), + ); + await setPetWebviewPosition(-1800.25, -399.75); + expect(errors).not.toHaveBeenCalled(); + expect(native.setPosition).toHaveBeenLastCalledWith( + expect.objectContaining({ x: -1800, y: -400 }), + ); + } finally { + errors.mockRestore(); + } + }); +}); diff --git a/src/lib/tauriWebviewApi.ts b/src/lib/tauriWebviewApi.ts index 644570a..b59cedf 100644 --- a/src/lib/tauriWebviewApi.ts +++ b/src/lib/tauriWebviewApi.ts @@ -1,12 +1,36 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { - LogicalPosition, LogicalSize, PhysicalPosition, + availableMonitors, + cursorPosition, + type Monitor, } from "@tauri-apps/api/window"; export type PetWebviewWindow = ReturnType; +export interface PetPointerState { + cursor: { x: number; y: number }; + scaleFactor: number; + workArea: Monitor["workArea"] | null; +} + +export async function getPetPointerState(): Promise { + const [cursor, scaleFactor, monitors] = await Promise.all([ + cursorPosition(), + getPetWebviewWindow().scaleFactor(), + availableMonitors(), + ]); + const monitor = monitors.find( + ({ position, size }) => + cursor.x >= position.x && + cursor.x < position.x + size.width && + cursor.y >= position.y && + cursor.y < position.y + size.height, + ); + return { cursor, scaleFactor, workArea: monitor?.workArea ?? null }; +} + export function getPetWebviewWindow(): PetWebviewWindow { return getCurrentWebviewWindow(); } @@ -42,16 +66,7 @@ export async function setPetWebviewPosition( y: number, ): Promise { await getPetWebviewWindow() - .setPosition(new PhysicalPosition(x, y)) - .catch((error) => console.error("恢复宠物位置失败", error)); -} - -export async function setPetWebviewLogicalPosition( - x: number, - y: number, -): Promise { - await getPetWebviewWindow() - .setPosition(new LogicalPosition(x, y)) + .setPosition(new PhysicalPosition(Math.round(x), Math.round(y))) .catch((error) => console.error("移动宠物位置失败", error)); } diff --git a/src/windows/PetWindow.test.tsx b/src/windows/PetWindow.test.tsx index 33fdaaa..6b8f63a 100644 --- a/src/windows/PetWindow.test.tsx +++ b/src/windows/PetWindow.test.tsx @@ -19,12 +19,13 @@ const mocks = vi.hoisted(() => ({ showChatNearPet: vi.fn(), showSettings: vi.fn(), hidePet: vi.fn(), + restorePetPosition: vi.fn(), openHome: vi.fn(), listenMascotChanged: vi.fn(), clearPetWebviewChrome: vi.fn(), setPetWebviewPosition: vi.fn(), startPetWebviewDrag: vi.fn(), - setPetWebviewLogicalPosition: vi.fn(), + getPetPointerState: vi.fn(), setPetWebviewLogicalSize: vi.fn(), petSupportsManualMotion: vi.fn(), onPetWebviewMoved: vi.fn(), @@ -40,6 +41,7 @@ vi.mock("../lib/tauriApi", () => ({ showChatNearPet: mocks.showChatNearPet, showSettings: mocks.showSettings, hidePet: mocks.hidePet, + restorePetPosition: mocks.restorePetPosition, openHome: mocks.openHome, listenMascotChanged: mocks.listenMascotChanged, }, @@ -49,7 +51,7 @@ vi.mock("../lib/tauriWebviewApi", () => ({ clearPetWebviewChrome: mocks.clearPetWebviewChrome, setPetWebviewPosition: mocks.setPetWebviewPosition, startPetWebviewDrag: mocks.startPetWebviewDrag, - setPetWebviewLogicalPosition: mocks.setPetWebviewLogicalPosition, + getPetPointerState: mocks.getPetPointerState, setPetWebviewLogicalSize: mocks.setPetWebviewLogicalSize, petSupportsManualMotion: mocks.petSupportsManualMotion, onPetWebviewMoved: mocks.onPetWebviewMoved, @@ -80,12 +82,30 @@ describe("PetWindow", () => { mocks.showChatNearPet.mockResolvedValue(undefined); mocks.showSettings.mockResolvedValue(undefined); mocks.hidePet.mockResolvedValue(undefined); + mocks.restorePetPosition.mockResolvedValue({ x: 120, y: 240 }); mocks.openHome.mockResolvedValue(undefined); mocks.listenMascotChanged.mockResolvedValue(vi.fn()); mocks.clearPetWebviewChrome.mockResolvedValue(undefined); mocks.setPetWebviewPosition.mockResolvedValue(undefined); mocks.startPetWebviewDrag.mockResolvedValue(undefined); - mocks.setPetWebviewLogicalPosition.mockResolvedValue(undefined); + mocks.getPetPointerState + .mockReset() + .mockResolvedValueOnce({ + cursor: { x: 100, y: 200 }, + scaleFactor: 1, + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1200, height: 900 }, + }, + }) + .mockResolvedValue({ + cursor: { x: 120, y: 200 }, + scaleFactor: 1, + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1200, height: 900 }, + }, + }); mocks.setPetWebviewLogicalSize.mockResolvedValue(undefined); mocks.petSupportsManualMotion.mockReturnValue(false); mocks.showPetContextMenu.mockResolvedValue(undefined); @@ -102,7 +122,8 @@ describe("PetWindow", () => { render(); await waitFor(() => expect(mocks.loadConfig).toHaveBeenCalledOnce()); expect(mocks.setPetWebviewLogicalSize).toHaveBeenCalledWith(160); - expect(mocks.setPetWebviewPosition).toHaveBeenCalledWith(120, 240); + expect(mocks.restorePetPosition).toHaveBeenCalledOnce(); + expect(mocks.setPetWebviewPosition).not.toHaveBeenCalled(); }); it("opens chat on click without drag", async () => { @@ -135,7 +156,7 @@ describe("PetWindow", () => { await waitFor(() => expect(mocks.startPetWebviewDrag).toHaveBeenCalledOnce(), ); - expect(mocks.setPetWebviewLogicalPosition).not.toHaveBeenCalled(); + expect(mocks.setPetWebviewPosition).not.toHaveBeenCalled(); }); it("moves the window with setPosition on Windows instead of OS drag", async () => { @@ -160,7 +181,7 @@ describe("PetWindow", () => { }); await waitFor(() => - expect(mocks.setPetWebviewLogicalPosition).toHaveBeenCalledWith(110, 188), + expect(mocks.setPetWebviewPosition).toHaveBeenCalledWith(110, 188), ); expect(mocks.startPetWebviewDrag).not.toHaveBeenCalled(); }); @@ -200,15 +221,178 @@ describe("PetWindow", () => { pointerId: 1, button: 0, }); + await waitFor(() => + expect(mocks.setPetWebviewPosition).toHaveBeenCalledWith(110, 188), + ); fireEvent.pointerUp(region, { pointerId: 1 }); - expect(frames).toHaveLength(1); + await waitFor(() => expect(frames).toHaveLength(1)); act(() => frames.shift()?.(66)); - const lastCall = mocks.setPetWebviewLogicalPosition.mock.calls.at(-1); + const lastCall = mocks.setPetWebviewPosition.mock.calls.at(-1); expect(lastCall?.[0]).toBeGreaterThan(110); expect(lastCall?.[1]).toBe(188); }); + it("keeps Retina drag and move events in physical pixels throughout inertia", async () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(performance, "now") + .mockReturnValueOnce(0) + .mockReturnValueOnce(50) + .mockReturnValue(50); + let moved: ((position: { x: number; y: number }) => void) | undefined; + mocks.onPetWebviewMoved.mockImplementation(async (handler) => { + moved = handler; + return vi.fn(); + }); + const state = { + scaleFactor: 2, + workArea: { + position: { x: 0, y: 78 }, + size: { width: 3600, height: 2160 }, + }, + }; + mocks.getPetPointerState + .mockReset() + .mockResolvedValueOnce({ ...state, cursor: { x: 200, y: 400 } }) + .mockResolvedValue({ ...state, cursor: { x: 240, y: 400 } }); + mocks.petSupportsManualMotion.mockReturnValue(true); + render(); + await waitFor(() => expect(moved).toBeDefined()); + const region = screen.getByTestId("pet-drag-region"); + fireEvent.pointerDown(region, { + clientX: 10, + clientY: 12, + screenX: 100, + screenY: 200, + button: 0, + }); + fireEvent.pointerMove(region, { + clientX: 30, + clientY: 12, + screenX: 120, + screenY: 200, + }); + await waitFor(() => + expect(mocks.setPetWebviewPosition).toHaveBeenCalledWith(220, 376), + ); + act(() => moved?.({ x: 220, y: 376 })); + fireEvent.pointerUp(region); + await waitFor(() => expect(frames).toHaveLength(1)); + let previousX = 220; + for (const time of [66, 82, 98]) { + act(() => frames.shift()?.(time)); + const [x, y] = mocks.setPetWebviewPosition.mock.calls.at(-1)!; + expect(x).toBeGreaterThan(previousX); + expect(x - previousX).toBeLessThan(45); + expect(y).toBe(376); + previousX = x; + act(() => moved?.({ x, y })); + } + }); + + it("clamps inertia to the native secondary-monitor work area", async () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(performance, "now") + .mockReturnValueOnce(0) + .mockReturnValueOnce(50) + .mockReturnValue(50); + const state = { + scaleFactor: 2, + workArea: { + position: { x: -1800, y: -400 }, + size: { width: 1800, height: 1200 }, + }, + }; + mocks.getPetPointerState + .mockReset() + .mockResolvedValueOnce({ ...state, cursor: { x: -30, y: 450 } }) + .mockResolvedValue({ ...state, cursor: { x: -10, y: 480 } }); + mocks.petSupportsManualMotion.mockReturnValue(true); + render(); + await waitFor(() => expect(mocks.restorePetPosition).toHaveBeenCalled()); + const region = screen.getByTestId("pet-drag-region"); + fireEvent.pointerDown(region, { clientX: 10, clientY: 0, button: 0 }); + fireEvent.pointerMove(region, { clientX: 30, clientY: 0 }); + await waitFor(() => + expect(mocks.setPetWebviewPosition).toHaveBeenCalledWith(-320, 480), + ); + fireEvent.pointerUp(region); + await waitFor(() => expect(frames).toHaveLength(1)); + act(() => frames.shift()?.(66)); + expect(mocks.setPetWebviewPosition).toHaveBeenLastCalledWith(-320, 480); + }); + + it("applies the final cursor query even when a short drag is released before IPC resolves", async () => { + let resolveState: + | ((value: Awaited>) => void) + | undefined; + mocks.getPetPointerState + .mockReset() + .mockResolvedValueOnce({ + cursor: { x: 200, y: 400 }, + scaleFactor: 2, + workArea: null, + }) + .mockImplementation( + () => + new Promise((resolve) => { + resolveState = resolve; + }), + ); + mocks.petSupportsManualMotion.mockReturnValue(true); + render(); + await waitFor(() => expect(mocks.restorePetPosition).toHaveBeenCalled()); + const region = screen.getByTestId("pet-drag-region"); + fireEvent.pointerDown(region, { clientX: 10, clientY: 12, button: 0 }); + fireEvent.pointerMove(region, { clientX: 30, clientY: 12 }); + fireEvent.pointerUp(region); + await act(async () => + resolveState?.({ + cursor: { x: 200, y: 400 }, + scaleFactor: 2, + workArea: null, + }), + ); + expect(mocks.setPetWebviewPosition).toHaveBeenLastCalledWith(180, 376); + }); + + it("keeps a slow drag inside the work area even when there is no inertia", async () => { + mocks.getPetPointerState.mockReset().mockResolvedValue({ + cursor: { x: 3440, y: 2178 }, + scaleFactor: 2, + workArea: { + position: { x: 0, y: 78 }, + size: { width: 3600, height: 2260 }, + }, + }); + mocks.petSupportsManualMotion.mockReturnValue(true); + render(); + await waitFor(() => expect(mocks.restorePetPosition).toHaveBeenCalled()); + const region = screen.getByTestId("pet-drag-region"); + fireEvent.pointerDown(region, { clientX: 0, clientY: 0, button: 0 }); + fireEvent.pointerMove(region, { clientX: 20, clientY: 20 }); + fireEvent.pointerUp(region); + await waitFor(() => + expect(mocks.setPetWebviewPosition).toHaveBeenLastCalledWith(3280, 2018), + ); + }); + it("resizes from the bottom-right hover handle and persists the size", async () => { render(); await waitFor(() => expect(mocks.loadConfig).toHaveBeenCalled()); diff --git a/src/windows/PetWindow.tsx b/src/windows/PetWindow.tsx index 06c0d0b..c8569dd 100644 --- a/src/windows/PetWindow.tsx +++ b/src/windows/PetWindow.tsx @@ -3,17 +3,19 @@ import { useEffect, useRef, useState } from "react"; import MascotImage from "../components/MascotImage"; import { DEFAULT_APP_CONFIG, MASCOT_SRC } from "../lib/configLogic"; import { showPetContextMenu } from "../lib/petContextMenu"; +import { clampPetPosition } from "../lib/petMotion"; import { tauriApi } from "../lib/tauriApi"; import { clearPetWebviewChrome, + getPetPointerState, getPetWebviewWindow, onPetWebviewFocusChanged, onPetWebviewMoved, petSupportsManualMotion, setPetWebviewLogicalSize, - setPetWebviewLogicalPosition, setPetWebviewPosition, startPetWebviewDrag, + type PetPointerState, } from "../lib/tauriWebviewApi"; import type { AppConfig, MascotId } from "../lib/types"; @@ -47,6 +49,9 @@ export default function PetWindow() { const pointerStartRef = useRef({ x: 0, y: 0 }); const grabOffsetRef = useRef({ x: 0, y: 0 }); const lastPositionRef = useRef({ x: 0, y: 0 }); + const pointerStateRef = useRef(null); + const pointerSequenceRef = useRef(0); + const pendingPointerMoveRef = useRef>(Promise.resolve()); const pointerSamplesRef = useRef([]); const glideFrameRef = useRef(null); const petSizeRef = useRef(DEFAULT_APP_CONFIG.petSize); @@ -87,10 +92,7 @@ export default function PetWindow() { petSizeRef.current = clampPetSize(config.petSize); await setPetWebviewLogicalSize(petSizeRef.current); - if (config.petX !== null && config.petY !== null) { - lastPositionRef.current = { x: config.petX, y: config.petY }; - await setPetWebviewPosition(config.petX, config.petY); - } + lastPositionRef.current = await tauriApi.restorePetPosition(); if (disposed) return; await tauriApi @@ -160,6 +162,7 @@ export default function PetWindow() { return () => { disposed = true; + pointerSequenceRef.current += 1; if (glideFrameRef.current !== null) { cancelAnimationFrame(glideFrameRef.current); } @@ -170,7 +173,7 @@ export default function PetWindow() { }; }, []); - const handlePointerDown = (event: React.PointerEvent) => { + const handlePointerDown = async (event: React.PointerEvent) => { if (event.button !== 0) return; if (glideFrameRef.current !== null) { @@ -178,17 +181,24 @@ export default function PetWindow() { glideFrameRef.current = null; } clearPetSelection(); + const sequence = ++pointerSequenceRef.current; pointerDownRef.current = true; movedSincePointerDownRef.current = false; dragStartedRef.current = false; pointerStartRef.current = { x: event.clientX, y: event.clientY }; grabOffsetRef.current = { x: event.clientX, y: event.clientY }; - pointerSamplesRef.current = [ - { x: event.screenX, y: event.screenY, time: performance.now() }, - ]; + pointerSamplesRef.current = []; if (petSupportsManualMotion() && event.currentTarget.setPointerCapture) { event.currentTarget.setPointerCapture(event.pointerId); } + if (petSupportsManualMotion()) { + const state = await getPetPointerState(); + if (sequence !== pointerSequenceRef.current) return; + pointerStateRef.current = state; + pointerSamplesRef.current = [ + { ...state.cursor, time: performance.now() }, + ]; + } }; const handlePointerMove = (event: React.PointerEvent) => { @@ -203,19 +213,32 @@ export default function PetWindow() { movedSincePointerDownRef.current = true; if (petSupportsManualMotion()) { dragStartedRef.current = true; - const position = { - x: event.screenX - grabOffsetRef.current.x, - y: event.screenY - grabOffsetRef.current.y, - }; - lastPositionRef.current = position; - const now = performance.now(); - pointerSamplesRef.current = [ - ...pointerSamplesRef.current.filter( - (sample) => now - sample.time <= 100, - ), - { x: event.screenX, y: event.screenY, time: now }, - ].slice(-6); - void setPetWebviewLogicalPosition(position.x, position.y); + const sequence = pointerSequenceRef.current; + pendingPointerMoveRef.current = getPetPointerState() + .then(async (state) => { + if (sequence !== pointerSequenceRef.current) return; + pointerStateRef.current = state; + // Native cursor/move events and saved coordinates are physical pixels. + // Only the grab offset from the DOM needs the window's DPI conversion. + const position = clampPetPosition( + { + x: state.cursor.x - grabOffsetRef.current.x * state.scaleFactor, + y: state.cursor.y - grabOffsetRef.current.y * state.scaleFactor, + }, + petSizeRef.current * state.scaleFactor, + state.workArea, + ); + lastPositionRef.current = position; + const now = performance.now(); + pointerSamplesRef.current = [ + ...pointerSamplesRef.current.filter( + (sample) => now - sample.time <= 100, + ), + { ...state.cursor, time: now }, + ].slice(-6); + await setPetWebviewPosition(position.x, position.y); + }) + .catch((error) => console.error("读取宠物拖动位置失败", error)); return; } if (dragStartedRef.current) return; @@ -225,16 +248,19 @@ export default function PetWindow() { const startGlide = () => { const samples = pointerSamplesRef.current; - if (samples.length < 2) return; + const state = pointerStateRef.current; + if (samples.length < 2 || !state) return; + const stopSpeed = GLIDE_STOP_SPEED * state.scaleFactor; + const maxSpeed = GLIDE_MAX_SPEED * state.scaleFactor; const first = samples[0]; const last = samples[samples.length - 1]; const elapsed = Math.max(1, last.time - first.time); let vx = (last.x - first.x) / elapsed; let vy = (last.y - first.y) / elapsed; const speed = Math.hypot(vx, vy); - if (speed < GLIDE_STOP_SPEED) return; - if (speed > GLIDE_MAX_SPEED) { - const scale = GLIDE_MAX_SPEED / speed; + if (speed < stopSpeed) return; + if (speed > maxSpeed) { + const scale = maxSpeed / speed; vx *= scale; vy *= scale; } @@ -253,36 +279,18 @@ export default function PetWindow() { x: lastPositionRef.current.x + vx * elapsedMs, y: lastPositionRef.current.y + vy * elapsedMs, }; - const desktop = window.screen as Screen & { - availLeft?: number; - availTop?: number; - }; - const left = desktop.availLeft ?? 0; - const top = desktop.availTop ?? 0; - const right = left + desktop.availWidth; - const bottom = top + desktop.availHeight; - const size = petSizeRef.current; - // Avoid teleporting a pet on a secondary monitor when the browser only - // exposes the primary screen. Clamp only when the current point is in it. - const current = lastPositionRef.current; - if ( - current.x + size > left && - current.x < right && - current.y + size > top && - current.y < bottom - ) { - const clamped = { - x: Math.min(right - size, Math.max(left, position.x)), - y: Math.min(bottom - size, Math.max(top, position.y)), - }; - if (clamped.x !== position.x) vx = 0; - if (clamped.y !== position.y) vy = 0; - position = clamped; - } + const clamped = clampPetPosition( + position, + petSizeRef.current * state.scaleFactor, + state.workArea, + ); + if (clamped.x !== position.x) vx = 0; + if (clamped.y !== position.y) vy = 0; + position = clamped; lastPositionRef.current = position; - void setPetWebviewLogicalPosition(position.x, position.y); + void setPetWebviewPosition(position.x, position.y); - if (Math.hypot(vx, vy) > GLIDE_STOP_SPEED) { + if (Math.hypot(vx, vy) > stopSpeed) { glideFrameRef.current = requestAnimationFrame(step); } else { glideFrameRef.current = null; @@ -292,18 +300,23 @@ export default function PetWindow() { glideFrameRef.current = requestAnimationFrame(step); }; - const endPointer = (event: React.PointerEvent) => { - if (dragStartedRef.current) { - void clearPetWebviewChrome(getPetWebviewWindow()); - if (petSupportsManualMotion()) { - startGlide(); - } - } + const endPointer = async (event: React.PointerEvent) => { + const dragged = dragStartedRef.current; + const sequence = pointerSequenceRef.current; + const canceled = event.type === "pointercancel"; pointerDownRef.current = false; dragStartedRef.current = false; if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } + if (dragged) { + void clearPetWebviewChrome(getPetWebviewWindow()); + if (petSupportsManualMotion() && !canceled) { + // Do not discard the final native cursor query when release beats IPC. + await pendingPointerMoveRef.current; + if (sequence === pointerSequenceRef.current) startGlide(); + } + } }; const handleResizePointerDown = (