Skip to main content

frust_native_widgets/api/
present.rs

1//! The app-facing front door to native presentations (`crate::present`):
2//! [`show_native_alert`] / [`show_native_sheet`], the awaitable forms, and
3//! [`show_native_alert_into`] / [`show_native_sheet_into`], the
4//! events-as-signals forms (`super::signals`' idiom) that need no `async`
5//! block at all.
6//!
7//! # The contract both forms keep
8//!
9//! - **Exactly one outcome.** An accepted alert resolves one
10//!   [`AlertOutcome`] — the chosen action's id, `Cancelled`, `Dismissed`
11//!   (through [`crate::present::dismiss`]) or `HostLost` — and an accepted
12//!   sheet one [`SheetOutcome`] — the tapped row's id, `Dismissed(User)`
13//!   (swiped away), `Dismissed(Programmatic)` (through
14//!   [`SheetHandle::dismiss`]) or `HostLost`; never a second: every platform
15//!   callback after the first is dropped by the presentation's generation
16//!   guard. A sheet's detent changes are intermediate events
17//!   ([`SheetSpec::on_detent`]), not outcomes.
18//! - **One at a time, across kinds.** A request while another presentation
19//!   of any kind is live is refused [`PresentError::Busy`] at once — never
20//!   queued, never replacing the live one; an alert and a sheet share the
21//!   one slot.
22//! - **Nothing blocks.** Both forms return immediately; the outcome arrives
23//!   when the user answers, driven by the platform's main thread.
24//!
25//! # Controlled, like every other native control
26//!
27//! [`show_native_alert_into`] *reports* the outcome by writing
28//! `Some(outcome)` into the app's signal once, and never touches it again —
29//! it never clears it, and never writes a second time. The app owns the
30//! signal: it reads the outcome on its next rebuild, acts on it, and resets
31//! it to `None` itself when it is ready to ask again (the controlled
32//! convention of `docs/CODE_STANDARDS.md`'s Interaction Semantics and every
33//! builder's `on_...` callback). A write wakes exactly one frust frame, like
34//! a control's callback does.
35
36use std::future::Future;
37use std::pin::Pin;
38use std::task::{Context, Poll, Waker};
39
40use frust::{RwSignal, Set, Theme};
41
42use super::theme::{argb_u32, is_dark};
43use crate::present::{
44    self, AlertOutcome, AlertSpec, PresentError, Presentation, PresentationHandle, SheetHandle,
45    SheetOutcome, SheetSpec,
46};
47
48/// Present a native alert and await its one outcome.
49///
50/// Resolves `Ok(outcome)` once the user (or [`crate::present::dismiss`], or
51/// the host going away) ends the alert, or `Err`: an invalid spec, `Busy`,
52/// no host to present over, or `Unsupported` on a platform without a native
53/// alert arm. The returned [`Presentation`] is a plain [`Future`] — await it
54/// from `frust::spawn_local` or any executor — and its
55/// [`Presentation::handle`] is what [`crate::present::dismiss`] takes.
56/// Dropping it frees the one-at-a-time slot but leaves the platform alert on
57/// screen until answered (that answer is then discarded).
58///
59/// ```ignore
60/// frust::spawn_local(async move {
61///     let spec = AlertSpec::new("Delete draft?", "This cannot be undone.")
62///         .with_action("keep", "Keep", ActionRole::Cancel)
63///         .with_action("delete", "Delete", ActionRole::Destructive);
64///     if let Ok(AlertOutcome::Action(id)) = show_native_alert(spec).await
65///         && id == "delete"
66///     {
67///         drafts.update(|d| d.clear());
68///     }
69/// });
70/// ```
71pub fn show_native_alert(spec: AlertSpec) -> Presentation<AlertOutcome> {
72    present::show_alert(spec)
73}
74
75/// Present a native alert and write its one outcome into `signal` as
76/// `Some(outcome)` — the no-`async` form (module doc's *Controlled*).
77///
78/// Returns the [`PresentationHandle`] [`crate::present::dismiss`] takes, or
79/// the error of a request refused **before** anything was presented —
80/// `InvalidSpec`, `Busy`, or a platform refusal decided on the spot — in
81/// which case `signal` is never written. An error discovered only later, on
82/// the platform's main thread (no host to present over, an iPad action sheet
83/// without an anchor requested off the main thread, a platform failure),
84/// cannot be written into an outcome signal: it is logged at `warn` and the
85/// signal is left as it was. Await [`show_native_alert`] instead when the
86/// app must tell those apart.
87///
88/// Must be called on the UI thread: the outcome is awaited on
89/// `frust::spawn_local`'s UI-thread task queue, pumped every frame.
90///
91/// # Errors
92/// The synchronous refusals listed above.
93///
94/// ```ignore
95/// // In a handler — no async block needed:
96/// let outcome: RwSignal<Option<AlertOutcome>> = RwSignal::new(None);
97/// native_button("Delete").on_press(move || {
98///     let spec = AlertSpec::new("Delete draft?", "")
99///         .with_action("keep", "Keep", ActionRole::Cancel)
100///         .with_action("delete", "Delete", ActionRole::Destructive);
101///     if let Err(err) = show_native_alert_into(spec, outcome) {
102///         log::warn!("no alert: {err}");
103///     }
104/// })
105/// // …and on a later rebuild, `outcome.get()` is `Some(AlertOutcome::Action(..))`.
106/// ```
107pub fn show_native_alert_into(
108    spec: AlertSpec,
109    signal: RwSignal<Option<AlertOutcome>>,
110) -> Result<PresentationHandle, PresentError> {
111    deliver_into(
112        show_native_alert(spec),
113        move |outcome| signal.set(Some(outcome)),
114        frust::spawn_local,
115    )
116}
117
118/// Present a native sheet and await its one outcome.
119///
120/// Resolves `Ok(outcome)` once the user (an action row, a swipe-down on a
121/// dismissible sheet), [`SheetHandle::dismiss`] or the host going away ends
122/// the sheet, or `Err`: an invalid spec, `Busy` (any other presentation is
123/// live), no host to present over, or `Unsupported` — every platform but
124/// iOS/iPadOS today (macOS and Android sheet arms are follow-up work). Take
125/// [`Presentation::sheet_handle`] before awaiting to move or dismiss it.
126///
127/// **iPad in regular width ignores detents**: the system presents a centered
128/// form sheet there; only an edge-attached sheet (iPhone, iPad in compact
129/// width) rests at [`SheetSpec::detents`]. Never rely on detent parity.
130///
131/// ```ignore
132/// let spec = SheetSpec::new(
133///     SheetContent::new()
134///         .with_title("Share draft")
135///         .with_message("Pick where it goes.")
136///         .with_action("copy", "Copy link", ActionRole::Default),
137/// )
138/// .with_theme(&theme)
139/// .on_detent(move |detent| expanded.set(detent == Detent::Large));
140/// let presentation = show_native_sheet(spec);
141/// let handle = presentation.sheet_handle();
142/// frust::spawn_local(async move {
143///     if let Ok(SheetOutcome::Action(id)) = presentation.await {
144///         last_action.set(Some(id));
145///     }
146/// });
147/// ```
148pub fn show_native_sheet(spec: SheetSpec) -> Presentation<SheetOutcome> {
149    present::show_sheet(spec)
150}
151
152/// Present a native sheet and write its one outcome into `signal` as
153/// `Some(outcome)` — the no-`async` form, with exactly
154/// [`show_native_alert_into`]'s contract (module doc's *Controlled*):
155/// synchronous refusals come back as `Err` and never touch `signal`; a later
156/// platform error is logged, not written.
157///
158/// Returns the [`SheetHandle`] that moves ([`SheetHandle::select_detent`])
159/// or dismisses the sheet. Must be called on the UI thread.
160///
161/// # Errors
162/// `InvalidSpec`, `Busy`, or a platform refusal decided on the spot
163/// (`Unsupported` off iOS).
164pub fn show_native_sheet_into(
165    spec: SheetSpec,
166    signal: RwSignal<Option<SheetOutcome>>,
167) -> Result<SheetHandle, PresentError> {
168    let presentation = show_native_sheet(spec);
169    let sheet_handle = presentation.sheet_handle();
170    deliver_into(
171        presentation,
172        move |outcome| signal.set(Some(outcome)),
173        frust::spawn_local,
174    )?;
175    sheet_handle.ok_or_else(|| {
176        PresentError::Platform("an accepted native sheet carried no handle".to_string())
177    })
178}
179
180impl SheetSpec {
181    /// Fold the active theme into the sheet — the theme ladder's
182    /// representative subset, as for every control: [`SheetSpec::tint`]
183    /// from `scheme().primary` (the accent ink the `Default`-role rows wear)
184    /// and [`SheetSpec::dark`] from the theme's brightness (L1).
185    #[must_use]
186    pub fn with_theme(mut self, theme: &Theme) -> Self {
187        self.tint = Some(argb_u32(theme.scheme().primary));
188        self.dark = Some(is_dark(theme));
189        self
190    }
191}
192
193/// A spawned future awaiting one presentation.
194type Delivery = Pin<Box<dyn Future<Output = ()>>>;
195
196/// The `_into` forms' body, over an injected `write` and `spawn` so the host
197/// tests can drive it without a frust runtime: a refused request answers
198/// `Err` synchronously and spawns nothing; an accepted one spawns a task that
199/// hands the outcome to `write` — an `FnOnce`, so once by construction.
200fn deliver_into<T: 'static>(
201    mut presentation: Presentation<T>,
202    write: impl FnOnce(T) + 'static,
203    spawn: impl FnOnce(Delivery),
204) -> Result<PresentationHandle, PresentError> {
205    let Some(handle) = presentation.handle() else {
206        return Err(refusal(&mut presentation));
207    };
208    spawn(Box::pin(async move {
209        match presentation.await {
210            Ok(outcome) => write(outcome),
211            Err(err) => log::warn!(
212                "frust-native-widgets: a native presentation ended without an outcome to \
213                 report: {err}"
214            ),
215        }
216    }));
217    Ok(handle)
218}
219
220/// The error a refused request (no handle) carries. `take_refusal` is the
221/// normal path; the poll is a fallback that cannot suspend (a refused
222/// presentation is already resolved), and a refused presentation holding no
223/// error would be a `present` contract break, reported as a platform error.
224fn refusal<T>(presentation: &mut Presentation<T>) -> PresentError {
225    if let Some(err) = presentation.take_refusal() {
226        return err;
227    }
228    match Pin::new(presentation).poll(&mut Context::from_waker(Waker::noop())) {
229        Poll::Ready(Err(err)) => err,
230        Poll::Ready(Ok(_)) | Poll::Pending => {
231            PresentError::Platform("a refused native presentation carried no error".to_string())
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::cell::RefCell;
239    use std::rc::Rc;
240
241    use frust::GetUntracked;
242
243    use super::*;
244    use crate::present::test_support::{pending_pair, with_slot_held};
245    use crate::present::{ActionRole, AlertStyle, DismissReason, SheetContent};
246
247    fn spec() -> AlertSpec {
248        AlertSpec::new("Delete draft?", "This cannot be undone.")
249            .with_action("keep", "Keep", ActionRole::Cancel)
250            .with_action("delete", "Delete", ActionRole::Destructive)
251    }
252
253    /// Captures what `deliver_into` spawns, so the test drives it.
254    fn capture() -> (Rc<RefCell<Vec<Delivery>>>, impl FnOnce(Delivery)) {
255        let spawned = Rc::new(RefCell::new(Vec::new()));
256        let sink = Rc::clone(&spawned);
257        (spawned, move |task| sink.borrow_mut().push(task))
258    }
259
260    fn poll(task: &mut Delivery) -> Poll<()> {
261        task.as_mut().poll(&mut Context::from_waker(Waker::noop()))
262    }
263
264    #[test]
265    fn the_adapter_writes_the_signal_exactly_once() {
266        let signal: RwSignal<Option<AlertOutcome>> = RwSignal::new(None);
267        let writes = Rc::new(RefCell::new(0));
268        let counter = Rc::clone(&writes);
269        let (tx, presentation) = pending_pair::<AlertOutcome>();
270        let (spawned, spawn) = capture();
271
272        let handle = deliver_into(
273            presentation,
274            move |outcome| {
275                *counter.borrow_mut() += 1;
276                signal.set(Some(outcome));
277            },
278            spawn,
279        );
280        assert!(handle.is_ok(), "an accepted request answers its handle");
281        let mut task = spawned.borrow_mut().pop().expect("one task spawned");
282        assert!(spawned.borrow().is_empty());
283
284        // Pending until the platform answers: nothing written yet.
285        assert_eq!(poll(&mut task), Poll::Pending);
286        assert_eq!(signal.get_untracked(), None);
287
288        assert!(tx.send(Ok(AlertOutcome::Action("delete".into()))));
289        assert_eq!(poll(&mut task), Poll::Ready(()));
290        assert_eq!(
291            signal.get_untracked(),
292            Some(AlertOutcome::Action("delete".into()))
293        );
294
295        // The task is finished (an executor drops it now), `write` was an
296        // `FnOnce`, and the sender was consumed by its one send — no path
297        // is left that could write again.
298        assert_eq!(*writes.borrow(), 1);
299    }
300
301    #[test]
302    fn a_late_error_is_logged_and_never_written() {
303        let signal: RwSignal<Option<AlertOutcome>> = RwSignal::new(None);
304        let (tx, presentation) = pending_pair::<AlertOutcome>();
305        let (spawned, spawn) = capture();
306
307        deliver_into(presentation, move |o| signal.set(Some(o)), spawn).expect("accepted");
308        let mut task = spawned.borrow_mut().pop().expect("one task spawned");
309        assert!(tx.send(Err(PresentError::NoHost)));
310        assert_eq!(poll(&mut task), Poll::Ready(()));
311        assert_eq!(signal.get_untracked(), None);
312    }
313
314    #[test]
315    fn busy_surfaces_as_err_from_both_forms() {
316        with_slot_held(|| {
317            let mut presentation = show_native_alert(spec());
318            assert_eq!(presentation.handle(), None);
319            assert_eq!(
320                Pin::new(&mut presentation).poll(&mut Context::from_waker(Waker::noop())),
321                Poll::Ready(Err(PresentError::Busy))
322            );
323
324            // The signal form answers synchronously and spawns nothing (the
325            // real `frust::spawn_local` would need a UI-thread executor).
326            let signal: RwSignal<Option<AlertOutcome>> = RwSignal::new(None);
327            assert_eq!(
328                show_native_alert_into(spec(), signal),
329                Err(PresentError::Busy)
330            );
331            assert_eq!(signal.get_untracked(), None);
332        });
333    }
334
335    #[test]
336    fn an_invalid_spec_surfaces_as_err_and_spawns_nothing() {
337        let mut too_many = spec()
338            .with_action("a", "A", ActionRole::Default)
339            .with_action("b", "B", ActionRole::Default);
340        too_many.style = AlertStyle::ActionSheet;
341        let (spawned, spawn) = capture();
342        let written = Rc::new(RefCell::new(false));
343        let flag = Rc::clone(&written);
344
345        let result = deliver_into(
346            show_native_alert(too_many),
347            move |_| *flag.borrow_mut() = true,
348            spawn,
349        );
350        assert!(matches!(result, Err(PresentError::InvalidSpec(_))));
351        assert!(spawned.borrow().is_empty());
352        assert!(!*written.borrow());
353    }
354
355    fn sheet() -> SheetSpec {
356        SheetSpec::new(SheetContent::new().with_title("Share draft").with_action(
357            "copy",
358            "Copy link",
359            ActionRole::Default,
360        ))
361    }
362
363    #[test]
364    fn the_adapter_writes_a_sheet_outcome_exactly_once() {
365        let signal: RwSignal<Option<SheetOutcome>> = RwSignal::new(None);
366        let (tx, presentation) = pending_pair::<SheetOutcome>();
367        let (spawned, spawn) = capture();
368
369        deliver_into(presentation, move |o| signal.set(Some(o)), spawn).expect("accepted");
370        let mut task = spawned.borrow_mut().pop().expect("one task spawned");
371        assert_eq!(poll(&mut task), Poll::Pending);
372        assert!(tx.send(Ok(SheetOutcome::Dismissed(DismissReason::User))));
373        assert_eq!(poll(&mut task), Poll::Ready(()));
374        assert_eq!(
375            signal.get_untracked(),
376            Some(SheetOutcome::Dismissed(DismissReason::User))
377        );
378    }
379
380    #[test]
381    fn busy_surfaces_as_err_from_both_sheet_forms() {
382        with_slot_held(|| {
383            let mut presentation = show_native_sheet(sheet());
384            assert_eq!(presentation.sheet_handle(), None);
385            assert_eq!(
386                Pin::new(&mut presentation).poll(&mut Context::from_waker(Waker::noop())),
387                Poll::Ready(Err(PresentError::Busy))
388            );
389            let signal: RwSignal<Option<SheetOutcome>> = RwSignal::new(None);
390            assert_eq!(
391                show_native_sheet_into(sheet(), signal),
392                Err(PresentError::Busy)
393            );
394            assert_eq!(signal.get_untracked(), None);
395        });
396    }
397
398    /// Off iOS the sheet arm refuses on the spot, so the signal form answers
399    /// synchronously and never needs its spawner.
400    #[cfg(not(target_os = "ios"))]
401    #[test]
402    fn the_sheet_forms_are_unsupported_off_ios() {
403        let _guard = crate::present::test_support::serialize();
404        let signal: RwSignal<Option<SheetOutcome>> = RwSignal::new(None);
405        assert_eq!(
406            show_native_sheet_into(sheet(), signal),
407            Err(PresentError::Unsupported)
408        );
409        assert_eq!(signal.get_untracked(), None);
410    }
411
412    #[test]
413    fn with_theme_folds_the_accent_ink_and_brightness() {
414        let light = Theme::neutral().with_brightness(frust::Brightness::Light);
415        let themed = sheet().with_theme(&light);
416        assert_eq!(themed.tint, Some(argb_u32(light.scheme().primary)));
417        assert_eq!(themed.dark, Some(false));
418        let dark = Theme::neutral().with_brightness(frust::Brightness::Dark);
419        assert_eq!(sheet().with_theme(&dark).dark, Some(true));
420        // Theming never touches the content or the detents.
421        assert_eq!(themed.content, sheet().content);
422        assert_eq!(themed.detents, sheet().detents);
423    }
424}