use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::process::Command;
use objc2_app_kit::{
NSAlert, NSAlertFirstButtonReturn, NSAlertStyle, NSApplication, NSBeep, NSColor, NSColorSpace,
NSEvent, NSOpenPanel, NSSavePanel, NSScreen, NSWorkspace,
};
use objc2_foundation::{MainThreadMarker, NSArray, NSRect, NSString, NSURL};
use crate::backend::{
BackendError, Clipboard, Display, FileDialogOptions, MessageDialogButton, MessageDialogChoice,
MessageDialogOptions, Notification, RgbaImage, ServiceResult, SystemTheme,
};
use crate::event::{Point, Rect};
use crate::primitives::dialog::DialogSeverity;
use crate::types::Color;
use crate::PlatformServices;
const NS_MODAL_RESPONSE_OK: isize = 1;
pub struct MacPlatformServices {
clipboard: MacClipboard,
}
impl MacPlatformServices {
pub fn new() -> Self {
Self {
clipboard: MacClipboard::new(),
}
}
}
impl Default for MacPlatformServices {
fn default() -> Self {
Self::new()
}
}
impl PlatformServices for MacPlatformServices {
fn clipboard(&self) -> &dyn Clipboard {
&self.clipboard
}
fn show_file_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let mtm = MainThreadMarker::new()
.expect("show_file_open_dialog must be called from the main thread");
unsafe {
let panel = NSOpenPanel::openPanel(mtm);
panel.setCanChooseFiles(true);
panel.setCanChooseDirectories(false);
configure_panel(&panel, &opts);
if panel.runModal() != NS_MODAL_RESPONSE_OK {
return None;
}
url_to_path(panel.URL().as_deref())
}
}
fn show_file_save_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let mtm = MainThreadMarker::new()
.expect("show_file_save_dialog must be called from the main thread");
unsafe {
let panel = NSSavePanel::savePanel(mtm);
configure_panel(&panel, &opts);
if let Some(ref name) = opts.initial_filename {
panel.setNameFieldStringValue(&NSString::from_str(name));
}
if panel.runModal() != NS_MODAL_RESPONSE_OK {
return None;
}
url_to_path(panel.URL().as_deref())
}
}
fn show_folder_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let mtm = MainThreadMarker::new()
.expect("show_folder_open_dialog must be called from the main thread");
unsafe {
let panel = NSOpenPanel::openPanel(mtm);
panel.setCanChooseFiles(false);
panel.setCanChooseDirectories(true);
configure_panel(&panel, &opts);
if panel.runModal() != NS_MODAL_RESPONSE_OK {
return None;
}
url_to_path(panel.URL().as_deref())
}
}
fn show_message_dialog(&self, opts: MessageDialogOptions) -> Option<MessageDialogChoice> {
let mtm = MainThreadMarker::new()?;
let order = native_button_order(&opts.buttons);
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str(&opts.title));
alert.setInformativeText(&NSString::from_str(&opts.body));
alert.setAlertStyle(severity_to_alert_style(opts.severity));
for &i in &order {
let button = &opts.buttons[i];
let ns_button = alert.addButtonWithTitle(&NSString::from_str(&button.label));
if let Some(key) = key_equivalent_for_button(button) {
ns_button.setKeyEquivalent(&NSString::from_str(key));
}
}
let response = alert.runModal();
let idx = usize::try_from(response - NSAlertFirstButtonReturn).ok()?;
let orig = *order.get(idx)?;
Some(opts.buttons[orig].id.clone())
}
fn send_notification(&self, n: Notification) {
let script = crate::desktop::display_notification_script(&n.title, &n.body, n.is_silent());
let _ = Command::new("osascript").arg("-e").arg(&script).spawn();
}
fn open_url(&self, url: &str) {
let _ = self.open_url_result(url);
}
fn open_url_result(&self, url: &str) -> ServiceResult<()> {
crate::desktop::open_with_default(std::ffi::OsStr::new(url))
}
fn reveal_in_file_manager(&self, path: &Path) -> ServiceResult<()> {
let Some(path_str) = path.to_str() else {
return Err(BackendError::PlatformFailure {
context: "reveal_in_file_manager: path is not valid UTF-8".to_string(),
});
};
let url = NSURL::fileURLWithPath_isDirectory(&NSString::from_str(path_str), path.is_dir());
let urls = NSArray::from_retained_slice(&[url]);
NSWorkspace::sharedWorkspace().activateFileViewerSelectingURLs(&urls);
Ok(())
}
fn open_path(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::open_with_default(path.as_os_str())
}
fn move_to_trash(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::move_to_trash(path)
}
fn beep(&self) -> ServiceResult<()> {
NSBeep();
Ok(())
}
fn system_theme(&self) -> ServiceResult<SystemTheme> {
let mtm = MainThreadMarker::new().ok_or(BackendError::Unsupported)?;
let appearance_name = NSApplication::sharedApplication(mtm)
.effectiveAppearance()
.name()
.to_string();
let high_contrast =
NSWorkspace::sharedWorkspace().accessibilityDisplayShouldIncreaseContrast();
Ok(system_theme_from_mac_appearance(
&appearance_name,
high_contrast,
mac_accent_color(),
))
}
fn displays(&self) -> ServiceResult<Vec<Display>> {
let mtm = MainThreadMarker::new().ok_or(BackendError::Unsupported)?;
let screens = NSScreen::screens(mtm);
let count = screens.count();
let mut out = Vec::with_capacity(count);
for i in 0..count {
let screen = screens.objectAtIndex(i);
out.push(Display {
bounds: rect_from_ns_rect(screen.frame()),
work_area: rect_from_ns_rect(screen.visibleFrame()),
scale: screen.backingScaleFactor() as f32,
primary: i == 0,
});
}
Ok(out)
}
fn cursor_screen_point(&self) -> ServiceResult<Point> {
let p = NSEvent::mouseLocation();
Ok(Point::new(p.x as f32, p.y as f32))
}
fn platform_name(&self) -> &'static str {
"macos"
}
}
fn rect_from_ns_rect(r: NSRect) -> Rect {
Rect::new(
r.origin.x as f32,
r.origin.y as f32,
r.size.width as f32,
r.size.height as f32,
)
}
fn system_theme_from_mac_appearance(
appearance_name: &str,
high_contrast: bool,
accent: Option<Color>,
) -> SystemTheme {
SystemTheme {
dark: appearance_name.contains("Dark"),
accent,
high_contrast,
}
}
fn mac_accent_color() -> Option<Color> {
let color = NSColor::controlAccentColor();
let rgb_color = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
let (mut r, mut g, mut b, mut a) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
unsafe {
rgb_color.getRed_green_blue_alpha(&mut r, &mut g, &mut b, &mut a);
}
Some(Color::rgba(
unit_to_u8(r),
unit_to_u8(g),
unit_to_u8(b),
unit_to_u8(a),
))
}
fn unit_to_u8(component: f64) -> u8 {
(component.clamp(0.0, 1.0) * 255.0).round() as u8
}
unsafe fn configure_panel(panel: &NSSavePanel, opts: &FileDialogOptions) {
if let Some(ref title) = opts.title {
panel.setMessage(Some(&NSString::from_str(title)));
}
if let Some(ref dir) = opts.initial_dir {
if let Some(dir_str) = dir.to_str() {
let url = NSURL::fileURLWithPath_isDirectory(&NSString::from_str(dir_str), true);
panel.setDirectoryURL(Some(&url));
}
}
let exts: Vec<_> = opts
.filters
.iter()
.flat_map(|(_, e)| e.iter())
.map(|ext| NSString::from_str(ext))
.collect();
if !exts.is_empty() {
let arr = NSArray::from_retained_slice(&exts);
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&arr));
}
}
unsafe fn url_to_path(url: Option<&NSURL>) -> Option<PathBuf> {
let path = url?.path()?;
Some(PathBuf::from(path.to_string()))
}
fn native_button_order(buttons: &[MessageDialogButton]) -> Vec<usize> {
let mut default_idx = None;
let mut cancel_idx = None;
let mut rest = Vec::new();
for (i, b) in buttons.iter().enumerate() {
if b.is_default && default_idx.is_none() {
default_idx = Some(i);
} else if b.is_cancel && cancel_idx.is_none() {
cancel_idx = Some(i);
} else {
rest.push(i);
}
}
let mut order = Vec::with_capacity(buttons.len());
order.extend(default_idx);
order.extend(cancel_idx);
order.extend(rest);
order
}
fn key_equivalent_for_button(button: &MessageDialogButton) -> Option<&'static str> {
if button.is_default {
Some("\r")
} else if button.is_cancel {
Some("\u{1b}")
} else {
None
}
}
fn severity_to_alert_style(severity: Option<DialogSeverity>) -> NSAlertStyle {
match severity {
Some(DialogSeverity::Error) => NSAlertStyle::Critical,
Some(DialogSeverity::Warning) => NSAlertStyle::Warning,
Some(DialogSeverity::Info) | Some(DialogSeverity::Question) | None => {
NSAlertStyle::Informational
}
}
}
pub struct MacClipboard {
inner: RefCell<Option<arboard::Clipboard>>,
}
impl MacClipboard {
fn new() -> Self {
Self {
inner: RefCell::new(arboard::Clipboard::new().ok()),
}
}
}
const NO_PASTEBOARD_CONTEXT: &str = "arboard::Clipboard::new (no NSPasteboard session)";
use crate::desktop::map_arboard_error;
impl Clipboard for MacClipboard {
fn read_text(&self) -> Option<String> {
self.inner.borrow_mut().as_mut()?.get_text().ok()
}
fn write_text(&self, text: &str) {
if let Some(cb) = self.inner.borrow_mut().as_mut() {
let _ = cb.set_text(text);
}
}
fn read_image(&self) -> ServiceResult<RgbaImage> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_PASTEBOARD_CONTEXT.to_string(),
})?;
let img = cb
.get_image()
.map_err(|e| map_arboard_error("arboard::get_image", e))?;
Ok(RgbaImage {
width: img.width as u32,
height: img.height as u32,
pixels: img.bytes.into_owned(),
})
}
fn write_image(&self, image: &RgbaImage) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_PASTEBOARD_CONTEXT.to_string(),
})?;
let data = arboard::ImageData {
width: image.width as usize,
height: image.height as usize,
bytes: std::borrow::Cow::Borrowed(&image.pixels),
};
cb.set_image(data)
.map_err(|e| map_arboard_error("arboard::set_image", e))
}
fn write_html(&self, html: &str, alt_text: &str) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_PASTEBOARD_CONTEXT.to_string(),
})?;
cb.set_html(html, Some(alt_text))
.map_err(|e| map_arboard_error("arboard::set_html", e))
}
fn read_file_list(&self) -> ServiceResult<Vec<PathBuf>> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_PASTEBOARD_CONTEXT.to_string(),
})?;
cb.get()
.file_list()
.map_err(|e| map_arboard_error("arboard::get().file_list()", e))
}
fn clear(&self) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_PASTEBOARD_CONTEXT.to_string(),
})?;
cb.clear()
.map_err(|e| map_arboard_error("arboard::clear", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_name_is_macos() {
let svc = MacPlatformServices::new();
assert_eq!(svc.platform_name(), "macos");
}
#[test]
fn beep_reports_success() {
let svc = MacPlatformServices::new();
assert_eq!(svc.beep(), Ok(()));
}
#[test]
fn displays_off_main_thread_returns_unsupported_not_panic() {
let svc = MacPlatformServices::new();
assert_eq!(svc.displays(), Err(BackendError::Unsupported));
}
#[test]
fn cursor_screen_point_reports_a_point() {
let svc = MacPlatformServices::new();
assert!(svc.cursor_screen_point().is_ok());
}
#[test]
fn open_url_result_reports_failure_when_the_opener_is_missing() {
let _guard = crate::desktop::PATH_OVERRIDE_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let original_path = std::env::var_os("PATH");
struct RestorePath(Option<std::ffi::OsString>);
impl Drop for RestorePath {
fn drop(&mut self) {
match self.0.take() {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
let _restore = RestorePath(original_path);
let empty_dir = tempfile::tempdir().expect("tempdir");
std::env::set_var("PATH", empty_dir.path());
let svc = MacPlatformServices::new();
assert!(svc.open_url_result("https://example.com/1087").is_err());
}
#[allow(clippy::print_stderr)]
#[test]
fn clipboard_image_html_file_list_and_clear_round_trip() {
let _pasteboard = crate::macos::lock_real_pasteboard();
let svc = MacPlatformServices::new();
let cb = svc.clipboard();
let image = RgbaImage {
width: 2,
height: 2,
pixels: vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ],
};
if let Err(e) = cb.write_image(&image) {
eprintln!("skipping: no live NSPasteboard session in this environment ({e:?})");
return;
}
let read_back = cb
.read_image()
.expect("read_image should see what write_image just wrote");
assert_eq!(read_back.width, image.width);
assert_eq!(read_back.height, image.height);
assert_eq!(
read_back.pixels.len(),
(image.width * image.height * 4) as usize
);
cb.write_html("<b>hi</b>", "hi").expect(
"write_html should succeed once write_image already proved the pasteboard is live",
);
let tmp = std::env::temp_dir().join("quadraui-954-clipboard-test-file.txt");
std::fs::write(&tmp, b"quadraui#954").expect("write temp file");
arboard::Clipboard::new()
.expect("a second arboard handle should also see the live pasteboard")
.set()
.file_list(&[&tmp])
.expect("seeding the pasteboard with a file list should succeed");
let files = cb
.read_file_list()
.expect("read_file_list should see the seeded file");
assert_eq!(
files
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect::<Vec<_>>(),
vec![tmp.canonicalize().expect("temp file should exist")]
);
let _ = std::fs::remove_file(&tmp);
cb.write_text("some text #954");
assert_eq!(cb.read_text(), Some("some text #954".to_string()));
cb.clear()
.expect("clear should succeed once the pasteboard has proven live");
assert_eq!(cb.read_text(), None);
}
#[test]
fn dark_aqua_appearance_reports_dark() {
let theme = system_theme_from_mac_appearance("NSAppearanceNameDarkAqua", false, None);
assert!(theme.dark);
assert!(!theme.high_contrast);
assert_eq!(theme.accent, None);
}
#[test]
fn aqua_appearance_reports_light() {
let theme = system_theme_from_mac_appearance("NSAppearanceNameAqua", false, None);
assert!(!theme.dark);
}
#[test]
fn high_contrast_flag_is_passed_through_not_rederived_from_name() {
let theme = system_theme_from_mac_appearance(
"NSAppearanceNameAccessibilityHighContrastDarkAqua",
true,
None,
);
assert!(theme.dark);
assert!(theme.high_contrast);
}
#[test]
fn accent_color_passes_through_unchanged() {
let accent = Some(Color::rgb(0, 122, 255));
let theme = system_theme_from_mac_appearance("NSAppearanceNameAqua", false, accent);
assert_eq!(theme.accent, accent);
}
#[test]
fn unit_to_u8_maps_the_full_range() {
assert_eq!(unit_to_u8(0.0), 0);
assert_eq!(unit_to_u8(1.0), 255);
assert_eq!(unit_to_u8(0.5), 128);
}
#[test]
fn unit_to_u8_clamps_out_of_range_input() {
assert_eq!(unit_to_u8(-1.0), 0);
assert_eq!(unit_to_u8(2.0), 255);
}
fn msg_btn(id: &str, is_default: bool, is_cancel: bool) -> MessageDialogButton {
MessageDialogButton {
id: crate::types::WidgetId::new(id),
label: id.to_string(),
is_default,
is_cancel,
}
}
#[test]
fn native_button_order_puts_default_first_and_cancel_second() {
let buttons = [
msg_btn("middle", false, false),
msg_btn("cancel", false, true),
msg_btn("default", true, false),
];
assert_eq!(native_button_order(&buttons), vec![2, 1, 0]);
}
#[test]
fn native_button_order_preserves_relative_order_of_the_rest() {
let buttons = [
msg_btn("a", false, false),
msg_btn("b", false, false),
msg_btn("c", false, false),
];
assert_eq!(native_button_order(&buttons), vec![0, 1, 2]);
}
#[test]
fn native_button_order_single_button_both_default_and_cancel() {
let buttons = [msg_btn("ok", true, true)];
assert_eq!(native_button_order(&buttons), vec![0]);
}
#[test]
fn native_button_order_no_default_or_cancel_keeps_declared_order() {
let buttons = [msg_btn("a", false, false), msg_btn("b", false, false)];
assert_eq!(native_button_order(&buttons), vec![0, 1]);
}
#[test]
fn key_equivalent_for_button_default_only_binds_return() {
assert_eq!(
key_equivalent_for_button(&msg_btn("ok", true, false)),
Some("\r")
);
}
#[test]
fn key_equivalent_for_button_cancel_only_binds_escape() {
assert_eq!(
key_equivalent_for_button(&msg_btn("cancel", false, true)),
Some("\u{1b}")
);
}
#[test]
fn key_equivalent_for_button_neither_binds_nothing() {
assert_eq!(
key_equivalent_for_button(&msg_btn("middle", false, false)),
None
);
}
#[test]
fn key_equivalent_for_button_both_default_and_cancel_binds_return_not_escape() {
assert_eq!(
key_equivalent_for_button(&msg_btn("ok", true, true)),
Some("\r")
);
}
#[test]
fn severity_to_alert_style_maps_error_and_warning_distinctly() {
assert_eq!(
severity_to_alert_style(Some(DialogSeverity::Error)),
NSAlertStyle::Critical
);
assert_eq!(
severity_to_alert_style(Some(DialogSeverity::Warning)),
NSAlertStyle::Warning
);
}
#[test]
fn severity_to_alert_style_defaults_question_and_none_to_informational() {
assert_eq!(
severity_to_alert_style(Some(DialogSeverity::Info)),
NSAlertStyle::Informational
);
assert_eq!(
severity_to_alert_style(Some(DialogSeverity::Question)),
NSAlertStyle::Informational
);
assert_eq!(severity_to_alert_style(None), NSAlertStyle::Informational);
}
#[test]
fn show_message_dialog_off_main_thread_returns_none_not_panic() {
let svc = MacPlatformServices::new();
let opts = MessageDialogOptions {
title: "Title".to_string(),
body: "Body".to_string(),
buttons: vec![msg_btn("ok", true, true)],
severity: None,
};
assert!(svc.show_message_dialog(opts).is_none());
}
}