From f893f8bc242dd850d904c6e74a5e15d2a043479f Mon Sep 17 00:00:00 2001 From: Jason Tsai Date: Thu, 28 Sep 2023 01:33:56 +0800 Subject: [PATCH] refactor(macos): backport system-tray implementation from tray-icon#69 --- .changes/refactor-macos-system-tray.md | 5 + src/platform_impl/macos/system_tray.rs | 387 ++++++++++++++----------- 2 files changed, 225 insertions(+), 167 deletions(-) create mode 100644 .changes/refactor-macos-system-tray.md diff --git a/.changes/refactor-macos-system-tray.md b/.changes/refactor-macos-system-tray.md new file mode 100644 index 000000000..246410b74 --- /dev/null +++ b/.changes/refactor-macos-system-tray.md @@ -0,0 +1,5 @@ +--- +"tao": patch +--- + +Refactor macOS system-tray implementation to fix missing click and window focus issues. (tray-icon#69) \ No newline at end of file diff --git a/src/platform_impl/macos/system_tray.rs b/src/platform_impl/macos/system_tray.rs index c51fb58d7..9a2699545 100644 --- a/src/platform_impl/macos/system_tray.rs +++ b/src/platform_impl/macos/system_tray.rs @@ -17,19 +17,28 @@ use crate::{ TrayId, }; use cocoa::{ - appkit::{ - NSButton, NSEventMask, NSEventModifierFlags, NSEventType, NSImage, NSStatusBar, NSStatusItem, - NSVariableStatusItemLength, NSWindow, - }, + appkit::{NSButton, NSImage, NSStatusBar, NSStatusItem, NSVariableStatusItemLength, NSWindow}, base::{id, nil, NO, YES}, - foundation::{NSData, NSPoint, NSSize, NSString}, + foundation::{NSData, NSInteger, NSPoint, NSRect, NSSize, NSString}, }; use objc::{ declare::ClassDecl, - runtime::{Class, Object, Protocol, Sel}, + runtime::{Class, Object, Sel}, }; use std::sync::Once; +const TRAY_ID: &str = "id"; +const TRAY_STATUS_ITEM: &str = "status_item"; +const TRAY_MENU: &str = "menu"; +const TRAY_MENU_ON_LEFT_CLICK: &str = "menu_on_left_click"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +enum ClickType { + Left, + Right, +} + pub struct SystemTrayBuilder { pub(crate) system_tray: SystemTray, } @@ -38,21 +47,37 @@ impl SystemTrayBuilder { /// Creates a new SystemTray for platforms where this is appropriate. #[inline] pub fn new(icon: Icon, tray_menu: Option) -> Self { + let (ns_status_bar, tray_target) = Self::create(icon.clone()); + + Self { + system_tray: SystemTray { + icon_is_template: false, + icon, + menu_on_left_click: true, + tray_menu, + ns_status_bar, + title: None, + tray_target, + }, + } + } + + fn create(icon: Icon) -> (id, id) { unsafe { - let ns_status_bar = + let ns_status_item = NSStatusBar::systemStatusBar(nil).statusItemWithLength_(NSVariableStatusItemLength); - let _: () = msg_send![ns_status_bar, retain]; - - Self { - system_tray: SystemTray { - icon_is_template: false, - icon, - menu_on_left_click: true, - tray_menu, - ns_status_bar, - title: None, - }, - } + let _: () = msg_send![ns_status_item, retain]; + + set_icon_for_ns_status_item_button(ns_status_item, icon, false); + + let button = ns_status_item.button(); + let frame: NSRect = msg_send![button, frame]; + let target: id = msg_send![make_tray_target_class(), alloc]; + let tray_target: id = msg_send![target, initWithFrame: frame]; + let _: () = msg_send![tray_target, retain]; + let _: () = msg_send![tray_target, setWantsLayer: YES]; + + (ns_status_item, tray_target) } } @@ -66,35 +91,23 @@ impl SystemTrayBuilder { ) -> Result { unsafe { // use our existing status bar - let status_bar = self.system_tray.ns_status_bar; - - // set our icon - self.system_tray.create_button_with_icon(); - - // attach click event to our button - let button = status_bar.button(); - let tray_target: id = msg_send![make_tray_class(), alloc]; - let tray_target: id = msg_send![tray_target, init]; - (*tray_target).set_ivar("id", tray_id.0); - (*tray_target).set_ivar("status_bar", status_bar); - (*tray_target).set_ivar("menu", nil); - (*tray_target).set_ivar("menu_on_left_click", self.system_tray.menu_on_left_click); - let _: () = msg_send![button, setAction: sel!(click:)]; - let _: () = msg_send![button, setTarget: tray_target]; - let _: () = msg_send![ - button, - sendActionOn: NSEventMask::NSLeftMouseDownMask - | NSEventMask::NSRightMouseDownMask - | NSEventMask::NSKeyDownMask - ]; + let ns_status_item = self.system_tray.ns_status_bar; + + let tray_target = self.system_tray.tray_target; + (*tray_target).set_ivar(TRAY_ID, tray_id.0); + (*tray_target).set_ivar(TRAY_STATUS_ITEM, ns_status_item); + (*tray_target).set_ivar(TRAY_MENU, nil); + (*tray_target).set_ivar(TRAY_MENU_ON_LEFT_CLICK, self.system_tray.menu_on_left_click); + + let button: id = ns_status_item.button(); + let _: () = msg_send![button, addSubview: tray_target]; // attach menu only if provided if let Some(menu) = self.system_tray.tray_menu.clone() { - // We set the tray menu to tray_target instead of status bar - // Because setting directly to status bar will overwrite the event callback of the button - // See `make_tray_class` for more information. - (*tray_target).set_ivar("menu", menu.menu); - let () = msg_send![menu.menu, setDelegate: tray_target]; + ns_status_item.setMenu_(menu.menu); + + (*tray_target).set_ivar(TRAY_MENU, menu.menu); + let () = msg_send![menu.menu, setDelegate: ns_status_item]; } // attach tool_tip if provided @@ -121,22 +134,37 @@ pub struct SystemTray { pub(crate) tray_menu: Option, pub(crate) ns_status_bar: id, pub(crate) title: Option, + pub(crate) tray_target: id, } impl Drop for SystemTray { fn drop(&mut self) { + self.remove(); + } +} + +impl SystemTray { + fn remove(&mut self) { unsafe { NSStatusBar::systemStatusBar(nil).removeStatusItem_(self.ns_status_bar); let _: () = msg_send![self.ns_status_bar, release]; } + + unsafe { + let _: () = msg_send![self.tray_target, removeFromSuperview]; + let _: () = msg_send![self.tray_target, release]; + } + + self.ns_status_bar = nil; + self.tray_target = nil; } -} -impl SystemTray { pub fn set_icon(&mut self, icon: Icon) { - // update our icon + set_icon_for_ns_status_item_button(self.ns_status_bar, icon.clone(), self.icon_is_template); + unsafe { + let _: () = msg_send![self.tray_target, updateDimensions]; + } self.icon = icon; - self.create_button_with_icon(); } pub fn set_icon_as_template(mut self, is_template: bool) { @@ -153,159 +181,184 @@ impl SystemTray { unsafe { let tooltip = NSString::alloc(nil).init_str(tooltip); let _: () = msg_send![self.ns_status_bar.button(), setToolTip: tooltip]; + let _: () = msg_send![self.tray_target, updateDimensions]; } } pub fn set_title(&self, title: &str) { unsafe { - NSButton::setTitle_( - self.ns_status_bar.button(), - NSString::alloc(nil).init_str(title), - ); + let title = NSString::alloc(nil).init_str(title); + let _: () = msg_send![self.ns_status_bar.button(), setTitle: title]; + let _: () = msg_send![self.tray_target, updateDimensions]; } } +} - fn create_button_with_icon(&self) { - // The image is to the right of the title https://developer.apple.com/documentation/appkit/nscellimageposition/nsimageleft - const NSIMAGE_LEFT: i32 = 2; +fn set_icon_for_ns_status_item_button(ns_status_item: id, icon: Icon, icon_is_template: bool) { + // The image is to the right of the title https://developer.apple.com/documentation/appkit/nscellimageposition/nsimageleft + const NSIMAGE_LEFT: i32 = 2; - let icon = self.icon.inner.to_png(); + let png_icon = icon.inner.to_png(); - let (width, height) = self.icon.inner.get_size(); + let (width, height) = icon.inner.get_size(); - let icon_height: f64 = 18.0; - let icon_width: f64 = (width as f64) / (height as f64 / icon_height); + let icon_height: f64 = 18.0; + let icon_width: f64 = (width as f64) / (height as f64 / icon_height); - unsafe { - let status_item = self.ns_status_bar; - let button = status_item.button(); - - // build our icon - let nsdata = NSData::dataWithBytes_length_( - nil, - icon.as_ptr() as *const std::os::raw::c_void, - icon.len() as u64, - ); - - let nsimage = NSImage::initWithData_(NSImage::alloc(nil), nsdata); - let new_size = NSSize::new(icon_width, icon_height); - - button.setImage_(nsimage); - let _: () = msg_send![nsimage, setSize: new_size]; - let _: () = msg_send![button, setImagePosition: NSIMAGE_LEFT]; - let is_template = match self.icon_is_template { - true => YES, - false => NO, - }; - let _: () = msg_send![nsimage, setTemplate: is_template]; - } + unsafe { + let status_item = ns_status_item; + let button = status_item.button(); + + // build our icon + let nsdata = NSData::dataWithBytes_length_( + nil, + png_icon.as_ptr() as *const std::os::raw::c_void, + png_icon.len() as u64, + ); + + let nsimage = NSImage::initWithData_(NSImage::alloc(nil), nsdata); + let new_size = NSSize::new(icon_width, icon_height); + + button.setImage_(nsimage); + let _: () = msg_send![nsimage, setSize: new_size]; + let _: () = msg_send![button, setImagePosition: NSIMAGE_LEFT]; + let _: () = msg_send![nsimage, setTemplate: icon_is_template as i8]; } } /// Create a `TrayHandler` Class that handle button click event and also menu opening and closing. /// /// We set the tray menu to tray_target instead of status bar, because setting directly to status bar -/// will overwrite the event callback of the button. When `perform_tray_click` called, it will set +/// will overwrite the event callback of the button. When `on_tray_click` called, it will set /// the menu to status bar in the end. And when the menu is closed `menu_did_close` will set it to /// nil again. -fn make_tray_class() -> *const Class { +fn make_tray_target_class() -> *const Class { static mut TRAY_CLASS: *const Class = 0 as *const Class; static INIT: Once = Once::new(); INIT.call_once(|| unsafe { - let superclass = class!(NSObject); - let mut decl = ClassDecl::new("TaoTrayHandler", superclass).unwrap(); - decl.add_ivar::("status_bar"); - decl.add_ivar::("menu"); - decl.add_ivar::("menu_on_left_click"); - decl.add_ivar::("id"); + let superclass = class!(NSView); + let mut decl = ClassDecl::new("TaoTrayTarget", superclass).unwrap(); + + decl.add_ivar::(TRAY_STATUS_ITEM); + decl.add_ivar::(TRAY_MENU); + decl.add_ivar::(TRAY_MENU_ON_LEFT_CLICK); + decl.add_ivar::(TRAY_ID); + + decl.add_method(sel!(dealloc), dealloc as extern "C" fn(&mut Object, _)); decl.add_method( - sel!(click:), - perform_tray_click as extern "C" fn(&mut Object, _, id), + sel!(mouseDown:), + on_mouse_down as extern "C" fn(&mut Object, _, id), + ); + decl.add_method( + sel!(rightMouseDown:), + on_right_mouse_down as extern "C" fn(&mut Object, _, id), + ); + decl.add_method( + sel!(mouseUp:), + on_mouse_up as extern "C" fn(&mut Object, _, id), ); - - let delegate = Protocol::get("NSMenuDelegate").unwrap(); - decl.add_protocol(&delegate); decl.add_method( - sel!(menuDidClose:), - menu_did_close as extern "C" fn(&mut Object, _, id), + sel!(updateDimensions), + update_dimensions as extern "C" fn(&mut Object, _), ); - TRAY_CLASS = decl.register(); - }); + extern "C" fn dealloc(this: &mut Object, _: Sel) { + unsafe { + this.set_ivar(TRAY_MENU, nil); + this.set_ivar(TRAY_STATUS_ITEM, nil); - unsafe { TRAY_CLASS } -} + let _: () = msg_send![super(this, class!(NSView)), dealloc]; + } + } -/// This will fire for an NSButton callback. -extern "C" fn perform_tray_click(this: &mut Object, _: Sel, button: id) { - unsafe { - let id = this.get_ivar::("id"); - let app: id = msg_send![class!(NSApplication), sharedApplication]; - let current_event: id = msg_send![app, currentEvent]; - - // icon position & size - let window: id = msg_send![current_event, window]; - let frame = NSWindow::frame(window); - let scale_factor = NSWindow::backingScaleFactor(window) as f64; - let position: PhysicalPosition = LogicalPosition::new( - frame.origin.x as f64, - bottom_left_to_top_left_for_tray(frame), - ) - .to_physical(scale_factor); - - let logical: LogicalSize = (frame.size.width as f64, frame.size.height as f64).into(); - let size: PhysicalSize = logical.to_physical(scale_factor); - - // cursor position - let mouse_location: NSPoint = msg_send![class!(NSEvent), mouseLocation]; - // what type of click? - let event_mask: NSEventType = msg_send![current_event, type]; - // grab the modifier flag, to make sure the ctrl + left click = right click - let key_code: NSEventModifierFlags = msg_send![current_event, modifierFlags]; - - let click_type = match event_mask { - // left click + control key - NSEventType::NSLeftMouseDown if key_code.contains(NSEventModifierFlags::NSControlKeyMask) => { - Some(TrayEvent::RightClick) + extern "C" fn on_mouse_down(this: &mut Object, _: Sel, event: id) { + on_tray_click(this, event, ClickType::Left); + } + + extern "C" fn on_right_mouse_down(this: &mut Object, _: Sel, event: id) { + on_tray_click(this, event, ClickType::Right); + } + + extern "C" fn on_mouse_up(this: &mut Object, _: Sel, _event: id) { + unsafe { + let ns_status_item = this.get_ivar::(TRAY_STATUS_ITEM); + let button: id = ns_status_item.button(); + let _: () = msg_send![button, highlight: NO]; + } + } + + extern "C" fn update_dimensions(this: &mut Object, _: Sel) { + unsafe { + let ns_status_item = this.get_ivar::(TRAY_STATUS_ITEM); + let button: id = msg_send![*ns_status_item, button]; + + let frame: NSRect = msg_send![button, frame]; + let _: () = msg_send![this, setFrame: frame]; } - NSEventType::NSLeftMouseDown => Some(TrayEvent::LeftClick), - NSEventType::NSRightMouseDown => Some(TrayEvent::RightClick), - _ => None, - }; - - if let Some(click_event) = click_type { - let event = Event::TrayEvent { - id: TrayId(*id as u16), - bounds: Rectangle { position, size }, - position: PhysicalPosition::new( - mouse_location.x, - bottom_left_to_top_left_for_cursor(mouse_location), - ), - event: click_event, - }; - - AppState::queue_event(EventWrapper::StaticEvent(event)); - - let menu = this.get_ivar::("menu"); - if *menu != nil { - let menu_on_left_click = this.get_ivar::("menu_on_left_click"); - if click_event == TrayEvent::RightClick - || (*menu_on_left_click && click_event == TrayEvent::LeftClick) + } + + fn on_tray_click(this: &mut Object, event: id, click_type: ClickType) { + unsafe { + let id = this.get_ivar::(TRAY_ID); + + // icon position & size + let window: id = msg_send![event, window]; + let frame = NSWindow::frame(window); + let scale_factor = NSWindow::backingScaleFactor(window) as f64; + let position: PhysicalPosition = LogicalPosition::new( + frame.origin.x as f64, + bottom_left_to_top_left_for_tray(frame), + ) + .to_physical(scale_factor); + + let logical: LogicalSize = (frame.size.width as f64, frame.size.height as f64).into(); + let size: PhysicalSize = logical.to_physical(scale_factor); + + // cursor position + let mouse_location: NSPoint = msg_send![class!(NSEvent), mouseLocation]; + + let event = Event::TrayEvent { + id: TrayId(*id), + bounds: Rectangle { position, size }, + position: PhysicalPosition::new( + mouse_location.x, + bottom_left_to_top_left_for_cursor(mouse_location), + ), + event: match click_type { + ClickType::Left => TrayEvent::LeftClick, + ClickType::Right => TrayEvent::RightClick, + }, + }; + + AppState::queue_event(EventWrapper::StaticEvent(event)); + + let status_item = *this.get_ivar::(TRAY_STATUS_ITEM); + let button: id = msg_send![status_item, button]; + + let menu_on_left_click = this.get_ivar::(TRAY_MENU_ON_LEFT_CLICK); + if click_type == ClickType::Right || (*menu_on_left_click && click_type == ClickType::Left) { - let status_bar = this.get_ivar::("status_bar"); - status_bar.setMenu_(*menu); - let () = msg_send![button, performClick: nil]; + let menu = *this.get_ivar::(TRAY_MENU); + let has_items = if menu == nil { + false + } else { + let num: NSInteger = msg_send![menu, numberOfItems]; + num > 0 + }; + if has_items { + let _: () = msg_send![button, performClick: nil]; + } else { + let _: () = msg_send![button, highlight: YES]; + } + } else { + let _: () = msg_send![button, highlight: YES]; } } } - } -} -// Set the menu of the status bar to nil, so it won't overwrite the button events. -extern "C" fn menu_did_close(this: &mut Object, _: Sel, _menu: id) { - unsafe { - let status_bar = this.get_ivar::("status_bar"); - status_bar.setMenu_(nil); - } + TRAY_CLASS = decl.register(); + }); + + unsafe { TRAY_CLASS } }