diff --git a/src/screen/dashboard/command_bar.rs b/src/screen/dashboard/command_bar.rs index 68d7c03d1..f513ed022 100644 --- a/src/screen/dashboard/command_bar.rs +++ b/src/screen/dashboard/command_bar.rs @@ -1,9 +1,9 @@ use data::Config; -use iced::widget::{column, combo_box, container, text}; +use iced::widget::{column, container, text}; use iced::Length; use crate::theme; -use crate::widget::{double_pass, key_press, Element}; +use crate::widget::{combo_box, double_pass, key_press, Element}; #[derive(Debug, Clone)] pub struct CommandBar { diff --git a/src/widget.rs b/src/widget.rs index 486a37c7f..751026127 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] pub use self::anchored_overlay::anchored_overlay; pub use self::collection::Collection; +pub use self::combo_box::combo_box; pub use self::context_menu::context_menu; pub use self::double_click::double_click; pub use self::double_pass::double_pass; @@ -13,6 +14,7 @@ use crate::Theme; pub mod anchored_overlay; pub mod collection; +pub mod combo_box; pub mod context_menu; pub mod double_click; mod double_pass; diff --git a/src/widget/combo_box.rs b/src/widget/combo_box.rs new file mode 100644 index 000000000..651611fc5 --- /dev/null +++ b/src/widget/combo_box.rs @@ -0,0 +1,704 @@ +use iced::advanced::{ + layout, mouse, overlay, renderer, text, widget, Clipboard, Layout, Shell, Widget, +}; +use iced::overlay::menu; +use iced::widget::text::LineHeight; +use iced::widget::{container, scrollable, text_input, TextInput}; +use iced::{event, keyboard, Event, Length, Padding, Rectangle}; + +use std::cell::RefCell; +use std::fmt::Display; +use std::time::Instant; + +use super::Element; + +/// A widget for searching and selecting a single value from a list of options. +/// +/// This widget is composed by a [`TextInput`] that can be filled with the text +/// to search for corresponding values from the list of options that are displayed +/// as a [`Menu`]. +pub struct ComboBox<'a, T, Message, Renderer = super::Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: text_input::StyleSheet + menu::StyleSheet, +{ + state: &'a State, + text_input: TextInput<'a, TextInputEvent, Renderer>, + font: Option, + selection: text_input::Value, + on_selected: Box Message>, + on_option_hovered: Option Message>>, + on_close: Option, + on_input: Option Message>>, + menu_style: ::Style, + padding: Padding, + size: Option, +} + +impl<'a, T, Message, Renderer> ComboBox<'a, T, Message, Renderer> +where + T: std::fmt::Display + Clone, + Renderer: text::Renderer, + Renderer::Theme: text_input::StyleSheet + menu::StyleSheet, +{ + /// Creates a new [`ComboBox`] with the given list of options, a placeholder, + /// the current selected value, and the message to produce when an option is + /// selected. + pub fn new( + state: &'a State, + placeholder: &str, + selection: Option<&T>, + on_selected: impl Fn(T) -> Message + 'static, + ) -> Self { + let text_input = + TextInput::new(placeholder, &state.value()).on_input(TextInputEvent::TextChanged); + + let selection = selection.map(T::to_string).unwrap_or_default(); + + Self { + state, + text_input, + font: None, + selection: text_input::Value::new(&selection), + on_selected: Box::new(on_selected), + on_option_hovered: None, + on_input: None, + on_close: None, + menu_style: Default::default(), + padding: text_input::DEFAULT_PADDING, + size: None, + } + } + + /// Sets the message that should be produced when some text is typed into + /// the [`TextInput`] of the [`ComboBox`]. + pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'static) -> Self { + self.on_input = Some(Box::new(on_input)); + self + } + + /// Sets the message that will be produced when an option of the + /// [`ComboBox`] is hovered using the arrow keys. + pub fn on_option_hovered(mut self, on_selection: impl Fn(T) -> Message + 'static) -> Self { + self.on_option_hovered = Some(Box::new(on_selection)); + self + } + + /// Sets the message that will be produced when the outside area + /// of the [`ComboBox`] is pressed. + pub fn on_close(mut self, message: Message) -> Self { + self.on_close = Some(message); + self + } + + /// Sets the [`Padding`] of the [`ComboBox`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self.text_input = self.text_input.padding(self.padding); + self + } + + /// Sets the style of the [`ComboBox`]. + // TODO: Define its own `StyleSheet` trait + pub fn style(mut self, style: S) -> Self + where + S: Into<::Style> + + Into<::Style> + + Clone, + { + self.menu_style = style.clone().into(); + self.text_input = self.text_input.style(style); + self + } + + /// Sets the style of the [`TextInput`] of the [`ComboBox`]. + pub fn text_input_style(mut self, style: S) -> Self + where + S: Into<::Style> + Clone, + { + self.text_input = self.text_input.style(style); + self + } + + /// Sets the [`Font`] of the [`ComboBox`]. + pub fn font(mut self, font: Renderer::Font) -> Self { + self.text_input = self.text_input.font(font); + self.font = Some(font); + self + } + + /// Sets the [`Icon`] of the [`ComboBox`]. + pub fn icon(mut self, icon: text_input::Icon) -> Self { + self.text_input = self.text_input.icon(icon); + self + } + + /// Returns whether the [`ComboBox`] is currently focused or not. + pub fn is_focused(&self) -> bool { + self.state.is_focused() + } + + /// Sets the text sixe of the [`ComboBox`]. + pub fn size(mut self, size: f32) -> Self { + self.text_input = self.text_input.size(size); + self.size = Some(size); + self + } + + /// Sets the [`LineHeight`] of the [`ComboBox`]. + pub fn line_height(self, line_height: impl Into) -> Self { + Self { + text_input: self.text_input.line_height(line_height), + ..self + } + } + + /// Sets the width of the [`ComboBox`]. + pub fn width(self, width: impl Into) -> Self { + Self { + text_input: self.text_input.width(width), + ..self + } + } +} + +/// The local state of a [`ComboBox`]. +#[derive(Debug, Clone)] +pub struct State(RefCell>); + +#[derive(Debug, Clone)] +struct Inner { + text_input: text_input::State, + value: String, + options: Vec, + option_matchers: Vec, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +struct Filtered { + options: Vec, + updated: Instant, +} + +impl State +where + T: Display + Clone, +{ + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options. + pub fn new(options: Vec) -> Self { + Self::with_selection(options, None) + } + + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options + /// and selected value. + pub fn with_selection(options: Vec, selection: Option<&T>) -> Self { + let value = selection.map(T::to_string).unwrap_or_default(); + + // Pre-build "matcher" strings ahead of time so that search is fast + let option_matchers = build_matchers(&options); + + let filtered_options = Filtered::new( + search(&options, &option_matchers, &value) + .cloned() + .collect(), + ); + + Self(RefCell::new(Inner { + text_input: text_input::State::new(), + value, + options, + option_matchers, + filtered_options, + })) + } + + /// Focuses the [`ComboBox`]. + pub fn focused(self) -> Self { + self.focus(); + self + } + + /// Focuses the [`ComboBox`]. + pub fn focus(&self) { + let mut inner = self.0.borrow_mut(); + + inner.text_input.focus(); + } + + /// Unfocuses the [`ComboBox`]. + pub fn unfocus(&self) { + let mut inner = self.0.borrow_mut(); + + inner.text_input.unfocus(); + } + + /// Returns whether the [`ComboBox`] is currently focused or not. + pub fn is_focused(&self) -> bool { + let inner = self.0.borrow(); + + inner.text_input.is_focused() + } + + fn value(&self) -> String { + let inner = self.0.borrow(); + + inner.value.clone() + } + + fn text_input_tree(&self) -> widget::Tree { + let inner = self.0.borrow(); + + inner.text_input_tree() + } + + fn update_text_input(&self, tree: widget::Tree) { + let mut inner = self.0.borrow_mut(); + + inner.update_text_input(tree) + } + + fn with_inner(&self, f: impl FnOnce(&Inner) -> O) -> O { + let inner = self.0.borrow(); + + f(&inner) + } + + fn with_inner_mut(&self, f: impl FnOnce(&mut Inner)) { + let mut inner = self.0.borrow_mut(); + + f(&mut inner); + } + + fn sync_filtered_options(&self, options: &mut Filtered) { + let inner = self.0.borrow(); + + inner.filtered_options.sync(options); + } +} + +impl Inner { + fn text_input_tree(&self) -> widget::Tree { + widget::Tree { + tag: widget::tree::Tag::of::(), + state: widget::tree::State::new(self.text_input.clone()), + children: vec![], + } + } + + fn update_text_input(&mut self, tree: widget::Tree) { + self.text_input = tree.state.downcast_ref::().clone(); + } +} + +impl Filtered +where + T: Clone, +{ + fn new(options: Vec) -> Self { + Self { + options, + updated: Instant::now(), + } + } + + fn empty() -> Self { + Self { + options: vec![], + updated: Instant::now(), + } + } + + fn update(&mut self, options: Vec) { + self.options = options; + self.updated = Instant::now(); + } + + fn sync(&self, other: &mut Filtered) { + if other.updated != self.updated { + *other = self.clone(); + } + } +} + +struct Menu { + menu: menu::State, + hovered_option: Option, + new_selection: Option, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +enum TextInputEvent { + TextChanged(String), +} + +impl<'a, T, Message, Renderer> Widget for ComboBox<'a, T, Message, Renderer> +where + T: Display + Clone + 'static, + Message: Clone, + Renderer: text::Renderer, + Renderer::Theme: + container::StyleSheet + text_input::StyleSheet + scrollable::StyleSheet + menu::StyleSheet, +{ + fn width(&self) -> Length { + Widget::::width(&self.text_input) + } + + fn height(&self) -> Length { + Widget::::height(&self.text_input) + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + self.text_input.layout(renderer, limits) + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::>() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Menu:: { + menu: menu::State::new(), + filtered_options: Filtered::empty(), + hovered_option: Some(0), + new_selection: None, + }) + } + + fn on_event( + &mut self, + tree: &mut widget::Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + let menu = tree.state.downcast_mut::>(); + + let started_focused = self.state.is_focused(); + // This is intended to check whether or not the message buffer was empty, + // since `Shell` does not expose such functionality. + let mut published_message_to_shell = false; + + // Create a new list of local messages + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + // Provide it to the widget + let mut tree = self.state.text_input_tree(); + let mut event_status = self.text_input.on_event( + &mut tree, + event.clone(), + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + self.state.update_text_input(tree); + + // Then finally react to them here + for message in local_messages { + let TextInputEvent::TextChanged(new_value) = message; + + if let Some(on_input) = &self.on_input { + shell.publish((on_input)(new_value.clone())); + published_message_to_shell = true; + } + + // Couple the filtered options with the `ComboBox` + // value and only recompute them when the value changes, + // instead of doing it in every `view` call + self.state.with_inner_mut(|state| { + menu.hovered_option = Some(0); + state.value = new_value; + + state.filtered_options.update( + search(&state.options, &state.option_matchers, &state.value) + .cloned() + .collect(), + ); + }); + shell.invalidate_layout(); + } + + if self.state.is_focused() { + self.state.with_inner(|state| { + if !started_focused { + if let Some(on_option_hovered) = &mut self.on_option_hovered { + let hovered_option = menu.hovered_option.unwrap_or(0); + + if let Some(option) = state.filtered_options.options.get(hovered_option) { + shell.publish(on_option_hovered(option.clone())); + published_message_to_shell = true; + } + } + } + + if let Event::Keyboard(keyboard::Event::KeyPressed { + key_code, + modifiers, + .. + }) = event + { + let shift_modifier = modifiers.shift(); + + match (key_code, shift_modifier) { + (keyboard::KeyCode::Enter, _) => { + if let Some(index) = &menu.hovered_option { + if let Some(option) = state.filtered_options.options.get(*index) { + menu.new_selection = Some(option.clone()); + } + } + + event_status = event::Status::Captured; + } + (keyboard::KeyCode::Up, _) | (keyboard::KeyCode::Tab, true) => { + if let Some(index) = &mut menu.hovered_option { + if *index == 0 { + *index = state.filtered_options.options.len().saturating_sub(1); + } else { + *index = index.saturating_sub(1); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_selection) = &mut self.on_option_hovered { + if let Some(option) = menu + .hovered_option + .and_then(|index| state.filtered_options.options.get(index)) + { + // Notify the selection + shell.publish((on_option_selection)(option.clone())); + published_message_to_shell = true; + } + } + + event_status = event::Status::Captured; + } + (keyboard::KeyCode::Down, _) | (keyboard::KeyCode::Tab, false) => { + if let Some(index) = &mut menu.hovered_option { + if *index == state.filtered_options.options.len().saturating_sub(1) + { + *index = 0; + } else { + *index = index.saturating_add(1).min( + state.filtered_options.options.len().saturating_sub(1), + ); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_selection) = &mut self.on_option_hovered { + if let Some(option) = menu + .hovered_option + .and_then(|index| state.filtered_options.options.get(index)) + { + // Notify the selection + shell.publish((on_option_selection)(option.clone())); + published_message_to_shell = true; + } + } + + event_status = event::Status::Captured; + } + _ => {} + } + } + }); + } + + // If the overlay menu has selected something + self.state.with_inner_mut(|state| { + if let Some(selection) = menu.new_selection.take() { + // Clear the value and reset the options and menu + state.value = String::new(); + state.filtered_options.update(state.options.clone()); + menu.menu = menu::State::default(); + + // Notify the selection + shell.publish((self.on_selected)(selection)); + published_message_to_shell = true; + + // Unfocus the input + let mut tree = state.text_input_tree(); + let _ = self.text_input.on_event( + &mut tree, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + layout, + mouse::Cursor::Unavailable, + renderer, + clipboard, + &mut Shell::new(&mut vec![]), + viewport, + ); + state.update_text_input(tree); + } + }); + + if started_focused && !self.state.is_focused() && !published_message_to_shell { + if let Some(message) = self.on_close.take() { + shell.publish(message); + } + } + + // Focus changed, invalidate widget tree to force a fresh `view` + if started_focused != self.state.is_focused() { + shell.invalidate_widgets(); + } + + event_status + } + + fn mouse_interaction( + &self, + _tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let tree = self.state.text_input_tree(); + self.text_input + .mouse_interaction(&tree, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + _tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let selection = if self.state.is_focused() || self.selection.is_empty() { + None + } else { + Some(&self.selection) + }; + + let tree = self.state.text_input_tree(); + self.text_input + .draw(&tree, renderer, theme, layout, cursor, selection); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option> { + let Menu { + menu, + filtered_options, + hovered_option, + .. + } = tree.state.downcast_mut::>(); + + if self.state.is_focused() { + let bounds = layout.bounds(); + + self.state.sync_filtered_options(filtered_options); + + let mut menu = menu::Menu::new( + menu, + &filtered_options.options, + hovered_option, + |x| (self.on_selected)(x), + self.on_option_hovered.as_deref(), + ) + .width(bounds.width) + .padding(self.padding) + .style(self.menu_style.clone()); + + if let Some(font) = self.font { + menu = menu.font(font); + } + + if let Some(size) = self.size { + menu = menu.text_size(size); + } + + // Spacing between text_input and menu + let spacing = 4.0; + + Some(menu.overlay(layout.position(), bounds.height + spacing)) + } else { + None + } + } +} + +impl<'a, T, Message> From> for Element<'a, Message> +where + T: Display + Clone + 'static, + Message: 'a + Clone, +{ + fn from(combo_box: ComboBox<'a, T, Message>) -> Self { + Self::new(combo_box) + } +} + +/// Search list of options for a given query. +pub fn search<'a, T, A>( + options: impl IntoIterator + 'a, + option_matchers: impl IntoIterator + 'a, + query: &'a str, +) -> impl Iterator + 'a +where + A: AsRef + 'a, +{ + let query: Vec = query + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .map(String::from) + .collect(); + + options + .into_iter() + .zip(option_matchers.into_iter()) + // Make sure each part of the query is found in the option + .filter_map(move |(option, matcher)| { + if query.iter().all(|part| matcher.as_ref().contains(part)) { + Some(option) + } else { + None + } + }) +} + +/// Build matchers from given list of options. +pub fn build_matchers<'a, T>(options: impl IntoIterator + 'a) -> Vec +where + T: Display + 'a, +{ + options + .into_iter() + .map(|opt| { + let mut matcher = opt.to_string(); + matcher.retain(|c| c.is_ascii_alphanumeric()); + matcher.to_lowercase() + }) + .collect() +} + +pub fn combo_box<'a, T, Message>( + state: &'a self::State, + placeholder: &str, + selection: Option<&T>, + on_selected: impl Fn(T) -> Message + 'static, +) -> ComboBox<'a, T, Message> +where + T: std::fmt::Display + Clone, +{ + ComboBox::new(state, placeholder, selection, on_selected) +}