rustydialogs 0.4.2

Provides a simple and cross-platform way to display native dialog boxes.
Documentation
use dispatch2::run_on_main;
use objc2::MainThreadOnly;
use objc2::rc::Retained;
use objc2_app_kit::{
	NSAlert,
	NSAlertFirstButtonReturn,
	NSColor,
	NSColorSpace,
	NSColorWell,
	NSColorWellStyle,
	NSAlertSecondButtonReturn,
	NSAlertStyle,
	NSAlertThirdButtonReturn,
	NSModalResponseOK,
	NSOpenPanel,
	NSScrollView,
	NSSavePanel,
	NSSecureTextField,
	NSTextField,
	NSTextView,
};
#[allow(deprecated)] // NSUserNotification is deprecated
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString, NSURL, NSUserNotification, NSUserNotificationCenter};

use super::*;

pub fn message_box(p: &MessageBox<'_>) -> Option<MessageResult> {
	let title_text = p.title;
	let message_text = p.message;
	let icon = p.icon;
	let buttons = p.buttons;

	run_on_main(move |mtm| {
		let alert = NSAlert::new(mtm);
		let title = NSString::from_str(title_text);
		let message = NSString::from_str(message_text);

		alert.setMessageText(&title);
		alert.setInformativeText(&message);
		alert.setAlertStyle(match icon {
			MessageIcon::Info | MessageIcon::Question => NSAlertStyle::Informational,
			MessageIcon::Warning => NSAlertStyle::Warning,
			MessageIcon::Error => NSAlertStyle::Critical,
		});

		let labels: &[&str] = match buttons {
			MessageButtons::Ok => &["OK"],
			MessageButtons::OkCancel => &["OK", "Cancel"],
			MessageButtons::YesNo => &["Yes", "No"],
			MessageButtons::YesNoCancel => &["Yes", "No", "Cancel"],
		};
		for label in labels {
			alert.addButtonWithTitle(&NSString::from_str(label));
		}

		let response = alert.runModal();

		let results: &[MessageResult] = match buttons {
			MessageButtons::Ok => &[MessageResult::Ok],
			MessageButtons::OkCancel => &[MessageResult::Ok, MessageResult::Cancel],
			MessageButtons::YesNo => &[MessageResult::Yes, MessageResult::No],
			MessageButtons::YesNoCancel => &[MessageResult::Yes, MessageResult::No, MessageResult::Cancel],
		};
		let index = if response == NSAlertFirstButtonReturn { 0 }
		else if response == NSAlertSecondButtonReturn { 1 }
		else if response == NSAlertThirdButtonReturn { 2 }
		else { !0 }; // Out of bounds, will be handled below
		results.get(index).copied()
	})
}

pub fn pick_file(p: &FileDialog<'_>) -> Option<PathBuf> {
	run_open_panel(p, false).and_then(|paths| paths.into_iter().next())
}

pub fn pick_files(p: &FileDialog<'_>) -> Option<Vec<PathBuf>> {
	run_open_panel(p, true)
}

pub fn save_file(p: &FileDialog<'_>) -> Option<PathBuf> {
	let title = p.title;
	let path = p.path;

	run_on_main(move |mtm| {
		let panel = NSSavePanel::savePanel(mtm);
		let title = NSString::from_str(title);
		panel.setTitle(Some(&title));
		panel.setCanCreateDirectories(true);

		let (directory, default_name) = initial_directory_and_name(path);
		if let Some(directory) = directory {
			let dir_url = path_to_file_url(&directory);
			panel.setDirectoryURL(Some(&dir_url));
		}

		if let Some(default_name) = default_name {
			let name = NSString::from_str(&default_name);
			panel.setNameFieldStringValue(&name);
		}

		let response = panel.runModal();
		if response != NSModalResponseOK {
			return None;
		}

		panel.URL().and_then(|url| url_to_pathbuf(&url))
	})
}

pub fn folder_dialog(p: &FolderDialog<'_>) -> Option<PathBuf> {
	let title_text = p.title;
	let directory = p.directory;

	run_on_main(move |mtm| {
		let panel = NSOpenPanel::openPanel(mtm);
		let title = NSString::from_str(title_text);
		panel.setTitle(Some(&title));
		panel.setCanChooseDirectories(true);
		panel.setCanChooseFiles(false);
		panel.setAllowsMultipleSelection(false);
		panel.setCanCreateDirectories(true);

		if let Some(directory) = directory {
			if let Some(path) = utils::abspath(Some(directory)) {
				let dir_url = path_to_file_url(path.as_ref());
				panel.setDirectoryURL(Some(&dir_url));
			}
		}

		let response = panel.runModal();
		if response != NSModalResponseOK {
			return None;
		}

		panel.URL().and_then(|url| url_to_pathbuf(&url))
	})
}

pub fn text_input(p: &TextInput<'_>) -> Option<String> {
	let title_text = p.title;
	let message_text = p.message;
	let value_text = p.value;
	let mode = p.mode;

	run_on_main(move |mtm| {
		let alert = NSAlert::new(mtm);
		let title = NSString::from_str(title_text);
		let message = NSString::from_str(message_text);
		let ok = NSString::from_str("OK");
		let cancel = NSString::from_str("Cancel");

		alert.setMessageText(&title);
		alert.setInformativeText(&message);
		alert.addButtonWithTitle(&ok);
		alert.addButtonWithTitle(&cancel);

		match mode {
			TextInputMode::Password => {
				let field = NSSecureTextField::new(mtm);
				let value = NSString::from_str(value_text);
				field.setStringValue(&value);
				alert.setAccessoryView(Some(&field));

				let response = alert.runModal();
				if response != NSAlertFirstButtonReturn { None }
				else { Some(field.stringValue().to_string()) }
			}
			TextInputMode::MultiLine => {
				let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(380.0, 160.0));
				let scroll = NSScrollView::initWithFrame(NSScrollView::alloc(mtm), frame);
				scroll.setHasVerticalScroller(true);
				scroll.setHasHorizontalScroller(false);

				let field = NSTextView::initWithFrame(NSTextView::alloc(mtm), frame);
				field.setEditable(true);
				field.setSelectable(true);
				let value = NSString::from_str(value_text);
				field.setString(&value);

				scroll.setDocumentView(Some(&field));
				alert.setAccessoryView(Some(&scroll));

				let response = alert.runModal();
				if response != NSAlertFirstButtonReturn { None }
				else { Some(field.string().to_string()) }
			}
			TextInputMode::SingleLine => {
				let value = NSString::from_str(value_text);
				let field = NSTextField::textFieldWithString(&value, mtm);
				alert.setAccessoryView(Some(&field));

				let response = alert.runModal();
				if response != NSAlertFirstButtonReturn { None }
				else { Some(field.stringValue().to_string()) }
			}
		}
	})
}

pub fn color_picker(p: &ColorPicker<'_>) -> Option<ColorValue> {
	let title_text = p.title;
	let initial = p.value;

	run_on_main(|mtm| {
		let alert = NSAlert::new(mtm);
		let title = NSString::from_str(title_text);
		let ok = NSString::from_str("OK");
		let cancel = NSString::from_str("Cancel");

		alert.setMessageText(&title);
		alert.addButtonWithTitle(&ok);
		alert.addButtonWithTitle(&cancel);

		let well = NSColorWell::colorWellWithStyle(NSColorWellStyle::Default, mtm);
		let initial = color_value_to_nscolor(initial);
		well.setColor(&initial);
		alert.setAccessoryView(Some(&well));

		let response = alert.runModal();
		if response == NSAlertFirstButtonReturn {
			nscolor_to_color_value(&well.color())
		}
		else {
			None
		}
	})
}

#[inline]
pub fn notify_setup(_app_id: &str) -> bool {
	// No explicit setup required for NSUserNotificationCenter.
	true
}

#[allow(deprecated)]
pub fn notify(p: &Notification<'_>) {
	if !notify_setup(p.app_id) {
		return;
	}

	run_on_main(|_mtm| {
		let center = NSUserNotificationCenter::defaultUserNotificationCenter();
		let notification = NSUserNotification::new();

		let title = NSString::from_str(p.title);
		let message = NSString::from_str(p.message);

		notification.setTitle(Some(&title));
		notification.setInformativeText(Some(&message));

		if !p.app_id.is_empty() {
			let subtitle = NSString::from_str(p.app_id);
			notification.setSubtitle(Some(&subtitle));
		}

		center.deliverNotification(&notification);
	});
}

fn run_open_panel(p: &FileDialog<'_>, multiple: bool) -> Option<Vec<PathBuf>> {
	let title_text = p.title;
	let initial_path = p.path;

	run_on_main(move |mtm| {
		let panel = NSOpenPanel::openPanel(mtm);
		let title = NSString::from_str(title_text);
		panel.setTitle(Some(&title));
		panel.setCanChooseDirectories(false);
		panel.setCanChooseFiles(true);
		panel.setAllowsMultipleSelection(multiple);
		panel.setCanCreateDirectories(true);

		if let Some(initial_path) = initial_path {
			let directory = initial_directory(Some(initial_path));
			if let Some(directory) = directory {
				let dir_url = path_to_file_url(&directory);
				panel.setDirectoryURL(Some(&dir_url));
			}
		}

		let response = panel.runModal();
		if response != NSModalResponseOK {
			return None;
		}

		if multiple {
			let urls = panel.URLs();
			let mut files = Vec::new();
			for url in urls.to_vec() {
				if let Some(path) = url_to_pathbuf(&url) {
					files.push(path);
				}
			}
			if files.is_empty() {
				None
			}
			else {
				Some(files)
			}
		}
		else {
			panel.URL()
				.and_then(|url| url_to_pathbuf(&url))
				.map(|path| vec![path])
		}
	})
}

fn path_to_file_url(path: &Path) -> Retained<NSURL> {
	let path = NSString::from_str(&path.to_string_lossy());
	NSURL::fileURLWithPath(&path)
}

fn color_value_to_nscolor(color: ColorValue) -> Retained<NSColor> {
	NSColor::colorWithSRGBRed_green_blue_alpha(
		u8_to_component(color.red),
		u8_to_component(color.green),
		u8_to_component(color.blue),
		1.0,
	)
}

fn nscolor_to_color_value(color: &NSColor) -> Option<ColorValue> {
	let color_space = NSColorSpace::sRGBColorSpace();
	let color = color.colorUsingColorSpace(&color_space)?;

	Some(ColorValue {
		red: component_to_u8(color.redComponent()),
		green: component_to_u8(color.greenComponent()),
		blue: component_to_u8(color.blueComponent()),
	})
}

fn u8_to_component(value: u8) -> f64 {
	(value as f64) / 255.0
}

fn component_to_u8(value: f64) -> u8 {
	(value.clamp(0.0, 1.0) * 255.0).round() as u8
}

fn url_to_pathbuf(url: &NSURL) -> Option<PathBuf> {
	let path = url.path()?;
	Some(PathBuf::from(path.to_string()))
}

fn initial_directory(initial_path: Option<&Path>) -> Option<PathBuf> {
	let (directory, _) = initial_directory_and_name(initial_path);
	directory
}

fn initial_directory_and_name(initial_path: Option<&Path>) -> (Option<PathBuf>, Option<String>) {
	let Some(path) = utils::abspath(initial_path) else {
		return (None, None);
	};

	if path.is_dir() {
		return (Some(path.into_owned()), None);
	}

	let directory = path.parent().map(Path::to_path_buf);
	let file_name = path.file_name().and_then(|name| name.to_str()).map(str::to_string);
	(directory, file_name)
}