use egui::Context;
use kanal::{Receiver, Sender, unbounded};
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub(crate) struct FileFilter {
pub(crate) name: &'static str,
pub(crate) extensions: &'static [&'static str],
}
pub(crate) const IMAGES: FileFilter = FileFilter {
name: "Images",
extensions: &["png", "jpg", "jpeg"],
};
pub(crate) const PHOTOS: FileFilter = FileFilter {
name: "Images",
extensions: &["png", "jpg", "jpeg", "svg"],
};
pub(crate) const SVG: FileFilter = FileFilter {
name: "SVG",
extensions: &["svg"],
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChosenFile {
pub(crate) name: String,
pub(crate) bytes: Vec<u8>,
}
impl ChosenFile {
pub(crate) fn extension(&self) -> String {
Path::new(&self.name)
.extension()
.map_or(String::new(), |extension| {
extension.to_string_lossy().to_ascii_lowercase()
})
}
pub(crate) fn stem(&self) -> String {
Path::new(&self.name).file_stem().map_or_else(
|| self.name.clone(),
|stem| stem.to_string_lossy().into_owned(),
)
}
pub(crate) fn text(&self) -> Result<&str, String> {
std::str::from_utf8(&self.bytes).map_err(|_| format!("{} is no text file", self.name))
}
}
pub(crate) struct FilePick<T> {
sender: Sender<Option<T>>,
receiver: Receiver<Option<T>>,
open: bool,
}
impl<T> Default for FilePick<T> {
fn default() -> Self {
let (sender, receiver) = unbounded();
Self {
sender,
receiver,
open: false,
}
}
}
impl<T> std::fmt::Debug for FilePick<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FilePick")
.field("open", &self.open)
.finish_non_exhaustive()
}
}
impl<T: Send + 'static> FilePick<T> {
pub(crate) fn ask(
&mut self,
ctx: &Context,
filter: FileFilter,
load: impl FnOnce(ChosenFile) -> T + Send + 'static,
) {
if self.open {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let answer = Answer {
sender: Some(self.sender.clone()),
ctx: ctx.clone(),
};
let spawned = std::thread::Builder::new()
.name("gled:ui:pick_file".to_string())
.spawn(move || {
#[cfg(feature = "profiling")]
profiling::register_thread!("pick_file");
answer.send(chosen_file(filter).map(load));
});
match spawned {
Ok(_) => self.open = true,
Err(error) => tracing::error!("Could not open the file chooser: {error}"),
}
}
#[cfg(target_arch = "wasm32")]
{
let sender = self.sender.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let picked = rfd::AsyncFileDialog::new()
.add_filter(filter.name, filter.extensions)
.pick_file()
.await;
let answer = match picked {
Some(file) => Some(load(ChosenFile {
name: file.file_name(),
bytes: file.read().await,
})),
None => None,
};
let _ = sender.send(answer);
ctx.request_repaint();
});
self.open = true;
}
}
pub(crate) fn take(&mut self) -> Option<T> {
let answer = self.receiver.try_recv().ok().flatten()?;
self.open = false;
answer
}
pub(crate) fn is_open(&self) -> bool {
self.open
}
}
#[cfg(not(target_arch = "wasm32"))]
struct Answer<T> {
sender: Option<Sender<Option<T>>>,
ctx: Context,
}
#[cfg(not(target_arch = "wasm32"))]
impl<T> Answer<T> {
fn send(mut self, answer: Option<T>) {
if let Some(sender) = self.sender.take()
&& sender.send(answer).is_err()
{
tracing::debug!("Nobody waits for the chosen file any more");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl<T> Drop for Answer<T> {
fn drop(&mut self) {
if let Some(sender) = self.sender.take() {
let _ = sender.send(None);
}
self.ctx.request_repaint();
}
}
#[cfg(not(target_arch = "wasm32"))]
fn chosen_file(filter: FileFilter) -> Option<ChosenFile> {
let path = test_pick().or_else(|| {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(filter.name, filter.extensions)
.pick_file();
pollster::block_on(dialog).map(|file| file.path().to_path_buf())
})?;
match std::fs::read(&path) {
Ok(bytes) => Some(ChosenFile {
name: path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned()),
bytes,
}),
Err(error) => {
tracing::warn!("Could not read {}: {error}", path.display());
None
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn test_pick() -> Option<std::path::PathBuf> {
if !cfg!(debug_assertions) {
return None;
}
let path = std::path::PathBuf::from(std::env::var_os("GLED_TEST_PICK_FILE")?);
tracing::debug!("Picking {} instead of asking", path.display());
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cancelled_chooser_closes_without_an_answer() {
let mut pick = FilePick::<u8> {
open: true,
..Default::default()
};
pick.sender.send(None).expect("the receiver is alive");
assert_eq!(pick.take(), None);
assert!(!pick.is_open(), "the chooser can be opened again");
}
#[test]
fn a_load_that_panics_still_closes_the_chooser() {
let mut pick = FilePick::<u8> {
open: true,
..Default::default()
};
let answer = Answer {
sender: Some(pick.sender.clone()),
ctx: Context::default(),
};
let worker = std::thread::spawn(move || {
let _answer = answer;
panic!("a decoder choking on a file");
});
assert!(worker.join().is_err(), "the worker panicked");
assert_eq!(pick.take(), None);
assert!(!pick.is_open(), "the chooser can be opened again");
}
#[test]
fn an_answer_is_taken_once() {
let mut pick = FilePick::<u8> {
open: true,
..Default::default()
};
pick.sender.send(Some(7)).expect("the receiver is alive");
assert_eq!(pick.take(), Some(7));
assert_eq!(pick.take(), None);
assert!(!pick.is_open());
}
}