Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down
91 changes: 91 additions & 0 deletions src-tauri/src/window_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhysicalPosition<i32>>,
) -> Result<PhysicalPosition<i32>, 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::<Vec<_>>();
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<PhysicalPosition<i32>, 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 {
Expand Down
78 changes: 76 additions & 2 deletions src-tauri/tests/command_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand Down Expand Up @@ -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!(
Expand Down
18 changes: 18 additions & 0 deletions src/lib/petMotion.ts
Original file line number Diff line number Diff line change
@@ -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)),
};
}
2 changes: 2 additions & 0 deletions src/lib/tauriApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const tauriApi = {
showChatNearPet: () => invoke<void>("show_chat_near_pet"),
hideChat: () => invoke<void>("hide_chat"),
hidePet: () => invoke<void>("hide_pet"),
restorePetPosition: () =>
invoke<{ x: number; y: number }>("restore_pet_position"),
showSettings: () => invoke<void>("show_settings"),
placeWindowBottomCenter: (label: string) =>
invoke<void>("place_window_bottom_center", { label }),
Expand Down
85 changes: 85 additions & 0 deletions src/lib/tauriWebviewApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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", () => ({
getCurrentWebviewWindow: vi.fn(),
}));

vi.mock("@tauri-apps/api/window", () => ({
availableMonitors: vi.fn(),
cursorPosition: vi.fn(),
LogicalPosition: class {
constructor(
public x: number,
Expand All @@ -27,8 +31,10 @@ vi.mock("@tauri-apps/api/window", () => ({

import {
clearPetWebviewChrome,
getPetPointerState,
petSupportsManualMotion,
petUsesManualDrag,
setPetWebviewPosition,
} from "./tauriWebviewApi";

describe("petUsesManualDrag", () => {
Expand Down Expand Up @@ -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<i32> 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();
}
});
});
Loading
Loading