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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions src-tauri/src/plugins/window/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
1 change: 1 addition & 0 deletions src-tauri/src/plugins/window/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[
"hide_window",
"set_always_on_top",
"set_taskbar_visibility",
"set_multi_screen_follow",
];

fn main() {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/plugins/window/permissions/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
9 changes: 9 additions & 0 deletions src-tauri/src/plugins/window/src/commands/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ pub async fn set_always_on_top<R: Runtime>(
pub async fn set_taskbar_visibility<R: Runtime>(window: WebviewWindow<R>, visible: bool) {
let _ = window.set_skip_taskbar(!visible);
}

// 多屏跟随由前端统一控制,Linux 上窗口可自由跨屏,无需调整原生行为。
#[command]
pub async fn set_multi_screen_follow<R: Runtime>(
_app_handle: AppHandle<R>,
_window: WebviewWindow<R>,
_enabled: bool,
) {
}
74 changes: 60 additions & 14 deletions src-tauri/src/plugins/window/src/commands/macos.rs
Original file line number Diff line number Diff line change
@@ -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<R: Runtime>(window: &WebviewWindow<R>) -> bool {
window.label() == MAIN_WINDOW_LABEL
}
Expand All @@ -27,24 +64,12 @@ fn set_macos_panel<R: Runtime>(
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 {
Expand Down Expand Up @@ -106,3 +131,24 @@ pub async fn set_always_on_top<R: Runtime>(
pub async fn set_taskbar_visibility<R: Runtime>(app_handle: AppHandle<R>, visible: bool) {
let _ = app_handle.set_dock_visibility(visible);
}

#[command]
pub async fn set_multi_screen_follow<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
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());
}
});
}
9 changes: 9 additions & 0 deletions src-tauri/src/plugins/window/src/commands/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,12 @@ pub async fn set_always_on_top<R: Runtime>(
pub async fn set_taskbar_visibility<R: Runtime>(window: WebviewWindow<R>, visible: bool) {
let _ = window.set_skip_taskbar(!visible);
}

// 多屏跟随由前端统一控制,Windows 上窗口可自由跨屏,无需调整原生行为。
#[command]
pub async fn set_multi_screen_follow<R: Runtime>(
_app_handle: AppHandle<R>,
_window: WebviewWindow<R>,
_enabled: bool,
) {
}
1 change: 1 addition & 0 deletions src-tauri/src/plugins/window/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::hide_window,
commands::set_always_on_top,
commands::set_taskbar_visibility,
commands::set_multi_screen_follow,
])
.build()
}
103 changes: 103 additions & 0 deletions src/composables/useMultiScreenFollow.ts
Original file line number Diff line number Diff line change
@@ -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<string, { x: number, y: number }>()

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 },
)
}
2 changes: 2 additions & 0 deletions src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/vi-VN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ữ",
Expand All @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"launchOnStartup": "开机自启动",
"showTaskbarIcon": "显示任务栏图标",
"showTrayIcon": "显示托盘图标",
"multiScreenFollow": "跟随当前屏幕",
"appearanceSettings": "外观设置",
"themeMode": "主题模式",
"language": "语言",
Expand All @@ -66,6 +67,7 @@
"hints": {
"showTaskbarIcon": "启用后,即可通过 OBS Studio 捕获窗口。",
"showTrayIcon": "启用后,在系统托盘中显示应用图标。",
"multiScreenFollow": "启用后,BongoCat 会自动移动到鼠标所在的显示器。",
"inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。",
"inputMonitoringPermissionGuide": "如果权限已开启,请先选中并点击“-”按钮将其删除,然后重新手动添加,最后重启应用以确保权限生效。"
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"launchOnStartup": "開機自動啟動",
"showTaskbarIcon": "顯示工作列圖示",
"showTrayIcon": "顯示托盤圖示",
"multiScreenFollow": "跟隨目前螢幕",
"appearanceSettings": "外觀設定",
"themeMode": "主題模式",
"language": "語言",
Expand All @@ -66,6 +67,7 @@
"hints": {
"showTaskbarIcon": "啟用後,即可透過 OBS Studio 擷取視窗。",
"showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。",
"multiScreenFollow": "啟用後,BongoCat 會自動移動到滑鼠所在的螢幕。",
"inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。",
"inputMonitoringPermissionGuide": "如果權限已開啟,請先選中並點擊「-」按鈕將其刪除,然後重新手動新增,最後重啟應用程式以確保權限生效。"
},
Expand Down
3 changes: 3 additions & 0 deletions src/pages/main/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -39,6 +40,8 @@ const resizing = ref(false)
const backgroundImagePath = ref<string>()
const { stickActive } = useGamepad()

useMultiScreenFollow()

onMounted(startListening)

onUnmounted(handleDestroy)
Expand Down
9 changes: 9 additions & 0 deletions src/pages/preference/components/general/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -46,6 +47,14 @@ watch(() => generalStore.app.autostart, async (value) => {
>
<Switch v-model:checked="generalStore.app.trayVisible" />
</ProListItem>

<ProListItem
v-if="isMac"
:description="$t('pages.preference.general.hints.multiScreenFollow')"
:title="$t('pages.preference.general.labels.multiScreenFollow')"
>
<Switch v-model:checked="generalStore.app.multiScreenFollow" />
</ProListItem>
</ProList>

<ProList :title="$t('pages.preference.general.labels.appearanceSettings')">
Expand Down
Loading