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..a80714a9807 --- /dev/null +++ b/examples/table-view/src/main.rs @@ -0,0 +1,272 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Table API example + +use std::collections::HashMap; + +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}; + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)] +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::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::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), + PrintMsg(String), + NoOp, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + table_model: table::SingleSelectModel, +} + +/// 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) => { + 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() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + cosmic::widget::responsive(|size| { + if size.width < 600.0 { + widget::compact_table(&self.table_model) + .on_item_left_click(Message::ItemSelect) + .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_left_click(Message::ItemSelect) + .on_category_left_click(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(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) + } + }) + .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/mod.rs b/src/widget/mod.rs index 06dd4e85b45..a51fe7947fe 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -315,6 +315,10 @@ 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; #[doc(inline)] pub use text::{text, Text}; @@ -337,7 +341,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..c39a393db6c --- /dev/null +++ b/src/widget/table/mod.rs @@ -0,0 +1,47 @@ +//! A widget allowing the user to display tables of information with optional sorting by category +//! + +pub mod model; +pub use model::{ + category::ItemCategory, + category::ItemInterface, + selection::{MultiSelect, SingleSelect}, + Entity, Model, +}; +pub mod widget; +pub use widget::compact::CompactTableView; +pub use widget::standard::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; + +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) +} + +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/model/category.rs b/src/widget/table/model/category.rs new file mode 100644 index 00000000000..5c79d404d50 --- /dev/null +++ b/src/widget/table/model/category.rs @@ -0,0 +1,19 @@ +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 + std::hash::Hash +{ + /// 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..99a7bd67ecc --- /dev/null +++ b/src/widget/table/model/mod.rs @@ -0,0 +1,365 @@ +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: Option<(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: None, + 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); + } + } + + /// 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)); + let mut order: Vec = self.order.iter().cloned().collect(); + order.sort_by(|entity_a, entity_b| { + 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/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/compact.rs b/src/widget/table/widget/compact.rs new file mode 100644 index 00000000000..0264be7130d --- /dev/null +++ b/src/widget/table/widget/compact.rs @@ -0,0 +1,257 @@ +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, + pub(super) icon_size: u16, + + #[setters(into)] + pub(super) divider_padding: Padding, + + // === Item Interaction === + #[setters(skip)] + 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_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>>>, +} + +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); + let context_menu = (val.item_context_builder)(&item); + + 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(val.icon_size)), + ) + .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) + // 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 { + mouse_area + } + }) + .apply(|ma| widget::context_menu(ma, context_menu)), + ) + .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, + 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), + } + } + + 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 + 'static, + { + 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 + } + + pub fn item_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(&Item) -> Option>> + 'static, + Message: 'static, + { + self.item_context_builder = Box::new(context_menu_builder); + self + } +} diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs new file mode 100644 index 00000000000..0396796e4a3 --- /dev/null +++ b/src/widget/table/widget/mod.rs @@ -0,0 +1,2 @@ +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..35762eea0da --- /dev/null +++ b/src/widget/table/widget/standard.rs @@ -0,0 +1,375 @@ +use derive_setters::Setters; + +use crate::widget::table::model::{ + category::{ItemCategory, ItemInterface}, + selection::Selectable, + Entity, Model, +}; +use crate::{ + 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) 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) on_item_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_double: Option Message + 'static>>, + #[setters(skip)] + 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_category_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_double: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_mid: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) category_context_builder: + Box Option>>>, +} + +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 { + // Header row + let header_row = val + .model + .categories + .iter() + .cloned() + .map(|category| { + let cat_context_tree = (val.category_context_builder)(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(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()), + 2 => Some(widget::icon::from_name("pan-down-symbolic").icon()), + _ => 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_mb_left { + mouse_area.on_press((on_category_select)(category)) + } else { + mouse_area + } + }) + .apply(|mouse_area| widget::context_menu(mouse_area, cat_context_tree)) + .apply(Element::from) + }) + .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) + .padding(val.divider_padding) + .apply(Element::from)] + } else { + val.model + .iter() + .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() + .apply(container) + .padding(val.divider_padding) + .apply(Element::from), + categories + .iter() + .map(|category| { + widget::row() + .spacing(val.icon_spacing) + .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) + .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) + // 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 { + mouse_area + } + }) + .apply(|mouse_area| widget::context_menu(mouse_area, item_context)) + .apply(Element::from), + ] + }) + .flatten() + .collect::>>() + }; + vec![vec![header_row], items_full] + .into_iter() + .flatten() + .collect::>>() + .apply(widget::column::with_children) + .width(val.width) + .height(val.height) + .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), + width: Length::Fill, + height: Length::Shrink, + + item_padding: Padding::from(space_xxs).into(), + item_spacing: 0, + icon_spacing: space_xxxs, + icon_size: 24, + + 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_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_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 + 'static, + { + 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 + } + + pub fn item_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(&Item) -> Option>> + 'static, + Message: 'static, + { + self.item_context_builder = Box::new(context_menu_builder); + self + } + + pub fn on_category_left_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + 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_right_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_right = Some(Box::new(on_select)); + self + } + + pub fn category_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(Category) -> Option>> + 'static, + Message: 'static, + { + self.category_context_builder = Box::new(context_menu_builder); + self + } +}