use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::{env, fs};
use figures::units::Lp;
use parking_lot::Mutex;
use crate::styles::components::{PrimaryColor, WidgetBackground};
use crate::styles::DynamicComponent;
use crate::value::{Destination, Dynamic, Source};
use crate::widget::{MakeWidget, OnceCallback, SharedCallback, WidgetList};
use crate::widgets::button::{ButtonKind, ClickCounter};
use crate::widgets::input::InputValue;
use crate::widgets::layers::{Modal, ModalHandle, ModalTarget};
use crate::widgets::Custom;
use crate::ModifiersExt;
#[cfg(feature = "native-dialogs")]
mod native;
#[derive(Clone, Debug)]
struct MessageButtons {
kind: MessageButtonsKind,
affirmative: MessageButton,
negative: Option<MessageButton>,
cancel: Option<MessageButton>,
}
#[derive(Clone, Debug, Copy)]
enum MessageButtonsKind {
YesNo,
OkCancel,
}
#[derive(Clone, Debug, Default)]
pub struct MessageButton {
callback: OptionalCallback,
caption: String,
}
impl MessageButton {
pub fn custom<F>(caption: impl Into<String>, on_click: F) -> Self
where
F: FnOnce() + Send + 'static,
{
Self {
callback: OptionalCallback(Arc::new(Mutex::new(Some(OnceCallback::new(move |()| {
on_click();
}))))),
caption: caption.into(),
}
}
}
impl From<String> for MessageButton {
fn from(value: String) -> Self {
Self {
callback: OptionalCallback::default(),
caption: value,
}
}
}
impl From<&'_ String> for MessageButton {
fn from(value: &'_ String) -> Self {
Self::from(value.clone())
}
}
impl From<&'_ str> for MessageButton {
fn from(value: &'_ str) -> Self {
Self::from(value.to_string())
}
}
impl<F> From<F> for MessageButton
where
F: FnOnce() + Send + 'static,
{
fn from(value: F) -> Self {
Self::custom(String::new(), value)
}
}
impl From<()> for MessageButton {
fn from(_value: ()) -> Self {
Self::default()
}
}
#[derive(Clone, Debug, Default)]
struct OptionalCallback(Arc<Mutex<Option<OnceCallback>>>);
impl OptionalCallback {
fn invoke(&self) {
if let Some(callback) = self.0.lock().take() {
callback.invoke(());
}
}
}
#[derive(Default, Clone, Eq, PartialEq, Copy, Debug)]
enum MessageLevel {
Error,
Warning,
#[default]
Info,
}
pub enum Undecided {}
pub enum OkCancel {}
pub enum YesNoCancel {}
#[must_use]
pub struct MessageBoxBuilder<Kind>(MessageBox, PhantomData<Kind>);
impl<Kind> MessageBoxBuilder<Kind> {
fn new(message: MessageBox) -> MessageBoxBuilder<Kind> {
Self(message, PhantomData)
}
pub fn with_explanation(mut self, explanation: impl Into<String>) -> Self {
self.0.description = explanation.into();
self
}
pub fn warning(mut self) -> Self {
self.0.level = MessageLevel::Warning;
self
}
pub fn error(mut self) -> Self {
self.0.level = MessageLevel::Error;
self
}
pub fn with_cancel(mut self, cancel: impl Into<MessageButton>) -> Self {
self.0.buttons.cancel = Some(cancel.into());
self
}
#[must_use]
pub fn finish(self) -> MessageBox {
self.0
}
}
impl MessageBoxBuilder<Undecided> {
pub fn with_yes(self, yes: impl Into<MessageButton>) -> MessageBoxBuilder<YesNoCancel> {
let Self(mut message, _) = self;
message.buttons.kind = MessageButtonsKind::YesNo;
message.buttons.affirmative = yes.into();
MessageBoxBuilder(message, PhantomData)
}
pub fn with_ok(self, ok: impl Into<MessageButton>) -> MessageBoxBuilder<OkCancel> {
let Self(mut message, _) = self;
message.buttons.affirmative = ok.into();
MessageBoxBuilder(message, PhantomData)
}
}
impl MessageBoxBuilder<YesNoCancel> {
pub fn with_no(mut self, no: impl Into<MessageButton>) -> Self {
self.0.buttons.negative = Some(no.into());
self
}
}
impl MessageBoxBuilder<OkCancel> {}
#[derive(Debug, Clone)]
pub struct MessageBox {
level: MessageLevel,
title: String,
description: String,
buttons: MessageButtons,
}
impl MessageBox {
fn new(title: String, kind: MessageButtonsKind) -> Self {
Self {
level: MessageLevel::default(),
title,
description: String::default(),
buttons: MessageButtons {
kind,
affirmative: MessageButton::default(),
negative: None,
cancel: None,
},
}
}
pub fn build(message: impl Into<String>) -> MessageBoxBuilder<Undecided> {
MessageBoxBuilder::new(Self::new(message.into(), MessageButtonsKind::OkCancel))
}
#[must_use]
pub fn message(message: impl Into<String>) -> Self {
Self::build(message).finish()
}
#[must_use]
pub fn with_explanation(mut self, explanation: impl Into<String>) -> Self {
self.description = explanation.into();
self
}
#[must_use]
pub fn warning(mut self) -> Self {
self.level = MessageLevel::Warning;
self
}
#[must_use]
pub fn error(mut self) -> Self {
self.level = MessageLevel::Error;
self
}
#[must_use]
pub fn with_cancel(mut self, cancel: impl Into<MessageButton>) -> Self {
self.buttons.cancel = Some(cancel.into());
self
}
pub fn open(&self, open_in: &impl OpenMessageBox) {
open_in.open_message_box(self);
}
}
pub trait OpenMessageBox {
fn open_message_box(&self, message: &MessageBox);
}
fn coalesce_empty<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.is_empty() {
s2
} else {
s1
}
}
impl<T> OpenMessageBox for T
where
T: ModalTarget,
{
fn open_message_box(&self, message: &MessageBox) {
let handle = self.new_handle();
let dialog = handle.build_dialog(
message
.title
.as_str()
.h5()
.and(message.description.as_str())
.into_rows(),
);
let (default_affirmative, default_negative) = match &message.buttons.kind {
MessageButtonsKind::OkCancel => ("OK", None),
MessageButtonsKind::YesNo => ("Yes", Some("No")),
};
let on_ok = message.buttons.affirmative.callback.clone();
let mut dialog = dialog.with_default_button(
coalesce_empty(&message.buttons.affirmative.caption, default_affirmative),
move || on_ok.invoke(),
);
if let (Some(negative), Some(default_negative)) =
(&message.buttons.negative, default_negative)
{
let on_negative = negative.callback.clone();
dialog = dialog.with_button(
coalesce_empty(&negative.caption, default_negative),
move || {
on_negative.invoke();
},
);
}
if let Some(cancel) = &message.buttons.cancel {
let on_cancel = cancel.callback.clone();
dialog
.with_cancel_button(coalesce_empty(&cancel.caption, "Cancel"), move || {
on_cancel.invoke();
})
.show();
} else {
dialog.show();
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FilePicker {
types: Vec<FileType>,
directory: Option<PathBuf>,
file_name: String,
title: String,
can_create_directories: Option<bool>,
}
impl Default for FilePicker {
fn default() -> Self {
Self::new()
}
}
impl FilePicker {
#[must_use]
pub const fn new() -> Self {
Self {
types: Vec::new(),
directory: None,
file_name: String::new(),
title: String::new(),
can_create_directories: None,
}
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn with_file_name(mut self, file_name: impl Into<String>) -> Self {
self.file_name = file_name.into();
self
}
#[must_use]
pub fn allowing_directory_creation(mut self, allowed: bool) -> Self {
self.can_create_directories = Some(allowed);
self
}
#[must_use]
pub fn with_types<Type>(mut self, types: impl IntoIterator<Item = Type>) -> Self
where
Type: Into<FileType>,
{
self.types = types.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_initial_directory(mut self, directory: impl AsRef<Path>) -> Self {
self.directory = Some(directory.as_ref().to_path_buf());
self
}
pub fn pick_file<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
pick_in.pick_file(self, on_dismiss);
}
pub fn save_file<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
pick_in.save_file(self, on_dismiss);
}
pub fn pick_files<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
pick_in.pick_files(self, on_dismiss);
}
pub fn pick_folder<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
pick_in.pick_folder(self, on_dismiss);
}
pub fn pick_folders<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
pick_in.pick_folders(self, on_dismiss);
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FileType {
name: String,
extensions: Vec<String>,
}
impl FileType {
pub fn new<Extension>(
name: impl Into<String>,
extensions: impl IntoIterator<Item = Extension>,
) -> Self
where
Extension: Into<String>,
{
Self {
name: name.into(),
extensions: extensions.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub fn matches(&self, path: &Path) -> bool {
let Some(extension) = path.extension() else {
return false;
};
self.extensions.iter().any(|test| **test == *extension)
}
}
impl<Name, Extension, const EXTENSIONS: usize> From<(Name, [Extension; EXTENSIONS])> for FileType
where
Name: Into<String>,
Extension: Into<String>,
{
fn from((name, extensions): (Name, [Extension; EXTENSIONS])) -> Self {
Self::new(name, extensions)
}
}
pub trait PickFile {
fn pick_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
fn save_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
fn pick_files<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static;
fn pick_folder<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
fn pick_folders<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static;
}
#[derive(Clone, Copy, Debug)]
enum ModeKind {
File,
SaveFile,
Files,
Folder,
Folders,
}
impl ModeKind {
const fn is_multiple(self) -> bool {
matches!(self, ModeKind::Files | ModeKind::Folders)
}
const fn is_file(self) -> bool {
matches!(self, ModeKind::File | ModeKind::Files | ModeKind::SaveFile)
}
}
enum ModeCallback {
Single(OnceCallback<Option<PathBuf>>),
Multiple(OnceCallback<Option<Vec<PathBuf>>>),
}
enum Mode {
File(OnceCallback<Option<PathBuf>>),
SaveFile(OnceCallback<Option<PathBuf>>),
Files(OnceCallback<Option<Vec<PathBuf>>>),
Folder(OnceCallback<Option<PathBuf>>),
Folders(OnceCallback<Option<Vec<PathBuf>>>),
}
impl Mode {
fn file<Callback>(callback: Callback) -> Self
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
Self::File(OnceCallback::new(callback))
}
fn save_file<Callback>(callback: Callback) -> Self
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
Self::SaveFile(OnceCallback::new(callback))
}
fn files<Callback>(callback: Callback) -> Self
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
Self::Files(OnceCallback::new(callback))
}
fn folder<Callback>(callback: Callback) -> Self
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
Self::Folder(OnceCallback::new(callback))
}
fn folders<Callback>(callback: Callback) -> Self
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
Self::Folders(OnceCallback::new(callback))
}
fn into_callback(self) -> ModeCallback {
match self {
Mode::File(once_callback)
| Mode::SaveFile(once_callback)
| Mode::Folder(once_callback) => ModeCallback::Single(once_callback),
Mode::Files(once_callback) | Mode::Folders(once_callback) => {
ModeCallback::Multiple(once_callback)
}
}
}
fn kind(&self) -> ModeKind {
match self {
Mode::File(_) => ModeKind::File,
Mode::SaveFile(_) => ModeKind::SaveFile,
Mode::Files(_) => ModeKind::Files,
Mode::Folder(_) => ModeKind::Folder,
Mode::Folders(_) => ModeKind::Folders,
}
}
}
impl PickFile for Modal {
fn pick_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
let modal = self.clone();
let handle = self.new_handle();
handle.present(FilePickerWidget {
handle: handle.clone(),
picker: picker.clone(),
mode: Mode::file(move |result| {
modal.dismiss();
callback(result);
}),
});
}
fn save_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
let modal = self.clone();
let handle = self.new_handle();
handle.present(FilePickerWidget {
handle: handle.clone(),
picker: picker.clone(),
mode: Mode::save_file(move |result| {
modal.dismiss();
callback(result);
}),
});
}
fn pick_files<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
let modal = self.clone();
let handle = self.new_handle();
handle.present(FilePickerWidget {
handle: handle.clone(),
picker: picker.clone(),
mode: Mode::files(move |result| {
modal.dismiss();
callback(result);
}),
});
}
fn pick_folder<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
let modal = self.clone();
let handle = self.new_handle();
handle.present(FilePickerWidget {
handle: handle.clone(),
picker: picker.clone(),
mode: Mode::folder(move |result| {
modal.dismiss();
callback(result);
}),
});
}
fn pick_folders<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
let modal = self.clone();
let handle = self.new_handle();
handle.present(FilePickerWidget {
handle: handle.clone(),
picker: picker.clone(),
mode: Mode::folders(move |result| {
modal.dismiss();
callback(result);
}),
});
}
}
struct FilePickerWidget {
handle: ModalHandle,
picker: FilePicker,
mode: Mode,
}
impl MakeWidget for FilePickerWidget {
#[allow(clippy::too_many_lines)]
fn make_widget(self) -> crate::widget::WidgetInstance {
let kind = self.mode.kind();
let callback = Arc::new(Mutex::new(Some(self.mode.into_callback())));
let title = if self.picker.title.is_empty() {
match kind {
ModeKind::File => "Select a file",
ModeKind::SaveFile => "Save file",
ModeKind::Files => "Select one or more files",
ModeKind::Folder => "Select a folder",
ModeKind::Folders => "Select one or more folders",
}
} else {
&self.picker.title
};
let caption = match kind {
ModeKind::File | ModeKind::Files | ModeKind::Folder | ModeKind::Folders => "Select",
ModeKind::SaveFile => "Save",
};
let chosen_paths = Dynamic::<Vec<PathBuf>>::default();
let confirm_enabled = chosen_paths.map_each(move |paths| {
!paths.is_empty() && paths.iter().all(|p| p.is_file() == kind.is_file())
});
let browsing_directory = Dynamic::new(
self.picker
.directory
.or_else(|| env::current_dir().ok())
.or_else(|| {
env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf))
})
.unwrap_or_default(),
);
let current_directory_files = browsing_directory.map_each(|dir| {
let mut children = Vec::new();
match fs::read_dir(dir) {
Ok(entries) => {
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name().to_string_lossy().into_owned();
children.push((name, entry.path()));
}
}
Err(err) => return Err(format!("Error reading directory: {err}")),
}
Ok(children)
});
let multi_click_threshold = Dynamic::new(Duration::from_millis(500));
let choose_file = SharedCallback::new({
let chosen_paths = chosen_paths.clone();
let callback = callback.clone();
let types = self.picker.types.clone();
move |()| {
let chosen_paths = chosen_paths.get();
let installed_callback = callback.lock().take();
match installed_callback {
Some(ModeCallback::Single(cb)) => {
let mut chosen_path = chosen_paths.into_iter().next();
if let Some(chosen_path_mut) = &mut chosen_path {
if matches!(kind, ModeKind::SaveFile) {
if !types.iter().any(|t| t.matches(chosen_path_mut)) {
if let Some(extension) =
types.first().and_then(|ty| ty.extensions.first())
{
let path = chosen_path_mut.as_mut_os_string();
path.push(".");
path.push(extension);
}
}
if chosen_path_mut.exists() {
*callback.lock() = Some(ModeCallback::Single(cb));
let name = chosen_path_mut
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
MessageBox::build("Confirm Overwrite")
.with_explanation(
format!("A file named \"{name}\" already exists. Do you want to overwrite the existing file?")
)
.with_yes({
let callback = callback.clone();
move || {
let Some(ModeCallback::Single(cb)) = callback.lock().take() else {
unreachable!("re-set above");
};
cb.invoke(chosen_path.clone());
}
})
.with_no(())
.finish()
.open(&self.handle);
return;
}
}
}
cb.invoke(chosen_path);
}
Some(ModeCallback::Multiple(cb)) => {
cb.invoke(Some(chosen_paths));
}
None => {}
}
}
});
let file_list = current_directory_files
.map_each({
let chosen_paths = chosen_paths.clone();
let allowed_types = self.picker.types.clone();
let multi_click_threshold = multi_click_threshold.clone();
let browsing_directory = browsing_directory.clone();
let choose_file = choose_file.clone();
move |files| match files {
Ok(files) => files
.iter()
.filter(|(name, path)| {
!name.starts_with('.') && path.is_dir()
|| (kind.is_file()
&& allowed_types.iter().all(|ty| ty.matches(path)))
})
.map({
|(name, full_path)| {
let selected = chosen_paths.map_each({
let full_path = full_path.clone();
move |chosen| chosen.contains(&full_path)
});
name.align_left()
.into_button()
.kind(ButtonKind::Transparent)
.on_click({
let mut counter =
ClickCounter::new(multi_click_threshold.clone(), {
let browsing_directory = browsing_directory.clone();
let choose_file = choose_file.clone();
let full_path = full_path.clone();
move |click_count, _| {
if click_count == 2 {
if full_path.is_dir() {
browsing_directory
.set(full_path.clone());
} else {
choose_file.invoke(());
}
}
}
})
.with_maximum(2);
let chosen_paths = chosen_paths.clone();
let full_path = full_path.clone();
move |click| {
if kind.is_multiple()
&& click.map_or(false, |click| {
click.modifiers.state().primary()
})
{
let mut paths = chosen_paths.lock();
let mut removed = false;
paths.retain(|p| {
if p == &full_path {
removed = true;
false
} else {
true
}
});
if !removed {
paths.push(full_path.clone());
}
} else {
let mut paths = chosen_paths.lock();
paths.clear();
paths.push(full_path.clone());
}
counter.click(click);
}
})
.with_dynamic(
&WidgetBackground,
DynamicComponent::new(move |ctx| {
if selected.get_tracking_invalidate(ctx) {
Some(ctx.get(&PrimaryColor).into())
} else {
None
}
}),
)
}
})
.collect::<WidgetList>()
.into_rows()
.make_widget(),
Err(err) => err.make_widget(),
}
})
.vertical_scroll()
.expand();
let file_ui = if matches!(kind, ModeKind::SaveFile) {
let name = Dynamic::<String>::default();
let name_weak = name.downgrade();
name.set_source(chosen_paths.for_each(move |paths| {
if paths.len() == 1 && paths[0].is_file() {
if let Some(path_name) = paths[0]
.file_name()
.map(|name| name.to_string_lossy().into_owned())
{
if let Some(name) = name_weak.upgrade() {
name.set(path_name);
}
}
}
}));
let browsing_directory = browsing_directory.clone();
let chosen_paths = chosen_paths.clone();
name.for_each(move |name| {
let Ok(mut paths) = chosen_paths.try_lock() else {
return;
};
paths.clear();
paths.push(browsing_directory.get().join(name));
})
.persist();
file_list.and(name.into_input()).into_rows().make_widget()
} else {
file_list.make_widget()
};
let click_duration_probe = Custom::empty().on_mounted({
move |ctx| multi_click_threshold.set(ctx.cushy().multi_click_threshold())
});
title
.and(click_duration_probe)
.into_columns()
.and(file_ui.width(Lp::inches(6)).height(Lp::inches(4)))
.and(
"Cancel"
.into_button()
.on_click({
let mode = callback.clone();
move |_| match mode.lock().take() {
Some(ModeCallback::Single(cb)) => cb.invoke(None),
Some(ModeCallback::Multiple(cb)) => {
cb.invoke(None);
}
None => {}
}
})
.into_escape()
.and(
caption
.into_button()
.on_click(move |_| choose_file.invoke(()))
.into_default()
.with_enabled(confirm_enabled),
)
.into_columns()
.align_right(),
)
.into_rows()
.contain()
.make_widget()
}
}