From ff19b4658803a336f31dd3385c04e7fed316fd7c Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Wed, 21 Oct 2026 19:47:31 +0200 Subject: [PATCH] platform: add native dialog lifecycle services --- Cargo.lock | 7 + Cargo.toml | 1 + NOTICE | 7 + README.md | 2 + crates/platform/Cargo.toml | 19 ++ crates/platform/LICENSE-APACHE | 177 +++++++++++++++ crates/platform/LICENSE-MIT | 21 ++ crates/platform/NOTICE | 6 + crates/platform/README.md | 42 ++++ crates/platform/src/dialog_outcome.rs | 78 +++++++ crates/platform/src/dialogs.rs | 247 +++++++++++++++++++++ crates/platform/src/lib.rs | 5 + crates/platform/tests/dialog_lifecycle.rs | 248 ++++++++++++++++++++++ 13 files changed, 860 insertions(+) create mode 100644 crates/platform/Cargo.toml create mode 100644 crates/platform/LICENSE-APACHE create mode 100644 crates/platform/LICENSE-MIT create mode 100644 crates/platform/NOTICE create mode 100644 crates/platform/README.md create mode 100644 crates/platform/src/dialog_outcome.rs create mode 100644 crates/platform/src/dialogs.rs create mode 100644 crates/platform/src/lib.rs create mode 100644 crates/platform/tests/dialog_lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index 4c7cda047..fc190e009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,6 +686,13 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "craft-platform" +version = "0.1.0" +dependencies = [ + "egui", +] + [[package]] name = "craft-print" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 319c1138f..da23b8672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` +craft-platform = { path = "crates/platform" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } diff --git a/NOTICE b/NOTICE index 9467671f3..49a34553b 100644 --- a/NOTICE +++ b/NOTICE @@ -106,3 +106,10 @@ Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Cl Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; applications retain their own document-font selection and licensing policy. + +Platform dialog lifecycle provenance + +The dialog outcomes and lifecycle adapt preserved local platform candidate +9b135779a1e7136438a217c18afe1693ea23f1ab. +Copyright (c) 2026 ArtCraft Team and the craft-libs contributors. +All adapted source is licensed MIT OR Apache-2.0. diff --git a/README.md b/README.md index 2f09381b3..4629d9a58 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` provides an optional serial queue. Pump it on the event-loop +thread before consuming answers and after code that can request a picker. Replies become visible +only after the pump releases transport ownership, so a continuation can enqueue the next picker. +Dropping a stale reply does not release the actual picker. A host-specific preflight is optional; +it runs off-thread and must be bounded. Unknown capability permits a picker. Proven absence may +request an application-owned fallback. Custom save-format sheets and portal choices remain intact. + +`Outcome::Selected`, explicit `Cancelled`, `Failed` and `Indeterminate` have different evidence. +An rfd `None` is `Indeterminate`; it cannot prove user cancellation rather than backend failure. +Disconnected workers are failures. Apps own localization, document identity/revision checks, filters, +actual save receipts, chained continuations and the decision to close. + +Retain the actual parent window through queued requests, preflight, the native picker AND any +unconsumed application continuation. `DeferredClose::update` cancels close during pending work and +returns a request to retry the application's normal close policy afterward. It never grants +permission to discard work. Run those guards before `craft_quit::on_close_request` in `App::logic`. + +## Integration + +Enable only the services a host uses. Applications retain palettes, settings, document identity, +close policy, filters, names, MIME types and native command routing. Existing craft-settings, +craft-atomic, craft-print, craft-docpath and craft-quit remain canonical. diff --git a/crates/platform/src/dialog_outcome.rs b/crates/platform/src/dialog_outcome.rs new file mode 100644 index 000000000..fb7f5d64b --- /dev/null +++ b/crates/platform/src/dialog_outcome.rs @@ -0,0 +1,78 @@ +//! Dialog results with evidence: an empty rfd answer does not establish user cancellation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Capability { + Available, + Unavailable(String), + Unknown(String), +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ErrorKind { + Unavailable, + Worker, + Backend, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DialogError { + pub kind: ErrorKind, + pub message: String, +} +impl DialogError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { kind, message: message.into() } + } +} +impl std::fmt::Display for DialogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for DialogError {} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Selected(T), + /// Explicit app/backend cancellation; never inferred from rfd's empty answer. + Cancelled, + Failed(DialogError), + /// No selection; rfd cannot say whether the user cancelled or its backend failed. + Indeterminate, +} +impl Outcome { + pub fn from_rfd(value: Option) -> Self { + value.map_or(Self::Indeterminate, Self::Selected) + } + pub fn map(self, map: impl FnOnce(T) -> U) -> Outcome { + match self { + Self::Selected(value) => Outcome::Selected(map(value)), + Self::Cancelled => Outcome::Cancelled, + Self::Failed(error) => Outcome::Failed(error), + Self::Indeterminate => Outcome::Indeterminate, + } + } + pub fn failed(message: impl Into) -> Self { + Self::Failed(DialogError::new(ErrorKind::Backend, message)) + } +} +impl Outcome> { + pub fn from_rfd_files(value: Option>) -> Self { + Self::from_rfd(value.filter(|paths| !paths.is_empty())) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_and_empty_rfd_answers_never_establish_cancellation() { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); + assert_eq!(Outcome::>::from_rfd_files(Some(Vec::new())), Outcome::Indeterminate); + assert_eq!(Outcome::from_rfd_files(Some(vec!["a"])), Outcome::Selected(vec!["a"])); + for _capability in [Capability::Available, Capability::Unavailable("missing".into()), Capability::Unknown("timeout".into())] { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); + } + } + #[test] + fn mapping_preserves_explicit_cancellation_and_failure() { + assert_eq!(Outcome::::Cancelled.map(u32::from), Outcome::Cancelled); + assert_eq!(Outcome::::failed("worker stopped").map(u32::from), Outcome::failed("worker stopped")); + assert_eq!(Outcome::Selected(7_u8).map(u32::from), Outcome::Selected(7)); + } +} diff --git a/crates/platform/src/dialogs.rs b/crates/platform/src/dialogs.rs new file mode 100644 index 000000000..a9ca9a160 --- /dev/null +++ b/crates/platform/src/dialogs.rs @@ -0,0 +1,247 @@ +//! UI-thread picker creation with reserved waiting workers. No backend or application policy. +//! +//! `create` must call the native picker method eagerly on the event-loop thread, returning its +//! already-created future. Hosts retain the parent through transport AND their continuation. +//! A missing backend answer is indeterminate; only explicit backend evidence proves cancellation. +use crate::dialog_outcome::{DialogError, ErrorKind, Outcome}; +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, Sender, SyncSender, TryRecvError}, + }, + thread::JoinHandle, +}; +pub type DialogFuture = Pin> + Send>>; + +/// Wait for a future without polling an idle native dialog. +pub fn block_on(future: impl Future) -> T { + struct Unpark(std::thread::Thread); + impl std::task::Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + } + let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + loop { + if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut context) { + return value; + } + std::thread::park(); + } +} +// Notification callbacks must not unwind a reserved worker after the UI observes readiness. +fn notify(wake: &dyn Fn()) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(wake)); +} +fn worker_error(message: impl Into) -> DialogError { + DialogError::new(ErrorKind::Worker, message) +} +fn guarded(f: impl FnOnce() -> Outcome) -> Outcome { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Outcome::Failed(worker_error("The file-dialog backend failed"))) +} +/// Reserve the worker before opening a native sheet. A failed reservation never calls `create`. +/// No timeout is imposed on a live picker. Backend constructor/poll panics become explicit failures. +pub fn wait_prepared> + Send + 'static>( + create: impl FnOnce() -> F, + deliver: impl FnOnce(Outcome) + Send + 'static, +) -> std::io::Result> { + let (send, receive) = mpsc::channel::(); + let worker = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + deliver(guarded(|| match receive.recv() { + Ok(future) => block_on(future), + Err(_) => Outcome::Failed(worker_error("The file-dialog factory failed")), + })); + })?; + if let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)) { + send.send(future).map_err(|_| std::io::Error::other("The reserved file-dialog worker stopped"))?; + } + Ok(worker) +} + +type Job = (R, Sender>); +/// Clonable request endpoint. At most one queued, preparing or live request is accepted. +pub struct DialogClient { + sender: SyncSender>, + busy: Arc, + wake: Arc, +} +impl Clone for DialogClient { + fn clone(&self) -> Self { + Self { sender: self.sender.clone(), busy: self.busy.clone(), wake: self.wake.clone() } + } +} +impl DialogClient { + pub fn request(&self, request: R) -> Receiver> { + let (answer, receive) = mpsc::channel(); + if self.busy.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + let _ = answer.send(Outcome::Failed(worker_error("A file dialog is already pending"))); + } else if let Err(error) = self.sender.try_send((request, answer)) { + self.busy.store(false, Ordering::SeqCst); + let (_, answer) = match error { + mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job) => job, + }; + let _ = answer.send(Outcome::Failed(worker_error("The file-dialog host is unavailable"))); + } + notify(self.wake.as_ref()); + receive + } +} +struct Preparing { + request: R, + ready: Receiver<()>, + send: Sender>, + completion: Completion, +} +struct Completion { + done: Receiver>, + answer: Sender>, +} +impl Completion { + fn poll(&self) -> Option> { + match self.done.try_recv() { + Ok(outcome) => Some(outcome), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Outcome::Failed(worker_error("The file-dialog worker stopped without an answer"))), + } + } +} +enum State { + Preparing(Preparing), + Waiting(Completion), +} +/// Event-loop-owned transport. Dropping a reply does not release a live picker or its parent. +/// `pump` must run before consuming replies, and after app code that may enqueue a request. +/// The host must retain this service/window while `pending()` or its own continuation is pending. +pub struct NativeDialogs { + queue: Receiver>, + busy: Arc, + state: Option>, + wake: Arc, + check: Arc Result<(), DialogError> + Send + Sync>, +} +impl NativeDialogs { + pub fn new(wake: impl Fn() + Send + Sync + 'static) -> (DialogClient, Self) { + Self::with_preflight(wake, || Ok(())) + } + /// Optional host-owned advisory check, run on the reserved worker before any picker exists. + /// Hosts must bound this check. Unknown capability must permit the actual picker; return an + /// Unavailable error only when absence is proven. This service performs no filesystem probes. + pub fn with_preflight( + wake: impl Fn() + Send + Sync + 'static, + check: impl Fn() -> Result<(), DialogError> + Send + Sync + 'static, + ) -> (DialogClient, Self) { + let (sender, queue) = mpsc::sync_channel(1); + let busy = Arc::new(AtomicBool::new(false)); + let wake: Arc = Arc::new(wake); + (DialogClient { sender, busy: busy.clone(), wake: wake.clone() }, Self { queue, busy, state: None, wake, check: Arc::new(check) }) + } + pub fn pending(&self) -> bool { + self.busy.load(Ordering::SeqCst) + } + pub fn pump(&mut self, mut create: impl FnMut(R) -> DialogFuture) { + if let Some(state) = self.state.take() { + match state { + State::Waiting(completion) => { + if let Some(outcome) = completion.poll() { + // Only the UI pump publishes replies, after releasing the transport. + // A continuation can therefore immediately request its next picker. + self.busy.store(false, Ordering::SeqCst); + let _ = completion.answer.send(outcome); + } else { + self.state = Some(State::Waiting(completion)); + } + } + State::Preparing(preparing) => { + if let Some(outcome) = preparing.completion.poll() { + self.busy.store(false, Ordering::SeqCst); + let _ = preparing.completion.answer.send(outcome); + } else if preparing.ready.try_recv().is_ok() { + let future = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| create(preparing.request))) + .unwrap_or_else(|_| Box::pin(async { Outcome::Failed(worker_error("The file-dialog factory failed")) })); + let _ = preparing.send.send(future); + self.state = Some(State::Waiting(preparing.completion)); + } else { + self.state = Some(State::Preparing(preparing)); + } + } + } + } + if self.state.is_none() + && let Ok((request, answer)) = self.queue.try_recv() + { + let (ready_send, ready) = mpsc::channel(); + let (send, receive) = mpsc::channel::>(); + let (done_send, done) = mpsc::channel(); + let check = self.check.clone(); + let wake = self.wake.clone(); + let started = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + let output = guarded(|| { + if let Err(error) = check() { + return Outcome::Failed(error); + } + if ready_send.send(()).is_err() { + return Outcome::Failed(worker_error("The file-dialog host stopped")); + } + notify(wake.as_ref()); + match receive.recv() { + Ok(future) => block_on(future), + Err(_) => Outcome::Failed(worker_error("The file-dialog host stopped")), + } + }); + let _ = done_send.send(output); + notify(wake.as_ref()); + }); + match started { + Ok(_) => self.state = Some(State::Preparing(Preparing { request, ready, send, completion: Completion { done, answer } })), + Err(error) => { + self.busy.store(false, Ordering::SeqCst); + let _ = answer.send(Outcome::Failed(worker_error(error.to_string()))); + notify(self.wake.as_ref()); + } + } + } + } +} +/// Holds close intent while a picker/continuation owns the parent. A retry never authorizes +/// discarding documents: the caller must run its ordinary unsaved-work policy again. +#[cfg(feature = "egui")] +#[derive(Default, Debug)] +pub struct DeferredClose { + held: bool, +} +#[cfg(feature = "egui")] +impl DeferredClose { + pub fn update(&mut self, ctx: &egui::Context, pending: bool) -> bool { + let outgoing = ctx.viewport(|v| { + v.commands + .iter() + .rev() + .find_map(|c| match c { + egui::ViewportCommand::Close => Some(true), + egui::ViewportCommand::CancelClose => Some(false), + _ => None, + }) + .unwrap_or(false) + }); + if pending { + let requested = outgoing || ctx.input(|i| i.viewport().close_requested()); + self.held |= requested; + // Every viewport command requests a repaint. An idle native picker must sleep. + if requested { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + false + } else if std::mem::take(&mut self.held) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.request_repaint(); + true + } else { + false + } + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs new file mode 100644 index 000000000..ba43fbfb1 --- /dev/null +++ b/crates/platform/src/lib.rs @@ -0,0 +1,5 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts. +pub mod dialog_outcome; +#[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] +pub mod dialogs; diff --git a/crates/platform/tests/dialog_lifecycle.rs b/crates/platform/tests/dialog_lifecycle.rs new file mode 100644 index 000000000..1b78d0319 --- /dev/null +++ b/crates/platform/tests/dialog_lifecycle.rs @@ -0,0 +1,248 @@ +#![cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] +use craft_platform::{ + dialog_outcome::{DialogError, ErrorKind, Outcome}, + dialogs::{NativeDialogs, wait_prepared}, +}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, +}; +use std::time::{Duration, Instant}; +fn pump_until( + service: &mut NativeDialogs, + mut create: impl FnMut(R) -> craft_platform::dialogs::DialogFuture, + until: impl Fn() -> bool, +) { + let deadline = Instant::now() + Duration::from_secs(3); + while !until() { + assert!(Instant::now() < deadline, "worker did not settle"); + service.pump(&mut create); + std::thread::yield_now(); + } +} +#[test] +fn factory_is_eager_on_caller_and_completion_on_worker() -> Result<(), Box> { + let caller = std::thread::current().id(); + let (tx, rx) = mpsc::channel(); + let worker = wait_prepared( + || { + assert_eq!(caller, std::thread::current().id()); + async { Outcome::Selected(42) } + }, + move |outcome| { + let _ = tx.send((outcome, std::thread::current().id())); + }, + )?; + let (outcome, thread) = rx.recv_timeout(Duration::from_secs(3))?; + assert_eq!(outcome, Outcome::Selected(42)); + assert_ne!(caller, thread); + assert!(worker.join().is_ok()); + Ok(()) +} +#[test] +fn constructor_and_poll_failures_are_not_cancellation() -> Result<(), Box> { + for construct in [false, true] { + let (tx, rx) = mpsc::channel(); + let worker = wait_prepared( + || { + if construct { + std::panic::resume_unwind(Box::new("constructor")); + } + async { + std::panic::resume_unwind(Box::new("poll")); + #[allow(unreachable_code)] + Outcome::::Cancelled + } + }, + move |outcome| { + let _ = tx.send(outcome); + }, + )?; + assert!(matches!(rx.recv_timeout(Duration::from_secs(3))?, Outcome::Failed(DialogError { kind: ErrorKind::Worker, .. }))); + assert!(worker.join().is_ok()); + } + Ok(()) +} +#[test] +fn queued_and_dropped_replies_keep_live_picker_busy() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::new(|| {}); + let reply = c.request(()); + assert!(service.pending()); + drop(reply); + let (release, wait) = mpsc::channel(); + let mut wait = Some(wait); + let created = Arc::new(AtomicUsize::new(0)); + let count = created.clone(); + pump_until( + &mut service, + move |()| { + count.fetch_add(1, Ordering::SeqCst); + let wait = wait.take(); + Box::pin(async move { + let _ = wait.map(|w| w.recv()); + Outcome::Selected(1) + }) + }, + || created.load(Ordering::SeqCst) == 1, + ); + assert!(service.pending()); + assert!(matches!(c.request(()).recv_timeout(Duration::from_secs(3))?, Outcome::Failed(_))); + release.send(())?; + let deadline = Instant::now() + Duration::from_secs(3); + while service.pending() { + assert!(Instant::now() < deadline); + service.pump(|()| Box::pin(async { Outcome::Indeterminate })); + std::thread::yield_now(); + } + Ok(()) +} +#[test] +fn proven_absence_skips_native_factory() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::with_preflight(|| {}, || Err(DialogError::new(ErrorKind::Unavailable, "test backend missing"))); + let reply = c.request(()); + let called = Arc::new(AtomicUsize::new(0)); + let count = called.clone(); + let mut result = None; + let deadline = Instant::now() + Duration::from_secs(3); + while result.is_none() { + assert!(Instant::now() < deadline); + service.pump(|()| { + count.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Outcome::Selected(1) }) + }); + result = reply.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(called.load(Ordering::SeqCst), 0); + assert!(matches!(result, Some(Outcome::Failed(DialogError { kind: ErrorKind::Unavailable, .. })))); + Ok(()) +} +#[test] +fn missing_backend_answer_is_ambiguous() { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); +} +#[cfg(feature = "egui")] +#[test] +fn held_close_retries_policy_without_granting_discard() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + let mut output = ctx.run_ui(input, |ui| assert!(!close.update(ui.ctx(), true))); + assert!(output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::CancelClose)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false))); + assert!(!output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Close)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +} +#[test] +fn notification_panic_cannot_kill_reserved_picker_worker() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::new(|| std::panic::resume_unwind(Box::new("wake failure"))); + let reply = c.request(()); + let mut result = None; + let deadline = Instant::now() + Duration::from_secs(3); + while result.is_none() { + assert!(Instant::now() < deadline); + service.pump(|()| Box::pin(async { Outcome::Selected(7) })); + result = reply.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(result, Some(Outcome::Selected(7))); + Ok(()) +} + +#[test] +fn completion_between_pumps_cannot_reject_a_chained_request() -> Result<(), Box> { + let wakes = Arc::new(AtomicUsize::new(0)); + let count = wakes.clone(); + let (client, mut service) = NativeDialogs::::new(move || { + count.fetch_add(1, Ordering::SeqCst); + }); + let first = client.request(1); + let (release, wait) = mpsc::channel(); + let mut wait = Some(wait); + let created = Arc::new(AtomicUsize::new(0)); + let count = created.clone(); + pump_until( + &mut service, + move |value| { + count.fetch_add(1, Ordering::SeqCst); + let wait = wait.take(); + Box::pin(async move { + let _ = wait.map(|wait| wait.recv()); + Outcome::Selected(value) + }) + }, + || created.load(Ordering::SeqCst) == 1, + ); + // Finish after the host's pump, before its app continuation polls the reply. + release.send(())?; + let deadline = Instant::now() + Duration::from_secs(3); + while wakes.load(Ordering::SeqCst) < 3 { + assert!(Instant::now() < deadline, "worker did not publish completion"); + std::thread::yield_now(); + } + assert!(matches!(first.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert!(service.pending(), "an unpublished completion still retains the parent"); + service.pump(|value| Box::pin(async move { Outcome::Selected(value) })); + assert_eq!(first.try_recv()?, Outcome::Selected(1)); + assert!(!service.pending()); + // The app can chain the next dialog immediately upon observing the first answer. + let second = client.request(2); + assert!(matches!(second.try_recv(), Err(mpsc::TryRecvError::Empty))); + let mut result = None; + while result.is_none() { + assert!(Instant::now() < deadline, "chained picker did not settle"); + service.pump(|value| Box::pin(async move { Outcome::Selected(value) })); + result = second.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(result, Some(Outcome::Selected(2))); + assert!(!service.pending()); + Ok(()) +} + +#[cfg(feature = "egui")] +#[test] +fn pending_dialog_without_close_intent_does_not_schedule_viewport_work() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + // Let egui settle its own initial font/style passes before testing the idle host. + for _ in 0..4 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + for _ in 0..3 { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!close.update(ui.ctx(), true)); + }); + output.textures_delta.clear(); + let viewport = output.viewport_output.get(&egui::ViewportId::ROOT).unwrap(); + assert!(viewport.commands.is_empty(), "an idle picker must not enqueue commands: {:?}", viewport.commands); + assert_ne!(viewport.repaint_delay, std::time::Duration::ZERO, "an idle picker must not request another frame"); + } + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +} + +#[cfg(feature = "egui")] +#[test] +fn programmatic_close_is_held_until_the_pending_dialog_settles() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + assert!(!close.update(ui.ctx(), true)); + }); + output.textures_delta.clear(); + let commands = &output.viewport_output.get(&egui::ViewportId::ROOT).unwrap().commands; + let last_close = commands.iter().rev().find(|command| matches!(command, egui::ViewportCommand::Close | egui::ViewportCommand::CancelClose)); + assert_eq!(last_close, Some(&egui::ViewportCommand::CancelClose)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false))); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +}