gled-ui 2.31.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Asking for a file to load, without stopping the window.
//!
//! The desktop's file chooser blocks whoever asks until it closes, so it runs
//! on a thread of its own, which also loads the file: the window keeps
//! painting meanwhile, and picks up the answer once it is there.
//!
//! Debug builds take the answer from `GLED_TEST_PICK_FILE` when it is set,
//! instead of opening the chooser, so the editors' file loading can be driven
//! in an automated session. Release builds always ask.

use egui::Context;
use kanal::{Receiver, Sender, unbounded};
use std::path::PathBuf;

/// The kind of file a chooser offers. A browser never opens one.
#[derive(Debug, Clone, Copy)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) struct FileFilter {
    pub(crate) name: &'static str,
    pub(crate) extensions: &'static [&'static str],
}

/// Bitmaps gled can decode.
pub(crate) const IMAGES: FileFilter = FileFilter {
    name: "Images",
    extensions: &["png", "jpg", "jpeg"],
};

/// A picture of a lamp: a bitmap or a drawing.
pub(crate) const PHOTOS: FileFilter = FileFilter {
    name: "Images",
    extensions: &["png", "jpg", "jpeg", "svg"],
};

pub(crate) const SVG: FileFilter = FileFilter {
    name: "SVG",
    extensions: &["svg"],
};

/// One question for a file at a time, and its answer: what `load` made of
/// the file, or nothing when the chooser was cancelled.
pub(crate) struct FilePick<T> {
    /// For the chooser's thread, which a browser never starts.
    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
    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> {
    /// Opens the chooser for a file of the kind `filter` names, and hands the
    /// chosen one to `load` on the chooser's thread. Only on the desktop: a
    /// browser cannot hand over a path, and is never asked.
    pub(crate) fn ask(
        &mut self,
        ctx: &Context,
        filter: FileFilter,
        load: impl FnOnce(PathBuf) -> 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 _ = (ctx, filter, load);
    }

    /// The answer, once the chooser has closed: `None` while it is open and
    /// when it was cancelled.
    pub(crate) fn take(&mut self) -> Option<T> {
        let answer = self.receiver.try_recv().ok().flatten()?;
        self.open = false;
        answer
    }

    /// Whether the chooser is open. A button that opens it is disabled
    /// meanwhile.
    pub(crate) fn is_open(&self) -> bool {
        self.open
    }
}

/// The way back from the chooser's thread. It always answers: a `load` that
/// panics on a hostile file unwinds through it and sends no file, so the
/// chooser can be opened again rather than staying "open" for the session.
#[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() {
            // the receiver may be gone with its window; then nobody waits
            let _ = sender.send(None);
        }
        self.ctx.request_repaint();
    }
}

/// Asks the desktop's chooser, which on macOS has to run on the main thread:
/// the async dialog hands itself over there and this thread waits for it.
#[cfg(not(target_arch = "wasm32"))]
fn chosen_file(filter: FileFilter) -> Option<PathBuf> {
    #[cfg(debug_assertions)]
    if let Some(path) = std::env::var_os("GLED_TEST_PICK_FILE") {
        let path = PathBuf::from(path);
        tracing::debug!("Picking {} instead of asking", path.display());
        return Some(path);
    }
    let dialog = rfd::AsyncFileDialog::new()
        .add_filter(filter.name, filter.extensions)
        .pick_file();
    pollster::block_on(dialog).map(|file| file.path().to_path_buf())
}

#[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");
    }

    /// Regression: a `load` that panicked sent nothing, and the chooser's
    /// buttons stayed disabled for the rest of the session.
    #[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());
    }
}