Merge pull request #170 from khonsulabs/dialogs

MessageBox + App::execute
This commit is contained in:
Jonathan Johnson 2024-10-03 13:48:49 -07:00 committed by GitHub
commit 0f06b22431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2442 additions and 125 deletions

View file

@ -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<T>` 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<Duration> + Send + Sync + 'static`
- `SharedCallback<Duration, ControlFlow<Duration>>`
- `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

805
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,13 +14,14 @@ readme = "./README.md"
rust-version = "1.80.0"
[features]
default = ["tracing-output", "roboto-flex"]
default = ["tracing-output", "roboto-flex", "native-dialogs"]
tracing-output = ["dep:tracing-subscriber"]
roboto-flex = []
plotters = ["dep:plotters", "kludgine/plotters"]
tokio = ["dep:tokio"]
tokio-multi-thread = ["tokio", "tokio/rt-multi-thread"]
serde = ["dep:serde", "figures/serde"]
native-dialogs = ["dep:rfd"]
[dependencies]
kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [
@ -33,6 +34,7 @@ kempt = "0.2.1"
intentional = "0.1.0"
tracing = "0.1.40"
tokio = { version = "1.40.0", optional = true, features = ["rt"] }
rfd = { version = "0.15.0", optional = true }
tracing-subscriber = { version = "0.3", optional = true, features = [
"env-filter",

150
examples/file-picker.rs Normal file
View file

@ -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::<Vec<PathBuf>>::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<Vec<PathBuf>>,
) -> impl FnMut(Option<PathBuf>) + Send + 'static {
let chosen_paths = chosen_paths.clone();
move |path| {
chosen_paths.set(path.into_iter().collect());
}
}
fn display_multiple_results(
chosen_paths: &Dynamic<Vec<PathBuf>>,
) -> impl FnMut(Option<Vec<PathBuf>>) + Send + 'static {
let chosen_paths = chosen_paths.clone();
move |path| {
chosen_paths.set(path.into_iter().flatten().collect());
}
}
fn picker_buttons(
mode: &Dynamic<PickerMode>,
pick_multiple: &Dynamic<bool>,
app: &App,
window: &WindowHandle,
modal: &Modal,
chosen_paths: &Dynamic<Vec<PathBuf>>,
) -> 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<Vec<PathBuf>>,
mode: &Dynamic<PickerMode>,
pick_multiple: &Dynamic<bool>,
) -> impl FnMut(Option<ButtonClick>) + 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))
}
};
}
}

46
examples/message-box.rs Normal file
View file

@ -0,0 +1,46 @@
use cushy::dialog::MessageBox;
use cushy::widget::MakeWidget;
use cushy::widgets::layers::Modal;
use cushy::window::PendingWindow;
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();
pending
.with_root(
"Show in Modal layer"
.into_button()
.on_click({
let modal = modal.clone();
move |_| {
example_message().open(&modal);
}
})
.and("Show above window".into_button().on_click({
move |_| {
example_message().open(&window);
}
}))
.and("Show in app".into_button().on_click({
let app = app.clone();
move |_| {
example_message().open(&app);
}
}))
.into_rows()
.centered()
.expand()
.and(modal)
.into_layers(),
)
.open(app)?;
Ok(())
}
fn example_message() -> MessageBox {
MessageBox::message("This is a dialog").with_explanation("This is some explanation text")
}

View file

@ -2,10 +2,11 @@ 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;
use kludgine::app::{AppEvent, AsApplication, Monitors};
use kludgine::app::{AppEvent, AsApplication, ExecutingApp, Monitors};
use parking_lot::{Mutex, MutexGuard};
use crate::fonts::FontCollection;
@ -313,11 +314,16 @@ pub struct RuntimeGuard<'a>(Box<dyn BoxableGuard<'a> + '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<Arc<Mutex<Clipboard>>>,
pub(crate) fonts: FontCollection,
settings: Arc<Mutex<AppSettings>>,
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]
@ -392,6 +414,13 @@ pub struct App {
}
impl App {
pub(crate) fn standalone() -> Self {
Self {
app: None,
cushy: Cushy::default(),
}
}
/// Returns a snapshot of information about the monitors connected to this
/// device.
///
@ -414,6 +443,20 @@ impl App {
.as_ref()
.and_then(kludgine::app::App::prevent_shutdown)
}
/// Executes `callback` on the main event loop thread.
///
/// Returns true if the callback was able to be sent to be executed. The app
/// may still terminate before the callback is executed regardless of the
/// result of this function. The only way to know with certainty that
/// `callback` is executed is to have `callback` notify the caller of its
/// completion.
pub fn execute<Callback>(&self, callback: Callback) -> bool
where
Callback: FnOnce(&ExecutingApp<'_, WindowCommand>) + Send + 'static,
{
self.app.as_ref().map_or(false, |app| app.execute(callback))
}
}
/// A guard preventing an [`App`] from shutting down.

944
src/dialog.rs Normal file
View file

@ -0,0 +1,944 @@
//! Modal dialogs such as message boxes and file pickers.
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;
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,
}
/// A button in a [`MessageBox`].
///
/// This type implements [`From`] for several types:
///
/// - `String`, `&str`: A button with the string's contents as the caption that
/// dismisses the message box.
/// - `FnMut()` implementors: A button with the default caption given its
/// context that invokes the closure when chosen.
///
/// To create a button with a custom caption that invokes a closure when chosen,
/// use [`MessageButton::custom`].
#[derive(Clone, Debug, Default)]
pub struct MessageButton {
callback: OptionalCallback,
caption: String,
}
impl MessageButton {
/// Returns a button with a custom caption that invokes `on_click` when
/// selected.
pub fn custom<F>(caption: impl Into<String>, mut on_click: F) -> Self
where
F: FnMut() + Send + 'static,
{
Self {
callback: OptionalCallback(Some(SharedCallback::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: FnMut() + Send + 'static,
{
fn from(mut value: F) -> Self {
Self {
callback: OptionalCallback(Some(SharedCallback::new(move |()| value()))),
caption: String::new(),
}
}
}
#[derive(Clone, Debug, Default)]
struct OptionalCallback(Option<SharedCallback>);
impl OptionalCallback {
fn invoke(&self) {
if let Some(callback) = &self.0 {
callback.invoke(());
}
}
}
#[derive(Default, Clone, Eq, PartialEq, Copy, Debug)]
enum MessageLevel {
Error,
Warning,
#[default]
Info,
}
/// A marker indicating a [`MessageBoxBuilder`] does not have a preference
/// between a yes/no/cancel or an ok/cancel configuration.
pub enum Undecided {}
/// Specializes a [`MessageBoxBuilder`] for an Ok/Cancel dialog.
pub enum OkCancel {}
/// Specializes a [`MessageBoxBuilder`] for a Yes/No dialog.
pub enum YesNoCancel {}
/// A builder for a [`MessageBox`].
#[must_use]
pub struct MessageBoxBuilder<Kind>(MessageBox, PhantomData<Kind>);
impl<Kind> MessageBoxBuilder<Kind> {
fn new(message: MessageBox) -> MessageBoxBuilder<Kind> {
Self(message, PhantomData)
}
/// Sets the explanation text and returns self.
pub fn with_explanation(mut self, explanation: impl Into<String>) -> Self {
self.0.description = explanation.into();
self
}
/// Displays this message as a warning.
///
/// When using native dialogs, not all platforms support this stylization.
pub fn warning(mut self) -> Self {
self.0.level = MessageLevel::Warning;
self
}
/// Displays this message as an error.
///
/// When using native dialogs, not all platforms support this stylization.
pub fn error(mut self) -> Self {
self.0.level = MessageLevel::Error;
self
}
/// Adds a cancel button and returns self.
pub fn with_cancel(mut self, cancel: impl Into<MessageButton>) -> Self {
self.0.buttons.cancel = Some(cancel.into());
self
}
/// Returns the completed message box.
#[must_use]
pub fn finish(self) -> MessageBox {
self.0
}
}
impl MessageBoxBuilder<Undecided> {
/// Sets the yes button and returns self.
pub fn with_yes(
Self(mut message, _): Self,
yes: impl Into<MessageButton>,
) -> MessageBoxBuilder<YesNoCancel> {
message.buttons.kind = MessageButtonsKind::YesNo;
message.buttons.affirmative = yes.into();
MessageBoxBuilder(message, PhantomData)
}
/// Sets the ok button and returns self.
pub fn with_ok(
Self(mut message, _): Self,
ok: impl Into<MessageButton>,
) -> MessageBoxBuilder<OkCancel> {
message.buttons.affirmative = ok.into();
MessageBoxBuilder(message, PhantomData)
}
}
impl MessageBoxBuilder<YesNoCancel> {
/// Sets the no button and returns self.
pub fn with_no(mut self, no: impl Into<MessageButton>) -> Self {
self.0.buttons.negative = Some(no.into());
self
}
}
impl MessageBoxBuilder<OkCancel> {}
/// A dialog that displays a message.
#[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,
},
}
}
/// Returns a builder for a dialog displaying `message`.
pub fn build(message: impl Into<String>) -> MessageBoxBuilder<Undecided> {
MessageBoxBuilder::new(Self::new(message.into(), MessageButtonsKind::OkCancel))
}
/// Returns a dialog displaying `message` with an `OK` button that dismisses
/// the dialog.
#[must_use]
pub fn message(message: impl Into<String>) -> Self {
Self::build(message).finish()
}
/// Sets the explanation text and returns self.
#[must_use]
pub fn with_explanation(mut self, explanation: impl Into<String>) -> Self {
self.description = explanation.into();
self
}
/// Displays this message as a warning.
///
/// When using native dialogs, not all platforms support this stylization.
#[must_use]
pub fn warning(mut self) -> Self {
self.level = MessageLevel::Warning;
self
}
/// Displays this message as an error.
///
/// When using native dialogs, not all platforms support this stylization.
#[must_use]
pub fn error(mut self) -> Self {
self.level = MessageLevel::Error;
self
}
/// Adds a cancel button and returns self.
#[must_use]
pub fn with_cancel(mut self, cancel: impl Into<MessageButton>) -> Self {
self.buttons.cancel = Some(cancel.into());
self
}
/// Opens this dialog in the given target.
///
/// A target can be a [`Modal`] layer, a [`WindowHandle`], or an [`App`].
pub fn open(&self, open_in: &impl OpenMessageBox) {
open_in.open_message_box(self);
}
}
/// A type that can open a [`MessageBox`] as a modal dialog.
pub trait OpenMessageBox {
/// Opens `message` as a modal dialog.
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 OpenMessageBox for Modal {
fn open_message_box(&self, message: &MessageBox) {
let dialog = self.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();
}
}
}
/// A dialog that can pick one or more files or directories.
#[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 {
/// 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<String>) -> 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<String>) -> 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<Type>(mut self, types: impl IntoIterator<Item = Type>) -> Self
where
Type: Into<FileType>,
{
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<Path>) -> 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<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + 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<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + 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<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + 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<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<PathBuf>) + 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<Callback>(&self, pick_in: &impl PickFile, on_dismiss: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + 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<String>,
}
impl FileType {
/// Returns a new file type from the given name and list of file extensions.
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(),
}
}
/// 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<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)
}
}
/// 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<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
/// Shows a picker that creates a new file and invokes `on_dismiss` when the
/// dialog is dismissed.
fn save_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
/// Shows a picker that selects one or more files and invokes `on_dismiss`
/// when the dialog is dismissed.
fn pick_files<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static;
/// Shows a picker that selects a single folder/directory and invokes
/// `on_dismiss` when the dialog is dismissed.
fn pick_folder<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static;
/// Shows a picker that selects one or more folders/directorys and invokes
/// `on_dismiss` when the dialog is dismissed.
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();
self.present(FilePickerWidget {
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();
self.present(FilePickerWidget {
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();
self.present(FilePickerWidget {
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();
self.present(FilePickerWidget {
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();
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::<Vec<PathBuf>>::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::<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()
}
}

324
src/dialog/native.rs Normal file
View file

@ -0,0 +1,324 @@
use std::path::PathBuf;
use std::thread;
use rfd::{FileDialog, MessageDialog, MessageDialogResult};
use super::{
coalesce_empty, FilePicker, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, Mode,
OpenMessageBox, PickFile,
};
use crate::window::WindowHandle;
use crate::App;
impl MessageButtons {
fn as_rfd_buttons(&self) -> rfd::MessageButtons {
let cancel_is_custom = self
.cancel
.as_ref()
.map_or(false, |b| !b.caption.is_empty());
match self.kind {
MessageButtonsKind::YesNo => {
let negative = self.negative.as_ref().expect("no button");
if cancel_is_custom
|| !self.affirmative.caption.is_empty()
|| !negative.caption.is_empty()
{
if let Some(cancel) = &self.cancel {
rfd::MessageButtons::YesNoCancelCustom(
coalesce_empty(&self.affirmative.caption, "Yes").to_string(),
coalesce_empty(&negative.caption, "No").to_string(),
coalesce_empty(&cancel.caption, "Yes").to_string(),
)
} else {
rfd::MessageButtons::OkCancelCustom(
coalesce_empty(&self.affirmative.caption, "Yes").to_string(),
coalesce_empty(&negative.caption, "No").to_string(),
)
}
} else if self.cancel.is_some() {
rfd::MessageButtons::YesNoCancel
} else {
rfd::MessageButtons::YesNo
}
}
MessageButtonsKind::OkCancel => {
if let Some(cancel) = &self.cancel {
if !self.affirmative.caption.is_empty() || !cancel.caption.is_empty() {
rfd::MessageButtons::OkCancelCustom(
coalesce_empty(&self.affirmative.caption, "OK").to_string(),
coalesce_empty(&cancel.caption, "Cancel").to_string(),
)
} else {
rfd::MessageButtons::OkCancel
}
} else if !self.affirmative.caption.is_empty() {
rfd::MessageButtons::OkCustom(self.affirmative.caption.clone())
} else {
rfd::MessageButtons::Ok
}
}
}
}
}
impl From<MessageLevel> for rfd::MessageLevel {
fn from(value: MessageLevel) -> Self {
match value {
MessageLevel::Error => rfd::MessageLevel::Error,
MessageLevel::Warning => rfd::MessageLevel::Warning,
MessageLevel::Info => rfd::MessageLevel::Info,
}
}
}
impl OpenMessageBox for WindowHandle {
fn open_message_box(&self, message: &MessageBox) {
let message = message.clone();
self.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 = MessageDialog::new()
.set_title(message.title)
.set_buttons(message.buttons.as_rfd_buttons())
.set_description(message.description)
.set_level(message.level.into());
if let Some(winit) = winit {
dialog = dialog.set_parent(&winit);
}
thread::spawn(move || {
handle_message_result(&dialog.show(), &message.buttons);
});
});
});
}
}
impl OpenMessageBox for App {
fn open_message_box(&self, message: &MessageBox) {
let shutdown_guard = self.prevent_shutdown();
let message = message.clone();
self.execute(move |_app| {
let dialog = MessageDialog::new()
.set_title(message.title)
.set_buttons(message.buttons.as_rfd_buttons())
.set_description(message.description)
.set_level(message.level.into());
thread::spawn(move || {
handle_message_result(&dialog.show(), &message.buttons);
drop(shutdown_guard);
});
});
}
}
fn handle_message_result(result: &MessageDialogResult, buttons: &MessageButtons) {
match result {
MessageDialogResult::Ok | MessageDialogResult::Yes => {
buttons.affirmative.callback.invoke();
}
MessageDialogResult::No => {
buttons
.negative
.as_ref()
.expect("no button")
.callback
.invoke();
}
MessageDialogResult::Cancel => {
if matches!(buttons.kind, MessageButtonsKind::YesNo) && buttons.cancel.is_none() {
// Cancel means No in this situation.
buttons
.negative
.as_ref()
.expect("no button")
.callback
.invoke();
} else {
buttons
.cancel
.as_ref()
.expect("cancel button")
.callback
.invoke();
}
}
MessageDialogResult::Custom(caption) => {
let (default_affirmative, default_negative) = match buttons.kind {
MessageButtonsKind::YesNo => ("Yes", Some("No")),
MessageButtonsKind::OkCancel => ("OK", None),
};
if coalesce_empty(&buttons.affirmative.caption, default_affirmative) == caption {
buttons.affirmative.callback.invoke();
} else if let Some(negative) = buttons.negative.as_ref().filter(|negative| {
&negative.caption == caption
|| default_negative
.map_or(false, |default_negative| default_negative == caption)
}) {
negative.callback.invoke();
} else if let Some(cancel) = buttons
.cancel
.as_ref()
.filter(|cancel| coalesce_empty(&cancel.caption, "Cancel") == caption)
{
cancel.callback.invoke();
} else {
unreachable!("no matching button")
}
}
}
}
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<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_window(self, picker, Mode::file(callback));
}
fn save_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_window(self, picker, Mode::save_file(callback));
}
fn pick_files<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
show_picker_in_window(self, picker, Mode::files(callback));
}
fn pick_folder<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_window(self, picker, Mode::folder(callback));
}
fn pick_folders<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + 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<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_app(self, picker, Mode::file(callback));
}
fn save_file<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_app(self, picker, Mode::save_file(callback));
}
fn pick_files<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
show_picker_in_app(self, picker, Mode::files(callback));
}
fn pick_folder<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<PathBuf>) + Send + 'static,
{
show_picker_in_app(self, picker, Mode::folder(callback));
}
fn pick_folders<Callback>(&self, picker: &FilePicker, callback: Callback)
where
Callback: FnOnce(Option<Vec<PathBuf>>) + Send + 'static,
{
show_picker_in_app(self, picker, Mode::folders(callback));
}
}

View file

@ -28,6 +28,7 @@ pub mod widget;
pub mod widgets;
pub mod window;
pub mod dialog;
#[doc(hidden)]
pub mod example;
use std::ops::{Add, AddAssign, Sub, SubAssign};

View file

@ -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<T> From<T> for DimensionRange

View file

@ -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<Duration>,
maximum: usize,
last_click: Option<Instant>,
count: usize,
on_click: SharedCallback<(usize, Option<ButtonClick>)>,
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<F>(threshold: impl IntoValue<Duration>, mut on_click: F) -> Self
where
F: FnMut(usize, Option<ButtonClick>) + 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<ButtonClick>) {
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();
}
}
}

View file

@ -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> => 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<Px>,

View file

@ -81,13 +81,18 @@ impl Stack {
(expand.child().clone(), GridDimension::Fractional { weight })
} else if let Some((child, size)) =
guard.downcast_ref::<Resize>().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)

View file

@ -73,7 +73,7 @@ pub trait PlatformWindowImplementation {
/// Marks the window to close as soon as possible.
fn close(&mut self);
/// Returns the underlying `winit` window, if one exists.
fn winit(&self) -> Option<&winit::window::Window>;
fn winit(&self) -> Option<&Arc<winit::window::Window>>;
/// Sets the window to redraw as soon as possible.
fn set_needs_redraw(&mut self);
/// Sets the window to redraw after a `duration`.
@ -114,8 +114,7 @@ pub trait PlatformWindowImplementation {
/// [`winit::window::Window::is_resizable`], or true if this window has no
/// winit window.
fn is_resizable(&self) -> bool {
self.winit()
.map_or(true, winit::window::Window::is_resizable)
self.winit().map_or(true, |win| win.is_resizable())
}
/// Returns true if the window can have its size changed.
@ -124,7 +123,7 @@ pub trait PlatformWindowImplementation {
/// dark if this window has no winit window.
fn theme(&self) -> winit::window::Theme {
self.winit()
.and_then(winit::window::Window::theme)
.and_then(|win| win.theme())
.unwrap_or(winit::window::Theme::Dark)
}
@ -208,7 +207,7 @@ impl PlatformWindowImplementation for kludgine::app::Window<'_, WindowCommand> {
self.close();
}
fn winit(&self) -> Option<&winit::window::Window> {
fn winit(&self) -> Option<&Arc<winit::window::Window>> {
Some(self.winit())
}
@ -260,6 +259,8 @@ pub trait PlatformWindow {
fn outer_size(&self) -> Size<UPx>;
/// Returns the shared application resources.
fn cushy(&self) -> &Cushy;
/// Returns the app managing this window's event loop.
fn app(&self) -> Option<&App>;
/// Sets the window to redraw as soon as possible.
fn set_needs_redraw(&mut self);
/// Sets the window to redraw after a `duration`.
@ -289,7 +290,7 @@ pub trait PlatformWindow {
fn set_max_inner_size(&self, max_size: Option<Size<UPx>>);
/// Returns a handle to the underlying winit window, if available.
fn winit(&self) -> Option<&winit::window::Window>;
fn winit(&self) -> Option<&Arc<winit::window::Window>>;
}
/// A currently running Cushy window.
@ -297,7 +298,7 @@ pub struct RunningWindow<W> {
window: W,
kludgine_id: KludgineId,
invalidation_status: InvalidationStatus,
cushy: Cushy,
app: App,
focused: Dynamic<bool>,
occluded: Dynamic<bool>,
inner_size: Dynamic<Size<UPx>>,
@ -313,7 +314,7 @@ where
window: W,
kludgine_id: KludgineId,
invalidation_status: &InvalidationStatus,
cushy: &Cushy,
app: &App,
focused: &Dynamic<bool>,
occluded: &Dynamic<bool>,
inner_size: &Dynamic<Size<UPx>>,
@ -323,7 +324,7 @@ where
window,
kludgine_id,
invalidation_status: invalidation_status.clone(),
cushy: cushy.clone(),
app: app.clone(),
focused: focused.clone(),
occluded: occluded.clone(),
inner_size: inner_size.clone(),
@ -382,7 +383,7 @@ where
/// initialized when the window opened.
#[must_use]
pub fn clipboard_guard(&self) -> Option<MutexGuard<'_, Clipboard>> {
self.cushy.clipboard_guard()
self.app.cushy().clipboard_guard()
}
}
@ -422,6 +423,10 @@ where
self.kludgine_id
}
fn app(&self) -> Option<&App> {
Some(&self.app)
}
fn focused(&self) -> &Dynamic<bool> {
&self.focused
}
@ -439,7 +444,7 @@ where
}
fn cushy(&self) -> &Cushy {
&self.cushy
self.app.cushy()
}
fn set_needs_redraw(&mut self) {
@ -490,7 +495,7 @@ where
self.window.set_ime_location(location);
}
fn winit(&self) -> Option<&winit::window::Window> {
fn winit(&self) -> Option<&Arc<winit::window::Window>> {
self.window.winit()
}
}
@ -1121,14 +1126,14 @@ where
App: Application + ?Sized,
{
let this = self.make_window();
let cushy = app.cushy().clone();
let app_app = app.as_app();
let handle = this.pending.handle();
OpenWindow::<T::Behavior>::open_with(
app,
sealed::Context {
user: this.context,
settings: RefCell::new(sealed::WindowSettings {
cushy,
app: app_app,
title: this.title,
redraw_status: this.pending.0.redraw_status.clone(),
on_open: this.on_open,
@ -1348,7 +1353,7 @@ struct OpenWindow<T> {
theme_mode: Value<ThemeMode>,
transparent: bool,
fonts: FontState,
cushy: Cushy,
app: App,
on_closed: Option<OnceCallback>,
vsync: bool,
dpi_scale: Dynamic<Fraction>,
@ -1729,10 +1734,10 @@ where
.persist();
}
let cushy = settings.cushy.clone();
let app = settings.app.clone();
let fonts = Self::load_fonts(
&mut settings,
cushy.fonts.clone(),
app.cushy().fonts.clone(),
graphics.font_system().db_mut(),
);
@ -1788,7 +1793,7 @@ where
theme_mode,
transparent: settings.transparent,
fonts,
cushy,
app,
on_closed: settings.on_closed,
vsync: settings.vsync,
close_requested: settings.close_requested,
@ -1847,7 +1852,7 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
self.synchronize_platform_window(&mut window);
@ -1859,7 +1864,7 @@ where
window,
graphics.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -1961,13 +1966,13 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
if self.behavior.close_requested(&mut RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2108,13 +2113,13 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2167,13 +2172,13 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2211,13 +2216,13 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2256,13 +2261,13 @@ where
) where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2315,7 +2320,7 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
self.cursor.location = None;
self.cursor_position
@ -2325,7 +2330,7 @@ where
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2358,13 +2363,13 @@ where
where
W: PlatformWindowImplementation,
{
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2500,13 +2505,13 @@ where
) -> Self {
context.pending.opened(window.handle());
let settings = context.settings.borrow();
let cushy = settings.cushy.clone();
let cushy = settings.app.cushy().clone();
let _guard = cushy.enter_runtime();
let mut window = RunningWindow::new(
window,
graphics.id(),
&settings.redraw_status,
&settings.cushy,
&settings.app,
&settings.focused,
&settings.occluded,
&settings.inner_size,
@ -2528,7 +2533,7 @@ where
window: kludgine::app::Window<'_, WindowCommand>,
kludgine: &mut Kludgine,
) {
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
self.focused.set(window.focused());
self.occluded.set(window.occluded());
@ -2539,7 +2544,7 @@ where
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2618,7 +2623,7 @@ where
window: kludgine::app::Window<'_, WindowCommand>,
kludgine: &mut Kludgine,
) -> bool {
let cushy = self.cushy.clone();
let cushy = self.app.cushy().clone();
let _guard = cushy.enter_runtime();
Self::request_close(
&mut self.should_close,
@ -2627,7 +2632,7 @@ where
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2815,7 +2820,7 @@ where
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2859,7 +2864,7 @@ where
window,
kludgine.id(),
&self.redraw_status,
&self.cushy,
&self.app,
&self.focused,
&self.occluded,
self.inner_size.source(),
@ -2959,7 +2964,6 @@ pub(crate) mod sealed {
use kludgine::app::winit::window::{Fullscreen, UserAttentionType, WindowButtons, WindowLevel};
use kludgine::Color;
use crate::app::Cushy;
use crate::context::sealed::InvalidationStatus;
use crate::context::EventContext;
use crate::fonts::FontCollection;
@ -2968,6 +2972,7 @@ pub(crate) mod sealed {
use crate::widget::{Callback, OnceCallback, SharedCallback};
use crate::widgets::shortcuts::ShortcutMap;
use crate::window::{FileDrop, PendingWindow, ThemeMode, WindowAttributes, WindowHandle};
use crate::App;
pub struct Context<C> {
pub user: C,
@ -2976,7 +2981,7 @@ pub(crate) mod sealed {
}
pub struct WindowSettings {
pub cushy: Cushy,
pub app: App,
pub redraw_status: InvalidationStatus,
pub title: Value<String>,
pub attributes: Option<WindowAttributes>,
@ -3516,7 +3521,7 @@ impl PlatformWindowImplementation for &mut VirtualState {
self.closed = true;
}
fn winit(&self) -> Option<&winit::window::Window> {
fn winit(&self) -> Option<&Arc<winit::window::Window>> {
None
}
@ -3659,7 +3664,7 @@ impl StandaloneWindowBuilder {
window,
&mut kludgine::Graphics::new(&mut kludgine, device, queue),
sealed::WindowSettings {
cushy: Cushy::default(),
app: App::standalone(),
redraw_status: InvalidationStatus::default(),
title: Value::default(),
attributes: None,