From 992ebd848de6bde595fe7005a3562a6d32282ff2 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Fri, 19 May 2023 20:08:08 +0530 Subject: [PATCH] feat: add ns-panel support (#44) --- src-tauri/Cargo.lock | 41 ++++ src-tauri/Cargo.toml | 13 +- src-tauri/src/main.rs | 7 +- src-tauri/src/ns_panel.rs | 428 ++++++++++++++++++++++++++++++++++++++ src/main.ts | 4 +- 5 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/ns_panel.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cd80d4a..131d273 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1771,6 +1771,17 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -2660,6 +2671,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3358acbb4acd4146138b9bda219e904a6bb5aaaa237f8eed06f4d6bc1580ecee" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", + "winapi", +] + [[package]] name = "system-deps" version = "5.0.0" @@ -3247,15 +3271,22 @@ dependencies = [ "auto-launch", "chrono", "chrono-tz", + "cocoa", + "core-foundation", + "core-graphics", "directories", "localzone", "num-format", + "objc", + "objc-foundation", + "objc_id", "plist", "rust_search", "serde", "serde_json", "smartcalc", "strsim", + "sys-locale", "tauri", "tauri-build", "window-vibrancy", @@ -3344,6 +3375,16 @@ version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" +[[package]] +name = "web-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "0.18.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8ae3bc..8646c7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,15 @@ smartcalc = { git = "https://github.com/ParthJadhav/smartcalc", branch = "stable chrono-tz = { version = "0.6.1", default-features = false } num-format = { version = "0.4", features = ["with-system-locale"] } localzone = "0.2.0" +sys-locale = "0.2.3" + +[target."cfg(target_os = \"macos\")".dependencies] +core-graphics = {version = "0.22.3"} +core-foundation = { version = "0.9.3" } +cocoa = { version = "0.24.1" } +objc = { version = "0.2.7" } +objc_id = {version = "0.1.1" } +objc-foundation = { version = "0.1.1" } [dependencies.chrono] version = "0.4" @@ -34,7 +43,7 @@ version = "0.4" [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL -default = [ "custom-protocol" ] +default = ["custom-protocol"] # this feature is used used for production builds where `devPath` points to the filesystem # DO NOT remove this -custom-protocol = [ "tauri/custom-protocol" ] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 92b069b..a739147 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![warn(clippy::nursery, clippy::pedantic)] mod util; +mod ns_panel; use tauri::{ CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, @@ -36,7 +37,10 @@ fn main() { open_command, get_icon, handle_input, - launch_on_login + launch_on_login, + ns_panel::init_ns_panel, + ns_panel::show_app, + ns_panel::hide_app ]) .setup(|app| { app.set_activation_policy(tauri::ActivationPolicy::Accessory); @@ -47,6 +51,7 @@ fn main() { window.hide().unwrap(); Ok(()) }) + .manage(ns_panel::State::default()) .system_tray(create_system_tray()) .on_system_tray_event(|app, event| match event { SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { diff --git a/src-tauri/src/ns_panel.rs b/src-tauri/src/ns_panel.rs new file mode 100644 index 0000000..58f277e --- /dev/null +++ b/src-tauri/src/ns_panel.rs @@ -0,0 +1,428 @@ +use std::sync::{Mutex, Once}; + +use objc_id::{Id, ShareId}; +use tauri::{ + AppHandle, GlobalShortcutManager, Manager, PhysicalPosition, PhysicalSize, Window, Wry, +}; + +use cocoa::{ + appkit::{CGFloat, NSMainMenuWindowLevel, NSWindow, NSWindowCollectionBehavior}, + base::{id, nil, BOOL, NO, YES}, + foundation::{NSPoint, NSRect}, +}; +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{self, Class, Object, Protocol, Sel}, + sel, sel_impl, Message, +}; +use objc_foundation::INSObject; + +#[link(name = "Foundation", kind = "framework")] +extern "C" { + pub fn NSMouseInRect(aPoint: NSPoint, aRect: NSRect, flipped: BOOL) -> BOOL; +} + +#[derive(Default)] +pub struct Store { + panel: Option>, +} + +#[derive(Default)] +pub struct State(pub Mutex); + +#[macro_export] +macro_rules! set_state { + ($app_handle:expr, $field:ident, $value:expr) => {{ + let handle = $app_handle.app_handle(); + handle + .state::<$crate::ns_panel::State>() + .0 + .lock() + .unwrap() + .$field = $value; + }}; +} + +#[macro_export] +macro_rules! get_state { + ($app_handle:expr, $field:ident) => {{ + let handle = $app_handle.app_handle(); + let value = handle + .state::<$crate::ns_panel::State>() + .0 + .lock() + .unwrap() + .$field; + + value + }}; + ($app_handle:expr, $field:ident, $action:ident) => {{ + let handle = $app_handle.app_handle(); + let value = handle + .state::<$crate::ns_panel::State>() + .0 + .lock() + .unwrap() + .$field + .$action(); + + value + }}; +} + +#[macro_export] +macro_rules! panel { + ($app_handle:expr) => {{ + let handle = $app_handle.app_handle(); + let panel = handle + .state::<$crate::ns_panel::State>() + .0 + .lock() + .unwrap() + .panel + .clone(); + + panel.unwrap() + }}; +} + +#[macro_export] +macro_rules! nsstring_to_string { + ($ns_string:expr) => {{ + use objc::{sel, sel_impl}; + let utf8: id = unsafe { objc::msg_send![$ns_string, UTF8String] }; + let string = if !utf8.is_null() { + Some(unsafe { + { + std::ffi::CStr::from_ptr(utf8 as *const std::ffi::c_char) + .to_string_lossy() + .into_owned() + } + }) + } else { + None + }; + + string + }}; +} + +static INIT: Once = Once::new(); +static PANEL_LABEL: &str = "main"; + +#[tauri::command] +pub fn init_ns_panel(app_handle: AppHandle, window: Window, shortcut: &str) { + INIT.call_once(|| { + set_state!(app_handle, panel, Some(create_ns_panel(&window))); + register_shortcut(app_handle, shortcut); + }); +} + +fn register_shortcut(app_handle: AppHandle, shortcut: &str) { + let mut shortcut_manager = app_handle.global_shortcut_manager(); + let window = app_handle.get_window(PANEL_LABEL).unwrap(); + + let panel = panel!(app_handle); + shortcut_manager + .register(shortcut, move || { + position_window_at_the_center_of_the_monitor_with_cursor(&window); + + if panel.is_visible() { + hide_app(window.app_handle()); + } else { + show_app(window.app_handle()); + }; + }) + .unwrap(); +} + +#[tauri::command] +pub fn show_app(app_handle: AppHandle) { + panel!(app_handle).show(); +} + +#[tauri::command] +pub fn hide_app(app_handle: AppHandle) { + panel!(app_handle).order_out(None); +} + +/// Positions a given window at the center of the monitor with cursor +fn position_window_at_the_center_of_the_monitor_with_cursor(window: &Window) { + if let Some(monitor) = get_monitor_with_cursor() { + let display_size = monitor.size.to_logical::(monitor.scale_factor); + let display_pos = monitor.position.to_logical::(monitor.scale_factor); + + let handle: id = window.ns_window().unwrap() as _; + let win_frame: NSRect = unsafe { handle.frame() }; + let rect = NSRect { + origin: NSPoint { + x: (display_pos.x + (display_size.width / 2.0)) - (win_frame.size.width / 2.0), + y: (display_pos.y + (display_size.height / 2.0)) - (win_frame.size.height / 2.0), + }, + size: win_frame.size, + }; + let _: () = unsafe { msg_send![handle, setFrame: rect display: YES] }; + } +} + +struct Monitor { + #[allow(dead_code)] + pub name: Option, + pub size: PhysicalSize, + pub position: PhysicalPosition, + pub scale_factor: f64, +} + +/// Gets the Monitor with cursor +fn get_monitor_with_cursor() -> Option { + objc::rc::autoreleasepool(|| { + let mouse_location: NSPoint = unsafe { msg_send![class!(NSEvent), mouseLocation] }; + let screens: id = unsafe { msg_send![class!(NSScreen), screens] }; + let screens_iter: id = unsafe { msg_send![screens, objectEnumerator] }; + let mut next_screen: id; + + let frame_with_cursor: Option = loop { + next_screen = unsafe { msg_send![screens_iter, nextObject] }; + if next_screen == nil { + break None; + } + + let frame: NSRect = unsafe { msg_send![next_screen, frame] }; + let is_mouse_in_screen_frame: BOOL = + unsafe { NSMouseInRect(mouse_location, frame, NO) }; + if is_mouse_in_screen_frame == YES { + break Some(frame); + } + }; + + if let Some(frame) = frame_with_cursor { + let name: id = unsafe { msg_send![next_screen, localizedName] }; + let screen_name = nsstring_to_string!(name); + let scale_factor: CGFloat = unsafe { msg_send![next_screen, backingScaleFactor] }; + let scale_factor: f64 = scale_factor; + + return Some(Monitor { + name: screen_name, + position: PhysicalPosition { + x: (frame.origin.x * scale_factor) as i32, + y: (frame.origin.y * scale_factor) as i32, + }, + size: PhysicalSize { + width: (frame.size.width * scale_factor) as u32, + height: (frame.size.height * scale_factor) as u32, + }, + scale_factor, + }); + } + + None + }) +} + +extern "C" { + pub fn object_setClass(obj: id, cls: id) -> id; +} + +#[allow(non_upper_case_globals)] +const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7; + +const CLS_NAME: &str = "RawNSPanel"; + +pub struct RawNSPanel; + +impl RawNSPanel { + fn get_class() -> &'static Class { + Class::get(CLS_NAME).unwrap_or_else(Self::define_class) + } + + fn define_class() -> &'static Class { + let mut cls = ClassDecl::new(CLS_NAME, class!(NSPanel)) + .unwrap_or_else(|| panic!("Unable to register {} class", CLS_NAME)); + + unsafe { + cls.add_method( + sel!(canBecomeKeyWindow), + Self::can_become_key_window as extern "C" fn(&Object, Sel) -> BOOL, + ); + } + + cls.register() + } + + /// Returns YES to ensure that RawNSPanel can become a key window + extern "C" fn can_become_key_window(_: &Object, _: Sel) -> BOOL { + YES + } +} +unsafe impl Message for RawNSPanel {} + +impl RawNSPanel { + fn show(&self) { + self.make_first_responder(Some(self.content_view())); + self.order_front_regardless(); + self.make_key_window(); + } + + fn is_visible(&self) -> bool { + let flag: BOOL = unsafe { msg_send![self, isVisible] }; + flag == YES + } + + fn make_key_window(&self) { + let _: () = unsafe { msg_send![self, makeKeyWindow] }; + } + + fn order_front_regardless(&self) { + let _: () = unsafe { msg_send![self, orderFrontRegardless] }; + } + + fn order_out(&self, sender: Option) { + let _: () = unsafe { msg_send![self, orderOut: sender.unwrap_or(nil)] }; + } + + fn content_view(&self) -> id { + unsafe { msg_send![self, contentView] } + } + + fn make_first_responder(&self, sender: Option) { + if let Some(responder) = sender { + let _: () = unsafe { msg_send![self, makeFirstResponder: responder] }; + } else { + let _: () = unsafe { msg_send![self, makeFirstResponder: self] }; + } + } + + fn set_level(&self, level: i32) { + let _: () = unsafe { msg_send![self, setLevel: level] }; + } + + fn set_style_mask(&self, style_mask: i32) { + let _: () = unsafe { msg_send![self, setStyleMask: style_mask] }; + } + + fn set_collection_behaviour(&self, behaviour: NSWindowCollectionBehavior) { + let _: () = unsafe { msg_send![self, setCollectionBehavior: behaviour] }; + } + + fn set_delegate(&self, delegate: Option>) { + if let Some(del) = delegate { + let _: () = unsafe { msg_send![self, setDelegate: del] }; + } else { + let _: () = unsafe { msg_send![self, setDelegate: self] }; + } + } + + /// Create an NSPanel from Tauri's NSWindow + fn from(ns_window: id) -> Id { + let ns_panel: id = unsafe { msg_send![Self::class(), class] }; + unsafe { + object_setClass(ns_window, ns_panel); + Id::from_retained_ptr(ns_window as *mut Self) + } + } +} + +impl INSObject for RawNSPanel { + fn class() -> &'static runtime::Class { + RawNSPanel::get_class() + } +} + +#[allow(dead_code)] +const DELEGATE_CLS_NAME: &str = "RawNSPanelDelegate"; + +#[allow(dead_code)] +struct RawNSPanelDelegate {} + +impl RawNSPanelDelegate { + #[allow(dead_code)] + fn get_class() -> &'static Class { + Class::get(DELEGATE_CLS_NAME).unwrap_or_else(Self::define_class) + } + + #[allow(dead_code)] + fn define_class() -> &'static Class { + let mut cls = ClassDecl::new(DELEGATE_CLS_NAME, class!(NSObject)) + .unwrap_or_else(|| panic!("Unable to register {} class", DELEGATE_CLS_NAME)); + + cls.add_protocol( + Protocol::get("NSWindowDelegate").expect("Failed to get NSWindowDelegate protocol"), + ); + + unsafe { + cls.add_ivar::("panel"); + + cls.add_method( + sel!(setPanel:), + Self::set_panel as extern "C" fn(&mut Object, Sel, id), + ); + + cls.add_method( + sel!(windowDidBecomeKey:), + Self::window_did_become_key as extern "C" fn(&Object, Sel, id), + ); + + cls.add_method( + sel!(windowDidResignKey:), + Self::window_did_resign_key as extern "C" fn(&Object, Sel, id), + ); + } + + cls.register() + } + + extern "C" fn set_panel(this: &mut Object, _: Sel, panel: id) { + unsafe { this.set_ivar("panel", panel) }; + } + + extern "C" fn window_did_become_key(_: &Object, _: Sel, _: id) {} + + /// Hide panel when it's no longer the key window + extern "C" fn window_did_resign_key(this: &Object, _: Sel, _: id) { + let panel: id = unsafe { *this.get_ivar("panel") }; + let _: () = unsafe { msg_send![panel, orderOut: nil] }; + } +} + +unsafe impl Message for RawNSPanelDelegate {} + +impl INSObject for RawNSPanelDelegate { + fn class() -> &'static runtime::Class { + Self::get_class() + } +} + +impl RawNSPanelDelegate { + pub fn set_panel_(&self, panel: ShareId) { + let _: () = unsafe { msg_send![self, setPanel: panel] }; + } +} + +fn create_ns_panel(window: &Window) -> ShareId { + // Convert NSWindow Object to NSPanel + let handle: id = window.ns_window().unwrap() as _; + let panel = RawNSPanel::from(handle); + let panel = panel.share(); + + // Set panel above the main menu window level + panel.set_level(NSMainMenuWindowLevel + 1); + + // Ensure that the panel can display over the top of fullscreen apps + panel.set_collection_behaviour( + NSWindowCollectionBehavior::NSWindowCollectionBehaviorTransient + | NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace + | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary, + ); + + // Ensures panel does not activate + panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel); + + // Setup delegate for an NSPanel to listen for window resign key and hide the panel + let delegate = RawNSPanelDelegate::new(); + delegate.set_panel_(panel.clone()); + panel.set_delegate(Some(delegate)); + + panel +} diff --git a/src/main.ts b/src/main.ts index f412a33..8b1104c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,8 @@ const reloadTheme = async () => { await fetchPreferencesData(); await reloadTheme(); + await invoke("init_ns_panel", {shortcut: preferences.get('shortcut')}); + document.addEventListener('keydown', event => { if (event.key === 'Escape') { appWindow.hide(); @@ -62,8 +64,6 @@ const reloadTheme = async () => { await invoke('launch_on_login', { enable: preferences.get('launch_on_login'), }); - - await listenForHotkey(preferences.get('shortcut')); })(); export async function listenForHotkey(shortcut: string) {