Document dialog outcomes and lifecycle contracts

This commit is contained in:
skylartaylor authored and skylar taylor-barrick committed 2026-10-22 00:46:41 +02:00
1 parent ff19b46588
commit d6517a8f7f
2 files changed
+30

No files matched your search

+19
View File
@@ -1,22 +1,34 @@
//! Dialog results with evidence: an empty rfd answer does not establish user cancellation.
/// Host assessment of whether a native backend is available; unknown is not proven absence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Capability {
/// The host has confirmed that a backend is available.
Available,
/// Proven backend absence, with a diagnostic suitable for the host.
Unavailable(String),
/// Inconclusive capability evidence; the host should still permit a picker attempt.
Unknown(String),
}
/// The layer that prevented a dialog request from completing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ErrorKind {
/// The host proved that no backend is available.
Unavailable,
/// Request transport or background execution failed.
Worker,
/// The picker backend reported or encountered a failure.
Backend,
}
/// A classified dialog failure with a host-facing diagnostic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DialogError {
/// The failing layer.
pub kind: ErrorKind,
/// Diagnostic text for the host to present or log.
pub message: String,
}
impl DialogError {
/// Construct a failure without inferring cancellation from the diagnostic.
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
}
@@ -27,19 +39,24 @@ impl std::fmt::Display for DialogError {
}
}
impl std::error::Error for DialogError {}
/// Selection, explicit cancellation, failure, or an answer whose cause is unknown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome<T> {
/// A value returned by the picker.
Selected(T),
/// Explicit app/backend cancellation; never inferred from rfd's empty answer.
Cancelled,
/// A transport, capability or backend failure.
Failed(DialogError),
/// No selection; rfd cannot say whether the user cancelled or its backend failed.
Indeterminate,
}
impl<T> Outcome<T> {
/// Preserve a selection; an empty rfd answer is indeterminate, never proven cancellation.
pub fn from_rfd(value: Option<T>) -> Self {
value.map_or(Self::Indeterminate, Self::Selected)
}
/// Transform only a selected value, preserving all other outcomes unchanged.
pub fn map<U>(self, map: impl FnOnce(T) -> U) -> Outcome<U> {
match self {
Self::Selected(value) => Outcome::Selected(map(value)),
@@ -48,11 +65,13 @@ impl<T> Outcome<T> {
Self::Indeterminate => Outcome::Indeterminate,
}
}
/// Construct a backend failure with the supplied diagnostic.
pub fn failed(message: impl Into<String>) -> Self {
Self::Failed(DialogError::new(ErrorKind::Backend, message))
}
}
impl<T> Outcome<Vec<T>> {
/// Treat missing and empty multi-selection answers as indeterminate.
pub fn from_rfd_files(value: Option<Vec<T>>) -> Self {
Self::from_rfd(value.filter(|paths| !paths.is_empty()))
}
+11
View File
@@ -14,6 +14,7 @@ use std::{
},
thread::JoinHandle,
};
/// An already-created native picker future that a reserved worker can wait on.
pub type DialogFuture<T> = Pin<Box<dyn Future<Output = Outcome<T>> + Send>>;
/// Wait for a future without polling an idle native dialog.
@@ -76,6 +77,8 @@ impl<R, T> Clone for DialogClient<R, T> {
}
}
impl<R, T> DialogClient<R, T> {
/// Queue a request without blocking and wake the host to pump it.
/// A busy or unavailable host answers with a worker failure; no second picker is opened.
pub fn request(&self, request: R) -> Receiver<Outcome<T>> {
let (answer, receive) = mpsc::channel();
if self.busy.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
@@ -125,6 +128,7 @@ pub struct NativeDialogs<R, T> {
check: Arc<dyn Fn() -> Result<(), DialogError> + Send + Sync>,
}
impl<R, T: Send + 'static> NativeDialogs<R, T> {
/// Create a client and its event-loop-owned service without a backend preflight check.
pub fn new(wake: impl Fn() + Send + Sync + 'static) -> (DialogClient<R, T>, Self) {
Self::with_preflight(wake, || Ok(()))
}
@@ -140,9 +144,13 @@ impl<R, T: Send + 'static> NativeDialogs<R, T> {
let wake: Arc<dyn Fn() + Send + Sync> = Arc::new(wake);
(DialogClient { sender, busy: busy.clone(), wake: wake.clone() }, Self { queue, busy, state: None, wake, check: Arc::new(check) })
}
/// Whether a queued, preparing or live picker still owns the transport reservation.
pub fn pending(&self) -> bool {
self.busy.load(Ordering::SeqCst)
}
/// Advance requests on the event-loop thread and publish completed replies.
/// Call before consuming replies and after enqueuing work. `create` must eagerly open
/// the native picker on this thread and return its already-created future.
pub fn pump(&mut self, mut create: impl FnMut(R) -> DialogFuture<T>) {
if let Some(state) = self.state.take() {
match state {
@@ -216,6 +224,9 @@ pub struct DeferredClose {
}
#[cfg(feature = "egui")]
impl DeferredClose {
/// Hold actual close requests while a picker or app continuation is pending.
/// Return `true` once after settlement so the host retries its unsaved-work policy;
/// this never grants permission to discard changes or closes the viewport itself.
pub fn update(&mut self, ctx: &egui::Context, pending: bool) -> bool {
let outgoing = ctx.viewport(|v| {
v.commands