diff --git a/src/theme/style/dropdown.rs b/src/theme/style/dropdown.rs index b6a0bc42149..17a8b9feb4f 100644 --- a/src/theme/style/dropdown.rs +++ b/src/theme/style/dropdown.rs @@ -23,6 +23,8 @@ impl dropdown::menu::StyleSheet for Theme { selected_text_color: cosmic.accent.base.into(), selected_background: Background::Color(cosmic.primary.component.hover.into()), + + description_color: cosmic.primary.component.on_disabled.into(), } } } diff --git a/src/widget/dropdown/menu/appearance.rs b/src/widget/dropdown/menu/appearance.rs index 806885f9ee5..e827b30393d 100644 --- a/src/widget/dropdown/menu/appearance.rs +++ b/src/widget/dropdown/menu/appearance.rs @@ -26,6 +26,8 @@ pub struct Appearance { pub selected_text_color: Color, /// Background when selected pub selected_background: Background, + /// Description text color + pub description_color: Color, } /// The style sheet of a menu. diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs index 93a2c2a3e05..f91acb6f1f6 100644 --- a/src/widget/dropdown/mod.rs +++ b/src/widget/dropdown/mod.rs @@ -5,6 +5,8 @@ pub mod menu; pub use menu::Menu; +pub mod multi; + mod widget; pub use widget::*; diff --git a/src/widget/dropdown/multi/menu.rs b/src/widget/dropdown/multi/menu.rs new file mode 100644 index 00000000000..cd510ddc889 --- /dev/null +++ b/src/widget/dropdown/multi/menu.rs @@ -0,0 +1,712 @@ +use super::Model; +pub use crate::widget::dropdown::menu::{Appearance, StyleSheet}; + +use std::ffi::OsStr; + +use crate::widget::{icon, Container}; +use iced_core::event::{self, Event}; +use iced_core::layout::{self, Layout}; +use iced_core::text::{self, Text}; +use iced_core::widget::Tree; +use iced_core::{ + alignment, mouse, overlay, renderer, svg, touch, Clipboard, Color, Element, Length, Padding, + Pixels, Point, Rectangle, Renderer, Shell, Size, Vector, Widget, +}; +use iced_widget::scrollable::Scrollable; + +/// A dropdown menu with multiple lists. +#[must_use] +pub struct Menu<'a, S, Item, Message> +where + S: AsRef, +{ + state: &'a mut State, + options: &'a Model, + hovered_option: &'a mut Option, + selected_option: Option<&'a Item>, + on_selected: Box Message + 'a>, + on_option_hovered: Option<&'a dyn Fn(Item) -> Message>, + width: f32, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + style: (), +} + +impl<'a, S, Item, Message: 'a> Menu<'a, S, Item, Message> +where + S: AsRef, + Item: Clone + PartialEq, +{ + /// Creates a new [`Menu`] with the given [`State`], a list of options, and + /// the message to produced when an option is selected. + pub(super) fn new( + state: &'a mut State, + options: &'a Model, + hovered_option: &'a mut Option, + selected_option: Option<&'a Item>, + on_selected: impl FnMut(Item) -> Message + 'a, + on_option_hovered: Option<&'a dyn Fn(Item) -> Message>, + ) -> Self { + Menu { + state, + options, + hovered_option, + selected_option, + on_selected: Box::new(on_selected), + on_option_hovered, + width: 0.0, + padding: Padding::ZERO, + text_size: None, + text_line_height: text::LineHeight::Absolute(Pixels::from(16.0)), + style: Default::default(), + } + } + + /// Sets the width of the [`Menu`]. + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } + + /// Sets the [`Padding`] of the [`Menu`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the text size of the [`Menu`]. + pub fn text_size(mut self, text_size: impl Into) -> Self { + self.text_size = Some(text_size.into().0); + self + } + + /// Sets the text [`LineHeight`] of the [`Menu`]. + pub fn text_line_height(mut self, line_height: impl Into) -> Self { + self.text_line_height = line_height.into(); + self + } + + /// Turns the [`Menu`] into an overlay [`Element`] at the given target + /// position. + /// + /// The `target_height` will be used to display the menu either on top + /// of the target or under it, depending on the screen position and the + /// dimensions of the [`Menu`]. + #[must_use] + pub fn overlay( + self, + position: Point, + target_height: f32, + ) -> overlay::Element<'a, Message, crate::Renderer> { + overlay::Element::new(position, Box::new(Overlay::new(self, target_height))) + } +} + +/// The local state of a [`Menu`]. +#[must_use] +#[derive(Debug)] +pub(super) struct State { + tree: Tree, +} + +impl State { + /// Creates a new [`State`] for a [`Menu`]. + pub fn new() -> Self { + Self { + tree: Tree::empty(), + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +struct Overlay<'a, Message> { + state: &'a mut Tree, + container: Container<'a, Message, crate::Renderer>, + width: f32, + target_height: f32, + style: (), +} + +impl<'a, Message: 'a> Overlay<'a, Message> { + pub fn new, Item: Clone + PartialEq>( + menu: Menu<'a, S, Item, Message>, + target_height: f32, + ) -> Self { + let Menu { + state, + options, + hovered_option, + selected_option, + on_selected, + on_option_hovered, + width, + padding, + text_size, + text_line_height, + style, + } = menu; + + let selected_icon = icon::from_name("object-select-symbolic").size(16).handle(); + + let mut container = Container::new(Scrollable::new(InnerList { + options, + hovered_option, + selected_option, + on_selected, + on_option_hovered, + text_size, + text_line_height, + padding, + selected_icon: match selected_icon.data { + icon::Data::Name(named) => named + .path() + .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg"))) + .map(iced_core::svg::Handle::from_path), + icon::Data::Svg(handle) => Some(handle), + icon::Data::Image(_) => None, + }, + })); + + container = container + .padding(padding) + .style(crate::style::Container::Dropdown); + + state.tree.diff(&mut container as &mut dyn Widget<_, _>); + + Self { + state: &mut state.tree, + container, + width, + target_height, + style, + } + } +} + +impl<'a, Message> iced_core::Overlay for Overlay<'a, Message> { + fn layout(&self, renderer: &crate::Renderer, bounds: Size, position: Point) -> layout::Node { + let space_below = bounds.height - (position.y + self.target_height); + let space_above = position.y; + + let limits = layout::Limits::new( + Size::ZERO, + Size::new( + bounds.width - position.x, + if space_below > space_above { + space_below + } else { + space_above + }, + ), + ) + .width(self.width); + + let mut node = self.container.layout(renderer, &limits); + + node.move_to(if space_below > space_above { + position + Vector::new(0.0, self.target_height) + } else { + position - Vector::new(0.0, node.size().height) + }); + + node + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let bounds = layout.bounds(); + + self.container.on_event( + self.state, event, layout, cursor, renderer, clipboard, shell, &bounds, + ) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self.container + .mouse_interaction(self.state, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let appearance = theme.appearance(&self.style); + let bounds = layout.bounds(); + + renderer.fill_quad( + renderer::Quad { + bounds, + border_color: appearance.border_color, + border_width: appearance.border_width, + border_radius: appearance.border_radius, + }, + appearance.background, + ); + + self.container + .draw(self.state, renderer, theme, style, layout, cursor, &bounds); + } +} + +struct InnerList<'a, S, Item, Message> { + options: &'a Model, + hovered_option: &'a mut Option, + selected_option: Option<&'a Item>, + on_selected: Box Message + 'a>, + on_option_hovered: Option<&'a dyn Fn(Item) -> Message>, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + selected_icon: Option, +} + +impl<'a, S, Item, Message> Widget for InnerList<'a, S, Item, Message> +where + S: AsRef, + Item: Clone + PartialEq, +{ + fn width(&self) -> Length { + Length::Fill + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout(&self, renderer: &crate::Renderer, limits: &layout::Limits) -> layout::Node { + use std::f32; + + let limits = limits.width(Length::Fill).height(Length::Shrink); + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let text_line_height = self.text_line_height.to_absolute(Pixels(text_size)); + + let lists = self.options.lists.len(); + let (descriptions, options) = self.options.lists.iter().fold((0, 0), |acc, l| { + ( + acc.0 + i32::from(l.description.is_some()), + acc.1 + l.options.len(), + ) + }); + + let vertical_padding = self.padding.vertical(); + let text_line_height = f32::from(text_line_height); + + let size = { + #[allow(clippy::cast_precision_loss)] + let intrinsic = Size::new(0.0, { + let text = vertical_padding + text_line_height; + let separators = ((vertical_padding / 2.0) + 1.0) * (lists - 1) as f32; + let descriptions = (text + 4.0) * descriptions as f32; + let options = text * options as f32; + separators + descriptions + options + }); + + limits.resolve(intrinsic) + }; + + layout::Node::new(size) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let bounds = layout.bounds(); + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if cursor.is_over(bounds) { + if let Some(item) = self.hovered_option.as_ref() { + shell.publish((self.on_selected)(item.clone())); + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some(cursor_position) = cursor.position_in(bounds) { + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let text_line_height = + f32::from(self.text_line_height.to_absolute(Pixels(text_size))); + + let heights = self + .options + .element_heights(self.padding.vertical(), text_line_height); + + let mut current_offset = 0.0; + + let previous_hover_option = self.hovered_option.take(); + + for (element, elem_height) in self.options.elements().zip(heights) { + let bounds = Rectangle { + x: 0.0, + y: 0.0 + current_offset, + width: bounds.width, + height: elem_height, + }; + + if bounds.contains(cursor_position) { + *self.hovered_option = if let OptionElement::Option((_, item)) = element + { + if previous_hover_option.as_ref() == Some(item) { + previous_hover_option + } else { + if let Some(on_option_hovered) = self.on_option_hovered { + shell.publish(on_option_hovered(item.clone())); + } + + Some(item.clone()) + } + } else { + None + }; + + break; + } + current_offset += elem_height; + } + } + } + Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(cursor_position) = cursor.position_in(bounds) { + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let text_line_height = + f32::from(self.text_line_height.to_absolute(Pixels(text_size))); + + let heights = self + .options + .element_heights(self.padding.vertical(), text_line_height); + + let mut current_offset = 0.0; + + let previous_hover_option = self.hovered_option.take(); + + for (element, elem_height) in self.options.elements().zip(heights) { + let bounds = Rectangle { + x: 0.0, + y: 0.0 + current_offset, + width: bounds.width, + height: elem_height, + }; + + if bounds.contains(cursor_position) { + *self.hovered_option = if let OptionElement::Option((_, item)) = element + { + if previous_hover_option.as_ref() == Some(item) { + previous_hover_option + } else { + Some(item.clone()) + } + } else { + None + }; + + if let Some(item) = self.hovered_option { + shell.publish((self.on_selected)(item.clone())); + } + + break; + } + current_offset += elem_height; + } + } + } + _ => {} + } + + event::Status::Ignored + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + let is_mouse_over = cursor.is_over(layout.bounds()); + + if is_mouse_over { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + #[allow(clippy::too_many_lines)] + fn draw( + &self, + _state: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let appearance = theme.appearance(&()); + let bounds = layout.bounds(); + + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let offset = viewport.y - bounds.y; + + let text_line_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))); + + let visible_options = self.options.visible_options( + self.padding.vertical(), + text_line_height, + offset, + viewport.height, + ); + + let mut current_offset = 0.0; + + for (elem, elem_height) in visible_options { + let bounds = Rectangle { + x: bounds.x, + y: bounds.y + current_offset, + width: bounds.width, + height: elem_height, + }; + + current_offset += elem_height; + + match elem { + OptionElement::Option((option, item)) => { + let (color, font) = if self.selected_option.as_ref() == Some(&item) { + let item_x = bounds.x + appearance.border_width; + let item_width = bounds.width - appearance.border_width * 2.0; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: item_x, + width: item_width, + ..bounds + }, + border_color: Color::TRANSPARENT, + border_width: 0.0, + border_radius: appearance.border_radius, + }, + appearance.selected_background, + ); + + if let Some(handle) = self.selected_icon.clone() { + svg::Renderer::draw( + renderer, + handle, + Some(appearance.selected_text_color), + Rectangle { + x: item_x + item_width - 16.0 - 8.0, + y: bounds.y + (bounds.height / 2.0 - 8.0), + width: 16.0, + height: 16.0, + }, + ); + } + + (appearance.selected_text_color, crate::font::FONT_SEMIBOLD) + } else if self.hovered_option.as_ref() == Some(item) { + let item_x = bounds.x + appearance.border_width; + let item_width = bounds.width - appearance.border_width * 2.0; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: item_x, + width: item_width, + ..bounds + }, + border_color: Color::TRANSPARENT, + border_width: 0.0, + border_radius: appearance.border_radius, + }, + appearance.hovered_background, + ); + + (appearance.hovered_text_color, crate::font::FONT) + } else { + (appearance.text_color, crate::font::FONT) + }; + + text::Renderer::fill_text( + renderer, + Text { + content: option.as_ref(), + bounds: Rectangle { + x: bounds.x + self.padding.left, + y: bounds.center_y(), + width: bounds.width, + ..bounds + }, + size: text_size, + line_height: self.text_line_height, + font, + color, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + }, + ); + } + + OptionElement::Separator => { + let divider = crate::widget::divider::horizontal::light().height(1.0); + + let mut layout_node = layout::Node::new(Size { + width: bounds.width, + height: 1.0, + }); + + layout_node.move_to(Point { + x: bounds.x, + y: bounds.y + self.padding.top, + }); + + Widget::::draw( + &crate::Element::::from(divider), + &Tree::empty(), + renderer, + theme, + style, + Layout::new(&layout_node), + cursor, + viewport, + ); + } + + OptionElement::Description(description) => { + text::Renderer::fill_text( + renderer, + Text { + content: description.as_ref(), + bounds: Rectangle { + x: bounds.center_x(), + y: bounds.center_y(), + ..bounds + }, + size: text_size, + line_height: text::LineHeight::Absolute(Pixels(text_line_height + 4.0)), + font: crate::font::FONT, + color: appearance.description_color, + horizontal_alignment: alignment::Horizontal::Center, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + }, + ); + } + } + } + } +} + +impl<'a, S, Item, Message: 'a> From> + for Element<'a, Message, crate::Renderer> +where + S: AsRef, + Item: Clone + PartialEq, +{ + fn from(list: InnerList<'a, S, Item, Message>) -> Self { + Element::new(list) + } +} + +pub(super) enum OptionElement<'a, S, Item> { + Description(&'a S), + Option(&'a (S, Item)), + Separator, +} + +impl Model { + pub(super) fn elements(&self) -> impl Iterator> + '_ { + let iterator = self.lists.iter().flat_map(|list| { + let description = list + .description + .as_ref() + .into_iter() + .map(OptionElement::Description); + + let options = list.options.iter().map(OptionElement::Option); + + description + .chain(options) + .chain(std::iter::once(OptionElement::Separator)) + }); + + iterator + } + + fn element_heights( + &self, + vertical_padding: f32, + text_line_height: f32, + ) -> impl Iterator + '_ { + self.elements().map(move |element| match element { + OptionElement::Option(_) => vertical_padding + text_line_height, + OptionElement::Separator => (vertical_padding / 2.0) + 1.0, + OptionElement::Description(_) => vertical_padding + text_line_height + 4.0, + }) + } + + fn visible_options( + &self, + padding_vertical: f32, + text_line_height: f32, + offset: f32, + height: f32, + ) -> impl Iterator, f32)> + '_ { + let heights = self.element_heights(padding_vertical, text_line_height); + + let mut current = 0.0; + self.elements() + .zip(heights) + .filter(move |(_, element_height)| { + let end = current + element_height; + let visible = current >= offset && end <= offset + height; + current = end; + visible + }) + } +} diff --git a/src/widget/dropdown/multi/mod.rs b/src/widget/dropdown/multi/mod.rs new file mode 100644 index 00000000000..2f3a68e52ea --- /dev/null +++ b/src/widget/dropdown/multi/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2023 System76 +// Copyright 2019 Héctor Ramón, Iced contributors +// SPDX-License-Identifier: MPL-2.0 AND MIT + +mod model; +pub use model::{list, model, List, Model}; + +pub mod menu; +pub use menu::Menu; + +mod widget; +pub use widget::{Appearance, Dropdown, StyleSheet}; + +pub fn dropdown<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static>( + model: &'a Model, + on_selected: impl Fn(Item) -> Message + 'a, +) -> Dropdown<'a, S, Message, Item> { + Dropdown::new(model, on_selected) +} diff --git a/src/widget/dropdown/multi/model.rs b/src/widget/dropdown/multi/model.rs new file mode 100644 index 00000000000..12bf426925a --- /dev/null +++ b/src/widget/dropdown/multi/model.rs @@ -0,0 +1,102 @@ +//! A [`Model`] for a multi menu dropdown widget. + +/// Create a [`Model`] for a multi-list dropdown. +pub fn model() -> Model { + Model { + lists: Vec::new(), + selected: None, + } +} + +/// Create a [`List`] for a multi-list dropdown widget. +pub fn list(description: Option, options: Vec<(S, Item)>) -> List { + List { + description, + options, + } +} + +/// A model for managing the options in a multi-list dropdown. +/// +/// ```no_run +/// #[derive(Copy, Clone, Eq, PartialEq)] +/// enum MenuOption { +/// Option1, +/// Option2, +/// Option3, +/// Option4, +/// Option5, +/// Option6 +/// } +/// use cosmic::widget::dropdown; +/// +/// let mut model = dropdown::multi::model(); +/// +/// model.insert(dropdown::multi::list(Some("List A"), vec![ +/// ("Option 1", MenuOption::Option1), +/// ("Option 2", MenuOption::Option2), +/// ("Option 3", MenuOption::Option3) +/// ])); +/// +/// model.insert(dropdown::multi::list(Some("List B"), vec![ +/// ("Option 4", MenuOption::Option4), +/// ("Option 5", MenuOption::Option5), +/// ("Option 6", MenuOption::Option6) +/// ])); +/// +/// model.clear(); +/// ``` +#[must_use] +pub struct Model { + pub lists: Vec>, + pub selected: Option, +} + +impl Model { + pub(super) fn get(&self, item: &Item) -> Option<&S> { + for list in &self.lists { + for option in &list.options { + if &option.1 == item { + return Some(&option.0); + } + } + } + + None + } + + pub(super) fn next(&self) -> Option<&(S, Item)> { + let Some(item) = self.selected.as_ref() else { + return None; + }; + + let mut next = false; + for list in &self.lists { + for option in &list.options { + if next { + return Some(option); + } + + if &option.1 == item { + next = true; + } + } + } + + None + } + + pub fn clear(&mut self) { + self.lists.clear(); + } + + pub fn insert(&mut self, list: List) { + self.lists.push(list); + } +} + +/// A list for a multi-list dropdown widget. +pub struct List { + pub description: Option, + pub options: Vec<(S, Item)>, +} diff --git a/src/widget/dropdown/multi/widget.rs b/src/widget/dropdown/multi/widget.rs new file mode 100644 index 00000000000..df5d618bd93 --- /dev/null +++ b/src/widget/dropdown/multi/widget.rs @@ -0,0 +1,507 @@ +// Copyright 2023 System76 +// Copyright 2019 Héctor Ramón, Iced contributors +// SPDX-License-Identifier: MPL-2.0 AND MIT + +use super::menu::{self, Menu}; +use crate::widget::icon; +use derive_setters::Setters; +use iced_core::event::{self, Event}; +use iced_core::text::{self, Text}; +use iced_core::widget::tree::{self, Tree}; +use iced_core::{alignment, keyboard, layout, mouse, overlay, renderer, svg, touch}; +use iced_core::{Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Widget}; +use std::ffi::OsStr; + +pub use iced_widget::style::pick_list::{Appearance, StyleSheet}; + +/// A widget for selecting a single value from a list of selections. +#[derive(Setters)] +pub struct Dropdown<'a, S: AsRef, Message, Item> { + #[setters(skip)] + on_selected: Box Message + 'a>, + #[setters(skip)] + selections: &'a super::Model, + #[setters(into)] + width: Length, + gap: f32, + #[setters(into)] + padding: Padding, + #[setters(strip_option)] + text_size: Option, + text_line_height: text::LineHeight, + #[setters(strip_option)] + font: Option, +} + +impl<'a, S: AsRef, Message, Item: Clone + PartialEq + 'static> Dropdown<'a, S, Message, Item> { + /// The default gap. + pub const DEFAULT_GAP: f32 = 4.0; + + /// The default padding. + pub const DEFAULT_PADDING: Padding = Padding::new(8.0); + + /// Creates a new [`Dropdown`] with the given list of selections, the current + /// selected value, and the message to produce when an option is selected. + pub fn new( + selections: &'a super::Model, + on_selected: impl Fn(Item) -> Message + 'a, + ) -> Self { + Self { + on_selected: Box::new(on_selected), + selections, + width: Length::Shrink, + gap: Self::DEFAULT_GAP, + padding: Self::DEFAULT_PADDING, + text_size: None, + text_line_height: text::LineHeight::Relative(1.2), + font: None, + } + } +} + +impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> + Widget for Dropdown<'a, S, Message, Item> +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout(&self, renderer: &crate::Renderer, limits: &layout::Limits) -> layout::Node { + layout( + renderer, + limits, + self.width, + self.gap, + self.padding, + self.text_size.unwrap_or(14.0), + self.text_line_height, + self.font, + self.selections + .selected + .as_ref() + .and_then(|id| self.selections.get(id)) + .map(AsRef::as_ref), + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &crate::Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + update( + &event, + layout, + cursor, + shell, + self.on_selected.as_ref(), + self.selections, + || tree.state.downcast_mut::>(), + ) + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + mouse_interaction(layout, cursor) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + _style: &iced_core::renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let font = self + .font + .unwrap_or_else(|| text::Renderer::default_font(renderer)); + + draw( + renderer, + theme, + layout, + cursor, + self.gap, + self.padding, + self.text_size, + self.text_line_height, + font, + self.selections + .selected + .as_ref() + .and_then(|id| self.selections.get(id)), + tree.state.downcast_ref::>(), + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &crate::Renderer, + ) -> Option> { + let state = tree.state.downcast_mut::>(); + + overlay( + layout, + renderer, + state, + self.gap, + self.padding, + self.text_size.unwrap_or(14.0), + self.font, + self.selections, + &self.on_selected, + ) + } +} + +impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> + From> for crate::Element<'a, Message> +{ + fn from(pick_list: Dropdown<'a, S, Message, Item>) -> Self { + Self::new(pick_list) + } +} + +/// The local state of a [`Dropdown`]. +#[derive(Debug)] +pub struct State { + icon: Option, + menu: menu::State, + keyboard_modifiers: keyboard::Modifiers, + is_open: bool, + hovered_option: Option, +} + +impl State { + /// Creates a new [`State`] for a [`Dropdown`]. + pub fn new() -> Self { + Self { + icon: match icon::from_name("pan-down-symbolic").size(16).handle().data { + icon::Data::Name(named) => named + .path() + .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg"))) + .map(iced_core::svg::Handle::from_path), + icon::Data::Svg(handle) => Some(handle), + icon::Data::Image(_) => None, + }, + menu: menu::State::default(), + keyboard_modifiers: keyboard::Modifiers::default(), + is_open: false, + hovered_option: None, + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +/// Computes the layout of a [`Dropdown`]. +#[allow(clippy::too_many_arguments)] +pub fn layout( + renderer: &crate::Renderer, + limits: &layout::Limits, + width: Length, + gap: f32, + padding: Padding, + text_size: f32, + text_line_height: text::LineHeight, + font: Option, + selection: Option<&str>, +) -> layout::Node { + use std::f32; + + let limits = limits.width(width).height(Length::Shrink).pad(padding); + + let max_width = match width { + Length::Shrink => { + let measure = |label: &str| -> f32 { + let width = text::Renderer::measure_width( + renderer, + label, + text_size, + font.unwrap_or_else(|| text::Renderer::default_font(renderer)), + text::Shaping::Advanced, + ); + + width.round() + }; + + selection.map(measure).unwrap_or_default() + } + _ => 0.0, + }; + + let size = { + let intrinsic = Size::new( + max_width + gap + 16.0, + f32::from(text_line_height.to_absolute(Pixels(text_size))), + ); + + limits.resolve(intrinsic).pad(padding) + }; + + layout::Node::new(size) +} + +/// Processes an [`Event`] and updates the [`State`] of a [`Dropdown`] +/// accordingly. +#[allow(clippy::too_many_arguments)] +pub fn update<'a, S: AsRef, Message, Item: Clone + PartialEq + 'static + 'a>( + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + on_selected: &dyn Fn(Item) -> Message, + selections: &super::Model, + state: impl FnOnce() -> &'a mut State, +) -> event::Status { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state(); + + 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 cursor.is_over(layout.bounds()) { + state.is_open = true; + state.hovered_option = selections.selected.clone(); + + event::Status::Captured + } else { + event::Status::Ignored + } + } + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { .. }, + }) => { + let state = state(); + + if state.keyboard_modifiers.command() + && cursor.is_over(layout.bounds()) + && !state.is_open + { + if let Some(option) = selections.next() { + shell.publish((on_selected)(option.1.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 [`Dropdown`]. +#[must_use] +pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Interaction { + let bounds = layout.bounds(); + let is_mouse_over = cursor.is_over(bounds); + + if is_mouse_over { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } +} + +/// Returns the current overlay of a [`Dropdown`]. +#[allow(clippy::too_many_arguments)] +pub fn overlay<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static>( + layout: Layout<'_>, + renderer: &crate::Renderer, + state: &'a mut State, + gap: f32, + padding: Padding, + text_size: f32, + font: Option, + selections: &'a super::Model, + on_selected: &'a dyn Fn(Item) -> Message, +) -> Option> { + if state.is_open { + let bounds = layout.bounds(); + + let menu = Menu::new( + &mut state.menu, + selections, + &mut state.hovered_option, + selections.selected.as_ref(), + |option| { + state.is_open = false; + + (on_selected)(option) + }, + None, + ) + .width({ + let measure = |label: &str| -> f32 { + let width = text::Renderer::measure_width( + renderer, + label, + text_size, + crate::font::FONT, + text::Shaping::Advanced, + ); + + width.round() + }; + + let measure_description = |label: &str| -> f32 { + let width = text::Renderer::measure_width( + renderer, + label, + text_size + 4.0, + crate::font::FONT, + text::Shaping::Advanced, + ); + + width.round() + }; + + selections + .elements() + .map(|element| match element { + super::menu::OptionElement::Description(desc) => { + measure_description(desc.as_ref()) + } + + super::menu::OptionElement::Option((option, _item)) => measure(option.as_ref()), + + super::menu::OptionElement::Separator => 1.0, + }) + .fold(0.0, |next, current| current.max(next)) + + gap + + 16.0 + + (padding.horizontal() * 2.0) + }) + .padding(padding) + .text_size(text_size); + + let mut position = layout.position(); + position.x -= padding.left; + + Some(menu.overlay(position, bounds.height)) + } else { + None + } +} + +/// Draws a [`Dropdown`]. +#[allow(clippy::too_many_arguments)] +pub fn draw<'a, S, Item: Clone + PartialEq + 'static>( + renderer: &mut crate::Renderer, + theme: &crate::Theme, + layout: Layout<'_>, + cursor: mouse::Cursor, + gap: f32, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + font: crate::font::Font, + selected: Option<&'a S>, + state: &'a State, +) where + S: AsRef + 'a, +{ + let bounds = layout.bounds(); + let is_mouse_over = cursor.is_over(bounds); + + let style = if is_mouse_over { + theme.hovered(&()) + } else { + theme.active(&()) + }; + + iced_core::Renderer::fill_quad( + renderer, + renderer::Quad { + bounds, + border_color: style.border_color, + border_width: style.border_width, + border_radius: style.border_radius, + }, + style.background, + ); + + if let Some(handle) = state.icon.clone() { + svg::Renderer::draw( + renderer, + handle, + Some(style.text_color), + Rectangle { + x: bounds.x + bounds.width - gap - 16.0, + y: bounds.center_y() - 8.0, + width: 16.0, + height: 16.0, + }, + ); + } + + if let Some(content) = selected.map(AsRef::as_ref) { + let text_size = text_size.unwrap_or_else(|| text::Renderer::default_size(renderer)); + + text::Renderer::fill_text( + renderer, + Text { + content, + size: text_size, + line_height: text_line_height, + font, + color: style.text_color, + bounds: Rectangle { + x: bounds.x + padding.left, + y: bounds.center_y(), + width: bounds.width - padding.horizontal(), + height: f32::from(text_line_height.to_absolute(Pixels(text_size))), + }, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + }, + ); + } +} diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index b722f9561a5..fbcf7dcbffe 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -404,7 +404,6 @@ pub fn overlay<'a, S: AsRef, Message: 'a>( let mut position = layout.position(); position.x -= padding.left; - Some(menu.overlay(position, bounds.height)) } else { None