diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index 842e0c44df1..5b42b7d2f8c 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -3,12 +3,12 @@ use std::{cell::RefCell, rc::Rc}; use apply::Apply; use cosmic::{ cosmic_theme, - iced::widget::{checkbox, column, pick_list, progress_bar, radio, slider, text, text_input}, + iced::widget::{checkbox, column, progress_bar, radio, slider, text, text_input}, iced::{id, Alignment, Length}, iced_core::Color, theme::ThemeType, widget::{ - button, color_picker::ColorPickerUpdate, cosmic_container::container, icon, + button, color_picker::ColorPickerUpdate, cosmic_container::container, dropdown, icon, segmented_button, segmented_selection, settings, spin_button, toggler, view_switcher, ColorPickerModel, }, @@ -74,7 +74,7 @@ pub enum Message { Debug(bool), IconTheme(segmented_button::Entity), MultiSelection(segmented_button::Entity), - PickListSelected(&'static str), + DropdownSelect(usize), RowSelected(usize), ScalingFactor(spin_button::Message), Selection(segmented_button::Entity), @@ -102,8 +102,8 @@ pub struct State { pub checkbox_value: bool, pub icon_themes: segmented_button::SingleSelectModel, pub multi_selection: segmented_button::MultiSelectModel, - pub pick_list_selected: Option<&'static str>, - pub pick_list_options: Vec<&'static str>, + pub dropdown_selected: Option, + pub dropdown_options: Vec<&'static str>, pub scaling_value: spin_button::Model, pub selection: segmented_button::SingleSelectModel, pub slider_value: f32, @@ -121,8 +121,8 @@ impl Default for State { fn default() -> State { State { checkbox_value: false, - pick_list_selected: Some("Option 1"), - pick_list_options: vec!["Option 1", "Option 2", "Option 3", "Option 4"], + dropdown_selected: Some(0), + dropdown_options: vec!["Option 1", "Option 2", "Option 3", "Option 4"], scaling_value: spin_button::Model::default() .value(1.0) .min(0.5) @@ -172,7 +172,7 @@ impl State { Message::ButtonPressed => (), Message::CheckboxToggled(value) => self.checkbox_value = value, Message::Debug(value) => return Some(Output::Debug(value)), - Message::PickListSelected(value) => self.pick_list_selected = Some(value), + Message::DropdownSelect(value) => self.dropdown_selected = Some(value), Message::RowSelected(row) => println!("Selected row {row}"), Message::MultiSelection(key) => self.multi_selection.activate(key), Message::ScalingFactor(message) => { @@ -277,10 +277,10 @@ impl State { )) .add(settings::item( "Pick List (TODO)", - pick_list( - &self.pick_list_options, - self.pick_list_selected, - Message::PickListSelected, + dropdown( + &self.dropdown_options, + self.dropdown_selected, + Message::DropdownSelect, ) .padding([8, 0, 8, 16]), )) diff --git a/src/theme/style/dropdown.rs b/src/theme/style/dropdown.rs new file mode 100644 index 00000000000..b6a0bc42149 --- /dev/null +++ b/src/theme/style/dropdown.rs @@ -0,0 +1,28 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::widget::dropdown; +use crate::Theme; +use iced::{Background, Color}; + +impl dropdown::menu::StyleSheet for Theme { + type Style = (); + + fn appearance(&self, _style: &Self::Style) -> dropdown::menu::Appearance { + let cosmic = self.cosmic(); + + dropdown::menu::Appearance { + text_color: cosmic.on_bg_color().into(), + background: Background::Color(cosmic.background.component.base.into()), + border_width: 0.0, + border_radius: 16.0.into(), + border_color: Color::TRANSPARENT, + + hovered_text_color: cosmic.on_bg_color().into(), + hovered_background: Background::Color(cosmic.primary.component.hover.into()), + + selected_text_color: cosmic.accent.base.into(), + selected_background: Background::Color(cosmic.primary.component.hover.into()), + } + } +} diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index 46dbfb12857..93b2a00be21 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -364,6 +364,7 @@ pub enum Container { Background, Card, Custom(Box container::Appearance>), + Dropdown, HeaderBar, Primary, Secondary, @@ -447,6 +448,19 @@ impl container::StyleSheet for Theme { } } + Container::Dropdown => { + let theme = self.cosmic(); + + container::Appearance { + icon_color: None, + text_color: None, + background: Some(iced::Background::Color(theme.primary.base.into())), + border_radius: f32::from(theme.space_xxs()).into(), + border_width: 1.0, + border_color: theme.bg_divider().into(), + } + } + Container::Tooltip => { let theme = self.cosmic(); @@ -593,8 +607,7 @@ impl menu::StyleSheet for Theme { border_width: 0.0, border_radius: 16.0.into(), border_color: Color::TRANSPARENT, - selected_text_color: cosmic.on_bg_color().into(), - // TODO doesn't seem to be specified + selected_text_color: cosmic.accent.base.into(), selected_background: Background::Color(cosmic.background.component.hover.into()), } } diff --git a/src/theme/style/mod.rs b/src/theme/style/mod.rs index ec418527374..a1b8f3bec4a 100644 --- a/src/theme/style/mod.rs +++ b/src/theme/style/mod.rs @@ -6,6 +6,8 @@ mod button; pub use self::button::Button; +mod dropdown; + pub mod iced; pub use self::iced::Application; pub use self::iced::Checkbox; diff --git a/src/widget/dropdown/menu/appearance.rs b/src/widget/dropdown/menu/appearance.rs new file mode 100644 index 00000000000..806885f9ee5 --- /dev/null +++ b/src/widget/dropdown/menu/appearance.rs @@ -0,0 +1,38 @@ +// Copyright 2023 System76 +// Copyright 2019 Héctor Ramón, Iced contributors +// SPDX-License-Identifier: MPL-2.0 AND MIT + +//! Change the appearance of menus. +use iced_core::{Background, BorderRadius, Color}; + +/// The appearance of a menu. +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// Menu text color + pub text_color: Color, + /// Menu background + pub background: Background, + /// Menu border width + pub border_width: f32, + /// Menu border radius + pub border_radius: BorderRadius, + /// Menu border color + pub border_color: Color, + /// Text color when hovered + pub hovered_text_color: Color, + /// Background when hovered + pub hovered_background: Background, + /// Text color when selected + pub selected_text_color: Color, + /// Background when selected + pub selected_background: Background, +} + +/// The style sheet of a menu. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default + Clone; + + /// Produces the [`Appearance`] of a menu. + fn appearance(&self, style: &Self::Style) -> Appearance; +} diff --git a/src/widget/dropdown/menu/mod.rs b/src/widget/dropdown/menu/mod.rs new file mode 100644 index 00000000000..1c3bffd3c73 --- /dev/null +++ b/src/widget/dropdown/menu/mod.rs @@ -0,0 +1,516 @@ +// Copyright 2023 System76 +// Copyright 2019 Héctor Ramón, Iced contributors +// SPDX-License-Identifier: MPL-2.0 AND MIT + +mod appearance; +pub use appearance::{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 list of selectable options. +#[must_use] +pub struct Menu<'a, S, Message> +where + S: AsRef, +{ + state: &'a mut State, + options: &'a [S], + hovered_option: &'a mut Option, + selected_option: Option, + on_selected: Box Message + 'a>, + on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, + width: f32, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + style: (), +} + +impl<'a, S: AsRef, Message: 'a> Menu<'a, S, Message> { + /// Creates a new [`Menu`] with the given [`State`], a list of options, and + /// the message to produced when an option is selected. + pub fn new( + state: &'a mut State, + options: &'a [S], + hovered_option: &'a mut Option, + selected_option: Option, + on_selected: impl FnMut(usize) -> Message + 'a, + on_option_hovered: Option<&'a dyn Fn(usize) -> 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::default(), + 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 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>(menu: Menu<'a, S, 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(List { + 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 List<'a, S: AsRef, Message> { + options: &'a [S], + hovered_option: &'a mut Option, + selected_option: Option, + on_selected: Box Message + 'a>, + on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, + padding: Padding, + text_size: Option, + text_line_height: text::LineHeight, + selected_icon: Option, +} + +impl<'a, S: AsRef, Message> Widget for List<'a, S, Message> { + 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 size = { + let intrinsic = Size::new( + 0.0, + (f32::from(text_line_height) + self.padding.vertical()) * self.options.len() as f32, + ); + + 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 { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if cursor.is_over(layout.bounds()) { + if let Some(index) = *self.hovered_option { + shell.publish((self.on_selected)(index)); + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some(cursor_position) = cursor.position_in(layout.bounds()) { + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let option_height = + f32::from(self.text_line_height.to_absolute(Pixels(text_size))) + + self.padding.vertical(); + + let new_hovered_option = (cursor_position.y / option_height) as usize; + + if let Some(on_option_hovered) = self.on_option_hovered { + if *self.hovered_option != Some(new_hovered_option) { + shell.publish(on_option_hovered(new_hovered_option)); + } + } + + *self.hovered_option = Some(new_hovered_option); + } + } + Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(cursor_position) = cursor.position_in(layout.bounds()) { + let text_size = self + .text_size + .unwrap_or_else(|| text::Renderer::default_size(renderer)); + + let option_height = + f32::from(self.text_line_height.to_absolute(Pixels(text_size))) + + self.padding.vertical(); + + *self.hovered_option = Some((cursor_position.y / option_height) as usize); + + if let Some(index) = *self.hovered_option { + shell.publish((self.on_selected)(index)); + return event::Status::Captured; + } + } + } + _ => {} + } + + 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() + } + } + + 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 option_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))) + + self.padding.vertical(); + + let offset = viewport.y - bounds.y; + let start = (offset / option_height) as usize; + let end = ((offset + viewport.height) / option_height).ceil() as usize; + + let visible_options = &self.options[start..end.min(self.options.len())]; + + for (i, option) in visible_options.iter().enumerate() { + let i = start + i; + + let bounds = Rectangle { + x: bounds.x, + y: bounds.y + (option_height * i as f32), + width: bounds.width, + height: option_height, + }; + + let (color, font) = if self.selected_option == Some(i) { + 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 == Some(i) { + 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: f32::INFINITY, + ..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, + }, + ); + } + } +} + +impl<'a, S: AsRef, Message: 'a> From> + for Element<'a, Message, crate::Renderer> +{ + fn from(list: List<'a, S, Message>) -> Self { + Element::new(list) + } +} diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs new file mode 100644 index 00000000000..93a2c2a3e05 --- /dev/null +++ b/src/widget/dropdown/mod.rs @@ -0,0 +1,17 @@ +// Copyright 2023 System76 +// Copyright 2019 Héctor Ramón, Iced contributors +// SPDX-License-Identifier: MPL-2.0 AND MIT + +pub mod menu; +pub use menu::Menu; + +mod widget; +pub use widget::*; + +pub fn dropdown<'a, S: AsRef, Message: 'a>( + selections: &'a [S], + selected: Option, + on_selected: impl Fn(usize) -> Message + 'a, +) -> Dropdown<'a, S, Message> { + Dropdown::new(selections, selected, on_selected) +} diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs new file mode 100644 index 00000000000..4bcae8714ce --- /dev/null +++ b/src/widget/dropdown/widget.rs @@ -0,0 +1,487 @@ +// 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> { + #[setters(skip)] + on_selected: Box Message + 'a>, + #[setters(skip)] + selections: &'a [S], + #[setters(skip)] + selected: Option, + #[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> Dropdown<'a, S, Message> { + /// 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 [S], + selected: Option, + on_selected: impl Fn(usize) -> Message + 'a, + ) -> Self { + Self { + on_selected: Box::new(on_selected), + selections, + selected, + 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> Widget for Dropdown<'a, S, Message> { + 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.selected + .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.selected, + 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.selected.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.selected, + &self.on_selected, + ) + } +} + +impl<'a, S: AsRef, Message: 'a> From> + for crate::Element<'a, Message> +{ + fn from(pick_list: Dropdown<'a, S, Message>) -> 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>( + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + on_selected: &dyn Fn(usize) -> Message, + selected: Option, + selections: &[S], + 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 = selected; + + 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 + { + let next_index = selected.map(|index| index + 1).unwrap_or_default(); + + if selections.len() < next_index { + shell.publish((on_selected)(next_index)); + } + + 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>( + layout: Layout<'_>, + renderer: &crate::Renderer, + state: &'a mut State, + gap: f32, + padding: Padding, + text_size: f32, + font: Option, + selections: &'a [S], + selected_option: Option, + on_selected: &'a dyn Fn(usize) -> Message, +) -> Option> { + if state.is_open { + let bounds = layout.bounds(); + + let mut menu = Menu::new( + &mut state.menu, + selections, + &mut state.hovered_option, + selected_option, + |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() + }; + + selections + .iter() + .map(|label| measure(label.as_ref())) + .fold(0.0, |next, current| current.max(next)) + + gap + + 16.0 + + padding.horizontal() + + padding.horizontal() + }) + .padding(padding) + .text_size(text_size); + + Some(menu.overlay(layout.position(), bounds.height)) + } else { + None + } +} + +/// Draws a [`Dropdown`]. +#[allow(clippy::too_many_arguments)] +pub fn draw<'a, S>( + 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/mod.rs b/src/widget/mod.rs index 2550f7ec965..ad03f115c55 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -12,7 +12,6 @@ pub use iced::widget::{image, Image}; pub use iced::widget::{lazy, Lazy}; pub use iced::widget::{mouse_area, MouseArea}; pub use iced::widget::{pane_grid, PaneGrid}; -pub use iced::widget::{pick_list, PickList}; pub use iced::widget::{progress_bar, ProgressBar}; pub use iced::widget::{radio, Radio}; pub use iced::widget::{responsive, Responsive}; @@ -106,6 +105,9 @@ pub mod divider { } } +pub mod dropdown; +pub use dropdown::{dropdown, Dropdown}; + pub mod flex_row; pub use flex_row::{flex_row, FlexRow};