summaryrefslogtreecommitdiffstats
path: root/widget/src/pick_list.rs
diff options
context:
space:
mode:
Diffstat (limited to 'widget/src/pick_list.rs')
-rw-r--r--widget/src/pick_list.rs658
1 files changed, 658 insertions, 0 deletions
diff --git a/widget/src/pick_list.rs b/widget/src/pick_list.rs
new file mode 100644
index 00000000..cd23cdd2
--- /dev/null
+++ b/widget/src/pick_list.rs
@@ -0,0 +1,658 @@
+//! Display a dropdown list of selectable values.
+use crate::container;
+use crate::core::alignment;
+use crate::core::event::{self, Event};
+use crate::core::keyboard;
+use crate::core::layout;
+use crate::core::mouse;
+use crate::core::overlay;
+use crate::core::renderer;
+use crate::core::text::{self, Text};
+use crate::core::touch;
+use crate::core::widget::tree::{self, Tree};
+use crate::core::{
+ Clipboard, Element, Layout, Length, Padding, Pixels, Point, Rectangle,
+ Shell, Size, Widget,
+};
+use crate::overlay::menu::{self, Menu};
+use crate::scrollable;
+
+use std::borrow::Cow;
+
+pub use crate::style::pick_list::{Appearance, StyleSheet};
+
+/// A widget for selecting a single value from a list of options.
+#[allow(missing_debug_implementations)]
+pub struct PickList<'a, T, Message, Renderer = crate::Renderer>
+where
+ [T]: ToOwned<Owned = Vec<T>>,
+ Renderer: text::Renderer,
+ Renderer::Theme: StyleSheet,
+{
+ on_selected: Box<dyn Fn(T) -> Message + 'a>,
+ options: Cow<'a, [T]>,
+ placeholder: Option<String>,
+ selected: Option<T>,
+ width: Length,
+ padding: Padding,
+ text_size: Option<f32>,
+ font: Option<Renderer::Font>,
+ handle: Handle<Renderer::Font>,
+ style: <Renderer::Theme as StyleSheet>::Style,
+}
+
+impl<'a, T: 'a, Message, Renderer> PickList<'a, T, Message, Renderer>
+where
+ T: ToString + Eq,
+ [T]: ToOwned<Owned = Vec<T>>,
+ Renderer: text::Renderer,
+ Renderer::Theme: StyleSheet
+ + scrollable::StyleSheet
+ + menu::StyleSheet
+ + container::StyleSheet,
+ <Renderer::Theme as menu::StyleSheet>::Style:
+ From<<Renderer::Theme as StyleSheet>::Style>,
+{
+ /// The default padding of a [`PickList`].
+ pub const DEFAULT_PADDING: Padding = Padding::new(5.0);
+
+ /// Creates a new [`PickList`] with the given list of options, the current
+ /// selected value, and the message to produce when an option is selected.
+ pub fn new(
+ options: impl Into<Cow<'a, [T]>>,
+ selected: Option<T>,
+ on_selected: impl Fn(T) -> Message + 'a,
+ ) -> Self {
+ Self {
+ on_selected: Box::new(on_selected),
+ options: options.into(),
+ placeholder: None,
+ selected,
+ width: Length::Shrink,
+ padding: Self::DEFAULT_PADDING,
+ text_size: None,
+ font: None,
+ handle: Default::default(),
+ style: Default::default(),
+ }
+ }
+
+ /// Sets the placeholder of the [`PickList`].
+ pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
+ self.placeholder = Some(placeholder.into());
+ self
+ }
+
+ /// Sets the width of the [`PickList`].
+ pub fn width(mut self, width: impl Into<Length>) -> Self {
+ self.width = width.into();
+ self
+ }
+
+ /// Sets the [`Padding`] of the [`PickList`].
+ pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
+ self.padding = padding.into();
+ self
+ }
+
+ /// Sets the text size of the [`PickList`].
+ pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
+ self.text_size = Some(size.into().0);
+ self
+ }
+
+ /// Sets the font of the [`PickList`].
+ pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
+ self.font = Some(font.into());
+ self
+ }
+
+ /// Sets the [`Handle`] of the [`PickList`].
+ pub fn handle(mut self, handle: Handle<Renderer::Font>) -> Self {
+ self.handle = handle;
+ self
+ }
+
+ /// Sets the style of the [`PickList`].
+ pub fn style(
+ mut self,
+ style: impl Into<<Renderer::Theme as StyleSheet>::Style>,
+ ) -> Self {
+ self.style = style.into();
+ self
+ }
+}
+
+impl<'a, T: 'a, Message, Renderer> Widget<Message, Renderer>
+ for PickList<'a, T, Message, Renderer>
+where
+ T: Clone + ToString + Eq + 'static,
+ [T]: ToOwned<Owned = Vec<T>>,
+ Message: 'a,
+ Renderer: text::Renderer + 'a,
+ Renderer::Theme: StyleSheet
+ + scrollable::StyleSheet
+ + menu::StyleSheet
+ + container::StyleSheet,
+ <Renderer::Theme as menu::StyleSheet>::Style:
+ From<<Renderer::Theme as StyleSheet>::Style>,
+{
+ fn tag(&self) -> tree::Tag {
+ tree::Tag::of::<State<T>>()
+ }
+
+ fn state(&self) -> tree::State {
+ tree::State::new(State::<T>::new())
+ }
+
+ fn width(&self) -> Length {
+ self.width
+ }
+
+ fn height(&self) -> Length {
+ Length::Shrink
+ }
+
+ fn layout(
+ &self,
+ renderer: &Renderer,
+ limits: &layout::Limits,
+ ) -> layout::Node {
+ layout(
+ renderer,
+ limits,
+ self.width,
+ self.padding,
+ self.text_size,
+ self.font,
+ self.placeholder.as_deref(),
+ &self.options,
+ )
+ }
+
+ fn on_event(
+ &mut self,
+ tree: &mut Tree,
+ event: Event,
+ layout: Layout<'_>,
+ cursor_position: Point,
+ _renderer: &Renderer,
+ _clipboard: &mut dyn Clipboard,
+ shell: &mut Shell<'_, Message>,
+ ) -> event::Status {
+ update(
+ event,
+ layout,
+ cursor_position,
+ shell,
+ self.on_selected.as_ref(),
+ self.selected.as_ref(),
+ &self.options,
+ || tree.state.downcast_mut::<State<T>>(),
+ )
+ }
+
+ fn mouse_interaction(
+ &self,
+ _tree: &Tree,
+ layout: Layout<'_>,
+ cursor_position: Point,
+ _viewport: &Rectangle,
+ _renderer: &Renderer,
+ ) -> mouse::Interaction {
+ mouse_interaction(layout, cursor_position)
+ }
+
+ fn draw(
+ &self,
+ tree: &Tree,
+ renderer: &mut Renderer,
+ theme: &Renderer::Theme,
+ _style: &renderer::Style,
+ layout: Layout<'_>,
+ cursor_position: Point,
+ _viewport: &Rectangle,
+ ) {
+ let font = self.font.unwrap_or_else(|| renderer.default_font());
+ draw(
+ renderer,
+ theme,
+ layout,
+ cursor_position,
+ self.padding,
+ self.text_size,
+ font,
+ self.placeholder.as_deref(),
+ self.selected.as_ref(),
+ &self.handle,
+ &self.style,
+ || tree.state.downcast_ref::<State<T>>(),
+ )
+ }
+
+ fn overlay<'b>(
+ &'b mut self,
+ tree: &'b mut Tree,
+ layout: Layout<'_>,
+ renderer: &Renderer,
+ ) -> Option<overlay::Element<'b, Message, Renderer>> {
+ let state = tree.state.downcast_mut::<State<T>>();
+
+ overlay(
+ layout,
+ state,
+ self.padding,
+ self.text_size,
+ self.font.unwrap_or_else(|| renderer.default_font()),
+ &self.options,
+ self.style.clone(),
+ )
+ }
+}
+
+impl<'a, T: 'a, Message, Renderer> From<PickList<'a, T, Message, Renderer>>
+ for Element<'a, Message, Renderer>
+where
+ T: Clone + ToString + Eq + 'static,
+ [T]: ToOwned<Owned = Vec<T>>,
+ Message: 'a,
+ Renderer: text::Renderer + 'a,
+ Renderer::Theme: StyleSheet
+ + scrollable::StyleSheet
+ + menu::StyleSheet
+ + container::StyleSheet,
+ <Renderer::Theme as menu::StyleSheet>::Style:
+ From<<Renderer::Theme as StyleSheet>::Style>,
+{
+ fn from(pick_list: PickList<'a, T, Message, Renderer>) -> Self {
+ Self::new(pick_list)
+ }
+}
+
+/// The local state of a [`PickList`].
+#[derive(Debug)]
+pub struct State<T> {
+ menu: menu::State,
+ keyboard_modifiers: keyboard::Modifiers,
+ is_open: bool,
+ hovered_option: Option<usize>,
+ last_selection: Option<T>,
+}
+
+impl<T> State<T> {
+ /// Creates a new [`State`] for a [`PickList`].
+ pub fn new() -> Self {
+ Self {
+ menu: menu::State::default(),
+ keyboard_modifiers: keyboard::Modifiers::default(),
+ is_open: bool::default(),
+ hovered_option: Option::default(),
+ last_selection: Option::default(),
+ }
+ }
+}
+
+impl<T> Default for State<T> {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// The handle to the right side of the [`PickList`].
+#[derive(Debug, Clone, PartialEq)]
+pub enum Handle<Font> {
+ /// Displays an arrow icon (▼).
+ ///
+ /// This is the default.
+ Arrow {
+ /// Font size of the content.
+ size: Option<f32>,
+ },
+ /// A custom static handle.
+ Static(Icon<Font>),
+ /// A custom dynamic handle.
+ Dynamic {
+ /// The [`Icon`] used when [`PickList`] is closed.
+ closed: Icon<Font>,
+ /// The [`Icon`] used when [`PickList`] is open.
+ open: Icon<Font>,
+ },
+ /// No handle will be shown.
+ None,
+}
+
+impl<Font> Default for Handle<Font> {
+ fn default() -> Self {
+ Self::Arrow { size: None }
+ }
+}
+
+/// The icon of a [`Handle`].
+#[derive(Debug, Clone, PartialEq)]
+pub struct Icon<Font> {
+ /// Font that will be used to display the `code_point`,
+ pub font: Font,
+ /// The unicode code point that will be used as the icon.
+ pub code_point: char,
+ /// Font size of the content.
+ pub size: Option<f32>,
+}
+
+/// Computes the layout of a [`PickList`].
+pub fn layout<Renderer, T>(
+ renderer: &Renderer,
+ limits: &layout::Limits,
+ width: Length,
+ padding: Padding,
+ text_size: Option<f32>,
+ font: Option<Renderer::Font>,
+ placeholder: Option<&str>,
+ options: &[T],
+) -> layout::Node
+where
+ Renderer: text::Renderer,
+ T: ToString,
+{
+ use std::f32;
+
+ let limits = limits.width(width).height(Length::Shrink).pad(padding);
+ let text_size = text_size.unwrap_or_else(|| renderer.default_size());
+
+ let max_width = match width {
+ Length::Shrink => {
+ let measure = |label: &str| -> f32 {
+ let (width, _) = renderer.measure(
+ label,
+ text_size,
+ font.unwrap_or_else(|| renderer.default_font()),
+ Size::new(f32::INFINITY, f32::INFINITY),
+ );
+
+ width.round()
+ };
+
+ let labels = options.iter().map(ToString::to_string);
+
+ let labels_width = labels
+ .map(|label| measure(&label))
+ .fold(100.0, |candidate, current| current.max(candidate));
+
+ let placeholder_width = placeholder.map(measure).unwrap_or(100.0);
+
+ labels_width.max(placeholder_width)
+ }
+ _ => 0.0,
+ };
+
+ let size = {
+ let intrinsic =
+ Size::new(max_width + text_size + padding.left, text_size * 1.2);
+
+ limits.resolve(intrinsic).pad(padding)
+ };
+
+ layout::Node::new(size)
+}
+
+/// Processes an [`Event`] and updates the [`State`] of a [`PickList`]
+/// accordingly.
+pub fn update<'a, T, Message>(
+ event: Event,
+ layout: Layout<'_>,
+ cursor_position: Point,
+ shell: &mut Shell<'_, Message>,
+ on_selected: &dyn Fn(T) -> Message,
+ selected: Option<&T>,
+ options: &[T],
+ state: impl FnOnce() -> &'a mut State<T>,
+) -> event::Status
+where
+ T: PartialEq + Clone + 'a,
+{
+ match event {
+ Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
+ | Event::Touch(touch::Event::FingerPressed { .. }) => {
+ let state = state();
+
+ let event_status = if state.is_open {
+ // Event wasn't processed by overlay, so cursor was clicked either outside it's
+ // bounds or on the drop-down, either way we close the overlay.
+ state.is_open = false;
+
+ event::Status::Captured
+ } else if layout.bounds().contains(cursor_position) {
+ state.is_open = true;
+ state.hovered_option =
+ options.iter().position(|option| Some(option) == selected);
+
+ event::Status::Captured
+ } else {
+ event::Status::Ignored
+ };
+
+ if let Some(last_selection) = state.last_selection.take() {
+ shell.publish((on_selected)(last_selection));
+
+ state.is_open = false;
+
+ event::Status::Captured
+ } else {
+ event_status
+ }
+ }
+ Event::Mouse(mouse::Event::WheelScrolled {
+ delta: mouse::ScrollDelta::Lines { y, .. },
+ }) => {
+ let state = state();
+
+ if state.keyboard_modifiers.command()
+ && layout.bounds().contains(cursor_position)
+ && !state.is_open
+ {
+ fn find_next<'a, T: PartialEq>(
+ selected: &'a T,
+ mut options: impl Iterator<Item = &'a T>,
+ ) -> Option<&'a T> {
+ let _ = options.find(|&option| option == selected);
+
+ options.next()
+ }
+
+ let next_option = if y < 0.0 {
+ if let Some(selected) = selected {
+ find_next(selected, options.iter())
+ } else {
+ options.first()
+ }
+ } else if y > 0.0 {
+ if let Some(selected) = selected {
+ find_next(selected, options.iter().rev())
+ } else {
+ options.last()
+ }
+ } else {
+ None
+ };
+
+ if let Some(next_option) = next_option {
+ shell.publish((on_selected)(next_option.clone()));
+ }
+
+ event::Status::Captured
+ } else {
+ event::Status::Ignored
+ }
+ }
+ Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
+ let state = state();
+
+ state.keyboard_modifiers = modifiers;
+
+ event::Status::Ignored
+ }
+ _ => event::Status::Ignored,
+ }
+}
+
+/// Returns the current [`mouse::Interaction`] of a [`PickList`].
+pub fn mouse_interaction(
+ layout: Layout<'_>,
+ cursor_position: Point,
+) -> mouse::Interaction {
+ let bounds = layout.bounds();
+ let is_mouse_over = bounds.contains(cursor_position);
+
+ if is_mouse_over {
+ mouse::Interaction::Pointer
+ } else {
+ mouse::Interaction::default()
+ }
+}
+
+/// Returns the current overlay of a [`PickList`].
+pub fn overlay<'a, T, Message, Renderer>(
+ layout: Layout<'_>,
+ state: &'a mut State<T>,
+ padding: Padding,
+ text_size: Option<f32>,
+ font: Renderer::Font,
+ options: &'a [T],
+ style: <Renderer::Theme as StyleSheet>::Style,
+) -> Option<overlay::Element<'a, Message, Renderer>>
+where
+ T: Clone + ToString,
+ Message: 'a,
+ Renderer: text::Renderer + 'a,
+ Renderer::Theme: StyleSheet
+ + scrollable::StyleSheet
+ + menu::StyleSheet
+ + container::StyleSheet,
+ <Renderer::Theme as menu::StyleSheet>::Style:
+ From<<Renderer::Theme as StyleSheet>::Style>,
+{
+ if state.is_open {
+ let bounds = layout.bounds();
+
+ let mut menu = Menu::new(
+ &mut state.menu,
+ options,
+ &mut state.hovered_option,
+ &mut state.last_selection,
+ )
+ .width(bounds.width)
+ .padding(padding)
+ .font(font)
+ .style(style);
+
+ if let Some(text_size) = text_size {
+ menu = menu.text_size(text_size);
+ }
+
+ Some(menu.overlay(layout.position(), bounds.height))
+ } else {
+ None
+ }
+}
+
+/// Draws a [`PickList`].
+pub fn draw<'a, T, Renderer>(
+ renderer: &mut Renderer,
+ theme: &Renderer::Theme,
+ layout: Layout<'_>,
+ cursor_position: Point,
+ padding: Padding,
+ text_size: Option<f32>,
+ font: Renderer::Font,
+ placeholder: Option<&str>,
+ selected: Option<&T>,
+ handle: &Handle<Renderer::Font>,
+ style: &<Renderer::Theme as StyleSheet>::Style,
+ state: impl FnOnce() -> &'a State<T>,
+) where
+ Renderer: text::Renderer,
+ Renderer::Theme: StyleSheet,
+ T: ToString + 'a,
+{
+ let bounds = layout.bounds();
+ let is_mouse_over = bounds.contains(cursor_position);
+ let is_selected = selected.is_some();
+
+ let style = if is_mouse_over {
+ theme.hovered(style)
+ } else {
+ theme.active(style)
+ };
+
+ renderer.fill_quad(
+ renderer::Quad {
+ bounds,
+ border_color: style.border_color,
+ border_width: style.border_width,
+ border_radius: style.border_radius.into(),
+ },
+ style.background,
+ );
+
+ let handle = match handle {
+ Handle::Arrow { size } => {
+ Some((Renderer::ICON_FONT, Renderer::ARROW_DOWN_ICON, *size))
+ }
+ Handle::Static(Icon {
+ font,
+ code_point,
+ size,
+ }) => Some((*font, *code_point, *size)),
+ Handle::Dynamic { open, closed } => {
+ if state().is_open {
+ Some((open.font, open.code_point, open.size))
+ } else {
+ Some((closed.font, closed.code_point, closed.size))
+ }
+ }
+ Handle::None => None,
+ };
+
+ if let Some((font, code_point, size)) = handle {
+ let size = size.unwrap_or_else(|| renderer.default_size());
+
+ renderer.fill_text(Text {
+ content: &code_point.to_string(),
+ size,
+ font,
+ color: style.handle_color,
+ bounds: Rectangle {
+ x: bounds.x + bounds.width - padding.horizontal(),
+ y: bounds.center_y(),
+ height: size * 1.2,
+ ..bounds
+ },
+ horizontal_alignment: alignment::Horizontal::Right,
+ vertical_alignment: alignment::Vertical::Center,
+ });
+ }
+
+ let label = selected.map(ToString::to_string);
+
+ if let Some(label) = label.as_deref().or(placeholder) {
+ let text_size = text_size.unwrap_or_else(|| renderer.default_size());
+
+ renderer.fill_text(Text {
+ content: label,
+ size: text_size,
+ font,
+ color: if is_selected {
+ style.text_color
+ } else {
+ style.placeholder_color
+ },
+ bounds: Rectangle {
+ x: bounds.x + padding.left,
+ y: bounds.center_y(),
+ width: bounds.width - padding.horizontal(),
+ height: text_size * 1.2,
+ },
+ horizontal_alignment: alignment::Horizontal::Left,
+ vertical_alignment: alignment::Vertical::Center,
+ });
+ }
+}