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}