Skip to main content

frust_native_widgets/present/
mod.rs

1//! Native presentations: platform-owned modal UI (an alert, and on iOS/iPadOS
2//! a sheet) requested imperatively from Rust and resolved to exactly one
3//! terminal outcome.
4//!
5//! # Why not a platform-view slot
6//!
7//! A control is a `platform_view` slot the differ owns: it disposes a slot
8//! that goes missing from the ingest stream and knows nothing about modal
9//! stacking. A modal belongs to the host instead — the resumed Android
10//! `Activity`, the topmost iOS view controller, the macOS key window — so a
11//! presentation is a **request** ([`show_alert`], [`show_sheet`]) answered by
12//! a [`Presentation`] future, never a node in the tree.
13//!
14//! # The contract
15//!
16//! - **One in flight, process-wide — across every kind.** A second request
17//!   while one is live resolves [`PresentError::Busy`] immediately — no
18//!   queueing, no replacing. The slot is shared by every presentation kind:
19//!   an alert and a sheet can never both be live, so a [`show_sheet`] while
20//!   an alert is up (or the reverse) is refused `Busy` too.
21//! - **Exactly one terminal outcome.** Each accepted request is stamped with
22//!   a fresh, never-zero **generation**, and resolves through a one-shot
23//!   channel whose sending half is consumed by its first use: an action, a
24//!   cancellation, a programmatic [`dismiss`], or the host going away
25//!   ([`AlertOutcome::HostLost`], [`SheetOutcome::HostLost`]). A platform
26//!   callback arriving for any other generation finds no live entry and is
27//!   dropped. A sheet's detent changes are **not** outcomes: they stream
28//!   through [`SheetSpec::on_detent`] while the sheet stays live.
29//! - **The slot frees itself.** It is released when the outcome is sent
30//!   (before the caller is woken, so the caller's continuation may present
31//!   again), when an arm drops its sender without sending (resolving
32//!   [`PresentError::Platform`]), or when the caller drops the
33//!   [`Presentation`]. Dropping the future frees only this module's
34//!   bookkeeping: the platform UI stays up until the user answers it, and
35//!   that answer is discarded.
36//! - **Awaitable anywhere.** [`Presentation`] is a plain [`Future`] with no
37//!   executor dependency: resolution is driven by the platform's main thread,
38//!   so it can be polled from `frust::spawn_local` or any other executor.
39//!   Nothing ever blocks waiting for a modal result.
40//!
41//! # Platform arms
42//!
43//! `AlertHost` is the alert seam, selected by `cfg` per OS:
44//!
45//! - **iOS/iPadOS** — `apple_alert`: a `UIAlertController` (alert or action
46//!   sheet, an iPad action sheet anchored as a popover) built over
47//!   `apple_host`'s shared helpers.
48//! - **macOS** — `appkit_alert`: an `NSAlert` presented as a window sheet
49//!   (`beginSheetModalForWindow:completionHandler:`) on the key/main window,
50//!   also built over `apple_host`'s shared helpers. macOS has no action-sheet
51//!   idiom, so [`AlertStyle::ActionSheet`] presents the same sheet as
52//!   [`AlertStyle::Alert`] and [`AlertSpec::anchor`] is ignored.
53//! - **Android** — `android_alert`: an `android.app.AlertDialog`, over
54//!   `android_host`'s shared JNI plumbing (the
55//!   `dev.frust.nativewidgets.FrustNativePresenter` Kotlin object that
56//!   tracks the resumed `Activity`, its one `nativeOnOutcome` callback and
57//!   the live-presentation guard).
58//! - **Every other target** — `unsupported`: [`PresentError::Unsupported`].
59//!
60//! `SheetHost` is the sheet seam: **iOS/iPadOS** — `apple_sheet`, a
61//! plugin-owned `UIViewController` subclass presented as a page sheet under
62//! `UISheetPresentationController` (detents, grabber, adaptive dismissal);
63//! **every other target** — this module's own `UnsupportedSheet`
64//! ([`PresentError::Unsupported`]; a macOS `NSPopover` or Android
65//! `BottomSheetDialog` arm is follow-up work).
66//!
67//! `apple_host` holds what every Apple arm shares: host discovery, the
68//! main-queue hop and the live-presentation guard.
69
70mod 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
111/// The most actions one alert takes — the common ceiling of the three
112/// platforms (Android's `AlertDialog` has exactly three button slots).
113pub const MAX_ALERT_ACTIONS: usize = 3;
114
115/// What an alert asks: a title, a message, up to [`MAX_ALERT_ACTIONS`]
116/// actions, and how it is presented.
117///
118/// Validated when submitted ([`show_alert`]), before any platform API is
119/// touched: at least one action and at most [`MAX_ALERT_ACTIONS`] actions,
120/// every action id non-empty and unique (the id is what
121/// [`AlertOutcome::Action`] reports back), at most one [`ActionRole::Cancel`]
122/// action (UIKit raises on a second one), and an [`AnchorRect`] — when given
123/// — finite with a non-negative size. An [`AlertStyle::ActionSheet`] on iPad
124/// additionally requires `anchor`; that rule is the iOS arm's, checked when
125/// it presents (only it can tell an iPad from an iPhone).
126#[derive(Clone, Debug, PartialEq)]
127pub struct AlertSpec {
128    /// The alert's title.
129    pub title: String,
130    /// The alert's body text.
131    pub message: String,
132    /// The buttons, in presentation order.
133    pub actions: Vec<AlertAction>,
134    /// Whether the user may dismiss the alert without choosing an action
135    /// (back key / outside tap where the platform has one) — answered as
136    /// [`AlertOutcome::Cancelled`].
137    pub cancelable: bool,
138    /// Centered alert or action sheet.
139    pub style: AlertStyle,
140    /// Where an action sheet points from, in logical points of the presenting
141    /// window's coordinate space (origin top-left).
142    pub anchor: Option<AnchorRect>,
143}
144
145impl AlertSpec {
146    /// A cancelable [`AlertStyle::Alert`] with no actions and no anchor —
147    /// add buttons with [`Self::with_action`].
148    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    /// Append one action.
160    #[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    /// The submit-time validation described on [`AlertSpec`].
176    ///
177    /// # Errors
178    /// [`PresentError::InvalidSpec`] naming the first rule broken.
179    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/// One alert button.
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct AlertAction {
228    /// Reported back verbatim as [`AlertOutcome::Action`] when chosen.
229    pub id: String,
230    /// The button's visible label.
231    pub label: String,
232    /// How the platform styles and places it.
233    pub role: ActionRole,
234}
235
236/// An action's semantic role, mapped onto each platform's own button styles.
237#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
238pub enum ActionRole {
239    /// An ordinary action.
240    #[default]
241    Default,
242    /// The action that backs out; at most one per alert.
243    Cancel,
244    /// An action that destroys data (red where the platform has a style).
245    Destructive,
246}
247
248/// How an alert is presented.
249#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
250pub enum AlertStyle {
251    /// A centered modal alert.
252    #[default]
253    Alert,
254    /// A sheet of actions rising from the bottom (iPhone), pointing at
255    /// [`AlertSpec::anchor`] (iPad); platforms without the idiom present it
256    /// as an ordinary alert.
257    ActionSheet,
258}
259
260/// A rectangle in logical points of the presenting window's coordinate
261/// space, origin top-left — where an action sheet's popover points from.
262///
263/// # Getting one
264///
265/// frust's logical pixels **are** view points on iOS (`frust-shell-ios`'s
266/// touch/IME contract: logical points pass through with no scale division),
267/// and the default shell's frust view fills its window, so any rect frust
268/// reports in window space is already an anchor, unconverted:
269///
270/// - **A native control's slot** — the rect a `platform_view` slot publishes
271///   each paint (`PlatformViewFrame::rect`, the absolute painted rect the
272///   platform view is positioned from) is exactly where the control sits;
273///   anchor a "more actions" sheet on the button that opened it.
274/// - **A frust widget** — its window-space rect: an overlay surface's
275///   `window_rect()`, or the origin a layout you control accumulates down to
276///   the widget plus its size.
277/// - **A point** — a zero-size rect at a tap location (the popover arrow
278///   points at the point).
279///
280/// The iOS arm converts it into the presenting controller's view with
281/// `convertRect:fromView:nil` (window base coordinates), so it also holds
282/// when that controller's view is not full-window (a form sheet). A rect
283/// outside the window is passed to UIKit as-is; UIKit clamps the popover
284/// onto the screen.
285#[derive(Clone, Copy, Debug, Default, PartialEq)]
286pub struct AnchorRect {
287    /// Left edge.
288    pub x: f64,
289    /// Top edge.
290    pub y: f64,
291    /// Width, non-negative.
292    pub width: f64,
293    /// Height, non-negative.
294    pub height: f64,
295}
296
297impl AnchorRect {
298    /// Every coordinate finite and the size non-negative.
299    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/// How an alert ended — exactly one per accepted [`show_alert`].
309#[derive(Clone, Debug, PartialEq, Eq, Hash)]
310pub enum AlertOutcome {
311    /// The user chose the action with this [`AlertAction::id`].
312    Action(String),
313    /// The user dismissed a cancelable alert without choosing an action.
314    Cancelled,
315    /// [`dismiss`] took the alert down.
316    Dismissed,
317    /// The presenting host (Activity, scene, window) went away before the
318    /// alert was answered.
319    HostLost,
320}
321
322/// The most actions one sheet takes — the same ceiling as an alert's
323/// ([`MAX_ALERT_ACTIONS`]): a sheet's action rows are a short choice, not a
324/// menu.
325pub const MAX_SHEET_ACTIONS: usize = 3;
326
327/// A sheet's content — the constrained schema a native sheet renders with
328/// platform views only (decision D4): a title, a message, an optional image
329/// and up to [`MAX_SHEET_ACTIONS`] action rows, laid out top to bottom in
330/// that order. Arbitrary frust content is deliberately not accepted: frust
331/// has a single render root, and a `platform_view` slot never mounts inside
332/// a presented controller.
333#[derive(Clone, Debug, Default, PartialEq, Eq)]
334pub struct SheetContent {
335    /// A bold headline; `None` (or empty) omits the row.
336    pub title: Option<String>,
337    /// Body text, wrapped over as many lines as it needs; `None` (or empty)
338    /// omits the row.
339    pub message: Option<String>,
340    /// Encoded image bytes (PNG/JPEG/HEIC — whatever the platform decodes),
341    /// shown aspect-fit under the text. Bytes the platform cannot decode omit
342    /// the row (logged), never fail the sheet.
343    pub image: Option<Arc<[u8]>>,
344    /// The action rows, in presentation order.
345    pub actions: Vec<SheetAction>,
346}
347
348impl SheetContent {
349    /// Empty content — add rows with the `with_*` builders. Presenting it
350    /// empty is refused ([`SheetSpec::validate`]).
351    pub fn new() -> Self {
352        Self::default()
353    }
354
355    /// Set the title row.
356    #[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    /// Set the message row.
363    #[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    /// Set the image row from encoded bytes.
370    #[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    /// Append one action row.
377    #[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    /// No row at all would render: no non-empty title or message, no image
393    /// and no action.
394    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/// One sheet action row — a system button.
403#[derive(Clone, Debug, PartialEq, Eq)]
404pub struct SheetAction {
405    /// Reported back verbatim as [`SheetOutcome::Action`] when tapped.
406    pub id: String,
407    /// The button's visible label.
408    pub label: String,
409    /// How the platform styles it: `Default` wears the sheet's tint,
410    /// `Destructive` the system red, `Cancel` the secondary label colour. A
411    /// sheet keeps every row where the spec puts it (unlike an alert, which
412    /// moves its `Cancel` action).
413    pub role: ActionRole,
414}
415
416/// A height a sheet rests at.
417///
418/// **iPad in regular width ignores detents**: UIKit presents a page sheet
419/// there as a centered form sheet at a fixed size, and only an edge-attached
420/// presentation (compact width, or compact height with
421/// `prefersEdgeAttachedInCompactHeight`) honours them — see
422/// `docs/LIMITATIONS.md`'s `native-sheet-ipad-regular-width-detents`. Never
423/// assume detent parity across idioms.
424#[derive(Clone, Copy, Debug, PartialEq)]
425pub enum Detent {
426    /// About half the screen height (UIKit's `mediumDetent`).
427    Medium,
428    /// The full-height sheet (UIKit's `largeDetent`).
429    Large,
430    /// A fraction `0 < f <= 1` of the largest height the sheet can take.
431    /// Needs iOS 16 (`customDetentWithIdentifier:resolver:`), checked at
432    /// runtime: below it the arm substitutes the nearest of [`Self::Medium`]
433    /// (`f <= 0.75`) or [`Self::Large`] and logs once, and
434    /// [`SheetSpec::on_detent`] then reports that substitute.
435    Custom(f64),
436}
437
438impl Detent {
439    /// A [`Self::Custom`] fraction must be finite with `0 < f <= 1`.
440    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    /// The system detent a [`Self::Custom`] stands in as where custom
448    /// detents do not exist (iOS 15): the nearest of [`Self::Medium`]
449    /// (`f <= 0.75`, the midpoint between about-half and all) and
450    /// [`Self::Large`]. Every other detent is itself.
451    #[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/// The callback [`SheetSpec::on_detent`] registers, behind an `Arc` so the
462/// spec stays `Clone`. Compared by identity, printed opaquely.
463#[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/// What a sheet asks: its [`SheetContent`], the detents it rests at and how
479/// its sheet chrome behaves.
480///
481/// Validated when submitted ([`show_sheet`]), before any platform API is
482/// touched — see [`Self::validate`].
483#[derive(Clone, Debug, PartialEq)]
484pub struct SheetSpec {
485    /// What the sheet shows.
486    pub content: SheetContent,
487    /// The heights the sheet may rest at, smallest first by convention;
488    /// non-empty, no duplicates. Default `[Medium, Large]`.
489    pub detents: Vec<Detent>,
490    /// The detent the sheet opens at; `None` lets the platform choose (the
491    /// smallest). Must be one of [`Self::detents`].
492    pub selected: Option<Detent>,
493    /// Show the grabber bar at the top edge (default `true`).
494    pub grabber: bool,
495    /// Whether scrolling to a scroll view's edge grows the sheet to its next
496    /// detent instead of scrolling (UIKit's
497    /// `prefersScrollingExpandsWhenScrolledToEdge`; default `true`).
498    pub scrolling_expands: bool,
499    /// The largest detent at which the content behind the sheet stays
500    /// undimmed and interactive; `None` dims at every detent. Must be one of
501    /// [`Self::detents`].
502    pub largest_undimmed: Option<Detent>,
503    /// The sheet's corner radius in points; `None` keeps the system radius.
504    pub corner_radius: Option<f64>,
505    /// Whether the user may swipe the sheet away — answered as
506    /// [`SheetOutcome::Dismissed`]`(`[`DismissReason::User`]`)`. `false`
507    /// leaves only an action or a programmatic dismissal (default `true`).
508    pub dismissible: bool,
509    /// A packed ARGB tint the `Default`-role action rows (and any other
510    /// tint-following chrome) wear; `None` keeps the system tint. With the
511    /// `frust-api` feature, `SheetSpec::with_theme` fills it (and
512    /// [`Self::dark`]) from the active theme.
513    pub tint: Option<u32>,
514    /// Pin the sheet's light/dark appearance; `None` follows the system.
515    pub dark: Option<bool>,
516    on_detent: Option<DetentListener>,
517}
518
519impl SheetSpec {
520    /// A dismissible `[Medium, Large]` sheet with a grabber, showing
521    /// `content`.
522    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    /// Replace the detents.
539    #[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    /// Open at `detent` (one of [`Self::detents`]).
546    #[must_use]
547    pub fn with_selected(mut self, detent: Detent) -> Self {
548        self.selected = Some(detent);
549        self
550    }
551
552    /// Set [`Self::dismissible`].
553    #[must_use]
554    pub fn with_dismissible(mut self, dismissible: bool) -> Self {
555        self.dismissible = dismissible;
556        self
557    }
558
559    /// Stream the user's detent changes into `listener` — intermediate
560    /// events while the sheet stays live, never terminal outcomes. Called on
561    /// the platform's main thread (frust's UI thread), once per change the
562    /// **user** makes (a drag, a grabber tap); a programmatic
563    /// [`SheetHandle::select_detent`] is not echoed back, following UIKit.
564    #[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    /// The listener [`Self::on_detent`] registered, if any.
571    #[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    /// The submit-time validation: the content is not empty (a non-empty
579    /// title or message, an image or an action) and any image is non-empty
580    /// bytes; at most [`MAX_SHEET_ACTIONS`] actions, every action id
581    /// non-empty and unique; at least one detent, no duplicates, every
582    /// [`Detent::Custom`] fraction finite with `0 < f <= 1`; `selected` and
583    /// `largest_undimmed`, when given, among `detents`; a `corner_radius`,
584    /// when given, finite and non-negative.
585    ///
586    /// # Errors
587    /// [`PresentError::InvalidSpec`] naming the first rule broken.
588    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/// Why a sheet went away without an action.
647#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
648pub enum DismissReason {
649    /// The user swiped it down (only a [`SheetSpec::dismissible`] sheet).
650    User,
651    /// [`SheetHandle::dismiss`] (or [`dismiss`]) took it down.
652    Programmatic,
653}
654
655/// How a sheet ended — exactly one per accepted [`show_sheet`].
656#[derive(Clone, Debug, PartialEq, Eq, Hash)]
657pub enum SheetOutcome {
658    /// The user tapped the action row with this [`SheetAction::id`]; the
659    /// sheet has already finished dismissing itself.
660    Action(String),
661    /// The sheet went away without an action.
662    Dismissed(DismissReason),
663    /// The presenting host went away before the sheet was answered.
664    HostLost,
665}
666
667/// Names one accepted sheet: take it down or move it between detents. Stale
668/// once that sheet has resolved — both calls then do nothing.
669#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
670pub struct SheetHandle {
671    generation: u64,
672}
673
674impl SheetHandle {
675    /// The generic [`PresentationHandle`] for the same sheet ([`dismiss`]
676    /// takes it too).
677    pub fn presentation(&self) -> PresentationHandle {
678        PresentationHandle {
679            generation: self.generation,
680        }
681    }
682
683    /// Animate the sheet to `detent` — ignored (logged) when `detent` is not
684    /// one of its [`SheetSpec::detents`] or is an invalid
685    /// [`Detent::Custom`]. Not echoed to [`SheetSpec::on_detent`].
686    pub fn select_detent(&self, detent: Detent) {
687        select_detent_on::<SheetPlatformHost>(self, detent);
688    }
689
690    /// Take the sheet down; it resolves
691    /// [`SheetOutcome::Dismissed`]`(`[`DismissReason::Programmatic`]`)`
692    /// exactly once.
693    pub fn dismiss(&self) {
694        dismiss_sheet_on::<SheetPlatformHost>(self);
695    }
696}
697
698impl Presentation<SheetOutcome> {
699    /// The [`SheetHandle`] for this sheet — `None` when the request was
700    /// refused before anything was presented (like [`Self::handle`]).
701    pub fn sheet_handle(&self) -> Option<SheetHandle> {
702        self.handle.map(|handle| SheetHandle {
703            generation: handle.generation,
704        })
705    }
706}
707
708/// Why a presentation could not be shown or did not complete.
709///
710/// `thiserror`-derived per `docs/CODE_STANDARDS.md`: callers match on the
711/// variant.
712#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
713#[non_exhaustive]
714pub enum PresentError {
715    /// Another presentation is live — one at a time, process-wide. Recover
716    /// by resolving it: take it down through its [`PresentationHandle`] with
717    /// [`dismiss`], or drop its [`Presentation`] future — either frees the
718    /// slot. A refusal logs the live presentation's generation and how long
719    /// it has been held (where the target has a monotonic clock); see
720    /// `docs/LIMITATIONS.md`'s `native-widgets-alert-busy-slot-unobserved-host-teardown`
721    /// for the case where a live presentation outlives an unobserved host teardown.
722    #[error("native presentation: another presentation is live")]
723    Busy,
724    /// No host to present over: no resumed Android `Activity`, no iOS
725    /// window scene with a root view controller, no macOS key/main window.
726    #[error("native presentation: no host to present over")]
727    NoHost,
728    /// This platform has no native presentation of this kind.
729    #[error("native presentation: unsupported on this platform")]
730    Unsupported,
731    /// The request broke a submit-time rule (see [`AlertSpec`] /
732    /// [`SheetSpec::validate`]).
733    #[error("native presentation: invalid spec: {0}")]
734    InvalidSpec(String),
735    /// A platform failure not covered above (a JNI/Objective-C error, an arm
736    /// that ended without an outcome).
737    #[error("native presentation: platform error: {0}")]
738    Platform(String),
739}
740
741/// Names one accepted presentation, for [`dismiss`]. Stale once that
742/// presentation has resolved: dismissing it then does nothing.
743#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
744pub struct PresentationHandle {
745    generation: u64,
746}
747
748/// The future a presentation request answers with — see the module doc's
749/// *The contract*.
750///
751/// Resolves once; every further poll answers [`Poll::Pending`] rather than
752/// panicking (a completion can be driven from a platform thread the polling
753/// executor knows nothing about, and a spurious re-poll must not take a
754/// process down). Dropping it releases the process-wide slot.
755#[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
761/// Either an already-decided result (validation failure, `Busy`, a host's
762/// synchronous refusal) or the live channel an arm resolves later — one
763/// concrete type so both paths are the same future.
764enum 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    /// The handle [`dismiss`] takes — `None` when the request was refused
785    /// before anything was presented (invalid spec, `Busy`, a synchronous
786    /// host refusal).
787    pub fn handle(&self) -> Option<PresentationHandle> {
788        self.handle
789    }
790
791    /// Take the error a request refused before anything was presented
792    /// (no [`Self::handle`]) resolves with, without polling — what an adapter
793    /// that must answer synchronously (the `api` layer's signal adapter)
794    /// reports. `None` for an accepted request, or once taken; a taken
795    /// refusal is not delivered again by polling.
796    #[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
821// `Presentation` never pin-projects: the ready value is moved out by
822// `Option::take` and the receiver is itself `Unpin` (an `Arc`).
823impl<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            // A receiver whose value was already taken registers the waker
835            // and answers `Pending`, exactly like one still waiting.
836            PresentationState::Pending(receiver) => Pin::new(receiver).poll(cx),
837        }
838    }
839}
840
841/// The platform seam an alert arm implements (selected by `cfg`, see the
842/// module doc's *Platform arms*).
843pub(crate) trait AlertHost {
844    /// Present `spec` for presentation `generation`, resolving `tx` exactly
845    /// once — later, from the platform's own callback — or return an error
846    /// synchronously without having sent anything. Never blocks for the
847    /// user's answer. `spec` has already passed [`AlertSpec::validate`].
848    fn show_alert(
849        spec: AlertSpec,
850        tx: Sender<AlertOutcome>,
851        generation: u64,
852    ) -> Result<(), PresentError>;
853
854    /// Take presentation `generation` down programmatically, resolving it
855    /// [`AlertOutcome::Dismissed`]. Must ignore a generation it is not
856    /// presenting: the live check [`dismiss`] makes first can race a
857    /// platform callback resolving that very presentation.
858    fn dismiss(generation: u64);
859}
860
861/// A live presentation: its generation and when its slot was claimed — the
862/// claim time backs a Busy refusal's diagnostic ([`submit`]).
863struct ActiveSlot {
864    generation: u64,
865    claimed_at: Option<Instant>,
866}
867
868/// The live presentation, if any — the process-wide Busy slot.
869static ACTIVE: Mutex<Option<ActiveSlot>> = Mutex::new(None);
870
871/// The counter behind [`next_generation`]. Starts at `1` so a generation is
872/// never `0` — a zero-initialized `jlong`/`u64` arriving from a host that
873/// lost track of its own presentation must not look like a valid one.
874static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
875
876/// A fresh, process-wide generation. Never `0`, including across the
877/// (theoretical) wrap of [`NEXT_GENERATION`].
878fn 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/// Capture the current monotonic clock, or `None` on targets where no such
888/// clock exists (wasm32-unknown-unknown). On native platforms, `Some(Instant::now())`;
889/// on wasm32, `None` without touching [`Instant`] to avoid a platform-panic
890/// where the clock is unavailable. The shim exists to let `show_alert` on web
891/// resolve `Err(PresentError::Unsupported)` instead of panicking, per the
892/// fail-soft contract in `frust-native-widgets` lib.rs.
893#[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
905/// Format the Busy refusal message logged when a second presentation is
906/// requested while one is live. Formats the live presentation's generation
907/// and the age of its slot if available (some platforms have no monotonic clock).
908/// Used by [`submit`] and asserted by tests; the single source of the logged text.
909fn 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
925/// Lock [`ACTIVE`], recovering from poisoning instead of panicking — a panic
926/// on either side (a polling caller, a platform callback) must not turn the
927/// other side's next call into a panic too. The guarded data is a plain
928/// `Option<ActiveSlot>`, coherent after any panic.
929fn lock_active() -> MutexGuard<'static, Option<ActiveSlot>> {
930    ACTIVE
931        .lock()
932        .unwrap_or_else(|poisoned| poisoned.into_inner())
933}
934
935/// Release the Busy slot if it still holds `generation` — the hook both
936/// channel halves call (`oneshot`'s module doc). A no-op when a newer
937/// presentation holds the slot or none does.
938pub(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/// The live presentation's generation and how long its slot has been held —
949/// the same claim a Busy refusal logs ([`submit`]), read directly by tests.
950/// `None` when the slot is free. The age is `None` on targets without a
951/// monotonic clock (wasm32-unknown-unknown). Test-only: production code reads
952/// the claim under the lock it already holds ([`submit`]), never through this
953/// second lock acquisition.
954#[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
964/// Claim the Busy slot for a fresh generation, then hand the sending half to
965/// `start`. The lock is released before `start` runs: a host that resolves
966/// synchronously releases the slot through the sender, which must not
967/// deadlock on a lock this function still holds.
968fn 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            // The host refused before presenting anything. Dropping `rx`
990            // releases the slot (a no-op if the host's dropped sender
991            // already did); the refusal itself is the answer.
992            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    // Checked, then released, before calling into the host: the host may
1007    // resolve synchronously, which re-enters `release_if_live`.
1008    if is_live(handle.generation) {
1009        H::dismiss(handle.generation);
1010    }
1011}
1012
1013/// Present a native alert — see the module doc's *The contract*.
1014///
1015/// The returned [`Presentation`] resolves [`AlertOutcome`] once the user (or
1016/// [`dismiss`], or the host going away) ends the alert, or
1017/// [`PresentError`]: `InvalidSpec` / `Busy` / a synchronous host refusal
1018/// (`NoHost`, `Unsupported`, `Platform`) on its first poll, or a later
1019/// `NoHost`/`Platform` from an arm that discovered it only on the platform's
1020/// main thread.
1021pub fn show_alert(spec: AlertSpec) -> Presentation<AlertOutcome> {
1022    show_alert_on::<PlatformHost>(spec)
1023}
1024
1025/// Take the presentation `handle` names down; it resolves
1026/// [`AlertOutcome::Dismissed`] — or, for a sheet,
1027/// [`SheetOutcome::Dismissed`]`(`[`DismissReason::Programmatic`]`)` —
1028/// exactly once. A stale handle — its presentation already resolved, or
1029/// superseded — is ignored.
1030pub fn dismiss(handle: &PresentationHandle) {
1031    dismiss_on::<PlatformHost>(handle);
1032}
1033
1034/// The platform seam a sheet arm implements (selected by `cfg`, see the
1035/// module doc's *Platform arms*). Shares the Busy slot, the generation guard
1036/// and the `oneshot` channel with [`AlertHost`] — only the platform half
1037/// differs.
1038pub(crate) trait SheetHost {
1039    /// Present `spec` for presentation `generation`, resolving `tx` exactly
1040    /// once — later, from the platform's own callback — or return an error
1041    /// synchronously without having sent anything. Never blocks for the
1042    /// user's answer. `spec` has already passed [`SheetSpec::validate`].
1043    fn show_sheet(
1044        spec: SheetSpec,
1045        tx: Sender<SheetOutcome>,
1046        generation: u64,
1047    ) -> Result<(), PresentError>;
1048
1049    /// Take presentation `generation` down programmatically, resolving it
1050    /// [`SheetOutcome::Dismissed`]`(`[`DismissReason::Programmatic`]`)`. Must
1051    /// ignore a generation it is not presenting (the same race
1052    /// [`AlertHost::dismiss`] names).
1053    fn dismiss(generation: u64);
1054
1055    /// Move presentation `generation` to `detent` (already validated as a
1056    /// [`Detent`]; membership in the spec's detents is the arm's check).
1057    /// Must ignore a generation it is not presenting.
1058    fn select_detent(generation: u64, detent: Detent);
1059}
1060
1061/// The sheet arm of every target without a native sheet (everything but
1062/// iOS/iPadOS today — macOS, Android, desktop Linux/Windows, web): every
1063/// request resolves [`PresentError::Unsupported`] on its first poll, and
1064/// nothing is ever live to dismiss or move.
1065#[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
1089/// Whether `generation` holds the Busy slot — the stale-handle filter
1090/// [`dismiss_on`] applies, shared by the sheet handle's calls. Released
1091/// before the caller reaches the host, which may resolve synchronously.
1092fn 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
1117/// Present a native sheet — see the module doc's *The contract*.
1118///
1119/// The returned [`Presentation`] resolves [`SheetOutcome`] once the user
1120/// (an action row, a swipe-down), [`SheetHandle::dismiss`] or the host going
1121/// away ends the sheet, or [`PresentError`]: `InvalidSpec` / `Busy` (another
1122/// presentation of any kind is live) / `Unsupported` (every platform but
1123/// iOS/iPadOS) on its first poll, or a later `NoHost`/`Platform` from the
1124/// arm. [`Presentation::sheet_handle`] names it for
1125/// [`SheetHandle::select_detent`] and [`SheetHandle::dismiss`].
1126///
1127/// On iPad in regular width the system shows a centered form sheet and
1128/// ignores the detents (see [`Detent`]).
1129pub fn show_sheet(spec: SheetSpec) -> Presentation<SheetOutcome> {
1130    show_sheet_on::<SheetPlatformHost>(spec)
1131}
1132
1133/// The integer outcome codes the Android presenter reports through its one
1134/// `nativeOnOutcome(generation, code, actionIndex)` callback — mirrored
1135/// verbatim by `FrustNativePresenter.kt`'s `OUTCOME_*` constants and pinned
1136/// against drift by this module's tests. Consumed by the Android alert arm
1137/// (`android_alert`/`android_host`); on every other target this module's own
1138/// tests are the only reader.
1139#[cfg_attr(not(any(test, target_os = "android")), allow(dead_code))]
1140pub(crate) mod wire {
1141    use super::{AlertOutcome, PresentError};
1142
1143    /// The user chose the action at `actionIndex` (spec order).
1144    pub(crate) const OUTCOME_ACTION: i32 = 0;
1145    /// The user cancelled (back key, outside tap).
1146    pub(crate) const OUTCOME_CANCELLED: i32 = 1;
1147    /// Programmatic dismissal.
1148    pub(crate) const OUTCOME_DISMISSED: i32 = 2;
1149    /// The hosting Activity was destroyed first.
1150    pub(crate) const OUTCOME_HOST_LOST: i32 = 3;
1151    /// No resumed Activity when the show actually ran.
1152    pub(crate) const OUTCOME_NO_HOST: i32 = 4;
1153    /// The show threw.
1154    pub(crate) const OUTCOME_FAILED: i32 = 5;
1155
1156    /// Map one `nativeOnOutcome` report onto an alert's result, reading the
1157    /// chosen action's id out of `action_ids` (the spec's ids, in order).
1158    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/// Test seams shared with other modules' host tests (the `api` layer's
1189/// adapter tests): a presentation whose sender the test holds, and the
1190/// process-wide Busy slot held for the duration of a closure. Every test in
1191/// this crate that touches [`ACTIVE`] serializes on
1192/// [`serialize`](test_support::serialize).
1193#[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    /// Serializes every test that touches [`super::ACTIVE`] — one
1202    /// process-global slot, and `#[test]`s run on parallel threads.
1203    static TEST_LOCK: Mutex<()> = Mutex::new(());
1204
1205    /// Hold [`TEST_LOCK`], recovering from a test that panicked holding it.
1206    pub(crate) fn serialize() -> MutexGuard<'static, ()> {
1207        TEST_LOCK
1208            .lock()
1209            .unwrap_or_else(|poisoned| poisoned.into_inner())
1210    }
1211
1212    /// An accepted-looking presentation and the sender that resolves it,
1213    /// under a generation nothing is ever live under — so neither half
1214    /// touches the Busy slot.
1215    #[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    /// Run `f` with the Busy slot held by some other presentation, freeing
1222    /// it afterwards; serialized on [`TEST_LOCK`].
1223    #[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    /// A scripted stand-in host: what `show_alert` should do, the one live
1247    /// sender it holds, and a record of every call.
1248    #[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            // Resolved outside the borrow, like a real arm resolving outside
1289            // its own lock.
1290            if let Some((_, tx)) = taken {
1291                tx.send(Ok(AlertOutcome::Dismissed));
1292            }
1293        }
1294    }
1295
1296    /// The platform delivering `outcome` for whatever is live on the fake —
1297    /// `false` when nothing is (a duplicate or stale callback).
1298    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    /// Run `f` holding `test_support`'s lock, with [`ACTIVE`] and the fake
1309    /// reset before and after.
1310    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            // The sender was consumed by that first delivery: a duplicate
1352            // platform callback has nothing left to resolve.
1353            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            // The refused request did not disturb the live one.
1378            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            // Just claimed, well under any real hang (age is available on native targets).
1399            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        // Test with age (Some case).
1410        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        // Test without age (None case).
1424        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            // `first` is still held (not even polled): the send alone freed
1442            // the slot, so a continuation may present again at once.
1443            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            // Now stale: filtered before it reaches the host at all.
1463            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            // Even a host asked directly with the old generation (the race
1489            // the trait doc names) leaves the live presentation alone.
1490            FakeHost::dismiss(old_handle.generation);
1491            assert_eq!(poll_once(&mut current), Poll::Pending);
1492
1493            dismiss_on::<FakeHost>(&current_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            // A dismiss arriving after the host is gone is stale.
1515            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            // The abandoned alert is still up on the platform; its eventual
1528            // answer arrives after a newer presentation took the slot.
1529            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            // One action so this fixture pins the anchor rule specifically,
1588            // not the (now earlier-checked) zero-action rule above.
1589            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    // --- Sheets ----------------------------------------------------------
1723
1724    /// The sheet counterpart of [`FakeState`]: one live sender, and a record
1725    /// of every show / dismiss / detent move that reached the host.
1726    #[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    /// The platform delivering `outcome` for whatever sheet is live — `false`
1774    /// when none is.
1775    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    /// [`isolated`] plus the sheet fake's reset.
1786    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        // Any single row is content; a full-height custom detent is in range.
1817        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        // Empty strings are no content either.
1834        assert_invalid_sheet(&SheetSpec::new(
1835            SheetContent::new().with_title("").with_message(""),
1836        ));
1837        // An image row needs bytes.
1838        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            // Nothing is ever live on it, so its dismiss/move are no-ops.
1939            UnsupportedSheet::dismiss(u64::MAX);
1940            UnsupportedSheet::select_detent(u64::MAX, Detent::Large);
1941        });
1942    }
1943
1944    /// The public entry point on this (non-iOS) host resolves through the
1945    /// unsupported arm.
1946    #[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            // A late swipe-down callback has nothing left to resolve.
1969            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            // Stale: filtered before the host, for both calls.
2007            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            // A detent move is not an outcome: the sheet is still live.
2031            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            // Resolving the sheet frees the slot for the next kind at once.
2065            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    // --- Kotlin <-> Rust drift checks for the presenter ------------------
2095    //
2096    // `FrustNativePresenter.kt` and `android_host.rs` are one contract: the
2097    // Kotlin package + class are baked into the Rust export's mangled symbol
2098    // and into the binary class name Rust loads, and the outcome codes are
2099    // shared integers. Neither side can be compiled against the other on a
2100    // host, so these read both sources as text.
2101
2102    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}