#[cfg(not(target_os = "ios"))]
compile_error!("the UIKit alert arm targets iOS only");
use std::cell::Cell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::NonNull;
use block2::RcBlock;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
use objc2_foundation::{NSNotification, NSNotificationCenter, NSString};
use objc2_ui_kit::{
UIAdaptivePresentationControllerDelegate, UIAlertAction, UIAlertActionStyle, UIAlertController,
UIAlertControllerStyle, UIDevice, UIPopoverPresentationControllerDelegate,
UIPresentationController, UISceneDidDisconnectNotification, UIUserInterfaceIdiom,
};
use super::apple_host::{
AfterDismiss, DisplacedAction, LivePresentation, dismiss_live, displaced_action, install_live,
on_main, presenting_anchor, take_live_as,
};
use super::{
ActionRole, AlertHost, AlertOutcome, AlertSpec, AlertStyle, AnchorRect, PresentError, Sender,
};
type ActionHandler = RcBlock<dyn Fn(NonNull<UIAlertAction>)>;
pub(crate) struct Host;
impl AlertHost for Host {
fn show_alert(
spec: AlertSpec,
tx: Sender<AlertOutcome>,
generation: u64,
) -> Result<(), PresentError> {
if let Some(mtm) = MainThreadMarker::new() {
check_anchor_rule(mtm, &spec)?;
}
on_main(move |mtm| start(mtm, spec, tx, generation));
Ok(())
}
fn dismiss(generation: u64) {
dismiss_live(generation);
}
}
fn check_anchor_rule(mtm: MainThreadMarker, spec: &AlertSpec) -> Result<(), PresentError> {
if spec.style == AlertStyle::ActionSheet
&& spec.anchor.is_none()
&& UIDevice::currentDevice(mtm).userInterfaceIdiom() == UIUserInterfaceIdiom::Pad
{
return Err(PresentError::InvalidSpec(
"an action sheet on iPad needs an anchor (AlertSpec::anchor): UIKit presents it as \
a popover, which must point at something"
.to_string(),
));
}
Ok(())
}
fn start(mtm: MainThreadMarker, spec: AlertSpec, tx: Sender<AlertOutcome>, generation: u64) {
if let Err(err) = check_anchor_rule(mtm, &spec) {
tx.send(Err(err));
return;
}
let anchor = spec.anchor;
let live = LiveAlert::build(mtm, &spec, tx, generation);
match install_live(mtm, generation, Box::new(live)) {
None => present_live(mtm, generation, anchor),
Some(displaced) => {
let then: AfterDismiss = Box::new(move |mtm| present_live(mtm, generation, anchor));
displaced.dismiss(mtm, Some(then));
}
}
}
fn present_live(mtm: MainThreadMarker, generation: u64, anchor: Option<AnchorRect>) {
let Some(live) = take_live_as::<LiveAlert>(mtm, generation) else {
return;
};
let Some(presenter) = presenting_anchor(mtm) else {
live.finish(Err(PresentError::NoHost));
return;
};
if let Some(popover) = live.controller.popoverPresentationController() {
unsafe {
popover.setDelegate(Some(ProtocolObject::from_ref(&*live.delegate)));
}
let Some((rect, view)) = anchor.zip(presenter.view()) else {
live.finish(Err(PresentError::InvalidSpec(
"this device presents the action sheet as a popover, which needs an anchor \
(AlertSpec::anchor)"
.to_string(),
)));
return;
};
popover.setSourceView(Some(&view));
popover.setSourceRect(view.convertRect_fromView(cg_rect(rect), None));
}
presenter.presentViewController_animated_completion(&live.controller, true, None);
if live.controller.presentingViewController().is_none() {
log::warn!(
"frust-native-widgets: UIKit refused to present the alert (the presenting controller \
is not in a window hierarchy)"
);
live.finish(Err(PresentError::NoHost));
return;
}
let scene = presenter
.view()
.and_then(|view| view.window())
.and_then(|window| window.windowScene());
if let Some(scene) = scene {
live.delegate.observe_disconnect(&scene);
} else {
log::debug!(
"frust-native-widgets: the presenting controller has no window scene; the alert's \
HostLost is not observed"
);
}
if let Some(unexpected) = install_live(mtm, generation, live) {
unexpected.dismiss(mtm, None);
}
}
fn cg_rect(rect: AnchorRect) -> CGRect {
CGRect::new(
CGPoint::new(rect.x, rect.y),
CGSize::new(rect.width, rect.height),
)
}
fn guarded(which: &str, body: impl FnOnce()) {
if catch_unwind(AssertUnwindSafe(body)).is_err() {
log::error!("frust-native-widgets: the alert's {which} callback panicked");
}
}
fn resolve(generation: u64, outcome: Result<AlertOutcome, PresentError>) {
let Some(mtm) = MainThreadMarker::new() else {
log::error!("frust-native-widgets: an alert callback arrived off the main thread");
return;
};
if let Some(live) = take_live_as::<LiveAlert>(mtm, generation) {
live.finish(outcome);
}
}
struct LiveAlert {
generation: u64,
tx: Sender<AlertOutcome>,
controller: Retained<UIAlertController>,
delegate: Retained<FrustNativeAlertDelegate>,
_handlers: Vec<ActionHandler>,
}
impl LiveAlert {
fn build(
mtm: MainThreadMarker,
spec: &AlertSpec,
tx: Sender<AlertOutcome>,
generation: u64,
) -> Self {
let title = non_empty(&spec.title);
let message = non_empty(&spec.message);
let style = match spec.style {
AlertStyle::Alert => UIAlertControllerStyle::Alert,
AlertStyle::ActionSheet => UIAlertControllerStyle::ActionSheet,
};
let controller = UIAlertController::alertControllerWithTitle_message_preferredStyle(
title.as_deref(),
message.as_deref(),
style,
mtm,
);
let mut handlers = Vec::with_capacity(spec.actions.len());
for action in &spec.actions {
let id = action.id.clone();
let handler: ActionHandler = RcBlock::new(move |_: NonNull<UIAlertAction>| {
guarded("action", || {
resolve(generation, Ok(AlertOutcome::Action(id.clone())));
});
});
let ui_action = UIAlertAction::actionWithTitle_style_handler(
Some(&NSString::from_str(&action.label)),
action_style(action.role),
Some(&handler),
mtm,
);
controller.addAction(&ui_action);
handlers.push(handler);
}
Self {
generation,
tx,
controller,
delegate: FrustNativeAlertDelegate::new(mtm, generation, spec.cancelable),
_handlers: handlers,
}
}
fn finish(self, outcome: Result<AlertOutcome, PresentError>) {
let Self {
tx,
controller,
delegate,
_handlers,
..
} = self;
delegate.stop_observing();
tx.send(outcome);
let _ = Retained::autorelease_ptr(controller);
let _ = Retained::autorelease_ptr(delegate);
}
fn take_down(self: Box<Self>, mtm: MainThreadMarker, then: Option<AfterDismiss>) {
let Some(presenter) = self.controller.presentingViewController() else {
self.finish(Ok(AlertOutcome::Dismissed));
if let Some(then) = then {
then(mtm);
}
return;
};
match displaced_action(self.controller.isBeingDismissed(), then.is_some()) {
DisplacedAction::Repark => {
let generation = self.generation;
if let Some(unexpected) = install_live(mtm, generation, self) {
unexpected.dismiss(mtm, None);
}
}
DisplacedAction::ResolveAndContinue => {
self.finish(Ok(AlertOutcome::Dismissed));
if let Some(then) = then {
then(mtm);
}
}
DisplacedAction::CloseThenContinue => {
let pending = Cell::new(Some((self, then)));
let completion = RcBlock::new(move || {
guarded("dismissal completion", || {
let Some((live, then)) = pending.take() else {
return;
};
live.finish(Ok(AlertOutcome::Dismissed));
if let (Some(then), Some(mtm)) = (then, MainThreadMarker::new()) {
then(mtm);
}
});
});
presenter.dismissViewControllerAnimated_completion(true, Some(&completion));
}
}
}
}
impl LivePresentation for LiveAlert {
fn dismiss(self: Box<Self>, mtm: MainThreadMarker, then: Option<AfterDismiss>) {
self.take_down(mtm, then);
}
}
fn non_empty(text: &str) -> Option<Retained<NSString>> {
(!text.is_empty()).then(|| NSString::from_str(text))
}
fn action_style(role: ActionRole) -> UIAlertActionStyle {
match role {
ActionRole::Default => UIAlertActionStyle::Default,
ActionRole::Cancel => UIAlertActionStyle::Cancel,
ActionRole::Destructive => UIAlertActionStyle::Destructive,
}
}
struct DelegateIvars {
generation: u64,
cancelable: bool,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = DelegateIvars]
struct FrustNativeAlertDelegate;
unsafe impl NSObjectProtocol for FrustNativeAlertDelegate {}
unsafe impl UIAdaptivePresentationControllerDelegate for FrustNativeAlertDelegate {
#[unsafe(method(presentationControllerShouldDismiss:))]
fn presentation_controller_should_dismiss(
&self,
_controller: &UIPresentationController,
) -> bool {
self.ivars().cancelable
}
#[unsafe(method(presentationControllerDidDismiss:))]
fn presentation_controller_did_dismiss(&self, _controller: &UIPresentationController) {
let generation = self.ivars().generation;
guarded("popover dismissal", || {
resolve(generation, Ok(AlertOutcome::Cancelled));
});
}
}
unsafe impl UIPopoverPresentationControllerDelegate for FrustNativeAlertDelegate {}
impl FrustNativeAlertDelegate {
#[unsafe(method(sceneDidDisconnect:))]
fn scene_did_disconnect(&self, _notification: &NSNotification) {
let generation = self.ivars().generation;
guarded("scene disconnect", || {
resolve(generation, Ok(AlertOutcome::HostLost));
});
}
}
);
impl FrustNativeAlertDelegate {
fn new(mtm: MainThreadMarker, generation: u64, cancelable: bool) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(DelegateIvars {
generation,
cancelable,
});
unsafe { msg_send![super(this), init] }
}
fn observe_disconnect(&self, scene: &AnyObject) {
let center = NSNotificationCenter::defaultCenter();
let name = unsafe { UISceneDidDisconnectNotification };
unsafe {
center.addObserver_selector_name_object(
self,
sel!(sceneDidDisconnect:),
Some(name),
Some(scene),
);
}
}
fn stop_observing(&self) {
unsafe { NSNotificationCenter::defaultCenter().removeObserver(self) };
}
}