crates/veilvoice-gui/src/dialog.rs
what this file is for · veilvoice-gui · 425 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Asking for a file without stopping the window.
//!
//! # The defect this exists to fix
//!
//! Every file picker in this application was opened with `rfd`'s **blocking**
//! API, straight from the frame that handled the click:
//!
//! ```ignore
//! if ui.button("choose file…").clicked() {
//! if let Some(path) = rfd::FileDialog::new().pick_file() { … }
//! }
//! ```
//!
//! `pick_file` does not return until the person has chosen a file or cancelled.
//! It is called from inside `update`, which is the render loop, so for as long
//! as that dialog is open **VeilVoice draws nothing at all**: the window does
//! not repaint, animations stop, the meters freeze, and dragging it leaves a
//! trail of stale pixels. Somebody browsing for a recording for thirty seconds
//! has a frozen application for thirty seconds.
//!
//! It is also the answer to "it lags when I select things", which is a real
//! report and an accurate one. There were seven of these.
//!
//! # How this is avoided, and the one platform where it cannot be
//!
//! The dialog runs on a thread of its own and the answer comes back down a
//! channel, which [`Pending::poll`] reads without waiting. `update` starts the
//! ask and returns immediately; the window keeps painting the whole time.
//!
//! **macOS is the exception, and it is not a shortcut.** `NSOpenPanel` must be
//! driven from the main thread; opening one anywhere else does not work, and
//! on some versions it does not fail politely either. So on macOS the ask is
//! made inline, exactly as before. That platform keeps the old behaviour
//! because the alternative is a picker that does not appear, and a frozen
//! window is better than no dialog.
//!
//! # In plain words
//!
//! When you click "choose a file", the box that opens used to freeze the rest
//! of VeilVoice until you picked something. Now it opens beside the
//! application and everything carries on running while you browse.
//!
//! On Apple computers it still waits, because macOS insists that file pickers
//! are opened by the main part of a program and there is no way around that
//! which actually works.
use std::path::PathBuf;
use std::sync::mpsc;
/// What is being asked for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Ask {
/// An existing file to open.
Open {
/// A named filter, as `(label, extensions)`. Empty for any file.
filter: Option<(String, Vec<String>)>,
},
/// A path to write to.
Save {
/// The name to offer.
suggested: String,
/// A named filter, as `(label, extensions)`.
filter: Option<(String, Vec<String>)>,
},
/// A directory.
Folder,
}
impl Ask {
/// An open dialog with no filter.
pub fn open() -> Self {
Self::Open { filter: None }
}
/// An open dialog restricted to these extensions.
pub fn open_filtered(label: &str, extensions: &[&str]) -> Self {
Self::Open {
filter: Some((
label.to_string(),
extensions.iter().map(|e| e.to_string()).collect(),
)),
}
}
/// A save dialog offering this name.
pub fn save(suggested: &str) -> Self {
Self::Save {
suggested: suggested.to_string(),
filter: None,
}
}
/// A save dialog offering this name, restricted to these extensions.
pub fn save_filtered(suggested: &str, label: &str, extensions: &[&str]) -> Self {
Self::Save {
suggested: suggested.to_string(),
filter: Some((
label.to_string(),
extensions.iter().map(|e| e.to_string()).collect(),
)),
}
}
/// Show it, here, now. Blocks until answered.
fn show(self) -> Option<PathBuf> {
let with_filter = |mut dialog: rfd::FileDialog,
filter: Option<(String, Vec<String>)>|
-> rfd::FileDialog {
if let Some((label, extensions)) = filter {
let refs: Vec<&str> = extensions.iter().map(String::as_str).collect();
dialog = dialog.add_filter(&label, &refs);
}
dialog
};
match self {
Ask::Open { filter } => with_filter(rfd::FileDialog::new(), filter).pick_file(),
Ask::Save { suggested, filter } => {
with_filter(rfd::FileDialog::new().set_file_name(&suggested), filter).save_file()
}
Ask::Folder => rfd::FileDialog::new().pick_folder(),
}
}
}
/// Whether a platform file panel can be opened from here at all.
///
/// # Two places where it cannot, and what each one did
///
/// **A test binary.** There is no window for a panel to belong to and, on a
/// build machine, usually no desktop either. Opening one there was not a
/// harmless no-operation: on macOS `rfd` panics outright rather than returning
/// nothing, and on Windows the dialog thread outlived the test harness and took
/// the process down with an access violation after every test had passed. Both
/// were failing builds on those two platforms and neither said anything about
/// VeilVoice.
///
/// **Anywhere but the main thread on macOS.** `NSOpenPanel` must be driven from
/// the main thread; asking from another one is not slow or unreliable, it is
/// impossible, and `rfd` says so by panicking. A panic here would take down an
/// application in the middle of somebody's recording, so the ask is refused and
/// reads as a cancel instead.
///
/// The main thread is recognised by its name, which is what Rust calls it and
/// is the only way to ask without `unsafe`. A thread deliberately named `main`
/// would fool it, and that is a thing this codebase does not do.
fn can_show() -> bool {
if cfg!(test) {
return false;
}
!cfg!(target_os = "macos") || std::thread::current().name() == Some("main")
}
/// A file dialog that is open, or has just been answered.
///
/// Held by whichever panel asked. `None` inside means nothing is being asked.
#[derive(Default)]
pub struct Pending {
waiting: Option<mpsc::Receiver<Option<PathBuf>>>,
}
impl Pending {
/// Nothing is being asked.
pub fn new() -> Self {
Self::default()
}
/// Whether a dialog is open right now.
///
/// A caller should disable the button that started it: two pickers open at
/// once is two answers arriving for one question, and the second would
/// overwrite the first with no way to tell which was which.
pub fn is_open(&self) -> bool {
self.waiting.is_some()
}
/// Start asking. Does nothing if a dialog is already open.
pub fn start(&mut self, ask: Ask) {
if self.waiting.is_some() {
return;
}
let (tx, rx) = mpsc::channel();
self.waiting = Some(rx);
// Nothing to open a panel on. The sender is dropped, so the ask reads
// as open now and as cancelled when it is next polled, which is the
// truth: nothing was shown, so nothing was chosen.
if !can_show() {
return;
}
// macOS requires the panel on the main thread. See the module note:
// this is the platform's rule, not a corner being cut.
if cfg!(target_os = "macos") {
let _ = tx.send(ask.show());
return;
}
std::thread::spawn(move || {
// The receiver is dropped if the panel that asked goes away. That
// is ordinary -- the answer simply has nowhere to go -- so the
// failure is discarded rather than unwrapped.
let _ = tx.send(ask.show());
});
}
/// The answer, if one has arrived. Never waits.
///
/// Returns `Some(None)` when the dialog was cancelled, which is a real
/// answer and different from "still open".
pub fn poll(&mut self) -> Option<Option<PathBuf>> {
let receiver = self.waiting.as_ref()?;
match receiver.try_recv() {
Ok(answer) => {
self.waiting = None;
Some(answer)
}
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => {
// The thread went away without answering. Treated as a cancel:
// leaving it "open" for ever would disable the button that
// started it and there would be no way back.
self.waiting = None;
Some(None)
}
}
}
/// The answer, if one arrived and was a path.
pub fn taken(&mut self) -> Option<PathBuf> {
self.poll().flatten()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_pending_is_not_open_and_has_no_answer() {
let mut pending = Pending::new();
assert!(!pending.is_open());
assert_eq!(pending.poll(), None);
assert_eq!(pending.taken(), None);
}
/// A dropped sender is a cancel, not a dialog that stays open for ever.
/// Otherwise the button that started it is disabled with no way back.
#[test]
fn a_thread_that_never_answers_is_treated_as_a_cancel() {
let (tx, rx) = mpsc::channel();
let mut pending = Pending { waiting: Some(rx) };
assert!(pending.is_open());
drop(tx);
assert_eq!(pending.poll(), Some(None));
assert!(!pending.is_open(), "the button has to become usable again");
}
/// Cancelling is an answer, and a different one from "still waiting".
#[test]
fn cancelling_is_reported_as_an_answer_rather_than_as_silence() {
let (tx, rx) = mpsc::channel();
let mut pending = Pending { waiting: Some(rx) };
assert_eq!(pending.poll(), None, "nothing sent yet");
tx.send(None).unwrap();
assert_eq!(pending.poll(), Some(None), "cancelled");
assert!(!pending.is_open());
}
#[test]
fn a_chosen_path_comes_back_once_and_closes_the_dialog() {
let (tx, rx) = mpsc::channel();
let mut pending = Pending { waiting: Some(rx) };
tx.send(Some(PathBuf::from("/tmp/a.wav"))).unwrap();
assert_eq!(pending.taken(), Some(PathBuf::from("/tmp/a.wav")));
assert!(!pending.is_open());
assert_eq!(pending.taken(), None, "and only once");
}
/// Nothing may reach the platform panel from a test binary. This is the
/// guard itself: `can_show` is what `start` asks, and a change that made it
/// answer yes here would put a native dialog back into every build machine
/// on macOS and Windows, which is how those two platforms were failing.
#[test]
fn no_platform_panel_is_opened_from_a_test_binary() {
assert!(!can_show(), "a test build must not open a native dialog");
}
/// An ask that cannot be shown still reads as open, and then as cancelled.
/// Reporting it as never started would leave the caller believing the
/// button had done nothing at all.
#[test]
fn an_ask_that_cannot_be_shown_is_open_and_then_cancelled() {
let mut pending = Pending::new();
pending.start(Ask::Folder);
assert!(pending.is_open());
assert_eq!(pending.poll(), Some(None));
assert!(!pending.is_open());
}
/// Two asks at once would be two answers to one question, and the second
/// would quietly overwrite the first.
#[test]
fn starting_a_second_dialog_while_one_is_open_does_nothing() {
let (tx, rx) = mpsc::channel();
let mut pending = Pending { waiting: Some(rx) };
pending.start(Ask::open());
assert!(pending.is_open());
tx.send(Some(PathBuf::from("first"))).unwrap();
assert_eq!(pending.taken(), Some(PathBuf::from("first")));
}
/// The builders carry what they were given, so a caller's filter is not
/// silently dropped on the way to the dialog.
#[test]
fn the_asks_carry_their_filters_and_names() {
match Ask::open_filtered("audio", &["wav", "mp3"]) {
Ask::Open {
filter: Some((label, extensions)),
} => {
assert_eq!(label, "audio");
assert_eq!(extensions, vec!["wav", "mp3"]);
}
other => panic!("{other:?}"),
}
match Ask::save("out.wav") {
Ask::Save { suggested, filter } => {
assert_eq!(suggested, "out.wav");
assert_eq!(filter, None);
}
other => panic!("{other:?}"),
}
assert_eq!(Ask::open(), Ask::Open { filter: None });
}
/// **The point of the module.** No panel may call the blocking picker
/// directly any more; every ask goes through here, which is the only place
/// that knows about threads and about macOS.
#[test]
fn nothing_outside_this_module_opens_a_dialog_on_the_render_thread() {
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
for entry in std::fs::read_dir(&here).expect("src/") {
let path = entry.expect("entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if name == "dialog.rs" {
continue;
}
let source = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
for (number, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("///") {
continue;
}
if trimmed.contains("rfd::FileDialog") {
offenders.push(format!("{name}:{}: {}", number + 1, line.trim()));
}
}
}
assert!(
offenders.is_empty(),
"these open a file dialog on the render loop, which freezes the window \
until it is answered:\n{}",
offenders.join("\n")
);
}
}
#[cfg(test)]
mod house_style {
/// **No dashes in anything the application shows.**
///
/// Not the em dash, and not the doubled hyphen this project used in its
/// place. A dash is almost always a colon, a semicolon, a full stop or a
/// pair of brackets wearing a disguise, and the sentence reads better once
/// it has been made to choose.
///
/// Checked across the whole crate rather than in one file, because the
/// strings a reader sees are spread through every panel. Comments are
/// exempt: this is about the interface, and a sweep of the rest of the
/// repository is a separate decision that has not been taken.
#[test]
fn no_dashes_in_anything_the_interface_says() {
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
for entry in std::fs::read_dir(&here).expect("src/") {
let path = entry.expect("entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let source = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
// Code only, and only outside the tests: a test may legitimately
// quote a dash in order to assert something about it, and this
// very test contains two.
let body = source.split("#[cfg(test)]").next().unwrap_or("");
for (number, line) in body.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
// The escape as well as the character. One was written as a
// unicode escape inside a format string and this test walked
// straight past it: the guard fooled by the same trick twice,
// once by its own comments and once by an escape sequence.
let escaped = concat!("\\u{", "2014}");
if line.contains('\u{2014}') || line.contains(" -- ") || line.contains(escaped) {
offenders.push(format!("{name}:{}: {}", number + 1, line.trim()));
}
}
}
assert!(
offenders.is_empty(),
"these carry a dash into the interface; rewrite the sentence with a \
colon, a semicolon, a full stop or brackets:\n{}",
offenders.join("\n")
);
}
}