use std::any::Any;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use teksilo_core::raw_handle::ParentHandle;
use teksilo_core::widget::EventContext;
use teksilo_core::window::TeksiloWindowId;
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct RequestId(u64);
#[derive(Debug, Clone)]
pub enum FileDialogResult {
File(Option<PathBuf>),
Files(Vec<PathBuf>),
Folder(Option<PathBuf>),
Saved(Option<PathBuf>),
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DialogKind {
PickFile,
PickFiles,
PickFolder,
SaveFile,
}
#[derive(Debug, Clone)]
pub struct FileFilter {
pub label: String,
pub extensions: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct FileDialogRequest {
kind: DialogKind,
title: Option<String>,
starting_dir: Option<PathBuf>,
default_file_name: Option<String>,
filters: Vec<FileFilter>,
parent: Option<ParentHandle>,
}
impl FileDialogRequest {
fn new(kind: DialogKind) -> Self {
Self {
kind,
title: None,
starting_dir: None,
default_file_name: None,
filters: Vec::new(),
parent: None,
}
}
pub fn pick_file() -> Self {
Self::new(DialogKind::PickFile)
}
pub fn pick_files() -> Self {
Self::new(DialogKind::PickFiles)
}
pub fn pick_folder() -> Self {
Self::new(DialogKind::PickFolder)
}
pub fn save_file() -> Self {
Self::new(DialogKind::SaveFile)
}
#[must_use]
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(t.into());
self
}
#[must_use]
pub fn starting_dir(mut self, p: impl Into<PathBuf>) -> Self {
self.starting_dir = Some(p.into());
self
}
#[must_use]
pub fn default_file_name(mut self, n: impl Into<String>) -> Self {
self.default_file_name = Some(n.into());
self
}
#[must_use]
pub fn add_filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self {
self.filters.push(FileFilter {
label: label.into(),
extensions: extensions.iter().map(|e| (*e).to_string()).collect(),
});
self
}
#[must_use]
pub fn with_parent(mut self, p: ParentHandle) -> Self {
self.parent = Some(p);
self
}
pub fn validate(&self) -> Result<(), String> {
for f in &self.filters {
if f.extensions.is_empty() {
return Err(format!("filter {:?} has no extensions", f.label));
}
for ext in &f.extensions {
if ext.is_empty() {
return Err(format!("filter {:?} has an empty extension", f.label));
}
if ext.starts_with('.') {
return Err(format!(
"filter {:?} extension {ext:?} must not start with a dot",
f.label
));
}
if ext
.chars()
.any(|c| c.is_whitespace() || c == '/' || c == '\\')
{
return Err(format!(
"filter {:?} extension {ext:?} contains whitespace or path separator",
f.label
));
}
}
}
Ok(())
}
#[allow(dead_code)]
fn kind(&self) -> DialogKind {
self.kind
}
}
pub struct FileDialogEventPayload {
pub request_id: RequestId,
pub window_id_owner: TeksiloWindowId,
pub result: FileDialogResult,
}
pub trait FileDialogBackend {
fn dispatch(
&mut self,
request_id: RequestId,
window_id: TeksiloWindowId,
request: FileDialogRequest,
poster: Arc<dyn teksilo_core::AppEventPoster>,
);
}
type ResultCallback = Box<dyn FnOnce(FileDialogResult, &mut EventContext)>;
struct PendingCallback {
window_id: TeksiloWindowId,
callback: ResultCallback,
}
struct FileDialogState {
backend: RefCell<Box<dyn FileDialogBackend>>,
pending: RefCell<HashMap<RequestId, PendingCallback>>,
next_id: Cell<u64>,
}
#[derive(Clone)]
pub struct FileDialogHandle {
inner: Rc<FileDialogState>,
}
impl FileDialogHandle {
pub fn new<B: FileDialogBackend + 'static>(backend: B) -> Self {
Self {
inner: Rc::new(FileDialogState {
backend: RefCell::new(Box::new(backend)),
pending: RefCell::new(HashMap::new()),
next_id: Cell::new(1),
}),
}
}
pub fn submit(
&self,
window_id: TeksiloWindowId,
request: FileDialogRequest,
poster: Arc<dyn teksilo_core::AppEventPoster>,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
request.validate()?;
let id = self.alloc_id();
self.inner.pending.borrow_mut().insert(
id,
PendingCallback {
window_id,
callback: Box::new(on_result),
},
);
self.inner
.backend
.borrow_mut()
.dispatch(id, window_id, request, poster);
Ok(id)
}
pub fn deliver(&self, payload: FileDialogEventPayload, ctx: &mut EventContext) {
let entry = self.inner.pending.borrow_mut().remove(&payload.request_id);
let Some(pending) = entry else {
return;
};
if pending.window_id != payload.window_id_owner {
return;
}
(pending.callback)(payload.result, ctx);
}
pub fn purge_window(&self, window_id: TeksiloWindowId) {
self.inner
.pending
.borrow_mut()
.retain(|_, p| p.window_id != window_id);
}
pub fn pending_count(&self) -> usize {
self.inner.pending.borrow().len()
}
fn alloc_id(&self) -> RequestId {
let n = self.inner.next_id.get();
self.inner.next_id.set(n.wrapping_add(1));
RequestId(n)
}
}
impl std::fmt::Debug for FileDialogHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileDialogHandle")
.field("pending", &self.inner.pending.borrow().len())
.finish_non_exhaustive()
}
}
pub trait EventContextFileDialogExt {
fn pick_file(
&mut self,
request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String>;
fn pick_files(
&mut self,
request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String>;
fn pick_folder(
&mut self,
request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String>;
fn save_file(
&mut self,
request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String>;
}
impl EventContextFileDialogExt for EventContext<'_> {
fn pick_file(
&mut self,
mut request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
request.kind = DialogKind::PickFile;
submit_via_ctx(self, request, on_result)
}
fn pick_files(
&mut self,
mut request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
request.kind = DialogKind::PickFiles;
submit_via_ctx(self, request, on_result)
}
fn pick_folder(
&mut self,
mut request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
request.kind = DialogKind::PickFolder;
submit_via_ctx(self, request, on_result)
}
fn save_file(
&mut self,
mut request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
request.kind = DialogKind::SaveFile;
submit_via_ctx(self, request, on_result)
}
}
fn submit_via_ctx(
ctx: &mut EventContext,
mut request: FileDialogRequest,
on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
) -> Result<RequestId, String> {
let window_id = ctx.window().map(|w| w.id()).ok_or_else(|| {
"EventContext has no window — file dialog needs a parent window".to_string()
})?;
#[cfg(target_os = "macos")]
ctx.focus_window(window_id);
if request.parent.is_none()
&& let Some(parent) = ctx.parent_window_handle()
{
request = request.with_parent(parent);
}
let handle = ctx
.app_state::<FileDialogHandle>()
.ok_or_else(|| {
"FileDialogHandle not installed in app-state — call \
TeksiloAppBuilder::install_file_dialog (or app_state(...)) at startup"
.to_string()
})?
.clone();
let poster = ctx
.poster()
.ok_or_else(|| {
"AppEventPoster not installed — file dialog needs a way to post \
results back to the UI loop"
.to_string()
})?
.clone();
handle.submit(window_id, request, poster, on_result)
}
pub struct MemoryFileDialog {
scripted: VecDeque<FileDialogResult>,
}
impl MemoryFileDialog {
pub fn new() -> Self {
Self {
scripted: VecDeque::new(),
}
}
pub fn enqueue(&mut self, r: FileDialogResult) {
self.scripted.push_back(r);
}
}
impl Default for MemoryFileDialog {
fn default() -> Self {
Self::new()
}
}
impl FileDialogBackend for MemoryFileDialog {
fn dispatch(
&mut self,
request_id: RequestId,
window_id: TeksiloWindowId,
_request: FileDialogRequest,
poster: Arc<dyn teksilo_core::AppEventPoster>,
) {
let result = self.scripted.pop_front().unwrap_or_else(|| {
FileDialogResult::Error("MemoryFileDialog: no scripted result enqueued".into())
});
let payload = FileDialogEventPayload {
request_id,
window_id_owner: window_id,
result,
};
poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
}
}
#[cfg(feature = "rfd-backend")]
mod rfd_backend {
use super::*;
pub struct RfdAsyncBackend;
impl RfdAsyncBackend {
pub fn new() -> Self {
Self
}
}
impl Default for RfdAsyncBackend {
fn default() -> Self {
Self::new()
}
}
impl FileDialogBackend for RfdAsyncBackend {
fn dispatch(
&mut self,
request_id: RequestId,
window_id: TeksiloWindowId,
request: FileDialogRequest,
poster: Arc<dyn teksilo_core::AppEventPoster>,
) {
let mut dialog = rfd::AsyncFileDialog::new();
if let Some(t) = request.title.as_ref() {
dialog = dialog.set_title(t);
}
if let Some(d) = request.starting_dir.as_ref() {
dialog = dialog.set_directory(d);
}
if let Some(n) = request.default_file_name.as_ref() {
dialog = dialog.set_file_name(n);
}
for f in &request.filters {
let exts: Vec<&str> = f.extensions.iter().map(String::as_str).collect();
dialog = dialog.add_filter(&f.label, &exts);
}
if let Some(parent) = request.parent.as_ref() {
dialog = dialog.set_parent(parent);
}
let kind = request.kind();
spawn_dialog_task(async move {
let result = match kind {
DialogKind::PickFile => FileDialogResult::File(
dialog.pick_file().await.map(|h| h.path().to_path_buf()),
),
DialogKind::PickFiles => FileDialogResult::Files(
dialog
.pick_files()
.await
.unwrap_or_default()
.into_iter()
.map(|h| h.path().to_path_buf())
.collect(),
),
DialogKind::PickFolder => FileDialogResult::Folder(
dialog.pick_folder().await.map(|h| h.path().to_path_buf()),
),
DialogKind::SaveFile => FileDialogResult::Saved(
dialog.save_file().await.map(|h| h.path().to_path_buf()),
),
};
let payload = FileDialogEventPayload {
request_id,
window_id_owner: window_id,
result,
};
poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
});
}
}
fn spawn_dialog_task<F>(f: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
async_std::task::spawn(f);
}
}
#[cfg(feature = "rfd-backend")]
pub use rfd_backend::RfdAsyncBackend;
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
use std::sync::Mutex;
use teksilo_core::AppEventPoster;
struct CapturingPoster {
captured: Mutex<Vec<Box<dyn Any + Send>>>,
}
impl CapturingPoster {
fn new() -> Arc<Self> {
Arc::new(Self {
captured: Mutex::new(Vec::new()),
})
}
fn drain(&self) -> Vec<Box<dyn Any + Send>> {
std::mem::take(&mut *self.captured.lock().unwrap())
}
}
impl AppEventPoster for CapturingPoster {
fn post_subscription_event(
&self,
_sub_id: teksilo_core::SubscriptionId,
_event: Box<dyn Any + Send>,
) {
}
fn post_external(&self, payload: Box<dyn Any + Send>) {
self.captured.lock().unwrap().push(payload);
}
}
fn teksilo_id(n: u64) -> TeksiloWindowId {
TeksiloWindowId::new(n)
}
#[test]
fn validate_rejects_empty_extension_list() {
let req = FileDialogRequest::pick_file().add_filter("Images", &[]);
assert!(req.validate().is_err());
}
#[test]
fn validate_rejects_leading_dot() {
let req = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
assert!(req.validate().is_err());
}
#[test]
fn validate_rejects_whitespace_extension() {
let req = FileDialogRequest::pick_file().add_filter("Images", &["png ", "jpg"]);
assert!(req.validate().is_err());
}
#[test]
fn validate_accepts_clean_filters() {
let req = FileDialogRequest::pick_file()
.title("Open")
.add_filter("Images", &["png", "jpg", "JPG"]);
assert!(req.validate().is_ok());
}
#[test]
fn memory_backend_pops_scripted_in_order() {
let mut mock = MemoryFileDialog::new();
mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/a.txt"))));
mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/b.txt"))));
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let _ = handle
.submit(
teksilo_id(1),
FileDialogRequest::pick_file(),
poster.clone(),
|_, _| {},
)
.unwrap();
let _ = handle
.submit(
teksilo_id(1),
FileDialogRequest::pick_file(),
poster.clone(),
|_, _| {},
)
.unwrap();
assert_eq!(handle.pending_count(), 2);
let posted = cap.drain();
assert_eq!(posted.len(), 2);
for p in posted {
let typed = p.downcast::<FileDialogEventPayload>().unwrap();
match typed.result {
FileDialogResult::File(Some(_)) => {}
_ => panic!("expected File(Some)"),
}
}
}
#[test]
fn purge_drops_callbacks_for_matching_window() {
let mut mock = MemoryFileDialog::new();
mock.enqueue(FileDialogResult::File(None));
mock.enqueue(FileDialogResult::File(None));
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let _ = handle
.submit(
teksilo_id(7),
FileDialogRequest::pick_file(),
poster.clone(),
|_, _| {},
)
.unwrap();
let _ = handle
.submit(
teksilo_id(8),
FileDialogRequest::pick_file(),
poster.clone(),
|_, _| {},
)
.unwrap();
assert_eq!(handle.pending_count(), 2);
handle.purge_window(teksilo_id(7));
assert_eq!(handle.pending_count(), 1);
handle.purge_window(teksilo_id(8));
assert_eq!(handle.pending_count(), 0);
}
#[test]
fn submit_validates_before_dispatch() {
let mock = MemoryFileDialog::new();
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let bad = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
assert!(
handle
.submit(teksilo_id(1), bad, poster, |_, _| {})
.is_err()
);
assert_eq!(handle.pending_count(), 0);
assert_eq!(cap.drain().len(), 0);
}
#[test]
fn payload_round_trips_through_capturing_poster() {
let mut mock = MemoryFileDialog::new();
mock.enqueue(FileDialogResult::Folder(Some(PathBuf::from("/home/u"))));
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let req_id = handle
.submit(
teksilo_id(42),
FileDialogRequest::pick_folder(),
poster,
|_, _| {},
)
.unwrap();
let mut posted = cap.drain();
assert_eq!(posted.len(), 1);
let payload = posted
.pop()
.unwrap()
.downcast::<FileDialogEventPayload>()
.expect("payload type matches");
assert_eq!(payload.request_id, req_id);
assert_eq!(payload.window_id_owner, teksilo_id(42));
match &payload.result {
FileDialogResult::Folder(Some(p)) => assert_eq!(p, &PathBuf::from("/home/u")),
other => panic!("unexpected result: {other:?}"),
}
}
#[test]
fn deliver_after_purge_is_silent() {
use std::cell::Cell as StdCell;
use teksilo_core::WidgetTree;
let mut mock = MemoryFileDialog::new();
mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/x"))));
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let fired = Rc::new(StdCell::new(false));
let fired_clone = fired.clone();
let req_id = handle
.submit(
teksilo_id(5),
FileDialogRequest::pick_file(),
poster,
move |_, _| fired_clone.set(true),
)
.unwrap();
handle.purge_window(teksilo_id(5));
let mut posted = cap.drain();
let payload = *posted
.pop()
.unwrap()
.downcast::<FileDialogEventPayload>()
.unwrap();
assert_eq!(payload.request_id, req_id);
let mut tree = WidgetTree::new();
let mut noop = teksilo_core::NoopWindowOps;
tree.run_with_event_context(&mut noop, |ctx| {
handle.deliver(payload, ctx);
});
assert!(!fired.get(), "callback must not fire after purge");
}
#[test]
fn deliver_invokes_callback_with_result() {
use std::cell::Cell as StdCell;
use teksilo_core::WidgetTree;
let mut mock = MemoryFileDialog::new();
mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/y.txt"))));
let handle = FileDialogHandle::new(mock);
let cap = CapturingPoster::new();
let poster: Arc<dyn AppEventPoster> = cap.clone();
let captured: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
let captured_clone = captured.clone();
let _ = StdCell::new(0);
let _ = handle
.submit(
teksilo_id(11),
FileDialogRequest::pick_file(),
poster,
move |result, _| {
if let FileDialogResult::File(Some(p)) = result {
*captured_clone.borrow_mut() = Some(p);
}
},
)
.unwrap();
let payload = *cap
.drain()
.pop()
.unwrap()
.downcast::<FileDialogEventPayload>()
.unwrap();
let mut tree = WidgetTree::new();
let mut noop = teksilo_core::NoopWindowOps;
tree.run_with_event_context(&mut noop, |ctx| handle.deliver(payload, ctx));
assert_eq!(*captured.borrow(), Some(PathBuf::from("/tmp/y.txt")));
assert_eq!(handle.pending_count(), 0);
}
}