1mod oneshot;
71
72#[cfg(target_os = "android")]
73mod android_alert;
74#[cfg(target_os = "android")]
75mod android_host;
76#[cfg(target_os = "macos")]
77mod appkit_alert;
78#[cfg(target_os = "ios")]
79mod apple_alert;
80#[cfg(any(target_os = "ios", target_os = "macos"))]
81mod apple_host;
82#[cfg(target_os = "ios")]
83mod apple_sheet;
84#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
85mod unsupported;
86
87#[cfg(target_os = "android")]
88use android_alert::Host as PlatformHost;
89#[cfg(target_os = "macos")]
90use appkit_alert::Host as PlatformHost;
91#[cfg(target_os = "ios")]
92use apple_alert::Host as PlatformHost;
93#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
94use unsupported::Host as PlatformHost;
95
96#[cfg(not(target_os = "ios"))]
97use UnsupportedSheet as SheetPlatformHost;
98#[cfg(target_os = "ios")]
99use apple_sheet::Host as SheetPlatformHost;
100
101pub(crate) use oneshot::Sender;
102
103use std::fmt;
104use std::future::Future;
105use std::pin::Pin;
106use std::sync::atomic::{AtomicU64, Ordering};
107use std::sync::{Arc, Mutex, MutexGuard};
108use std::task::{Context, Poll};
109use std::time::Instant;
110
111pub const MAX_ALERT_ACTIONS: usize = 3;
114
115#[derive(Clone, Debug, PartialEq)]
127pub struct AlertSpec {
128 pub title: String,
130 pub message: String,
132 pub actions: Vec<AlertAction>,
134 pub cancelable: bool,
138 pub style: AlertStyle,
140 pub anchor: Option<AnchorRect>,
143}
144
145impl AlertSpec {
146 pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
149 Self {
150 title: title.into(),
151 message: message.into(),
152 actions: Vec::new(),
153 cancelable: true,
154 style: AlertStyle::Alert,
155 anchor: None,
156 }
157 }
158
159 #[must_use]
161 pub fn with_action(
162 mut self,
163 id: impl Into<String>,
164 label: impl Into<String>,
165 role: ActionRole,
166 ) -> Self {
167 self.actions.push(AlertAction {
168 id: id.into(),
169 label: label.into(),
170 role,
171 });
172 self
173 }
174
175 pub fn validate(&self) -> Result<(), PresentError> {
180 if self.actions.is_empty() {
181 return Err(PresentError::InvalidSpec(
182 "an alert needs at least one action".to_string(),
183 ));
184 }
185 if self.actions.len() > MAX_ALERT_ACTIONS {
186 return Err(PresentError::InvalidSpec(format!(
187 "an alert takes at most {MAX_ALERT_ACTIONS} actions, got {}",
188 self.actions.len()
189 )));
190 }
191 for (index, action) in self.actions.iter().enumerate() {
192 if action.id.is_empty() {
193 return Err(PresentError::InvalidSpec(format!(
194 "action {index} has an empty id"
195 )));
196 }
197 if self.actions[..index].iter().any(|a| a.id == action.id) {
198 return Err(PresentError::InvalidSpec(format!(
199 "duplicate action id {:?}",
200 action.id
201 )));
202 }
203 }
204 let cancels = self
205 .actions
206 .iter()
207 .filter(|a| a.role == ActionRole::Cancel)
208 .count();
209 if cancels > 1 {
210 return Err(PresentError::InvalidSpec(format!(
211 "at most one action may take the Cancel role, got {cancels}"
212 )));
213 }
214 if let Some(anchor) = &self.anchor
215 && !anchor.is_valid()
216 {
217 return Err(PresentError::InvalidSpec(
218 "the anchor rect must be finite with a non-negative size".to_string(),
219 ));
220 }
221 Ok(())
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct AlertAction {
228 pub id: String,
230 pub label: String,
232 pub role: ActionRole,
234}
235
236#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
238pub enum ActionRole {
239 #[default]
241 Default,
242 Cancel,
244 Destructive,
246}
247
248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
250pub enum AlertStyle {
251 #[default]
253 Alert,
254 ActionSheet,
258}
259
260#[derive(Clone, Copy, Debug, Default, PartialEq)]
286pub struct AnchorRect {
287 pub x: f64,
289 pub y: f64,
291 pub width: f64,
293 pub height: f64,
295}
296
297impl AnchorRect {
298 fn is_valid(&self) -> bool {
300 [self.x, self.y, self.width, self.height]
301 .iter()
302 .all(|v| v.is_finite())
303 && self.width >= 0.0
304 && self.height >= 0.0
305 }
306}
307
308#[derive(Clone, Debug, PartialEq, Eq, Hash)]
310pub enum AlertOutcome {
311 Action(String),
313 Cancelled,
315 Dismissed,
317 HostLost,
320}
321
322pub const MAX_SHEET_ACTIONS: usize = 3;
326
327#[derive(Clone, Debug, Default, PartialEq, Eq)]
334pub struct SheetContent {
335 pub title: Option<String>,
337 pub message: Option<String>,
340 pub image: Option<Arc<[u8]>>,
344 pub actions: Vec<SheetAction>,
346}
347
348impl SheetContent {
349 pub fn new() -> Self {
352 Self::default()
353 }
354
355 #[must_use]
357 pub fn with_title(mut self, title: impl Into<String>) -> Self {
358 self.title = Some(title.into());
359 self
360 }
361
362 #[must_use]
364 pub fn with_message(mut self, message: impl Into<String>) -> Self {
365 self.message = Some(message.into());
366 self
367 }
368
369 #[must_use]
371 pub fn with_image(mut self, bytes: impl Into<Arc<[u8]>>) -> Self {
372 self.image = Some(bytes.into());
373 self
374 }
375
376 #[must_use]
378 pub fn with_action(
379 mut self,
380 id: impl Into<String>,
381 label: impl Into<String>,
382 role: ActionRole,
383 ) -> Self {
384 self.actions.push(SheetAction {
385 id: id.into(),
386 label: label.into(),
387 role,
388 });
389 self
390 }
391
392 fn is_empty(&self) -> bool {
395 self.title.as_deref().is_none_or(str::is_empty)
396 && self.message.as_deref().is_none_or(str::is_empty)
397 && self.image.is_none()
398 && self.actions.is_empty()
399 }
400}
401
402#[derive(Clone, Debug, PartialEq, Eq)]
404pub struct SheetAction {
405 pub id: String,
407 pub label: String,
409 pub role: ActionRole,
414}
415
416#[derive(Clone, Copy, Debug, PartialEq)]
425pub enum Detent {
426 Medium,
428 Large,
430 Custom(f64),
436}
437
438impl Detent {
439 fn is_valid(self) -> bool {
441 match self {
442 Self::Medium | Self::Large => true,
443 Self::Custom(fraction) => fraction.is_finite() && fraction > 0.0 && fraction <= 1.0,
444 }
445 }
446
447 #[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))]
452 pub(crate) fn system_fallback(self) -> Self {
453 match self {
454 Self::Custom(fraction) if fraction <= 0.75 => Self::Medium,
455 Self::Custom(_) => Self::Large,
456 other => other,
457 }
458 }
459}
460
461#[derive(Clone)]
464struct DetentListener(Arc<dyn Fn(Detent) + Send + Sync>);
465
466impl fmt::Debug for DetentListener {
467 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468 f.write_str("DetentListener(..)")
469 }
470}
471
472impl PartialEq for DetentListener {
473 fn eq(&self, other: &Self) -> bool {
474 Arc::ptr_eq(&self.0, &other.0)
475 }
476}
477
478#[derive(Clone, Debug, PartialEq)]
484pub struct SheetSpec {
485 pub content: SheetContent,
487 pub detents: Vec<Detent>,
490 pub selected: Option<Detent>,
493 pub grabber: bool,
495 pub scrolling_expands: bool,
499 pub largest_undimmed: Option<Detent>,
503 pub corner_radius: Option<f64>,
505 pub dismissible: bool,
509 pub tint: Option<u32>,
514 pub dark: Option<bool>,
516 on_detent: Option<DetentListener>,
517}
518
519impl SheetSpec {
520 pub fn new(content: SheetContent) -> Self {
523 Self {
524 content,
525 detents: vec![Detent::Medium, Detent::Large],
526 selected: None,
527 grabber: true,
528 scrolling_expands: true,
529 largest_undimmed: None,
530 corner_radius: None,
531 dismissible: true,
532 tint: None,
533 dark: None,
534 on_detent: None,
535 }
536 }
537
538 #[must_use]
540 pub fn with_detents(mut self, detents: impl IntoIterator<Item = Detent>) -> Self {
541 self.detents = detents.into_iter().collect();
542 self
543 }
544
545 #[must_use]
547 pub fn with_selected(mut self, detent: Detent) -> Self {
548 self.selected = Some(detent);
549 self
550 }
551
552 #[must_use]
554 pub fn with_dismissible(mut self, dismissible: bool) -> Self {
555 self.dismissible = dismissible;
556 self
557 }
558
559 #[must_use]
565 pub fn on_detent(mut self, listener: impl Fn(Detent) + Send + Sync + 'static) -> Self {
566 self.on_detent = Some(DetentListener(Arc::new(listener)));
567 self
568 }
569
570 #[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))]
572 pub(crate) fn detent_listener(&self) -> Option<Arc<dyn Fn(Detent) + Send + Sync>> {
573 self.on_detent
574 .as_ref()
575 .map(|listener| Arc::clone(&listener.0))
576 }
577
578 pub fn validate(&self) -> Result<(), PresentError> {
589 let invalid = |message: String| Err(PresentError::InvalidSpec(message));
590 if self.content.is_empty() {
591 return invalid(
592 "a sheet needs content: a title, a message, an image or an action".to_string(),
593 );
594 }
595 if self.content.image.as_deref().is_some_and(<[u8]>::is_empty) {
596 return invalid("the sheet's image bytes are empty".to_string());
597 }
598 let actions = &self.content.actions;
599 if actions.len() > MAX_SHEET_ACTIONS {
600 return invalid(format!(
601 "a sheet takes at most {MAX_SHEET_ACTIONS} actions, got {}",
602 actions.len()
603 ));
604 }
605 for (index, action) in actions.iter().enumerate() {
606 if action.id.is_empty() {
607 return invalid(format!("action {index} has an empty id"));
608 }
609 if actions[..index].iter().any(|a| a.id == action.id) {
610 return invalid(format!("duplicate action id {:?}", action.id));
611 }
612 }
613 if self.detents.is_empty() {
614 return invalid("a sheet needs at least one detent".to_string());
615 }
616 for (index, detent) in self.detents.iter().enumerate() {
617 if !detent.is_valid() {
618 return invalid(format!(
619 "detent {index} ({detent:?}): a custom fraction must be finite with \
620 0 < f <= 1"
621 ));
622 }
623 if self.detents[..index].contains(detent) {
624 return invalid(format!("duplicate detent {detent:?}"));
625 }
626 }
627 for (name, detent) in [
628 ("selected", self.selected),
629 ("largest_undimmed", self.largest_undimmed),
630 ] {
631 if let Some(detent) = detent
632 && !self.detents.contains(&detent)
633 {
634 return invalid(format!("{name} ({detent:?}) is not one of the detents"));
635 }
636 }
637 if let Some(radius) = self.corner_radius
638 && !(radius.is_finite() && radius >= 0.0)
639 {
640 return invalid("the corner radius must be finite and non-negative".to_string());
641 }
642 Ok(())
643 }
644}
645
646#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
648pub enum DismissReason {
649 User,
651 Programmatic,
653}
654
655#[derive(Clone, Debug, PartialEq, Eq, Hash)]
657pub enum SheetOutcome {
658 Action(String),
661 Dismissed(DismissReason),
663 HostLost,
665}
666
667#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
670pub struct SheetHandle {
671 generation: u64,
672}
673
674impl SheetHandle {
675 pub fn presentation(&self) -> PresentationHandle {
678 PresentationHandle {
679 generation: self.generation,
680 }
681 }
682
683 pub fn select_detent(&self, detent: Detent) {
687 select_detent_on::<SheetPlatformHost>(self, detent);
688 }
689
690 pub fn dismiss(&self) {
694 dismiss_sheet_on::<SheetPlatformHost>(self);
695 }
696}
697
698impl Presentation<SheetOutcome> {
699 pub fn sheet_handle(&self) -> Option<SheetHandle> {
702 self.handle.map(|handle| SheetHandle {
703 generation: handle.generation,
704 })
705 }
706}
707
708#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
713#[non_exhaustive]
714pub enum PresentError {
715 #[error("native presentation: another presentation is live")]
723 Busy,
724 #[error("native presentation: no host to present over")]
727 NoHost,
728 #[error("native presentation: unsupported on this platform")]
730 Unsupported,
731 #[error("native presentation: invalid spec: {0}")]
734 InvalidSpec(String),
735 #[error("native presentation: platform error: {0}")]
738 Platform(String),
739}
740
741#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
744pub struct PresentationHandle {
745 generation: u64,
746}
747
748#[must_use = "a presentation's outcome is only observed by polling it; dropping it abandons the outcome"]
756pub struct Presentation<T> {
757 state: PresentationState<T>,
758 handle: Option<PresentationHandle>,
759}
760
761enum PresentationState<T> {
765 Ready(Option<Result<T, PresentError>>),
766 Pending(oneshot::Receiver<T>),
767}
768
769impl<T> Presentation<T> {
770 fn ready(result: Result<T, PresentError>) -> Self {
771 Self {
772 state: PresentationState::Ready(Some(result)),
773 handle: None,
774 }
775 }
776
777 fn pending(receiver: oneshot::Receiver<T>, generation: u64) -> Self {
778 Self {
779 state: PresentationState::Pending(receiver),
780 handle: Some(PresentationHandle { generation }),
781 }
782 }
783
784 pub fn handle(&self) -> Option<PresentationHandle> {
788 self.handle
789 }
790
791 #[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
797 pub(crate) fn take_refusal(&mut self) -> Option<PresentError> {
798 match &mut self.state {
799 PresentationState::Ready(slot) if matches!(slot, Some(Err(_))) => {
800 slot.take().and_then(Result::err)
801 }
802 _ => None,
803 }
804 }
805}
806
807impl<T> fmt::Debug for Presentation<T> {
808 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
809 let state = match &self.state {
810 PresentationState::Ready(Some(_)) => "ready",
811 PresentationState::Ready(None) => "taken",
812 PresentationState::Pending(_) => "pending",
813 };
814 f.debug_struct("Presentation")
815 .field("state", &state)
816 .field("handle", &self.handle)
817 .finish()
818 }
819}
820
821impl<T> Unpin for Presentation<T> {}
824
825impl<T> Future for Presentation<T> {
826 type Output = Result<T, PresentError>;
827
828 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
829 match &mut self.get_mut().state {
830 PresentationState::Ready(value) => match value.take() {
831 Some(value) => Poll::Ready(value),
832 None => Poll::Pending,
833 },
834 PresentationState::Pending(receiver) => Pin::new(receiver).poll(cx),
837 }
838 }
839}
840
841pub(crate) trait AlertHost {
844 fn show_alert(
849 spec: AlertSpec,
850 tx: Sender<AlertOutcome>,
851 generation: u64,
852 ) -> Result<(), PresentError>;
853
854 fn dismiss(generation: u64);
859}
860
861struct ActiveSlot {
864 generation: u64,
865 claimed_at: Option<Instant>,
866}
867
868static ACTIVE: Mutex<Option<ActiveSlot>> = Mutex::new(None);
870
871static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
875
876fn next_generation() -> u64 {
879 loop {
880 let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
881 if generation != 0 {
882 return generation;
883 }
884 }
885}
886
887#[inline]
894fn claim_clock() -> Option<Instant> {
895 #[cfg(target_arch = "wasm32")]
896 {
897 None
898 }
899 #[cfg(not(target_arch = "wasm32"))]
900 {
901 Some(Instant::now())
902 }
903}
904
905fn busy_refusal_message(generation: u64, age: Option<std::time::Duration>) -> String {
910 match age {
911 Some(duration) => format!(
912 "frust-native-widgets: presentation request refused: presentation {} has been live for {:.1}s \
913 — dismiss it through its handle or drop its future to free the slot",
914 generation,
915 duration.as_secs_f64()
916 ),
917 None => format!(
918 "frust-native-widgets: presentation request refused: presentation {} has been live for \
919 (age unavailable on this target) — dismiss it through its handle or drop its future to free the slot",
920 generation
921 ),
922 }
923}
924
925fn lock_active() -> MutexGuard<'static, Option<ActiveSlot>> {
930 ACTIVE
931 .lock()
932 .unwrap_or_else(|poisoned| poisoned.into_inner())
933}
934
935pub(crate) fn release_if_live(generation: u64) {
939 let mut active = lock_active();
940 if active
941 .as_ref()
942 .is_some_and(|slot| slot.generation == generation)
943 {
944 *active = None;
945 }
946}
947
948#[cfg(test)]
955pub(crate) fn live_since() -> Option<(u64, Option<std::time::Duration>)> {
956 lock_active().as_ref().map(|slot| {
957 (
958 slot.generation,
959 slot.claimed_at.map(|instant| instant.elapsed()),
960 )
961 })
962}
963
964fn submit<T>(start: impl FnOnce(Sender<T>, u64) -> Result<(), PresentError>) -> Presentation<T> {
969 let generation = {
970 let mut active = lock_active();
971 if let Some(live) = active.as_ref() {
972 let age = live.claimed_at.map(|instant| instant.elapsed());
973 let message = busy_refusal_message(live.generation, age);
974 log::warn!("{}", message);
975 return Presentation::ready(Err(PresentError::Busy));
976 }
977 let generation = next_generation();
978 *active = Some(ActiveSlot {
979 generation,
980 claimed_at: claim_clock(),
981 });
982 generation
983 };
984
985 let (tx, rx) = oneshot::channel(generation);
986 match start(tx, generation) {
987 Ok(()) => Presentation::pending(rx, generation),
988 Err(err) => {
989 drop(rx);
993 Presentation::ready(Err(err))
994 }
995 }
996}
997
998fn show_alert_on<H: AlertHost>(spec: AlertSpec) -> Presentation<AlertOutcome> {
999 if let Err(err) = spec.validate() {
1000 return Presentation::ready(Err(err));
1001 }
1002 submit(|tx, generation| H::show_alert(spec, tx, generation))
1003}
1004
1005fn dismiss_on<H: AlertHost>(handle: &PresentationHandle) {
1006 if is_live(handle.generation) {
1009 H::dismiss(handle.generation);
1010 }
1011}
1012
1013pub fn show_alert(spec: AlertSpec) -> Presentation<AlertOutcome> {
1022 show_alert_on::<PlatformHost>(spec)
1023}
1024
1025pub fn dismiss(handle: &PresentationHandle) {
1031 dismiss_on::<PlatformHost>(handle);
1032}
1033
1034pub(crate) trait SheetHost {
1039 fn show_sheet(
1044 spec: SheetSpec,
1045 tx: Sender<SheetOutcome>,
1046 generation: u64,
1047 ) -> Result<(), PresentError>;
1048
1049 fn dismiss(generation: u64);
1054
1055 fn select_detent(generation: u64, detent: Detent);
1059}
1060
1061#[cfg_attr(target_os = "ios", allow(dead_code))]
1066pub(crate) struct UnsupportedSheet;
1067
1068impl SheetHost for UnsupportedSheet {
1069 fn show_sheet(
1070 _spec: SheetSpec,
1071 _tx: Sender<SheetOutcome>,
1072 _generation: u64,
1073 ) -> Result<(), PresentError> {
1074 Err(PresentError::Unsupported)
1075 }
1076
1077 fn dismiss(_generation: u64) {}
1078
1079 fn select_detent(_generation: u64, _detent: Detent) {}
1080}
1081
1082fn show_sheet_on<H: SheetHost>(spec: SheetSpec) -> Presentation<SheetOutcome> {
1083 if let Err(err) = spec.validate() {
1084 return Presentation::ready(Err(err));
1085 }
1086 submit(|tx, generation| H::show_sheet(spec, tx, generation))
1087}
1088
1089fn is_live(generation: u64) -> bool {
1093 lock_active()
1094 .as_ref()
1095 .is_some_and(|slot| slot.generation == generation)
1096}
1097
1098fn dismiss_sheet_on<H: SheetHost>(handle: &SheetHandle) {
1099 if is_live(handle.generation) {
1100 H::dismiss(handle.generation);
1101 }
1102}
1103
1104fn select_detent_on<H: SheetHost>(handle: &SheetHandle, detent: Detent) {
1105 if !detent.is_valid() {
1106 log::warn!(
1107 "frust-native-widgets: select_detent({detent:?}) ignored: a custom fraction must be \
1108 finite with 0 < f <= 1"
1109 );
1110 return;
1111 }
1112 if is_live(handle.generation) {
1113 H::select_detent(handle.generation, detent);
1114 }
1115}
1116
1117pub fn show_sheet(spec: SheetSpec) -> Presentation<SheetOutcome> {
1130 show_sheet_on::<SheetPlatformHost>(spec)
1131}
1132
1133#[cfg_attr(not(any(test, target_os = "android")), allow(dead_code))]
1140pub(crate) mod wire {
1141 use super::{AlertOutcome, PresentError};
1142
1143 pub(crate) const OUTCOME_ACTION: i32 = 0;
1145 pub(crate) const OUTCOME_CANCELLED: i32 = 1;
1147 pub(crate) const OUTCOME_DISMISSED: i32 = 2;
1149 pub(crate) const OUTCOME_HOST_LOST: i32 = 3;
1151 pub(crate) const OUTCOME_NO_HOST: i32 = 4;
1153 pub(crate) const OUTCOME_FAILED: i32 = 5;
1155
1156 pub(crate) fn alert_outcome(
1159 code: i32,
1160 action_index: i32,
1161 action_ids: &[String],
1162 ) -> Result<AlertOutcome, PresentError> {
1163 match code {
1164 OUTCOME_ACTION => usize::try_from(action_index)
1165 .ok()
1166 .and_then(|index| action_ids.get(index))
1167 .map(|id| AlertOutcome::Action(id.clone()))
1168 .ok_or_else(|| {
1169 PresentError::Platform(format!(
1170 "action index {action_index} out of range for {} actions",
1171 action_ids.len()
1172 ))
1173 }),
1174 OUTCOME_CANCELLED => Ok(AlertOutcome::Cancelled),
1175 OUTCOME_DISMISSED => Ok(AlertOutcome::Dismissed),
1176 OUTCOME_HOST_LOST => Ok(AlertOutcome::HostLost),
1177 OUTCOME_NO_HOST => Err(PresentError::NoHost),
1178 OUTCOME_FAILED => Err(PresentError::Platform(
1179 "the presenter failed to show the alert".to_string(),
1180 )),
1181 other => Err(PresentError::Platform(format!(
1182 "unknown presenter outcome code {other}"
1183 ))),
1184 }
1185 }
1186}
1187
1188#[cfg(test)]
1194pub(crate) mod test_support {
1195 use std::sync::{Mutex, MutexGuard};
1196
1197 use super::{
1198 ActiveSlot, Presentation, Sender, claim_clock, lock_active, next_generation, oneshot,
1199 };
1200
1201 static TEST_LOCK: Mutex<()> = Mutex::new(());
1204
1205 pub(crate) fn serialize() -> MutexGuard<'static, ()> {
1207 TEST_LOCK
1208 .lock()
1209 .unwrap_or_else(|poisoned| poisoned.into_inner())
1210 }
1211
1212 #[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
1216 pub(crate) fn pending_pair<T>() -> (Sender<T>, Presentation<T>) {
1217 let (tx, rx) = oneshot::channel(u64::MAX);
1218 (tx, Presentation::pending(rx, u64::MAX))
1219 }
1220
1221 #[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
1224 pub(crate) fn with_slot_held<R>(f: impl FnOnce() -> R) -> R {
1225 let _guard = serialize();
1226 *lock_active() = Some(ActiveSlot {
1227 generation: next_generation(),
1228 claimed_at: claim_clock(),
1229 });
1230 let result = f();
1231 *lock_active() = None;
1232 result
1233 }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use std::cell::RefCell;
1239 use std::collections::BTreeMap;
1240 use std::sync::atomic::Ordering as AtomicOrdering;
1241 use std::task::Waker;
1242
1243 use super::oneshot::tests::counting_waker;
1244 use super::*;
1245
1246 #[derive(Default)]
1249 struct FakeState {
1250 refuse: Option<PresentError>,
1251 live: Option<(u64, Sender<AlertOutcome>)>,
1252 shown: Vec<AlertSpec>,
1253 dismissed: Vec<u64>,
1254 }
1255
1256 thread_local! {
1257 static FAKE: RefCell<FakeState> = RefCell::new(FakeState::default());
1258 }
1259
1260 struct FakeHost;
1261
1262 impl AlertHost for FakeHost {
1263 fn show_alert(
1264 spec: AlertSpec,
1265 tx: Sender<AlertOutcome>,
1266 generation: u64,
1267 ) -> Result<(), PresentError> {
1268 FAKE.with(|fake| {
1269 let mut fake = fake.borrow_mut();
1270 fake.shown.push(spec);
1271 if let Some(err) = fake.refuse.clone() {
1272 return Err(err);
1273 }
1274 fake.live = Some((generation, tx));
1275 Ok(())
1276 })
1277 }
1278
1279 fn dismiss(generation: u64) {
1280 let taken = FAKE.with(|fake| {
1281 let mut fake = fake.borrow_mut();
1282 fake.dismissed.push(generation);
1283 match &fake.live {
1284 Some((live, _)) if *live == generation => fake.live.take(),
1285 _ => None,
1286 }
1287 });
1288 if let Some((_, tx)) = taken {
1291 tx.send(Ok(AlertOutcome::Dismissed));
1292 }
1293 }
1294 }
1295
1296 fn platform_resolves(outcome: Result<AlertOutcome, PresentError>) -> bool {
1299 match FAKE.with(|fake| fake.borrow_mut().live.take()) {
1300 Some((_, tx)) => {
1301 tx.send(outcome);
1302 true
1303 }
1304 None => false,
1305 }
1306 }
1307
1308 fn isolated<R>(f: impl FnOnce() -> R) -> R {
1311 let _guard = test_support::serialize();
1312 let reset = || {
1313 *lock_active() = None;
1314 FAKE.with(|fake| *fake.borrow_mut() = FakeState::default());
1315 };
1316 reset();
1317 let result = f();
1318 reset();
1319 result
1320 }
1321
1322 fn poll_with<T>(
1323 presentation: &mut Presentation<T>,
1324 waker: &Waker,
1325 ) -> Poll<Result<T, PresentError>> {
1326 let mut cx = Context::from_waker(waker);
1327 Pin::new(presentation).poll(&mut cx)
1328 }
1329
1330 fn poll_once<T>(presentation: &mut Presentation<T>) -> Poll<Result<T, PresentError>> {
1331 poll_with(presentation, &counting_waker().0)
1332 }
1333
1334 fn two_button_spec() -> AlertSpec {
1335 AlertSpec::new("Delete draft?", "This cannot be undone.")
1336 .with_action("keep", "Keep", ActionRole::Cancel)
1337 .with_action("delete", "Delete", ActionRole::Destructive)
1338 }
1339
1340 #[test]
1341 fn the_outcome_is_delivered_exactly_once() {
1342 isolated(|| {
1343 let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
1344 assert!(presentation.handle().is_some());
1345
1346 let (waker, woken) = counting_waker();
1347 assert_eq!(poll_with(&mut presentation, &waker), Poll::Pending);
1348
1349 assert!(platform_resolves(Ok(AlertOutcome::Action("delete".into()))));
1350 assert_eq!(woken.load(AtomicOrdering::SeqCst), 1);
1351 assert!(!platform_resolves(Ok(AlertOutcome::Cancelled)));
1354
1355 assert_eq!(
1356 poll_with(&mut presentation, &waker),
1357 Poll::Ready(Ok(AlertOutcome::Action("delete".into())))
1358 );
1359 assert_eq!(poll_with(&mut presentation, &waker), Poll::Pending);
1360 assert_eq!(woken.load(AtomicOrdering::SeqCst), 1);
1361 });
1362 }
1363
1364 #[test]
1365 fn a_second_show_while_one_is_live_is_busy() {
1366 isolated(|| {
1367 let mut first = show_alert_on::<FakeHost>(two_button_spec());
1368 let mut second = show_alert_on::<FakeHost>(two_button_spec());
1369
1370 assert_eq!(second.handle(), None);
1371 assert_eq!(poll_once(&mut second), Poll::Ready(Err(PresentError::Busy)));
1372 assert_eq!(
1373 FAKE.with(|fake| fake.borrow().shown.len()),
1374 1,
1375 "a Busy request must never reach the host"
1376 );
1377 assert_eq!(poll_once(&mut first), Poll::Pending);
1379 assert!(platform_resolves(Ok(AlertOutcome::Cancelled)));
1380 assert_eq!(
1381 poll_once(&mut first),
1382 Poll::Ready(Ok(AlertOutcome::Cancelled))
1383 );
1384 });
1385 }
1386
1387 #[test]
1388 fn a_busy_refusal_reports_the_live_generation() {
1389 isolated(|| {
1390 let first = show_alert_on::<FakeHost>(two_button_spec());
1391 let first_generation = first.handle().expect("accepted").generation;
1392
1393 let mut second = show_alert_on::<FakeHost>(two_button_spec());
1394 assert_eq!(poll_once(&mut second), Poll::Ready(Err(PresentError::Busy)));
1395
1396 let (generation, age) = live_since().expect("the first presentation is still live");
1397 assert_eq!(generation, first_generation);
1398 if let Some(duration) = age {
1400 assert!(duration < std::time::Duration::from_secs(5));
1401 }
1402
1403 drop(first);
1404 });
1405 }
1406
1407 #[test]
1408 fn a_busy_refusal_message_is_formatted_with_and_without_age() {
1409 let age_some = Some(std::time::Duration::from_secs_f64(1.5));
1411 let msg_with_age = busy_refusal_message(42, age_some);
1412 assert!(msg_with_age.contains("42"), "message includes generation");
1413 assert!(msg_with_age.contains("1.5"), "message includes age");
1414 assert!(
1415 msg_with_age.contains("has been live for"),
1416 "message includes time phrase"
1417 );
1418 assert!(
1419 !msg_with_age.contains("unavailable"),
1420 "message does not say unavailable"
1421 );
1422
1423 let msg_no_age = busy_refusal_message(43, None);
1425 assert!(msg_no_age.contains("43"), "message includes generation");
1426 assert!(
1427 msg_no_age.contains("age unavailable on this target"),
1428 "message explains no age"
1429 );
1430 assert!(
1431 msg_no_age.contains("has been live for"),
1432 "message includes time phrase"
1433 );
1434 }
1435
1436 #[test]
1437 fn the_slot_is_free_again_once_the_outcome_is_sent() {
1438 isolated(|| {
1439 let first = show_alert_on::<FakeHost>(two_button_spec());
1440 assert!(platform_resolves(Ok(AlertOutcome::Cancelled)));
1441 let mut next = show_alert_on::<FakeHost>(two_button_spec());
1444 assert!(next.handle().is_some());
1445 assert_eq!(poll_once(&mut next), Poll::Pending);
1446 drop(first);
1447 });
1448 }
1449
1450 #[test]
1451 fn dismiss_resolves_dismissed_once() {
1452 isolated(|| {
1453 let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
1454 let handle = presentation.handle().expect("accepted");
1455
1456 dismiss_on::<FakeHost>(&handle);
1457 assert_eq!(
1458 poll_once(&mut presentation),
1459 Poll::Ready(Ok(AlertOutcome::Dismissed))
1460 );
1461
1462 dismiss_on::<FakeHost>(&handle);
1464 assert_eq!(
1465 FAKE.with(|fake| fake.borrow().dismissed.clone()),
1466 vec![handle.generation]
1467 );
1468 assert_eq!(poll_once(&mut presentation), Poll::Pending);
1469 });
1470 }
1471
1472 #[test]
1473 fn a_late_dismiss_for_an_old_generation_is_ignored() {
1474 isolated(|| {
1475 let old = show_alert_on::<FakeHost>(two_button_spec());
1476 let old_handle = old.handle().expect("accepted");
1477 assert!(platform_resolves(Ok(AlertOutcome::Action("keep".into()))));
1478 drop(old);
1479
1480 let mut current = show_alert_on::<FakeHost>(two_button_spec());
1481 let current_handle = current.handle().expect("accepted");
1482 assert_ne!(old_handle, current_handle);
1483
1484 dismiss_on::<FakeHost>(&old_handle);
1485 assert!(FAKE.with(|fake| fake.borrow().dismissed.is_empty()));
1486 assert_eq!(poll_once(&mut current), Poll::Pending);
1487
1488 FakeHost::dismiss(old_handle.generation);
1491 assert_eq!(poll_once(&mut current), Poll::Pending);
1492
1493 dismiss_on::<FakeHost>(¤t_handle);
1494 assert_eq!(
1495 poll_once(&mut current),
1496 Poll::Ready(Ok(AlertOutcome::Dismissed))
1497 );
1498 });
1499 }
1500
1501 #[test]
1502 fn host_lost_resolves_and_frees_the_slot() {
1503 isolated(|| {
1504 let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
1505 let handle = presentation.handle().expect("accepted");
1506
1507 assert!(platform_resolves(Ok(AlertOutcome::HostLost)));
1508 assert_eq!(
1509 poll_once(&mut presentation),
1510 Poll::Ready(Ok(AlertOutcome::HostLost))
1511 );
1512 assert!(live_since().is_none());
1513
1514 dismiss_on::<FakeHost>(&handle);
1516 assert!(FAKE.with(|fake| fake.borrow().dismissed.is_empty()));
1517 });
1518 }
1519
1520 #[test]
1521 fn dropping_the_presentation_releases_the_guard() {
1522 isolated(|| {
1523 let abandoned = show_alert_on::<FakeHost>(two_button_spec());
1524 drop(abandoned);
1525 assert!(live_since().is_none());
1526
1527 let stale_sender = FAKE.with(|fake| fake.borrow_mut().live.take());
1530 let mut current = show_alert_on::<FakeHost>(two_button_spec());
1531 let (_, stale_tx) = stale_sender.expect("the fake held the first sender");
1532 assert!(!stale_tx.send(Ok(AlertOutcome::Cancelled)), "discarded");
1533
1534 assert_eq!(poll_once(&mut current), Poll::Pending);
1535 assert_eq!(
1536 live_since().map(|(generation, _)| generation),
1537 current.handle().map(|h| h.generation)
1538 );
1539 });
1540 }
1541
1542 #[test]
1543 fn a_host_refusal_resolves_the_error_and_frees_the_slot() {
1544 isolated(|| {
1545 FAKE.with(|fake| fake.borrow_mut().refuse = Some(PresentError::NoHost));
1546 let mut refused = show_alert_on::<FakeHost>(two_button_spec());
1547 assert_eq!(refused.handle(), None);
1548 assert_eq!(
1549 poll_once(&mut refused),
1550 Poll::Ready(Err(PresentError::NoHost))
1551 );
1552 assert!(live_since().is_none());
1553 });
1554 }
1555
1556 #[test]
1557 fn an_arm_dropping_its_sender_resolves_platform_and_frees_the_slot() {
1558 isolated(|| {
1559 let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
1560 drop(FAKE.with(|fake| fake.borrow_mut().live.take()));
1561 assert!(live_since().is_none());
1562 assert_eq!(
1563 poll_once(&mut presentation),
1564 Poll::Ready(Err(PresentError::Platform(
1565 oneshot::DROPPED_WITHOUT_OUTCOME.to_string()
1566 )))
1567 );
1568 });
1569 }
1570
1571 #[test]
1572 fn an_invalid_spec_is_refused_before_claiming_the_slot() {
1573 isolated(|| {
1574 let zero_actions = AlertSpec::new("t", "m");
1575 let four = AlertSpec::new("t", "m")
1576 .with_action("a", "A", ActionRole::Default)
1577 .with_action("b", "B", ActionRole::Default)
1578 .with_action("c", "C", ActionRole::Default)
1579 .with_action("d", "D", ActionRole::Default);
1580 let duplicate = AlertSpec::new("t", "m")
1581 .with_action("a", "A", ActionRole::Default)
1582 .with_action("a", "Again", ActionRole::Default);
1583 let empty_id = AlertSpec::new("t", "m").with_action("", "A", ActionRole::Default);
1584 let two_cancels = AlertSpec::new("t", "m")
1585 .with_action("a", "A", ActionRole::Cancel)
1586 .with_action("b", "B", ActionRole::Cancel);
1587 let mut bad_anchor =
1590 AlertSpec::new("t", "m").with_action("a", "A", ActionRole::Default);
1591 bad_anchor.style = AlertStyle::ActionSheet;
1592 bad_anchor.anchor = Some(AnchorRect {
1593 x: f64::NAN,
1594 y: 0.0,
1595 width: 10.0,
1596 height: 10.0,
1597 });
1598 let mut negative_anchor = bad_anchor.clone();
1599 negative_anchor.anchor = Some(AnchorRect {
1600 x: 0.0,
1601 y: 0.0,
1602 width: -1.0,
1603 height: 10.0,
1604 });
1605
1606 for spec in [
1607 zero_actions,
1608 four,
1609 duplicate,
1610 empty_id,
1611 two_cancels,
1612 bad_anchor,
1613 negative_anchor,
1614 ] {
1615 let mut presentation = show_alert_on::<FakeHost>(spec.clone());
1616 assert!(
1617 matches!(
1618 poll_once(&mut presentation),
1619 Poll::Ready(Err(PresentError::InvalidSpec(_)))
1620 ),
1621 "{spec:?} should be refused"
1622 );
1623 assert!(live_since().is_none());
1624 }
1625 assert!(FAKE.with(|fake| fake.borrow().shown.is_empty()));
1626 });
1627 }
1628
1629 #[test]
1630 fn a_refusal_is_taken_once_and_an_accepted_request_has_none() {
1631 isolated(|| {
1632 let mut accepted = show_alert_on::<FakeHost>(two_button_spec());
1633 assert_eq!(accepted.take_refusal(), None);
1634 assert_eq!(poll_once(&mut accepted), Poll::Pending);
1635
1636 let mut busy = show_alert_on::<FakeHost>(two_button_spec());
1637 assert_eq!(busy.take_refusal(), Some(PresentError::Busy));
1638 assert_eq!(busy.take_refusal(), None);
1639 assert_eq!(poll_once(&mut busy), Poll::Pending);
1640 });
1641 }
1642
1643 #[test]
1644 fn a_valid_spec_passes() {
1645 let mut sheet = two_button_spec().with_action("share", "Share", ActionRole::Default);
1646 sheet.style = AlertStyle::ActionSheet;
1647 sheet.anchor = Some(AnchorRect {
1648 x: 10.0,
1649 y: 20.0,
1650 width: 0.0,
1651 height: 0.0,
1652 });
1653 assert_eq!(sheet.validate(), Ok(()));
1654 assert_eq!(
1655 AlertSpec::new("", "")
1656 .with_action("a", "A", ActionRole::Default)
1657 .validate(),
1658 Ok(())
1659 );
1660 }
1661
1662 #[test]
1663 fn a_zero_action_spec_is_rejected() {
1664 assert_eq!(
1665 AlertSpec::new("t", "m").validate(),
1666 Err(PresentError::InvalidSpec(
1667 "an alert needs at least one action".to_string()
1668 ))
1669 );
1670 }
1671
1672 #[test]
1673 fn generations_are_never_zero_and_always_fresh() {
1674 let a = next_generation();
1675 let b = next_generation();
1676 assert_ne!(a, 0);
1677 assert_ne!(b, 0);
1678 assert_ne!(a, b);
1679 }
1680
1681 #[test]
1682 fn the_wire_table_maps_every_code() {
1683 let ids = ["keep".to_string(), "delete".to_string()];
1684 assert_eq!(
1685 wire::alert_outcome(wire::OUTCOME_ACTION, 1, &ids),
1686 Ok(AlertOutcome::Action("delete".into()))
1687 );
1688 assert!(matches!(
1689 wire::alert_outcome(wire::OUTCOME_ACTION, 2, &ids),
1690 Err(PresentError::Platform(_))
1691 ));
1692 assert!(matches!(
1693 wire::alert_outcome(wire::OUTCOME_ACTION, -1, &ids),
1694 Err(PresentError::Platform(_))
1695 ));
1696 assert_eq!(
1697 wire::alert_outcome(wire::OUTCOME_CANCELLED, -1, &ids),
1698 Ok(AlertOutcome::Cancelled)
1699 );
1700 assert_eq!(
1701 wire::alert_outcome(wire::OUTCOME_DISMISSED, -1, &ids),
1702 Ok(AlertOutcome::Dismissed)
1703 );
1704 assert_eq!(
1705 wire::alert_outcome(wire::OUTCOME_HOST_LOST, -1, &ids),
1706 Ok(AlertOutcome::HostLost)
1707 );
1708 assert_eq!(
1709 wire::alert_outcome(wire::OUTCOME_NO_HOST, -1, &ids),
1710 Err(PresentError::NoHost)
1711 );
1712 assert!(matches!(
1713 wire::alert_outcome(wire::OUTCOME_FAILED, -1, &ids),
1714 Err(PresentError::Platform(_))
1715 ));
1716 assert!(matches!(
1717 wire::alert_outcome(99, -1, &ids),
1718 Err(PresentError::Platform(_))
1719 ));
1720 }
1721
1722 #[derive(Default)]
1727 struct FakeSheetState {
1728 live: Option<(u64, Sender<SheetOutcome>)>,
1729 shown: usize,
1730 dismissed: Vec<u64>,
1731 moved: Vec<(u64, Detent)>,
1732 }
1733
1734 thread_local! {
1735 static FAKE_SHEET: RefCell<FakeSheetState> = RefCell::new(FakeSheetState::default());
1736 }
1737
1738 struct FakeSheetHost;
1739
1740 impl SheetHost for FakeSheetHost {
1741 fn show_sheet(
1742 _spec: SheetSpec,
1743 tx: Sender<SheetOutcome>,
1744 generation: u64,
1745 ) -> Result<(), PresentError> {
1746 FAKE_SHEET.with(|fake| {
1747 let mut fake = fake.borrow_mut();
1748 fake.shown += 1;
1749 fake.live = Some((generation, tx));
1750 });
1751 Ok(())
1752 }
1753
1754 fn dismiss(generation: u64) {
1755 let taken = FAKE_SHEET.with(|fake| {
1756 let mut fake = fake.borrow_mut();
1757 fake.dismissed.push(generation);
1758 match &fake.live {
1759 Some((live, _)) if *live == generation => fake.live.take(),
1760 _ => None,
1761 }
1762 });
1763 if let Some((_, tx)) = taken {
1764 tx.send(Ok(SheetOutcome::Dismissed(DismissReason::Programmatic)));
1765 }
1766 }
1767
1768 fn select_detent(generation: u64, detent: Detent) {
1769 FAKE_SHEET.with(|fake| fake.borrow_mut().moved.push((generation, detent)));
1770 }
1771 }
1772
1773 fn sheet_resolves(outcome: Result<SheetOutcome, PresentError>) -> bool {
1776 match FAKE_SHEET.with(|fake| fake.borrow_mut().live.take()) {
1777 Some((_, tx)) => {
1778 tx.send(outcome);
1779 true
1780 }
1781 None => false,
1782 }
1783 }
1784
1785 fn isolated_sheet<R>(f: impl FnOnce() -> R) -> R {
1787 isolated(|| {
1788 let reset = || FAKE_SHEET.with(|fake| *fake.borrow_mut() = FakeSheetState::default());
1789 reset();
1790 let result = f();
1791 reset();
1792 result
1793 })
1794 }
1795
1796 fn sheet_spec() -> SheetSpec {
1797 SheetSpec::new(
1798 SheetContent::new()
1799 .with_title("Share draft")
1800 .with_message("Pick where it goes.")
1801 .with_action("copy", "Copy link", ActionRole::Default)
1802 .with_action("delete", "Delete", ActionRole::Destructive),
1803 )
1804 }
1805
1806 fn assert_invalid_sheet(spec: &SheetSpec) {
1807 assert!(
1808 matches!(spec.validate(), Err(PresentError::InvalidSpec(_))),
1809 "{spec:?} should be refused"
1810 );
1811 }
1812
1813 #[test]
1814 fn a_valid_sheet_spec_passes() {
1815 assert_eq!(sheet_spec().validate(), Ok(()));
1816 for content in [
1818 SheetContent::new().with_title("t"),
1819 SheetContent::new().with_message("m"),
1820 SheetContent::new().with_image(vec![0x89, b'P', b'N', b'G']),
1821 SheetContent::new().with_action("ok", "OK", ActionRole::Default),
1822 ] {
1823 let spec = SheetSpec::new(content)
1824 .with_detents([Detent::Custom(0.25), Detent::Medium, Detent::Custom(1.0)])
1825 .with_selected(Detent::Custom(0.25));
1826 assert_eq!(spec.validate(), Ok(()), "{spec:?}");
1827 }
1828 }
1829
1830 #[test]
1831 fn empty_sheet_content_is_refused() {
1832 assert_invalid_sheet(&SheetSpec::new(SheetContent::new()));
1833 assert_invalid_sheet(&SheetSpec::new(
1835 SheetContent::new().with_title("").with_message(""),
1836 ));
1837 assert_invalid_sheet(&SheetSpec::new(
1839 SheetContent::new()
1840 .with_title("t")
1841 .with_image(Vec::<u8>::new()),
1842 ));
1843 }
1844
1845 #[test]
1846 fn more_than_three_sheet_actions_are_refused() {
1847 let content = (0..=MAX_SHEET_ACTIONS).fold(SheetContent::new(), |content, i| {
1848 content.with_action(format!("a{i}"), "A", ActionRole::Default)
1849 });
1850 assert_eq!(content.actions.len(), MAX_SHEET_ACTIONS + 1);
1851 assert_invalid_sheet(&SheetSpec::new(content));
1852 }
1853
1854 #[test]
1855 fn sheet_action_ids_must_be_non_empty_and_unique() {
1856 assert_invalid_sheet(&SheetSpec::new(SheetContent::new().with_action(
1857 "",
1858 "A",
1859 ActionRole::Default,
1860 )));
1861 assert_invalid_sheet(&SheetSpec::new(
1862 SheetContent::new()
1863 .with_action("a", "A", ActionRole::Default)
1864 .with_action("a", "Again", ActionRole::Cancel),
1865 ));
1866 }
1867
1868 #[test]
1869 fn a_custom_fraction_must_lie_in_zero_exclusive_to_one_inclusive() {
1870 for fraction in [0.0, -0.25, 1.000_001, 2.0, f64::NAN, f64::INFINITY] {
1871 assert_invalid_sheet(&sheet_spec().with_detents([Detent::Custom(fraction)]));
1872 }
1873 for fraction in [f64::MIN_POSITIVE, 0.5, 1.0] {
1874 assert_eq!(
1875 sheet_spec()
1876 .with_detents([Detent::Custom(fraction)])
1877 .validate(),
1878 Ok(())
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn a_custom_detent_falls_back_to_the_nearest_system_detent() {
1885 assert_eq!(Detent::Custom(0.1).system_fallback(), Detent::Medium);
1886 assert_eq!(Detent::Custom(0.75).system_fallback(), Detent::Medium);
1887 assert_eq!(Detent::Custom(0.76).system_fallback(), Detent::Large);
1888 assert_eq!(Detent::Custom(1.0).system_fallback(), Detent::Large);
1889 assert_eq!(Detent::Medium.system_fallback(), Detent::Medium);
1890 assert_eq!(Detent::Large.system_fallback(), Detent::Large);
1891 }
1892
1893 #[test]
1894 fn detent_rules_are_enforced() {
1895 assert_invalid_sheet(&sheet_spec().with_detents([]));
1896 assert_invalid_sheet(&sheet_spec().with_detents([Detent::Medium, Detent::Medium]));
1897 assert_invalid_sheet(
1898 &sheet_spec()
1899 .with_detents([Detent::Medium])
1900 .with_selected(Detent::Large),
1901 );
1902 let mut undimmed = sheet_spec().with_detents([Detent::Large]);
1903 undimmed.largest_undimmed = Some(Detent::Medium);
1904 assert_invalid_sheet(&undimmed);
1905 let mut radius = sheet_spec();
1906 radius.corner_radius = Some(-1.0);
1907 assert_invalid_sheet(&radius);
1908 radius.corner_radius = Some(f64::NAN);
1909 assert_invalid_sheet(&radius);
1910 radius.corner_radius = Some(0.0);
1911 assert_eq!(radius.validate(), Ok(()));
1912 }
1913
1914 #[test]
1915 fn an_invalid_sheet_never_reaches_the_host_or_the_slot() {
1916 isolated_sheet(|| {
1917 let mut refused = show_sheet_on::<FakeSheetHost>(SheetSpec::new(SheetContent::new()));
1918 assert_eq!(refused.sheet_handle(), None);
1919 assert!(matches!(
1920 poll_once(&mut refused),
1921 Poll::Ready(Err(PresentError::InvalidSpec(_)))
1922 ));
1923 assert!(live_since().is_none());
1924 assert_eq!(FAKE_SHEET.with(|fake| fake.borrow().shown), 0);
1925 });
1926 }
1927
1928 #[test]
1929 fn the_unsupported_sheet_arm_refuses_and_frees_the_slot() {
1930 isolated_sheet(|| {
1931 let mut refused = show_sheet_on::<UnsupportedSheet>(sheet_spec());
1932 assert_eq!(refused.sheet_handle(), None);
1933 assert_eq!(
1934 poll_once(&mut refused),
1935 Poll::Ready(Err(PresentError::Unsupported))
1936 );
1937 assert!(live_since().is_none());
1938 UnsupportedSheet::dismiss(u64::MAX);
1940 UnsupportedSheet::select_detent(u64::MAX, Detent::Large);
1941 });
1942 }
1943
1944 #[cfg(not(target_os = "ios"))]
1947 #[test]
1948 fn show_sheet_is_unsupported_off_ios() {
1949 isolated_sheet(|| {
1950 let mut presentation = show_sheet(sheet_spec());
1951 assert_eq!(
1952 poll_once(&mut presentation),
1953 Poll::Ready(Err(PresentError::Unsupported))
1954 );
1955 assert!(live_since().is_none());
1956 });
1957 }
1958
1959 #[test]
1960 fn a_sheet_action_resolves_exactly_once() {
1961 isolated_sheet(|| {
1962 let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
1963 let handle = presentation.sheet_handle().expect("accepted");
1964 assert_eq!(Some(handle.presentation()), presentation.handle());
1965 assert_eq!(poll_once(&mut presentation), Poll::Pending);
1966
1967 assert!(sheet_resolves(Ok(SheetOutcome::Action("copy".into()))));
1968 assert!(!sheet_resolves(Ok(SheetOutcome::Dismissed(
1970 DismissReason::User
1971 ))));
1972 assert_eq!(
1973 poll_once(&mut presentation),
1974 Poll::Ready(Ok(SheetOutcome::Action("copy".into())))
1975 );
1976 assert_eq!(poll_once(&mut presentation), Poll::Pending);
1977 assert!(live_since().is_none());
1978 });
1979 }
1980
1981 #[test]
1982 fn a_user_swipe_resolves_dismissed_user() {
1983 isolated_sheet(|| {
1984 let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
1985 assert!(sheet_resolves(Ok(SheetOutcome::Dismissed(
1986 DismissReason::User
1987 ))));
1988 assert_eq!(
1989 poll_once(&mut presentation),
1990 Poll::Ready(Ok(SheetOutcome::Dismissed(DismissReason::User)))
1991 );
1992 });
1993 }
1994
1995 #[test]
1996 fn the_sheet_handle_dismisses_once_and_goes_stale() {
1997 isolated_sheet(|| {
1998 let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
1999 let handle = presentation.sheet_handle().expect("accepted");
2000
2001 dismiss_sheet_on::<FakeSheetHost>(&handle);
2002 assert_eq!(
2003 poll_once(&mut presentation),
2004 Poll::Ready(Ok(SheetOutcome::Dismissed(DismissReason::Programmatic)))
2005 );
2006 dismiss_sheet_on::<FakeSheetHost>(&handle);
2008 select_detent_on::<FakeSheetHost>(&handle, Detent::Large);
2009 FAKE_SHEET.with(|fake| {
2010 let fake = fake.borrow();
2011 assert_eq!(fake.dismissed, vec![handle.generation]);
2012 assert!(fake.moved.is_empty());
2013 });
2014 });
2015 }
2016
2017 #[test]
2018 fn select_detent_reaches_the_host_only_while_live_and_valid() {
2019 isolated_sheet(|| {
2020 let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
2021 let handle = presentation.sheet_handle().expect("accepted");
2022
2023 select_detent_on::<FakeSheetHost>(&handle, Detent::Large);
2024 select_detent_on::<FakeSheetHost>(&handle, Detent::Custom(0.0));
2025 select_detent_on::<FakeSheetHost>(&handle, Detent::Custom(f64::NAN));
2026 assert_eq!(
2027 FAKE_SHEET.with(|fake| fake.borrow().moved.clone()),
2028 vec![(handle.generation, Detent::Large)]
2029 );
2030 assert_eq!(poll_once(&mut presentation), Poll::Pending);
2032 assert!(live_since().is_some());
2033 });
2034 }
2035
2036 #[test]
2037 fn a_lost_host_resolves_the_sheet_and_frees_the_slot() {
2038 isolated_sheet(|| {
2039 let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
2040 assert!(sheet_resolves(Ok(SheetOutcome::HostLost)));
2041 assert_eq!(
2042 poll_once(&mut presentation),
2043 Poll::Ready(Ok(SheetOutcome::HostLost))
2044 );
2045 assert!(live_since().is_none());
2046 });
2047 }
2048
2049 #[test]
2050 fn alerts_and_sheets_share_the_one_busy_slot() {
2051 isolated_sheet(|| {
2052 let alert = show_alert_on::<FakeHost>(two_button_spec());
2053 let mut sheet = show_sheet_on::<FakeSheetHost>(sheet_spec());
2054 assert_eq!(sheet.sheet_handle(), None);
2055 assert_eq!(poll_once(&mut sheet), Poll::Ready(Err(PresentError::Busy)));
2056 assert_eq!(FAKE_SHEET.with(|fake| fake.borrow().shown), 0);
2057 drop(alert);
2058
2059 let live_sheet = show_sheet_on::<FakeSheetHost>(sheet_spec());
2060 assert!(live_sheet.sheet_handle().is_some());
2061 let mut alert = show_alert_on::<FakeHost>(two_button_spec());
2062 assert_eq!(poll_once(&mut alert), Poll::Ready(Err(PresentError::Busy)));
2063
2064 assert!(sheet_resolves(Ok(SheetOutcome::Dismissed(
2066 DismissReason::User
2067 ))));
2068 let mut next = show_alert_on::<FakeHost>(two_button_spec());
2069 assert!(next.handle().is_some());
2070 assert_eq!(poll_once(&mut next), Poll::Pending);
2071 drop(live_sheet);
2072 });
2073 }
2074
2075 #[test]
2076 fn the_detent_listener_is_carried_and_compared_by_identity() {
2077 let seen = Arc::new(Mutex::new(Vec::new()));
2078 let sink = Arc::clone(&seen);
2079 let spec = sheet_spec().on_detent(move |detent| {
2080 sink.lock().expect("unpoisoned").push(detent);
2081 });
2082 let listener = spec.detent_listener().expect("registered");
2083 listener(Detent::Large);
2084 listener(Detent::Custom(0.4));
2085 assert_eq!(
2086 *seen.lock().expect("unpoisoned"),
2087 vec![Detent::Large, Detent::Custom(0.4)]
2088 );
2089 assert_eq!(spec.clone(), spec, "a clone shares the listener");
2090 assert_ne!(spec.clone().on_detent(|_| {}), spec);
2091 assert!(sheet_spec().detent_listener().is_none());
2092 }
2093
2094 const PRESENTER_KT: &str = include_str!(
2103 "../../platform/android/src/main/kotlin/dev/frust/nativewidgets/FrustNativePresenter.kt"
2104 );
2105 const ANDROID_HOST_RS: &str = include_str!("android_host.rs");
2106
2107 fn kotlin_package() -> &'static str {
2108 PRESENTER_KT
2109 .lines()
2110 .find_map(|line| line.trim().strip_prefix("package "))
2111 .expect("FrustNativePresenter.kt declares a package")
2112 .trim()
2113 }
2114
2115 fn kotlin_object_name() -> String {
2116 PRESENTER_KT
2117 .lines()
2118 .find_map(|line| {
2119 line.trim()
2120 .strip_prefix("object ")
2121 .filter(|rest| rest.starts_with(char::is_uppercase))
2122 })
2123 .expect("FrustNativePresenter.kt declares a named `object`")
2124 .chars()
2125 .take_while(|c| c.is_alphanumeric() || *c == '_')
2126 .collect()
2127 }
2128
2129 #[test]
2130 fn presenter_outcome_codes_match_between_kotlin_and_rust() {
2131 let mut kotlin = BTreeMap::new();
2132 for line in PRESENTER_KT.lines() {
2133 let Some(rest) = line.trim().strip_prefix("const val OUTCOME_") else {
2134 continue;
2135 };
2136 let (name, value) = rest.split_once('=').expect("`NAME = value`");
2137 let value: i32 = value
2138 .trim()
2139 .parse()
2140 .unwrap_or_else(|e| panic!("OUTCOME_{name}: {e}"));
2141 kotlin.insert(name.trim().to_string(), value);
2142 }
2143 let rust = BTreeMap::from([
2144 ("ACTION".to_string(), wire::OUTCOME_ACTION),
2145 ("CANCELLED".to_string(), wire::OUTCOME_CANCELLED),
2146 ("DISMISSED".to_string(), wire::OUTCOME_DISMISSED),
2147 ("HOST_LOST".to_string(), wire::OUTCOME_HOST_LOST),
2148 ("NO_HOST".to_string(), wire::OUTCOME_NO_HOST),
2149 ("FAILED".to_string(), wire::OUTCOME_FAILED),
2150 ]);
2151 assert_eq!(
2152 kotlin, rust,
2153 "FrustNativePresenter.kt's OUTCOME_* constants and present::wire must be edited \
2154 together"
2155 );
2156 }
2157
2158 #[test]
2159 fn presenter_class_and_export_match_the_kotlin_declaration() {
2160 let package = kotlin_package();
2161 let object = kotlin_object_name();
2162
2163 let binary = format!("\"{package}.{object}\"");
2164 assert!(
2165 ANDROID_HOST_RS.contains(&format!("const PRESENTER_CLASS_BINARY: &str = {binary};")),
2166 "android_host.rs's PRESENTER_CLASS_BINARY must be {binary}"
2167 );
2168
2169 assert!(
2170 PRESENTER_KT.contains(
2171 "external fun nativeOnOutcome(generation: Long, code: Int, actionIndex: Int)"
2172 ),
2173 "FrustNativePresenter.kt must declare the nativeOnOutcome(Long, Int, Int) callback"
2174 );
2175 let symbol = format!(
2176 "pub extern \"system\" fn Java_{}_{object}_nativeOnOutcome",
2177 package.replace('.', "_")
2178 );
2179 assert!(
2180 ANDROID_HOST_RS.contains(&symbol),
2181 "android_host.rs must export `{symbol}` — the JVM binds `external` methods by \
2182 mangled name alone"
2183 );
2184 }
2185}