diff --git a/Cargo.lock b/Cargo.lock index 3c465988..0caf0a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5185,6 +5185,7 @@ dependencies = [ name = "tauri-plugin-custom-window" version = "0.1.0" dependencies = [ + "objc2-app-kit", "serde", "tauri", "tauri-nspanel", diff --git a/src-tauri/src/plugins/window/Cargo.toml b/src-tauri/src/plugins/window/Cargo.toml index 82601fac..5134e6c9 100644 --- a/src-tauri/src/plugins/window/Cargo.toml +++ b/src-tauri/src/plugins/window/Cargo.toml @@ -16,6 +16,7 @@ tauri-plugin.workspace = true [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel.workspace = true +objc2-app-kit = "0.3" [target."cfg(target_os = \"windows\")".dependencies] windows = { version = "0.61", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation"] } \ No newline at end of file diff --git a/src-tauri/src/plugins/window/build.rs b/src-tauri/src/plugins/window/build.rs index fa348766..9bdb782e 100644 --- a/src-tauri/src/plugins/window/build.rs +++ b/src-tauri/src/plugins/window/build.rs @@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[ "hide_window", "set_always_on_top", "set_taskbar_visibility", + "set_multi_screen_follow", ]; fn main() { diff --git a/src-tauri/src/plugins/window/permissions/default.toml b/src-tauri/src/plugins/window/permissions/default.toml index 4c72b735..ffbb3b1e 100644 --- a/src-tauri/src/plugins/window/permissions/default.toml +++ b/src-tauri/src/plugins/window/permissions/default.toml @@ -2,4 +2,4 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility"] +permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility", "allow-set-multi-screen-follow"] diff --git a/src-tauri/src/plugins/window/src/commands/linux.rs b/src-tauri/src/plugins/window/src/commands/linux.rs index d8c5567e..91610c28 100644 --- a/src-tauri/src/plugins/window/src/commands/linux.rs +++ b/src-tauri/src/plugins/window/src/commands/linux.rs @@ -31,3 +31,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Linux 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/commands/macos.rs b/src-tauri/src/plugins/window/src/commands/macos.rs index 90c510ca..36fb1c8f 100644 --- a/src-tauri/src/plugins/window/src/commands/macos.rs +++ b/src-tauri/src/plugins/window/src/commands/macos.rs @@ -1,14 +1,51 @@ #![allow(deprecated)] use crate::MAIN_WINDOW_LABEL; +use objc2_app_kit::NSWindowCollectionBehavior; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Runtime, WebviewWindow, command}; use tauri_nspanel::{CollectionBehavior, ManagerExt, PanelLevel}; +// 多屏跟随开关;开启后去掉 `.stationary()`,让 NSPanel 可在不同屏幕之间移动。 +static MULTI_SCREEN_FOLLOW: AtomicBool = AtomicBool::new(false); + enum MacOSPanelStatus { Show, Hide, SetAlwaysOnTop(bool), } +fn show_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } +} + +fn hide_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } +} + fn is_main_window(window: &WebviewWindow) -> bool { window.label() == MAIN_WINDOW_LABEL } @@ -27,24 +64,12 @@ fn set_macos_panel( MacOSPanelStatus::Show => { panel.show(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .can_join_all_spaces() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(show_collection_behavior()); } MacOSPanelStatus::Hide => { panel.hide(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .move_to_active_space() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(hide_collection_behavior()); } MacOSPanelStatus::SetAlwaysOnTop(always_on_top) => { if always_on_top { @@ -106,3 +131,24 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(app_handle: AppHandle, visible: bool) { let _ = app_handle.set_dock_visibility(visible); } + +#[command] +pub async fn set_multi_screen_follow( + app_handle: AppHandle, + window: WebviewWindow, + enabled: bool, +) { + if !is_main_window(&window) { + return; + } + + MULTI_SCREEN_FOLLOW.store(enabled, Ordering::SeqCst); + + let app_handle_clone = app_handle.clone(); + + let _ = app_handle.run_on_main_thread(move || { + if let Ok(panel) = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL) { + panel.set_collection_behavior(show_collection_behavior()); + } + }); +} diff --git a/src-tauri/src/plugins/window/src/commands/windows.rs b/src-tauri/src/plugins/window/src/commands/windows.rs index a9170b84..151db2dd 100644 --- a/src-tauri/src/plugins/window/src/commands/windows.rs +++ b/src-tauri/src/plugins/window/src/commands/windows.rs @@ -82,3 +82,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Windows 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/lib.rs b/src-tauri/src/plugins/window/src/lib.rs index 94266e6a..0f849da8 100644 --- a/src-tauri/src/plugins/window/src/lib.rs +++ b/src-tauri/src/plugins/window/src/lib.rs @@ -14,6 +14,7 @@ pub fn init() -> TauriPlugin { commands::hide_window, commands::set_always_on_top, commands::set_taskbar_visibility, + commands::set_multi_screen_follow, ]) .build() } diff --git a/src/composables/useMultiScreenFollow.ts b/src/composables/useMultiScreenFollow.ts new file mode 100644 index 00000000..f1580d04 --- /dev/null +++ b/src/composables/useMultiScreenFollow.ts @@ -0,0 +1,103 @@ +import type { Monitor } from '@tauri-apps/api/window' + +import { PhysicalPosition } from '@tauri-apps/api/dpi' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { cursorPosition, monitorFromPoint } from '@tauri-apps/api/window' +import { useIntervalFn } from '@vueuse/core' +import { watch } from 'vue' + +import { setMultiScreenFollow } from '@/plugins/window' +import { useCatStore } from '@/stores/cat' +import { useGeneralStore } from '@/stores/general' +import { isMac } from '@/utils/platform' + +const POLL_INTERVAL_MS = 800 + +function monitorKey(mon: Monitor) { + return `${mon.position.x},${mon.position.y},${mon.size.width},${mon.size.height}` +} + +export function useMultiScreenFollow() { + if (!isMac) return + + const generalStore = useGeneralStore() + const catStore = useCatStore() + const appWindow = getCurrentWebviewWindow() + + // 记忆每个屏幕上窗口最后停留的相对偏移(相对屏幕原点)。 + // 大屏 → 小屏时偏移会被裁剪到小屏边界,若直接用这个被裁剪过的偏移再换算回大屏, + // 用户最初的位置就会丢失。缓存能在回到原屏幕时恢复用户实际设定的位置。 + const monitorOffsets = new Map() + + const tick = async () => { + if (!catStore.window.visible) return + + const [winPos, winSize, cursor, scaleFactor] = await Promise.all([ + appWindow.outerPosition(), + appWindow.outerSize(), + cursorPosition(), + appWindow.scaleFactor(), + ]) + + const cursorLogical = cursor.toLogical(scaleFactor) + const winCenterLogical = new PhysicalPosition( + winPos.x + Math.floor(winSize.width / 2), + winPos.y + Math.floor(winSize.height / 2), + ).toLogical(scaleFactor) + + const [cursorMon, winMon] = await Promise.all([ + monitorFromPoint(cursorLogical.x, cursorLogical.y), + monitorFromPoint(winCenterLogical.x, winCenterLogical.y), + ]) + + if (!cursorMon || !winMon) return + + // 始终更新当前所在屏幕的偏移记忆,捕捉用户在屏内手动拖动后的最新位置。 + monitorOffsets.set(monitorKey(winMon), { + x: winPos.x - winMon.position.x, + y: winPos.y - winMon.position.y, + }) + + const sameMonitor + = winMon.position.x === cursorMon.position.x + && winMon.position.y === cursorMon.position.y + && winMon.size.width === cursorMon.size.width + && winMon.size.height === cursorMon.size.height + + if (sameMonitor) return + + // 优先使用目标屏幕的历史偏移;首次进入则沿用源屏幕的偏移作为初值。 + const remembered = monitorOffsets.get(monitorKey(cursorMon)) + const offsetX = remembered?.x ?? (winPos.x - winMon.position.x) + const offsetY = remembered?.y ?? (winPos.y - winMon.position.y) + + const minX = cursorMon.position.x + const maxX = cursorMon.position.x + cursorMon.size.width - winSize.width + const minY = cursorMon.position.y + const maxY = cursorMon.position.y + cursorMon.size.height - winSize.height + + const targetX = Math.max(minX, Math.min(cursorMon.position.x + offsetX, maxX)) + const targetY = Math.max(minY, Math.min(cursorMon.position.y + offsetY, maxY)) + + if (targetX === winPos.x && targetY === winPos.y) return + + await appWindow.setPosition(new PhysicalPosition(targetX, targetY)) + } + + const { pause, resume } = useIntervalFn(tick, POLL_INTERVAL_MS, { immediate: false }) + + watch( + () => generalStore.app.multiScreenFollow, + async (enabled) => { + await setMultiScreenFollow(enabled) + + if (enabled) { + resume() + } else { + monitorOffsets.clear() + pause() + } + }, + { immediate: true }, + ) +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 71b1e8b3..d5acdead 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -50,6 +50,7 @@ "launchOnStartup": "Launch on Startup", "showTaskbarIcon": "Show Taskbar Icon", "showTrayIcon": "Show Tray Icon", + "multiScreenFollow": "Follow Active Display", "appearanceSettings": "Appearance Settings", "themeMode": "Theme Mode", "language": "Language", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "When enabled, the window can be captured via OBS Studio.", "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", + "multiScreenFollow": "When enabled, the cat automatically moves to whichever display the cursor is on.", "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", "inputMonitoringPermissionGuide": "If the permission is already enabled, select it and click the \"-\" button to remove it, then manually add it again and restart the app." }, diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index fb87561f..68e0b238 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -50,6 +50,7 @@ "launchOnStartup": "Iniciar na inicialização", "showTaskbarIcon": "Mostrar ícone na barra de tarefas", "showTrayIcon": "Mostrar ícone na bandeja", + "multiScreenFollow": "Seguir o monitor ativo", "appearanceSettings": "Configurações de aparência", "themeMode": "Tema", "language": "Idiomas", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "Uma vez ativado, você pode capturar a janela via OBS Studio.", "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", + "multiScreenFollow": "Quando ativado, o BongoCat se move automaticamente para o monitor onde está o cursor.", "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", "inputMonitoringPermissionGuide": "Se a permissão já estiver ativada, primeiro selecione-a e clique no botão \"-\" para removê-la. Em seguida, adicione-a novamente manualmente e reinicie o aplicativo para garantir que a permissão entre em vigor." }, diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index 832063c4..a19c0994 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -50,6 +50,7 @@ "launchOnStartup": "Khởi động cùng hệ thống", "showTaskbarIcon": "Hiện biểu tượng trên thanh tác vụ (icon taskbar)", "showTrayIcon": "Hiện biểu tượng trên khay hệ thống (tray)", + "multiScreenFollow": "Theo màn hình hiện tại", "appearanceSettings": "Cài đặt giao diện", "themeMode": "Giao diện", "language": "Ngôn ngữ", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "Bật để có thể quay cửa sổ qua OBS.", "showTrayIcon": "Bật để hiện biểu tượng ứng dụng trên khay hệ thống.", + "multiScreenFollow": "Khi bật, BongoCat sẽ tự động di chuyển sang màn hình đang đặt con trỏ chuột.", "inputMonitoringPermission": "Bật quyền giám sát để nhận sự kiện bàn phím và chuột từ hệ thống nhằm phản hồi thao tác của bạn.", "inputMonitoringPermissionGuide": "Nếu quyền đã được bật, hãy chọn nó và nhấn nút \"-\" để xóa. Sau đó thêm lại thủ công và khởi động lại ứng dụng để đảm bảo quyền được áp dụng." }, diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 9abd52c4..6ea0ec6b 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -50,6 +50,7 @@ "launchOnStartup": "开机自启动", "showTaskbarIcon": "显示任务栏图标", "showTrayIcon": "显示托盘图标", + "multiScreenFollow": "跟随当前屏幕", "appearanceSettings": "外观设置", "themeMode": "主题模式", "language": "语言", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "启用后,即可通过 OBS Studio 捕获窗口。", "showTrayIcon": "启用后,在系统托盘中显示应用图标。", + "multiScreenFollow": "启用后,BongoCat 会自动移动到鼠标所在的显示器。", "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", "inputMonitoringPermissionGuide": "如果权限已开启,请先选中并点击“-”按钮将其删除,然后重新手动添加,最后重启应用以确保权限生效。" }, diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 9ab44a04..a74a5602 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -50,6 +50,7 @@ "launchOnStartup": "開機自動啟動", "showTaskbarIcon": "顯示工作列圖示", "showTrayIcon": "顯示托盤圖示", + "multiScreenFollow": "跟隨目前螢幕", "appearanceSettings": "外觀設定", "themeMode": "主題模式", "language": "語言", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "啟用後,即可透過 OBS Studio 擷取視窗。", "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", + "multiScreenFollow": "啟用後,BongoCat 會自動移動到滑鼠所在的螢幕。", "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", "inputMonitoringPermissionGuide": "如果權限已開啟,請先選中並點擊「-」按鈕將其刪除,然後重新手動新增,最後重啟應用程式以確保權限生效。" }, diff --git a/src/pages/main/index.vue b/src/pages/main/index.vue index b0427e67..af5f5ea9 100644 --- a/src/pages/main/index.vue +++ b/src/pages/main/index.vue @@ -16,6 +16,7 @@ import { useAppMenu } from '@/composables/useAppMenu' import { useDevice } from '@/composables/useDevice' import { useGamepad } from '@/composables/useGamepad' import { useModel } from '@/composables/useModel' +import { useMultiScreenFollow } from '@/composables/useMultiScreenFollow' import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY } from '@/constants' import { hideWindow, setAlwaysOnTop, setTaskbarVisibility, showWindow } from '@/plugins/window' @@ -39,6 +40,8 @@ const resizing = ref(false) const backgroundImagePath = ref() const { stickActive } = useGamepad() +useMultiScreenFollow() + onMounted(startListening) onUnmounted(handleDestroy) diff --git a/src/pages/preference/components/general/index.vue b/src/pages/preference/components/general/index.vue index b69a303e..4c5a49bf 100644 --- a/src/pages/preference/components/general/index.vue +++ b/src/pages/preference/components/general/index.vue @@ -6,6 +6,7 @@ import { watch } from 'vue' import ProListItem from '@/components/pro-list-item/index.vue' import ProList from '@/components/pro-list/index.vue' import { useGeneralStore } from '@/stores/general' +import { isMac } from '@/utils/platform' import MacosPermissions from './components/macos-permissions/index.vue' import ThemeMode from './components/theme-mode/index.vue' @@ -46,6 +47,14 @@ watch(() => generalStore.app.autostart, async (value) => { > + + + + diff --git a/src/plugins/window.ts b/src/plugins/window.ts index 014e3e5b..92c664ef 100644 --- a/src/plugins/window.ts +++ b/src/plugins/window.ts @@ -13,6 +13,7 @@ const COMMAND = { HIDE_WINDOW: 'plugin:custom-window|hide_window', SET_ALWAYS_ON_TOP: 'plugin:custom-window|set_always_on_top', SET_TASKBAR_VISIBILITY: 'plugin:custom-window|set_taskbar_visibility', + SET_MULTI_SCREEN_FOLLOW: 'plugin:custom-window|set_multi_screen_follow', } export function showWindow(label?: WindowLabel) { @@ -52,3 +53,7 @@ export async function toggleWindowVisible(label?: WindowLabel) { export async function setTaskbarVisibility(visible: boolean) { invoke(COMMAND.SET_TASKBAR_VISIBILITY, { visible }) } + +export function setMultiScreenFollow(enabled: boolean) { + return invoke(COMMAND.SET_MULTI_SCREEN_FOLLOW, { enabled }) +} diff --git a/src/stores/general.ts b/src/stores/general.ts index 875ac3c1..bb1384fc 100644 --- a/src/stores/general.ts +++ b/src/stores/general.ts @@ -13,6 +13,7 @@ export interface GeneralStore { autostart: boolean taskbarVisible: boolean trayVisible: boolean + multiScreenFollow: boolean } appearance: { theme: 'auto' | Theme @@ -49,6 +50,7 @@ export const useGeneralStore = defineStore('general', () => { autostart: false, taskbarVisible: false, trayVisible: true, + multiScreenFollow: false, }) const appearance = reactive({