diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a1095cc..e98da800f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 widget. - A rare deadlock occurring when multiple threads were racing to execute `Dynamic` change callbacks has been fixed. +- `Stack` no longer unwraps a `Resize` child if the resize widget is resizing in + the direction opposite of the Stack's orientation. ### Added @@ -187,6 +189,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `impl FnMut(Duration) -> ControlFlow + Send + Sync + 'static` - `SharedCallback>` - `SharedCallback` +- `Cushy::multi_click_threshold`/`Cushy::set_multi_click_threshold` provide + access to the setting used by Cushy widgets to detect whether two clicks are + related. +- `ClickCounter` is a new helper that simplifies handling actions based on how + many sequential clicks were observed. +- `Dimension::is_unbounded` is a new helper that returns true if neither the + start or end is bounded. +- `&String` and `Cow<'_, str>` now implement `MakeWidget`. +- `MessageBox` displays a prompt to the user in a `Modal` layer, above a + `WindowHandle`, or in an `App`. When shown above a window or app, the `rfd` + crate is used to use the native system dialogs. +- `FilePicker` displays a file picker to the user in a `Modal` layer, above a + `WindowHandle`, or in an `App`. When shown above a window or app, the `rfd` + crate is used to use the native system dialogs. + + The `FilePicker` type supports these modes of operation: + + - Saving a file + - Choosing a single file + - Choosing one or more files + - Choosing a single folder/directory + - Choosing one or more folders/directories [139]: https://github.com/khonsulabs/cushy/issues/139 diff --git a/examples/file-picker.rs b/examples/file-picker.rs new file mode 100644 index 000000000..87e35cf1a --- /dev/null +++ b/examples/file-picker.rs @@ -0,0 +1,150 @@ +use std::path::PathBuf; + +use cushy::dialog::{FilePicker, PickFile}; +use cushy::value::{Destination, Dynamic, Source}; +use cushy::widget::{MakeWidget, MakeWidgetList}; +use cushy::widgets::button::ButtonClick; +use cushy::widgets::checkbox::Checkable; +use cushy::widgets::layers::Modal; +use cushy::window::{PendingWindow, WindowHandle}; +use cushy::{App, Open}; + +#[cushy::main] +fn main(app: &mut App) -> cushy::Result { + let modal = Modal::new(); + let pending = PendingWindow::default(); + let window = pending.handle(); + let chosen_paths = Dynamic::>::default(); + let picker_mode = Dynamic::default(); + let pick_multiple = Dynamic::new(false); + let results = chosen_paths.map_each(|paths| { + if paths.is_empty() { + "None".make_widget() + } else { + paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .into_rows() + .make_widget() + } + }); + + pending + .with_root( + picker_mode + .new_radio(PickerMode::SaveFile, "Save File") + .and(picker_mode.new_radio(PickerMode::PickFile, "Pick File")) + .and(picker_mode.new_radio(PickerMode::PickFolder, "Pick Folder")) + .into_columns() + .and(pick_multiple.to_checkbox("Select Multiple").with_enabled( + picker_mode.map_each(|kind| !matches!(kind, PickerMode::SaveFile)), + )) + .and(picker_buttons( + &picker_mode, + &pick_multiple, + app, + &window, + &modal, + &chosen_paths, + )) + .and("Result:") + .and(results) + .into_rows() + .centered() + .vertical_scroll() + .expand() + .and(modal) + .into_layers(), + ) + .open(app)?; + Ok(()) +} + +#[derive(Default, Clone, Copy, Eq, PartialEq, Debug)] +enum PickerMode { + #[default] + SaveFile, + PickFile, + PickFolder, +} + +fn file_picker() -> FilePicker { + FilePicker::new() + .with_title("Pick a Rust source file") + .with_types([("Rust Source", ["rs"])]) +} + +fn display_single_result( + chosen_paths: &Dynamic>, +) -> impl FnMut(Option) + Send + 'static { + let chosen_paths = chosen_paths.clone(); + move |path| { + chosen_paths.set(path.into_iter().collect()); + } +} + +fn display_multiple_results( + chosen_paths: &Dynamic>, +) -> impl FnMut(Option>) + Send + 'static { + let chosen_paths = chosen_paths.clone(); + move |path| { + chosen_paths.set(path.into_iter().flatten().collect()); + } +} + +fn picker_buttons( + mode: &Dynamic, + pick_multiple: &Dynamic, + app: &App, + window: &WindowHandle, + modal: &Modal, + chosen_paths: &Dynamic>, +) -> impl MakeWidget { + "Show in Modal layer" + .into_button() + .on_click(show_picker_in(modal, chosen_paths, mode, pick_multiple)) + .and("Show above window".into_button().on_click(show_picker_in( + window, + chosen_paths, + mode, + pick_multiple, + ))) + .and("Show in app".into_button().on_click(show_picker_in( + app, + chosen_paths, + mode, + pick_multiple, + ))) + .into_rows() +} + +fn show_picker_in( + target: &(impl PickFile + Clone + Send + 'static), + chosen_paths: &Dynamic>, + mode: &Dynamic, + pick_multiple: &Dynamic, +) -> impl FnMut(Option) + Send + 'static { + let target = target.clone(); + let chosen_paths = chosen_paths.clone(); + let mode = mode.clone(); + let pick_multiple = pick_multiple.clone(); + move |_| { + match mode.get() { + PickerMode::SaveFile => { + file_picker().save_file(&target, display_single_result(&chosen_paths)) + } + PickerMode::PickFile if pick_multiple.get() => { + file_picker().pick_files(&target, display_multiple_results(&chosen_paths)) + } + PickerMode::PickFile => { + file_picker().pick_file(&target, display_single_result(&chosen_paths)) + } + PickerMode::PickFolder if pick_multiple.get() => { + file_picker().pick_folders(&target, display_multiple_results(&chosen_paths)) + } + PickerMode::PickFolder => { + file_picker().pick_folder(&target, display_single_result(&chosen_paths)) + } + }; + } +} diff --git a/examples/messagebox.rs b/examples/message-box.rs similarity index 100% rename from examples/messagebox.rs rename to examples/message-box.rs diff --git a/src/app.rs b/src/app.rs index 32be2b192..33751d01f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::marker::PhantomData; use std::process::exit; use std::sync::Arc; use std::thread; +use std::time::Duration; use arboard::Clipboard; use kludgine::app::winit::error::EventLoopError; @@ -313,11 +314,16 @@ pub struct RuntimeGuard<'a>(Box + 'a>); trait BoxableGuard<'a> {} impl<'a, T> BoxableGuard<'a> for T {} +struct AppSettings { + multi_click_threshold: Duration, +} + /// Shared resources for a GUI application. #[derive(Clone)] pub struct Cushy { pub(crate) clipboard: Option>>, pub(crate) fonts: FontCollection, + settings: Arc>, runtime: BoxedRuntime, } @@ -328,10 +334,26 @@ impl Cushy { .ok() .map(|clipboard| Arc::new(Mutex::new(clipboard))), fonts: FontCollection::default(), + settings: Arc::new(Mutex::new(AppSettings { + multi_click_threshold: Duration::from_millis(500), + })), runtime, } } + /// Returns the duration between two mouse clicks that should be allowed to + /// elapse for the clicks to be considered separate actions. + #[must_use] + pub fn multi_click_threshold(&self) -> Duration { + self.settings.lock().multi_click_threshold + } + + /// Sets the maximum time between sequential clicks that should be + /// considered the same action. + pub fn set_multi_click_threshold(&self, threshold: Duration) { + self.settings.lock().multi_click_threshold = threshold; + } + /// Returns a locked mutex guard to the OS's clipboard, if one was able to be /// initialized when the window opened. #[must_use] diff --git a/src/debug.rs b/src/debug.rs index 7f263ef04..1560f59d0 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -133,7 +133,7 @@ impl Drop for DebugContext { } } -trait Observable: Send + Sync { +trait Observable: Send { fn label(&self) -> &str; // fn alive(&self) -> bool; fn widget(&self) -> &WidgetInstance; diff --git a/src/dialog.rs b/src/dialog.rs index f54eb9934..6f77626a7 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -1,9 +1,23 @@ //! Modal dialogs such as message boxes and file pickers. use std::marker::PhantomData; - -use crate::widget::{MakeWidget, SharedCallback}; +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; +use crate::widgets::Custom; +use crate::ModifiersExt; #[cfg(feature = "native-dialogs")] mod native; @@ -323,3 +337,608 @@ impl OpenMessageBox for Modal { } } } + +/// A dialog that can pick one or more files or directories. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct FilePicker { + types: Vec, + directory: Option, + file_name: String, + title: String, + can_create_directories: Option, +} + +impl Default for FilePicker { + fn default() -> Self { + Self::new() + } +} + +impl FilePicker { + /// Returns a new file picker dialog. + #[must_use] + pub const fn new() -> Self { + Self { + types: Vec::new(), + directory: None, + file_name: String::new(), + title: String::new(), + can_create_directories: None, + } + } + + /// Sets the title of the dialog and returns self. + #[must_use] + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Sets the initial file name for the dialog and returns self. + #[must_use] + pub fn with_file_name(mut self, file_name: impl Into) -> Self { + self.file_name = file_name.into(); + self + } + + /// Enables directory creation within the dialog and returns self. + #[must_use] + pub fn allowing_directory_creation(mut self, allowed: bool) -> Self { + self.can_create_directories = Some(allowed); + self + } + + /// Adds the list of type filters to the dialog and returns self. + /// + /// These type filters are used for the dialog to only show related files + /// and restrict what extensions are allowed to be picked. + #[must_use] + pub fn with_types(mut self, types: impl IntoIterator) -> Self + where + Type: Into, + { + self.types = types.into_iter().map(Into::into).collect(); + self + } + + /// Sets the initial directory for the dialog and returns self. + #[must_use] + pub fn with_initial_directory(mut self, directory: impl AsRef) -> Self { + self.directory = Some(directory.as_ref().to_path_buf()); + self + } + + /// Shows a picker that selects a single file and invokes `on_dismiss` when + /// the dialog is dismissed. + pub fn pick_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.pick_file(self, on_dismiss); + } + + /// Shows a picker that creates a new file and invokes `on_dismiss` when the + /// dialog is dismissed. + pub fn save_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.save_file(self, on_dismiss); + } + + /// Shows a picker that selects one or more files and invokes `on_dismiss` + /// when the dialog is dismissed. + pub fn pick_files(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + pick_in.pick_files(self, on_dismiss); + } + + /// Shows a picker that selects a single folder/directory and invokes + /// `on_dismiss` when the dialog is dismissed. + pub fn pick_folder(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.pick_folder(self, on_dismiss); + } + + /// Shows a picker that selects one or more folders/directorys and invokes + /// `on_dismiss` when the dialog is dismissed. + pub fn pick_folders(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + pick_in.pick_folders(self, on_dismiss); + } +} + +/// A file type filter used in a [`FilePicker`]. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct FileType { + name: String, + extensions: Vec, +} + +impl FileType { + /// Returns a new file type from the given name and list of file extensions. + pub fn new( + name: impl Into, + extensions: impl IntoIterator, + ) -> Self + where + Extension: Into, + { + Self { + name: name.into(), + extensions: extensions.into_iter().map(Into::into).collect(), + } + } + + /// Returns true if the given path matches this file type's extensions. + #[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 From<(Name, [Extension; EXTENSIONS])> for FileType +where + Name: Into, + Extension: Into, +{ + fn from((name, extensions): (Name, [Extension; EXTENSIONS])) -> Self { + Self::new(name, extensions) + } +} + +/// Shows a [`FilePicker`] in a given mode. +pub trait PickFile { + /// Shows a picker that selects a single file and invokes `on_dismiss` when + /// the dialog is dismissed. + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that creates a new file and invokes `on_dismiss` when the + /// dialog is dismissed. + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that selects one or more files and invokes `on_dismiss` + /// when the dialog is dismissed. + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static; + /// Shows a picker that selects a single folder/directory and invokes + /// `on_dismiss` when the dialog is dismissed. + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that selects one or more folders/directorys and invokes + /// `on_dismiss` when the dialog is dismissed. + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + 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>), + Multiple(OnceCallback>>), +} + +enum Mode { + File(OnceCallback>), + SaveFile(OnceCallback>), + Files(OnceCallback>>), + Folder(OnceCallback>), + Folders(OnceCallback>>), +} + +impl Mode { + fn file(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::File(OnceCallback::new(callback)) + } + + fn save_file(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::SaveFile(OnceCallback::new(callback)) + } + + fn files(callback: Callback) -> Self + where + Callback: FnOnce(Option>) + Send + 'static, + { + Self::Files(OnceCallback::new(callback)) + } + + fn folder(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::Folder(OnceCallback::new(callback)) + } + + fn folders(callback: Callback) -> Self + where + Callback: FnOnce(Option>) + 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(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::file(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::save_file(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::files(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::folder(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::folders(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } +} + +struct FilePickerWidget { + 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::>::default(); + let confirm_enabled = chosen_paths.map_each(|paths| !paths.is_empty()); + + 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(); + match callback.lock().take() { + Some(ModeCallback::Single(cb)) => { + let mut chosen_path = chosen_paths.into_iter().next(); + if let Some(chosen_path) = &mut chosen_path { + if matches!(kind, ModeKind::SaveFile) + && !types.iter().any(|t| t.matches(chosen_path)) + { + if let Some(extension) = + types.first().and_then(|ty| ty.extensions.first()) + { + let path = chosen_path.as_mut_os_string(); + path.push("."); + path.push(extension); + } + } + } + + 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::() + .into_rows() + .make_widget(), + Err(err) => err.make_widget(), + } + }) + .vertical_scroll() + .expand(); + + let file_ui = if matches!(kind, ModeKind::SaveFile) { + let name = Dynamic::::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() + } +} diff --git a/src/dialog/native.rs b/src/dialog/native.rs index 5f2b2becf..7cad19e84 100644 --- a/src/dialog/native.rs +++ b/src/dialog/native.rs @@ -1,9 +1,11 @@ +use std::path::PathBuf; use std::thread; -use rfd::{MessageDialog, MessageDialogResult}; +use rfd::{FileDialog, MessageDialog, MessageDialogResult}; use super::{ - coalesce_empty, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, OpenMessageBox, + coalesce_empty, FilePicker, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, Mode, + OpenMessageBox, PickFile, }; use crate::window::WindowHandle; use crate::App; @@ -173,3 +175,150 @@ fn handle_message_result(result: &MessageDialogResult, buttons: &MessageButtons) } } } + +fn create_file_dialog(picker: FilePicker) -> FileDialog { + let mut dialog = FileDialog::new(); + + if !picker.title.is_empty() { + dialog = dialog.set_title(picker.title); + } + + if let Some(directory) = picker.directory { + dialog = dialog.set_directory(directory); + } + + if !picker.file_name.is_empty() { + dialog = dialog.set_file_name(picker.file_name); + } + + for ty in picker.types { + dialog = dialog.add_filter(ty.name, &ty.extensions); + } + + if let Some(can_create) = picker.can_create_directories { + dialog = dialog.set_can_create_directories(can_create); + } + dialog +} + +fn show_picker_in_window(window: &WindowHandle, picker: &FilePicker, mode: Mode) { + let picker = picker.clone(); + window.execute(move |context| { + // Get access to the winit handle from the window thread. + let winit = context.winit().cloned(); + // We can't utilize the window handle outside of the main thread + // with winit, so we now need to move execution to the event loop + // thread. + let Some(app) = context.app().cloned() else { + return; + }; + app.execute(move |_app| { + let mut dialog = create_file_dialog(picker); + + if let Some(winit) = winit { + dialog = dialog.set_parent(&winit); + } + + // Now that we've set the parent, we can move this to its own + // blocking thread to be shown. + thread::spawn(move || match mode { + Mode::File(on_dismiss) => on_dismiss.invoke(dialog.pick_file()), + Mode::SaveFile(on_dismiss) => on_dismiss.invoke(dialog.save_file()), + Mode::Files(on_dismiss) => on_dismiss.invoke(dialog.pick_files()), + Mode::Folder(on_dismiss) => on_dismiss.invoke(dialog.pick_folder()), + Mode::Folders(on_dismiss) => on_dismiss.invoke(dialog.pick_folders()), + }); + }); + }); +} + +impl PickFile for WindowHandle { + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::file(callback)); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::save_file(callback)); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::files(callback)); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::folder(callback)); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::folders(callback)); + } +} + +fn show_picker_in_app(app: &App, picker: &FilePicker, mode: Mode) { + let picker = picker.clone(); + app.execute(move |_| { + let dialog = create_file_dialog(picker); + + // Now that we've set the parent, we can move this to its own + // blocking thread to be shown. + thread::spawn(move || match mode { + Mode::File(on_dismiss) => on_dismiss.invoke(dialog.pick_file()), + Mode::SaveFile(on_dismiss) => on_dismiss.invoke(dialog.save_file()), + Mode::Files(on_dismiss) => on_dismiss.invoke(dialog.pick_files()), + Mode::Folder(on_dismiss) => on_dismiss.invoke(dialog.pick_folder()), + Mode::Folders(on_dismiss) => on_dismiss.invoke(dialog.pick_folders()), + }); + }); +} + +impl PickFile for App { + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::file(callback)); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::save_file(callback)); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::files(callback)); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::folder(callback)); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::folders(callback)); + } +} diff --git a/src/styles.rs b/src/styles.rs index 05a104816..6826d98a5 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -927,6 +927,12 @@ impl DimensionRange { Bound::Included(value) => Some(value), } } + + /// Returns true if this range has no bounds. + #[must_use] + pub const fn is_unbounded(&self) -> bool { + matches!(&self.start, Bound::Unbounded) && matches!(&self.end, Bound::Unbounded) + } } impl From for DimensionRange diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 653f3907a..7194ba0e2 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -1,5 +1,5 @@ //! A clickable, labeled button -use std::time::Duration; +use std::time::{Duration, Instant}; use figures::units::{Lp, Px, UPx}; use figures::{IntoSigned, Point, Rect, Round, ScreenScale, Size}; @@ -8,7 +8,7 @@ use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; -use crate::animation::{AnimationHandle, AnimationTarget, LinearInterpolate, Spawn}; +use crate::animation::{AnimationHandle, AnimationTarget, IntoAnimate, LinearInterpolate, Spawn}; use crate::context::{ AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetCacheKey, WidgetContext, }; @@ -20,7 +20,9 @@ use crate::styles::components::{ }; use crate::styles::{ColorExt, Styles}; use crate::value::{Destination, Dynamic, IntoValue, Source, Value}; -use crate::widget::{Callback, EventHandling, MakeWidget, Widget, WidgetRef, HANDLED}; +use crate::widget::{ + Callback, EventHandling, MakeWidget, SharedCallback, Widget, WidgetRef, HANDLED, +}; use crate::window::{DeviceId, WindowLocal}; use crate::FitMeasuredSize; @@ -607,3 +609,80 @@ pub struct ButtonClick { /// The keyboard modifiers state when this click began. pub modifiers: Modifiers, } + +/// A multi-click gesture recognizer. +pub struct ClickCounter { + threshold: Value, + maximum: usize, + last_click: Option, + count: usize, + on_click: SharedCallback<(usize, Option)>, + delay_fire: AnimationHandle, +} + +impl ClickCounter { + /// Returns a new click counter that allows up to `threshold` between each + /// click to be recognized as a single action. `on_click` will be invoked + /// after no clicks have been detected for `threshold`. + /// + /// `on_click` accepts two parameters: + /// + /// - The number of clicks recognized for this action. + /// - The final [`ButtonClick`], if provided. + #[must_use] + pub fn new(threshold: impl IntoValue, mut on_click: F) -> Self + where + F: FnMut(usize, Option) + Send + 'static, + { + Self { + threshold: threshold.into_value(), + maximum: usize::MAX, + last_click: None, + count: 0, + on_click: SharedCallback::new(move |(count, click)| on_click(count, click)), + delay_fire: AnimationHandle::new(), + } + } + + /// Sets the maximum number of clicks this counter recognizes to `maximum`. + /// + /// This causes the counter to immediately invoke the callback when the + /// maximum clicks have been reached, allowing for slightly more responsive + /// interfaces when the user is clicking multiple times. + #[must_use] + pub fn with_maximum(mut self, maximum: usize) -> Self { + self.maximum = maximum; + self + } + + /// Notes a single click. + pub fn click(&mut self, click: Option) { + let now = Instant::now(); + let threshold = self.threshold.get(); + if let Some(last_click) = self.last_click { + let elapsed = now.saturating_duration_since(last_click); + if elapsed < threshold { + self.count += 1; + } else { + self.count = 1; + } + } else { + self.count = 1; + } + self.last_click = Some(now); + + if self.count == self.maximum { + self.delay_fire.clear(); + self.on_click.invoke((self.count, click)); + self.count = 0; + } else { + let on_activation = self.on_click.clone(); + let count = self.count; + self.delay_fire = threshold + .on_complete(move || { + on_activation.invoke((count, click)); + }) + .spawn(); + } + } +} diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 35560d0d7..67b6583c4 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -1,5 +1,6 @@ //! A read-only text widget. +use std::borrow::Cow; use std::fmt::{Display, Write}; use figures::units::{Px, UPx}; @@ -12,7 +13,7 @@ use crate::context::{GraphicsContext, LayoutContext, Trackable, WidgetContext}; use crate::styles::components::TextColor; use crate::styles::FontFamilyList; use crate::value::{Dynamic, Generation, IntoReadOnly, ReadOnly, Value}; -use crate::widget::{Widget, WidgetInstance}; +use crate::widget::{MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag}; use crate::window::WindowLocal; use crate::ConstraintLimit; @@ -127,8 +128,8 @@ where macro_rules! impl_make_widget { ($($type:ty => $kind:ty),*) => { - $(impl crate::widget::MakeWidgetWithTag for $type { - fn make_with_tag(self, id: crate::widget::WidgetTag) -> WidgetInstance { + $(impl MakeWidgetWithTag for $type { + fn make_with_tag(self, id: WidgetTag) -> WidgetInstance { Label::<$kind>::new(self).make_with_tag(id) } })* @@ -145,6 +146,18 @@ impl_make_widget!( ReadOnly => String ); +impl MakeWidgetWithTag for Cow<'_, str> { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + Label::new(self.into_owned()).make_with_tag(tag) + } +} + +impl MakeWidgetWithTag for &'_ String { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + Label::new(self.clone()).make_with_tag(tag) + } +} + #[derive(Debug)] struct LabelCacheKey { text: MeasuredText, diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 90bc83fdc..045770e7a 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -81,13 +81,18 @@ impl Stack { (expand.child().clone(), GridDimension::Fractional { weight }) } else if let Some((child, size)) = guard.downcast_ref::().and_then(|r| { - let range = match self.layout.orientation { - Orientation::Row => r.height, - Orientation::Column => r.width, + let (range, other_range) = match self.layout.orientation { + Orientation::Row => (r.height, r.width), + Orientation::Column => (r.width, r.height), }; - range.minimum().map(|size| { - (r.child().clone(), GridDimension::Measured { size }) - }) + let cell = if other_range.is_unbounded() { + r.child().clone() + } else { + WidgetRef::new(widget.clone()) + }; + range + .minimum() + .map(|size| (cell, GridDimension::Measured { size })) }) { (child, size)