From 949a31855d87c058622d669a4d0f1e7d39f78bbe Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Thu, 26 Dec 2024 19:16:22 -0500 Subject: [PATCH 01/16] Implemented rudimentary table widget, currently not interactible --- src/widget/mod.rs | 3 +- src/widget/table/mod.rs | 7 + src/widget/table/model/category.rs | 17 ++ src/widget/table/model/entity.rs | 127 ++++++++++ src/widget/table/model/mod.rs | 354 ++++++++++++++++++++++++++++ src/widget/table/model/selection.rs | 115 +++++++++ src/widget/table/widget.rs | 174 ++++++++++++++ 7 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 src/widget/table/mod.rs create mode 100644 src/widget/table/model/category.rs create mode 100644 src/widget/table/model/entity.rs create mode 100644 src/widget/table/model/mod.rs create mode 100644 src/widget/table/model/selection.rs create mode 100644 src/widget/table/widget.rs diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 06dd4e85b45..0a11f0517e2 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -315,6 +315,8 @@ pub use spin_button::{spin_button, vertical as vertical_spin_button, SpinButton} pub mod tab_bar; +pub mod table; + pub mod text; #[doc(inline)] pub use text::{text, Text}; @@ -337,7 +339,6 @@ pub use toggler::toggler; pub use tooltip::{tooltip, Tooltip}; pub mod tooltip { use crate::Element; - use std::borrow::Cow; pub use iced::widget::tooltip::Position; diff --git a/src/widget/table/mod.rs b/src/widget/table/mod.rs new file mode 100644 index 00000000000..9f4ce541889 --- /dev/null +++ b/src/widget/table/mod.rs @@ -0,0 +1,7 @@ +//! A widget allowing the user to display tables of information with optional sorting by category +//! + +pub mod model; +pub use model::{Entity, Model}; +pub mod widget; +pub use widget::TableView; diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs new file mode 100644 index 00000000000..efa4624a60e --- /dev/null +++ b/src/widget/table/model/category.rs @@ -0,0 +1,17 @@ +use std::borrow::Cow; + +use crate::widget::Icon; + +/// Implementation of std::fmt::Display allows user to customize the header +/// Ideally, this is implemented on an enum. +pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq { + /// Function that gets the width of the data + fn width(&self) -> iced::Length; +} + +pub trait ItemInterface: Default { + fn get_icon(&self, category: Category) -> Option; + fn get_text(&self, category: Category) -> Cow<'static, str>; + + fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering; +} diff --git a/src/widget/table/model/entity.rs b/src/widget/table/model/entity.rs new file mode 100644 index 00000000000..44dd79a5adf --- /dev/null +++ b/src/widget/table/model/entity.rs @@ -0,0 +1,127 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use slotmap::{SecondaryMap, SparseSecondaryMap}; + +use super::{ + category::{ItemCategory, ItemInterface}, + Entity, Model, Selectable, +}; + +/// A newly-inserted item which may have additional actions applied to it. +pub struct EntityMut< + 'a, + SelectionMode: Default, + Item: ItemInterface, + Category: ItemCategory, +> { + pub(super) id: Entity, + pub(super) model: &'a mut Model, +} + +impl<'a, SelectionMode: Default, Item: ItemInterface, Category: ItemCategory> + EntityMut<'a, SelectionMode, Item, Category> +where + Model: Selectable, +{ + /// Activates the newly-inserted item. + /// + /// ```ignore + /// model.insert().text("Item A").activate(); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn activate(self) -> Self { + self.model.activate(self.id); + self + } + + /// Associates extra data with an external secondary map. + /// + /// The secondary map internally uses a `Vec`, so should only be used for data that + /// is commonly associated. + /// + /// ```ignore + /// let mut secondary_data = segmented_button::SecondaryMap::default(); + /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn secondary(self, map: &mut SecondaryMap, data: Data) -> Self { + map.insert(self.id, data); + self + } + + /// Associates extra data with an external sparse secondary map. + /// + /// Sparse maps internally use a `HashMap`, for data that is sparsely associated. + /// + /// ```ignore + /// let mut secondary_data = segmented_button::SparseSecondaryMap::default(); + /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn secondary_sparse( + self, + map: &mut SparseSecondaryMap, + data: Data, + ) -> Self { + map.insert(self.id, data); + self + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.insert().text("Item A").data(String::from("custom string")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn data(self, data: Data) -> Self { + self.model.data_set(self.id, data); + self + } + + /// Returns the ID of the item that was inserted. + /// + /// ```ignore + /// let id = model.insert("Item A").id(); + /// ``` + #[must_use] + pub fn id(self) -> Entity { + self.id + } + + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn indent(self, indent: u16) -> Self { + self.model.indent_set(self.id, indent); + self + } + + /// Define the position of the item. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn position(self, position: u16) -> Self { + self.model.position_set(self.id, position); + self + } + + /// Swap the position with another item in the model. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn position_swap(self, other: Entity) -> Self { + self.model.position_swap(self.id, other); + self + } + + /// Defines the text for the item. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn item(self, item: Item) -> Self { + self.model.item_set(self.id, item); + self + } + + /// Calls a function with the ID without consuming the wrapper. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn with_id(self, func: impl FnOnce(Entity)) -> Self { + func(self.id); + self + } +} diff --git a/src/widget/table/model/mod.rs b/src/widget/table/model/mod.rs new file mode 100644 index 00000000000..43fe47c3aa0 --- /dev/null +++ b/src/widget/table/model/mod.rs @@ -0,0 +1,354 @@ +pub mod category; +pub mod entity; +pub mod selection; + +use std::{ + any::{Any, TypeId}, + collections::{HashMap, VecDeque}, +}; + +use category::{ItemCategory, ItemInterface}; +use entity::EntityMut; +use selection::Selectable; +use slotmap::{SecondaryMap, SlotMap}; + +slotmap::new_key_type! { + /// Unique key type for items in the table + pub struct Entity; +} + +/// The portion of the model used only by the application. +#[derive(Debug, Default)] +pub(super) struct Storage(HashMap>>); + +pub struct Model, Category: ItemCategory> +where + Category: ItemCategory, +{ + pub(super) categories: Vec, + + /// Stores the items + pub(super) items: SlotMap, + + /// Whether the item is selected or not + pub(super) active: SecondaryMap, + + /// Optional indents for the table items + pub(super) indents: SecondaryMap, + + /// Order which the items will be displayed. + pub(super) order: VecDeque, + + /// Stores the current selection(s) + pub(super) selection: SelectionMode, + + /// What category to sort by and whether it's ascending or not + pub(super) sort: (Category, bool), + + /// Application-managed data associated with each item + pub(super) storage: Storage, +} + +impl, Category: ItemCategory> + Model +where + Self: Selectable, +{ + pub fn new(categories: Vec) -> Self { + Self { + categories, + items: SlotMap::default(), + active: SecondaryMap::default(), + indents: SecondaryMap::default(), + order: VecDeque::new(), + selection: SelectionMode::default(), + sort: (Category::default(), false), + storage: Storage::default(), + } + } + + pub fn categories(&mut self, cats: Vec) { + self.categories = cats; + } + + /// Activates the item in the model. + /// + /// ```ignore + /// model.activate(id); + /// ``` + pub fn activate(&mut self, id: Entity) { + Selectable::activate(self, id); + } + + /// Activates the item at the given position, returning true if it was activated. + pub fn activate_position(&mut self, position: u16) -> bool { + if let Some(entity) = self.entity_at(position) { + self.activate(entity); + return true; + } + + false + } + + /// Removes all items from the model. + /// + /// Any IDs held elsewhere by the application will no longer be usable with the map. + /// The generation is incremented on removal, so the stale IDs will return `None` for + /// any attempt to get values from the map. + /// + /// ```ignore + /// model.clear(); + /// ``` + pub fn clear(&mut self) { + for entity in self.order.clone() { + self.remove(entity); + } + } + + /// Check if an item exists in the map. + /// + /// ```ignore + /// if model.contains_item(id) { + /// println!("ID is still valid"); + /// } + /// ``` + pub fn contains_item(&self, id: Entity) -> bool { + self.items.contains_key(id) + } + + /// Get an immutable reference to data associated with an item. + /// + /// ```ignore + /// if let Some(data) = model.data::(id) { + /// println!("found string on {:?}: {}", id, data); + /// } + /// ``` + pub fn item(&self, id: Entity) -> Option<&Item> { + self.items.get(id) + } + + /// Get a mutable reference to data associated with an item. + pub fn item_mut(&mut self, id: Entity) -> Option<&mut Item> { + self.items.get_mut(id) + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.data_set::(id, String::from("custom string")); + /// ``` + pub fn item_set(&mut self, id: Entity, data: Item) { + if let Some(item) = self.items.get_mut(id) { + *item = data; + } + } + + /// Get an immutable reference to data associated with an item. + /// + /// ```ignore + /// if let Some(data) = model.data::(id) { + /// println!("found string on {:?}: {}", id, data); + /// } + /// ``` + pub fn data(&self, id: Entity) -> Option<&Data> { + self.storage + .0 + .get(&TypeId::of::()) + .and_then(|storage| storage.get(id)) + .and_then(|data| data.downcast_ref()) + } + + /// Get a mutable reference to data associated with an item. + pub fn data_mut(&mut self, id: Entity) -> Option<&mut Data> { + self.storage + .0 + .get_mut(&TypeId::of::()) + .and_then(|storage| storage.get_mut(id)) + .and_then(|data| data.downcast_mut()) + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.data_set::(id, String::from("custom string")); + /// ``` + pub fn data_set(&mut self, id: Entity, data: Data) { + if self.contains_item(id) { + self.storage + .0 + .entry(TypeId::of::()) + .or_default() + .insert(id, Box::new(data)); + } + } + + /// Removes a specific data type from the item. + /// + /// ```ignore + /// model.data.remove::(id); + /// ``` + pub fn data_remove(&mut self, id: Entity) { + self.storage + .0 + .get_mut(&TypeId::of::()) + .and_then(|storage| storage.remove(id)); + } + + /// Enable or disable an item. + /// + /// ```ignore + /// model.enable(id, true); + /// ``` + pub fn enable(&mut self, id: Entity, enable: bool) { + if let Some(e) = self.active.get_mut(id) { + *e = enable; + } + } + + /// Get the item that is located at a given position. + #[must_use] + pub fn entity_at(&mut self, position: u16) -> Option { + self.order.get(position as usize).copied() + } + + /// Inserts a new item in the model. + /// + /// ```ignore + /// let id = model.insert().text("Item A").icon("custom-icon").id(); + /// ``` + #[must_use] + pub fn insert(&mut self) -> EntityMut { + let id = self.items.insert(Item::default()); + self.order.push_back(id); + EntityMut { model: self, id } + } + + /// Check if the given ID is the active ID. + #[must_use] + pub fn is_active(&self, id: Entity) -> bool { + ::is_active(self, id) + } + + /// Check if the item is enabled. + /// + /// ```ignore + /// if model.is_enabled(id) { + /// if let Some(text) = model.text(id) { + /// println!("{text} is enabled"); + /// } + /// } + /// ``` + #[must_use] + pub fn is_enabled(&self, id: Entity) -> bool { + self.active.get(id).map_or(false, |e| *e) + } + + /// Iterates across items in the model in the order that they are displayed. + pub fn iter(&self) -> impl Iterator + '_ { + self.order.iter().copied() + } + + pub fn indent(&self, id: Entity) -> Option { + self.indents.get(id).copied() + } + + pub fn indent_set(&mut self, id: Entity, indent: u16) -> Option { + if !self.contains_item(id) { + return None; + } + + self.indents.insert(id, indent) + } + + pub fn indent_remove(&mut self, id: Entity) -> Option { + self.indents.remove(id) + } + + /// The position of the item in the model. + /// + /// ```ignore + /// if let Some(position) = model.position(id) { + /// println!("found item at {}", position); + /// } + #[must_use] + pub fn position(&self, id: Entity) -> Option { + #[allow(clippy::cast_possible_truncation)] + self.order.iter().position(|k| *k == id).map(|v| v as u16) + } + + /// Change the position of an item in the model. + /// + /// ```ignore + /// if let Some(new_position) = model.position_set(id, 0) { + /// println!("placed item at {}", new_position); + /// } + /// ``` + pub fn position_set(&mut self, id: Entity, position: u16) -> Option { + let Some(index) = self.position(id) else { + return None; + }; + + self.order.remove(index as usize); + + let position = self.order.len().min(position as usize); + + self.order.insert(position, id); + Some(position) + } + + /// Swap the position of two items in the model. + /// + /// Returns false if the swap cannot be performed. + /// + /// ```ignore + /// if model.position_swap(first_id, second_id) { + /// println!("positions swapped"); + /// } + /// ``` + pub fn position_swap(&mut self, first: Entity, second: Entity) -> bool { + let Some(first_index) = self.position(first) else { + return false; + }; + + let Some(second_index) = self.position(second) else { + return false; + }; + + self.order.swap(first_index as usize, second_index as usize); + true + } + + /// Removes an item from the model. + /// + /// The generation of the slot for the ID will be incremented, so this ID will no + /// longer be usable with the map. Subsequent attempts to get values from the map + /// with this ID will return `None` and failed to assign values. + pub fn remove(&mut self, id: Entity) { + self.items.remove(id); + self.deactivate(id); + + for storage in self.storage.0.values_mut() { + storage.remove(id); + } + + if let Some(index) = self.position(id) { + self.order.remove(index as usize); + } + } + + /// Sorts items in the model, this should be called before it is drawn after all items have been added for the view + pub fn sort(&mut self, category: Category, ascending: bool) { + self.sort = (category, ascending); + let mut order: Vec = self.order.iter().cloned().collect(); + order.sort_by(|entity_a, entity_b| { + self.item(*entity_a) + .unwrap() + .compare(self.item(*entity_b).unwrap(), category) + }); + self.order = order.into(); + } +} diff --git a/src/widget/table/model/selection.rs b/src/widget/table/model/selection.rs new file mode 100644 index 00000000000..24b7b67d0f9 --- /dev/null +++ b/src/widget/table/model/selection.rs @@ -0,0 +1,115 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Describes logic specific to the single-select and multi-select modes of a model. + +use super::{ + category::{ItemCategory, ItemInterface}, + Entity, Model, +}; +use std::collections::HashSet; + +/// Describes a type that has selectable items. +pub trait Selectable { + /// Activate an item. + fn activate(&mut self, id: Entity); + + /// Deactivate an item. + fn deactivate(&mut self, id: Entity); + + /// Checks if the item is active. + fn is_active(&self, id: Entity) -> bool; +} + +/// [`Model`] Ensures that only one key may be selected. +#[derive(Debug, Default)] +pub struct SingleSelect { + pub active: Entity, +} + +impl, Category: ItemCategory> Selectable + for Model +{ + fn activate(&mut self, id: Entity) { + if !self.items.contains_key(id) { + return; + } + + self.selection.active = id; + } + + fn deactivate(&mut self, id: Entity) { + if id == self.selection.active { + self.selection.active = Entity::default(); + } + } + + fn is_active(&self, id: Entity) -> bool { + self.selection.active == id + } +} + +impl, Category: ItemCategory> Model { + /// Get an immutable reference to the data associated with the active item. + #[must_use] + pub fn active_data(&self) -> Option<&Data> { + self.data(self.active()) + } + + /// Get a mutable reference to the data associated with the active item. + #[must_use] + pub fn active_data_mut(&mut self) -> Option<&mut Data> { + self.data_mut(self.active()) + } + + /// Deactivates the active item. + pub fn deactivate(&mut self) { + Selectable::deactivate(self, Entity::default()); + } + + /// The ID of the active item. + #[must_use] + pub fn active(&self) -> Entity { + self.selection.active + } +} + +/// [`Model`] permits multiple keys to be active at a time. +#[derive(Debug, Default)] +pub struct MultiSelect { + pub active: HashSet, +} + +impl, Category: ItemCategory> Selectable + for Model +{ + fn activate(&mut self, id: Entity) { + if !self.items.contains_key(id) { + return; + } + + if !self.selection.active.insert(id) { + self.selection.active.remove(&id); + } + } + + fn deactivate(&mut self, id: Entity) { + self.selection.active.remove(&id); + } + + fn is_active(&self, id: Entity) -> bool { + self.selection.active.contains(&id) + } +} + +impl, Category: ItemCategory> Model { + /// Deactivates the item in the model. + pub fn deactivate(&mut self, id: Entity) { + Selectable::deactivate(self, id); + } + + /// The IDs of the active items. + pub fn active(&self) -> impl Iterator + '_ { + self.selection.active.iter().copied() + } +} diff --git a/src/widget/table/widget.rs b/src/widget/table/widget.rs new file mode 100644 index 00000000000..ef7ad4106bf --- /dev/null +++ b/src/widget/table/widget.rs @@ -0,0 +1,174 @@ +use super::model::{ + category::{ItemCategory, ItemInterface}, + selection::Selectable, + Entity, Model, +}; +use crate::{ + ext::CollectionWidget, + theme, + widget::{self, container, divider}, + Apply, Element, +}; +use iced::{Alignment, Padding}; +use iced_widget::container::Catalog; + +// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED + +#[must_use] +pub struct TableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, +{ + pub(super) model: &'a Model, + + pub(super) spacing: u16, + pub(super) padding: Padding, + pub(super) list_item_padding: Padding, + pub(super) divider_padding: Padding, + pub(super) style: theme::Container<'a>, + + pub(super) on_selected: Box Message + 'a>, + pub(super) on_category_select: Box Message + 'a>, + pub(super) on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, +} + +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, +{ + pub fn new( + model: &'a Model, + on_selected: impl Fn(Entity) -> Message + 'a, + on_category_select: impl Fn(Category, bool) -> Message + 'a, + on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, + ) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::active().cosmic().spacing; + Self { + model, + spacing: 0, + padding: Padding::from(0), + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + list_item_padding: Padding::from(space_xxs).into(), + style: theme::Container::Background, + + on_selected: Box::new(on_selected), + on_category_select: Box::new(on_category_select), + on_option_hovered, + } + } + + pub fn spacing(mut self, spacing: u16) -> Self { + self.spacing = spacing; + self + } + + /// Sets the style variant of this [`Circular`]. + pub fn style(mut self, style: ::Class<'a>) -> Self { + self.style = style; + self + } + + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self + } + + pub fn divider_padding(mut self, padding: u16) -> Self { + self.divider_padding = Padding::from(0).left(padding).right(padding); + self + } + + pub fn list_item_padding(mut self, padding: impl Into) -> Self { + self.list_item_padding = padding.into(); + self + } + #[must_use] + pub fn into_element(self) -> Element<'a, Message> { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + + crate::widget::column() + .push(widget::row::with_children( + self.model + .categories + .iter() + .map(|category| { + container( + widget::row() + .spacing(space_xxxs) + .push(widget::text::heading(category.to_string())) + .push_maybe(if self.model.sort.0 == *category { + match self.model.sort.1 { + true => { + Some(widget::icon::from_name("pan-up-symbolic").icon()) + } + false => Some( + widget::icon::from_name("pan-down-symbolic").icon(), + ), + } + } else { + None + }), + ) + .padding( + Padding::from(0) + .left(self.list_item_padding.left) + .right(self.list_item_padding.right), + ) + .width(category.width()) + .into() + }) + .collect(), + )) + .append(&mut if self.model.items.is_empty() { + vec![container(divider::horizontal::default()).padding(self.divider_padding)] + } else { + self.model + .order + .iter() + .map(|entity| { + let item = self.model.item(*entity).unwrap(); + let categories = &self.model.categories; + + vec![ + container(divider::horizontal::default()).padding(self.divider_padding), + container(widget::row::with_children( + categories + .iter() + .map(|category| { + container( + widget::row() + .push_maybe(item.get_icon(*category)) + .push(widget::text::body(item.get_text(*category))), + ) + .width(category.width()) + .align_y(Alignment::Center) + .padding(self.list_item_padding) + .apply(Element::from) + }) + .collect(), + )), + ] + }) + .flatten() + .collect() + }) + .spacing(self.spacing) + .padding(self.padding) + .apply(container) + .padding([self.spacing, 0]) + .class(self.style) + .width(iced::Length::Fill) + .into() + } +} From 8132f8f3e849ae1dd7e3482a61117244c16c9d9f Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sun, 29 Dec 2024 22:32:11 -0500 Subject: [PATCH 02/16] Compact view implemented --- src/widget/table/model/mod.rs | 18 +- src/widget/table/widget.rs | 174 ---------------- src/widget/table/widget/mod.rs | 331 +++++++++++++++++++++++++++++++ src/widget/table/widget/state.rs | 8 + 4 files changed, 351 insertions(+), 180 deletions(-) delete mode 100644 src/widget/table/widget.rs create mode 100644 src/widget/table/widget/mod.rs create mode 100644 src/widget/table/widget/state.rs diff --git a/src/widget/table/model/mod.rs b/src/widget/table/model/mod.rs index 43fe47c3aa0..b6935435077 100644 --- a/src/widget/table/model/mod.rs +++ b/src/widget/table/model/mod.rs @@ -43,7 +43,7 @@ where pub(super) selection: SelectionMode, /// What category to sort by and whether it's ascending or not - pub(super) sort: (Category, bool), + pub(super) sort: Option<(Category, bool)>, /// Application-managed data associated with each item pub(super) storage: Storage, @@ -62,7 +62,7 @@ where indents: SecondaryMap::default(), order: VecDeque::new(), selection: SelectionMode::default(), - sort: (Category::default(), false), + sort: None, storage: Storage::default(), } } @@ -342,12 +342,18 @@ where /// Sorts items in the model, this should be called before it is drawn after all items have been added for the view pub fn sort(&mut self, category: Category, ascending: bool) { - self.sort = (category, ascending); + self.sort = Some((category, ascending)); let mut order: Vec = self.order.iter().cloned().collect(); order.sort_by(|entity_a, entity_b| { - self.item(*entity_a) - .unwrap() - .compare(self.item(*entity_b).unwrap(), category) + if ascending { + self.item(*entity_a) + .unwrap() + .compare(self.item(*entity_b).unwrap(), category) + } else { + self.item(*entity_b) + .unwrap() + .compare(self.item(*entity_a).unwrap(), category) + } }); self.order = order.into(); } diff --git a/src/widget/table/widget.rs b/src/widget/table/widget.rs deleted file mode 100644 index ef7ad4106bf..00000000000 --- a/src/widget/table/widget.rs +++ /dev/null @@ -1,174 +0,0 @@ -use super::model::{ - category::{ItemCategory, ItemInterface}, - selection::Selectable, - Entity, Model, -}; -use crate::{ - ext::CollectionWidget, - theme, - widget::{self, container, divider}, - Apply, Element, -}; -use iced::{Alignment, Padding}; -use iced_widget::container::Catalog; - -// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED - -#[must_use] -pub struct TableView<'a, SelectionMode, Item, Category, Message> -where - Category: ItemCategory, - Item: ItemInterface, - Model: Selectable, - SelectionMode: Default, -{ - pub(super) model: &'a Model, - - pub(super) spacing: u16, - pub(super) padding: Padding, - pub(super) list_item_padding: Padding, - pub(super) divider_padding: Padding, - pub(super) style: theme::Container<'a>, - - pub(super) on_selected: Box Message + 'a>, - pub(super) on_category_select: Box Message + 'a>, - pub(super) on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, -} - -impl<'a, SelectionMode, Item, Category, Message> - TableView<'a, SelectionMode, Item, Category, Message> -where - SelectionMode: Default, - Model: Selectable, - Category: ItemCategory, - Item: ItemInterface, -{ - pub fn new( - model: &'a Model, - on_selected: impl Fn(Entity) -> Message + 'a, - on_category_select: impl Fn(Category, bool) -> Message + 'a, - on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, - ) -> Self { - let cosmic_theme::Spacing { - space_xxxs, - space_xxs, - .. - } = theme::active().cosmic().spacing; - Self { - model, - spacing: 0, - padding: Padding::from(0), - divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), - list_item_padding: Padding::from(space_xxs).into(), - style: theme::Container::Background, - - on_selected: Box::new(on_selected), - on_category_select: Box::new(on_category_select), - on_option_hovered, - } - } - - pub fn spacing(mut self, spacing: u16) -> Self { - self.spacing = spacing; - self - } - - /// Sets the style variant of this [`Circular`]. - pub fn style(mut self, style: ::Class<'a>) -> Self { - self.style = style; - self - } - - pub fn padding(mut self, padding: impl Into) -> Self { - self.padding = padding.into(); - self - } - - pub fn divider_padding(mut self, padding: u16) -> Self { - self.divider_padding = Padding::from(0).left(padding).right(padding); - self - } - - pub fn list_item_padding(mut self, padding: impl Into) -> Self { - self.list_item_padding = padding.into(); - self - } - #[must_use] - pub fn into_element(self) -> Element<'a, Message> { - let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; - - crate::widget::column() - .push(widget::row::with_children( - self.model - .categories - .iter() - .map(|category| { - container( - widget::row() - .spacing(space_xxxs) - .push(widget::text::heading(category.to_string())) - .push_maybe(if self.model.sort.0 == *category { - match self.model.sort.1 { - true => { - Some(widget::icon::from_name("pan-up-symbolic").icon()) - } - false => Some( - widget::icon::from_name("pan-down-symbolic").icon(), - ), - } - } else { - None - }), - ) - .padding( - Padding::from(0) - .left(self.list_item_padding.left) - .right(self.list_item_padding.right), - ) - .width(category.width()) - .into() - }) - .collect(), - )) - .append(&mut if self.model.items.is_empty() { - vec![container(divider::horizontal::default()).padding(self.divider_padding)] - } else { - self.model - .order - .iter() - .map(|entity| { - let item = self.model.item(*entity).unwrap(); - let categories = &self.model.categories; - - vec![ - container(divider::horizontal::default()).padding(self.divider_padding), - container(widget::row::with_children( - categories - .iter() - .map(|category| { - container( - widget::row() - .push_maybe(item.get_icon(*category)) - .push(widget::text::body(item.get_text(*category))), - ) - .width(category.width()) - .align_y(Alignment::Center) - .padding(self.list_item_padding) - .apply(Element::from) - }) - .collect(), - )), - ] - }) - .flatten() - .collect() - }) - .spacing(self.spacing) - .padding(self.padding) - .apply(container) - .padding([self.spacing, 0]) - .class(self.style) - .width(iced::Length::Fill) - .into() - } -} diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs new file mode 100644 index 00000000000..02f62e3d3eb --- /dev/null +++ b/src/widget/table/widget/mod.rs @@ -0,0 +1,331 @@ +mod state; +use state::State; + +use super::model::{ + category::{ItemCategory, ItemInterface}, + selection::Selectable, + Entity, Model, +}; +use crate::{ + ext::CollectionWidget, + theme, + widget::{self, container, divider, menu}, + Apply, Element, +}; +use iced::{Alignment, Border, Length, Padding}; + +// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED + +#[must_use] +pub struct TableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + pub(super) model: &'a Model, + + pub(super) item_spacing: u16, + pub(super) element_padding: Padding, + pub(super) item_padding: Padding, + pub(super) divider_padding: Padding, + pub(super) item_context_tree: Option>>, + pub(super) category_context_tree: Option>>, + + pub(super) on_item_select: Option Message + 'a>>, + pub(super) on_item_context: Option Message + 'a>>, + pub(super) on_category_select: Option Message + 'a>>, + pub(super) on_category_context: Option Message + 'a>>, +} + +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, + Message: Clone + 'static, +{ + pub fn new(model: &'a Model) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::active().cosmic().spacing; + + Self { + model, + item_spacing: 0, + element_padding: Padding::from(0), + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + item_padding: Padding::from(space_xxs).into(), + on_item_select: None, + on_item_context: None, + item_context_tree: None, + on_category_select: None, + on_category_context: None, + category_context_tree: None, + } + } + + pub fn item_spacing(mut self, spacing: u16) -> Self { + self.item_spacing = spacing; + self + } + + pub fn element_padding(mut self, padding: impl Into) -> Self { + self.element_padding = padding.into(); + self + } + + pub fn divider_padding(mut self, padding: u16) -> Self { + self.divider_padding = Padding::from(0).left(padding).right(padding); + self + } + + pub fn item_padding(mut self, padding: impl Into) -> Self { + self.item_padding = padding.into(); + self + } + + pub fn on_item_select(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_select = Some(Box::new(on_select)); + self + } + + pub fn on_item_context(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_context = Some(Box::new(on_select)); + self + } + + pub fn item_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.item_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.item_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + + pub fn on_category_select(mut self, on_select: F) -> Self + where + F: Fn(Category, bool) -> Message + 'a, + { + self.on_category_select = Some(Box::new(on_select)); + self + } + + pub fn on_category_context(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'a, + { + self.on_category_context = Some(Box::new(on_select)); + self + } + + #[must_use] + pub fn element_standard(&self) -> Element<'a, Message> { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + + let header_row = self + .model + .categories + .iter() + .map(|category| { + widget::row() + .spacing(space_xxxs) + .push(widget::text::heading(category.to_string())) + .push_maybe(if let Some(sort) = self.model.sort { + if sort.0 == *category { + match sort.1 { + true => Some(widget::icon::from_name("pan-up-symbolic").icon()), + false => Some(widget::icon::from_name("pan-down-symbolic").icon()), + } + } else { + None + } + } else { + None + }) + .apply(container) + .padding( + Padding::default() + .left(self.item_padding.left) + .right(self.item_padding.right), + ) + .width(category.width()) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_category_select) = self.on_category_select { + mouse_area.on_press((on_category_select)( + *category, + if let Some(sort) = self.model.sort { + if sort.0 == *category { + !sort.1 + } else { + false + } + } else { + false + }, + )) + } else { + mouse_area + } + }) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::row::with_children) + .apply(Element::from); + let items_full = if self.model.items.is_empty() { + vec![divider::horizontal::default() + .apply(container) + .padding(self.divider_padding) + .apply(Element::from)] + } else { + self.model + .order + .iter() + .map(|entity| { + let item = self.model.item(*entity).unwrap(); + let categories = &self.model.categories; + let selected = self.model.is_active(*entity); + + vec![ + divider::horizontal::default() + .apply(container) + .padding(self.divider_padding) + .apply(Element::from), + categories + .iter() + .map(|category| { + widget::row() + .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) + .push(widget::text::body(item.get_text(*category))) + .align_y(Alignment::Center) + .apply(container) + .width(category.width()) + .align_y(Alignment::Center) + .padding(self.item_padding) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::row::with_children) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_item_select) = self.on_item_select { + mouse_area.on_press((on_item_select)(*entity)) + } else { + mouse_area + } + }) + .apply(container) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + } + })) + .apply(Element::from), + ] + }) + .flatten() + .collect::>>() + }; + vec![vec![header_row], items_full] + .into_iter() + .flatten() + .collect::>>() + .apply(widget::column::with_children) + .apply(Element::from) + } + + #[must_use] + pub fn element_compact(&self) -> Element<'a, Message> { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + self.model + .iter() + .map(|entity| { + let item = self.model.item(entity).unwrap(); + widget::column() + .push( + widget::divider::horizontal::default() + .apply(container) + .padding(self.divider_padding), + ) + .push( + widget::row() + .align_y(Alignment::Center) + .push_maybe( + item.get_icon(Category::default()).map(|icon| icon.size(48)), + ) + .push( + widget::column() + .push(widget::text::body(item.get_text(Category::default()))) + .push({ + let mut elements = self + .model + .categories + .iter() + .skip_while(|cat| **cat != Category::default()) + .map(|category| { + vec![ + widget::text::caption(item.get_text(*category)) + .apply(Element::from), + widget::text::caption("-").apply(Element::from), + ] + }) + .flatten() + .collect::>>(); + elements.pop(); + elements + .apply(widget::row::with_children) + .spacing(space_xxxs) + .wrap() + }), + ) + .apply(container) + .padding(self.item_padding), + ) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::column::with_children) + .apply(Element::from) + } +} diff --git a/src/widget/table/widget/state.rs b/src/widget/table/widget/state.rs new file mode 100644 index 00000000000..4d529db4020 --- /dev/null +++ b/src/widget/table/widget/state.rs @@ -0,0 +1,8 @@ +use slotmap::SecondaryMap; + +use crate::widget::table::Entity; + +pub struct State { + pub(super) num_items: usize, + pub(super) selected: SecondaryMap, +} From fc531b58c9c0ecaf928f0bdfdca5484f28883db4 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Mon, 30 Dec 2024 14:17:35 -0500 Subject: [PATCH 03/16] Added table view example --- examples/table-view/Cargo.toml | 15 +++ examples/table-view/src/main.rs | 200 ++++++++++++++++++++++++++++++++ src/widget/table/widget/mod.rs | 1 + 3 files changed, 216 insertions(+) create mode 100644 examples/table-view/Cargo.toml create mode 100644 examples/table-view/src/main.rs diff --git a/examples/table-view/Cargo.toml b/examples/table-view/Cargo.toml new file mode 100644 index 00000000000..ba3bd88e02e --- /dev/null +++ b/examples/table-view/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "table-view" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +tracing-log = "0.2.0" +chrono = "*" + +[dependencies.libcosmic] +features = ["debug", "multi-window", "wayland", "winit", "desktop", "tokio"] +path = "../.." +default-features = false diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs new file mode 100644 index 00000000000..b7cbb8ab9c6 --- /dev/null +++ b/examples/table-view/src/main.rs @@ -0,0 +1,200 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Table API example + +use chrono::Datelike; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced_core::Size; +use cosmic::widget::nav_bar; +use cosmic::widget::table; +use cosmic::{executor, iced, Element}; + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub enum Category { + #[default] + Name, + Date, + Size, +} + +impl std::fmt::Display for Category { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Name => "Name", + Self::Date => "Date", + Self::Size => "Size", + }) + } +} + +impl table::model::category::ItemCategory for Category { + fn width(&self) -> iced::Length { + match self { + Self::Name => iced::Length::Fill, + Self::Date => iced::Length::Fixed(200.0), + Self::Size => iced::Length::Fixed(150.0), + } + } +} + +struct Item { + name: String, + date: chrono::DateTime, + size: u64, +} + +impl Default for Item { + fn default() -> Self { + Self { + name: Default::default(), + date: Default::default(), + size: Default::default(), + } + } +} + +impl table::model::category::ItemInterface for Item { + fn get_icon(&self, category: Category) -> Option { + if category == Category::Name { + Some(cosmic::widget::icon::from_name("application-x-executable-symbolic").icon()) + } else { + None + } + } + + fn get_text(&self, category: Category) -> std::borrow::Cow<'static, str> { + match category { + Category::Name => self.name.clone().into(), + Category::Date => self.date.format("%Y/%m/%d").to_string().into(), + Category::Size => format!("{} items", self.size).into(), + } + } + + fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering { + match category { + Category::Name => self.name.to_lowercase().cmp(&other.name.to_lowercase()), + Category::Date => self.date.cmp(&other.date), + Category::Size => self.size.cmp(&other.size), + } + } +} + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let settings = Settings::default() + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + ItemSelect(table::Entity), + CategorySelect(Category, bool), +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + table_model: table::Model, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AppDemoTable"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _: Self::Flags) -> (Self, Task) { + let mut nav_model = nav_bar::Model::default(); + + nav_model.activate_position(0); + + let mut table_model = + table::Model::new(vec![Category::Name, Category::Date, Category::Size]); + + table_model.insert().item(Item { + name: "Foo".into(), + date: chrono::DateTime::default() + .with_day(1) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 2, + }); + table_model.insert().item(Item { + name: "Bar".into(), + date: chrono::DateTime::default() + .with_day(2) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 4, + }); + table_model.insert().item(Item { + name: "Baz".into(), + date: chrono::DateTime::default() + .with_day(3) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 12, + }); + + let app = App { core, table_model }; + + let command = Task::none(); + + (app, command) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::ItemSelect(entity) => self.table_model.activate(entity), + Message::CategorySelect(category, descending) => { + self.table_model.sort(category, descending) + } + } + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + table::TableView::new(&self.table_model) + .on_item_select(Message::ItemSelect) + .on_category_select(Message::CategorySelect) + .element_standard() + } +} diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 02f62e3d3eb..0cb826a4a57 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -215,6 +215,7 @@ where .iter() .map(|category| { widget::row() + .spacing(space_xxxs) .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) .push(widget::text::body(item.get_text(*category))) .align_y(Alignment::Center) From ad4ed38538579821d44a80d0147333585d075057 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Mon, 30 Dec 2024 14:37:27 -0500 Subject: [PATCH 04/16] Fixed table view issues with selection on compact view and some spacing problems --- examples/table-view/src/main.rs | 24 ++++++++---- src/widget/table/mod.rs | 15 +++++++- src/widget/table/widget/mod.rs | 67 ++++++++++++++++++++++++++------- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index b7cbb8ab9c6..f4c34070672 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -28,7 +28,7 @@ impl std::fmt::Display for Category { } } -impl table::model::category::ItemCategory for Category { +impl table::ItemCategory for Category { fn width(&self) -> iced::Length { match self { Self::Name => iced::Length::Fill, @@ -54,7 +54,7 @@ impl Default for Item { } } -impl table::model::category::ItemInterface for Item { +impl table::ItemInterface for Item { fn get_icon(&self, category: Category) -> Option { if category == Category::Name { Some(cosmic::widget::icon::from_name("application-x-executable-symbolic").icon()) @@ -104,7 +104,7 @@ pub enum Message { /// The [`App`] stores application-specific state. pub struct App { core: Core, - table_model: table::Model, + table_model: table::SingleSelectModel, } /// Implement [`cosmic::Application`] to integrate with COSMIC. @@ -192,9 +192,19 @@ impl cosmic::Application for App { /// Creates a view after each update. fn view(&self) -> Element { - table::TableView::new(&self.table_model) - .on_item_select(Message::ItemSelect) - .on_category_select(Message::CategorySelect) - .element_standard() + cosmic::widget::responsive(|size| { + if size.width < 600.0 { + table::SingleSelectTableView::new(&self.table_model) + .on_item_select(Message::ItemSelect) + .on_category_select(Message::CategorySelect) + .element_compact() + } else { + table::SingleSelectTableView::new(&self.table_model) + .on_item_select(Message::ItemSelect) + .on_category_select(Message::CategorySelect) + .element_standard() + } + }) + .into() } } diff --git a/src/widget/table/mod.rs b/src/widget/table/mod.rs index 9f4ce541889..594e73d2afe 100644 --- a/src/widget/table/mod.rs +++ b/src/widget/table/mod.rs @@ -2,6 +2,19 @@ //! pub mod model; -pub use model::{Entity, Model}; +pub use model::{ + category::ItemCategory, + category::ItemInterface, + selection::{MultiSelect, SingleSelect}, + Entity, Model, +}; pub mod widget; pub use widget::TableView; + +pub type SingleSelectTableView<'a, Item, Category, Message> = + TableView<'a, SingleSelect, Item, Category, Message>; +pub type SingleSelectModel = Model; + +pub type MultiSelectTableView<'a, Item, Category, Message> = + TableView<'a, MultiSelect, Item, Category, Message>; +pub type MultiSelectModel = Model; diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 0cb826a4a57..73cb60c361b 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -199,12 +199,11 @@ where .apply(Element::from)] } else { self.model - .order .iter() .map(|entity| { - let item = self.model.item(*entity).unwrap(); + let item = self.model.item(entity).unwrap(); let categories = &self.model.categories; - let selected = self.model.is_active(*entity); + let selected = self.model.is_active(entity); vec![ divider::horizontal::default() @@ -222,20 +221,12 @@ where .apply(container) .width(category.width()) .align_y(Alignment::Center) - .padding(self.item_padding) .apply(Element::from) }) .collect::>>() .apply(widget::row::with_children) - .apply(widget::mouse_area) - .apply(|mouse_area| { - if let Some(ref on_item_select) = self.on_item_select { - mouse_area.on_press((on_item_select)(*entity)) - } else { - mouse_area - } - }) .apply(container) + .padding(self.item_padding) .class(theme::Container::custom(move |theme| { widget::container::Style { icon_color: if selected { @@ -262,6 +253,14 @@ where shadow: Default::default(), } })) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_item_select) = self.on_item_select { + mouse_area.on_press((on_item_select)(entity)) + } else { + mouse_area + } + }) .apply(Element::from), ] }) @@ -273,6 +272,8 @@ where .flatten() .collect::>>() .apply(widget::column::with_children) + .spacing(self.item_spacing) + .padding(self.element_padding) .apply(Element::from) } @@ -283,7 +284,9 @@ where .iter() .map(|entity| { let item = self.model.item(entity).unwrap(); + let selected = self.model.is_active(entity); widget::column() + .spacing(self.item_spacing) .push( widget::divider::horizontal::default() .apply(container) @@ -291,6 +294,7 @@ where ) .push( widget::row() + .spacing(space_xxxs) .align_y(Alignment::Center) .push_maybe( item.get_icon(Category::default()).map(|icon| icon.size(48)), @@ -321,12 +325,49 @@ where }), ) .apply(container) - .padding(self.item_padding), + .padding(self.item_padding) + .width(iced::Length::Fill) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + } + })) + .apply(widget::mouse_area) + .apply(|ma| { + if let Some(on_item_select) = &self.on_item_select { + ma.on_press((on_item_select)(entity)) + } else { + ma + } + }), ) .apply(Element::from) }) .collect::>>() .apply(widget::column::with_children) + .spacing(self.item_spacing) + .padding(self.element_padding) .apply(Element::from) } } From 825792e36710818a65d57b2633cf374b3f231b6a Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Wed, 1 Jan 2025 18:04:12 -0800 Subject: [PATCH 05/16] Added custom widget version of table Currently does not have mouse interaction, in the demo the normal one is right and custom is left --- examples/table-view/src/main.rs | 53 +- src/widget/table/model/category.rs | 4 +- src/widget/table/widget/mod.rs | 770 ++++++++++++++++++++++++++--- src/widget/table/widget/state.rs | 17 +- 4 files changed, 759 insertions(+), 85 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index f4c34070672..2cb4bf0abdb 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -10,7 +10,7 @@ use cosmic::widget::nav_bar; use cosmic::widget::table; use cosmic::{executor, iced, Element}; -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)] pub enum Category { #[default] Name, @@ -29,11 +29,11 @@ impl std::fmt::Display for Category { } impl table::ItemCategory for Category { - fn width(&self) -> iced::Length { + fn width(&self) -> u16 { match self { - Self::Name => iced::Length::Fill, - Self::Date => iced::Length::Fixed(200.0), - Self::Size => iced::Length::Fixed(150.0), + Self::Name => 300, + Self::Date => 200, + Self::Size => 150, } } } @@ -63,11 +63,11 @@ impl table::ItemInterface for Item { } } - fn get_text(&self, category: Category) -> std::borrow::Cow<'static, str> { + fn get_text(&self, category: Category) -> Option { match category { - Category::Name => self.name.clone().into(), - Category::Date => self.date.format("%Y/%m/%d").to_string().into(), - Category::Size => format!("{} items", self.size).into(), + Category::Name => Some(self.name.clone()), + Category::Date => Some(self.date.format("%Y/%m/%d").to_string()), + Category::Size => Some(format!("{} items", self.size)), } } @@ -192,19 +192,30 @@ impl cosmic::Application for App { /// Creates a view after each update. fn view(&self) -> Element { - cosmic::widget::responsive(|size| { - if size.width < 600.0 { + // cosmic::widget::responsive(|size| { + // if size.width < 600.0 { + // table::SingleSelectTableView::new(&self.table_model) + // .on_item_select(Message::ItemSelect) + // .on_category_select(Message::CategorySelect) + // .element_compact() + // } else { + // table::SingleSelectTableView::new(&self.table_model) + // .on_item_select(Message::ItemSelect) + // .on_category_select(Message::CategorySelect) + // .element_standard() + // } + // }) + // .into() + cosmic::widget::row() + .push(Element::new( table::SingleSelectTableView::new(&self.table_model) - .on_item_select(Message::ItemSelect) - .on_category_select(Message::CategorySelect) - .element_compact() - } else { + .on_item_left(Message::ItemSelect), + )) + .push( table::SingleSelectTableView::new(&self.table_model) - .on_item_select(Message::ItemSelect) - .on_category_select(Message::CategorySelect) - .element_standard() - } - }) - .into() + .on_item_left(Message::ItemSelect) + .element_standard(), + ) + .into() } } diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs index efa4624a60e..94f9dee56e5 100644 --- a/src/widget/table/model/category.rs +++ b/src/widget/table/model/category.rs @@ -6,12 +6,12 @@ use crate::widget::Icon; /// Ideally, this is implemented on an enum. pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq { /// Function that gets the width of the data - fn width(&self) -> iced::Length; + fn width(&self) -> u16; } pub trait ItemInterface: Default { fn get_icon(&self, category: Category) -> Option; - fn get_text(&self, category: Category) -> Cow<'static, str>; + fn get_text(&self, category: Category) -> Option; fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering; } diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 73cb60c361b..8236d92b20a 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -1,4 +1,24 @@ mod state; +use std::{ + collections::HashMap, + hash::{DefaultHasher, Hash, Hasher}, +}; + +use iced_core::{ + image::{self, Renderer as ImageRenderer}, + Layout, +}; +use iced_core::{layout, text::Renderer as TextRenderer}; +use iced_core::{renderer::Quad, Renderer as IcedRenderer}; + +use derive_setters::Setters; +use iced_core::{ + text::{LineHeight, Shaping, Wrapping}, + widget::{tree, Tree}, + Text, Widget, +}; +use palette::named::BLACK; +use slotmap::SecondaryMap; use state::State; use super::model::{ @@ -9,46 +29,216 @@ use super::model::{ use crate::{ ext::CollectionWidget, theme, - widget::{self, container, divider, menu}, + widget::{ + self, container, divider, + dnd_destination::DragId, + menu::{self, menu_roots_children, menu_roots_diff, MenuBarState}, + }, Apply, Element, }; -use iced::{Alignment, Border, Length, Padding}; +use iced::{ + alignment, + clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, + Alignment, Background, Border, Length, Padding, Point, Rectangle, Size, Vector, +}; // THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED +#[derive(Setters)] #[must_use] pub struct TableView<'a, SelectionMode, Item, Category, Message> where - Category: ItemCategory, + Category: ItemCategory + Hash + 'static, Item: ItemInterface, Model: Selectable, SelectionMode: Default, Message: Clone + 'static, { + /// iced widget ID + pub(super) id: widget::Id, + /// The table model + #[setters(skip)] pub(super) model: &'a Model, + // === Element Layout === + /// Desired width of the widget. + pub(super) width: Length, + /// Desired height of the widget. + pub(super) height: Length, + /// Spacing between items and the dividers pub(super) item_spacing: u16, + /// Spacing between text and icons in items + pub(super) icon_spacing: u16, + /// Size of the icon + pub(super) icon_size: u16, + /// The size of a single indent + pub(super) indent_spacing: u16, + /// The padding for the entire table + #[setters(into)] pub(super) element_padding: Padding, + /// The padding for each item + #[setters(into)] pub(super) item_padding: Padding, + /// the horizontal padding for each divider + #[setters(into)] pub(super) divider_padding: Padding, - pub(super) item_context_tree: Option>>, - pub(super) category_context_tree: Option>>, + /// Size of the font. + pub(super) font_size: f32, + + /// The context tree to show on right clicking an item + #[setters(skip)] + pub(super) item_context_tree: Option>>, + /// The context tree to show on right clicking a category + #[setters(skip)] + pub(super) category_context_tree: Option>>, + + // === Item Mouse Events === + /// Message to emit when the user left clicks on a table item + #[setters(skip)] + pub(super) on_item_left: Option Message + 'a>>, + /// Message to emit when the user double clicks on a table item + #[setters(skip)] + pub(super) on_item_double: Option Message + 'a>>, + /// Message to emit when the user middle clicks on a table item + #[setters(skip)] + pub(super) on_item_middle: Option Message + 'a>>, + /// Message to emit when the user right clicks on a table item + #[setters(skip)] + pub(super) on_item_right: Option Message + 'a>>, - pub(super) on_item_select: Option Message + 'a>>, - pub(super) on_item_context: Option Message + 'a>>, - pub(super) on_category_select: Option Message + 'a>>, + // === Category Mouse Events === + /// Message to emit when the user clicks on a category + #[setters(skip)] + pub(super) on_category_select: Option Message + 'a>>, + /// Message to emit when the user right clicks on a category + #[setters(skip)] pub(super) on_category_context: Option Message + 'a>>, + + // === Drag n Drop === + /// Message to emit on the DND drop event + #[setters(skip)] + pub(super) on_dnd_drop: + Option, String, DndAction) -> Message + 'static>>, + /// MIME Types for the Drag n Drop + pub(super) mimes: Vec, + /// Message to emit on the DND enter event + #[setters(skip)] + pub(super) on_dnd_enter: Option) -> Message + 'static>>, + /// Message to emit on the DND leave event + #[setters(skip)] + pub(super) on_dnd_leave: Option Message + 'static>>, + /// The Drag ID of the table + #[setters(strip_option)] + pub(super) drag_id: Option, +} + +// PRIVATE INTERFACE +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory + Hash + 'static, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + fn max_item_dimensions( + &self, + state: &mut State, + renderer: &crate::Renderer, + ) -> (f32, f32) { + let mut width = 0.0f32; + let mut height = 0.0f32; + let font = renderer.default_font(); + + for key in self.model.iter() { + let (button_width, button_height) = self.item_dimensions(state, font, key); + + state + .item_layout + .push(Size::new(button_width, button_height)); + + height = height.max(button_height); + width = width.max(button_width); + } + + for size in &mut state.item_layout { + size.height = height; + } + + (width, height) + } + + fn item_dimensions( + &self, + state: &mut State, + font: crate::font::Font, + id: Entity, + ) -> (f32, f32) { + let mut width = 0f32; + let mut height = 0f32; + let mut icon_spacing = 0f32; + + if let Some((item, paragraphs)) = self.model.item(id).zip(state.paragraphs.entry(id)) { + let paragraphs = paragraphs.or_default(); + for category in self.model.categories.iter().copied() { + if let Some(text) = item.get_text(category) { + if !text.is_empty() { + icon_spacing = f32::from(self.icon_spacing); + let paragraph = if let Some(entry) = paragraphs.get(&category) { + entry.clone() + } else { + crate::Plain::new(Text { + content: text.as_str(), + size: iced::Pixels(self.font_size), + bounds: Size::INFINITY + .apply(|inf| Size::new(category.width() as f32, inf.height)), + font, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::default(), + line_height: LineHeight::default(), + }) + }; + + let size = paragraph.min_bounds(); + width += size.width; + height = height.max(size.height); + } + } + // Add icon to measurement if icon was given. + if let Some(_) = item.get_icon(category) { + width += f32::from(self.icon_size) + icon_spacing; + height = height.max(f32::from(self.icon_size)); + } + } + } + + // Add indent to measurement if found. + if let Some(indent) = self.model.indent(id) { + width = f32::from(indent).mul_add(f32::from(self.indent_spacing), width); + } + + // Add button padding to the max size found + width += f32::from(self.item_padding.left) + f32::from(self.item_padding.right); + height += f32::from(self.item_padding.top) + f32::from(self.item_padding.top); + + (width, height) + } } +// PUBLIC INTERFACE impl<'a, SelectionMode, Item, Category, Message> TableView<'a, SelectionMode, Item, Category, Message> where SelectionMode: Default, Model: Selectable, - Category: ItemCategory, + Category: ItemCategory + Hash + 'static, Item: ItemInterface, Message: Clone + 'static, { + /// Creates a new table view with the given model pub fn new(model: &'a Model) -> Self { let cosmic_theme::Spacing { space_xxxs, @@ -57,53 +247,67 @@ where } = theme::active().cosmic().spacing; Self { + id: widget::Id::unique(), model, + width: Length::Fill, + height: Length::Shrink, item_spacing: 0, element_padding: Padding::from(0), divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), item_padding: Padding::from(space_xxs).into(), - on_item_select: None, - on_item_context: None, + icon_spacing: space_xxxs, + icon_size: 24, + indent_spacing: space_xxs, + font_size: 14.0, item_context_tree: None, + category_context_tree: None, on_category_select: None, on_category_context: None, - category_context_tree: None, + on_item_left: None, + on_item_double: None, + on_item_middle: None, + on_item_right: None, + on_dnd_drop: None, + mimes: Vec::new(), + on_dnd_enter: None, + on_dnd_leave: None, + drag_id: None, } } - pub fn item_spacing(mut self, spacing: u16) -> Self { - self.item_spacing = spacing; - self - } - - pub fn element_padding(mut self, padding: impl Into) -> Self { - self.element_padding = padding.into(); - self - } - - pub fn divider_padding(mut self, padding: u16) -> Self { - self.divider_padding = Padding::from(0).left(padding).right(padding); + /// Sets the message to be emitted on left click + pub fn on_item_left(mut self, on_left: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_left = Some(Box::new(on_left)); self } - pub fn item_padding(mut self, padding: impl Into) -> Self { - self.item_padding = padding.into(); + /// Sets the message to be emitted on double click + pub fn on_item_double(mut self, on_double: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_double = Some(Box::new(on_double)); self } - pub fn on_item_select(mut self, on_select: F) -> Self + /// Sets the message to be emitted on middle click + pub fn on_item_middle(mut self, on_middle: F) -> Self where F: Fn(Entity) -> Message + 'a, { - self.on_item_select = Some(Box::new(on_select)); + self.on_item_middle = Some(Box::new(on_middle)); self } - pub fn on_item_context(mut self, on_select: F) -> Self + /// Sets the message to be emitted on right click + pub fn on_item_right(mut self, on_right: F) -> Self where F: Fn(Entity) -> Message + 'a, { - self.on_item_context = Some(Box::new(on_select)); + self.on_item_right = Some(Box::new(on_right)); self } @@ -123,7 +327,7 @@ where pub fn on_category_select(mut self, on_select: F) -> Self where - F: Fn(Category, bool) -> Message + 'a, + F: Fn(Category) -> Message + 'a, { self.on_category_select = Some(Box::new(on_select)); self @@ -137,6 +341,448 @@ where self } + /// Handle the dnd drop event. + pub fn on_dnd_drop( + mut self, + dnd_drop_handler: impl Fn(Entity, Option, DndAction) -> Message + 'static, + ) -> Self { + self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| { + dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action) + })); + self.mimes = D::allowed().iter().cloned().collect(); + self + } + + /// Handle the dnd enter event. + pub fn on_dnd_enter( + mut self, + dnd_enter_handler: impl Fn(Entity, Vec) -> Message + 'static, + ) -> Self { + self.on_dnd_enter = Some(Box::new(dnd_enter_handler)); + self + } + + /// Handle the dnd leave event. + pub fn on_dnd_leave(mut self, dnd_leave_handler: impl Fn(Entity) -> Message + 'static) -> Self { + self.on_dnd_leave = Some(Box::new(dnd_leave_handler)); + self + } +} + +// Widget implementation +impl<'a, SelectionMode, Item, Category, Message> Widget + for TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory + Hash + 'static, + Item: ItemInterface, + Message: Clone + 'static, +{ + fn children(&self) -> Vec { + let mut children = Vec::new(); + + // Item context tree + if let Some(ref item_context) = self.item_context_tree { + let mut tree = Tree::empty(); + tree.state = tree::State::new(MenuBarState::default()); + tree.children = menu_roots_children(&item_context); + children.push(tree); + } + + // Category context tree + if let Some(ref category_context) = self.category_context_tree { + let mut tree = Tree::empty(); + tree.state = tree::State::new(MenuBarState::default()); + tree.children = menu_roots_children(&category_context); + children.push(tree); + } + + children + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + #[allow(clippy::default_trait_access)] + tree::State::new(State:: { + num_items: self.model.order.len(), + selected: self.model.active.clone(), + paragraphs: SecondaryMap::new(), + item_layout: Vec::new(), + text_hashes: SecondaryMap::new(), + + sort_hash: HashMap::new(), + header_paragraphs: HashMap::new(), + category_layout: Vec::new(), + }) + } + + fn diff(&mut self, tree: &mut Tree) { + let state = tree.state.downcast_mut::>(); + + let sort_state = self.model.sort; + let mut hasher = DefaultHasher::new(); + sort_state.hash(&mut hasher); + let cat_hash = hasher.finish(); + for category in self.model.categories.iter().copied() { + if let Some(prev_hash) = state.sort_hash.insert(category, cat_hash) { + if prev_hash == cat_hash { + continue; + } + } + + let category_name = category.to_string(); + let text = Text { + content: category_name.as_str(), + size: iced::Pixels(self.font_size), + bounds: Size::INFINITY, + font: crate::font::bold(), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::default(), + line_height: LineHeight::default(), + }; + + if let Some(header) = state.header_paragraphs.get_mut(&category) { + header.update(text); + } else { + state + .header_paragraphs + .insert(category, crate::Plain::new(text)); + } + } + + for key in self.model.iter() { + if let Some(item) = self.model.item(key) { + // TODO: add hover support if design approves it + let button_state = self.model.is_active(key); + + let mut hasher = DefaultHasher::new(); + button_state.hash(&mut hasher); + // hash each text + for category in self.model.categories.iter().copied() { + let text = item.get_text(category); + + text.hash(&mut hasher); + } + let text_hash = hasher.finish(); + + if let Some(prev_hash) = state.text_hashes.insert(key, text_hash) { + // If the text didn't change, don't update the paragraph + if prev_hash == text_hash { + continue; + } + } + + state.selected.insert(key, button_state); + + // Update the paragraph if the text changed + for category in self.model.categories.iter().copied() { + if let Some(text) = item.get_text(category) { + let text = Text { + content: text.as_ref(), + size: iced::Pixels(self.font_size), + bounds: Size::INFINITY, + font: crate::font::default(), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::default(), + line_height: LineHeight::default(), + }; + + if let Some(item) = state.paragraphs.get_mut(key) { + if let Some(paragraph) = item.get_mut(&category) { + paragraph.update(text); + } else { + item.insert(category, crate::Plain::new(text)); + } + } else { + let mut hm = HashMap::new(); + hm.insert(category, crate::Plain::new(text)); + state.paragraphs.insert(key, hm); + } + } + } + } + } + + // BUG: IF THE CATEGORY_CONTEXT AND ITEM_CONTEXT ARE CLEARED AND A DIFFERENT ONE IS SET, IT BREAKS + // Diff the item context menu + if let Some(ref mut item_context) = self.item_context_tree { + if let Some(ref mut category_context) = self.category_context_tree { + if tree.children.is_empty() { + let mut child_tree = Tree::empty(); + child_tree.state = tree::State::new(MenuBarState::default()); + tree.children.push(child_tree); + + let mut child_tree = Tree::empty(); + child_tree.state = tree::State::new(MenuBarState::default()); + tree.children.push(child_tree); + } else { + tree.children.truncate(2); + } + menu_roots_diff(item_context, &mut tree.children[0]); + menu_roots_diff(category_context, &mut tree.children[1]); + } else { + if tree.children.is_empty() { + let mut child_tree = Tree::empty(); + child_tree.state = tree::State::new(MenuBarState::default()); + tree.children.push(child_tree); + } else { + tree.children.truncate(1); + } + menu_roots_diff(item_context, &mut tree.children[0]); + } + } else { + if let Some(ref mut category_context) = self.category_context_tree { + if tree.children.is_empty() { + let mut child_tree = Tree::empty(); + child_tree.state = tree::State::new(MenuBarState::default()); + tree.children.push(child_tree); + } else { + tree.children.truncate(1); + } + menu_roots_diff(category_context, &mut tree.children[0]); + } else { + tree.children.clear(); + } + } + } + + fn size(&self) -> iced::Size { + Size::new(self.width, self.height) + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &crate::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let state = tree.state.downcast_mut::>(); + let size = { + state.item_layout.clear(); + state.category_layout.clear(); + let limits = limits.width(self.width); + + for category in self.model.categories.iter().copied() { + state + .category_layout + .push(Size::new(category.width() as f32, 20.0)); + } + + let (width, item_height) = self.max_item_dimensions(state, renderer); + + for size in &mut state.item_layout { + size.width = width.max(limits.max().width); + } + + let spacing = f32::from(self.item_spacing); + let mut height = state.category_layout[0].height; + for _ in self.model.iter() { + height += spacing + 1.0 + spacing; + height += item_height; + } + + limits.height(Length::Fixed(height)).resolve( + self.width, + self.height, + Size::new(width, height), + ) + }; + layout::Node::new(size) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &iced_core::renderer::Style, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced::Rectangle, + ) { + let state = tree.state.downcast_ref::>(); + let cosmic = theme.cosmic(); + let accent = theme::active().cosmic().accent_color(); + + let bounds = layout.bounds().shrink(self.element_padding); + + let header_quad = Rectangle::new(bounds.position(), Size::new(bounds.width, 20.0)).shrink( + Padding::new(0.0) + .left(self.item_padding.left) + .right(self.item_padding.right), + ); + + // TODO: HEADER TEXT + let mut category_origin = header_quad.position(); + for (nth, category) in self.model.categories.iter().copied().enumerate() { + let category_quad = Rectangle::new(category_origin, state.category_layout[nth]); + if let Some(category_quad) = category_quad.intersection(&bounds) { + renderer.fill_paragraph( + state.header_paragraphs.get(&category).unwrap().raw(), + Point::new(category_quad.position().x, category_quad.center_y()), + cosmic.on_bg_color().into(), + category_quad, + ); + } else { + break; + } + + category_origin.x += category_quad.width; + } + + let body_quad = Rectangle::new( + layout.position() + Vector::new(0.0, 20.0), + Size::new(bounds.width, bounds.height - 20.0), + ); + + if self.model.order.is_empty() { + let divider_quad = layout + .bounds() + .shrink(self.divider_padding) + .apply(|mut dq| { + dq.height = 1.0; + dq + }); + + // If empty, draw a single divider and quit + renderer.fill_quad( + Quad { + bounds: divider_quad, + border: Default::default(), + shadow: Default::default(), + }, + Background::Color(cosmic.bg_divider().into()), + ); + } else { + let mut divider_quad = body_quad.shrink(self.divider_padding).apply(|mut dq| { + dq.height = 1.0; + dq + }); + let mut item_quad = body_quad.shrink(self.element_padding); + for (nth, entity) in self.model.iter().enumerate() { + // draw divider above + + renderer.fill_quad( + Quad { + bounds: divider_quad, + border: Default::default(), + shadow: Default::default(), + }, + Background::Color(cosmic.bg_divider().into()), + ); + + divider_quad.y += 1.0; + item_quad.y += 1.0; + item_quad.width = state.item_layout[nth].width; + item_quad.height = state.item_layout[nth].height; + + if state.selected.get(entity).copied().unwrap_or(false) { + renderer.fill_quad( + Quad { + bounds: item_quad, + border: Border { + color: iced::Color::TRANSPARENT, + width: 0.0, + radius: cosmic.radius_xs().into(), + }, + shadow: Default::default(), + }, + Background::Color(accent.into()), + ); + } + + let content_quad = item_quad.clone().shrink(self.item_padding); + let mut item_content_quad = content_quad.clone(); + if let Some(item) = self.model.item(entity) { + for (nth, category) in self.model.categories.iter().copied().enumerate() { + // TODO: Icons and text + let mut icon_spacing = 0; + if let Some((_, _)) = item.get_icon(category).zip(item.get_text(category)) { + icon_spacing = self.icon_spacing; + } + + item_content_quad.width = state.category_layout[nth].width; + + if let Some(mut item_content_quad) = + item_content_quad.intersection(&content_quad) + { + let mut offset = Point::::default(); + if let Some(icon) = item.get_icon(category) { + let layout_node = layout::Node::new(Size { + width: self.icon_size as f32, + height: self.icon_size as f32, + }) + .move_to(Point { + x: item_content_quad.x, + y: item_content_quad.y, + }); + Widget::::draw( + Element::::from(icon.clone()).as_widget(), + &Tree::empty(), + renderer, + theme, + style, + Layout::new(&layout_node), + cursor, + viewport, + ); + offset.x += self.icon_size as f32; + } + + if let Some(text) = item.get_text(category) { + offset.x += icon_spacing as f32; + + item_content_quad.x = item_content_quad.x + offset.x; + item_content_quad.width -= offset.x; + + renderer.fill_paragraph( + state + .paragraphs + .get(entity) + .unwrap() + .get(&category) + .unwrap() + .raw(), + Point::new( + item_content_quad.x, + item_content_quad.y + item_content_quad.height / 2.0, + ), + cosmic.on_bg_color().into(), + item_content_quad, + ); + } + } else { + break; + } + + item_content_quad.x += state.category_layout[nth].width; + } + } + + item_quad.y += state.item_layout[nth].height; + divider_quad.y += item_quad.height; + } + } + } +} + +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory + Hash + 'static, + Item: ItemInterface, + Message: Clone + 'static, +{ #[must_use] pub fn element_standard(&self) -> Element<'a, Message> { let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; @@ -145,12 +791,13 @@ where .model .categories .iter() + .copied() .map(|category| { widget::row() .spacing(space_xxxs) .push(widget::text::heading(category.to_string())) .push_maybe(if let Some(sort) = self.model.sort { - if sort.0 == *category { + if sort.0 == category { match sort.1 { true => Some(widget::icon::from_name("pan-up-symbolic").icon()), false => Some(widget::icon::from_name("pan-down-symbolic").icon()), @@ -162,27 +809,11 @@ where None }) .apply(container) - .padding( - Padding::default() - .left(self.item_padding.left) - .right(self.item_padding.right), - ) .width(category.width()) .apply(widget::mouse_area) .apply(|mouse_area| { if let Some(ref on_category_select) = self.on_category_select { - mouse_area.on_press((on_category_select)( - *category, - if let Some(sort) = self.model.sort { - if sort.0 == *category { - !sort.1 - } else { - false - } - } else { - false - }, - )) + mouse_area.on_press((on_category_select)(category)) } else { mouse_area } @@ -191,6 +822,11 @@ where }) .collect::>>() .apply(widget::row::with_children) + .padding( + Padding::default() + .left(self.item_padding.left) + .right(self.item_padding.right), + ) .apply(Element::from); let items_full = if self.model.items.is_empty() { vec![divider::horizontal::default() @@ -214,9 +850,15 @@ where .iter() .map(|category| { widget::row() - .spacing(space_xxxs) - .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) - .push(widget::text::body(item.get_text(*category))) + .spacing(self.icon_spacing) + .push_maybe( + item.get_icon(*category) + .map(|icon| icon.size(self.icon_size)), + ) + .push_maybe( + item.get_text(*category) + .map(|text| widget::text::body(text)), + ) .align_y(Alignment::Center) .apply(container) .width(category.width()) @@ -255,7 +897,7 @@ where })) .apply(widget::mouse_area) .apply(|mouse_area| { - if let Some(ref on_item_select) = self.on_item_select { + if let Some(ref on_item_select) = self.on_item_left { mouse_area.on_press((on_item_select)(entity)) } else { mouse_area @@ -301,7 +943,10 @@ where ) .push( widget::column() - .push(widget::text::body(item.get_text(Category::default()))) + .push_maybe( + item.get_text(Category::default()) + .map(|text| widget::text::body(text)), + ) .push({ let mut elements = self .model @@ -309,11 +954,16 @@ where .iter() .skip_while(|cat| **cat != Category::default()) .map(|category| { - vec![ - widget::text::caption(item.get_text(*category)) - .apply(Element::from), - widget::text::caption("-").apply(Element::from), - ] + item.get_text(*category) + .map(|text| { + vec![ + widget::text::caption(text) + .apply(Element::from), + widget::text::caption("-") + .apply(Element::from), + ] + }) + .unwrap_or_default() }) .flatten() .collect::>>(); @@ -355,7 +1005,7 @@ where })) .apply(widget::mouse_area) .apply(|ma| { - if let Some(on_item_select) = &self.on_item_select { + if let Some(on_item_select) = &self.on_item_left { ma.on_press((on_item_select)(entity)) } else { ma diff --git a/src/widget/table/widget/state.rs b/src/widget/table/widget/state.rs index 4d529db4020..82fb88db3d0 100644 --- a/src/widget/table/widget/state.rs +++ b/src/widget/table/widget/state.rs @@ -1,8 +1,21 @@ +use std::collections::HashMap; + +use iced::Size; use slotmap::SecondaryMap; -use crate::widget::table::Entity; +use crate::widget::table::{Entity, ItemCategory}; -pub struct State { +pub struct State +where + Category: ItemCategory + 'static, +{ pub(super) num_items: usize, pub(super) selected: SecondaryMap, + pub(super) paragraphs: SecondaryMap>, + pub(super) text_hashes: SecondaryMap, + pub(super) item_layout: Vec, + + pub(super) sort_hash: HashMap, + pub(super) header_paragraphs: HashMap, + pub(super) category_layout: Vec, } From dd9d1c0c2ff0891e9ac9ea12f608123775c0cb67 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Thu, 2 Jan 2025 22:29:00 -0800 Subject: [PATCH 06/16] Revert "Added custom widget version of table" This reverts commit 825792e36710818a65d57b2633cf374b3f231b6a. --- examples/table-view/src/main.rs | 53 +- src/widget/table/model/category.rs | 4 +- src/widget/table/widget/mod.rs | 770 +++-------------------------- src/widget/table/widget/state.rs | 17 +- 4 files changed, 85 insertions(+), 759 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index 2cb4bf0abdb..f4c34070672 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -10,7 +10,7 @@ use cosmic::widget::nav_bar; use cosmic::widget::table; use cosmic::{executor, iced, Element}; -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] pub enum Category { #[default] Name, @@ -29,11 +29,11 @@ impl std::fmt::Display for Category { } impl table::ItemCategory for Category { - fn width(&self) -> u16 { + fn width(&self) -> iced::Length { match self { - Self::Name => 300, - Self::Date => 200, - Self::Size => 150, + Self::Name => iced::Length::Fill, + Self::Date => iced::Length::Fixed(200.0), + Self::Size => iced::Length::Fixed(150.0), } } } @@ -63,11 +63,11 @@ impl table::ItemInterface for Item { } } - fn get_text(&self, category: Category) -> Option { + fn get_text(&self, category: Category) -> std::borrow::Cow<'static, str> { match category { - Category::Name => Some(self.name.clone()), - Category::Date => Some(self.date.format("%Y/%m/%d").to_string()), - Category::Size => Some(format!("{} items", self.size)), + Category::Name => self.name.clone().into(), + Category::Date => self.date.format("%Y/%m/%d").to_string().into(), + Category::Size => format!("{} items", self.size).into(), } } @@ -192,30 +192,19 @@ impl cosmic::Application for App { /// Creates a view after each update. fn view(&self) -> Element { - // cosmic::widget::responsive(|size| { - // if size.width < 600.0 { - // table::SingleSelectTableView::new(&self.table_model) - // .on_item_select(Message::ItemSelect) - // .on_category_select(Message::CategorySelect) - // .element_compact() - // } else { - // table::SingleSelectTableView::new(&self.table_model) - // .on_item_select(Message::ItemSelect) - // .on_category_select(Message::CategorySelect) - // .element_standard() - // } - // }) - // .into() - cosmic::widget::row() - .push(Element::new( + cosmic::widget::responsive(|size| { + if size.width < 600.0 { table::SingleSelectTableView::new(&self.table_model) - .on_item_left(Message::ItemSelect), - )) - .push( + .on_item_select(Message::ItemSelect) + .on_category_select(Message::CategorySelect) + .element_compact() + } else { table::SingleSelectTableView::new(&self.table_model) - .on_item_left(Message::ItemSelect) - .element_standard(), - ) - .into() + .on_item_select(Message::ItemSelect) + .on_category_select(Message::CategorySelect) + .element_standard() + } + }) + .into() } } diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs index 94f9dee56e5..efa4624a60e 100644 --- a/src/widget/table/model/category.rs +++ b/src/widget/table/model/category.rs @@ -6,12 +6,12 @@ use crate::widget::Icon; /// Ideally, this is implemented on an enum. pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq { /// Function that gets the width of the data - fn width(&self) -> u16; + fn width(&self) -> iced::Length; } pub trait ItemInterface: Default { fn get_icon(&self, category: Category) -> Option; - fn get_text(&self, category: Category) -> Option; + fn get_text(&self, category: Category) -> Cow<'static, str>; fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering; } diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 8236d92b20a..73cb60c361b 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -1,24 +1,4 @@ mod state; -use std::{ - collections::HashMap, - hash::{DefaultHasher, Hash, Hasher}, -}; - -use iced_core::{ - image::{self, Renderer as ImageRenderer}, - Layout, -}; -use iced_core::{layout, text::Renderer as TextRenderer}; -use iced_core::{renderer::Quad, Renderer as IcedRenderer}; - -use derive_setters::Setters; -use iced_core::{ - text::{LineHeight, Shaping, Wrapping}, - widget::{tree, Tree}, - Text, Widget, -}; -use palette::named::BLACK; -use slotmap::SecondaryMap; use state::State; use super::model::{ @@ -29,216 +9,46 @@ use super::model::{ use crate::{ ext::CollectionWidget, theme, - widget::{ - self, container, divider, - dnd_destination::DragId, - menu::{self, menu_roots_children, menu_roots_diff, MenuBarState}, - }, + widget::{self, container, divider, menu}, Apply, Element, }; -use iced::{ - alignment, - clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, - Alignment, Background, Border, Length, Padding, Point, Rectangle, Size, Vector, -}; +use iced::{Alignment, Border, Length, Padding}; // THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED -#[derive(Setters)] #[must_use] pub struct TableView<'a, SelectionMode, Item, Category, Message> where - Category: ItemCategory + Hash + 'static, + Category: ItemCategory, Item: ItemInterface, Model: Selectable, SelectionMode: Default, Message: Clone + 'static, { - /// iced widget ID - pub(super) id: widget::Id, - /// The table model - #[setters(skip)] pub(super) model: &'a Model, - // === Element Layout === - /// Desired width of the widget. - pub(super) width: Length, - /// Desired height of the widget. - pub(super) height: Length, - /// Spacing between items and the dividers pub(super) item_spacing: u16, - /// Spacing between text and icons in items - pub(super) icon_spacing: u16, - /// Size of the icon - pub(super) icon_size: u16, - /// The size of a single indent - pub(super) indent_spacing: u16, - /// The padding for the entire table - #[setters(into)] pub(super) element_padding: Padding, - /// The padding for each item - #[setters(into)] pub(super) item_padding: Padding, - /// the horizontal padding for each divider - #[setters(into)] pub(super) divider_padding: Padding, - /// Size of the font. - pub(super) font_size: f32, - - /// The context tree to show on right clicking an item - #[setters(skip)] - pub(super) item_context_tree: Option>>, - /// The context tree to show on right clicking a category - #[setters(skip)] - pub(super) category_context_tree: Option>>, - - // === Item Mouse Events === - /// Message to emit when the user left clicks on a table item - #[setters(skip)] - pub(super) on_item_left: Option Message + 'a>>, - /// Message to emit when the user double clicks on a table item - #[setters(skip)] - pub(super) on_item_double: Option Message + 'a>>, - /// Message to emit when the user middle clicks on a table item - #[setters(skip)] - pub(super) on_item_middle: Option Message + 'a>>, - /// Message to emit when the user right clicks on a table item - #[setters(skip)] - pub(super) on_item_right: Option Message + 'a>>, + pub(super) item_context_tree: Option>>, + pub(super) category_context_tree: Option>>, - // === Category Mouse Events === - /// Message to emit when the user clicks on a category - #[setters(skip)] - pub(super) on_category_select: Option Message + 'a>>, - /// Message to emit when the user right clicks on a category - #[setters(skip)] + pub(super) on_item_select: Option Message + 'a>>, + pub(super) on_item_context: Option Message + 'a>>, + pub(super) on_category_select: Option Message + 'a>>, pub(super) on_category_context: Option Message + 'a>>, - - // === Drag n Drop === - /// Message to emit on the DND drop event - #[setters(skip)] - pub(super) on_dnd_drop: - Option, String, DndAction) -> Message + 'static>>, - /// MIME Types for the Drag n Drop - pub(super) mimes: Vec, - /// Message to emit on the DND enter event - #[setters(skip)] - pub(super) on_dnd_enter: Option) -> Message + 'static>>, - /// Message to emit on the DND leave event - #[setters(skip)] - pub(super) on_dnd_leave: Option Message + 'static>>, - /// The Drag ID of the table - #[setters(strip_option)] - pub(super) drag_id: Option, -} - -// PRIVATE INTERFACE -impl<'a, SelectionMode, Item, Category, Message> - TableView<'a, SelectionMode, Item, Category, Message> -where - Category: ItemCategory + Hash + 'static, - Item: ItemInterface, - Model: Selectable, - SelectionMode: Default, - Message: Clone + 'static, -{ - fn max_item_dimensions( - &self, - state: &mut State, - renderer: &crate::Renderer, - ) -> (f32, f32) { - let mut width = 0.0f32; - let mut height = 0.0f32; - let font = renderer.default_font(); - - for key in self.model.iter() { - let (button_width, button_height) = self.item_dimensions(state, font, key); - - state - .item_layout - .push(Size::new(button_width, button_height)); - - height = height.max(button_height); - width = width.max(button_width); - } - - for size in &mut state.item_layout { - size.height = height; - } - - (width, height) - } - - fn item_dimensions( - &self, - state: &mut State, - font: crate::font::Font, - id: Entity, - ) -> (f32, f32) { - let mut width = 0f32; - let mut height = 0f32; - let mut icon_spacing = 0f32; - - if let Some((item, paragraphs)) = self.model.item(id).zip(state.paragraphs.entry(id)) { - let paragraphs = paragraphs.or_default(); - for category in self.model.categories.iter().copied() { - if let Some(text) = item.get_text(category) { - if !text.is_empty() { - icon_spacing = f32::from(self.icon_spacing); - let paragraph = if let Some(entry) = paragraphs.get(&category) { - entry.clone() - } else { - crate::Plain::new(Text { - content: text.as_str(), - size: iced::Pixels(self.font_size), - bounds: Size::INFINITY - .apply(|inf| Size::new(category.width() as f32, inf.height)), - font, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: Shaping::Advanced, - wrapping: Wrapping::default(), - line_height: LineHeight::default(), - }) - }; - - let size = paragraph.min_bounds(); - width += size.width; - height = height.max(size.height); - } - } - // Add icon to measurement if icon was given. - if let Some(_) = item.get_icon(category) { - width += f32::from(self.icon_size) + icon_spacing; - height = height.max(f32::from(self.icon_size)); - } - } - } - - // Add indent to measurement if found. - if let Some(indent) = self.model.indent(id) { - width = f32::from(indent).mul_add(f32::from(self.indent_spacing), width); - } - - // Add button padding to the max size found - width += f32::from(self.item_padding.left) + f32::from(self.item_padding.right); - height += f32::from(self.item_padding.top) + f32::from(self.item_padding.top); - - (width, height) - } } -// PUBLIC INTERFACE impl<'a, SelectionMode, Item, Category, Message> TableView<'a, SelectionMode, Item, Category, Message> where SelectionMode: Default, Model: Selectable, - Category: ItemCategory + Hash + 'static, + Category: ItemCategory, Item: ItemInterface, Message: Clone + 'static, { - /// Creates a new table view with the given model pub fn new(model: &'a Model) -> Self { let cosmic_theme::Spacing { space_xxxs, @@ -247,67 +57,53 @@ where } = theme::active().cosmic().spacing; Self { - id: widget::Id::unique(), model, - width: Length::Fill, - height: Length::Shrink, item_spacing: 0, element_padding: Padding::from(0), divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), item_padding: Padding::from(space_xxs).into(), - icon_spacing: space_xxxs, - icon_size: 24, - indent_spacing: space_xxs, - font_size: 14.0, + on_item_select: None, + on_item_context: None, item_context_tree: None, - category_context_tree: None, on_category_select: None, on_category_context: None, - on_item_left: None, - on_item_double: None, - on_item_middle: None, - on_item_right: None, - on_dnd_drop: None, - mimes: Vec::new(), - on_dnd_enter: None, - on_dnd_leave: None, - drag_id: None, + category_context_tree: None, } } - /// Sets the message to be emitted on left click - pub fn on_item_left(mut self, on_left: F) -> Self - where - F: Fn(Entity) -> Message + 'a, - { - self.on_item_left = Some(Box::new(on_left)); + pub fn item_spacing(mut self, spacing: u16) -> Self { + self.item_spacing = spacing; self } - /// Sets the message to be emitted on double click - pub fn on_item_double(mut self, on_double: F) -> Self - where - F: Fn(Entity) -> Message + 'a, - { - self.on_item_double = Some(Box::new(on_double)); + pub fn element_padding(mut self, padding: impl Into) -> Self { + self.element_padding = padding.into(); self } - /// Sets the message to be emitted on middle click - pub fn on_item_middle(mut self, on_middle: F) -> Self + pub fn divider_padding(mut self, padding: u16) -> Self { + self.divider_padding = Padding::from(0).left(padding).right(padding); + self + } + + pub fn item_padding(mut self, padding: impl Into) -> Self { + self.item_padding = padding.into(); + self + } + + pub fn on_item_select(mut self, on_select: F) -> Self where F: Fn(Entity) -> Message + 'a, { - self.on_item_middle = Some(Box::new(on_middle)); + self.on_item_select = Some(Box::new(on_select)); self } - /// Sets the message to be emitted on right click - pub fn on_item_right(mut self, on_right: F) -> Self + pub fn on_item_context(mut self, on_select: F) -> Self where F: Fn(Entity) -> Message + 'a, { - self.on_item_right = Some(Box::new(on_right)); + self.on_item_context = Some(Box::new(on_select)); self } @@ -327,7 +123,7 @@ where pub fn on_category_select(mut self, on_select: F) -> Self where - F: Fn(Category) -> Message + 'a, + F: Fn(Category, bool) -> Message + 'a, { self.on_category_select = Some(Box::new(on_select)); self @@ -341,448 +137,6 @@ where self } - /// Handle the dnd drop event. - pub fn on_dnd_drop( - mut self, - dnd_drop_handler: impl Fn(Entity, Option, DndAction) -> Message + 'static, - ) -> Self { - self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| { - dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action) - })); - self.mimes = D::allowed().iter().cloned().collect(); - self - } - - /// Handle the dnd enter event. - pub fn on_dnd_enter( - mut self, - dnd_enter_handler: impl Fn(Entity, Vec) -> Message + 'static, - ) -> Self { - self.on_dnd_enter = Some(Box::new(dnd_enter_handler)); - self - } - - /// Handle the dnd leave event. - pub fn on_dnd_leave(mut self, dnd_leave_handler: impl Fn(Entity) -> Message + 'static) -> Self { - self.on_dnd_leave = Some(Box::new(dnd_leave_handler)); - self - } -} - -// Widget implementation -impl<'a, SelectionMode, Item, Category, Message> Widget - for TableView<'a, SelectionMode, Item, Category, Message> -where - SelectionMode: Default, - Model: Selectable, - Category: ItemCategory + Hash + 'static, - Item: ItemInterface, - Message: Clone + 'static, -{ - fn children(&self) -> Vec { - let mut children = Vec::new(); - - // Item context tree - if let Some(ref item_context) = self.item_context_tree { - let mut tree = Tree::empty(); - tree.state = tree::State::new(MenuBarState::default()); - tree.children = menu_roots_children(&item_context); - children.push(tree); - } - - // Category context tree - if let Some(ref category_context) = self.category_context_tree { - let mut tree = Tree::empty(); - tree.state = tree::State::new(MenuBarState::default()); - tree.children = menu_roots_children(&category_context); - children.push(tree); - } - - children - } - - fn tag(&self) -> tree::Tag { - tree::Tag::of::>() - } - - fn state(&self) -> tree::State { - #[allow(clippy::default_trait_access)] - tree::State::new(State:: { - num_items: self.model.order.len(), - selected: self.model.active.clone(), - paragraphs: SecondaryMap::new(), - item_layout: Vec::new(), - text_hashes: SecondaryMap::new(), - - sort_hash: HashMap::new(), - header_paragraphs: HashMap::new(), - category_layout: Vec::new(), - }) - } - - fn diff(&mut self, tree: &mut Tree) { - let state = tree.state.downcast_mut::>(); - - let sort_state = self.model.sort; - let mut hasher = DefaultHasher::new(); - sort_state.hash(&mut hasher); - let cat_hash = hasher.finish(); - for category in self.model.categories.iter().copied() { - if let Some(prev_hash) = state.sort_hash.insert(category, cat_hash) { - if prev_hash == cat_hash { - continue; - } - } - - let category_name = category.to_string(); - let text = Text { - content: category_name.as_str(), - size: iced::Pixels(self.font_size), - bounds: Size::INFINITY, - font: crate::font::bold(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: Shaping::Advanced, - wrapping: Wrapping::default(), - line_height: LineHeight::default(), - }; - - if let Some(header) = state.header_paragraphs.get_mut(&category) { - header.update(text); - } else { - state - .header_paragraphs - .insert(category, crate::Plain::new(text)); - } - } - - for key in self.model.iter() { - if let Some(item) = self.model.item(key) { - // TODO: add hover support if design approves it - let button_state = self.model.is_active(key); - - let mut hasher = DefaultHasher::new(); - button_state.hash(&mut hasher); - // hash each text - for category in self.model.categories.iter().copied() { - let text = item.get_text(category); - - text.hash(&mut hasher); - } - let text_hash = hasher.finish(); - - if let Some(prev_hash) = state.text_hashes.insert(key, text_hash) { - // If the text didn't change, don't update the paragraph - if prev_hash == text_hash { - continue; - } - } - - state.selected.insert(key, button_state); - - // Update the paragraph if the text changed - for category in self.model.categories.iter().copied() { - if let Some(text) = item.get_text(category) { - let text = Text { - content: text.as_ref(), - size: iced::Pixels(self.font_size), - bounds: Size::INFINITY, - font: crate::font::default(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: Shaping::Advanced, - wrapping: Wrapping::default(), - line_height: LineHeight::default(), - }; - - if let Some(item) = state.paragraphs.get_mut(key) { - if let Some(paragraph) = item.get_mut(&category) { - paragraph.update(text); - } else { - item.insert(category, crate::Plain::new(text)); - } - } else { - let mut hm = HashMap::new(); - hm.insert(category, crate::Plain::new(text)); - state.paragraphs.insert(key, hm); - } - } - } - } - } - - // BUG: IF THE CATEGORY_CONTEXT AND ITEM_CONTEXT ARE CLEARED AND A DIFFERENT ONE IS SET, IT BREAKS - // Diff the item context menu - if let Some(ref mut item_context) = self.item_context_tree { - if let Some(ref mut category_context) = self.category_context_tree { - if tree.children.is_empty() { - let mut child_tree = Tree::empty(); - child_tree.state = tree::State::new(MenuBarState::default()); - tree.children.push(child_tree); - - let mut child_tree = Tree::empty(); - child_tree.state = tree::State::new(MenuBarState::default()); - tree.children.push(child_tree); - } else { - tree.children.truncate(2); - } - menu_roots_diff(item_context, &mut tree.children[0]); - menu_roots_diff(category_context, &mut tree.children[1]); - } else { - if tree.children.is_empty() { - let mut child_tree = Tree::empty(); - child_tree.state = tree::State::new(MenuBarState::default()); - tree.children.push(child_tree); - } else { - tree.children.truncate(1); - } - menu_roots_diff(item_context, &mut tree.children[0]); - } - } else { - if let Some(ref mut category_context) = self.category_context_tree { - if tree.children.is_empty() { - let mut child_tree = Tree::empty(); - child_tree.state = tree::State::new(MenuBarState::default()); - tree.children.push(child_tree); - } else { - tree.children.truncate(1); - } - menu_roots_diff(category_context, &mut tree.children[0]); - } else { - tree.children.clear(); - } - } - } - - fn size(&self) -> iced::Size { - Size::new(self.width, self.height) - } - - fn layout( - &self, - tree: &mut Tree, - renderer: &crate::Renderer, - limits: &layout::Limits, - ) -> layout::Node { - let state = tree.state.downcast_mut::>(); - let size = { - state.item_layout.clear(); - state.category_layout.clear(); - let limits = limits.width(self.width); - - for category in self.model.categories.iter().copied() { - state - .category_layout - .push(Size::new(category.width() as f32, 20.0)); - } - - let (width, item_height) = self.max_item_dimensions(state, renderer); - - for size in &mut state.item_layout { - size.width = width.max(limits.max().width); - } - - let spacing = f32::from(self.item_spacing); - let mut height = state.category_layout[0].height; - for _ in self.model.iter() { - height += spacing + 1.0 + spacing; - height += item_height; - } - - limits.height(Length::Fixed(height)).resolve( - self.width, - self.height, - Size::new(width, height), - ) - }; - layout::Node::new(size) - } - - fn draw( - &self, - tree: &Tree, - renderer: &mut crate::Renderer, - theme: &crate::Theme, - style: &iced_core::renderer::Style, - layout: iced_core::Layout<'_>, - cursor: iced_core::mouse::Cursor, - viewport: &iced::Rectangle, - ) { - let state = tree.state.downcast_ref::>(); - let cosmic = theme.cosmic(); - let accent = theme::active().cosmic().accent_color(); - - let bounds = layout.bounds().shrink(self.element_padding); - - let header_quad = Rectangle::new(bounds.position(), Size::new(bounds.width, 20.0)).shrink( - Padding::new(0.0) - .left(self.item_padding.left) - .right(self.item_padding.right), - ); - - // TODO: HEADER TEXT - let mut category_origin = header_quad.position(); - for (nth, category) in self.model.categories.iter().copied().enumerate() { - let category_quad = Rectangle::new(category_origin, state.category_layout[nth]); - if let Some(category_quad) = category_quad.intersection(&bounds) { - renderer.fill_paragraph( - state.header_paragraphs.get(&category).unwrap().raw(), - Point::new(category_quad.position().x, category_quad.center_y()), - cosmic.on_bg_color().into(), - category_quad, - ); - } else { - break; - } - - category_origin.x += category_quad.width; - } - - let body_quad = Rectangle::new( - layout.position() + Vector::new(0.0, 20.0), - Size::new(bounds.width, bounds.height - 20.0), - ); - - if self.model.order.is_empty() { - let divider_quad = layout - .bounds() - .shrink(self.divider_padding) - .apply(|mut dq| { - dq.height = 1.0; - dq - }); - - // If empty, draw a single divider and quit - renderer.fill_quad( - Quad { - bounds: divider_quad, - border: Default::default(), - shadow: Default::default(), - }, - Background::Color(cosmic.bg_divider().into()), - ); - } else { - let mut divider_quad = body_quad.shrink(self.divider_padding).apply(|mut dq| { - dq.height = 1.0; - dq - }); - let mut item_quad = body_quad.shrink(self.element_padding); - for (nth, entity) in self.model.iter().enumerate() { - // draw divider above - - renderer.fill_quad( - Quad { - bounds: divider_quad, - border: Default::default(), - shadow: Default::default(), - }, - Background::Color(cosmic.bg_divider().into()), - ); - - divider_quad.y += 1.0; - item_quad.y += 1.0; - item_quad.width = state.item_layout[nth].width; - item_quad.height = state.item_layout[nth].height; - - if state.selected.get(entity).copied().unwrap_or(false) { - renderer.fill_quad( - Quad { - bounds: item_quad, - border: Border { - color: iced::Color::TRANSPARENT, - width: 0.0, - radius: cosmic.radius_xs().into(), - }, - shadow: Default::default(), - }, - Background::Color(accent.into()), - ); - } - - let content_quad = item_quad.clone().shrink(self.item_padding); - let mut item_content_quad = content_quad.clone(); - if let Some(item) = self.model.item(entity) { - for (nth, category) in self.model.categories.iter().copied().enumerate() { - // TODO: Icons and text - let mut icon_spacing = 0; - if let Some((_, _)) = item.get_icon(category).zip(item.get_text(category)) { - icon_spacing = self.icon_spacing; - } - - item_content_quad.width = state.category_layout[nth].width; - - if let Some(mut item_content_quad) = - item_content_quad.intersection(&content_quad) - { - let mut offset = Point::::default(); - if let Some(icon) = item.get_icon(category) { - let layout_node = layout::Node::new(Size { - width: self.icon_size as f32, - height: self.icon_size as f32, - }) - .move_to(Point { - x: item_content_quad.x, - y: item_content_quad.y, - }); - Widget::::draw( - Element::::from(icon.clone()).as_widget(), - &Tree::empty(), - renderer, - theme, - style, - Layout::new(&layout_node), - cursor, - viewport, - ); - offset.x += self.icon_size as f32; - } - - if let Some(text) = item.get_text(category) { - offset.x += icon_spacing as f32; - - item_content_quad.x = item_content_quad.x + offset.x; - item_content_quad.width -= offset.x; - - renderer.fill_paragraph( - state - .paragraphs - .get(entity) - .unwrap() - .get(&category) - .unwrap() - .raw(), - Point::new( - item_content_quad.x, - item_content_quad.y + item_content_quad.height / 2.0, - ), - cosmic.on_bg_color().into(), - item_content_quad, - ); - } - } else { - break; - } - - item_content_quad.x += state.category_layout[nth].width; - } - } - - item_quad.y += state.item_layout[nth].height; - divider_quad.y += item_quad.height; - } - } - } -} - -impl<'a, SelectionMode, Item, Category, Message> - TableView<'a, SelectionMode, Item, Category, Message> -where - SelectionMode: Default, - Model: Selectable, - Category: ItemCategory + Hash + 'static, - Item: ItemInterface, - Message: Clone + 'static, -{ #[must_use] pub fn element_standard(&self) -> Element<'a, Message> { let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; @@ -791,13 +145,12 @@ where .model .categories .iter() - .copied() .map(|category| { widget::row() .spacing(space_xxxs) .push(widget::text::heading(category.to_string())) .push_maybe(if let Some(sort) = self.model.sort { - if sort.0 == category { + if sort.0 == *category { match sort.1 { true => Some(widget::icon::from_name("pan-up-symbolic").icon()), false => Some(widget::icon::from_name("pan-down-symbolic").icon()), @@ -809,11 +162,27 @@ where None }) .apply(container) + .padding( + Padding::default() + .left(self.item_padding.left) + .right(self.item_padding.right), + ) .width(category.width()) .apply(widget::mouse_area) .apply(|mouse_area| { if let Some(ref on_category_select) = self.on_category_select { - mouse_area.on_press((on_category_select)(category)) + mouse_area.on_press((on_category_select)( + *category, + if let Some(sort) = self.model.sort { + if sort.0 == *category { + !sort.1 + } else { + false + } + } else { + false + }, + )) } else { mouse_area } @@ -822,11 +191,6 @@ where }) .collect::>>() .apply(widget::row::with_children) - .padding( - Padding::default() - .left(self.item_padding.left) - .right(self.item_padding.right), - ) .apply(Element::from); let items_full = if self.model.items.is_empty() { vec![divider::horizontal::default() @@ -850,15 +214,9 @@ where .iter() .map(|category| { widget::row() - .spacing(self.icon_spacing) - .push_maybe( - item.get_icon(*category) - .map(|icon| icon.size(self.icon_size)), - ) - .push_maybe( - item.get_text(*category) - .map(|text| widget::text::body(text)), - ) + .spacing(space_xxxs) + .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) + .push(widget::text::body(item.get_text(*category))) .align_y(Alignment::Center) .apply(container) .width(category.width()) @@ -897,7 +255,7 @@ where })) .apply(widget::mouse_area) .apply(|mouse_area| { - if let Some(ref on_item_select) = self.on_item_left { + if let Some(ref on_item_select) = self.on_item_select { mouse_area.on_press((on_item_select)(entity)) } else { mouse_area @@ -943,10 +301,7 @@ where ) .push( widget::column() - .push_maybe( - item.get_text(Category::default()) - .map(|text| widget::text::body(text)), - ) + .push(widget::text::body(item.get_text(Category::default()))) .push({ let mut elements = self .model @@ -954,16 +309,11 @@ where .iter() .skip_while(|cat| **cat != Category::default()) .map(|category| { - item.get_text(*category) - .map(|text| { - vec![ - widget::text::caption(text) - .apply(Element::from), - widget::text::caption("-") - .apply(Element::from), - ] - }) - .unwrap_or_default() + vec![ + widget::text::caption(item.get_text(*category)) + .apply(Element::from), + widget::text::caption("-").apply(Element::from), + ] }) .flatten() .collect::>>(); @@ -1005,7 +355,7 @@ where })) .apply(widget::mouse_area) .apply(|ma| { - if let Some(on_item_select) = &self.on_item_left { + if let Some(on_item_select) = &self.on_item_select { ma.on_press((on_item_select)(entity)) } else { ma diff --git a/src/widget/table/widget/state.rs b/src/widget/table/widget/state.rs index 82fb88db3d0..4d529db4020 100644 --- a/src/widget/table/widget/state.rs +++ b/src/widget/table/widget/state.rs @@ -1,21 +1,8 @@ -use std::collections::HashMap; - -use iced::Size; use slotmap::SecondaryMap; -use crate::widget::table::{Entity, ItemCategory}; +use crate::widget::table::Entity; -pub struct State -where - Category: ItemCategory + 'static, -{ +pub struct State { pub(super) num_items: usize, pub(super) selected: SecondaryMap, - pub(super) paragraphs: SecondaryMap>, - pub(super) text_hashes: SecondaryMap, - pub(super) item_layout: Vec, - - pub(super) sort_hash: HashMap, - pub(super) header_paragraphs: HashMap, - pub(super) category_layout: Vec, } From 2401c2a4ec270558748d8983609746518cc6b131 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sat, 4 Jan 2025 11:57:33 -0800 Subject: [PATCH 07/16] Added table function for generic table view creation --- examples/table-view/src/main.rs | 6 +++--- src/widget/mod.rs | 1 + src/widget/table/mod.rs | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index f4c34070672..3bea9b363fa 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -6,8 +6,8 @@ use chrono::Datelike; use cosmic::app::{Core, Settings, Task}; use cosmic::iced_core::Size; -use cosmic::widget::nav_bar; use cosmic::widget::table; +use cosmic::widget::{self, nav_bar}; use cosmic::{executor, iced, Element}; #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] @@ -194,12 +194,12 @@ impl cosmic::Application for App { fn view(&self) -> Element { cosmic::widget::responsive(|size| { if size.width < 600.0 { - table::SingleSelectTableView::new(&self.table_model) + widget::table(&self.table_model) .on_item_select(Message::ItemSelect) .on_category_select(Message::CategorySelect) .element_compact() } else { - table::SingleSelectTableView::new(&self.table_model) + widget::table(&self.table_model) .on_item_select(Message::ItemSelect) .on_category_select(Message::CategorySelect) .element_standard() diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 0a11f0517e2..e915e62626c 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -316,6 +316,7 @@ pub use spin_button::{spin_button, vertical as vertical_spin_button, SpinButton} pub mod tab_bar; pub mod table; +pub use table::table; pub mod text; #[doc(inline)] diff --git a/src/widget/table/mod.rs b/src/widget/table/mod.rs index 594e73d2afe..b5a5d0b760b 100644 --- a/src/widget/table/mod.rs +++ b/src/widget/table/mod.rs @@ -18,3 +18,16 @@ pub type SingleSelectModel = Model pub type MultiSelectTableView<'a, Item, Category, Message> = TableView<'a, MultiSelect, Item, Category, Message>; pub type MultiSelectModel = Model; + +pub fn table<'a, SelectionMode, Item, Category, Message>( + model: &'a Model, +) -> TableView<'a, SelectionMode, Item, Category, Message> +where + Message: Clone, + SelectionMode: Default, + Category: ItemCategory, + Item: ItemInterface, + Model: model::selection::Selectable, +{ + TableView::new(model) +} From 2305e25fa81a1418783fc224bdd8938ae2d878bd Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sat, 4 Jan 2025 12:16:08 -0800 Subject: [PATCH 08/16] Use derive_setters for TableView --- src/widget/table/widget/mod.rs | 53 ++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 73cb60c361b..85040900ab5 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -1,4 +1,5 @@ mod state; +use derive_setters::Setters; use state::State; use super::model::{ @@ -16,6 +17,7 @@ use iced::{Alignment, Border, Length, Padding}; // THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED +#[derive(Setters)] #[must_use] pub struct TableView<'a, SelectionMode, Item, Category, Message> where @@ -27,16 +29,28 @@ where { pub(super) model: &'a Model, - pub(super) item_spacing: u16, + #[setters(into)] pub(super) element_padding: Padding, + + #[setters(into)] pub(super) item_padding: Padding, + pub(super) item_spacing: u16, + + #[setters(into)] pub(super) divider_padding: Padding, + + #[setters(skip)] pub(super) item_context_tree: Option>>, + #[setters(skip)] pub(super) category_context_tree: Option>>, + #[setters(skip)] pub(super) on_item_select: Option Message + 'a>>, + #[setters(skip)] pub(super) on_item_context: Option Message + 'a>>, + #[setters(skip)] pub(super) on_category_select: Option Message + 'a>>, + #[setters(skip)] pub(super) on_category_context: Option Message + 'a>>, } @@ -71,26 +85,6 @@ where } } - pub fn item_spacing(mut self, spacing: u16) -> Self { - self.item_spacing = spacing; - self - } - - pub fn element_padding(mut self, padding: impl Into) -> Self { - self.element_padding = padding.into(); - self - } - - pub fn divider_padding(mut self, padding: u16) -> Self { - self.divider_padding = Padding::from(0).left(padding).right(padding); - self - } - - pub fn item_padding(mut self, padding: impl Into) -> Self { - self.item_padding = padding.into(); - self - } - pub fn on_item_select(mut self, on_select: F) -> Self where F: Fn(Entity) -> Message + 'a, @@ -121,6 +115,20 @@ where self } + pub fn category_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.category_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.category_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + pub fn on_category_select(mut self, on_select: F) -> Self where F: Fn(Category, bool) -> Message + 'a, @@ -137,7 +145,7 @@ where self } - #[must_use] + /*#[must_use] pub fn element_standard(&self) -> Element<'a, Message> { let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; @@ -370,4 +378,5 @@ where .padding(self.element_padding) .apply(Element::from) } + */ } From 91c35e88adcdc28bc288a640107764578c07725e Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sat, 4 Jan 2025 12:42:34 -0800 Subject: [PATCH 09/16] Made compact and standard table view into separate widgets --- examples/table-view/src/main.rs | 9 +- src/widget/mod.rs | 2 +- src/widget/table/mod.rs | 16 +- src/widget/table/widget/compact.rs | 249 ++++++++++++++++++ src/widget/table/widget/mod.rs | 384 +--------------------------- src/widget/table/widget/standard.rs | 298 +++++++++++++++++++++ src/widget/table/widget/state.rs | 8 - 7 files changed, 570 insertions(+), 396 deletions(-) create mode 100644 src/widget/table/widget/compact.rs create mode 100644 src/widget/table/widget/standard.rs delete mode 100644 src/widget/table/widget/state.rs diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index 3bea9b363fa..ea6411441fb 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -6,9 +6,10 @@ use chrono::Datelike; use cosmic::app::{Core, Settings, Task}; use cosmic::iced_core::Size; +use cosmic::prelude::*; use cosmic::widget::table; use cosmic::widget::{self, nav_bar}; -use cosmic::{executor, iced, Element}; +use cosmic::{executor, iced}; #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] pub enum Category { @@ -194,15 +195,15 @@ impl cosmic::Application for App { fn view(&self) -> Element { cosmic::widget::responsive(|size| { if size.width < 600.0 { - widget::table(&self.table_model) + widget::compact_table(&self.table_model) .on_item_select(Message::ItemSelect) .on_category_select(Message::CategorySelect) - .element_compact() + .apply(Element::from) } else { widget::table(&self.table_model) .on_item_select(Message::ItemSelect) .on_category_select(Message::CategorySelect) - .element_standard() + .apply(Element::from) } }) .into() diff --git a/src/widget/mod.rs b/src/widget/mod.rs index e915e62626c..d6aa01e856c 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -316,7 +316,7 @@ pub use spin_button::{spin_button, vertical as vertical_spin_button, SpinButton} pub mod tab_bar; pub mod table; -pub use table::table; +pub use table::{compact_table, table}; pub mod text; #[doc(inline)] diff --git a/src/widget/table/mod.rs b/src/widget/table/mod.rs index b5a5d0b760b..c39a393db6c 100644 --- a/src/widget/table/mod.rs +++ b/src/widget/table/mod.rs @@ -9,7 +9,8 @@ pub use model::{ Entity, Model, }; pub mod widget; -pub use widget::TableView; +pub use widget::compact::CompactTableView; +pub use widget::standard::TableView; pub type SingleSelectTableView<'a, Item, Category, Message> = TableView<'a, SingleSelect, Item, Category, Message>; @@ -31,3 +32,16 @@ where { TableView::new(model) } + +pub fn compact_table<'a, SelectionMode, Item, Category, Message>( + model: &'a Model, +) -> CompactTableView<'a, SelectionMode, Item, Category, Message> +where + Message: Clone, + SelectionMode: Default, + Category: ItemCategory, + Item: ItemInterface, + Model: model::selection::Selectable, +{ + CompactTableView::new(model) +} diff --git a/src/widget/table/widget/compact.rs b/src/widget/table/widget/compact.rs new file mode 100644 index 00000000000..62891fca462 --- /dev/null +++ b/src/widget/table/widget/compact.rs @@ -0,0 +1,249 @@ +use derive_setters::Setters; + +use crate::widget::table::model::{ + category::{ItemCategory, ItemInterface}, + selection::Selectable, + Entity, Model, +}; +use crate::{ + theme, + widget::{self, container, menu}, + Apply, Element, +}; +use iced::{Alignment, Border, Padding}; + +#[derive(Setters)] +#[must_use] +pub struct CompactTableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + pub(super) model: &'a Model, + + #[setters(into)] + pub(super) element_padding: Padding, + + #[setters(into)] + pub(super) item_padding: Padding, + pub(super) item_spacing: u16, + + #[setters(into)] + pub(super) divider_padding: Padding, + + #[setters(skip)] + pub(super) item_context_tree: Option>>, + #[setters(skip)] + pub(super) category_context_tree: Option>>, + + #[setters(skip)] + pub(super) on_item_select: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_item_context: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_category_select: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_category_context: Option Message + 'a>>, +} + +impl<'a, SelectionMode, Item, Category, Message> + From> for Element<'a, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + fn from(val: CompactTableView<'a, SelectionMode, Item, Category, Message>) -> Self { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + val.model + .iter() + .map(|entity| { + let item = val.model.item(entity).unwrap(); + let selected = val.model.is_active(entity); + widget::column() + .spacing(val.item_spacing) + .push( + widget::divider::horizontal::default() + .apply(container) + .padding(val.divider_padding), + ) + .push( + widget::row() + .spacing(space_xxxs) + .align_y(Alignment::Center) + .push_maybe( + item.get_icon(Category::default()).map(|icon| icon.size(48)), + ) + .push( + widget::column() + .push(widget::text::body(item.get_text(Category::default()))) + .push({ + let mut elements = val + .model + .categories + .iter() + .skip_while(|cat| **cat != Category::default()) + .map(|category| { + vec![ + widget::text::caption(item.get_text(*category)) + .apply(Element::from), + widget::text::caption("-").apply(Element::from), + ] + }) + .flatten() + .collect::>>(); + elements.pop(); + elements + .apply(widget::row::with_children) + .spacing(space_xxxs) + .wrap() + }), + ) + .apply(container) + .padding(val.item_padding) + .width(iced::Length::Fill) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + } + })) + .apply(widget::mouse_area) + .apply(|ma| { + if let Some(on_item_select) = &val.on_item_select { + ma.on_press((on_item_select)(entity)) + } else { + ma + } + }), + ) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::column::with_children) + .spacing(val.item_spacing) + .padding(val.element_padding) + .apply(Element::from) + } +} + +impl<'a, SelectionMode, Item, Category, Message> + CompactTableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, + Message: Clone + 'static, +{ + pub fn new(model: &'a Model) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::active().cosmic().spacing; + + Self { + model, + element_padding: Padding::from(0), + + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + + item_padding: Padding::from(space_xxs).into(), + item_spacing: 0, + + on_item_select: None, + on_item_context: None, + item_context_tree: None, + + on_category_select: None, + on_category_context: None, + category_context_tree: None, + } + } + + pub fn on_item_select(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_select = Some(Box::new(on_select)); + self + } + + pub fn on_item_context(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_context = Some(Box::new(on_select)); + self + } + + pub fn item_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.item_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.item_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + + pub fn category_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.category_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.category_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + + pub fn on_category_select(mut self, on_select: F) -> Self + where + F: Fn(Category, bool) -> Message + 'a, + { + self.on_category_select = Some(Box::new(on_select)); + self + } + + pub fn on_category_context(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'a, + { + self.on_category_context = Some(Box::new(on_select)); + self + } +} diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs index 85040900ab5..0396796e4a3 100644 --- a/src/widget/table/widget/mod.rs +++ b/src/widget/table/widget/mod.rs @@ -1,382 +1,2 @@ -mod state; -use derive_setters::Setters; -use state::State; - -use super::model::{ - category::{ItemCategory, ItemInterface}, - selection::Selectable, - Entity, Model, -}; -use crate::{ - ext::CollectionWidget, - theme, - widget::{self, container, divider, menu}, - Apply, Element, -}; -use iced::{Alignment, Border, Length, Padding}; - -// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED - -#[derive(Setters)] -#[must_use] -pub struct TableView<'a, SelectionMode, Item, Category, Message> -where - Category: ItemCategory, - Item: ItemInterface, - Model: Selectable, - SelectionMode: Default, - Message: Clone + 'static, -{ - pub(super) model: &'a Model, - - #[setters(into)] - pub(super) element_padding: Padding, - - #[setters(into)] - pub(super) item_padding: Padding, - pub(super) item_spacing: u16, - - #[setters(into)] - pub(super) divider_padding: Padding, - - #[setters(skip)] - pub(super) item_context_tree: Option>>, - #[setters(skip)] - pub(super) category_context_tree: Option>>, - - #[setters(skip)] - pub(super) on_item_select: Option Message + 'a>>, - #[setters(skip)] - pub(super) on_item_context: Option Message + 'a>>, - #[setters(skip)] - pub(super) on_category_select: Option Message + 'a>>, - #[setters(skip)] - pub(super) on_category_context: Option Message + 'a>>, -} - -impl<'a, SelectionMode, Item, Category, Message> - TableView<'a, SelectionMode, Item, Category, Message> -where - SelectionMode: Default, - Model: Selectable, - Category: ItemCategory, - Item: ItemInterface, - Message: Clone + 'static, -{ - pub fn new(model: &'a Model) -> Self { - let cosmic_theme::Spacing { - space_xxxs, - space_xxs, - .. - } = theme::active().cosmic().spacing; - - Self { - model, - item_spacing: 0, - element_padding: Padding::from(0), - divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), - item_padding: Padding::from(space_xxs).into(), - on_item_select: None, - on_item_context: None, - item_context_tree: None, - on_category_select: None, - on_category_context: None, - category_context_tree: None, - } - } - - pub fn on_item_select(mut self, on_select: F) -> Self - where - F: Fn(Entity) -> Message + 'a, - { - self.on_item_select = Some(Box::new(on_select)); - self - } - - pub fn on_item_context(mut self, on_select: F) -> Self - where - F: Fn(Entity) -> Message + 'a, - { - self.on_item_context = Some(Box::new(on_select)); - self - } - - pub fn item_context(mut self, context_menu: Option>>) -> Self - where - Message: 'static, - { - self.item_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.item_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - - self - } - - pub fn category_context(mut self, context_menu: Option>>) -> Self - where - Message: 'static, - { - self.category_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.category_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - - self - } - - pub fn on_category_select(mut self, on_select: F) -> Self - where - F: Fn(Category, bool) -> Message + 'a, - { - self.on_category_select = Some(Box::new(on_select)); - self - } - - pub fn on_category_context(mut self, on_select: F) -> Self - where - F: Fn(Category) -> Message + 'a, - { - self.on_category_context = Some(Box::new(on_select)); - self - } - - /*#[must_use] - pub fn element_standard(&self) -> Element<'a, Message> { - let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; - - let header_row = self - .model - .categories - .iter() - .map(|category| { - widget::row() - .spacing(space_xxxs) - .push(widget::text::heading(category.to_string())) - .push_maybe(if let Some(sort) = self.model.sort { - if sort.0 == *category { - match sort.1 { - true => Some(widget::icon::from_name("pan-up-symbolic").icon()), - false => Some(widget::icon::from_name("pan-down-symbolic").icon()), - } - } else { - None - } - } else { - None - }) - .apply(container) - .padding( - Padding::default() - .left(self.item_padding.left) - .right(self.item_padding.right), - ) - .width(category.width()) - .apply(widget::mouse_area) - .apply(|mouse_area| { - if let Some(ref on_category_select) = self.on_category_select { - mouse_area.on_press((on_category_select)( - *category, - if let Some(sort) = self.model.sort { - if sort.0 == *category { - !sort.1 - } else { - false - } - } else { - false - }, - )) - } else { - mouse_area - } - }) - .apply(Element::from) - }) - .collect::>>() - .apply(widget::row::with_children) - .apply(Element::from); - let items_full = if self.model.items.is_empty() { - vec![divider::horizontal::default() - .apply(container) - .padding(self.divider_padding) - .apply(Element::from)] - } else { - self.model - .iter() - .map(|entity| { - let item = self.model.item(entity).unwrap(); - let categories = &self.model.categories; - let selected = self.model.is_active(entity); - - vec![ - divider::horizontal::default() - .apply(container) - .padding(self.divider_padding) - .apply(Element::from), - categories - .iter() - .map(|category| { - widget::row() - .spacing(space_xxxs) - .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) - .push(widget::text::body(item.get_text(*category))) - .align_y(Alignment::Center) - .apply(container) - .width(category.width()) - .align_y(Alignment::Center) - .apply(Element::from) - }) - .collect::>>() - .apply(widget::row::with_children) - .apply(container) - .padding(self.item_padding) - .class(theme::Container::custom(move |theme| { - widget::container::Style { - icon_color: if selected { - Some(theme.cosmic().on_accent_color().into()) - } else { - None - }, - text_color: if selected { - Some(theme.cosmic().on_accent_color().into()) - } else { - None - }, - background: if selected { - Some(iced::Background::Color( - theme.cosmic().accent_color().into(), - )) - } else { - None - }, - border: Border { - radius: theme.cosmic().radius_xs().into(), - ..Default::default() - }, - shadow: Default::default(), - } - })) - .apply(widget::mouse_area) - .apply(|mouse_area| { - if let Some(ref on_item_select) = self.on_item_select { - mouse_area.on_press((on_item_select)(entity)) - } else { - mouse_area - } - }) - .apply(Element::from), - ] - }) - .flatten() - .collect::>>() - }; - vec![vec![header_row], items_full] - .into_iter() - .flatten() - .collect::>>() - .apply(widget::column::with_children) - .spacing(self.item_spacing) - .padding(self.element_padding) - .apply(Element::from) - } - - #[must_use] - pub fn element_compact(&self) -> Element<'a, Message> { - let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; - self.model - .iter() - .map(|entity| { - let item = self.model.item(entity).unwrap(); - let selected = self.model.is_active(entity); - widget::column() - .spacing(self.item_spacing) - .push( - widget::divider::horizontal::default() - .apply(container) - .padding(self.divider_padding), - ) - .push( - widget::row() - .spacing(space_xxxs) - .align_y(Alignment::Center) - .push_maybe( - item.get_icon(Category::default()).map(|icon| icon.size(48)), - ) - .push( - widget::column() - .push(widget::text::body(item.get_text(Category::default()))) - .push({ - let mut elements = self - .model - .categories - .iter() - .skip_while(|cat| **cat != Category::default()) - .map(|category| { - vec![ - widget::text::caption(item.get_text(*category)) - .apply(Element::from), - widget::text::caption("-").apply(Element::from), - ] - }) - .flatten() - .collect::>>(); - elements.pop(); - elements - .apply(widget::row::with_children) - .spacing(space_xxxs) - .wrap() - }), - ) - .apply(container) - .padding(self.item_padding) - .width(iced::Length::Fill) - .class(theme::Container::custom(move |theme| { - widget::container::Style { - icon_color: if selected { - Some(theme.cosmic().on_accent_color().into()) - } else { - None - }, - text_color: if selected { - Some(theme.cosmic().on_accent_color().into()) - } else { - None - }, - background: if selected { - Some(iced::Background::Color( - theme.cosmic().accent_color().into(), - )) - } else { - None - }, - border: Border { - radius: theme.cosmic().radius_xs().into(), - ..Default::default() - }, - shadow: Default::default(), - } - })) - .apply(widget::mouse_area) - .apply(|ma| { - if let Some(on_item_select) = &self.on_item_select { - ma.on_press((on_item_select)(entity)) - } else { - ma - } - }), - ) - .apply(Element::from) - }) - .collect::>>() - .apply(widget::column::with_children) - .spacing(self.item_spacing) - .padding(self.element_padding) - .apply(Element::from) - } - */ -} +pub mod compact; +pub mod standard; diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs new file mode 100644 index 00000000000..f9450c70f77 --- /dev/null +++ b/src/widget/table/widget/standard.rs @@ -0,0 +1,298 @@ +use derive_setters::Setters; + +use crate::widget::table::model::{ + category::{ItemCategory, ItemInterface}, + selection::Selectable, + Entity, Model, +}; +use crate::{ + ext::CollectionWidget, + theme, + widget::{self, container, divider, menu}, + Apply, Element, +}; +use iced::{Alignment, Border, Length, Padding}; + +// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED + +#[derive(Setters)] +#[must_use] +pub struct TableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + pub(super) model: &'a Model, + + #[setters(into)] + pub(super) element_padding: Padding, + + #[setters(into)] + pub(super) item_padding: Padding, + pub(super) item_spacing: u16, + + #[setters(into)] + pub(super) divider_padding: Padding, + + #[setters(skip)] + pub(super) item_context_tree: Option>>, + #[setters(skip)] + pub(super) category_context_tree: Option>>, + + #[setters(skip)] + pub(super) on_item_select: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_item_context: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_category_select: Option Message + 'a>>, + #[setters(skip)] + pub(super) on_category_context: Option Message + 'a>>, +} + +impl<'a, SelectionMode, Item, Category, Message> + From> for Element<'a, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + fn from(val: TableView<'a, SelectionMode, Item, Category, Message>) -> Self { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + + let header_row = val + .model + .categories + .iter() + .map(|category| { + widget::row() + .spacing(space_xxxs) + .push(widget::text::heading(category.to_string())) + .push_maybe(if let Some(sort) = val.model.sort { + if sort.0 == *category { + match sort.1 { + true => Some(widget::icon::from_name("pan-up-symbolic").icon()), + false => Some(widget::icon::from_name("pan-down-symbolic").icon()), + } + } else { + None + } + } else { + None + }) + .apply(container) + .padding( + Padding::default() + .left(val.item_padding.left) + .right(val.item_padding.right), + ) + .width(category.width()) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_category_select) = val.on_category_select { + mouse_area.on_press((on_category_select)( + *category, + if let Some(sort) = val.model.sort { + if sort.0 == *category { + !sort.1 + } else { + false + } + } else { + false + }, + )) + } else { + mouse_area + } + }) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::row::with_children) + .apply(Element::from); + let items_full = if val.model.items.is_empty() { + vec![divider::horizontal::default() + .apply(container) + .padding(val.divider_padding) + .apply(Element::from)] + } else { + val.model + .iter() + .map(|entity| { + let item = val.model.item(entity).unwrap(); + let categories = &val.model.categories; + let selected = val.model.is_active(entity); + + vec![ + divider::horizontal::default() + .apply(container) + .padding(val.divider_padding) + .apply(Element::from), + categories + .iter() + .map(|category| { + widget::row() + .spacing(space_xxxs) + .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) + .push(widget::text::body(item.get_text(*category))) + .align_y(Alignment::Center) + .apply(container) + .width(category.width()) + .align_y(Alignment::Center) + .apply(Element::from) + }) + .collect::>>() + .apply(widget::row::with_children) + .apply(container) + .padding(val.item_padding) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + } + })) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_item_select) = val.on_item_select { + mouse_area.on_press((on_item_select)(entity)) + } else { + mouse_area + } + }) + .apply(Element::from), + ] + }) + .flatten() + .collect::>>() + }; + vec![vec![header_row], items_full] + .into_iter() + .flatten() + .collect::>>() + .apply(widget::column::with_children) + .spacing(val.item_spacing) + .padding(val.element_padding) + .apply(Element::from) + } +} + +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, + Message: Clone + 'static, +{ + pub fn new(model: &'a Model) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::active().cosmic().spacing; + + Self { + model, + element_padding: Padding::from(0), + + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + + item_padding: Padding::from(space_xxs).into(), + item_spacing: 0, + + on_item_select: None, + on_item_context: None, + item_context_tree: None, + + on_category_select: None, + on_category_context: None, + category_context_tree: None, + } + } + + pub fn on_item_select(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_select = Some(Box::new(on_select)); + self + } + + pub fn on_item_context(mut self, on_select: F) -> Self + where + F: Fn(Entity) -> Message + 'a, + { + self.on_item_context = Some(Box::new(on_select)); + self + } + + pub fn item_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.item_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.item_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + + pub fn category_context(mut self, context_menu: Option>>) -> Self + where + Message: 'static, + { + self.category_context_tree = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + + if let Some(ref mut context_menu) = self.category_context_tree { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + self + } + + pub fn on_category_select(mut self, on_select: F) -> Self + where + F: Fn(Category, bool) -> Message + 'a, + { + self.on_category_select = Some(Box::new(on_select)); + self + } + + pub fn on_category_context(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'a, + { + self.on_category_context = Some(Box::new(on_select)); + self + } +} diff --git a/src/widget/table/widget/state.rs b/src/widget/table/widget/state.rs deleted file mode 100644 index 4d529db4020..00000000000 --- a/src/widget/table/widget/state.rs +++ /dev/null @@ -1,8 +0,0 @@ -use slotmap::SecondaryMap; - -use crate::widget::table::Entity; - -pub struct State { - pub(super) num_items: usize, - pub(super) selected: SecondaryMap, -} From 4111fcbd05979fe54984865c289f0200c688025b Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sun, 5 Jan 2025 15:56:35 -0800 Subject: [PATCH 10/16] Added doc inline to widget --- src/widget/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widget/mod.rs b/src/widget/mod.rs index d6aa01e856c..a51fe7947fe 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -316,6 +316,7 @@ pub use spin_button::{spin_button, vertical as vertical_spin_button, SpinButton} pub mod tab_bar; pub mod table; +#[doc(inline)] pub use table::{compact_table, table}; pub mod text; From 68fdd8fbe4b59d10b1caf55472cb43c67155e31e Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Mon, 6 Jan 2025 12:15:44 -0800 Subject: [PATCH 11/16] Added category context menus --- examples/table-view/src/main.rs | 2 +- src/widget/table/model/category.rs | 2 +- src/widget/table/widget/standard.rs | 31 +++++++++++++++++++---------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index ea6411441fb..23df06a244e 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -11,7 +11,7 @@ use cosmic::widget::table; use cosmic::widget::{self, nav_bar}; use cosmic::{executor, iced}; -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)] pub enum Category { #[default] Name, diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs index efa4624a60e..c3dd5400600 100644 --- a/src/widget/table/model/category.rs +++ b/src/widget/table/model/category.rs @@ -4,7 +4,7 @@ use crate::widget::Icon; /// Implementation of std::fmt::Display allows user to customize the header /// Ideally, this is implemented on an enum. -pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq { +pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq + std::hash::Hash { /// Function that gets the width of the data fn width(&self) -> iced::Length; } diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs index f9450c70f77..bf86153c495 100644 --- a/src/widget/table/widget/standard.rs +++ b/src/widget/table/widget/standard.rs @@ -40,7 +40,8 @@ where #[setters(skip)] pub(super) item_context_tree: Option>>, #[setters(skip)] - pub(super) category_context_tree: Option>>, + pub(super) category_context_trees: + std::collections::HashMap>>>, #[setters(skip)] pub(super) on_item_select: Option Message + 'a>>, @@ -64,11 +65,15 @@ where fn from(val: TableView<'a, SelectionMode, Item, Category, Message>) -> Self { let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; + let mut category_contexts = val.category_context_trees.into_values(); + let header_row = val .model .categories .iter() .map(|category| { + let cat_context_tree = category_contexts.next().unwrap(); + widget::row() .spacing(space_xxxs) .push(widget::text::heading(category.to_string())) @@ -110,6 +115,7 @@ where mouse_area } }) + .apply(|mouse_area| widget::context_menu(mouse_area, cat_context_tree)) .apply(Element::from) }) .collect::>>() @@ -217,7 +223,7 @@ where .. } = theme::active().cosmic().spacing; - Self { + let mut result = Self { model, element_padding: Padding::from(0), @@ -232,8 +238,14 @@ where on_category_select: None, on_category_context: None, - category_context_tree: None, + category_context_trees: std::collections::HashMap::new(), + }; + + for category in model.categories.iter().cloned() { + result.category_context_trees.insert(category, None); } + + result } pub fn on_item_select(mut self, on_select: F) -> Self @@ -266,16 +278,15 @@ where self } - pub fn category_context(mut self, context_menu: Option>>) -> Self + pub fn category_context(mut self, category: Category, context_menu: Option>>) -> Self where Message: 'static, { - self.category_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.category_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } + *self.category_context_trees.get_mut(&category).unwrap() = + context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); + if let Some(ref mut context_menu) = self.category_context_trees.get_mut(&category).unwrap() { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } self } From ea4e6c6cde5eaa1dccb758fcd577394a0ceabdd9 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Fri, 10 Jan 2025 22:44:42 -0800 Subject: [PATCH 12/16] Added item contexts and changed the context menu functions to take builder functions --- examples/table-view/src/main.rs | 87 ++++++++++++++++++++++++++++- src/widget/table/widget/compact.rs | 62 ++++---------------- src/widget/table/widget/standard.rs | 87 ++++++++++++++--------------- 3 files changed, 139 insertions(+), 97 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index 23df06a244e..d7dd8ae8ebc 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -3,6 +3,8 @@ //! Table API example +use std::collections::HashMap; + use chrono::Datelike; use cosmic::app::{Core, Settings, Task}; use cosmic::iced_core::Size; @@ -100,6 +102,7 @@ fn main() -> Result<(), Box> { pub enum Message { ItemSelect(table::Entity), CategorySelect(Category, bool), + NoOp, } /// The [`App`] stores application-specific state. @@ -187,6 +190,7 @@ impl cosmic::Application for App { Message::CategorySelect(category, descending) => { self.table_model.sort(category, descending) } + Message::NoOp => {} } Task::none() } @@ -197,15 +201,96 @@ impl cosmic::Application for App { if size.width < 600.0 { widget::compact_table(&self.table_model) .on_item_select(Message::ItemSelect) - .on_category_select(Message::CategorySelect) + .item_context(|item| { + Some(widget::menu::items( + &HashMap::new(), + vec![widget::menu::Item::Button( + format!("Action on {}", item.name), + None, + Action::None, + )], + )) + }) .apply(Element::from) } else { widget::table(&self.table_model) .on_item_select(Message::ItemSelect) .on_category_select(Message::CategorySelect) + .item_context(|item| { + Some(widget::menu::items( + &HashMap::new(), + vec![widget::menu::Item::Button( + format!("Action on {}", item.name), + None, + Action::None, + )], + )) + }) + .category_context(|category| { + Some(match category { + Category::Name => widget::menu::items( + &HashMap::new(), + vec![ + widget::menu::Item::Button( + "Action on Name Category", + None, + Action::None, + ), + widget::menu::Item::Button( + "Other action on Name", + None, + Action::None, + ), + ], + ), + Category::Date => widget::menu::items( + &HashMap::new(), + vec![ + widget::menu::Item::Button( + "Action on Date Category", + None, + Action::None, + ), + widget::menu::Item::Button( + "Other action on Date", + None, + Action::None, + ), + ], + ), + Category::Size => widget::menu::items( + &HashMap::new(), + vec![ + widget::menu::Item::Button( + "Action on Size Category", + None, + Action::None, + ), + widget::menu::Item::Button( + "Other action on Size", + None, + Action::None, + ), + ], + ), + }) + }) .apply(Element::from) } }) .into() } } + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Action { + None, +} + +impl widget::menu::Action for Action { + type Message = Message; + + fn message(&self) -> Self::Message { + Message::NoOp + } +} diff --git a/src/widget/table/widget/compact.rs b/src/widget/table/widget/compact.rs index 62891fca462..71b88cbb50f 100644 --- a/src/widget/table/widget/compact.rs +++ b/src/widget/table/widget/compact.rs @@ -30,23 +30,18 @@ where #[setters(into)] pub(super) item_padding: Padding, pub(super) item_spacing: u16, + pub(super) icon_size: u16, #[setters(into)] pub(super) divider_padding: Padding, #[setters(skip)] - pub(super) item_context_tree: Option>>, - #[setters(skip)] - pub(super) category_context_tree: Option>>, + pub(super) item_context_builder: Box Option>>>, #[setters(skip)] pub(super) on_item_select: Option Message + 'a>>, #[setters(skip)] pub(super) on_item_context: Option Message + 'a>>, - #[setters(skip)] - pub(super) on_category_select: Option Message + 'a>>, - #[setters(skip)] - pub(super) on_category_context: Option Message + 'a>>, } impl<'a, SelectionMode, Item, Category, Message> @@ -65,6 +60,8 @@ where .map(|entity| { let item = val.model.item(entity).unwrap(); let selected = val.model.is_active(entity); + let context_menu = (val.item_context_builder)(&item); + widget::column() .spacing(val.item_spacing) .push( @@ -140,7 +137,8 @@ where } else { ma } - }), + }) + .apply(|ma| widget::context_menu(ma, context_menu)), ) .apply(Element::from) }) @@ -176,14 +174,11 @@ where item_padding: Padding::from(space_xxs).into(), item_spacing: 0, + icon_size: 48, + item_context_builder: Box::new(|_| None), on_item_select: None, on_item_context: None, - item_context_tree: None, - - on_category_select: None, - on_category_context: None, - category_context_tree: None, } } @@ -203,47 +198,12 @@ where self } - pub fn item_context(mut self, context_menu: Option>>) -> Self - where - Message: 'static, - { - self.item_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.item_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - - self - } - - pub fn category_context(mut self, context_menu: Option>>) -> Self + pub fn item_context(mut self, context_menu_builder: F) -> Self where + F: Fn(&Item) -> Option>> + 'static, Message: 'static, { - self.category_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.category_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - - self - } - - pub fn on_category_select(mut self, on_select: F) -> Self - where - F: Fn(Category, bool) -> Message + 'a, - { - self.on_category_select = Some(Box::new(on_select)); - self - } - - pub fn on_category_context(mut self, on_select: F) -> Self - where - F: Fn(Category) -> Message + 'a, - { - self.on_category_context = Some(Box::new(on_select)); + self.item_context_builder = Box::new(context_menu_builder); self } } diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs index bf86153c495..3a1eafa05ec 100644 --- a/src/widget/table/widget/standard.rs +++ b/src/widget/table/widget/standard.rs @@ -33,15 +33,15 @@ where #[setters(into)] pub(super) item_padding: Padding, pub(super) item_spacing: u16, + pub(super) icon_size: u16, #[setters(into)] pub(super) divider_padding: Padding, #[setters(skip)] - pub(super) item_context_tree: Option>>, + pub(super) item_context_builder: Box Option>>>, #[setters(skip)] - pub(super) category_context_trees: - std::collections::HashMap>>>, + pub(super) category_contexts: Box Option>>>, #[setters(skip)] pub(super) on_item_select: Option Message + 'a>>, @@ -65,29 +65,34 @@ where fn from(val: TableView<'a, SelectionMode, Item, Category, Message>) -> Self { let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; - let mut category_contexts = val.category_context_trees.into_values(); - let header_row = val .model .categories .iter() + .cloned() .map(|category| { - let cat_context_tree = category_contexts.next().unwrap(); + let cat_context_tree = (val.category_contexts)(category); + + let mut sort_state = 0; + if let Some(sort) = val.model.sort { + if sort.0 == category { + if sort.1 { + sort_state = 1; + } else { + sort_state = 2; + } + } + }; + + // Build the category header widget::row() .spacing(space_xxxs) .push(widget::text::heading(category.to_string())) - .push_maybe(if let Some(sort) = val.model.sort { - if sort.0 == *category { - match sort.1 { - true => Some(widget::icon::from_name("pan-up-symbolic").icon()), - false => Some(widget::icon::from_name("pan-down-symbolic").icon()), - } - } else { - None - } - } else { - None + .push_maybe(match sort_state { + 1 => Some(widget::icon::from_name("pan-up-symbolic").icon()), + 2 => Some(widget::icon::from_name("pan-down-symbolic").icon()), + _ => None, }) .apply(container) .padding( @@ -100,9 +105,9 @@ where .apply(|mouse_area| { if let Some(ref on_category_select) = val.on_category_select { mouse_area.on_press((on_category_select)( - *category, + category, if let Some(sort) = val.model.sort { - if sort.0 == *category { + if sort.0 == category { !sort.1 } else { false @@ -121,6 +126,7 @@ where .collect::>>() .apply(widget::row::with_children) .apply(Element::from); + // Build the items let items_full = if val.model.items.is_empty() { vec![divider::horizontal::default() .apply(container) @@ -129,10 +135,11 @@ where } else { val.model .iter() - .map(|entity| { + .map(move |entity| { let item = val.model.item(entity).unwrap(); let categories = &val.model.categories; let selected = val.model.is_active(entity); + let item_context = (val.item_context_builder)(&item); vec![ divider::horizontal::default() @@ -144,7 +151,10 @@ where .map(|category| { widget::row() .spacing(space_xxxs) - .push_maybe(item.get_icon(*category).map(|icon| icon.size(24))) + .push_maybe( + item.get_icon(*category) + .map(|icon| icon.size(val.icon_size)), + ) .push(widget::text::body(item.get_text(*category))) .align_y(Alignment::Center) .apply(container) @@ -190,6 +200,7 @@ where mouse_area } }) + .apply(|mouse_area| widget::context_menu(mouse_area, item_context)) .apply(Element::from), ] }) @@ -223,7 +234,7 @@ where .. } = theme::active().cosmic().spacing; - let mut result = Self { + Self { model, element_padding: Padding::from(0), @@ -231,21 +242,16 @@ where item_padding: Padding::from(space_xxs).into(), item_spacing: 0, + icon_size: 24, on_item_select: None, on_item_context: None, - item_context_tree: None, + item_context_builder: Box::new(|_| None), on_category_select: None, on_category_context: None, - category_context_trees: std::collections::HashMap::new(), - }; - - for category in model.categories.iter().cloned() { - result.category_context_trees.insert(category, None); + category_contexts: Box::new(|_| None), } - - result } pub fn on_item_select(mut self, on_select: F) -> Self @@ -264,30 +270,21 @@ where self } - pub fn item_context(mut self, context_menu: Option>>) -> Self + pub fn item_context(mut self, context_menu_builder: F) -> Self where + F: Fn(&Item) -> Option>> + 'static, Message: 'static, { - self.item_context_tree = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - - if let Some(ref mut context_menu) = self.item_context_tree { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - + self.item_context_builder = Box::new(context_menu_builder); self } - pub fn category_context(mut self, category: Category, context_menu: Option>>) -> Self + pub fn category_context(mut self, context_menu_builder: F) -> Self where + F: Fn(Category) -> Option>> + 'static, Message: 'static, { - *self.category_context_trees.get_mut(&category).unwrap() = - context_menu.map(|menus| vec![menu::Tree::with_children(widget::row(), menus)]); - if let Some(ref mut context_menu) = self.category_context_trees.get_mut(&category).unwrap() { - context_menu.iter_mut().for_each(menu::Tree::set_index); - } - + self.category_contexts = Box::new(context_menu_builder); self } From 1e28f3977196265730dfb653e30c6ab018c7a042 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Fri, 10 Jan 2025 22:46:20 -0800 Subject: [PATCH 13/16] Ran cargo fmt --- src/widget/table/model/category.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs index c3dd5400600..5c79d404d50 100644 --- a/src/widget/table/model/category.rs +++ b/src/widget/table/model/category.rs @@ -4,7 +4,9 @@ use crate::widget::Icon; /// Implementation of std::fmt::Display allows user to customize the header /// Ideally, this is implemented on an enum. -pub trait ItemCategory: Default + std::fmt::Display + Clone + Copy + PartialEq + Eq + std::hash::Hash { +pub trait ItemCategory: + Default + std::fmt::Display + Clone + Copy + PartialEq + Eq + std::hash::Hash +{ /// Function that gets the width of the data fn width(&self) -> iced::Length; } From d4bce76213f868cf693479e6e231ebae50ec5ba9 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Fri, 10 Jan 2025 22:50:05 -0800 Subject: [PATCH 14/16] Fixed warnings --- src/widget/table/widget/compact.rs | 3 ++- src/widget/table/widget/standard.rs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/widget/table/widget/compact.rs b/src/widget/table/widget/compact.rs index 71b88cbb50f..c75557513d6 100644 --- a/src/widget/table/widget/compact.rs +++ b/src/widget/table/widget/compact.rs @@ -74,7 +74,8 @@ where .spacing(space_xxxs) .align_y(Alignment::Center) .push_maybe( - item.get_icon(Category::default()).map(|icon| icon.size(48)), + item.get_icon(Category::default()) + .map(|icon| icon.size(val.icon_size)), ) .push( widget::column() diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs index 3a1eafa05ec..4f84646ebe2 100644 --- a/src/widget/table/widget/standard.rs +++ b/src/widget/table/widget/standard.rs @@ -6,12 +6,11 @@ use crate::widget::table::model::{ Entity, Model, }; use crate::{ - ext::CollectionWidget, theme, widget::{self, container, divider, menu}, Apply, Element, }; -use iced::{Alignment, Border, Length, Padding}; +use iced::{Alignment, Border, Padding}; // THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED From 160070c1b562b042699dc5ab96b3d2bf58077b37 Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sat, 11 Jan 2025 15:46:05 -0800 Subject: [PATCH 15/16] Added more mouse interaction options --- examples/table-view/src/main.rs | 82 +++++--------- src/widget/table/model/mod.rs | 5 + src/widget/table/widget/compact.rs | 79 ++++++++++--- src/widget/table/widget/standard.rs | 170 ++++++++++++++++++++-------- 4 files changed, 217 insertions(+), 119 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index d7dd8ae8ebc..a80714a9807 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -101,7 +101,8 @@ fn main() -> Result<(), Box> { #[derive(Clone, Debug)] pub enum Message { ItemSelect(table::Entity), - CategorySelect(Category, bool), + CategorySelect(Category), + PrintMsg(String), NoOp, } @@ -187,9 +188,16 @@ impl cosmic::Application for App { fn update(&mut self, message: Self::Message) -> Task { match message { Message::ItemSelect(entity) => self.table_model.activate(entity), - Message::CategorySelect(category, descending) => { - self.table_model.sort(category, descending) + Message::CategorySelect(category) => { + let mut ascending = true; + if let Some(old_sort) = self.table_model.get_sort() { + if old_sort.0 == category { + ascending = !old_sort.1; + } + } + self.table_model.sort(category, ascending) } + Message::PrintMsg(string) => tracing_log::log::info!("{}", string), Message::NoOp => {} } Task::none() @@ -200,7 +208,7 @@ impl cosmic::Application for App { cosmic::widget::responsive(|size| { if size.width < 600.0 { widget::compact_table(&self.table_model) - .on_item_select(Message::ItemSelect) + .on_item_left_click(Message::ItemSelect) .item_context(|item| { Some(widget::menu::items( &HashMap::new(), @@ -214,8 +222,8 @@ impl cosmic::Application for App { .apply(Element::from) } else { widget::table(&self.table_model) - .on_item_select(Message::ItemSelect) - .on_category_select(Message::CategorySelect) + .on_item_left_click(Message::ItemSelect) + .on_category_left_click(Message::CategorySelect) .item_context(|item| { Some(widget::menu::items( &HashMap::new(), @@ -227,53 +235,21 @@ impl cosmic::Application for App { )) }) .category_context(|category| { - Some(match category { - Category::Name => widget::menu::items( - &HashMap::new(), - vec![ - widget::menu::Item::Button( - "Action on Name Category", - None, - Action::None, - ), - widget::menu::Item::Button( - "Other action on Name", - None, - Action::None, - ), - ], - ), - Category::Date => widget::menu::items( - &HashMap::new(), - vec![ - widget::menu::Item::Button( - "Action on Date Category", - None, - Action::None, - ), - widget::menu::Item::Button( - "Other action on Date", - None, - Action::None, - ), - ], - ), - Category::Size => widget::menu::items( - &HashMap::new(), - vec![ - widget::menu::Item::Button( - "Action on Size Category", - None, - Action::None, - ), - widget::menu::Item::Button( - "Other action on Size", - None, - Action::None, - ), - ], - ), - }) + Some(widget::menu::items( + &HashMap::new(), + vec![ + widget::menu::Item::Button( + format!("Action on {} category", category.to_string()), + None, + Action::None, + ), + widget::menu::Item::Button( + format!("Other action on {} category", category.to_string()), + None, + Action::None, + ), + ], + )) }) .apply(Element::from) } diff --git a/src/widget/table/model/mod.rs b/src/widget/table/model/mod.rs index b6935435077..99a7bd67ecc 100644 --- a/src/widget/table/model/mod.rs +++ b/src/widget/table/model/mod.rs @@ -340,6 +340,11 @@ where } } + /// Get the sort data + pub fn get_sort(&self) -> Option<(Category, bool)> { + self.sort + } + /// Sorts items in the model, this should be called before it is drawn after all items have been added for the view pub fn sort(&mut self, category: Category, ascending: bool) { self.sort = Some((category, ascending)); diff --git a/src/widget/table/widget/compact.rs b/src/widget/table/widget/compact.rs index c75557513d6..0264be7130d 100644 --- a/src/widget/table/widget/compact.rs +++ b/src/widget/table/widget/compact.rs @@ -35,13 +35,17 @@ where #[setters(into)] pub(super) divider_padding: Padding, + // === Item Interaction === #[setters(skip)] - pub(super) item_context_builder: Box Option>>>, - + pub(super) on_item_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_double: Option Message + 'static>>, #[setters(skip)] - pub(super) on_item_select: Option Message + 'a>>, + pub(super) on_item_mb_mid: Option Message + 'static>>, #[setters(skip)] - pub(super) on_item_context: Option Message + 'a>>, + pub(super) on_item_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) item_context_builder: Box Option>>>, } impl<'a, SelectionMode, Item, Category, Message> @@ -132,11 +136,36 @@ where } })) .apply(widget::mouse_area) - .apply(|ma| { - if let Some(on_item_select) = &val.on_item_select { - ma.on_press((on_item_select)(entity)) + // Left click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Double click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_double_click((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Middle click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_mid { + mouse_area.on_middle_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Right click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_right { + mouse_area.on_right_press((on_item_mb)(entity)) } else { - ma + mouse_area } }) .apply(|ma| widget::context_menu(ma, context_menu)), @@ -177,25 +206,43 @@ where item_spacing: 0, icon_size: 48, + on_item_mb_left: None, + on_item_mb_double: None, + on_item_mb_mid: None, + on_item_mb_right: None, item_context_builder: Box::new(|_| None), - on_item_select: None, - on_item_context: None, } } - pub fn on_item_select(mut self, on_select: F) -> Self + pub fn on_item_left_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_left = Some(Box::new(on_click)); + self + } + + pub fn on_item_double_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_double = Some(Box::new(on_click)); + self + } + + pub fn on_item_middle_click(mut self, on_click: F) -> Self where - F: Fn(Entity) -> Message + 'a, + F: Fn(Entity) -> Message + 'static, { - self.on_item_select = Some(Box::new(on_select)); + self.on_item_mb_mid = Some(Box::new(on_click)); self } - pub fn on_item_context(mut self, on_select: F) -> Self + pub fn on_item_right_click(mut self, on_click: F) -> Self where - F: Fn(Entity) -> Message + 'a, + F: Fn(Entity) -> Message + 'static, { - self.on_item_context = Some(Box::new(on_select)); + self.on_item_mb_right = Some(Box::new(on_click)); self } diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs index 4f84646ebe2..35762eea0da 100644 --- a/src/widget/table/widget/standard.rs +++ b/src/widget/table/widget/standard.rs @@ -10,7 +10,7 @@ use crate::{ widget::{self, container, divider, menu}, Apply, Element, }; -use iced::{Alignment, Border, Padding}; +use iced::{Alignment, Border, Length, Padding}; // THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED @@ -28,28 +28,45 @@ where #[setters(into)] pub(super) element_padding: Padding, + #[setters(into)] + pub(super) width: Length, + #[setters(into)] + pub(super) height: Length, #[setters(into)] pub(super) item_padding: Padding, pub(super) item_spacing: u16, + pub(super) icon_spacing: u16, pub(super) icon_size: u16, #[setters(into)] pub(super) divider_padding: Padding, + // === Item Interaction === #[setters(skip)] - pub(super) item_context_builder: Box Option>>>, + pub(super) on_item_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_double: Option Message + 'static>>, #[setters(skip)] - pub(super) category_contexts: Box Option>>>, + pub(super) on_item_mb_mid: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) item_context_builder: Box Option>>>, + // Item DND + // === Category Interaction === #[setters(skip)] - pub(super) on_item_select: Option Message + 'a>>, + pub(super) on_category_mb_left: Option Message + 'static>>, #[setters(skip)] - pub(super) on_item_context: Option Message + 'a>>, + pub(super) on_category_mb_double: Option Message + 'static>>, #[setters(skip)] - pub(super) on_category_select: Option Message + 'a>>, + pub(super) on_category_mb_mid: Option Message + 'static>>, #[setters(skip)] - pub(super) on_category_context: Option Message + 'a>>, + pub(super) on_category_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) category_context_builder: + Box Option>>>, } impl<'a, SelectionMode, Item, Category, Message> @@ -62,15 +79,14 @@ where Message: Clone + 'static, { fn from(val: TableView<'a, SelectionMode, Item, Category, Message>) -> Self { - let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; - + // Header row let header_row = val .model .categories .iter() .cloned() .map(|category| { - let cat_context_tree = (val.category_contexts)(category); + let cat_context_tree = (val.category_context_builder)(category); let mut sort_state = 0; @@ -86,7 +102,7 @@ where // Build the category header widget::row() - .spacing(space_xxxs) + .spacing(val.icon_spacing) .push(widget::text::heading(category.to_string())) .push_maybe(match sort_state { 1 => Some(widget::icon::from_name("pan-up-symbolic").icon()), @@ -102,19 +118,8 @@ where .width(category.width()) .apply(widget::mouse_area) .apply(|mouse_area| { - if let Some(ref on_category_select) = val.on_category_select { - mouse_area.on_press((on_category_select)( - category, - if let Some(sort) = val.model.sort { - if sort.0 == category { - !sort.1 - } else { - false - } - } else { - false - }, - )) + if let Some(ref on_category_select) = val.on_category_mb_left { + mouse_area.on_press((on_category_select)(category)) } else { mouse_area } @@ -149,7 +154,7 @@ where .iter() .map(|category| { widget::row() - .spacing(space_xxxs) + .spacing(val.icon_spacing) .push_maybe( item.get_icon(*category) .map(|icon| icon.size(val.icon_size)), @@ -192,9 +197,34 @@ where } })) .apply(widget::mouse_area) + // Left click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Double click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_double_click((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Middle click .apply(|mouse_area| { - if let Some(ref on_item_select) = val.on_item_select { - mouse_area.on_press((on_item_select)(entity)) + if let Some(ref on_item_mb) = val.on_item_mb_mid { + mouse_area.on_middle_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Right click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_right { + mouse_area.on_right_press((on_item_mb)(entity)) } else { mouse_area } @@ -211,6 +241,8 @@ where .flatten() .collect::>>() .apply(widget::column::with_children) + .width(val.width) + .height(val.height) .spacing(val.item_spacing) .padding(val.element_padding) .apply(Element::from) @@ -235,37 +267,61 @@ where Self { model, - element_padding: Padding::from(0), - divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + element_padding: Padding::from(0), + width: Length::Fill, + height: Length::Shrink, item_padding: Padding::from(space_xxs).into(), item_spacing: 0, + icon_spacing: space_xxxs, icon_size: 24, - on_item_select: None, - on_item_context: None, + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + + on_item_mb_left: None, + on_item_mb_double: None, + on_item_mb_mid: None, + on_item_mb_right: None, item_context_builder: Box::new(|_| None), - on_category_select: None, - on_category_context: None, - category_contexts: Box::new(|_| None), + on_category_mb_left: None, + on_category_mb_double: None, + on_category_mb_mid: None, + on_category_mb_right: None, + category_context_builder: Box::new(|_| None), } } - pub fn on_item_select(mut self, on_select: F) -> Self + pub fn on_item_left_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_left = Some(Box::new(on_click)); + self + } + + pub fn on_item_double_click(mut self, on_click: F) -> Self where - F: Fn(Entity) -> Message + 'a, + F: Fn(Entity) -> Message + 'static, { - self.on_item_select = Some(Box::new(on_select)); + self.on_item_mb_double = Some(Box::new(on_click)); self } - pub fn on_item_context(mut self, on_select: F) -> Self + pub fn on_item_middle_click(mut self, on_click: F) -> Self where - F: Fn(Entity) -> Message + 'a, + F: Fn(Entity) -> Message + 'static, { - self.on_item_context = Some(Box::new(on_select)); + self.on_item_mb_mid = Some(Box::new(on_click)); + self + } + + pub fn on_item_right_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_right = Some(Box::new(on_click)); self } @@ -278,28 +334,42 @@ where self } - pub fn category_context(mut self, context_menu_builder: F) -> Self + pub fn on_category_left_click(mut self, on_select: F) -> Self where - F: Fn(Category) -> Option>> + 'static, - Message: 'static, + F: Fn(Category) -> Message + 'static, { - self.category_contexts = Box::new(context_menu_builder); + self.on_category_mb_left = Some(Box::new(on_select)); + self + } + pub fn on_category_double_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_double = Some(Box::new(on_select)); + self + } + pub fn on_category_middle_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_mid = Some(Box::new(on_select)); self } - pub fn on_category_select(mut self, on_select: F) -> Self + pub fn on_category_right_click(mut self, on_select: F) -> Self where - F: Fn(Category, bool) -> Message + 'a, + F: Fn(Category) -> Message + 'static, { - self.on_category_select = Some(Box::new(on_select)); + self.on_category_mb_right = Some(Box::new(on_select)); self } - pub fn on_category_context(mut self, on_select: F) -> Self + pub fn category_context(mut self, context_menu_builder: F) -> Self where - F: Fn(Category) -> Message + 'a, + F: Fn(Category) -> Option>> + 'static, + Message: 'static, { - self.on_category_context = Some(Box::new(on_select)); + self.category_context_builder = Box::new(context_menu_builder); self } } From db4c3d780de98e409ecb2dc8cf4699b3f0873dbc Mon Sep 17 00:00:00 2001 From: Adam Cosner Date: Sat, 18 Jan 2025 22:57:04 -0800 Subject: [PATCH 16/16] Changed insert method and removed default requirement for Item --- examples/table-view/src/main.rs | 6 +++--- src/widget/table/model/category.rs | 2 +- src/widget/table/model/mod.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs index a80714a9807..8b2d4f62a88 100644 --- a/examples/table-view/src/main.rs +++ b/examples/table-view/src/main.rs @@ -143,7 +143,7 @@ impl cosmic::Application for App { let mut table_model = table::Model::new(vec![Category::Name, Category::Date, Category::Size]); - table_model.insert().item(Item { + let _ = table_model.insert(Item { name: "Foo".into(), date: chrono::DateTime::default() .with_day(1) @@ -154,7 +154,7 @@ impl cosmic::Application for App { .unwrap(), size: 2, }); - table_model.insert().item(Item { + let _ = table_model.insert(Item { name: "Bar".into(), date: chrono::DateTime::default() .with_day(2) @@ -165,7 +165,7 @@ impl cosmic::Application for App { .unwrap(), size: 4, }); - table_model.insert().item(Item { + let _ = table_model.insert(Item { name: "Baz".into(), date: chrono::DateTime::default() .with_day(3) diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs index 5c79d404d50..e9bb74778ab 100644 --- a/src/widget/table/model/category.rs +++ b/src/widget/table/model/category.rs @@ -11,7 +11,7 @@ pub trait ItemCategory: fn width(&self) -> iced::Length; } -pub trait ItemInterface: Default { +pub trait ItemInterface { fn get_icon(&self, category: Category) -> Option; fn get_text(&self, category: Category) -> Cow<'static, str>; diff --git a/src/widget/table/model/mod.rs b/src/widget/table/model/mod.rs index 99a7bd67ecc..3af94c57396 100644 --- a/src/widget/table/model/mod.rs +++ b/src/widget/table/model/mod.rs @@ -221,8 +221,8 @@ where /// let id = model.insert().text("Item A").icon("custom-icon").id(); /// ``` #[must_use] - pub fn insert(&mut self) -> EntityMut { - let id = self.items.insert(Item::default()); + pub fn insert(&mut self, item: Item) -> EntityMut { + let id = self.items.insert(item); self.order.push_back(id); EntityMut { model: self, id } }