gled-ui 2.34.0

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. A browser's
//! chooser answers asynchronously; it hands over the file's bytes, never a
//! path, so neither does the desktop's.
//!
//! 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::Path;

/// The kind of file a chooser offers.
#[derive(Debug, Clone, Copy)]
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"],
};

/// A chosen file: its name, to show and to tell its kind by, and its bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChosenFile {
    pub(crate) name: String,
    pub(crate) bytes: Vec<u8>,
}

impl ChosenFile {
    /// The file's extension, lower case; empty without one.
    pub(crate) fn extension(&self) -> String {
        Path::new(&self.name)
            .extension()
            .map_or(String::new(), |extension| {
                extension.to_string_lossy().to_ascii_lowercase()
            })
    }

    /// The file's name without its extension.
    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(),
        )
    }

    /// The file as text, such as an SVG.
    pub(crate) fn text(&self) -> Result<&str, String> {
        std::str::from_utf8(&self.bytes).map_err(|_| format!("{} is no text file", self.name))
    }
}

/// 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, or the browser's answer.
    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 on the desktop, once
    /// the browser has read it in a browser.
    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;
        }
    }

    /// 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.
/// A file that cannot be read is reported and counts as none.
#[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
        }
    }
}

/// The file debug builds pick instead of asking, from `GLED_TEST_PICK_FILE`.
#[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");
    }

    /// 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());
    }
}