├── .gitignore ├── rustfmt.toml ├── assets └── pick_list.gif ├── src ├── widget │ ├── overlay.rs │ ├── drag.rs │ ├── text_input │ │ ├── editor.rs │ │ ├── value.rs │ │ └── cursor.rs │ ├── mouse_area.rs │ ├── overlay │ │ └── menu.rs │ ├── pick_list.rs │ ├── row.rs │ └── column.rs ├── widget.rs ├── lib.rs └── helpers.rs ├── LICENSE ├── examples ├── README.md ├── mouse_area.rs ├── pick_list.rs ├── drag.rs └── text_input.rs ├── Cargo.toml ├── README.md ├── doc-style.css └── palette.htm /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width=80 2 | edition="2024" 3 | -------------------------------------------------------------------------------- /assets/pick_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airstrike/sweeten/HEAD/assets/pick_list.gif -------------------------------------------------------------------------------- /src/widget/overlay.rs: -------------------------------------------------------------------------------- 1 | //! Overlay widgets for displaying content above other widgets. 2 | 3 | pub mod menu; 4 | -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | //! Sweetened widgets for [`iced`]. 2 | //! 3 | //! This module contains enhanced versions of common `iced` widgets. Each widget 4 | //! is a drop-in replacement for its `iced` counterpart, with additional methods 5 | //! for extended functionality. 6 | //! 7 | //! [`iced`]: https://github.com/iced-rs/iced 8 | 9 | pub mod column; 10 | pub mod drag; 11 | pub mod mouse_area; 12 | pub mod overlay; 13 | pub mod pick_list; 14 | pub mod row; 15 | pub mod text_input; 16 | 17 | pub use column::Column; 18 | pub use mouse_area::MouseArea; 19 | pub use pick_list::PickList; 20 | pub use row::Row; 21 | pub use text_input::TextInput; 22 | 23 | // Re-export helper functions and macros (same pattern as iced_widget) 24 | pub use crate::helpers::*; 25 | pub use crate::{column, row}; 26 | -------------------------------------------------------------------------------- /src/widget/drag.rs: -------------------------------------------------------------------------------- 1 | //! Drag-and-drop support for [`Row`] and [`Column`] widgets. 2 | //! 3 | //! This module provides types for handling drag-and-drop reordering of items 4 | //! within [`Row`] and [`Column`] containers. 5 | //! 6 | //! [`Row`]: super::Row 7 | //! [`Column`]: super::Column 8 | 9 | /// Events emitted during drag operations. 10 | #[derive(Debug, Clone)] 11 | pub enum DragEvent { 12 | /// An item was picked up and drag started. 13 | Picked { 14 | /// Index of the picked item. 15 | index: usize, 16 | }, 17 | /// An item was dropped onto a target position. 18 | Dropped { 19 | /// Original index of the dragged item. 20 | index: usize, 21 | /// Index of the target position. 22 | target_index: usize, 23 | }, 24 | /// The drag was canceled (e.g., cursor left the widget area). 25 | Canceled { 26 | /// Index of the item that was being dragged. 27 | index: usize, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andy Terra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [Text Input](#text-input) 4 | - [Mouse Area](#mouse-area) 5 | - [Pick List](#pick-list) 6 | 7 | Run any example using: 8 | 9 | ```bash 10 | cargo run --example 11 | ``` 12 | 13 | --- 14 | 15 | ## Text Input 16 | 17 | Demonstrates the enhanced text_input widget with focus/blur messages: 18 | 19 | - `on_focus(Message)` - emit a message when the input gains focus 20 | - `on_blur(Message)` - emit a message when the input loses focus 21 | - Form validation with inline error display 22 | - Tab navigation between fields 23 | 24 | ```bash 25 | cargo run --example text_input 26 | ``` 27 | 28 |
29 | Text Input Demo 30 |
31 | 32 | --- 33 | 34 | ## Mouse Area 35 | 36 | Demonstrates the enhanced mouse area widget with click position tracking. 37 | 38 | ```bash 39 | cargo run --example mouse_area 40 | ``` 41 | 42 | --- 43 | 44 | ## Pick List 45 | 46 | Shows how to use the pick list with disabled items functionality. 47 | 48 | ```bash 49 | cargo run --example pick_list 50 | ``` 51 | 52 |
53 | Pick List Demo 54 |
55 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sweeten" 3 | version = "0.14.0-dev" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | authors = ["Andy Terra "] 7 | description = "`sweeten` your daily `iced` brew" 8 | license = "MIT" 9 | repository = "https://github.com/airstrike/sweeten" 10 | keywords = ["gui", "iced", "widgets"] 11 | categories = ["gui"] 12 | readme = "README.md" 13 | 14 | [dependencies] 15 | iced_core.version = "0.14.0-dev" 16 | iced_runtime.version = "0.14.0-dev" 17 | iced_widget.version = "0.14.0-dev" 18 | unicode-segmentation = "1.11.0" 19 | 20 | [dev-dependencies] 21 | iced.version = "0.14.0-dev" 22 | iced.features = ["advanced"] 23 | 24 | [patch.crates-io] 25 | iced.git = "https://github.com/iced-rs/iced.git" 26 | iced_core.git = "https://github.com/iced-rs/iced.git" 27 | iced_runtime.git = "https://github.com/iced-rs/iced.git" 28 | iced_widget.git = "https://github.com/iced-rs/iced.git" 29 | 30 | # For testing new bleeding edge iced features 31 | # iced.path = "../iced" 32 | # iced_core.path = "../iced/core" 33 | # iced_runtime.path = "../iced/runtime" 34 | # iced_widget.path = "../iced/widget" 35 | 36 | [package.metadata.docs.rs] 37 | all-features = true 38 | rustdoc-args = ["--extend-css", "doc-style.css"] 39 | 40 | [lints.rust] 41 | rust_2018_idioms = { level = "deny", priority = -1 } 42 | unsafe_code = "deny" 43 | 44 | [lints.clippy] 45 | semicolon_if_nothing_returned = "deny" 46 | trivially-copy-pass-by-ref = "deny" 47 | redundant-closure-for-method-calls = "deny" 48 | needless_borrow = "deny" 49 | useless_conversion = "deny" 50 | 51 | [lints.rustdoc] 52 | broken_intra_doc_links = "deny" 53 | -------------------------------------------------------------------------------- /examples/mouse_area.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrates the enhanced mouse_area widget with position tracking. 2 | //! 3 | //! Run with: `cargo run --example mouse_area` 4 | 5 | use iced::widget::{center, column, container, text}; 6 | use iced::{Center, Element, Point}; 7 | 8 | use sweeten::mouse_area; 9 | 10 | fn main() -> iced::Result { 11 | iced::application(App::default, App::update, App::view) 12 | .window_size((300, 300)) 13 | .centered() 14 | .title("sweeten • mouse_area with Point") 15 | .run() 16 | } 17 | 18 | #[derive(Default)] 19 | struct App { 20 | status: String, 21 | } 22 | 23 | #[derive(Clone, Debug)] 24 | enum Message { 25 | Mouse(&'static str, Point), 26 | } 27 | 28 | impl App { 29 | fn update(&mut self, message: Message) { 30 | match message { 31 | Message::Mouse(event, p) => { 32 | self.status = format!("{event} at ({:.0}, {:.0})", p.x, p.y); 33 | } 34 | } 35 | } 36 | 37 | fn view(&self) -> Element<'_, Message> { 38 | center( 39 | column![ 40 | mouse_area( 41 | center("Hover and click me!").style(container::rounded_box) 42 | ) 43 | .on_enter(|p| Message::Mouse("Entered", p)) 44 | .on_exit(|p| Message::Mouse("Exited", p)) 45 | .on_press(|p| Message::Mouse("Left press", p)) 46 | .on_release(|p| Message::Mouse("Left release", p)) 47 | .on_right_press(|p| Message::Mouse("Right press", p)) 48 | .on_right_release(|p| Message::Mouse("Right release", p)) 49 | .on_middle_press(|p| Message::Mouse("Middle press", p)) 50 | .on_middle_release(|p| Message::Mouse("Middle release", p)), 51 | text(&self.status).align_x(Center) 52 | ] 53 | .spacing(10) 54 | .align_x(Center), 55 | ) 56 | .padding(10) 57 | .into() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/widget/text_input/editor.rs: -------------------------------------------------------------------------------- 1 | use crate::text_input::{Cursor, Value}; 2 | 3 | pub struct Editor<'a> { 4 | value: &'a mut Value, 5 | cursor: &'a mut Cursor, 6 | } 7 | 8 | impl<'a> Editor<'a> { 9 | pub fn new(value: &'a mut Value, cursor: &'a mut Cursor) -> Editor<'a> { 10 | Editor { value, cursor } 11 | } 12 | 13 | pub fn contents(&self) -> String { 14 | self.value.to_string() 15 | } 16 | 17 | pub fn insert(&mut self, character: char) { 18 | if let Some((left, right)) = self.cursor.selection(self.value) { 19 | self.cursor.move_left(self.value); 20 | self.value.remove_many(left, right); 21 | } 22 | 23 | self.value.insert(self.cursor.end(self.value), character); 24 | self.cursor.move_right(self.value); 25 | } 26 | 27 | pub fn paste(&mut self, content: Value) { 28 | let length = content.len(); 29 | if let Some((left, right)) = self.cursor.selection(self.value) { 30 | self.cursor.move_left(self.value); 31 | self.value.remove_many(left, right); 32 | } 33 | 34 | self.value.insert_many(self.cursor.end(self.value), content); 35 | 36 | self.cursor.move_right_by_amount(self.value, length); 37 | } 38 | 39 | pub fn backspace(&mut self) { 40 | match self.cursor.selection(self.value) { 41 | Some((start, end)) => { 42 | self.cursor.move_left(self.value); 43 | self.value.remove_many(start, end); 44 | } 45 | None => { 46 | let start = self.cursor.start(self.value); 47 | 48 | if start > 0 { 49 | self.cursor.move_left(self.value); 50 | self.value.remove(start - 1); 51 | } 52 | } 53 | } 54 | } 55 | 56 | pub fn delete(&mut self) { 57 | match self.cursor.selection(self.value) { 58 | Some(_) => { 59 | self.backspace(); 60 | } 61 | None => { 62 | let end = self.cursor.end(self.value); 63 | 64 | if end < self.value.len() { 65 | self.value.remove(end); 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/pick_list.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrates the enhanced pick_list widget with disabled items. 2 | //! 3 | //! This example shows: 4 | //! - `disabled(Fn(&[T]) -> Vec)` - dynamically disable items 5 | //! - Disabled items are visually distinct and non-selectable 6 | //! 7 | //! Run with: `cargo run --example pick_list` 8 | 9 | use iced::widget::{center, column}; 10 | use iced::{Center, Element, Fill}; 11 | 12 | use sweeten::pick_list; 13 | 14 | fn main() -> iced::Result { 15 | iced::application(App::default, App::update, App::view) 16 | .title("sweeten • pick_list with disabled items") 17 | .window_size((300.0, 200.0)) 18 | .theme(App::theme) 19 | .run() 20 | } 21 | 22 | #[derive(Default)] 23 | struct App { 24 | selected_language: Option, 25 | } 26 | 27 | #[derive(Clone, Debug)] 28 | enum Message { 29 | Pick(Language), 30 | } 31 | 32 | impl App { 33 | fn theme(&self) -> iced::Theme { 34 | iced::Theme::TokyoNightLight 35 | } 36 | 37 | fn update(&mut self, message: Message) { 38 | match message { 39 | Message::Pick(option) => { 40 | self.selected_language = Some(option); 41 | } 42 | } 43 | } 44 | 45 | fn view(&self) -> Element<'_, Message> { 46 | let pick = pick_list( 47 | &Language::ALL[..], 48 | self.selected_language, 49 | Message::Pick, 50 | ) 51 | .disabled(|languages: &[Language]| { 52 | languages 53 | .iter() 54 | .map(|lang| matches!(lang, Language::Javascript)) 55 | .collect() 56 | }) 57 | .placeholder("Choose a language..."); 58 | 59 | center( 60 | column![ 61 | "Which is the best programming language?", 62 | pick, 63 | self.selected_language 64 | .map(|language| match language { 65 | Language::Rust => "Correct!", 66 | Language::Javascript => "Wrong!", 67 | _ => "You must have misclicked... Try again!", 68 | }) 69 | .unwrap_or(""), 70 | ] 71 | .width(Fill) 72 | .align_x(Center) 73 | .spacing(10), 74 | ) 75 | .into() 76 | } 77 | } 78 | 79 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 80 | pub enum Language { 81 | #[default] 82 | Rust, 83 | Elm, 84 | Ruby, 85 | Haskell, 86 | C, 87 | Javascript, 88 | Other, 89 | } 90 | 91 | impl Language { 92 | const ALL: [Language; 7] = [ 93 | Language::C, 94 | Language::Javascript, 95 | Language::Elm, 96 | Language::Ruby, 97 | Language::Haskell, 98 | Language::Rust, 99 | Language::Other, 100 | ]; 101 | } 102 | 103 | impl std::fmt::Display for Language { 104 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 105 | write!( 106 | f, 107 | "{}", 108 | match self { 109 | Language::Rust => "Rust", 110 | Language::Elm => "Elm", 111 | Language::Ruby => "Ruby", 112 | Language::Haskell => "Haskell", 113 | Language::C => "C", 114 | Language::Javascript => "Javascript", 115 | Language::Other => "Some other language", 116 | } 117 | ) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | // This crate contains modifications of widgets from [`iced`]. 3 | // 4 | // [`iced`]: https://github.com/iced-rs/iced 5 | // 6 | // Copyright 2019 Héctor Ramón, Iced contributors 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy of 9 | // this software and associated documentation files (the "Software"), to deal in 10 | // the Software without restriction, including without limitation the rights to 11 | // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 12 | // the Software, and to permit persons to whom the Software is furnished to do so, 13 | // subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all 16 | // copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 20 | // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 21 | // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 22 | // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | //! # sweeten 26 | //! 27 | //! `sweeten` provides enhanced versions of common [`iced`] widgets with 28 | //! additional functionality for more complex use cases. It aims to maintain 29 | //! the simplicity and elegance of `iced` while offering "sweetened" variants 30 | //! with extended capabilities. 31 | //! 32 | //! ## Widgets 33 | //! 34 | //! The following widgets are available in the [`widget`] module: 35 | //! 36 | //! - [`column`] — Distribute content vertically, with support for drag-and-drop 37 | //! reordering via [`on_drag`](widget::column::Column::on_drag). 38 | //! - [`mouse_area`] — A container for capturing mouse events where all handlers 39 | //! receive the cursor position as a [`Point`]. 40 | //! - [`pick_list`] — A dropdown list of selectable options, with support for 41 | //! disabling items. 42 | //! - [`row`] — Distribute content horizontally, with support for drag-and-drop 43 | //! reordering via [`on_drag`](widget::row::Row::on_drag). 44 | //! - [`text_input`] — A text input field, with support for [`on_focus`] and 45 | //! [`on_blur`] messages. 46 | //! 47 | //! ## Usage 48 | //! 49 | //! Import the widgets you need from `sweeten::widget`: 50 | //! 51 | //! ```no_run 52 | //! use sweeten::widget::{column, mouse_area, pick_list, row, text_input}; 53 | //! # fn main() {} 54 | //! ``` 55 | //! 56 | //! The widgets are designed to be drop-in replacements for their `iced` 57 | //! counterparts, with additional methods for the extended functionality. 58 | //! 59 | //! [`iced`]: https://github.com/iced-rs/iced 60 | //! [`column`]: mod@widget::column 61 | //! [`mouse_area`]: mod@widget::mouse_area 62 | //! [`pick_list`]: mod@widget::pick_list 63 | //! [`row`]: mod@widget::row 64 | //! [`text_input`]: mod@widget::text_input 65 | //! [`Point`]: crate::core::Point 66 | //! [`on_focus`]: widget::text_input::TextInput::on_focus 67 | //! [`on_blur`]: widget::text_input::TextInput::on_blur 68 | 69 | mod helpers; 70 | pub mod widget; 71 | 72 | pub use helpers::*; 73 | 74 | // Re-exports to mirror iced_widget structure (allows minimal diff for widgets) 75 | pub use iced_core as core; 76 | pub use iced_core::Theme; 77 | pub use iced_widget::Renderer; 78 | pub use iced_widget::{button, scrollable, text_editor}; 79 | 80 | // Re-export widget modules at crate level (mirrors iced_widget's structure) 81 | pub use widget::overlay; 82 | pub use widget::text_input; 83 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | //! Helper functions to create widgets. 2 | 3 | use crate::core; 4 | use crate::core::Element; 5 | use crate::overlay::menu; 6 | use crate::widget::MouseArea; 7 | use crate::widget::column::{self, Column}; 8 | use crate::widget::pick_list::{self, PickList}; 9 | use crate::widget::row::{self, Row}; 10 | use crate::widget::text_input::{self, TextInput}; 11 | 12 | use std::borrow::Borrow; 13 | 14 | /// Creates a [`Column`] with the given children. 15 | /// 16 | /// Columns distribute their children vertically. 17 | #[macro_export] 18 | macro_rules! column { 19 | () => ( 20 | $crate::widget::Column::new() 21 | ); 22 | ($($x:expr),+ $(,)?) => ( 23 | $crate::widget::Column::with_children([$($crate::core::Element::from($x)),+]) 24 | ); 25 | } 26 | 27 | /// Creates a [`Row`] with the given children. 28 | /// 29 | /// Rows distribute their children horizontally. 30 | #[macro_export] 31 | macro_rules! row { 32 | () => ( 33 | $crate::widget::Row::new() 34 | ); 35 | ($($x:expr),+ $(,)?) => ( 36 | $crate::widget::Row::with_children([$($crate::core::Element::from($x)),+]) 37 | ); 38 | } 39 | 40 | /// Creates a new [`Row`] with the given children. 41 | pub fn row<'a, Message, Theme, Renderer>( 42 | children: impl IntoIterator>, 43 | ) -> Row<'a, Message, Theme, Renderer> 44 | where 45 | Renderer: core::Renderer, 46 | Theme: row::Catalog, 47 | { 48 | Row::with_children(children) 49 | } 50 | 51 | /// Creates a new [`Column`] with the given children. 52 | pub fn column<'a, Message, Theme, Renderer>( 53 | children: impl IntoIterator>, 54 | ) -> Column<'a, Message, Theme, Renderer> 55 | where 56 | Renderer: core::Renderer, 57 | Theme: column::Catalog, 58 | { 59 | Column::with_children(children) 60 | } 61 | 62 | /// Creates a new [`TextInput`]. 63 | /// 64 | /// This is a sweetened version of [`iced`'s `text_input`] with support for 65 | /// [`on_focus`] and [`on_blur`] messages. 66 | /// 67 | /// [`iced`'s `text_input`]: https://docs.iced.rs/iced/widget/text_input/index.html 68 | /// [`on_focus`]: TextInput::on_focus 69 | /// [`on_blur`]: TextInput::on_blur 70 | pub fn text_input<'a, Message, Theme, Renderer>( 71 | placeholder: &str, 72 | value: &str, 73 | ) -> TextInput<'a, Message, Theme, Renderer> 74 | where 75 | Message: Clone, 76 | Theme: text_input::Catalog + 'a, 77 | Renderer: core::text::Renderer, 78 | { 79 | TextInput::new(placeholder, value) 80 | } 81 | 82 | /// Creates a new [`PickList`]. 83 | /// 84 | /// This is a sweetened version of [`iced`'s `pick_list`] with support for 85 | /// disabling items in the dropdown via [`disabled`]. 86 | /// 87 | /// [`iced`'s `pick_list`]: https://docs.iced.rs/iced/widget/pick_list/index.html 88 | /// [`disabled`]: PickList::disabled 89 | pub fn pick_list<'a, T, L, V, Message, Theme, Renderer>( 90 | options: L, 91 | selected: Option, 92 | on_selected: impl Fn(T) -> Message + 'a, 93 | ) -> PickList<'a, T, L, V, Message, Theme, Renderer> 94 | where 95 | T: ToString + PartialEq + Clone + 'a, 96 | L: Borrow<[T]> + 'a, 97 | V: Borrow + 'a, 98 | Message: Clone, 99 | Theme: pick_list::Catalog + menu::Catalog, 100 | Renderer: core::text::Renderer, 101 | { 102 | PickList::new(options, selected, on_selected) 103 | } 104 | 105 | /// Creates a new [`MouseArea`] for capturing mouse events. 106 | /// 107 | /// This is a sweetened version of [`iced`'s `MouseArea`] where all event 108 | /// handlers receive the cursor position as a [`Point`]. 109 | /// 110 | /// [`iced`'s `MouseArea`]: https://docs.iced.rs/iced/widget/struct.MouseArea.html 111 | /// [`Point`]: crate::core::Point 112 | pub fn mouse_area<'a, Message, Theme, Renderer>( 113 | widget: impl Into>, 114 | ) -> MouseArea<'a, Message, Theme, Renderer> 115 | where 116 | Renderer: core::Renderer, 117 | { 118 | MouseArea::new(widget) 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | ## `sweeten` your daily `iced` brew 6 | 7 | [![Crates.io](https://img.shields.io/crates/v/sweeten.svg)](https://crates.io/crates/sweeten) 8 | [![Documentation](https://docs.rs/sweeten/badge.svg)](https://docs.rs/sweeten) 9 | [![License](https://img.shields.io/crates/l/sweeten.svg)](https://github.com/airstrike/sweeten/blob/master/LICENSE) 10 | [![Made with iced](https://iced.rs/badge.svg)](https://github.com/iced-rs/iced) 11 | 12 |
13 | 14 | ## Overview 15 | 16 | `sweeten` provides sweetened versions of common `iced` widgets with additional 17 | functionality for more complex use cases. It aims to maintain the simplicity and 18 | elegance of `iced` while offering "sweetened" variants with extended 19 | capabilities. 20 | 21 | ## Installation 22 | 23 | If you're using the latest `iced` release: 24 | 25 | ```bash 26 | cargo add sweeten 27 | ``` 28 | 29 | If you're tracking `iced` from git, add this to your `Cargo.toml`: 30 | 31 | ```toml 32 | sweeten = { git = "https://github.com/airstrike/sweeten", branch = "master" } 33 | ``` 34 | 35 | ## Current Features 36 | 37 | ### `MouseArea` 38 | 39 | A sweetened version of `iced`'s `mouse_area` widget with an additional 40 | `on_press_with` method for capturing the click position with a closure. Use it 41 | like: 42 | 43 | ```rust 44 | mouse_area("Click me and I'll tell you where!",) 45 | .on_press_with(|point| Message::ClickWithPoint(point)), 46 | ``` 47 | 48 | ### `PickList` 49 | 50 | A sweetened version of `iced`'s `PickList` which accepts an optional closure to 51 | disable some items. Use it like: 52 | 53 | ```rust 54 | pick_list( 55 | &Language::ALL[..], 56 | Some(|languages: &[Language]| { 57 | languages 58 | .iter() 59 | .map(|lang| matches!(lang, Language::Javascript)) 60 | .collect() 61 | }), 62 | self.selected_language, 63 | Message::Pick, 64 | ) 65 | .placeholder("Choose a language..."); 66 | ``` 67 | 68 | > Note that the compiler is not currently able to infer the type of the closure, 69 | > so you may need to specify it explicitly as shown above. 70 | 71 | ### `TextInput` 72 | 73 | A sweetened version of `iced`'s `text_input` widget with additional focus-related features: 74 | 75 | - `.on_focus` and `.on_blur` methods for handling focus events 76 | - Sweetened `focus_next` and `focus_previous` focus management functions, which return the ID of the focused element 77 | 78 | ### `Row` and `Column` 79 | 80 | Sweetened versions of `iced`'s `Row` and `Column` with drag-and-drop reordering 81 | support via `.on_drag`: 82 | 83 | ```rust 84 | use sweeten::widget::column; 85 | use sweeten::widget::drag::DragEvent; 86 | 87 | column(items.iter().map(|s| s.as_str().into())) 88 | .spacing(5) 89 | .on_drag(Message::Reorder) 90 | .into() 91 | ``` 92 | 93 | ## Examples 94 | 95 | For complete examples, see [`examples/`](examples/) or run an example like this: 96 | 97 | ```bash 98 | cargo run --example mouse_area 99 | ``` 100 | 101 | Other examples include: 102 | ```bash 103 | cargo run --example pick_list 104 | cargo run --example text_input 105 | ``` 106 | 107 | ## Code Structure 108 | 109 | The library is organized into modules for each enhanced widget: 110 | 111 | - `widget/`: Contains all widget implementations 112 | - `mouse_area.rs`: Sweetened mouse interaction handling 113 | - `pick_list.rs`: Sweetened pick list with item disabling 114 | - `text_input.rs`: Sweetened text input with focus handling 115 | - (more widgets coming soon!) 116 | 117 | ## Planned Features 118 | 119 | - [x] MouseArea widget 120 | - [x] PickList widget 121 | - [x] TextInput widget with focus management 122 | - [x] Row and Column with drag and drop 123 | 124 | ## Contributing 125 | 126 | Contributions are welcome! If you have ideas for new widgets or enhancements: 127 | 128 | 1. Fork the repository 129 | 2. Create a feature branch 130 | 3. Implement your changes with tests 131 | 4. Submit a PR 132 | 133 | ## License 134 | 135 | MIT 136 | 137 | ## Acknowledgements 138 | 139 | - [iced](https://github.com/iced-rs/iced) 140 | - [Rust programming language](https://www.rust-lang.org/) -------------------------------------------------------------------------------- /examples/drag.rs: -------------------------------------------------------------------------------- 1 | use iced::Length::Fill; 2 | use iced::widget::{button, container, text}; 3 | use iced::{Center, Element, Task}; 4 | 5 | use sweeten::widget::drag::DragEvent; 6 | use sweeten::widget::{column, row}; 7 | 8 | pub fn main() -> iced::Result { 9 | iced::application(App::new, App::update, App::view) 10 | .title("sweeten • drag and drop") 11 | .window_size((400, 400)) 12 | .run() 13 | } 14 | 15 | #[derive(Default)] 16 | struct App { 17 | elements: Vec<&'static str>, 18 | mode: Mode, 19 | } 20 | 21 | #[derive(Debug, Clone, Default, PartialEq)] 22 | enum Mode { 23 | Row, 24 | #[default] 25 | Column, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | enum Message { 30 | Reorder(DragEvent), 31 | SwitchMode(Mode), 32 | } 33 | 34 | impl App { 35 | fn new() -> (Self, Task) { 36 | ( 37 | Self { 38 | elements: vec![ 39 | "Apple", 40 | "Banana", 41 | "Cherry", 42 | "Date", 43 | "Elderberry", 44 | ], 45 | ..Default::default() 46 | }, 47 | Task::none(), 48 | ) 49 | } 50 | 51 | fn update(&mut self, message: Message) { 52 | match message { 53 | Message::SwitchMode(mode) => { 54 | self.mode = mode; 55 | } 56 | Message::Reorder(event) => match event { 57 | DragEvent::Picked { .. } => { 58 | // Optionally handle pick event 59 | } 60 | DragEvent::Dropped { 61 | index, 62 | target_index, 63 | } => { 64 | // Update self.elements based on index and target_index 65 | let item = self.elements.remove(index); 66 | self.elements.insert(target_index, item); 67 | } 68 | DragEvent::Canceled { .. } => { 69 | // Optionally handle cancel event 70 | } 71 | }, 72 | } 73 | } 74 | 75 | fn view(&self) -> Element<'_, Message> { 76 | let items = self.elements.iter().copied().map(pickme); 77 | let drag: Element<'_, Message> = match self.mode { 78 | Mode::Column => column(items) 79 | .spacing(5) 80 | .deadband_zone(0.0) 81 | .on_drag(Message::Reorder) 82 | .align_x(Center) 83 | .into(), 84 | Mode::Row => row(items) 85 | .spacing(5) 86 | .on_drag(Message::Reorder) 87 | .style(|_| row::Style { 88 | scale: 1.5, 89 | moved_item_overlay: iced::Color::BLACK.scale_alpha(0.75), 90 | ghost_background: iced::color![170, 0, 0] 91 | .scale_alpha(0.25) 92 | .into(), 93 | ghost_border: iced::Border { 94 | color: iced::Color::TRANSPARENT, 95 | width: 0.0, 96 | radius: 5.0.into(), 97 | }, 98 | }) 99 | .align_y(Center) 100 | .into(), 101 | }; 102 | 103 | container( 104 | column![ 105 | row![ 106 | text("Drag items around!").width(Fill), 107 | button(text("ROW").size(12)) 108 | .on_press(Message::SwitchMode(Mode::Row)) 109 | .style(if self.mode == Mode::Row { 110 | button::primary 111 | } else { 112 | button::subtle 113 | }), 114 | button(text("COLUMN").size(12)) 115 | .on_press(Message::SwitchMode(Mode::Column)) 116 | .style(if self.mode == Mode::Column { 117 | button::primary 118 | } else { 119 | button::subtle 120 | }), 121 | ] 122 | .spacing(5) 123 | .align_y(Center), 124 | container(drag) 125 | .padding(20) 126 | .center(Fill) 127 | .style(container::bordered_box) 128 | ] 129 | .align_x(Center) 130 | .spacing(5), 131 | ) 132 | .padding(20) 133 | .center(Fill) 134 | .into() 135 | } 136 | } 137 | 138 | fn pickme(label: &str) -> Element<'_, Message> { 139 | container(text(label)) 140 | .style(container::rounded_box) 141 | .padding(5) 142 | .into() 143 | } 144 | -------------------------------------------------------------------------------- /src/widget/text_input/value.rs: -------------------------------------------------------------------------------- 1 | use unicode_segmentation::UnicodeSegmentation; 2 | 3 | /// The value of a [`TextInput`]. 4 | /// 5 | /// [`TextInput`]: super::TextInput 6 | // TODO: Reduce allocations, cache results (?) 7 | #[derive(Debug, Clone)] 8 | pub struct Value { 9 | graphemes: Vec, 10 | } 11 | 12 | impl Value { 13 | /// Creates a new [`Value`] from a string slice. 14 | pub fn new(string: &str) -> Self { 15 | let graphemes = UnicodeSegmentation::graphemes(string, true) 16 | .map(String::from) 17 | .collect(); 18 | 19 | Self { graphemes } 20 | } 21 | 22 | /// Returns whether the [`Value`] is empty or not. 23 | /// 24 | /// A [`Value`] is empty when it contains no graphemes. 25 | pub fn is_empty(&self) -> bool { 26 | self.len() == 0 27 | } 28 | 29 | /// Returns the total amount of graphemes in the [`Value`]. 30 | pub fn len(&self) -> usize { 31 | self.graphemes.len() 32 | } 33 | 34 | /// Returns the position of the previous start of a word from the given 35 | /// grapheme `index`. 36 | pub fn previous_start_of_word(&self, index: usize) -> usize { 37 | let previous_string = 38 | &self.graphemes[..index.min(self.graphemes.len())].concat(); 39 | 40 | UnicodeSegmentation::split_word_bound_indices(previous_string as &str) 41 | .filter(|(_, word)| !word.trim_start().is_empty()) 42 | .next_back() 43 | .map(|(i, previous_word)| { 44 | index 45 | - UnicodeSegmentation::graphemes(previous_word, true) 46 | .count() 47 | - UnicodeSegmentation::graphemes( 48 | &previous_string[i + previous_word.len()..] as &str, 49 | true, 50 | ) 51 | .count() 52 | }) 53 | .unwrap_or(0) 54 | } 55 | 56 | /// Returns the position of the next end of a word from the given grapheme 57 | /// `index`. 58 | pub fn next_end_of_word(&self, index: usize) -> usize { 59 | let next_string = &self.graphemes[index..].concat(); 60 | 61 | UnicodeSegmentation::split_word_bound_indices(next_string as &str) 62 | .find(|(_, word)| !word.trim_start().is_empty()) 63 | .map(|(i, next_word)| { 64 | index 65 | + UnicodeSegmentation::graphemes(next_word, true).count() 66 | + UnicodeSegmentation::graphemes( 67 | &next_string[..i] as &str, 68 | true, 69 | ) 70 | .count() 71 | }) 72 | .unwrap_or(self.len()) 73 | } 74 | 75 | /// Returns a new [`Value`] containing the graphemes from `start` until the 76 | /// given `end`. 77 | pub fn select(&self, start: usize, end: usize) -> Self { 78 | let graphemes = 79 | self.graphemes[start.min(self.len())..end.min(self.len())].to_vec(); 80 | 81 | Self { graphemes } 82 | } 83 | 84 | /// Returns a new [`Value`] containing the graphemes until the given 85 | /// `index`. 86 | pub fn until(&self, index: usize) -> Self { 87 | let graphemes = self.graphemes[..index.min(self.len())].to_vec(); 88 | 89 | Self { graphemes } 90 | } 91 | 92 | /// Inserts a new `char` at the given grapheme `index`. 93 | pub fn insert(&mut self, index: usize, c: char) { 94 | self.graphemes.insert(index, c.to_string()); 95 | 96 | self.graphemes = 97 | UnicodeSegmentation::graphemes(&self.to_string() as &str, true) 98 | .map(String::from) 99 | .collect(); 100 | } 101 | 102 | /// Inserts a bunch of graphemes at the given grapheme `index`. 103 | pub fn insert_many(&mut self, index: usize, mut value: Value) { 104 | let _ = self 105 | .graphemes 106 | .splice(index..index, value.graphemes.drain(..)); 107 | } 108 | 109 | /// Removes the grapheme at the given `index`. 110 | pub fn remove(&mut self, index: usize) { 111 | let _ = self.graphemes.remove(index); 112 | } 113 | 114 | /// Removes the graphemes from `start` to `end`. 115 | pub fn remove_many(&mut self, start: usize, end: usize) { 116 | let _ = self.graphemes.splice(start..end, std::iter::empty()); 117 | } 118 | 119 | /// Returns a new [`Value`] with all its graphemes replaced with the 120 | /// dot ('•') character. 121 | pub fn secure(&self) -> Self { 122 | Self { 123 | graphemes: std::iter::repeat_n( 124 | String::from("•"), 125 | self.graphemes.len(), 126 | ) 127 | .collect(), 128 | } 129 | } 130 | } 131 | 132 | impl std::fmt::Display for Value { 133 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 134 | f.write_str(&self.graphemes.concat()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/widget/text_input/cursor.rs: -------------------------------------------------------------------------------- 1 | //! Track the cursor of a text input. 2 | use crate::text_input::Value; 3 | 4 | /// The cursor of a text input. 5 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 6 | pub struct Cursor { 7 | state: State, 8 | } 9 | 10 | /// The state of a [`Cursor`]. 11 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 12 | pub enum State { 13 | /// Cursor without a selection 14 | Index(usize), 15 | 16 | /// Cursor selecting a range of text 17 | Selection { 18 | /// The start of the selection 19 | start: usize, 20 | /// The end of the selection 21 | end: usize, 22 | }, 23 | } 24 | 25 | impl Default for Cursor { 26 | fn default() -> Self { 27 | Cursor { 28 | state: State::Index(0), 29 | } 30 | } 31 | } 32 | 33 | impl Cursor { 34 | /// Returns the [`State`] of the [`Cursor`]. 35 | pub fn state(&self, value: &Value) -> State { 36 | match self.state { 37 | State::Index(index) => State::Index(index.min(value.len())), 38 | State::Selection { start, end } => { 39 | let start = start.min(value.len()); 40 | let end = end.min(value.len()); 41 | 42 | if start == end { 43 | State::Index(start) 44 | } else { 45 | State::Selection { start, end } 46 | } 47 | } 48 | } 49 | } 50 | 51 | /// Returns the current selection of the [`Cursor`] for the given [`Value`]. 52 | /// 53 | /// `start` is guaranteed to be <= than `end`. 54 | pub fn selection(&self, value: &Value) -> Option<(usize, usize)> { 55 | match self.state(value) { 56 | State::Selection { start, end } => { 57 | Some((start.min(end), start.max(end))) 58 | } 59 | State::Index(_) => None, 60 | } 61 | } 62 | 63 | pub(crate) fn move_to(&mut self, position: usize) { 64 | self.state = State::Index(position); 65 | } 66 | 67 | pub(crate) fn move_right(&mut self, value: &Value) { 68 | self.move_right_by_amount(value, 1); 69 | } 70 | 71 | pub(crate) fn move_right_by_words(&mut self, value: &Value) { 72 | self.move_to(value.next_end_of_word(self.right(value))); 73 | } 74 | 75 | pub(crate) fn move_right_by_amount( 76 | &mut self, 77 | value: &Value, 78 | amount: usize, 79 | ) { 80 | match self.state(value) { 81 | State::Index(index) => { 82 | self.move_to(index.saturating_add(amount).min(value.len())); 83 | } 84 | State::Selection { start, end } => self.move_to(end.max(start)), 85 | } 86 | } 87 | 88 | pub(crate) fn move_left(&mut self, value: &Value) { 89 | match self.state(value) { 90 | State::Index(index) if index > 0 => self.move_to(index - 1), 91 | State::Selection { start, end } => self.move_to(start.min(end)), 92 | State::Index(_) => self.move_to(0), 93 | } 94 | } 95 | 96 | pub(crate) fn move_left_by_words(&mut self, value: &Value) { 97 | self.move_to(value.previous_start_of_word(self.left(value))); 98 | } 99 | 100 | pub(crate) fn select_range(&mut self, start: usize, end: usize) { 101 | if start == end { 102 | self.state = State::Index(start); 103 | } else { 104 | self.state = State::Selection { start, end }; 105 | } 106 | } 107 | 108 | pub(crate) fn select_left(&mut self, value: &Value) { 109 | match self.state(value) { 110 | State::Index(index) if index > 0 => { 111 | self.select_range(index, index - 1); 112 | } 113 | State::Selection { start, end } if end > 0 => { 114 | self.select_range(start, end - 1); 115 | } 116 | _ => {} 117 | } 118 | } 119 | 120 | pub(crate) fn select_right(&mut self, value: &Value) { 121 | match self.state(value) { 122 | State::Index(index) if index < value.len() => { 123 | self.select_range(index, index + 1); 124 | } 125 | State::Selection { start, end } if end < value.len() => { 126 | self.select_range(start, end + 1); 127 | } 128 | _ => {} 129 | } 130 | } 131 | 132 | pub(crate) fn select_left_by_words(&mut self, value: &Value) { 133 | match self.state(value) { 134 | State::Index(index) => { 135 | self.select_range(index, value.previous_start_of_word(index)); 136 | } 137 | State::Selection { start, end } => { 138 | self.select_range(start, value.previous_start_of_word(end)); 139 | } 140 | } 141 | } 142 | 143 | pub(crate) fn select_right_by_words(&mut self, value: &Value) { 144 | match self.state(value) { 145 | State::Index(index) => { 146 | self.select_range(index, value.next_end_of_word(index)); 147 | } 148 | State::Selection { start, end } => { 149 | self.select_range(start, value.next_end_of_word(end)); 150 | } 151 | } 152 | } 153 | 154 | pub(crate) fn select_all(&mut self, value: &Value) { 155 | self.select_range(0, value.len()); 156 | } 157 | 158 | pub(crate) fn start(&self, value: &Value) -> usize { 159 | let start = match self.state { 160 | State::Index(index) => index, 161 | State::Selection { start, .. } => start, 162 | }; 163 | 164 | start.min(value.len()) 165 | } 166 | 167 | pub(crate) fn end(&self, value: &Value) -> usize { 168 | let end = match self.state { 169 | State::Index(index) => index, 170 | State::Selection { end, .. } => end, 171 | }; 172 | 173 | end.min(value.len()) 174 | } 175 | 176 | fn left(&self, value: &Value) -> usize { 177 | match self.state(value) { 178 | State::Index(index) => index, 179 | State::Selection { start, end } => start.min(end), 180 | } 181 | } 182 | 183 | fn right(&self, value: &Value) -> usize { 184 | match self.state(value) { 185 | State::Index(index) => index, 186 | State::Selection { start, end } => start.max(end), 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /doc-style.css: -------------------------------------------------------------------------------- 1 | /* Sweeten brand colors for docs.rs */ 2 | 3 | @import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&family=Lekton:wght@400;700&display=swap'); 4 | 5 | :root[data-theme="dark"] { 6 | /* Sweeten Color System */ 7 | /* Green */ 8 | --green-900: #085e16; 9 | --green-700: #2a9d3d; 10 | --green-500: #38b24d; 11 | --green-300: #3dc754; 12 | 13 | /* Lime */ 14 | --lime-900: #55931f; 15 | --lime-700: #76c431; 16 | --lime-500: #88cb4d; 17 | --lime-300: #9cdf62; 18 | 19 | /* Blue */ 20 | --blue-900: #19318f; 21 | --blue-700: #2740a5; 22 | --blue-500: #3350c1; 23 | --blue-300: #5971cf; 24 | 25 | /* Cobalt */ 26 | --cobalt-900: #1c4359; 27 | --cobalt-700: #2d5b76; 28 | --cobalt-500: #4682a4; 29 | --cobalt-300: #5abaf2; 30 | 31 | /* Neutral */ 32 | --black: #000000; 33 | --gray-950: #0d0f11; 34 | --gray-900: #1a1d21; 35 | --gray-600: #3d4249; 36 | --gray-400: #6b7280; 37 | --gray-200: #d1d5db; 38 | --white: #f8fafc; 39 | 40 | /* Semantic mappings for docs */ 41 | --sweeten-bg: var(--gray-900); 42 | --sweeten-active: var(--lime-500); 43 | --sweeten-muted: var(--gray-400); 44 | --sweeten-text: var(--gray-200); 45 | --sweeten-link: var(--cobalt-300); 46 | 47 | --font-family: 'Lato', ui-sans-serif, system-ui, sans-serif; 48 | --font-family-code: 'Lekton', ui-monospace, monospace; 49 | 50 | /* Override rustdoc dark theme variables */ 51 | --main-background-color: var(--sweeten-bg); 52 | --main-color: var(--sweeten-text); 53 | --sidebar-background-color: var(--sweeten-bg); 54 | --sidebar-background-color-hover: var(--gray-600); 55 | --link-color: var(--sweeten-link); 56 | --sidebar-link-color: var(--sweeten-muted); 57 | --sidebar-current-link-background-color: transparent; 58 | --search-input-focused-border-color: var(--sweeten-active); 59 | --headings-border-bottom-color: var(--gray-600); 60 | --settings-button-border-focus: var(--sweeten-active); 61 | } 62 | 63 | #crate-search, 64 | h1, 65 | h2, 66 | h3, 67 | h4, 68 | h5, 69 | h6, 70 | .sidebar, 71 | .mobile-topbar, 72 | .search-input, 73 | .search-results .result-name, 74 | .item-table dt>a, 75 | .out-of-band, 76 | .sub-heading, 77 | span.since, 78 | a.src, 79 | rustdoc-toolbar, 80 | summary.hideme, 81 | .scraped-example-list, 82 | .rustdoc-breadcrumbs, 83 | ul.all-items { 84 | font-family: var(--font-family); 85 | } 86 | 87 | /* Code uses Lekton */ 88 | pre, code, .rustdoc code, .docblock code, .docblock pre { 89 | font-family: var(--font-family-code); 90 | } 91 | 92 | /* Inline code in prose should match surrounding text size */ 93 | .docblock p code, 94 | .docblock li code, 95 | .docblock td code { 96 | font-size: inherit; 97 | padding: 0.1em 0.3em; 98 | } 99 | 100 | /* Semantic colors for different item types */ 101 | /* Modules - green */ 102 | .item-table .mod a { 103 | color: var(--green-500); 104 | } 105 | .item-table .mod a:hover { 106 | color: var(--green-300); 107 | } 108 | 109 | /* Functions - lime */ 110 | .item-table .fn a { 111 | color: var(--lime-500); 112 | } 113 | .item-table .fn a:hover { 114 | color: var(--lime-300); 115 | } 116 | 117 | /* Enums/Types - cobalt */ 118 | .item-table .enum a, 119 | .item-table .struct a, 120 | .item-table .type a { 121 | color: var(--cobalt-300); 122 | } 123 | .item-table .enum a:hover, 124 | .item-table .struct a:hover, 125 | .item-table .type a:hover { 126 | color: var(--cobalt-300); 127 | } 128 | 129 | /* Override dark theme background */ 130 | body { 131 | background-color: var(--sweeten-bg); 132 | font-family: var(--font-family); 133 | } 134 | 135 | /* Ensure bold text uses proper weight */ 136 | strong, b, h1, h2, h3, .sidebar .logo-container { 137 | font-weight: 700; 138 | } 139 | 140 | .sidebar, 141 | .sidebar-elems, 142 | nav.sub { 143 | background-color: var(--sweeten-bg); 144 | } 145 | 146 | /* Main content area */ 147 | .content, 148 | main { 149 | background-color: var(--sweeten-bg); 150 | color: var(--sweeten-text); 151 | } 152 | 153 | /* Links - cobalt by default, lime on hover */ 154 | a { 155 | color: var(--sweeten-link); 156 | text-decoration: none; 157 | } 158 | 159 | a:hover { 160 | color: var(--sweeten-active); 161 | } 162 | 163 | /* Headers - white for h1-h3, muted for rest */ 164 | h1, 165 | h2, 166 | h3 { 167 | color: var(--white); 168 | } 169 | 170 | h4, 171 | h5, 172 | h6 { 173 | color: var(--sweeten-text); 174 | } 175 | 176 | /* Sidebar section headers */ 177 | .sidebar .block h3, 178 | .sidebar-elems .block h3, 179 | .sidebar .block h3 a, 180 | .sidebar-elems .block h3 a { 181 | color: var(--white); 182 | } 183 | 184 | /* Sidebar active item */ 185 | .sidebar .current { 186 | background-color: var(--sweeten-bg) !important; 187 | color: var(--sweeten-active); 188 | } 189 | 190 | .sidebar a { 191 | color: var(--sweeten-muted); 192 | } 193 | 194 | .sidebar-elems a:hover, 195 | .sidebar a:hover, 196 | .sidebar .current a { 197 | color: var(--white); 198 | background-color: var(--gray-600) !important; 199 | } 200 | 201 | :target:not([data-nosnippet]) { 202 | background-color: rgba(255, 255, 255, 0.05); 203 | color: var(--sweeten-active); 204 | border-right: 1px solid var(--sweeten-active); 205 | } 206 | 207 | /* Search box */ 208 | .search-input { 209 | background-color: var(--gray-600); 210 | border: none; 211 | border-radius: 0; 212 | color: var(--white); 213 | } 214 | 215 | .search-input:focus { 216 | border: 1px solid var(--sweeten-active); 217 | } 218 | 219 | /* Muted text */ 220 | .since, 221 | .stability, 222 | .out-of-band, 223 | .docblock .warning { 224 | color: var(--sweeten-muted); 225 | } 226 | 227 | /* Function/type signatures */ 228 | .item-decl { 229 | background-color: var(--gray-950); 230 | } 231 | 232 | /* All code blocks */ 233 | .docblock pre, 234 | .docblock .example-wrap, 235 | .rustdoc .example-wrap, 236 | pre.rust, 237 | .item-decl pre { 238 | background-color: var(--gray-950); 239 | } 240 | 241 | /* Inline code in prose */ 242 | .docblock code, 243 | .docblock p code, 244 | .docblock li code { 245 | background-color: var(--gray-950); 246 | } 247 | 248 | .docblock li code { 249 | display: inline-block; 250 | } 251 | 252 | /* Top bar */ 253 | .top-doc .docblock, 254 | .search-container { 255 | background-color: var(--sweeten-bg); 256 | } 257 | -------------------------------------------------------------------------------- /examples/text_input.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrates the enhanced text_input widget with focus/blur messages. 2 | //! 3 | //! This example shows: 4 | //! - `on_focus(Fn(String) -> Message)` - receive the current value when focused 5 | //! - `on_blur(Message)` - emit a message when focus is lost 6 | //! - Form validation with inline error display 7 | //! - Tab navigation between fields 8 | //! 9 | //! Run with: `cargo run --example text_input` 10 | 11 | use iced::keyboard; 12 | use iced::widget::{ 13 | Id, button, center, column, container, operation, row, text, 14 | }; 15 | use iced::{Center, Element, Fill, Subscription, Task}; 16 | 17 | use sweeten::text_input; 18 | 19 | fn main() -> iced::Result { 20 | iced::application(App::new, App::update, App::view) 21 | .window_size((500.0, 300.0)) 22 | .title("sweeten • text_input with focus handling") 23 | .subscription(App::subscription) 24 | .run() 25 | } 26 | 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 28 | pub enum Field { 29 | Username, 30 | Password, 31 | } 32 | 33 | impl Field { 34 | fn id(&self) -> Id { 35 | Id::from(match self { 36 | Field::Username => "username", 37 | Field::Password => "password", 38 | }) 39 | } 40 | 41 | fn placeholder(&self) -> &'static str { 42 | match self { 43 | Field::Username => "Enter username", 44 | Field::Password => "Enter password", 45 | } 46 | } 47 | 48 | fn label(&self) -> &'static str { 49 | match self { 50 | Field::Username => "USERNAME", 51 | Field::Password => "PASSWORD", 52 | } 53 | } 54 | 55 | fn validation_hint(&self) -> &'static str { 56 | match self { 57 | Field::Username => "Letters and numbers only", 58 | Field::Password => "Go to town, but min length is 12!", 59 | } 60 | } 61 | } 62 | 63 | #[derive(Debug, Clone)] 64 | pub struct Input { 65 | field: Field, 66 | value: String, 67 | error: Option, 68 | } 69 | 70 | impl Input { 71 | fn new(field: Field) -> Self { 72 | Self { 73 | field, 74 | value: String::new(), 75 | error: None, 76 | } 77 | } 78 | 79 | fn field(&self) -> Field { 80 | self.field 81 | } 82 | 83 | fn value(&self) -> &str { 84 | &self.value 85 | } 86 | 87 | fn error(&self) -> Option<&str> { 88 | self.error.as_deref() 89 | } 90 | 91 | fn with_value(mut self, value: String) -> Self { 92 | self.value = value; 93 | self 94 | } 95 | 96 | fn validate(mut self) -> Self { 97 | match self.field { 98 | Field::Username => { 99 | if self.value.is_empty() { 100 | self.error = Some("Username is required".to_string()); 101 | } else if !self.value.chars().all(|c| c.is_alphanumeric()) { 102 | self.error = Some("Letters and numbers only".to_string()); 103 | } else { 104 | self.error = None; 105 | } 106 | } 107 | Field::Password => { 108 | if self.value.is_empty() { 109 | self.error = Some("Password is required".to_string()); 110 | } else if self.value.len() < 12 { 111 | self.error = Some( 112 | "Password must be at least 12 characters".to_string(), 113 | ); 114 | } else { 115 | self.error = None; 116 | } 117 | } 118 | } 119 | self 120 | } 121 | } 122 | 123 | #[derive(Debug)] 124 | struct App { 125 | username: Input, 126 | password: Input, 127 | focused_field: Option, 128 | } 129 | 130 | #[derive(Debug, Clone)] 131 | enum Message { 132 | InputChanged(Field, String), 133 | InputFocused(Field), 134 | InputBlurred(Field), 135 | SubmitForm, 136 | FocusNext, 137 | FocusPrevious, 138 | FocusedId(Id), 139 | } 140 | 141 | impl App { 142 | fn new() -> (Self, Task) { 143 | ( 144 | Self { 145 | username: Input::new(Field::Username), 146 | password: Input::new(Field::Password), 147 | focused_field: None, 148 | }, 149 | Task::done(Message::FocusNext), 150 | ) 151 | } 152 | 153 | fn update(&mut self, message: Message) -> Task { 154 | match message { 155 | Message::InputChanged(field, value) => match field { 156 | Field::Username => { 157 | self.username = 158 | self.username.clone().with_value(value).validate(); 159 | } 160 | Field::Password => { 161 | self.password = 162 | self.password.clone().with_value(value).validate(); 163 | } 164 | }, 165 | Message::InputFocused(field) => { 166 | self.focused_field = Some(field); 167 | } 168 | Message::InputBlurred(field) => { 169 | if self.focused_field == Some(field) { 170 | self.focused_field = None; 171 | } 172 | } 173 | Message::SubmitForm => { 174 | self.username = self.username.clone().validate(); 175 | self.password = self.password.clone().validate(); 176 | 177 | if self.form_is_valid() { 178 | println!("Form submitted successfully!"); 179 | } else { 180 | // Focus the first invalid field 181 | let field_to_focus = if self.username.error().is_some() { 182 | Field::Username 183 | } else { 184 | Field::Password 185 | }; 186 | 187 | return operation::focus(field_to_focus.id()); 188 | } 189 | } 190 | Message::FocusNext => { 191 | return sweeten::text_input::focus_next().discard(); 192 | } 193 | Message::FocusPrevious => { 194 | return sweeten::text_input::focus_previous() 195 | .map(Message::FocusedId); 196 | } 197 | Message::FocusedId(id) => { 198 | println!("focused: {id:?}"); 199 | } 200 | } 201 | Task::none() 202 | } 203 | 204 | fn form_is_valid(&self) -> bool { 205 | !self.username.value().is_empty() 206 | && !self.password.value().is_empty() 207 | && self.username.error().is_none() 208 | && self.password.error().is_none() 209 | } 210 | 211 | fn view(&self) -> Element<'_, Message> { 212 | let create_field_view = |input: &Input| { 213 | let field = input.field(); 214 | let value = input.value(); 215 | let is_focused = self.focused_field == Some(field); 216 | 217 | let input_widget = text_input(field.placeholder(), value) 218 | .id(field.id()) 219 | .on_input(move |text| Message::InputChanged(field, text)) 220 | .on_focus(Message::InputFocused(field)) 221 | .on_blur(Message::InputBlurred(field)) 222 | .width(Fill) 223 | .secure(field == Field::Password); 224 | 225 | let status_text_content = if let Some(error) = input.error() { 226 | format!("Error: {error}") 227 | } else if is_focused { 228 | field.validation_hint().to_string() 229 | } else { 230 | String::default() 231 | }; 232 | 233 | let status_text = text(status_text_content).size(10.0).style( 234 | if input.error().is_some() { 235 | text::danger 236 | } else { 237 | text::primary 238 | }, 239 | ); 240 | 241 | column![text(field.label()), input_widget, status_text].spacing(5) 242 | }; 243 | 244 | let submit_button = button(text("Submit").center()) 245 | .on_press_maybe(self.form_is_valid().then_some(Message::SubmitForm)) 246 | .width(120); 247 | 248 | let form_status_content = if self.username.error().is_some() 249 | || self.password.error().is_some() 250 | { 251 | "Please fix the errors above" 252 | } else if self.form_is_valid() { 253 | "Form is valid!" 254 | } else { 255 | "" 256 | }; 257 | 258 | let form_status = 259 | text(form_status_content).style(if self.form_is_valid() { 260 | text::success 261 | } else { 262 | text::danger 263 | }); 264 | 265 | center( 266 | column![ 267 | create_field_view(&self.username), 268 | create_field_view(&self.password), 269 | row![form_status, container(submit_button).align_right(Fill)] 270 | .spacing(20) 271 | .align_y(Center) 272 | ] 273 | .width(400) 274 | .align_x(Center) 275 | .spacing(20), 276 | ) 277 | .padding(20) 278 | .into() 279 | } 280 | 281 | fn subscription(&self) -> Subscription { 282 | use iced::event::{self, Event}; 283 | use iced::keyboard::{Key, key::Named}; 284 | 285 | event::listen_with(|event, _, _| match event { 286 | Event::Keyboard(keyboard::Event::KeyPressed { 287 | key, 288 | modifiers, 289 | .. 290 | }) => match key { 291 | Key::Named(Named::Tab) => { 292 | if modifiers.shift() { 293 | Some(Message::FocusPrevious) 294 | } else { 295 | Some(Message::FocusNext) 296 | } 297 | } 298 | Key::Named(Named::Enter) => Some(Message::SubmitForm), 299 | _ => None, 300 | }, 301 | _ => None, 302 | }) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /palette.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sweeten — Color System 7 | 138 | 139 | 140 |
141 |
142 |
Copied!
143 | 144 | 273 | 274 | 275 | -------------------------------------------------------------------------------- /src/widget/mouse_area.rs: -------------------------------------------------------------------------------- 1 | //! A container for capturing mouse events. 2 | //! 3 | //! This is a sweetened version of `iced`'s [`MouseArea`] where all event 4 | //! handlers receive the cursor position as a [`Point`]. 5 | //! 6 | //! [`MouseArea`]: https://docs.iced.rs/iced/widget/struct.MouseArea.html 7 | //! 8 | //! # Example 9 | //! ```no_run 10 | //! # pub type State = (); 11 | //! # pub type Element<'a, Message> = iced::Element<'a, Message>; 12 | //! use iced::Point; 13 | //! use iced::widget::text; 14 | //! use sweeten::widget::mouse_area; 15 | //! 16 | //! #[derive(Clone)] 17 | //! enum Message { 18 | //! Clicked(Point), 19 | //! } 20 | //! 21 | //! fn view(state: &State) -> Element<'_, Message> { 22 | //! mouse_area(text("Click me!")) 23 | //! .on_press(Message::Clicked) 24 | //! .into() 25 | //! } 26 | //! ``` 27 | use crate::core::layout; 28 | use crate::core::mouse; 29 | use crate::core::overlay; 30 | use crate::core::renderer; 31 | use crate::core::touch; 32 | use crate::core::widget::{Operation, Tree, tree}; 33 | use crate::core::{ 34 | Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell, Size, 35 | Vector, Widget, 36 | }; 37 | 38 | /// Emit messages on mouse events. 39 | pub struct MouseArea< 40 | 'a, 41 | Message, 42 | Theme = crate::Theme, 43 | Renderer = crate::Renderer, 44 | > { 45 | content: Element<'a, Message, Theme, Renderer>, 46 | on_press: Option Message + 'a>>, 47 | on_release: Option Message + 'a>>, 48 | on_double_click: Option Message + 'a>>, 49 | on_right_press: Option Message + 'a>>, 50 | on_right_release: Option Message + 'a>>, 51 | on_middle_press: Option Message + 'a>>, 52 | on_middle_release: Option Message + 'a>>, 53 | on_scroll: Option Message + 'a>>, 54 | on_enter: Option Message + 'a>>, 55 | on_move: Option Message + 'a>>, 56 | on_exit: Option Message + 'a>>, 57 | interaction: Option, 58 | } 59 | 60 | impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { 61 | /// Sets the message to emit on a left button press. 62 | /// 63 | /// The closure receives the click position as a [`Point`]. 64 | #[must_use] 65 | pub fn on_press(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 66 | self.on_press = Some(Box::new(f)); 67 | self 68 | } 69 | 70 | /// Sets the message to emit on a left button press, if `Some`. 71 | /// 72 | /// The closure receives the click position as a [`Point`]. 73 | #[must_use] 74 | pub fn on_press_maybe( 75 | mut self, 76 | f: Option Message + 'a>, 77 | ) -> Self { 78 | self.on_press = f.map(|f| Box::new(f) as _); 79 | self 80 | } 81 | 82 | /// Sets the message to emit on a left button release. 83 | /// 84 | /// The closure receives the release position as a [`Point`]. 85 | #[must_use] 86 | pub fn on_release(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 87 | self.on_release = Some(Box::new(f)); 88 | self 89 | } 90 | 91 | /// Sets the message to emit on a double click. 92 | /// 93 | /// The closure receives the click position as a [`Point`]. 94 | /// 95 | /// If you use this with [`on_press`]/[`on_release`], those 96 | /// events will be emitted as normal. 97 | /// 98 | /// The event stream will be: on_press -> on_release -> on_press 99 | /// -> on_double_click -> on_release -> on_press ... 100 | /// 101 | /// [`on_press`]: Self::on_press 102 | /// [`on_release`]: Self::on_release 103 | #[must_use] 104 | pub fn on_double_click( 105 | mut self, 106 | f: impl Fn(Point) -> Message + 'a, 107 | ) -> Self { 108 | self.on_double_click = Some(Box::new(f)); 109 | self 110 | } 111 | 112 | /// Sets the message to emit on a right button press. 113 | /// 114 | /// The closure receives the click position as a [`Point`]. 115 | #[must_use] 116 | pub fn on_right_press(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 117 | self.on_right_press = Some(Box::new(f)); 118 | self 119 | } 120 | 121 | /// Sets the message to emit on a right button release. 122 | /// 123 | /// The closure receives the release position as a [`Point`]. 124 | #[must_use] 125 | pub fn on_right_release( 126 | mut self, 127 | f: impl Fn(Point) -> Message + 'a, 128 | ) -> Self { 129 | self.on_right_release = Some(Box::new(f)); 130 | self 131 | } 132 | 133 | /// Sets the message to emit on a middle button press. 134 | /// 135 | /// The closure receives the click position as a [`Point`]. 136 | #[must_use] 137 | pub fn on_middle_press( 138 | mut self, 139 | f: impl Fn(Point) -> Message + 'a, 140 | ) -> Self { 141 | self.on_middle_press = Some(Box::new(f)); 142 | self 143 | } 144 | 145 | /// Sets the message to emit on a middle button release. 146 | /// 147 | /// The closure receives the release position as a [`Point`]. 148 | #[must_use] 149 | pub fn on_middle_release( 150 | mut self, 151 | f: impl Fn(Point) -> Message + 'a, 152 | ) -> Self { 153 | self.on_middle_release = Some(Box::new(f)); 154 | self 155 | } 156 | 157 | /// Sets the message to emit when the scroll wheel is used. 158 | #[must_use] 159 | pub fn on_scroll( 160 | mut self, 161 | on_scroll: impl Fn(mouse::ScrollDelta) -> Message + 'a, 162 | ) -> Self { 163 | self.on_scroll = Some(Box::new(on_scroll)); 164 | self 165 | } 166 | 167 | /// Sets the message to emit when the mouse enters the area. 168 | /// 169 | /// The closure receives the entry position as a [`Point`]. 170 | #[must_use] 171 | pub fn on_enter(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 172 | self.on_enter = Some(Box::new(f)); 173 | self 174 | } 175 | 176 | /// Sets the message to emit when the mouse moves in the area. 177 | /// 178 | /// The closure receives the current position as a [`Point`]. 179 | #[must_use] 180 | pub fn on_move(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 181 | self.on_move = Some(Box::new(f)); 182 | self 183 | } 184 | 185 | /// Sets the message to emit when the mouse exits the area. 186 | /// 187 | /// The closure receives the exit position as a [`Point`]. 188 | #[must_use] 189 | pub fn on_exit(mut self, f: impl Fn(Point) -> Message + 'a) -> Self { 190 | self.on_exit = Some(Box::new(f)); 191 | self 192 | } 193 | 194 | /// The [`mouse::Interaction`] to use when hovering the area. 195 | #[must_use] 196 | pub fn interaction(mut self, interaction: mouse::Interaction) -> Self { 197 | self.interaction = Some(interaction); 198 | self 199 | } 200 | } 201 | 202 | /// Local state of the [`MouseArea`]. 203 | #[derive(Default)] 204 | struct State { 205 | is_hovered: bool, 206 | bounds: Rectangle, 207 | cursor_position: Option, 208 | previous_click: Option, 209 | } 210 | 211 | impl<'a, Message, Theme, Renderer> MouseArea<'a, Message, Theme, Renderer> { 212 | /// Creates a [`MouseArea`] with the given content. 213 | pub fn new( 214 | content: impl Into>, 215 | ) -> Self { 216 | MouseArea { 217 | content: content.into(), 218 | on_press: None, 219 | on_release: None, 220 | on_double_click: None, 221 | on_right_press: None, 222 | on_right_release: None, 223 | on_middle_press: None, 224 | on_middle_release: None, 225 | on_scroll: None, 226 | on_enter: None, 227 | on_move: None, 228 | on_exit: None, 229 | interaction: None, 230 | } 231 | } 232 | } 233 | 234 | impl Widget 235 | for MouseArea<'_, Message, Theme, Renderer> 236 | where 237 | Renderer: renderer::Renderer, 238 | { 239 | fn tag(&self) -> tree::Tag { 240 | tree::Tag::of::() 241 | } 242 | 243 | fn state(&self) -> tree::State { 244 | tree::State::new(State::default()) 245 | } 246 | 247 | fn children(&self) -> Vec { 248 | vec![Tree::new(&self.content)] 249 | } 250 | 251 | fn diff(&self, tree: &mut Tree) { 252 | tree.diff_children(std::slice::from_ref(&self.content)); 253 | } 254 | 255 | fn size(&self) -> Size { 256 | self.content.as_widget().size() 257 | } 258 | 259 | fn layout( 260 | &mut self, 261 | tree: &mut Tree, 262 | renderer: &Renderer, 263 | limits: &layout::Limits, 264 | ) -> layout::Node { 265 | self.content.as_widget_mut().layout( 266 | &mut tree.children[0], 267 | renderer, 268 | limits, 269 | ) 270 | } 271 | 272 | fn operate( 273 | &mut self, 274 | tree: &mut Tree, 275 | layout: Layout<'_>, 276 | renderer: &Renderer, 277 | operation: &mut dyn Operation, 278 | ) { 279 | self.content.as_widget_mut().operate( 280 | &mut tree.children[0], 281 | layout, 282 | renderer, 283 | operation, 284 | ); 285 | } 286 | 287 | fn update( 288 | &mut self, 289 | tree: &mut Tree, 290 | event: &Event, 291 | layout: Layout<'_>, 292 | cursor: mouse::Cursor, 293 | renderer: &Renderer, 294 | clipboard: &mut dyn Clipboard, 295 | shell: &mut Shell<'_, Message>, 296 | viewport: &Rectangle, 297 | ) { 298 | self.content.as_widget_mut().update( 299 | &mut tree.children[0], 300 | event, 301 | layout, 302 | cursor, 303 | renderer, 304 | clipboard, 305 | shell, 306 | viewport, 307 | ); 308 | 309 | if shell.is_event_captured() { 310 | return; 311 | } 312 | 313 | update(self, tree, event, layout, cursor, shell); 314 | } 315 | 316 | fn mouse_interaction( 317 | &self, 318 | tree: &Tree, 319 | layout: Layout<'_>, 320 | cursor: mouse::Cursor, 321 | viewport: &Rectangle, 322 | renderer: &Renderer, 323 | ) -> mouse::Interaction { 324 | let content_interaction = self.content.as_widget().mouse_interaction( 325 | &tree.children[0], 326 | layout, 327 | cursor, 328 | viewport, 329 | renderer, 330 | ); 331 | 332 | match (self.interaction, content_interaction) { 333 | (Some(interaction), mouse::Interaction::None) 334 | if cursor.is_over(layout.bounds()) => 335 | { 336 | interaction 337 | } 338 | _ => content_interaction, 339 | } 340 | } 341 | 342 | fn draw( 343 | &self, 344 | tree: &Tree, 345 | renderer: &mut Renderer, 346 | theme: &Theme, 347 | renderer_style: &renderer::Style, 348 | layout: Layout<'_>, 349 | cursor: mouse::Cursor, 350 | viewport: &Rectangle, 351 | ) { 352 | self.content.as_widget().draw( 353 | &tree.children[0], 354 | renderer, 355 | theme, 356 | renderer_style, 357 | layout, 358 | cursor, 359 | viewport, 360 | ); 361 | } 362 | 363 | fn overlay<'b>( 364 | &'b mut self, 365 | tree: &'b mut Tree, 366 | layout: Layout<'b>, 367 | renderer: &Renderer, 368 | viewport: &Rectangle, 369 | translation: Vector, 370 | ) -> Option> { 371 | self.content.as_widget_mut().overlay( 372 | &mut tree.children[0], 373 | layout, 374 | renderer, 375 | viewport, 376 | translation, 377 | ) 378 | } 379 | } 380 | 381 | impl<'a, Message, Theme, Renderer> From> 382 | for Element<'a, Message, Theme, Renderer> 383 | where 384 | Message: 'a, 385 | Theme: 'a, 386 | Renderer: 'a + renderer::Renderer, 387 | { 388 | fn from( 389 | area: MouseArea<'a, Message, Theme, Renderer>, 390 | ) -> Element<'a, Message, Theme, Renderer> { 391 | Element::new(area) 392 | } 393 | } 394 | 395 | /// Processes the given [`Event`] and updates the [`State`] of an [`MouseArea`] 396 | /// accordingly. 397 | fn update( 398 | widget: &mut MouseArea<'_, Message, Theme, Renderer>, 399 | tree: &mut Tree, 400 | event: &Event, 401 | layout: Layout<'_>, 402 | cursor: mouse::Cursor, 403 | shell: &mut Shell<'_, Message>, 404 | ) { 405 | let state: &mut State = tree.state.downcast_mut(); 406 | 407 | let cursor_position = cursor.position(); 408 | let bounds = layout.bounds(); 409 | 410 | if state.cursor_position != cursor_position || state.bounds != bounds { 411 | let was_hovered = state.is_hovered; 412 | 413 | state.is_hovered = cursor.is_over(layout.bounds()); 414 | state.cursor_position = cursor_position; 415 | state.bounds = bounds; 416 | 417 | if let Some(position) = cursor.position_in(layout.bounds()) { 418 | match ( 419 | widget.on_enter.as_ref(), 420 | widget.on_move.as_ref(), 421 | widget.on_exit.as_ref(), 422 | ) { 423 | (Some(on_enter), _, _) if state.is_hovered && !was_hovered => { 424 | shell.publish(on_enter(position)); 425 | } 426 | (_, Some(on_move), _) if state.is_hovered => { 427 | shell.publish(on_move(position)); 428 | } 429 | (_, _, Some(on_exit)) if !state.is_hovered && was_hovered => { 430 | shell.publish(on_exit(position)); 431 | } 432 | _ => {} 433 | } 434 | } 435 | } 436 | 437 | if !cursor.is_over(layout.bounds()) { 438 | return; 439 | } 440 | 441 | match event { 442 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) 443 | | Event::Touch(touch::Event::FingerPressed { .. }) => { 444 | if let Some(on_press) = widget.on_press.as_ref() { 445 | if let Some(position) = cursor.position_in(layout.bounds()) { 446 | shell.publish(on_press(position)); 447 | shell.capture_event(); 448 | } 449 | } 450 | 451 | if let Some(position) = cursor.position_in(layout.bounds()) 452 | && let Some(on_double_click) = widget.on_double_click.as_ref() 453 | { 454 | let new_click = mouse::Click::new( 455 | position, 456 | mouse::Button::Left, 457 | state.previous_click, 458 | ); 459 | 460 | if new_click.kind() == mouse::click::Kind::Double { 461 | shell.publish(on_double_click(position)); 462 | } 463 | 464 | state.previous_click = Some(new_click); 465 | 466 | // Even if this is not a double click, but the press is nevertheless 467 | // processed by us and should not be popup to parent widgets. 468 | shell.capture_event(); 469 | } 470 | } 471 | Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) 472 | | Event::Touch(touch::Event::FingerLifted { .. }) => { 473 | if let Some(on_release) = widget.on_release.as_ref() { 474 | if let Some(position) = cursor.position_in(layout.bounds()) { 475 | shell.publish(on_release(position)); 476 | } 477 | } 478 | } 479 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { 480 | if let Some(on_right_press) = widget.on_right_press.as_ref() { 481 | if let Some(position) = cursor.position_in(layout.bounds()) { 482 | shell.publish(on_right_press(position)); 483 | shell.capture_event(); 484 | } 485 | } 486 | } 487 | Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { 488 | if let Some(on_right_release) = widget.on_right_release.as_ref() { 489 | if let Some(position) = cursor.position_in(layout.bounds()) { 490 | shell.publish(on_right_release(position)); 491 | } 492 | } 493 | } 494 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => { 495 | if let Some(on_middle_press) = widget.on_middle_press.as_ref() { 496 | if let Some(position) = cursor.position_in(layout.bounds()) { 497 | shell.publish(on_middle_press(position)); 498 | shell.capture_event(); 499 | } 500 | } 501 | } 502 | Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => { 503 | if let Some(on_middle_release) = widget.on_middle_release.as_ref() { 504 | if let Some(position) = cursor.position_in(layout.bounds()) { 505 | shell.publish(on_middle_release(position)); 506 | } 507 | } 508 | } 509 | Event::Mouse(mouse::Event::WheelScrolled { delta }) => { 510 | if let Some(on_scroll) = widget.on_scroll.as_ref() { 511 | shell.publish(on_scroll(*delta)); 512 | shell.capture_event(); 513 | } 514 | } 515 | _ => {} 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /src/widget/overlay/menu.rs: -------------------------------------------------------------------------------- 1 | //! Build and show dropdown menus. 2 | use crate::core::alignment; 3 | use crate::core::border::{self, Border}; 4 | use crate::core::layout::{self, Layout}; 5 | use crate::core::mouse; 6 | use crate::core::overlay; 7 | use crate::core::renderer; 8 | use crate::core::text::{self, Text}; 9 | use crate::core::touch; 10 | use crate::core::widget::tree::{self, Tree}; 11 | use crate::core::window; 12 | use crate::core::{ 13 | Background, Clipboard, Color, Event, Length, Padding, Pixels, Point, 14 | Rectangle, Size, Theme, Vector, 15 | }; 16 | use crate::core::{Element, Shell, Widget}; 17 | use crate::scrollable::{self, Scrollable}; 18 | 19 | /// A list of selectable options. 20 | pub struct Menu< 21 | 'a, 22 | 'b, 23 | T, 24 | Message, 25 | Theme = crate::Theme, 26 | Renderer = crate::Renderer, 27 | > where 28 | Theme: Catalog, 29 | Renderer: text::Renderer, 30 | 'b: 'a, 31 | { 32 | state: &'a mut State, 33 | options: &'a [T], 34 | disabled: Option>, 35 | hovered_option: &'a mut Option, 36 | on_selected: Box Message + 'a>, 37 | on_option_hovered: Option<&'a dyn Fn(T) -> Message>, 38 | width: f32, 39 | padding: Padding, 40 | text_size: Option, 41 | text_line_height: text::LineHeight, 42 | text_shaping: text::Shaping, 43 | font: Option, 44 | class: &'a ::Class<'b>, 45 | } 46 | 47 | impl<'a, 'b, T, Message, Theme, Renderer> 48 | Menu<'a, 'b, T, Message, Theme, Renderer> 49 | where 50 | T: ToString + Clone, 51 | Message: 'a, 52 | Theme: Catalog + 'a, 53 | Renderer: text::Renderer + 'a, 54 | 'b: 'a, 55 | { 56 | /// Creates a new [`Menu`] with the given [`State`], a list of options, 57 | /// the message to produced when an option is selected, and its [`Style`]. 58 | pub fn new( 59 | state: &'a mut State, 60 | options: &'a [T], 61 | hovered_option: &'a mut Option, 62 | on_selected: impl FnMut(T) -> Message + 'a, 63 | disabled: Option>, 64 | on_option_hovered: Option<&'a dyn Fn(T) -> Message>, 65 | class: &'a ::Class<'b>, 66 | ) -> Self { 67 | Menu { 68 | state, 69 | options, 70 | disabled, 71 | hovered_option, 72 | on_selected: Box::new(on_selected), 73 | on_option_hovered, 74 | width: 0.0, 75 | padding: Padding::ZERO, 76 | text_size: None, 77 | text_line_height: text::LineHeight::default(), 78 | text_shaping: text::Shaping::default(), 79 | font: None, 80 | class, 81 | } 82 | } 83 | 84 | /// Sets the width of the [`Menu`]. 85 | pub fn width(mut self, width: f32) -> Self { 86 | self.width = width; 87 | self 88 | } 89 | 90 | /// Sets the [`Padding`] of the [`Menu`]. 91 | pub fn padding>(mut self, padding: P) -> Self { 92 | self.padding = padding.into(); 93 | self 94 | } 95 | 96 | /// Sets the text size of the [`Menu`]. 97 | pub fn text_size(mut self, text_size: impl Into) -> Self { 98 | self.text_size = Some(text_size.into()); 99 | self 100 | } 101 | 102 | /// Sets the text [`text::LineHeight`] of the [`Menu`]. 103 | pub fn text_line_height( 104 | mut self, 105 | line_height: impl Into, 106 | ) -> Self { 107 | self.text_line_height = line_height.into(); 108 | self 109 | } 110 | 111 | /// Sets the [`text::Shaping`] strategy of the [`Menu`]. 112 | pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { 113 | self.text_shaping = shaping; 114 | self 115 | } 116 | 117 | /// Sets the font of the [`Menu`]. 118 | pub fn font(mut self, font: impl Into) -> Self { 119 | self.font = Some(font.into()); 120 | self 121 | } 122 | 123 | /// Turns the [`Menu`] into an overlay [`Element`] at the given target 124 | /// position. 125 | /// Check if an option at the given index is disabled. 126 | pub fn is_disabled(&self, index: usize) -> bool { 127 | self.disabled 128 | .as_ref() 129 | .and_then(|d| d.get(index)) 130 | .copied() 131 | .unwrap_or(false) 132 | } 133 | 134 | /// 135 | /// The `target_height` will be used to display the menu either on top 136 | /// of the target or under it, depending on the screen position and the 137 | /// dimensions of the [`Menu`]. 138 | pub fn overlay( 139 | self, 140 | position: Point, 141 | viewport: Rectangle, 142 | target_height: f32, 143 | menu_height: Length, 144 | ) -> overlay::Element<'a, Message, Theme, Renderer> { 145 | overlay::Element::new(Box::new(Overlay::new( 146 | position, 147 | viewport, 148 | self, 149 | target_height, 150 | menu_height, 151 | ))) 152 | } 153 | } 154 | 155 | /// The local state of a [`Menu`]. 156 | #[derive(Debug)] 157 | pub struct State { 158 | tree: Tree, 159 | } 160 | 161 | impl State { 162 | /// Creates a new [`State`] for a [`Menu`]. 163 | pub fn new() -> Self { 164 | Self { 165 | tree: Tree::empty(), 166 | } 167 | } 168 | } 169 | 170 | impl Default for State { 171 | fn default() -> Self { 172 | Self::new() 173 | } 174 | } 175 | 176 | struct Overlay<'a, 'b, Message, Theme, Renderer> 177 | where 178 | Theme: Catalog, 179 | Renderer: crate::core::text::Renderer, 180 | { 181 | position: Point, 182 | viewport: Rectangle, 183 | tree: &'a mut Tree, 184 | list: Scrollable<'a, Message, Theme, Renderer>, 185 | width: f32, 186 | target_height: f32, 187 | class: &'a ::Class<'b>, 188 | } 189 | 190 | impl<'a, 'b, Message, Theme, Renderer> Overlay<'a, 'b, Message, Theme, Renderer> 191 | where 192 | Message: 'a, 193 | Theme: Catalog + scrollable::Catalog + 'a, 194 | Renderer: text::Renderer + 'a, 195 | 'b: 'a, 196 | { 197 | pub fn new( 198 | position: Point, 199 | viewport: Rectangle, 200 | menu: Menu<'a, 'b, T, Message, Theme, Renderer>, 201 | target_height: f32, 202 | menu_height: Length, 203 | ) -> Self 204 | where 205 | T: Clone + ToString, 206 | { 207 | let Menu { 208 | state, 209 | options, 210 | disabled, 211 | hovered_option, 212 | on_selected, 213 | on_option_hovered, 214 | width, 215 | padding, 216 | font, 217 | text_size, 218 | text_line_height, 219 | text_shaping, 220 | class, 221 | } = menu; 222 | 223 | let list = Scrollable::new(List { 224 | options, 225 | disabled, 226 | hovered_option, 227 | on_selected, 228 | on_option_hovered, 229 | font, 230 | text_size, 231 | text_line_height, 232 | text_shaping, 233 | padding, 234 | class, 235 | }) 236 | .height(menu_height); 237 | 238 | state.tree.diff(&list as &dyn Widget<_, _, _>); 239 | 240 | Self { 241 | position, 242 | viewport, 243 | tree: &mut state.tree, 244 | list, 245 | width, 246 | target_height, 247 | class, 248 | } 249 | } 250 | } 251 | 252 | impl crate::core::Overlay 253 | for Overlay<'_, '_, Message, Theme, Renderer> 254 | where 255 | Theme: Catalog, 256 | Renderer: text::Renderer, 257 | { 258 | fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { 259 | let space_below = 260 | bounds.height - (self.position.y + self.target_height); 261 | let space_above = self.position.y; 262 | 263 | let limits = layout::Limits::new( 264 | Size::ZERO, 265 | Size::new( 266 | bounds.width - self.position.x, 267 | if space_below > space_above { 268 | space_below 269 | } else { 270 | space_above 271 | }, 272 | ), 273 | ) 274 | .width(self.width); 275 | 276 | let node = self.list.layout(self.tree, renderer, &limits); 277 | let size = node.size(); 278 | 279 | node.move_to(if space_below > space_above { 280 | self.position + Vector::new(0.0, self.target_height) 281 | } else { 282 | self.position - Vector::new(0.0, size.height) 283 | }) 284 | } 285 | 286 | fn update( 287 | &mut self, 288 | event: &Event, 289 | layout: Layout<'_>, 290 | cursor: mouse::Cursor, 291 | renderer: &Renderer, 292 | clipboard: &mut dyn Clipboard, 293 | shell: &mut Shell<'_, Message>, 294 | ) { 295 | let bounds = layout.bounds(); 296 | 297 | self.list.update( 298 | self.tree, event, layout, cursor, renderer, clipboard, shell, 299 | &bounds, 300 | ); 301 | } 302 | 303 | fn mouse_interaction( 304 | &self, 305 | layout: Layout<'_>, 306 | cursor: mouse::Cursor, 307 | renderer: &Renderer, 308 | ) -> mouse::Interaction { 309 | self.list.mouse_interaction( 310 | self.tree, 311 | layout, 312 | cursor, 313 | &self.viewport, 314 | renderer, 315 | ) 316 | } 317 | 318 | fn draw( 319 | &self, 320 | renderer: &mut Renderer, 321 | theme: &Theme, 322 | defaults: &renderer::Style, 323 | layout: Layout<'_>, 324 | cursor: mouse::Cursor, 325 | ) { 326 | let bounds = layout.bounds(); 327 | 328 | let style = Catalog::style(theme, self.class); 329 | 330 | renderer.fill_quad( 331 | renderer::Quad { 332 | bounds, 333 | border: style.border, 334 | ..renderer::Quad::default() 335 | }, 336 | style.background, 337 | ); 338 | 339 | self.list.draw( 340 | self.tree, renderer, theme, defaults, layout, cursor, &bounds, 341 | ); 342 | } 343 | } 344 | 345 | struct List<'a, 'b, T, Message, Theme, Renderer> 346 | where 347 | Theme: Catalog, 348 | Renderer: text::Renderer, 349 | { 350 | options: &'a [T], 351 | disabled: Option>, 352 | hovered_option: &'a mut Option, 353 | on_selected: Box Message + 'a>, 354 | on_option_hovered: Option<&'a dyn Fn(T) -> Message>, 355 | padding: Padding, 356 | text_size: Option, 357 | text_line_height: text::LineHeight, 358 | text_shaping: text::Shaping, 359 | font: Option, 360 | class: &'a ::Class<'b>, 361 | } 362 | 363 | impl List<'_, '_, T, Message, Theme, Renderer> 364 | where 365 | Theme: Catalog, 366 | Renderer: text::Renderer, 367 | { 368 | fn is_disabled(&self, index: usize) -> bool { 369 | self.disabled 370 | .as_ref() 371 | .and_then(|d| d.get(index)) 372 | .copied() 373 | .unwrap_or(false) 374 | } 375 | } 376 | 377 | struct ListState { 378 | is_hovered: Option, 379 | } 380 | 381 | impl Widget 382 | for List<'_, '_, T, Message, Theme, Renderer> 383 | where 384 | T: Clone + ToString, 385 | Theme: Catalog, 386 | Renderer: text::Renderer, 387 | { 388 | fn tag(&self) -> tree::Tag { 389 | tree::Tag::of::>() 390 | } 391 | 392 | fn state(&self) -> tree::State { 393 | tree::State::new(ListState { is_hovered: None }) 394 | } 395 | 396 | fn size(&self) -> Size { 397 | Size { 398 | width: Length::Fill, 399 | height: Length::Shrink, 400 | } 401 | } 402 | 403 | fn layout( 404 | &mut self, 405 | _tree: &mut Tree, 406 | renderer: &Renderer, 407 | limits: &layout::Limits, 408 | ) -> layout::Node { 409 | use std::f32; 410 | 411 | let text_size = 412 | self.text_size.unwrap_or_else(|| renderer.default_size()); 413 | 414 | let text_line_height = self.text_line_height.to_absolute(text_size); 415 | 416 | let size = { 417 | let intrinsic = Size::new( 418 | 0.0, 419 | (f32::from(text_line_height) + self.padding.y()) 420 | * self.options.len() as f32, 421 | ); 422 | 423 | limits.resolve(Length::Fill, Length::Shrink, intrinsic) 424 | }; 425 | 426 | layout::Node::new(size) 427 | } 428 | 429 | fn update( 430 | &mut self, 431 | tree: &mut Tree, 432 | event: &Event, 433 | layout: Layout<'_>, 434 | cursor: mouse::Cursor, 435 | renderer: &Renderer, 436 | _clipboard: &mut dyn Clipboard, 437 | shell: &mut Shell<'_, Message>, 438 | _viewport: &Rectangle, 439 | ) { 440 | match event { 441 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { 442 | if cursor.is_over(layout.bounds()) 443 | && let Some(index) = *self.hovered_option 444 | && !self.is_disabled(index) 445 | && let Some(option) = self.options.get(index) 446 | { 447 | shell.publish((self.on_selected)(option.clone())); 448 | shell.capture_event(); 449 | } 450 | } 451 | Event::Mouse(mouse::Event::CursorMoved { .. }) => { 452 | if let Some(cursor_position) = 453 | cursor.position_in(layout.bounds()) 454 | { 455 | let text_size = self 456 | .text_size 457 | .unwrap_or_else(|| renderer.default_size()); 458 | 459 | let option_height = 460 | f32::from(self.text_line_height.to_absolute(text_size)) 461 | + self.padding.y(); 462 | 463 | let new_hovered_option = 464 | (cursor_position.y / option_height) as usize; 465 | 466 | if *self.hovered_option != Some(new_hovered_option) 467 | && !self.is_disabled(new_hovered_option) 468 | && let Some(option) = 469 | self.options.get(new_hovered_option) 470 | { 471 | if let Some(on_option_hovered) = self.on_option_hovered 472 | { 473 | shell.publish(on_option_hovered(option.clone())); 474 | } 475 | 476 | shell.request_redraw(); 477 | } 478 | 479 | if !self.is_disabled(new_hovered_option) { 480 | *self.hovered_option = Some(new_hovered_option); 481 | } 482 | } 483 | } 484 | Event::Touch(touch::Event::FingerPressed { .. }) => { 485 | if let Some(cursor_position) = 486 | cursor.position_in(layout.bounds()) 487 | { 488 | let text_size = self 489 | .text_size 490 | .unwrap_or_else(|| renderer.default_size()); 491 | 492 | let option_height = 493 | f32::from(self.text_line_height.to_absolute(text_size)) 494 | + self.padding.y(); 495 | 496 | let index = (cursor_position.y / option_height) as usize; 497 | 498 | if !self.is_disabled(index) { 499 | *self.hovered_option = Some(index); 500 | 501 | if let Some(option) = self.options.get(index) { 502 | shell.publish((self.on_selected)(option.clone())); 503 | shell.capture_event(); 504 | } 505 | } 506 | } 507 | } 508 | _ => {} 509 | } 510 | 511 | let state = tree.state.downcast_mut::(); 512 | 513 | if let Event::Window(window::Event::RedrawRequested(_now)) = event { 514 | state.is_hovered = Some(cursor.is_over(layout.bounds())); 515 | } else if state.is_hovered.is_some_and(|is_hovered| { 516 | is_hovered != cursor.is_over(layout.bounds()) 517 | }) { 518 | shell.request_redraw(); 519 | } 520 | } 521 | 522 | fn mouse_interaction( 523 | &self, 524 | _tree: &Tree, 525 | layout: Layout<'_>, 526 | cursor: mouse::Cursor, 527 | _viewport: &Rectangle, 528 | renderer: &Renderer, 529 | ) -> mouse::Interaction { 530 | if let Some(cursor_position) = cursor.position_in(layout.bounds()) { 531 | let text_size = 532 | self.text_size.unwrap_or_else(|| renderer.default_size()); 533 | 534 | let option_height = 535 | f32::from(self.text_line_height.to_absolute(text_size)) 536 | + self.padding.y(); 537 | 538 | let hovered_option = (cursor_position.y / option_height) as usize; 539 | 540 | if !self.is_disabled(hovered_option) { 541 | return mouse::Interaction::Pointer; 542 | } 543 | } 544 | 545 | mouse::Interaction::default() 546 | } 547 | 548 | fn draw( 549 | &self, 550 | _tree: &Tree, 551 | renderer: &mut Renderer, 552 | theme: &Theme, 553 | _style: &renderer::Style, 554 | layout: Layout<'_>, 555 | _cursor: mouse::Cursor, 556 | viewport: &Rectangle, 557 | ) { 558 | let style = Catalog::style(theme, self.class); 559 | let bounds = layout.bounds(); 560 | 561 | let text_size = 562 | self.text_size.unwrap_or_else(|| renderer.default_size()); 563 | let option_height = 564 | f32::from(self.text_line_height.to_absolute(text_size)) 565 | + self.padding.y(); 566 | 567 | let offset = viewport.y - bounds.y; 568 | let start = (offset / option_height) as usize; 569 | let end = ((offset + viewport.height) / option_height).ceil() as usize; 570 | 571 | let visible_options = &self.options[start..end.min(self.options.len())]; 572 | 573 | for (i, option) in visible_options.iter().enumerate() { 574 | let i = start + i; 575 | let is_selected = *self.hovered_option == Some(i); 576 | let is_disabled = self.is_disabled(i); 577 | 578 | let bounds = Rectangle { 579 | x: bounds.x, 580 | y: bounds.y + (option_height * i as f32), 581 | width: bounds.width, 582 | height: option_height, 583 | }; 584 | 585 | if is_selected && !is_disabled { 586 | renderer.fill_quad( 587 | renderer::Quad { 588 | bounds: Rectangle { 589 | x: bounds.x + style.border.width, 590 | width: bounds.width - style.border.width * 2.0, 591 | ..bounds 592 | }, 593 | border: border::rounded(style.border.radius), 594 | ..renderer::Quad::default() 595 | }, 596 | style.selected_background, 597 | ); 598 | } else if is_disabled { 599 | renderer.fill_quad( 600 | renderer::Quad { 601 | bounds: Rectangle { 602 | x: bounds.x + style.border.width, 603 | width: bounds.width - style.border.width * 2.0, 604 | ..bounds 605 | }, 606 | border: border::rounded(style.border.radius), 607 | ..renderer::Quad::default() 608 | }, 609 | style.disabled_background, 610 | ); 611 | } 612 | 613 | renderer.fill_text( 614 | Text { 615 | content: option.to_string(), 616 | bounds: Size::new(f32::INFINITY, bounds.height), 617 | size: text_size, 618 | line_height: self.text_line_height, 619 | font: self.font.unwrap_or_else(|| renderer.default_font()), 620 | align_x: text::Alignment::Default, 621 | align_y: alignment::Vertical::Center, 622 | shaping: self.text_shaping, 623 | wrapping: text::Wrapping::default(), 624 | }, 625 | Point::new(bounds.x + self.padding.left, bounds.center_y()), 626 | if is_disabled { 627 | style.disabled_text_color 628 | } else if is_selected { 629 | style.selected_text_color 630 | } else { 631 | style.text_color 632 | }, 633 | *viewport, 634 | ); 635 | } 636 | } 637 | } 638 | 639 | impl<'a, 'b, T, Message, Theme, Renderer> 640 | From> 641 | for Element<'a, Message, Theme, Renderer> 642 | where 643 | T: ToString + Clone, 644 | Message: 'a, 645 | Theme: 'a + Catalog, 646 | Renderer: 'a + text::Renderer, 647 | 'b: 'a, 648 | { 649 | fn from(list: List<'a, 'b, T, Message, Theme, Renderer>) -> Self { 650 | Element::new(list) 651 | } 652 | } 653 | 654 | /// The appearance of a [`Menu`]. 655 | #[derive(Debug, Clone, Copy, PartialEq)] 656 | pub struct Style { 657 | /// The [`Background`] of the menu. 658 | pub background: Background, 659 | /// The [`Border`] of the menu. 660 | pub border: Border, 661 | /// The text [`Color`] of the menu. 662 | pub text_color: Color, 663 | /// The text [`Color`] of a selected option in the menu. 664 | pub selected_text_color: Color, 665 | /// The background [`Color`] of a selected option in the menu. 666 | pub selected_background: Background, 667 | /// The text [`Color`] of a disabled option in the menu. 668 | pub disabled_text_color: Color, 669 | /// The background [`Color`] of a disabled option in the menu. 670 | pub disabled_background: Background, 671 | } 672 | 673 | /// The theme catalog of a [`Menu`]. 674 | pub trait Catalog: scrollable::Catalog { 675 | /// The item class of the [`Catalog`]. 676 | type Class<'a>; 677 | 678 | /// The default class produced by the [`Catalog`]. 679 | fn default<'a>() -> ::Class<'a>; 680 | 681 | /// The default class for the scrollable of the [`Menu`]. 682 | fn default_scrollable<'a>() -> ::Class<'a> { 683 | ::default() 684 | } 685 | 686 | /// The [`Style`] of a class with the given status. 687 | fn style(&self, class: &::Class<'_>) -> Style; 688 | } 689 | 690 | /// A styling function for a [`Menu`]. 691 | pub type StyleFn<'a, Theme> = Box Style + 'a>; 692 | 693 | impl Catalog for Theme { 694 | type Class<'a> = StyleFn<'a, Self>; 695 | 696 | fn default<'a>() -> StyleFn<'a, Self> { 697 | Box::new(default) 698 | } 699 | 700 | fn style(&self, class: &StyleFn<'_, Self>) -> Style { 701 | class(self) 702 | } 703 | } 704 | 705 | /// The default style of the list of a [`Menu`]. 706 | pub fn default(theme: &Theme) -> Style { 707 | let palette = theme.extended_palette(); 708 | 709 | Style { 710 | background: palette.background.weak.color.into(), 711 | border: Border { 712 | width: 1.0, 713 | radius: 0.0.into(), 714 | color: palette.background.strong.color, 715 | }, 716 | text_color: palette.background.weak.text, 717 | selected_text_color: palette.primary.strong.text, 718 | selected_background: palette.primary.strong.color.into(), 719 | disabled_text_color: palette.background.strong.color, 720 | disabled_background: palette.background.weak.color.into(), 721 | } 722 | } 723 | -------------------------------------------------------------------------------- /src/widget/pick_list.rs: -------------------------------------------------------------------------------- 1 | //! Pick lists display a dropdown list of selectable options. 2 | //! 3 | //! This is a sweetened version of `iced`'s [`pick_list`] with support for 4 | //! disabling individual items via [`PickList::disabled`]. 5 | //! 6 | //! [`pick_list`]: https://docs.iced.rs/iced/widget/pick_list/ 7 | //! 8 | //! # Example 9 | //! ```no_run 10 | //! # pub type Element<'a, Message> = iced::Element<'a, Message>; 11 | //! use sweeten::widget::pick_list; 12 | //! 13 | //! struct State { 14 | //! favorite: Option, 15 | //! } 16 | //! 17 | //! #[derive(Debug, Clone, Copy, PartialEq, Eq)] 18 | //! enum Fruit { 19 | //! Apple, 20 | //! Orange, 21 | //! Strawberry, 22 | //! Tomato, 23 | //! } 24 | //! 25 | //! #[derive(Debug, Clone)] 26 | //! enum Message { 27 | //! FruitSelected(Fruit), 28 | //! } 29 | //! 30 | //! fn view(state: &State) -> Element<'_, Message> { 31 | //! let fruits = [ 32 | //! Fruit::Apple, 33 | //! Fruit::Orange, 34 | //! Fruit::Strawberry, 35 | //! Fruit::Tomato, 36 | //! ]; 37 | //! 38 | //! // Disable Tomato because it's not a fruit! 39 | //! pick_list(fruits, state.favorite, Message::FruitSelected) 40 | //! .disabled(|options| { 41 | //! options.iter().map(|f| matches!(f, Fruit::Tomato)).collect() 42 | //! }) 43 | //! .placeholder("Select your favorite fruit...") 44 | //! .into() 45 | //! } 46 | //! 47 | //! impl std::fmt::Display for Fruit { 48 | //! fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | //! f.write_str(match self { 50 | //! Self::Apple => "Apple", 51 | //! Self::Orange => "Orange", 52 | //! Self::Strawberry => "Strawberry", 53 | //! Self::Tomato => "Tomato", 54 | //! }) 55 | //! } 56 | //! } 57 | //! ``` 58 | use crate::core::alignment; 59 | use crate::core::keyboard; 60 | use crate::core::layout; 61 | use crate::core::mouse; 62 | use crate::core::overlay; 63 | use crate::core::renderer; 64 | use crate::core::text::paragraph; 65 | use crate::core::text::{self, Text}; 66 | use crate::core::touch; 67 | use crate::core::widget::tree::{self, Tree}; 68 | use crate::core::window; 69 | use crate::core::{ 70 | Background, Border, Clipboard, Color, Element, Event, Layout, Length, 71 | Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, 72 | }; 73 | use crate::overlay::menu::{self, Menu}; 74 | 75 | use std::borrow::Borrow; 76 | use std::f32; 77 | 78 | /// A widget for selecting a single value from a list of options. 79 | /// 80 | /// # Example 81 | /// ```no_run 82 | /// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } 83 | /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; 84 | /// # 85 | /// use iced::widget::pick_list; 86 | /// 87 | /// struct State { 88 | /// favorite: Option, 89 | /// } 90 | /// 91 | /// #[derive(Debug, Clone, Copy, PartialEq, Eq)] 92 | /// enum Fruit { 93 | /// Apple, 94 | /// Orange, 95 | /// Strawberry, 96 | /// Tomato, 97 | /// } 98 | /// 99 | /// #[derive(Debug, Clone)] 100 | /// enum Message { 101 | /// FruitSelected(Fruit), 102 | /// } 103 | /// 104 | /// fn view(state: &State) -> Element<'_, Message> { 105 | /// let fruits = [ 106 | /// Fruit::Apple, 107 | /// Fruit::Orange, 108 | /// Fruit::Strawberry, 109 | /// Fruit::Tomato, 110 | /// ]; 111 | /// 112 | /// pick_list( 113 | /// fruits, 114 | /// state.favorite, 115 | /// Message::FruitSelected, 116 | /// ) 117 | /// .placeholder("Select your favorite fruit...") 118 | /// .into() 119 | /// } 120 | /// 121 | /// fn update(state: &mut State, message: Message) { 122 | /// match message { 123 | /// Message::FruitSelected(fruit) => { 124 | /// state.favorite = Some(fruit); 125 | /// } 126 | /// } 127 | /// } 128 | /// 129 | /// impl std::fmt::Display for Fruit { 130 | /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 | /// f.write_str(match self { 132 | /// Self::Apple => "Apple", 133 | /// Self::Orange => "Orange", 134 | /// Self::Strawberry => "Strawberry", 135 | /// Self::Tomato => "Tomato", 136 | /// }) 137 | /// } 138 | /// } 139 | /// ``` 140 | #[allow(clippy::type_complexity)] 141 | pub struct PickList< 142 | 'a, 143 | T, 144 | L, 145 | V, 146 | Message, 147 | Theme = crate::Theme, 148 | Renderer = crate::Renderer, 149 | > where 150 | T: ToString + PartialEq + Clone, 151 | L: Borrow<[T]> + 'a, 152 | V: Borrow + 'a, 153 | Theme: Catalog, 154 | Renderer: text::Renderer, 155 | { 156 | on_select: Box Message + 'a>, 157 | on_open: Option, 158 | on_close: Option, 159 | options: L, 160 | disabled: Option Vec + 'a>>, 161 | placeholder: Option, 162 | selected: Option, 163 | width: Length, 164 | padding: Padding, 165 | text_size: Option, 166 | text_line_height: text::LineHeight, 167 | text_shaping: text::Shaping, 168 | font: Option, 169 | handle: Handle, 170 | class: ::Class<'a>, 171 | menu_class: ::Class<'a>, 172 | last_status: Option, 173 | menu_height: Length, 174 | } 175 | 176 | impl<'a, T, L, V, Message, Theme, Renderer> 177 | PickList<'a, T, L, V, Message, Theme, Renderer> 178 | where 179 | T: ToString + PartialEq + Clone, 180 | L: Borrow<[T]> + 'a, 181 | V: Borrow + 'a, 182 | Message: Clone, 183 | Theme: Catalog, 184 | Renderer: text::Renderer, 185 | { 186 | /// Creates a new [`PickList`] with the given list of options, the current 187 | /// selected value, and the message to produce when an option is selected. 188 | pub fn new( 189 | options: L, 190 | selected: Option, 191 | on_select: impl Fn(T) -> Message + 'a, 192 | ) -> Self { 193 | Self { 194 | on_select: Box::new(on_select), 195 | on_open: None, 196 | on_close: None, 197 | options, 198 | disabled: None, 199 | placeholder: None, 200 | selected, 201 | width: Length::Shrink, 202 | padding: crate::button::DEFAULT_PADDING, 203 | text_size: None, 204 | text_line_height: text::LineHeight::default(), 205 | text_shaping: text::Shaping::default(), 206 | font: None, 207 | handle: Handle::default(), 208 | class: ::default(), 209 | menu_class: ::default_menu(), 210 | last_status: None, 211 | menu_height: Length::Shrink, 212 | } 213 | } 214 | 215 | /// Sets the placeholder of the [`PickList`]. 216 | pub fn placeholder(mut self, placeholder: impl Into) -> Self { 217 | self.placeholder = Some(placeholder.into()); 218 | self 219 | } 220 | 221 | /// Sets a function that determines which options are disabled. 222 | /// 223 | /// The function receives the list of options and returns a `Vec` 224 | /// where `true` means the option at that index is disabled. 225 | pub fn disabled( 226 | mut self, 227 | disabled: impl Fn(&[T]) -> Vec + 'a, 228 | ) -> Self { 229 | self.disabled = Some(Box::new(disabled)); 230 | self 231 | } 232 | 233 | /// Sets the width of the [`PickList`]. 234 | pub fn width(mut self, width: impl Into) -> Self { 235 | self.width = width.into(); 236 | self 237 | } 238 | 239 | /// Sets the height of the [`Menu`]. 240 | pub fn menu_height(mut self, menu_height: impl Into) -> Self { 241 | self.menu_height = menu_height.into(); 242 | self 243 | } 244 | 245 | /// Sets the [`Padding`] of the [`PickList`]. 246 | pub fn padding>(mut self, padding: P) -> Self { 247 | self.padding = padding.into(); 248 | self 249 | } 250 | 251 | /// Sets the text size of the [`PickList`]. 252 | pub fn text_size(mut self, size: impl Into) -> Self { 253 | self.text_size = Some(size.into()); 254 | self 255 | } 256 | 257 | /// Sets the text [`text::LineHeight`] of the [`PickList`]. 258 | pub fn text_line_height( 259 | mut self, 260 | line_height: impl Into, 261 | ) -> Self { 262 | self.text_line_height = line_height.into(); 263 | self 264 | } 265 | 266 | /// Sets the [`text::Shaping`] strategy of the [`PickList`]. 267 | pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { 268 | self.text_shaping = shaping; 269 | self 270 | } 271 | 272 | /// Sets the font of the [`PickList`]. 273 | pub fn font(mut self, font: impl Into) -> Self { 274 | self.font = Some(font.into()); 275 | self 276 | } 277 | 278 | /// Sets the [`Handle`] of the [`PickList`]. 279 | pub fn handle(mut self, handle: Handle) -> Self { 280 | self.handle = handle; 281 | self 282 | } 283 | 284 | /// Sets the message that will be produced when the [`PickList`] is opened. 285 | pub fn on_open(mut self, on_open: Message) -> Self { 286 | self.on_open = Some(on_open); 287 | self 288 | } 289 | 290 | /// Sets the message that will be produced when the [`PickList`] is closed. 291 | pub fn on_close(mut self, on_close: Message) -> Self { 292 | self.on_close = Some(on_close); 293 | self 294 | } 295 | 296 | /// Sets the style of the [`PickList`]. 297 | #[must_use] 298 | pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self 299 | where 300 | ::Class<'a>: From>, 301 | { 302 | self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); 303 | self 304 | } 305 | 306 | /// Sets the style of the [`Menu`]. 307 | #[must_use] 308 | pub fn menu_style( 309 | mut self, 310 | style: impl Fn(&Theme) -> menu::Style + 'a, 311 | ) -> Self 312 | where 313 | ::Class<'a>: From>, 314 | { 315 | self.menu_class = (Box::new(style) as menu::StyleFn<'a, Theme>).into(); 316 | self 317 | } 318 | 319 | /// Sets the style class of the [`PickList`]. 320 | #[must_use] 321 | pub fn class( 322 | mut self, 323 | class: impl Into<::Class<'a>>, 324 | ) -> Self { 325 | self.class = class.into(); 326 | self 327 | } 328 | 329 | /// Sets the style class of the [`Menu`]. 330 | #[must_use] 331 | pub fn menu_class( 332 | mut self, 333 | class: impl Into<::Class<'a>>, 334 | ) -> Self { 335 | self.menu_class = class.into(); 336 | self 337 | } 338 | } 339 | 340 | impl<'a, T, L, V, Message, Theme, Renderer> Widget 341 | for PickList<'a, T, L, V, Message, Theme, Renderer> 342 | where 343 | T: Clone + ToString + PartialEq + 'a, 344 | L: Borrow<[T]>, 345 | V: Borrow, 346 | Message: Clone + 'a, 347 | Theme: Catalog + 'a, 348 | Renderer: text::Renderer + 'a, 349 | { 350 | fn tag(&self) -> tree::Tag { 351 | tree::Tag::of::>() 352 | } 353 | 354 | fn state(&self) -> tree::State { 355 | tree::State::new(State::::new()) 356 | } 357 | 358 | fn size(&self) -> Size { 359 | Size { 360 | width: self.width, 361 | height: Length::Shrink, 362 | } 363 | } 364 | 365 | fn layout( 366 | &mut self, 367 | tree: &mut Tree, 368 | renderer: &Renderer, 369 | limits: &layout::Limits, 370 | ) -> layout::Node { 371 | let state = tree.state.downcast_mut::>(); 372 | 373 | let font = self.font.unwrap_or_else(|| renderer.default_font()); 374 | let text_size = 375 | self.text_size.unwrap_or_else(|| renderer.default_size()); 376 | let options = self.options.borrow(); 377 | 378 | state.options.resize_with(options.len(), Default::default); 379 | 380 | let option_text = Text { 381 | content: "", 382 | bounds: Size::new( 383 | f32::INFINITY, 384 | self.text_line_height.to_absolute(text_size).into(), 385 | ), 386 | size: text_size, 387 | line_height: self.text_line_height, 388 | font, 389 | align_x: text::Alignment::Default, 390 | align_y: alignment::Vertical::Center, 391 | shaping: self.text_shaping, 392 | wrapping: text::Wrapping::default(), 393 | }; 394 | 395 | for (option, paragraph) in options.iter().zip(state.options.iter_mut()) 396 | { 397 | let label = option.to_string(); 398 | 399 | let _ = paragraph.update(Text { 400 | content: &label, 401 | ..option_text 402 | }); 403 | } 404 | 405 | if let Some(placeholder) = &self.placeholder { 406 | let _ = state.placeholder.update(Text { 407 | content: placeholder, 408 | ..option_text 409 | }); 410 | } 411 | 412 | let max_width = match self.width { 413 | Length::Shrink => { 414 | let labels_width = 415 | state.options.iter().fold(0.0, |width, paragraph| { 416 | f32::max(width, paragraph.min_width()) 417 | }); 418 | 419 | labels_width.max( 420 | self.placeholder 421 | .as_ref() 422 | .map(|_| state.placeholder.min_width()) 423 | .unwrap_or(0.0), 424 | ) 425 | } 426 | _ => 0.0, 427 | }; 428 | 429 | let size = { 430 | let intrinsic = Size::new( 431 | max_width + text_size.0 + self.padding.left, 432 | f32::from(self.text_line_height.to_absolute(text_size)), 433 | ); 434 | 435 | limits 436 | .width(self.width) 437 | .shrink(self.padding) 438 | .resolve(self.width, Length::Shrink, intrinsic) 439 | .expand(self.padding) 440 | }; 441 | 442 | layout::Node::new(size) 443 | } 444 | 445 | fn update( 446 | &mut self, 447 | tree: &mut Tree, 448 | event: &Event, 449 | layout: Layout<'_>, 450 | cursor: mouse::Cursor, 451 | _renderer: &Renderer, 452 | _clipboard: &mut dyn Clipboard, 453 | shell: &mut Shell<'_, Message>, 454 | _viewport: &Rectangle, 455 | ) { 456 | let state = tree.state.downcast_mut::>(); 457 | 458 | match event { 459 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) 460 | | Event::Touch(touch::Event::FingerPressed { .. }) => { 461 | if state.is_open { 462 | // Event wasn't processed by overlay, so cursor was clicked either outside its 463 | // bounds or on the drop-down, either way we close the overlay. 464 | state.is_open = false; 465 | 466 | if let Some(on_close) = &self.on_close { 467 | shell.publish(on_close.clone()); 468 | } 469 | 470 | shell.capture_event(); 471 | } else if cursor.is_over(layout.bounds()) { 472 | let selected = self.selected.as_ref().map(Borrow::borrow); 473 | 474 | state.is_open = true; 475 | state.hovered_option = self 476 | .options 477 | .borrow() 478 | .iter() 479 | .position(|option| Some(option) == selected); 480 | 481 | if let Some(on_open) = &self.on_open { 482 | shell.publish(on_open.clone()); 483 | } 484 | 485 | shell.capture_event(); 486 | } 487 | } 488 | Event::Mouse(mouse::Event::WheelScrolled { 489 | delta: mouse::ScrollDelta::Lines { y, .. }, 490 | }) => { 491 | if state.keyboard_modifiers.command() 492 | && cursor.is_over(layout.bounds()) 493 | && !state.is_open 494 | { 495 | fn find_next<'a, T: PartialEq>( 496 | selected: &'a T, 497 | mut options: impl Iterator, 498 | ) -> Option<&'a T> { 499 | let _ = options.find(|&option| option == selected); 500 | 501 | options.next() 502 | } 503 | 504 | let options = self.options.borrow(); 505 | let selected = self.selected.as_ref().map(Borrow::borrow); 506 | 507 | let next_option = if *y < 0.0 { 508 | if let Some(selected) = selected { 509 | find_next(selected, options.iter()) 510 | } else { 511 | options.first() 512 | } 513 | } else if *y > 0.0 { 514 | if let Some(selected) = selected { 515 | find_next(selected, options.iter().rev()) 516 | } else { 517 | options.last() 518 | } 519 | } else { 520 | None 521 | }; 522 | 523 | if let Some(next_option) = next_option { 524 | shell.publish((self.on_select)(next_option.clone())); 525 | } 526 | 527 | shell.capture_event(); 528 | } 529 | } 530 | Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { 531 | state.keyboard_modifiers = *modifiers; 532 | } 533 | _ => {} 534 | }; 535 | 536 | let status = { 537 | let is_hovered = cursor.is_over(layout.bounds()); 538 | 539 | if state.is_open { 540 | Status::Opened { is_hovered } 541 | } else if is_hovered { 542 | Status::Hovered 543 | } else { 544 | Status::Active 545 | } 546 | }; 547 | 548 | if let Event::Window(window::Event::RedrawRequested(_now)) = event { 549 | self.last_status = Some(status); 550 | } else if self 551 | .last_status 552 | .is_some_and(|last_status| last_status != status) 553 | { 554 | shell.request_redraw(); 555 | } 556 | } 557 | 558 | fn mouse_interaction( 559 | &self, 560 | _tree: &Tree, 561 | layout: Layout<'_>, 562 | cursor: mouse::Cursor, 563 | _viewport: &Rectangle, 564 | _renderer: &Renderer, 565 | ) -> mouse::Interaction { 566 | let bounds = layout.bounds(); 567 | let is_mouse_over = cursor.is_over(bounds); 568 | 569 | if is_mouse_over { 570 | mouse::Interaction::Pointer 571 | } else { 572 | mouse::Interaction::default() 573 | } 574 | } 575 | 576 | fn draw( 577 | &self, 578 | tree: &Tree, 579 | renderer: &mut Renderer, 580 | theme: &Theme, 581 | _style: &renderer::Style, 582 | layout: Layout<'_>, 583 | _cursor: mouse::Cursor, 584 | viewport: &Rectangle, 585 | ) { 586 | let font = self.font.unwrap_or_else(|| renderer.default_font()); 587 | let selected = self.selected.as_ref().map(Borrow::borrow); 588 | let state = tree.state.downcast_ref::>(); 589 | 590 | let bounds = layout.bounds(); 591 | 592 | let style = Catalog::style( 593 | theme, 594 | &self.class, 595 | self.last_status.unwrap_or(Status::Active), 596 | ); 597 | 598 | renderer.fill_quad( 599 | renderer::Quad { 600 | bounds, 601 | border: style.border, 602 | ..renderer::Quad::default() 603 | }, 604 | style.background, 605 | ); 606 | 607 | let handle = match &self.handle { 608 | Handle::Arrow { size } => Some(( 609 | Renderer::ICON_FONT, 610 | Renderer::ARROW_DOWN_ICON, 611 | *size, 612 | text::LineHeight::default(), 613 | text::Shaping::Basic, 614 | )), 615 | Handle::Static(Icon { 616 | font, 617 | code_point, 618 | size, 619 | line_height, 620 | shaping, 621 | }) => Some((*font, *code_point, *size, *line_height, *shaping)), 622 | Handle::Dynamic { open, closed } => { 623 | if state.is_open { 624 | Some(( 625 | open.font, 626 | open.code_point, 627 | open.size, 628 | open.line_height, 629 | open.shaping, 630 | )) 631 | } else { 632 | Some(( 633 | closed.font, 634 | closed.code_point, 635 | closed.size, 636 | closed.line_height, 637 | closed.shaping, 638 | )) 639 | } 640 | } 641 | Handle::None => None, 642 | }; 643 | 644 | if let Some((font, code_point, size, line_height, shaping)) = handle { 645 | let size = size.unwrap_or_else(|| renderer.default_size()); 646 | 647 | renderer.fill_text( 648 | Text { 649 | content: code_point.to_string(), 650 | size, 651 | line_height, 652 | font, 653 | bounds: Size::new( 654 | bounds.width, 655 | f32::from(line_height.to_absolute(size)), 656 | ), 657 | align_x: text::Alignment::Right, 658 | align_y: alignment::Vertical::Center, 659 | shaping, 660 | wrapping: text::Wrapping::default(), 661 | }, 662 | Point::new( 663 | bounds.x + bounds.width - self.padding.right, 664 | bounds.center_y(), 665 | ), 666 | style.handle_color, 667 | *viewport, 668 | ); 669 | } 670 | 671 | let label = selected.map(ToString::to_string); 672 | 673 | if let Some(label) = label.or_else(|| self.placeholder.clone()) { 674 | let text_size = 675 | self.text_size.unwrap_or_else(|| renderer.default_size()); 676 | 677 | renderer.fill_text( 678 | Text { 679 | content: label, 680 | size: text_size, 681 | line_height: self.text_line_height, 682 | font, 683 | bounds: Size::new( 684 | bounds.width - self.padding.x(), 685 | f32::from(self.text_line_height.to_absolute(text_size)), 686 | ), 687 | align_x: text::Alignment::Default, 688 | align_y: alignment::Vertical::Center, 689 | shaping: self.text_shaping, 690 | wrapping: text::Wrapping::default(), 691 | }, 692 | Point::new(bounds.x + self.padding.left, bounds.center_y()), 693 | if selected.is_some() { 694 | style.text_color 695 | } else { 696 | style.placeholder_color 697 | }, 698 | *viewport, 699 | ); 700 | } 701 | } 702 | 703 | fn overlay<'b>( 704 | &'b mut self, 705 | tree: &'b mut Tree, 706 | layout: Layout<'_>, 707 | renderer: &Renderer, 708 | viewport: &Rectangle, 709 | translation: Vector, 710 | ) -> Option> { 711 | let state = tree.state.downcast_mut::>(); 712 | let font = self.font.unwrap_or_else(|| renderer.default_font()); 713 | 714 | if state.is_open { 715 | let bounds = layout.bounds(); 716 | 717 | let on_select = &self.on_select; 718 | 719 | let disabled = 720 | self.disabled.as_ref().map(|f| f(self.options.borrow())); 721 | 722 | let mut menu = Menu::new( 723 | &mut state.menu, 724 | self.options.borrow(), 725 | &mut state.hovered_option, 726 | |option| { 727 | state.is_open = false; 728 | 729 | (on_select)(option) 730 | }, 731 | disabled, 732 | None, 733 | &self.menu_class, 734 | ) 735 | .width(bounds.width) 736 | .padding(self.padding) 737 | .font(font) 738 | .text_shaping(self.text_shaping); 739 | 740 | if let Some(text_size) = self.text_size { 741 | menu = menu.text_size(text_size); 742 | } 743 | 744 | Some(menu.overlay( 745 | layout.position() + translation, 746 | *viewport, 747 | bounds.height, 748 | self.menu_height, 749 | )) 750 | } else { 751 | None 752 | } 753 | } 754 | } 755 | 756 | impl<'a, T, L, V, Message, Theme, Renderer> 757 | From> 758 | for Element<'a, Message, Theme, Renderer> 759 | where 760 | T: Clone + ToString + PartialEq + 'a, 761 | L: Borrow<[T]> + 'a, 762 | V: Borrow + 'a, 763 | Message: Clone + 'a, 764 | Theme: Catalog + 'a, 765 | Renderer: text::Renderer + 'a, 766 | { 767 | fn from( 768 | pick_list: PickList<'a, T, L, V, Message, Theme, Renderer>, 769 | ) -> Self { 770 | Self::new(pick_list) 771 | } 772 | } 773 | 774 | #[derive(Debug)] 775 | struct State { 776 | menu: menu::State, 777 | keyboard_modifiers: keyboard::Modifiers, 778 | is_open: bool, 779 | hovered_option: Option, 780 | options: Vec>, 781 | placeholder: paragraph::Plain

, 782 | } 783 | 784 | impl State

{ 785 | /// Creates a new [`State`] for a [`PickList`]. 786 | fn new() -> Self { 787 | Self { 788 | menu: menu::State::default(), 789 | keyboard_modifiers: keyboard::Modifiers::default(), 790 | is_open: bool::default(), 791 | hovered_option: Option::default(), 792 | options: Vec::new(), 793 | placeholder: paragraph::Plain::default(), 794 | } 795 | } 796 | } 797 | 798 | impl Default for State

{ 799 | fn default() -> Self { 800 | Self::new() 801 | } 802 | } 803 | 804 | /// The handle to the right side of the [`PickList`]. 805 | #[derive(Debug, Clone, PartialEq)] 806 | pub enum Handle { 807 | /// Displays an arrow icon (▼). 808 | /// 809 | /// This is the default. 810 | Arrow { 811 | /// Font size of the content. 812 | size: Option, 813 | }, 814 | /// A custom static handle. 815 | Static(Icon), 816 | /// A custom dynamic handle. 817 | Dynamic { 818 | /// The [`Icon`] used when [`PickList`] is closed. 819 | closed: Icon, 820 | /// The [`Icon`] used when [`PickList`] is open. 821 | open: Icon, 822 | }, 823 | /// No handle will be shown. 824 | None, 825 | } 826 | 827 | impl Default for Handle { 828 | fn default() -> Self { 829 | Self::Arrow { size: None } 830 | } 831 | } 832 | 833 | /// The icon of a [`Handle`]. 834 | #[derive(Debug, Clone, PartialEq)] 835 | pub struct Icon { 836 | /// Font that will be used to display the `code_point`, 837 | pub font: Font, 838 | /// The unicode code point that will be used as the icon. 839 | pub code_point: char, 840 | /// Font size of the content. 841 | pub size: Option, 842 | /// Line height of the content. 843 | pub line_height: text::LineHeight, 844 | /// The shaping strategy of the icon. 845 | pub shaping: text::Shaping, 846 | } 847 | 848 | /// The possible status of a [`PickList`]. 849 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 850 | pub enum Status { 851 | /// The [`PickList`] can be interacted with. 852 | Active, 853 | /// The [`PickList`] is being hovered. 854 | Hovered, 855 | /// The [`PickList`] is open. 856 | Opened { 857 | /// Whether the [`PickList`] is hovered, while open. 858 | is_hovered: bool, 859 | }, 860 | } 861 | 862 | /// The appearance of a pick list. 863 | #[derive(Debug, Clone, Copy, PartialEq)] 864 | pub struct Style { 865 | /// The text [`Color`] of the pick list. 866 | pub text_color: Color, 867 | /// The placeholder [`Color`] of the pick list. 868 | pub placeholder_color: Color, 869 | /// The handle [`Color`] of the pick list. 870 | pub handle_color: Color, 871 | /// The [`Background`] of the pick list. 872 | pub background: Background, 873 | /// The [`Border`] of the pick list. 874 | pub border: Border, 875 | } 876 | 877 | /// The theme catalog of a [`PickList`]. 878 | pub trait Catalog: menu::Catalog { 879 | /// The item class of the [`Catalog`]. 880 | type Class<'a>; 881 | 882 | /// The default class produced by the [`Catalog`]. 883 | fn default<'a>() -> ::Class<'a>; 884 | 885 | /// The default class for the menu of the [`PickList`]. 886 | fn default_menu<'a>() -> ::Class<'a> { 887 | ::default() 888 | } 889 | 890 | /// The [`Style`] of a class with the given status. 891 | fn style( 892 | &self, 893 | class: &::Class<'_>, 894 | status: Status, 895 | ) -> Style; 896 | } 897 | 898 | /// A styling function for a [`PickList`]. 899 | /// 900 | /// This is just a boxed closure: `Fn(&Theme, Status) -> Style`. 901 | pub type StyleFn<'a, Theme> = Box Style + 'a>; 902 | 903 | impl Catalog for Theme { 904 | type Class<'a> = StyleFn<'a, Self>; 905 | 906 | fn default<'a>() -> StyleFn<'a, Self> { 907 | Box::new(default) 908 | } 909 | 910 | fn style(&self, class: &StyleFn<'_, Self>, status: Status) -> Style { 911 | class(self, status) 912 | } 913 | } 914 | 915 | /// The default style of the field of a [`PickList`]. 916 | pub fn default(theme: &Theme, status: Status) -> Style { 917 | let palette = theme.extended_palette(); 918 | 919 | let active = Style { 920 | text_color: palette.background.weak.text, 921 | background: palette.background.weak.color.into(), 922 | placeholder_color: palette.secondary.base.color, 923 | handle_color: palette.background.weak.text, 924 | border: Border { 925 | radius: 2.0.into(), 926 | width: 1.0, 927 | color: palette.background.strong.color, 928 | }, 929 | }; 930 | 931 | match status { 932 | Status::Active => active, 933 | Status::Hovered | Status::Opened { .. } => Style { 934 | border: Border { 935 | color: palette.primary.strong.color, 936 | ..active.border 937 | }, 938 | ..active 939 | }, 940 | } 941 | } 942 | -------------------------------------------------------------------------------- /src/widget/row.rs: -------------------------------------------------------------------------------- 1 | //! Distribute content horizontally. 2 | //! 3 | //! This is a sweetened version of `iced`'s [`Row`] with drag-and-drop 4 | //! reordering support via [`Row::on_drag`]. 5 | //! 6 | //! [`Row`]: https://docs.iced.rs/iced/widget/struct.Row.html 7 | //! 8 | //! # Example 9 | //! 10 | //! ```no_run 11 | //! # pub type Element<'a, Message> = iced::Element<'a, Message>; 12 | //! use sweeten::widget::row; 13 | //! use sweeten::widget::drag::DragEvent; 14 | //! 15 | //! #[derive(Clone)] 16 | //! enum Message { 17 | //! Reorder(DragEvent), 18 | //! } 19 | //! 20 | //! fn view(items: &[String]) -> Element<'_, Message> { 21 | //! row(items.iter().map(|s| s.as_str().into())) 22 | //! .spacing(5) 23 | //! .on_drag(Message::Reorder) 24 | //! .into() 25 | //! } 26 | //! ``` 27 | 28 | use crate::core::alignment::{self, Alignment}; 29 | use crate::core::layout::{self, Layout}; 30 | use crate::core::mouse; 31 | use crate::core::overlay; 32 | use crate::core::renderer; 33 | use crate::core::time::Instant; 34 | use crate::core::widget::{Operation, Tree, tree}; 35 | use crate::core::{ 36 | Animation, Background, Border, Clipboard, Color, Element, Event, Length, 37 | Padding, Pixels, Point, Rectangle, Shell, Size, Transformation, Vector, 38 | Widget, 39 | }; 40 | 41 | use super::drag::DragEvent; 42 | 43 | const DRAG_DEADBAND_DISTANCE: f32 = 5.0; 44 | 45 | /// A container that distributes its contents horizontally. 46 | /// 47 | /// # Example 48 | /// ```no_run 49 | /// # mod iced { pub mod widget { pub use iced_widget::*; } } 50 | /// # pub type State = (); 51 | /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; 52 | /// use iced::widget::{button, row}; 53 | /// 54 | /// #[derive(Debug, Clone)] 55 | /// enum Message { 56 | /// // ... 57 | /// } 58 | /// 59 | /// fn view(state: &State) -> Element<'_, Message> { 60 | /// row![ 61 | /// "I am to the left!", 62 | /// button("I am in the middle!"), 63 | /// "I am to the right!", 64 | /// ].into() 65 | /// } 66 | /// ``` 67 | #[allow(missing_debug_implementations)] 68 | pub struct Row<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> 69 | where 70 | Theme: Catalog, 71 | { 72 | spacing: f32, 73 | padding: Padding, 74 | width: Length, 75 | height: Length, 76 | align: Alignment, 77 | clip: bool, 78 | deadband_zone: f32, 79 | children: Vec>, 80 | on_drag: Option Message + 'a>>, 81 | class: Theme::Class<'a>, 82 | } 83 | 84 | impl<'a, Message, Theme, Renderer> Row<'a, Message, Theme, Renderer> 85 | where 86 | Renderer: crate::core::Renderer, 87 | Theme: Catalog, 88 | { 89 | /// Creates an empty [`Row`]. 90 | pub fn new() -> Self { 91 | Self::from_vec(Vec::new()) 92 | } 93 | 94 | /// Creates a [`Row`] with the given capacity. 95 | pub fn with_capacity(capacity: usize) -> Self { 96 | Self::from_vec(Vec::with_capacity(capacity)) 97 | } 98 | 99 | /// Creates a [`Row`] with the given elements. 100 | pub fn with_children( 101 | children: impl IntoIterator>, 102 | ) -> Self { 103 | let iterator = children.into_iter(); 104 | 105 | Self::with_capacity(iterator.size_hint().0).extend(iterator) 106 | } 107 | 108 | /// Creates a [`Row`] from an already allocated [`Vec`]. 109 | /// 110 | /// Keep in mind that the [`Row`] will not inspect the [`Vec`], which means 111 | /// it won't automatically adapt to the sizing strategy of its contents. 112 | /// 113 | /// If any of the children have a [`Length::Fill`] strategy, you will need to 114 | /// call [`Row::width`] or [`Row::height`] accordingly. 115 | pub fn from_vec( 116 | children: Vec>, 117 | ) -> Self { 118 | Self { 119 | spacing: 0.0, 120 | padding: Padding::ZERO, 121 | width: Length::Shrink, 122 | height: Length::Shrink, 123 | align: Alignment::Start, 124 | clip: false, 125 | deadband_zone: DRAG_DEADBAND_DISTANCE, 126 | children, 127 | class: Theme::default(), 128 | on_drag: None, 129 | } 130 | } 131 | 132 | /// Sets the horizontal spacing _between_ elements. 133 | /// 134 | /// Custom margins per element do not exist in iced. You should use this 135 | /// method instead! While less flexible, it helps you keep spacing between 136 | /// elements consistent. 137 | pub fn spacing(mut self, amount: impl Into) -> Self { 138 | self.spacing = amount.into().0; 139 | self 140 | } 141 | 142 | /// Sets the [`Padding`] of the [`Row`]. 143 | pub fn padding>(mut self, padding: P) -> Self { 144 | self.padding = padding.into(); 145 | self 146 | } 147 | 148 | /// Sets the width of the [`Row`]. 149 | pub fn width(mut self, width: impl Into) -> Self { 150 | self.width = width.into(); 151 | self 152 | } 153 | 154 | /// Sets the height of the [`Row`]. 155 | pub fn height(mut self, height: impl Into) -> Self { 156 | self.height = height.into(); 157 | self 158 | } 159 | 160 | /// Sets the vertical alignment of the contents of the [`Row`] . 161 | pub fn align_y(mut self, align: impl Into) -> Self { 162 | self.align = Alignment::from(align.into()); 163 | self 164 | } 165 | 166 | /// Sets whether the contents of the [`Row`] should be clipped on 167 | /// overflow. 168 | pub fn clip(mut self, clip: bool) -> Self { 169 | self.clip = clip; 170 | self 171 | } 172 | 173 | /// Sets the drag deadband zone of the [`Row`]. 174 | /// 175 | /// This is the minimum distance in pixels that the cursor must move 176 | /// before a drag operation begins. Default is 5.0 pixels. 177 | pub fn deadband_zone(mut self, deadband_zone: f32) -> Self { 178 | self.deadband_zone = deadband_zone; 179 | self 180 | } 181 | 182 | /// Adds an [`Element`] to the [`Row`]. 183 | pub fn push( 184 | mut self, 185 | child: impl Into>, 186 | ) -> Self { 187 | let child = child.into(); 188 | let child_size = child.as_widget().size_hint(); 189 | 190 | if !child_size.is_void() { 191 | self.width = self.width.enclose(child_size.width); 192 | self.height = self.height.enclose(child_size.height); 193 | self.children.push(child); 194 | } 195 | 196 | self 197 | } 198 | 199 | /// Adds an element to the [`Row`], if `Some`. 200 | pub fn push_maybe( 201 | self, 202 | child: Option>>, 203 | ) -> Self { 204 | if let Some(child) = child { 205 | self.push(child) 206 | } else { 207 | self 208 | } 209 | } 210 | 211 | /// Sets the style of the [`Row`]. 212 | #[must_use] 213 | pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self 214 | where 215 | Theme::Class<'a>: From>, 216 | { 217 | self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); 218 | self 219 | } 220 | 221 | /// Sets the style class of the [`Row`]. 222 | #[must_use] 223 | pub fn class(mut self, class: impl Into>) -> Self { 224 | self.class = class.into(); 225 | self 226 | } 227 | 228 | /// Extends the [`Row`] with the given children. 229 | pub fn extend( 230 | self, 231 | children: impl IntoIterator>, 232 | ) -> Self { 233 | children.into_iter().fold(self, Self::push) 234 | } 235 | 236 | /// Sets a handler for drag events. 237 | /// 238 | /// When set, items in the [`Row`] can be dragged and reordered. 239 | /// The handler receives a [`DragEvent`] describing what happened. 240 | pub fn on_drag( 241 | mut self, 242 | on_drag: impl Fn(DragEvent) -> Message + 'a, 243 | ) -> Self { 244 | self.on_drag = Some(Box::new(on_drag)); 245 | self 246 | } 247 | 248 | /// Computes the index where a dragged item should be dropped. 249 | fn compute_target_index( 250 | &self, 251 | cursor_position: Point, 252 | layout: Layout<'_>, 253 | ) -> usize { 254 | let bounds = layout.bounds(); 255 | let cursor_x = cursor_position.x; 256 | 257 | if cursor_x < bounds.x { 258 | return 0; 259 | } 260 | 261 | for (i, child_layout) in layout.children().enumerate() { 262 | let bounds = child_layout.bounds(); 263 | let x = bounds.x; 264 | let width = bounds.width; 265 | 266 | if cursor_x <= x + width { 267 | return i; 268 | } 269 | } 270 | 271 | self.children.len().saturating_sub(1) 272 | } 273 | } 274 | 275 | impl Default for Row<'_, Message, Theme, Renderer> 276 | where 277 | Renderer: crate::core::Renderer, 278 | Theme: Catalog, 279 | { 280 | fn default() -> Self { 281 | Self::new() 282 | } 283 | } 284 | 285 | impl<'a, Message, Theme, Renderer: crate::core::Renderer> 286 | FromIterator> 287 | for Row<'a, Message, Theme, Renderer> 288 | where 289 | Theme: Catalog, 290 | { 291 | fn from_iter< 292 | T: IntoIterator>, 293 | >( 294 | iter: T, 295 | ) -> Self { 296 | Self::with_children(iter) 297 | } 298 | } 299 | 300 | // Internal state for drag animations 301 | #[derive(Debug, Clone)] 302 | enum Action { 303 | Idle { 304 | now: Option, 305 | animations: ItemAnimations, 306 | }, 307 | Picking { 308 | index: usize, 309 | origin: Point, 310 | now: Instant, 311 | animations: ItemAnimations, 312 | }, 313 | Dragging { 314 | index: usize, 315 | origin: Point, 316 | last_cursor: Point, 317 | now: Instant, 318 | animations: ItemAnimations, 319 | }, 320 | } 321 | 322 | impl Default for Action { 323 | fn default() -> Self { 324 | Self::Idle { 325 | now: None, 326 | animations: ItemAnimations::default(), 327 | } 328 | } 329 | } 330 | 331 | #[derive(Default, Debug, Clone)] 332 | struct ItemAnimations { 333 | offsets: Vec>, 334 | } 335 | 336 | impl ItemAnimations { 337 | fn zero(&mut self) { 338 | for animation in &mut self.offsets { 339 | *animation = Animation::new(0.0); 340 | } 341 | } 342 | 343 | fn is_animating(&self, now: Instant) -> bool { 344 | self.offsets.iter().any(|anim| anim.is_animating(now)) 345 | } 346 | 347 | fn with_capacity(&mut self, count: usize) { 348 | if self.offsets.len() < count { 349 | self.offsets.resize_with(count, || Animation::new(0.0)); 350 | } 351 | } 352 | } 353 | 354 | impl Widget 355 | for Row<'_, Message, Theme, Renderer> 356 | where 357 | Renderer: crate::core::Renderer, 358 | Theme: Catalog, 359 | { 360 | fn tag(&self) -> tree::Tag { 361 | tree::Tag::of::() 362 | } 363 | 364 | fn state(&self) -> tree::State { 365 | let mut animations = ItemAnimations::default(); 366 | animations.with_capacity(self.children.len()); 367 | 368 | tree::State::new(Action::Idle { 369 | now: Some(Instant::now()), 370 | animations, 371 | }) 372 | } 373 | 374 | fn children(&self) -> Vec { 375 | self.children.iter().map(Tree::new).collect() 376 | } 377 | 378 | fn diff(&self, tree: &mut Tree) { 379 | tree.diff_children(&self.children); 380 | 381 | let action = tree.state.downcast_mut::(); 382 | 383 | match action { 384 | Action::Idle { animations, .. } 385 | | Action::Picking { animations, .. } 386 | | Action::Dragging { animations, .. } => { 387 | animations.with_capacity(self.children.len()); 388 | } 389 | } 390 | } 391 | 392 | fn size(&self) -> Size { 393 | Size { 394 | width: self.width, 395 | height: self.height, 396 | } 397 | } 398 | 399 | fn layout( 400 | &mut self, 401 | tree: &mut Tree, 402 | renderer: &Renderer, 403 | limits: &layout::Limits, 404 | ) -> layout::Node { 405 | layout::flex::resolve( 406 | layout::flex::Axis::Horizontal, 407 | renderer, 408 | limits, 409 | self.width, 410 | self.height, 411 | self.padding, 412 | self.spacing, 413 | self.align, 414 | &mut self.children, 415 | &mut tree.children, 416 | ) 417 | } 418 | 419 | fn operate( 420 | &mut self, 421 | tree: &mut Tree, 422 | layout: Layout<'_>, 423 | renderer: &Renderer, 424 | operation: &mut dyn Operation, 425 | ) { 426 | operation.container(None, layout.bounds()); 427 | operation.traverse(&mut |operation| { 428 | self.children 429 | .iter_mut() 430 | .zip(&mut tree.children) 431 | .zip(layout.children()) 432 | .for_each(|((child, state), layout)| { 433 | child 434 | .as_widget_mut() 435 | .operate(state, layout, renderer, operation); 436 | }); 437 | }); 438 | } 439 | 440 | fn update( 441 | &mut self, 442 | tree: &mut Tree, 443 | event: &Event, 444 | layout: Layout<'_>, 445 | cursor: mouse::Cursor, 446 | renderer: &Renderer, 447 | clipboard: &mut dyn Clipboard, 448 | shell: &mut Shell<'_, Message>, 449 | viewport: &Rectangle, 450 | ) { 451 | let action = tree.state.downcast_mut::(); 452 | 453 | for ((child, state), layout) in self 454 | .children 455 | .iter_mut() 456 | .zip(&mut tree.children) 457 | .zip(layout.children()) 458 | { 459 | child.as_widget_mut().update( 460 | state, event, layout, cursor, renderer, clipboard, shell, 461 | viewport, 462 | ); 463 | } 464 | 465 | if shell.is_event_captured() { 466 | return; 467 | } 468 | 469 | match &event { 470 | Event::Window(crate::core::window::Event::RedrawRequested(now)) => { 471 | match action { 472 | Action::Idle { 473 | now: current_now, 474 | animations, 475 | } => { 476 | *current_now = Some(*now); 477 | 478 | if animations.is_animating(*now) { 479 | shell.request_redraw(); 480 | } 481 | } 482 | Action::Picking { 483 | now: current_now, .. 484 | } 485 | | Action::Dragging { 486 | now: current_now, .. 487 | } => { 488 | *current_now = *now; 489 | shell.request_redraw(); 490 | } 491 | } 492 | } 493 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { 494 | if self.on_drag.is_some() { 495 | if let Some(cursor_position) = 496 | cursor.position_over(layout.bounds()) 497 | { 498 | let animations = match action { 499 | Action::Idle { animations, .. } => animations, 500 | Action::Picking { animations, .. } => animations, 501 | Action::Dragging { animations, .. } => animations, 502 | }; 503 | animations.zero(); 504 | 505 | let index = 506 | self.compute_target_index(cursor_position, layout); 507 | 508 | *action = Action::Picking { 509 | index, 510 | origin: cursor_position, 511 | now: Instant::now(), 512 | animations: std::mem::take(animations), 513 | }; 514 | 515 | shell.capture_event(); 516 | shell.request_redraw(); 517 | } 518 | } 519 | } 520 | Event::Mouse(mouse::Event::CursorMoved { .. }) => match action { 521 | Action::Picking { 522 | index, 523 | origin, 524 | now, 525 | animations, 526 | } => { 527 | if let Some(cursor_position) = cursor.position() { 528 | if cursor_position.distance(*origin) 529 | > self.deadband_zone 530 | { 531 | let index = *index; 532 | let origin = *origin; 533 | let now = *now; 534 | 535 | *action = Action::Dragging { 536 | index, 537 | origin, 538 | last_cursor: cursor_position, 539 | now, 540 | animations: std::mem::take(animations), 541 | }; 542 | 543 | shell.request_redraw(); 544 | 545 | if let Some(on_drag) = &self.on_drag { 546 | shell.publish(on_drag(DragEvent::Picked { 547 | index, 548 | })); 549 | } 550 | 551 | shell.capture_event(); 552 | } 553 | } 554 | } 555 | Action::Dragging { 556 | origin, 557 | index, 558 | now, 559 | animations, 560 | .. 561 | } => { 562 | shell.request_redraw(); 563 | 564 | if let Some(cursor_position) = cursor.position() { 565 | animations.with_capacity(self.children.len()); 566 | 567 | let target_index = 568 | self.compute_target_index(cursor_position, layout); 569 | 570 | let drag_width = if let Some(child_layout) = 571 | layout.children().nth(*index) 572 | { 573 | child_layout.bounds().width + self.spacing 574 | } else { 575 | 0.0 576 | }; 577 | 578 | for i in 0..animations.offsets.len() { 579 | if i == *index { 580 | animations.offsets[i] 581 | .go_mut(1.0, Instant::now()); 582 | continue; 583 | } 584 | 585 | let target_offset = match target_index.cmp(index) { 586 | std::cmp::Ordering::Less 587 | if (target_index..*index).contains(&i) => 588 | { 589 | drag_width 590 | } 591 | std::cmp::Ordering::Greater 592 | if (*index + 1..=target_index) 593 | .contains(&i) => 594 | { 595 | -drag_width 596 | } 597 | _ => 0.0, 598 | }; 599 | 600 | animations.offsets[i] 601 | .go_mut(target_offset, Instant::now()); 602 | } 603 | 604 | let origin = *origin; 605 | let index = *index; 606 | let now = *now; 607 | 608 | *action = Action::Dragging { 609 | last_cursor: cursor_position, 610 | origin, 611 | index, 612 | now, 613 | animations: std::mem::take(animations), 614 | }; 615 | 616 | shell.capture_event(); 617 | } else { 618 | let index = *index; 619 | let now = *now; 620 | 621 | if let Some(on_drag) = &self.on_drag { 622 | shell.publish(on_drag(DragEvent::Canceled { 623 | index, 624 | })); 625 | } 626 | 627 | *action = Action::Idle { 628 | now: Some(now), 629 | animations: std::mem::take(animations), 630 | }; 631 | } 632 | } 633 | _ => {} 634 | }, 635 | Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { 636 | match action { 637 | Action::Dragging { 638 | index, 639 | animations, 640 | now, 641 | .. 642 | } => { 643 | let current_now = *now; 644 | 645 | animations.with_capacity(self.children.len()); 646 | 647 | if let Some(cursor_position) = cursor.position() { 648 | let target_index = self 649 | .compute_target_index(cursor_position, layout); 650 | 651 | let drag_width = if let Some(child_layout) = 652 | layout.children().nth(*index) 653 | { 654 | child_layout.bounds().width + self.spacing 655 | } else { 656 | 0.0 657 | }; 658 | 659 | for i in 0..animations.offsets.len() { 660 | let target_offset = 661 | match target_index.cmp(index) { 662 | std::cmp::Ordering::Less 663 | if (target_index..*index) 664 | .contains(&i) => 665 | { 666 | drag_width 667 | } 668 | std::cmp::Ordering::Greater 669 | if (*index + 1..=target_index) 670 | .contains(&i) => 671 | { 672 | -drag_width 673 | } 674 | _ => 0.0, 675 | }; 676 | 677 | if i == *index { 678 | animations.offsets[i] = 679 | Animation::new(target_offset); 680 | } else { 681 | animations.offsets[i] 682 | .go_mut(target_offset, Instant::now()); 683 | } 684 | } 685 | 686 | if let Some(on_drag) = &self.on_drag { 687 | shell.publish(on_drag(DragEvent::Dropped { 688 | index: *index, 689 | target_index, 690 | })); 691 | shell.capture_event(); 692 | } 693 | } else if let Some(on_drag) = &self.on_drag { 694 | shell.publish(on_drag(DragEvent::Canceled { 695 | index: *index, 696 | })); 697 | shell.capture_event(); 698 | } 699 | 700 | *action = Action::Idle { 701 | now: Some(current_now), 702 | animations: std::mem::take(animations), 703 | }; 704 | } 705 | Action::Picking { 706 | animations, now, .. 707 | } => { 708 | *action = Action::Idle { 709 | now: Some(*now), 710 | animations: std::mem::take(animations), 711 | }; 712 | } 713 | _ => {} 714 | } 715 | shell.request_redraw(); 716 | } 717 | _ => {} 718 | } 719 | } 720 | 721 | fn mouse_interaction( 722 | &self, 723 | tree: &Tree, 724 | layout: Layout<'_>, 725 | cursor: mouse::Cursor, 726 | viewport: &Rectangle, 727 | renderer: &Renderer, 728 | ) -> mouse::Interaction { 729 | let action = tree.state.downcast_ref::(); 730 | 731 | if let Action::Dragging { .. } = *action { 732 | return mouse::Interaction::Grabbing; 733 | } 734 | 735 | self.children 736 | .iter() 737 | .zip(&tree.children) 738 | .zip(layout.children()) 739 | .map(|((child, state), layout)| { 740 | child.as_widget().mouse_interaction( 741 | state, layout, cursor, viewport, renderer, 742 | ) 743 | }) 744 | .max() 745 | .unwrap_or_default() 746 | } 747 | 748 | fn draw( 749 | &self, 750 | tree: &Tree, 751 | renderer: &mut Renderer, 752 | theme: &Theme, 753 | defaults: &renderer::Style, 754 | layout: Layout<'_>, 755 | cursor: mouse::Cursor, 756 | viewport: &Rectangle, 757 | ) { 758 | let action = tree.state.downcast_ref::(); 759 | let style = theme.style(&self.class); 760 | 761 | match action { 762 | Action::Dragging { 763 | index, 764 | last_cursor, 765 | origin, 766 | now, 767 | animations, 768 | .. 769 | } => { 770 | let child_count = self.children.len(); 771 | 772 | let target_index = if cursor.position().is_some() { 773 | let target_index = 774 | self.compute_target_index(*last_cursor, layout); 775 | target_index.min(child_count - 1) 776 | } else { 777 | *index 778 | }; 779 | 780 | let drag_bounds = 781 | layout.children().nth(*index).unwrap().bounds(); 782 | let drag_width = drag_bounds.width + self.spacing; 783 | 784 | for i in 0..child_count { 785 | let child = &self.children[i]; 786 | let state = &tree.children[i]; 787 | let child_layout = layout.children().nth(i).unwrap(); 788 | 789 | if i == *index { 790 | let scale_factor = 1.0 791 | + (style.scale - 1.0) 792 | * animations.offsets[i] 793 | .interpolate_with(|v| v, *now); 794 | 795 | let scaling = Transformation::scale(scale_factor); 796 | let translation = *last_cursor - *origin * scaling; 797 | 798 | renderer.with_translation(translation, |renderer| { 799 | renderer.with_transformation(scaling, |renderer| { 800 | renderer.with_layer( 801 | child_layout.bounds(), 802 | |renderer| { 803 | child.as_widget().draw( 804 | state, 805 | renderer, 806 | theme, 807 | defaults, 808 | child_layout, 809 | cursor, 810 | viewport, 811 | ); 812 | }, 813 | ); 814 | }); 815 | }); 816 | } else { 817 | let base_offset = if i < animations.offsets.len() { 818 | animations.offsets[i].interpolate_with(|v| v, *now) 819 | } else { 820 | 0.0 821 | }; 822 | 823 | let offset = if base_offset == 0.0 { 824 | match target_index.cmp(index) { 825 | std::cmp::Ordering::Less 826 | if i >= target_index && i < *index => 827 | { 828 | drag_width 829 | } 830 | std::cmp::Ordering::Greater 831 | if i > *index && i <= target_index => 832 | { 833 | -drag_width 834 | } 835 | _ => 0.0, 836 | } 837 | } else { 838 | base_offset 839 | }; 840 | 841 | let translation = Vector::new(offset, 0.0); 842 | 843 | renderer.with_translation(translation, |renderer| { 844 | child.as_widget().draw( 845 | state, 846 | renderer, 847 | theme, 848 | defaults, 849 | child_layout, 850 | cursor, 851 | viewport, 852 | ); 853 | 854 | if offset != 0.0 { 855 | let progress = (offset / drag_width).abs(); 856 | 857 | renderer.fill_quad( 858 | renderer::Quad { 859 | bounds: child_layout.bounds(), 860 | ..renderer::Quad::default() 861 | }, 862 | style 863 | .moved_item_overlay 864 | .scale_alpha(progress), 865 | ); 866 | } 867 | }); 868 | } 869 | } 870 | 871 | let target_index = 872 | self.compute_target_index(*last_cursor, layout); 873 | let is_moving_left = target_index < *index; 874 | 875 | let ghost_translation = layout 876 | .children() 877 | .enumerate() 878 | .filter(|(i, _)| *i != *index) 879 | .fold(0.0, |acc, (i, child_layout)| { 880 | if i < animations.offsets.len() { 881 | let offset = animations.offsets[i] 882 | .interpolate_with(|v| v, *now); 883 | 884 | if offset != 0.0 { 885 | let width = 886 | child_layout.bounds().width + self.spacing; 887 | 888 | if is_moving_left 889 | && i >= target_index 890 | && i < *index 891 | { 892 | return acc - width; 893 | } else if !is_moving_left 894 | && i > *index 895 | && i <= target_index 896 | { 897 | return acc + width; 898 | } 899 | } 900 | } 901 | acc 902 | }); 903 | 904 | let ghost_vector = Vector::new(ghost_translation, 0.0); 905 | 906 | renderer.with_translation(ghost_vector, |renderer| { 907 | renderer.fill_quad( 908 | renderer::Quad { 909 | bounds: drag_bounds, 910 | border: style.ghost_border, 911 | ..renderer::Quad::default() 912 | }, 913 | style.ghost_background, 914 | ); 915 | }); 916 | } 917 | Action::Idle { 918 | now: Some(now), 919 | animations, 920 | } => { 921 | for (i, child) in self.children.iter().enumerate() { 922 | let state = &tree.children[i]; 923 | let child_layout = layout.children().nth(i).unwrap(); 924 | 925 | let offset = if i < animations.offsets.len() { 926 | let is_animating = 927 | animations.offsets[i].is_animating(*now); 928 | 929 | if is_animating { 930 | animations.offsets[i].interpolate_with(|v| v, *now) 931 | } else { 932 | 0.0 933 | } 934 | } else { 935 | 0.0 936 | }; 937 | 938 | let translation = Vector::new(offset, 0.0); 939 | 940 | renderer.with_translation(translation, |renderer| { 941 | child.as_widget().draw( 942 | state, 943 | renderer, 944 | theme, 945 | defaults, 946 | child_layout, 947 | cursor, 948 | viewport, 949 | ); 950 | 951 | if offset != 0.0 { 952 | let width = 953 | child_layout.bounds().width + self.spacing; 954 | let progress = (offset / width).abs(); 955 | 956 | renderer.fill_quad( 957 | renderer::Quad { 958 | bounds: child_layout.bounds(), 959 | ..renderer::Quad::default() 960 | }, 961 | style.moved_item_overlay.scale_alpha(progress), 962 | ); 963 | } 964 | }); 965 | } 966 | } 967 | _ => { 968 | for ((child, state), layout) in self 969 | .children 970 | .iter() 971 | .zip(&tree.children) 972 | .zip(layout.children()) 973 | { 974 | child.as_widget().draw( 975 | state, renderer, theme, defaults, layout, cursor, 976 | viewport, 977 | ); 978 | } 979 | } 980 | } 981 | } 982 | 983 | fn overlay<'b>( 984 | &'b mut self, 985 | tree: &'b mut Tree, 986 | layout: Layout<'b>, 987 | renderer: &Renderer, 988 | viewport: &Rectangle, 989 | translation: Vector, 990 | ) -> Option> { 991 | overlay::from_children( 992 | &mut self.children, 993 | tree, 994 | layout, 995 | renderer, 996 | viewport, 997 | translation, 998 | ) 999 | } 1000 | } 1001 | 1002 | impl<'a, Message, Theme, Renderer> From> 1003 | for Element<'a, Message, Theme, Renderer> 1004 | where 1005 | Message: 'a, 1006 | Theme: Catalog + 'a, 1007 | Renderer: crate::core::Renderer + 'a, 1008 | { 1009 | fn from(row: Row<'a, Message, Theme, Renderer>) -> Self { 1010 | Self::new(row) 1011 | } 1012 | } 1013 | 1014 | /// The theme catalog of a [`Row`]. 1015 | pub trait Catalog { 1016 | /// The item class of the [`Catalog`]. 1017 | type Class<'a>; 1018 | 1019 | /// The default class produced by the [`Catalog`]. 1020 | fn default<'a>() -> Self::Class<'a>; 1021 | 1022 | /// The [`Style`] of a class with the given status. 1023 | fn style(&self, class: &Self::Class<'_>) -> Style; 1024 | } 1025 | 1026 | /// The appearance of a [`Row`] during drag operations. 1027 | #[derive(Debug, Clone, Copy)] 1028 | pub struct Style { 1029 | /// The scaling to apply to a picked element while it's being dragged. 1030 | pub scale: f32, 1031 | /// The color of the overlay on items that are moved around. 1032 | pub moved_item_overlay: Color, 1033 | /// The border of the dragged item's ghost. 1034 | pub ghost_border: Border, 1035 | /// The background of the dragged item's ghost. 1036 | pub ghost_background: Background, 1037 | } 1038 | 1039 | /// A styling function for a [`Row`]. 1040 | pub type StyleFn<'a, Theme> = Box Style + 'a>; 1041 | 1042 | impl Catalog for crate::Theme { 1043 | type Class<'a> = StyleFn<'a, Self>; 1044 | 1045 | fn default<'a>() -> Self::Class<'a> { 1046 | Box::new(default) 1047 | } 1048 | 1049 | fn style(&self, class: &Self::Class<'_>) -> Style { 1050 | class(self) 1051 | } 1052 | } 1053 | 1054 | /// The default style for a [`Row`]. 1055 | pub fn default(theme: &crate::Theme) -> Style { 1056 | Style { 1057 | scale: 1.05, 1058 | moved_item_overlay: theme 1059 | .extended_palette() 1060 | .primary 1061 | .base 1062 | .color 1063 | .scale_alpha(0.2), 1064 | ghost_border: Border { 1065 | width: 1.0, 1066 | color: theme.extended_palette().secondary.base.color, 1067 | radius: 0.0.into(), 1068 | }, 1069 | ghost_background: theme 1070 | .extended_palette() 1071 | .secondary 1072 | .base 1073 | .color 1074 | .scale_alpha(0.2) 1075 | .into(), 1076 | } 1077 | } 1078 | -------------------------------------------------------------------------------- /src/widget/column.rs: -------------------------------------------------------------------------------- 1 | //! Distribute content vertically. 2 | //! 3 | //! This is a sweetened version of `iced`'s [`Column`] with drag-and-drop 4 | //! reordering support via [`Column::on_drag`]. 5 | //! 6 | //! [`Column`]: https://docs.iced.rs/iced/widget/struct.Column.html 7 | //! 8 | //! # Example 9 | //! 10 | //! ```no_run 11 | //! # pub type Element<'a, Message> = iced::Element<'a, Message>; 12 | //! use sweeten::widget::column; 13 | //! use sweeten::widget::drag::DragEvent; 14 | //! 15 | //! #[derive(Clone)] 16 | //! enum Message { 17 | //! Reorder(DragEvent), 18 | //! } 19 | //! 20 | //! fn view(items: &[String]) -> Element<'_, Message> { 21 | //! column(items.iter().map(|s| s.as_str().into())) 22 | //! .spacing(5) 23 | //! .on_drag(Message::Reorder) 24 | //! .into() 25 | //! } 26 | //! ``` 27 | 28 | use crate::core::alignment::{self, Alignment}; 29 | use crate::core::layout::{self, Layout}; 30 | use crate::core::mouse; 31 | use crate::core::overlay; 32 | use crate::core::renderer; 33 | use crate::core::time::Instant; 34 | use crate::core::widget::{Operation, Tree, tree}; 35 | use crate::core::{ 36 | Animation, Background, Border, Clipboard, Color, Element, Event, Length, 37 | Padding, Pixels, Point, Rectangle, Shell, Size, Transformation, Vector, 38 | Widget, 39 | }; 40 | 41 | use super::drag::DragEvent; 42 | 43 | const DRAG_DEADBAND_DISTANCE: f32 = 5.0; 44 | 45 | /// A container that distributes its contents vertically. 46 | /// 47 | /// # Example 48 | /// ```no_run 49 | /// # mod iced { pub mod widget { pub use iced_widget::*; } } 50 | /// # pub type State = (); 51 | /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; 52 | /// use iced::widget::{button, column}; 53 | /// 54 | /// #[derive(Debug, Clone)] 55 | /// enum Message { 56 | /// // ... 57 | /// } 58 | /// 59 | /// fn view(state: &State) -> Element<'_, Message> { 60 | /// column![ 61 | /// "I am on top!", 62 | /// button("I am in the center!"), 63 | /// "I am below.", 64 | /// ].into() 65 | /// } 66 | /// ``` 67 | #[allow(missing_debug_implementations)] 68 | pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> 69 | where 70 | Theme: Catalog, 71 | { 72 | spacing: f32, 73 | padding: Padding, 74 | width: Length, 75 | height: Length, 76 | max_width: f32, 77 | align: Alignment, 78 | clip: bool, 79 | deadband_zone: f32, 80 | children: Vec>, 81 | on_drag: Option Message + 'a>>, 82 | class: Theme::Class<'a>, 83 | } 84 | 85 | impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer> 86 | where 87 | Renderer: crate::core::Renderer, 88 | Theme: Catalog, 89 | { 90 | /// Creates an empty [`Column`]. 91 | pub fn new() -> Self { 92 | Self::from_vec(Vec::new()) 93 | } 94 | 95 | /// Creates a [`Column`] with the given capacity. 96 | pub fn with_capacity(capacity: usize) -> Self { 97 | Self::from_vec(Vec::with_capacity(capacity)) 98 | } 99 | 100 | /// Creates a [`Column`] with the given elements. 101 | pub fn with_children( 102 | children: impl IntoIterator>, 103 | ) -> Self { 104 | let iterator = children.into_iter(); 105 | 106 | Self::with_capacity(iterator.size_hint().0).extend(iterator) 107 | } 108 | 109 | /// Creates a [`Column`] from an already allocated [`Vec`]. 110 | /// 111 | /// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means 112 | /// it won't automatically adapt to the sizing strategy of its contents. 113 | /// 114 | /// If any of the children have a [`Length::Fill`] strategy, you will need to 115 | /// call [`Column::width`] or [`Column::height`] accordingly. 116 | pub fn from_vec( 117 | children: Vec>, 118 | ) -> Self { 119 | Self { 120 | spacing: 0.0, 121 | padding: Padding::ZERO, 122 | width: Length::Shrink, 123 | height: Length::Shrink, 124 | max_width: f32::INFINITY, 125 | align: Alignment::Start, 126 | clip: false, 127 | deadband_zone: DRAG_DEADBAND_DISTANCE, 128 | children, 129 | class: Theme::default(), 130 | on_drag: None, 131 | } 132 | } 133 | 134 | /// Sets the vertical spacing _between_ elements. 135 | /// 136 | /// Custom margins per element do not exist in iced. You should use this 137 | /// method instead! While less flexible, it helps you keep spacing between 138 | /// elements consistent. 139 | pub fn spacing(mut self, amount: impl Into) -> Self { 140 | self.spacing = amount.into().0; 141 | self 142 | } 143 | 144 | /// Sets the [`Padding`] of the [`Column`]. 145 | pub fn padding>(mut self, padding: P) -> Self { 146 | self.padding = padding.into(); 147 | self 148 | } 149 | 150 | /// Sets the width of the [`Column`]. 151 | pub fn width(mut self, width: impl Into) -> Self { 152 | self.width = width.into(); 153 | self 154 | } 155 | 156 | /// Sets the height of the [`Column`]. 157 | pub fn height(mut self, height: impl Into) -> Self { 158 | self.height = height.into(); 159 | self 160 | } 161 | 162 | /// Sets the maximum width of the [`Column`]. 163 | pub fn max_width(mut self, max_width: impl Into) -> Self { 164 | self.max_width = max_width.into().0; 165 | self 166 | } 167 | 168 | /// Sets the horizontal alignment of the contents of the [`Column`]. 169 | pub fn align_x(mut self, align: impl Into) -> Self { 170 | self.align = Alignment::from(align.into()); 171 | self 172 | } 173 | 174 | /// Sets whether the contents of the [`Column`] should be clipped on 175 | /// overflow. 176 | pub fn clip(mut self, clip: bool) -> Self { 177 | self.clip = clip; 178 | self 179 | } 180 | 181 | /// Sets the drag deadband zone of the [`Column`]. 182 | /// 183 | /// This is the minimum distance in pixels that the cursor must move 184 | /// before a drag operation begins. Default is 5.0 pixels. 185 | pub fn deadband_zone(mut self, deadband_zone: f32) -> Self { 186 | self.deadband_zone = deadband_zone; 187 | self 188 | } 189 | 190 | /// Adds an element to the [`Column`]. 191 | pub fn push( 192 | mut self, 193 | child: impl Into>, 194 | ) -> Self { 195 | let child = child.into(); 196 | let child_size = child.as_widget().size_hint(); 197 | 198 | if !child_size.is_void() { 199 | self.width = self.width.enclose(child_size.width); 200 | self.height = self.height.enclose(child_size.height); 201 | self.children.push(child); 202 | } 203 | 204 | self 205 | } 206 | 207 | /// Adds an element to the [`Column`], if `Some`. 208 | pub fn push_maybe( 209 | self, 210 | child: Option>>, 211 | ) -> Self { 212 | if let Some(child) = child { 213 | self.push(child) 214 | } else { 215 | self 216 | } 217 | } 218 | 219 | /// Sets the style of the [`Column`]. 220 | #[must_use] 221 | pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self 222 | where 223 | Theme::Class<'a>: From>, 224 | { 225 | self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); 226 | self 227 | } 228 | 229 | /// Sets the style class of the [`Column`]. 230 | #[must_use] 231 | pub fn class(mut self, class: impl Into>) -> Self { 232 | self.class = class.into(); 233 | self 234 | } 235 | 236 | /// Extends the [`Column`] with the given children. 237 | pub fn extend( 238 | self, 239 | children: impl IntoIterator>, 240 | ) -> Self { 241 | children.into_iter().fold(self, Self::push) 242 | } 243 | 244 | /// Sets a handler for drag events. 245 | /// 246 | /// When set, items in the [`Column`] can be dragged and reordered. 247 | /// The handler receives a [`DragEvent`] describing what happened. 248 | pub fn on_drag( 249 | mut self, 250 | on_drag: impl Fn(DragEvent) -> Message + 'a, 251 | ) -> Self { 252 | self.on_drag = Some(Box::new(on_drag)); 253 | self 254 | } 255 | 256 | /// Computes the index where a dragged item should be dropped. 257 | fn compute_target_index( 258 | &self, 259 | cursor_position: Point, 260 | layout: Layout<'_>, 261 | ) -> usize { 262 | let bounds = layout.bounds(); 263 | let cursor_y = cursor_position.y; 264 | 265 | if cursor_y < bounds.y { 266 | return 0; 267 | } 268 | 269 | for (i, child_layout) in layout.children().enumerate() { 270 | let bounds = child_layout.bounds(); 271 | let y = bounds.y; 272 | let height = bounds.height; 273 | 274 | if cursor_y <= y + height { 275 | return i; 276 | } 277 | } 278 | 279 | self.children.len().saturating_sub(1) 280 | } 281 | } 282 | 283 | impl Default for Column<'_, Message, Theme, Renderer> 284 | where 285 | Renderer: crate::core::Renderer, 286 | Theme: Catalog, 287 | { 288 | fn default() -> Self { 289 | Self::new() 290 | } 291 | } 292 | 293 | impl<'a, Message, Theme, Renderer: crate::core::Renderer> 294 | FromIterator> 295 | for Column<'a, Message, Theme, Renderer> 296 | where 297 | Theme: Catalog, 298 | { 299 | fn from_iter< 300 | T: IntoIterator>, 301 | >( 302 | iter: T, 303 | ) -> Self { 304 | Self::with_children(iter) 305 | } 306 | } 307 | 308 | // Internal state for drag animations 309 | #[derive(Debug, Clone)] 310 | enum Action { 311 | Idle { 312 | now: Option, 313 | animations: ItemAnimations, 314 | }, 315 | Picking { 316 | index: usize, 317 | origin: Point, 318 | now: Instant, 319 | animations: ItemAnimations, 320 | }, 321 | Dragging { 322 | index: usize, 323 | origin: Point, 324 | last_cursor: Point, 325 | now: Instant, 326 | animations: ItemAnimations, 327 | }, 328 | } 329 | 330 | impl Default for Action { 331 | fn default() -> Self { 332 | Self::Idle { 333 | now: None, 334 | animations: ItemAnimations::default(), 335 | } 336 | } 337 | } 338 | 339 | #[derive(Default, Debug, Clone)] 340 | struct ItemAnimations { 341 | offsets: Vec>, 342 | } 343 | 344 | impl ItemAnimations { 345 | fn zero(&mut self) { 346 | for animation in &mut self.offsets { 347 | *animation = Animation::new(0.0); 348 | } 349 | } 350 | 351 | fn is_animating(&self, now: Instant) -> bool { 352 | self.offsets.iter().any(|anim| anim.is_animating(now)) 353 | } 354 | 355 | fn with_capacity(&mut self, count: usize) { 356 | if self.offsets.len() < count { 357 | self.offsets.resize_with(count, || Animation::new(0.0)); 358 | } 359 | } 360 | } 361 | 362 | impl Widget 363 | for Column<'_, Message, Theme, Renderer> 364 | where 365 | Renderer: crate::core::Renderer, 366 | Theme: Catalog, 367 | { 368 | fn tag(&self) -> tree::Tag { 369 | tree::Tag::of::() 370 | } 371 | 372 | fn state(&self) -> tree::State { 373 | let mut animations = ItemAnimations::default(); 374 | animations.with_capacity(self.children.len()); 375 | 376 | tree::State::new(Action::Idle { 377 | now: Some(Instant::now()), 378 | animations, 379 | }) 380 | } 381 | 382 | fn children(&self) -> Vec { 383 | self.children.iter().map(Tree::new).collect() 384 | } 385 | 386 | fn diff(&self, tree: &mut Tree) { 387 | tree.diff_children(&self.children); 388 | 389 | let action = tree.state.downcast_mut::(); 390 | 391 | match action { 392 | Action::Idle { animations, .. } 393 | | Action::Picking { animations, .. } 394 | | Action::Dragging { animations, .. } => { 395 | animations.with_capacity(self.children.len()); 396 | } 397 | } 398 | } 399 | 400 | fn size(&self) -> Size { 401 | Size { 402 | width: self.width, 403 | height: self.height, 404 | } 405 | } 406 | 407 | fn layout( 408 | &mut self, 409 | tree: &mut Tree, 410 | renderer: &Renderer, 411 | limits: &layout::Limits, 412 | ) -> layout::Node { 413 | let limits = limits.max_width(self.max_width); 414 | 415 | layout::flex::resolve( 416 | layout::flex::Axis::Vertical, 417 | renderer, 418 | &limits, 419 | self.width, 420 | self.height, 421 | self.padding, 422 | self.spacing, 423 | self.align, 424 | &mut self.children, 425 | &mut tree.children, 426 | ) 427 | } 428 | 429 | fn operate( 430 | &mut self, 431 | tree: &mut Tree, 432 | layout: Layout<'_>, 433 | renderer: &Renderer, 434 | operation: &mut dyn Operation, 435 | ) { 436 | operation.container(None, layout.bounds()); 437 | operation.traverse(&mut |operation| { 438 | self.children 439 | .iter_mut() 440 | .zip(&mut tree.children) 441 | .zip(layout.children()) 442 | .for_each(|((child, state), layout)| { 443 | child 444 | .as_widget_mut() 445 | .operate(state, layout, renderer, operation); 446 | }); 447 | }); 448 | } 449 | 450 | fn update( 451 | &mut self, 452 | tree: &mut Tree, 453 | event: &Event, 454 | layout: Layout<'_>, 455 | cursor: mouse::Cursor, 456 | renderer: &Renderer, 457 | clipboard: &mut dyn Clipboard, 458 | shell: &mut Shell<'_, Message>, 459 | viewport: &Rectangle, 460 | ) { 461 | let action = tree.state.downcast_mut::(); 462 | 463 | for ((child, state), layout) in self 464 | .children 465 | .iter_mut() 466 | .zip(&mut tree.children) 467 | .zip(layout.children()) 468 | { 469 | child.as_widget_mut().update( 470 | state, event, layout, cursor, renderer, clipboard, shell, 471 | viewport, 472 | ); 473 | } 474 | 475 | if shell.is_event_captured() { 476 | return; 477 | } 478 | 479 | match &event { 480 | Event::Window(crate::core::window::Event::RedrawRequested(now)) => { 481 | match action { 482 | Action::Idle { 483 | now: current_now, 484 | animations, 485 | } => { 486 | *current_now = Some(*now); 487 | 488 | if animations.is_animating(*now) { 489 | shell.request_redraw(); 490 | } 491 | } 492 | Action::Picking { 493 | now: current_now, .. 494 | } 495 | | Action::Dragging { 496 | now: current_now, .. 497 | } => { 498 | *current_now = *now; 499 | shell.request_redraw(); 500 | } 501 | } 502 | } 503 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { 504 | if self.on_drag.is_some() { 505 | if let Some(cursor_position) = 506 | cursor.position_over(layout.bounds()) 507 | { 508 | let animations = match action { 509 | Action::Idle { animations, .. } => animations, 510 | Action::Picking { animations, .. } => animations, 511 | Action::Dragging { animations, .. } => animations, 512 | }; 513 | animations.zero(); 514 | 515 | let index = 516 | self.compute_target_index(cursor_position, layout); 517 | 518 | *action = Action::Picking { 519 | index, 520 | origin: cursor_position, 521 | now: Instant::now(), 522 | animations: std::mem::take(animations), 523 | }; 524 | 525 | shell.capture_event(); 526 | shell.request_redraw(); 527 | } 528 | } 529 | } 530 | Event::Mouse(mouse::Event::CursorMoved { .. }) => match action { 531 | Action::Picking { 532 | index, 533 | origin, 534 | now, 535 | animations, 536 | } => { 537 | if let Some(cursor_position) = cursor.position() { 538 | if cursor_position.distance(*origin) 539 | > self.deadband_zone 540 | { 541 | let index = *index; 542 | let origin = *origin; 543 | let now = *now; 544 | 545 | *action = Action::Dragging { 546 | index, 547 | origin, 548 | last_cursor: cursor_position, 549 | now, 550 | animations: std::mem::take(animations), 551 | }; 552 | 553 | shell.request_redraw(); 554 | 555 | if let Some(on_drag) = &self.on_drag { 556 | shell.publish(on_drag(DragEvent::Picked { 557 | index, 558 | })); 559 | } 560 | 561 | shell.capture_event(); 562 | } 563 | } 564 | } 565 | Action::Dragging { 566 | origin, 567 | index, 568 | now, 569 | animations, 570 | .. 571 | } => { 572 | shell.request_redraw(); 573 | 574 | if let Some(cursor_position) = cursor.position() { 575 | animations.with_capacity(self.children.len()); 576 | 577 | let target_index = 578 | self.compute_target_index(cursor_position, layout); 579 | 580 | let drag_height = if let Some(child_layout) = 581 | layout.children().nth(*index) 582 | { 583 | child_layout.bounds().height + self.spacing 584 | } else { 585 | 0.0 586 | }; 587 | 588 | for i in 0..animations.offsets.len() { 589 | if i == *index { 590 | animations.offsets[i] 591 | .go_mut(1.0, Instant::now()); 592 | continue; 593 | } 594 | 595 | let target_offset = match target_index.cmp(index) { 596 | std::cmp::Ordering::Less 597 | if (target_index..*index).contains(&i) => 598 | { 599 | drag_height 600 | } 601 | std::cmp::Ordering::Greater 602 | if (*index + 1..=target_index) 603 | .contains(&i) => 604 | { 605 | -drag_height 606 | } 607 | _ => 0.0, 608 | }; 609 | 610 | animations.offsets[i] 611 | .go_mut(target_offset, Instant::now()); 612 | } 613 | 614 | let origin = *origin; 615 | let index = *index; 616 | let now = *now; 617 | 618 | *action = Action::Dragging { 619 | last_cursor: cursor_position, 620 | origin, 621 | index, 622 | now, 623 | animations: std::mem::take(animations), 624 | }; 625 | 626 | shell.capture_event(); 627 | } else { 628 | let index = *index; 629 | let now = *now; 630 | 631 | if let Some(on_drag) = &self.on_drag { 632 | shell.publish(on_drag(DragEvent::Canceled { 633 | index, 634 | })); 635 | } 636 | 637 | *action = Action::Idle { 638 | now: Some(now), 639 | animations: std::mem::take(animations), 640 | }; 641 | } 642 | } 643 | _ => {} 644 | }, 645 | Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { 646 | match action { 647 | Action::Dragging { 648 | index, 649 | animations, 650 | now, 651 | .. 652 | } => { 653 | let current_now = *now; 654 | 655 | animations.with_capacity(self.children.len()); 656 | 657 | if let Some(cursor_position) = cursor.position() { 658 | let target_index = self 659 | .compute_target_index(cursor_position, layout); 660 | 661 | let drag_height = if let Some(child_layout) = 662 | layout.children().nth(*index) 663 | { 664 | child_layout.bounds().height + self.spacing 665 | } else { 666 | 0.0 667 | }; 668 | 669 | for i in 0..animations.offsets.len() { 670 | let target_offset = 671 | match target_index.cmp(index) { 672 | std::cmp::Ordering::Less 673 | if (target_index..*index) 674 | .contains(&i) => 675 | { 676 | drag_height 677 | } 678 | std::cmp::Ordering::Greater 679 | if (*index + 1..=target_index) 680 | .contains(&i) => 681 | { 682 | -drag_height 683 | } 684 | _ => 0.0, 685 | }; 686 | 687 | if i == *index { 688 | animations.offsets[i] = 689 | Animation::new(target_offset); 690 | } else { 691 | animations.offsets[i] 692 | .go_mut(target_offset, Instant::now()); 693 | } 694 | } 695 | 696 | if let Some(on_drag) = &self.on_drag { 697 | shell.publish(on_drag(DragEvent::Dropped { 698 | index: *index, 699 | target_index, 700 | })); 701 | shell.capture_event(); 702 | } 703 | } else if let Some(on_drag) = &self.on_drag { 704 | shell.publish(on_drag(DragEvent::Canceled { 705 | index: *index, 706 | })); 707 | shell.capture_event(); 708 | } 709 | 710 | *action = Action::Idle { 711 | now: Some(current_now), 712 | animations: std::mem::take(animations), 713 | }; 714 | } 715 | Action::Picking { 716 | animations, now, .. 717 | } => { 718 | *action = Action::Idle { 719 | now: Some(*now), 720 | animations: std::mem::take(animations), 721 | }; 722 | } 723 | _ => {} 724 | } 725 | shell.request_redraw(); 726 | } 727 | _ => {} 728 | } 729 | } 730 | 731 | fn mouse_interaction( 732 | &self, 733 | tree: &Tree, 734 | layout: Layout<'_>, 735 | cursor: mouse::Cursor, 736 | viewport: &Rectangle, 737 | renderer: &Renderer, 738 | ) -> mouse::Interaction { 739 | let action = tree.state.downcast_ref::(); 740 | 741 | if let Action::Dragging { .. } = *action { 742 | return mouse::Interaction::Grabbing; 743 | } 744 | 745 | self.children 746 | .iter() 747 | .zip(&tree.children) 748 | .zip(layout.children()) 749 | .map(|((child, state), layout)| { 750 | child.as_widget().mouse_interaction( 751 | state, layout, cursor, viewport, renderer, 752 | ) 753 | }) 754 | .max() 755 | .unwrap_or_default() 756 | } 757 | 758 | fn draw( 759 | &self, 760 | tree: &Tree, 761 | renderer: &mut Renderer, 762 | theme: &Theme, 763 | defaults: &renderer::Style, 764 | layout: Layout<'_>, 765 | cursor: mouse::Cursor, 766 | viewport: &Rectangle, 767 | ) { 768 | let action = tree.state.downcast_ref::(); 769 | let style = theme.style(&self.class); 770 | 771 | match action { 772 | Action::Dragging { 773 | index, 774 | last_cursor, 775 | origin, 776 | now, 777 | animations, 778 | .. 779 | } => { 780 | let child_count = self.children.len(); 781 | 782 | let target_index = if cursor.position().is_some() { 783 | let target_index = 784 | self.compute_target_index(*last_cursor, layout); 785 | target_index.min(child_count - 1) 786 | } else { 787 | *index 788 | }; 789 | 790 | let drag_bounds = 791 | layout.children().nth(*index).unwrap().bounds(); 792 | let drag_height = drag_bounds.height + self.spacing; 793 | 794 | for i in 0..child_count { 795 | let child = &self.children[i]; 796 | let state = &tree.children[i]; 797 | let child_layout = layout.children().nth(i).unwrap(); 798 | 799 | if i == *index { 800 | let scale_factor = 1.0 801 | + (style.scale - 1.0) 802 | * animations.offsets[i] 803 | .interpolate_with(|v| v, *now); 804 | 805 | let scaling = Transformation::scale(scale_factor); 806 | let translation = *last_cursor - *origin * scaling; 807 | 808 | renderer.with_translation(translation, |renderer| { 809 | renderer.with_transformation(scaling, |renderer| { 810 | renderer.with_layer( 811 | child_layout.bounds(), 812 | |renderer| { 813 | child.as_widget().draw( 814 | state, 815 | renderer, 816 | theme, 817 | defaults, 818 | child_layout, 819 | cursor, 820 | viewport, 821 | ); 822 | }, 823 | ); 824 | }); 825 | }); 826 | } else { 827 | let base_offset = if i < animations.offsets.len() { 828 | animations.offsets[i].interpolate_with(|v| v, *now) 829 | } else { 830 | 0.0 831 | }; 832 | 833 | let offset = if base_offset == 0.0 { 834 | match target_index.cmp(index) { 835 | std::cmp::Ordering::Less 836 | if i >= target_index && i < *index => 837 | { 838 | drag_height 839 | } 840 | std::cmp::Ordering::Greater 841 | if i > *index && i <= target_index => 842 | { 843 | -drag_height 844 | } 845 | _ => 0.0, 846 | } 847 | } else { 848 | base_offset 849 | }; 850 | 851 | let translation = Vector::new(0.0, offset); 852 | 853 | renderer.with_translation(translation, |renderer| { 854 | child.as_widget().draw( 855 | state, 856 | renderer, 857 | theme, 858 | defaults, 859 | child_layout, 860 | cursor, 861 | viewport, 862 | ); 863 | 864 | if offset != 0.0 { 865 | let progress = (offset / drag_height).abs(); 866 | 867 | renderer.fill_quad( 868 | renderer::Quad { 869 | bounds: child_layout.bounds(), 870 | ..renderer::Quad::default() 871 | }, 872 | style 873 | .moved_item_overlay 874 | .scale_alpha(progress), 875 | ); 876 | } 877 | }); 878 | } 879 | } 880 | 881 | let target_index = 882 | self.compute_target_index(*last_cursor, layout); 883 | let is_moving_up = target_index < *index; 884 | 885 | let ghost_translation = layout 886 | .children() 887 | .enumerate() 888 | .filter(|(i, _)| *i != *index) 889 | .fold(0.0, |acc, (i, child_layout)| { 890 | if i < animations.offsets.len() { 891 | let offset = animations.offsets[i] 892 | .interpolate_with(|v| v, *now); 893 | 894 | if offset != 0.0 { 895 | let height = 896 | child_layout.bounds().height + self.spacing; 897 | 898 | if is_moving_up 899 | && i >= target_index 900 | && i < *index 901 | { 902 | return acc - height; 903 | } else if !is_moving_up 904 | && i > *index 905 | && i <= target_index 906 | { 907 | return acc + height; 908 | } 909 | } 910 | } 911 | acc 912 | }); 913 | 914 | let ghost_vector = Vector::new(0.0, ghost_translation); 915 | 916 | renderer.with_translation(ghost_vector, |renderer| { 917 | renderer.fill_quad( 918 | renderer::Quad { 919 | bounds: drag_bounds, 920 | border: style.ghost_border, 921 | ..renderer::Quad::default() 922 | }, 923 | style.ghost_background, 924 | ); 925 | }); 926 | } 927 | Action::Idle { 928 | now: Some(now), 929 | animations, 930 | } => { 931 | for (i, child) in self.children.iter().enumerate() { 932 | let state = &tree.children[i]; 933 | let child_layout = layout.children().nth(i).unwrap(); 934 | 935 | let offset = if i < animations.offsets.len() { 936 | let is_animating = 937 | animations.offsets[i].is_animating(*now); 938 | 939 | if is_animating { 940 | animations.offsets[i].interpolate_with(|v| v, *now) 941 | } else { 942 | 0.0 943 | } 944 | } else { 945 | 0.0 946 | }; 947 | 948 | let translation = Vector::new(0.0, offset); 949 | 950 | renderer.with_translation(translation, |renderer| { 951 | child.as_widget().draw( 952 | state, 953 | renderer, 954 | theme, 955 | defaults, 956 | child_layout, 957 | cursor, 958 | viewport, 959 | ); 960 | 961 | if offset != 0.0 { 962 | let height = 963 | child_layout.bounds().height + self.spacing; 964 | let progress = (offset / height).abs(); 965 | 966 | renderer.fill_quad( 967 | renderer::Quad { 968 | bounds: child_layout.bounds(), 969 | ..renderer::Quad::default() 970 | }, 971 | style.moved_item_overlay.scale_alpha(progress), 972 | ); 973 | } 974 | }); 975 | } 976 | } 977 | _ => { 978 | for ((child, state), layout) in self 979 | .children 980 | .iter() 981 | .zip(&tree.children) 982 | .zip(layout.children()) 983 | { 984 | child.as_widget().draw( 985 | state, renderer, theme, defaults, layout, cursor, 986 | viewport, 987 | ); 988 | } 989 | } 990 | } 991 | } 992 | 993 | fn overlay<'b>( 994 | &'b mut self, 995 | tree: &'b mut Tree, 996 | layout: Layout<'b>, 997 | renderer: &Renderer, 998 | viewport: &Rectangle, 999 | translation: Vector, 1000 | ) -> Option> { 1001 | overlay::from_children( 1002 | &mut self.children, 1003 | tree, 1004 | layout, 1005 | renderer, 1006 | viewport, 1007 | translation, 1008 | ) 1009 | } 1010 | } 1011 | 1012 | impl<'a, Message, Theme, Renderer> From> 1013 | for Element<'a, Message, Theme, Renderer> 1014 | where 1015 | Message: 'a, 1016 | Theme: Catalog + 'a, 1017 | Renderer: crate::core::Renderer + 'a, 1018 | { 1019 | fn from(column: Column<'a, Message, Theme, Renderer>) -> Self { 1020 | Self::new(column) 1021 | } 1022 | } 1023 | 1024 | /// The theme catalog of a [`Column`]. 1025 | pub trait Catalog { 1026 | /// The item class of the [`Catalog`]. 1027 | type Class<'a>; 1028 | 1029 | /// The default class produced by the [`Catalog`]. 1030 | fn default<'a>() -> Self::Class<'a>; 1031 | 1032 | /// The [`Style`] of a class with the given status. 1033 | fn style(&self, class: &Self::Class<'_>) -> Style; 1034 | } 1035 | 1036 | /// The appearance of a [`Column`] during drag operations. 1037 | #[derive(Debug, Clone, Copy)] 1038 | pub struct Style { 1039 | /// The scaling to apply to a picked element while it's being dragged. 1040 | pub scale: f32, 1041 | /// The color of the overlay on items that are moved around. 1042 | pub moved_item_overlay: Color, 1043 | /// The border of the dragged item's ghost. 1044 | pub ghost_border: Border, 1045 | /// The background of the dragged item's ghost. 1046 | pub ghost_background: Background, 1047 | } 1048 | 1049 | /// A styling function for a [`Column`]. 1050 | pub type StyleFn<'a, Theme> = Box Style + 'a>; 1051 | 1052 | impl Catalog for crate::Theme { 1053 | type Class<'a> = StyleFn<'a, Self>; 1054 | 1055 | fn default<'a>() -> Self::Class<'a> { 1056 | Box::new(default) 1057 | } 1058 | 1059 | fn style(&self, class: &Self::Class<'_>) -> Style { 1060 | class(self) 1061 | } 1062 | } 1063 | 1064 | /// The default style for a [`Column`]. 1065 | pub fn default(theme: &crate::Theme) -> Style { 1066 | Style { 1067 | scale: 1.05, 1068 | moved_item_overlay: theme 1069 | .extended_palette() 1070 | .primary 1071 | .base 1072 | .color 1073 | .scale_alpha(0.2), 1074 | ghost_border: Border { 1075 | width: 1.0, 1076 | color: theme.extended_palette().secondary.base.color, 1077 | radius: 0.0.into(), 1078 | }, 1079 | ghost_background: theme 1080 | .extended_palette() 1081 | .secondary 1082 | .base 1083 | .color 1084 | .scale_alpha(0.2) 1085 | .into(), 1086 | } 1087 | } 1088 | --------------------------------------------------------------------------------