diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 06dd4e85..0a11f051 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 00000000..9f4ce541 --- /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 00000000..efa4624a --- /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 00000000..44dd79a5 --- /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 00000000..43fe47c3 --- /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 00000000..24b7b67d --- /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 00000000..ef7ad410 --- /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() + } +}