Skip to content
Merged
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
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
resolver = "2"

[workspace.package]
version = "2.2.2"
version = "2.3.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "familiar-app-ui",
"version": "2.2.2",
"version": "2.3.0",
"private": true,
"license": "MIT OR Apache-2.0",
"type": "module",
Expand Down
70 changes: 66 additions & 4 deletions app/src-tauri/src/desktop_pet_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ pub fn resize(
use cocoa::appkit::NSWindow;
use cocoa::base::{id, YES};
use cocoa::foundation::{NSPoint, NSRect, NSSize};
use objc::{class, msg_send, sel, sel_impl};

let Ok(ns_window_ptr) = window_for_main_thread.ns_window() else {
tracing::warn!("failed to access the desktop pet NSPanel for resize");
Expand All @@ -127,16 +128,28 @@ pub fn resize(

unsafe {
let frame = ns_window.frame();
let x = if anchor_bottom {
let mut x = if anchor_bottom {
frame.origin.x + (frame.size.width - width) / 2.0
} else {
frame.origin.x
};
let y = if anchor_bottom {
let mut y = if anchor_bottom {
frame.origin.y
} else {
frame.origin.y + frame.size.height - height
};

// Clamp into the screen's visible work area so a sprite
// size change never pushes the window off-screen.
let screen: id = msg_send![class!(NSScreen), mainScreen];
if !screen.is_null() {
let visible: NSRect = msg_send![screen, visibleFrame];
let max_x = visible.origin.x + visible.size.width - width;
let max_y = visible.origin.y + visible.size.height - height;
x = x.max(visible.origin.x).min(max_x.max(visible.origin.x));
y = y.max(visible.origin.y).min(max_y.max(visible.origin.y));
}

let resized_frame = NSRect::new(NSPoint::new(x, y), NSSize::new(width, height));
ns_window.setFrame_display_(resized_frame, YES);
}
Expand All @@ -157,8 +170,20 @@ pub fn resize(
let scale_factor = window.scale_factor().map_err(|error| error.to_string())?;
let new_width = (width * scale_factor).round() as i32;
let new_height = (height * scale_factor).round() as i32;
let x = old_position.x - (new_width - old_size.width as i32) / 2;
let y = old_position.y - (new_height - old_size.height as i32);
let mut x = old_position.x - (new_width - old_size.width as i32) / 2;
let mut y = old_position.y - (new_height - old_size.height as i32);

// Clamp into the monitor's work area so a sprite size change never
// pushes the window off-screen.
if let Ok(Some(monitor)) = window.current_monitor() {
let work = monitor.work_area();
let wl = work.position.x;
let wt = work.position.y;
let wr = wl + work.size.width as i32;
let wb = wt + work.size.height as i32;
x = x.clamp(wl, (wr - new_width).max(wl));
y = y.clamp(wt, (wb - new_height).max(wt));
}

window
.set_size(tauri::Size::Logical(tauri::LogicalSize::new(width, height)))
Expand Down Expand Up @@ -938,6 +963,43 @@ pub fn is_within_visible_work_area(window: &tauri::WebviewWindow) -> bool {
})
}

/// If the window is partially outside its monitor's work area (e.g. after a
/// sprite resize at the screen edge), clamp it back fully inside. Returns
/// `true` when a correction was applied.
pub fn correct_partial_offscreen(window: &tauri::WebviewWindow) -> bool {
let (Ok(pos), Ok(size)) = (window.outer_position(), window.outer_size()) else {
return false;
};
let Ok(Some(monitor)) = window.current_monitor() else {
return false;
};

let work = monitor.work_area();
let wl = work.position.x;
let wt = work.position.y;
let wr = wl + work.size.width as i32;
let wb = wt + work.size.height as i32;
let w = size.width as i32;
let h = size.height as i32;

let x = pos.x.clamp(wl, (wr - w).max(wl));
let y = pos.y.clamp(wt, (wb - h).max(wt));

if x != pos.x || y != pos.y {
tracing::info!(
from_x = pos.x,
from_y = pos.y,
to_x = x,
to_y = y,
"desktop pet partially off-screen; clamping back into work area"
);
let _ = window.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new(x, y)));
true
} else {
false
}
}

/// Clamp a requested physical top-left position into the work area of the
/// monitor nearest to that point. A stale saved coordinate — a monitor that
/// was unplugged, a resolution or DPI change, or a window dragged below the
Expand Down
28 changes: 17 additions & 11 deletions app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,20 +284,26 @@ fn main() {
let Some(main_win) = watcher_app_handle.get_webview_window("main") else {
continue;
};
if desktop_pet_window::is_within_visible_work_area(&main_win) {

// Fully off-screen (e.g. monitor unplugged): re-anchor to
// the saved position on a visible monitor.
if !desktop_pet_window::is_within_visible_work_area(&main_win) {
let spec = desktop_pet_window::parse_position(
&watcher_config_state
.get_config()
.renderer
.desktop_pet
.position,
);
tracing::info!("desktop pet off-screen; re-anchoring to a visible monitor");
let resolved = desktop_pet_window::resolve_position(&main_win, spec);
let _ = main_win.set_position(tauri::Position::Physical(resolved));
continue;
}

let spec = desktop_pet_window::parse_position(
&watcher_config_state
.get_config()
.renderer
.desktop_pet
.position,
);
tracing::info!("desktop pet off-screen; re-anchoring to a visible monitor");
let resolved = desktop_pet_window::resolve_position(&main_win, spec);
let _ = main_win.set_position(tauri::Position::Physical(resolved));
// Partially off-screen (e.g. sprite resize at screen edge):
// clamp back fully inside the work area.
desktop_pet_window::correct_partial_offscreen(&main_win);
}
});

Expand Down
8 changes: 8 additions & 0 deletions app/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export const translations = {
'lbl_builtin': '系统自带',
'lbl_custom': '自定义导入',
'btn_use_pack': '使用',
'btn_pool_pack': '随机',
'btn_in_pool': '已入池',
'desc_pool_pack': '加入随机池后,启动时从池中随机使用',
'menu_shuffle': '换一只',
'btn_preview_pack': '预览动作',
'btn_delete_pack': '删除素材包',
'title_preview_modal': '素材动作预览',
Expand Down Expand Up @@ -489,6 +493,10 @@ export const translations = {
'lbl_builtin': 'Built-in',
'lbl_custom': 'Custom',
'btn_use_pack': 'Use',
'btn_pool_pack': 'Random',
'btn_in_pool': 'In Pool',
'desc_pool_pack': 'Pools packs for random selection on launch',
'menu_shuffle': 'Shuffle Pet',
'btn_preview_pack': 'Preview',
'btn_delete_pack': 'Delete Pack',
'title_preview_modal': 'Action Preview',
Expand Down
10 changes: 10 additions & 0 deletions app/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@
<span data-i18n="menu_click_through">鼠标穿透</span>
<span class="menu-check">✓</span>
</div>
<div class="menu-item" id="menu-shuffle" style="display:none;">
<svg class="menu-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="21 16 21 21 16 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
<span data-i18n="menu_shuffle">换一只</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" id="menu-quit">
<svg class="menu-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
Expand Down
61 changes: 57 additions & 4 deletions app/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function setupContextMenu() {
const contextMenu = document.getElementById("context-menu");
const menuSettings = document.getElementById("menu-settings");
const menuClickThrough = document.getElementById("menu-click-through");
const menuShuffle = document.getElementById("menu-shuffle");

// Show/hide the menu and, while it is open, suspend left-click pass-through
// so the menu items stay clickable; restore the configured pass-through on
Expand Down Expand Up @@ -212,6 +213,9 @@ function setupContextMenu() {
if (menuClickThrough) {
menuClickThrough.classList.toggle('checked', clickThroughEnabled);
}
if (menuShuffle) {
menuShuffle.style.display = spritePoolCount > 1 ? "flex" : "none";
}
positionMenuAt(clientX, clientY);
}

Expand Down Expand Up @@ -262,6 +266,14 @@ function setupContextMenu() {
});
}

// Reroll a random pet from the configured sprite pool.
if (menuShuffle) {
menuShuffle.addEventListener("click", async () => {
setMenuOpen(false);
await loadActiveSpritePack(true);
});
}

// Quit Application
if (menuQuit) {
menuQuit.addEventListener("click", async () => {
Expand All @@ -277,14 +289,49 @@ function setupContextMenu() {

let currentSpriteId = null;
let currentSpriteRevision = null;
let lastPackKey = null;
let spritePoolCount = 0;

function packConfigKey(petConf) {
const pool = Array.isArray(petConf?.sprite_pool)
? [...petConf.sprite_pool].sort() : [];
return JSON.stringify([petConf?.sprite || "", pool]);
}

async function loadActiveSpritePack(force = true) {
async function loadActiveSpritePack(reroll = false) {
try {
const packInfo = await invoke("get_active_sprite_pack");
const config = await invoke("get_config");
const petConf = config.renderer?.["desktop-pet"] || {};
lastPackKey = packConfigKey(petConf);
const pool = Array.isArray(petConf.sprite_pool)
? petConf.sprite_pool.filter((id) => typeof id === "string" && id) : [];
spritePoolCount = pool.length;

// Random pool mode: pick one of the pooled packs (a different one
// when rerolling); empty pool falls back to the single `sprite`.
let packInfo = null;
if (pool.length > 0) {
try {
const packs = await invoke("get_sprite_packs");
let candidates = (packs || [])
.filter((p) => p.manifest && pool.includes(p.manifest.id));
if (reroll && candidates.length > 1) {
candidates = candidates.filter((p) => p.manifest.id !== currentSpriteId);
}
if (candidates.length > 0) {
packInfo = candidates[Math.floor(Math.random() * candidates.length)];
}
} catch (e) {
console.error("Failed to resolve sprite pool", e);
}
}
if (!packInfo) {
packInfo = await invoke("get_active_sprite_pack");
}
if (packInfo && packInfo.manifest) {
const samePack = currentSpriteId === packInfo.manifest.id
&& currentSpriteRevision === packInfo.asset_revision;
if (!force && samePack) return;
if (!reroll && samePack) return;

currentSpriteId = packInfo.manifest.id;
currentSpriteRevision = packInfo.asset_revision;
Expand Down Expand Up @@ -324,6 +371,11 @@ async function init() {

await loadActiveSpritePack();

// Periodically reroll the random pool so a pooled pet rotates over time.
setInterval(() => {
if (spritePoolCount > 1) loadActiveSpritePack(true);
}, 10 * 60 * 1000);

// Listen for state changes from Rust Backend
await listen("state_changed", (event) => {
const state = event.payload;
Expand Down Expand Up @@ -446,7 +498,8 @@ async function applyConfigToWindow(config) {

const petConf = config.renderer['desktop-pet'];

if (petConf.sprite && currentSpriteId) {
const packKey = packConfigKey(petConf);
if (lastPackKey !== null && packKey !== lastPackKey) {
await loadActiveSpritePack(false);
}

Expand Down
10 changes: 10 additions & 0 deletions app/src/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,16 @@ input:checked + .slider:before {
cursor: default;
}

.sprite-pack-pool-btn {
width: 30px;
height: 30px;
flex: 0 0 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}

/* Sprite Pack Preview Modal */
.preview-modal-overlay {
position: fixed;
Expand Down
Loading
Loading