Add `ui::table` (#20447)

Nate Butler created

This PR adds the `ui::Table` component.

It has a rather simple API, but cells can contain either strings or
elements, allowing for some complex uses.

Example usage:

```rust
Table::new(vec!["Product", "Price", "Stock"])
    .width(px(600.))
    .striped()
    .row(vec!["Laptop", "$999", "In Stock"])
    .row(vec!["Phone", "$599", "Low Stock"])
    .row(vec!["Tablet", "$399", "Out of Stock"])
```

For more complex use cases, the table supports mixed content:

```rust
Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"])
    .width(px(840.))
    .row(vec![
        element_cell(Indicator::dot().color(Color::Success).into_any_element()),
        string_cell("Project A"),
        string_cell("High"),
        string_cell("2023-12-31"),
        element_cell(Button::new("view_a", "View").style(ButtonStyle::Filled).full_width().into_any_element()),
    ])
    // ... more rows
```

Preview:

![CleanShot 2024-11-08 at 20 53
04@2x](https://github.com/user-attachments/assets/b39122f0-a29b-423b-8e24-86ab4c42bac2)

This component is pretty basic, improvements are welcome!

Release Notes:

- N/A

Change summary

crates/ui/src/components.rs               |   2 
crates/ui/src/components/button/button.rs |   8 
crates/ui/src/components/checkbox.rs      |   4 
crates/ui/src/components/facepile.rs      |   2 
crates/ui/src/components/icon.rs          |   4 
crates/ui/src/components/indicator.rs     |   4 
crates/ui/src/components/table.rs         | 239 +++++++++++++++++++++++++
crates/ui/src/traits/component_preview.rs |  58 +++++
crates/workspace/src/theme_preview.rs     |   9 
9 files changed, 306 insertions(+), 24 deletions(-)

Detailed changes

crates/ui/src/components.rs 🔗

@@ -26,6 +26,7 @@ mod settings_group;
 mod stack;
 mod tab;
 mod tab_bar;
+mod table;
 mod tool_strip;
 mod tooltip;
 
@@ -60,6 +61,7 @@ pub use settings_group::*;
 pub use stack::*;
 pub use tab::*;
 pub use tab_bar::*;
+pub use table::*;
 pub use tool_strip::*;
 pub use tooltip::*;
 

crates/ui/src/components/button/button.rs 🔗

@@ -445,7 +445,7 @@ impl ComponentPreview for Button {
 
     fn examples() -> Vec<ComponentExampleGroup<Self>> {
         vec![
-            example_group(
+            example_group_with_title(
                 "Styles",
                 vec![
                     single_example("Default", Button::new("default", "Default")),
@@ -463,7 +463,7 @@ impl ComponentPreview for Button {
                     ),
                 ],
             ),
-            example_group(
+            example_group_with_title(
                 "Tinted",
                 vec![
                     single_example(
@@ -488,7 +488,7 @@ impl ComponentPreview for Button {
                     ),
                 ],
             ),
-            example_group(
+            example_group_with_title(
                 "States",
                 vec![
                     single_example("Default", Button::new("default_state", "Default")),
@@ -502,7 +502,7 @@ impl ComponentPreview for Button {
                     ),
                 ],
             ),
-            example_group(
+            example_group_with_title(
                 "With Icons",
                 vec![
                     single_example(

crates/ui/src/components/checkbox.rs 🔗

@@ -123,7 +123,7 @@ impl ComponentPreview for Checkbox {
 
     fn examples() -> Vec<ComponentExampleGroup<Self>> {
         vec![
-            example_group(
+            example_group_with_title(
                 "Default",
                 vec![
                     single_example(
@@ -140,7 +140,7 @@ impl ComponentPreview for Checkbox {
                     ),
                 ],
             ),
-            example_group(
+            example_group_with_title(
                 "Disabled",
                 vec![
                     single_example(

crates/ui/src/components/facepile.rs 🔗

@@ -83,7 +83,7 @@ impl ComponentPreview for Facepile {
             "https://avatars.githubusercontent.com/u/1714999?s=60&v=4",
         ];
 
-        vec![example_group(
+        vec![example_group_with_title(
             "Examples",
             vec![
                 single_example(

crates/ui/src/components/icon.rs 🔗

@@ -6,7 +6,7 @@ use ui_macros::DerivePathStr;
 
 use crate::{
     prelude::*,
-    traits::component_preview::{example_group, ComponentExample, ComponentPreview},
+    traits::component_preview::{ComponentExample, ComponentPreview},
     Indicator,
 };
 
@@ -510,7 +510,7 @@ impl ComponentPreview for Icon {
             IconName::ArrowCircle,
         ];
 
-        vec![example_group(
+        vec![example_group_with_title(
             "Arrow Icons",
             arrow_icons
                 .into_iter()

crates/ui/src/components/indicator.rs 🔗

@@ -91,7 +91,7 @@ impl ComponentPreview for Indicator {
 
     fn examples() -> Vec<ComponentExampleGroup<Self>> {
         vec![
-            example_group(
+            example_group_with_title(
                 "Types",
                 vec![
                     single_example("Dot", Indicator::dot().color(Color::Info)),
@@ -102,7 +102,7 @@ impl ComponentPreview for Indicator {
                     ),
                 ],
             ),
-            example_group(
+            example_group_with_title(
                 "Examples",
                 vec![
                     single_example("Info", Indicator::dot().color(Color::Info)),

crates/ui/src/components/table.rs 🔗

@@ -0,0 +1,239 @@
+use crate::{prelude::*, Indicator};
+use gpui::{div, AnyElement, FontWeight, IntoElement, Length};
+
+/// A table component
+#[derive(IntoElement)]
+pub struct Table {
+    column_headers: Vec<SharedString>,
+    rows: Vec<Vec<TableCell>>,
+    column_count: usize,
+    striped: bool,
+    width: Length,
+}
+
+impl Table {
+    /// Create a new table with a column count equal to the
+    /// number of headers provided.
+    pub fn new(headers: Vec<impl Into<SharedString>>) -> Self {
+        let column_count = headers.len();
+
+        Table {
+            column_headers: headers.into_iter().map(Into::into).collect(),
+            column_count,
+            rows: Vec::new(),
+            striped: false,
+            width: Length::Auto,
+        }
+    }
+
+    /// Adds a row to the table.
+    ///
+    /// The row must have the same number of columns as the table.
+    pub fn row(mut self, items: Vec<impl Into<TableCell>>) -> Self {
+        if items.len() == self.column_count {
+            self.rows.push(items.into_iter().map(Into::into).collect());
+        } else {
+            // TODO: Log error: Row length mismatch
+        }
+        self
+    }
+
+    /// Adds multiple rows to the table.
+    ///
+    /// Each row must have the same number of columns as the table.
+    /// Rows that don't match the column count are ignored.
+    pub fn rows(mut self, rows: Vec<Vec<impl Into<TableCell>>>) -> Self {
+        for row in rows {
+            self = self.row(row);
+        }
+        self
+    }
+
+    fn base_cell_style(cx: &WindowContext) -> Div {
+        div()
+            .px_1p5()
+            .flex_1()
+            .justify_start()
+            .text_ui(cx)
+            .whitespace_nowrap()
+            .text_ellipsis()
+            .overflow_hidden()
+    }
+
+    /// Enables row striping.
+    pub fn striped(mut self) -> Self {
+        self.striped = true;
+        self
+    }
+
+    /// Sets the width of the table.
+    pub fn width(mut self, width: impl Into<Length>) -> Self {
+        self.width = width.into();
+        self
+    }
+}
+
+impl RenderOnce for Table {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let header = div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .justify_between()
+            .w_full()
+            .p_2()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .children(self.column_headers.into_iter().map(|h| {
+                Self::base_cell_style(cx)
+                    .font_weight(FontWeight::SEMIBOLD)
+                    .child(h)
+            }));
+
+        let row_count = self.rows.len();
+        let rows = self.rows.into_iter().enumerate().map(|(ix, row)| {
+            let is_last = ix == row_count - 1;
+            let bg = if ix % 2 == 1 && self.striped {
+                Some(cx.theme().colors().text.opacity(0.05))
+            } else {
+                None
+            };
+            div()
+                .w_full()
+                .flex()
+                .flex_row()
+                .items_center()
+                .justify_between()
+                .px_1p5()
+                .py_1()
+                .when_some(bg, |row, bg| row.bg(bg))
+                .when(!is_last, |row| {
+                    row.border_b_1().border_color(cx.theme().colors().border)
+                })
+                .children(row.into_iter().map(|cell| match cell {
+                    TableCell::String(s) => Self::base_cell_style(cx).child(s),
+                    TableCell::Element(e) => Self::base_cell_style(cx).child(e),
+                }))
+        });
+
+        div()
+            .w(self.width)
+            .overflow_hidden()
+            .child(header)
+            .children(rows)
+    }
+}
+
+/// Represents a cell in a table.
+pub enum TableCell {
+    /// A cell containing a string value.
+    String(SharedString),
+    /// A cell containing a UI element.
+    Element(AnyElement),
+}
+
+/// Creates a `TableCell` containing a string value.
+pub fn string_cell(s: impl Into<SharedString>) -> TableCell {
+    TableCell::String(s.into())
+}
+
+/// Creates a `TableCell` containing an element.
+pub fn element_cell(e: impl Into<AnyElement>) -> TableCell {
+    TableCell::Element(e.into())
+}
+
+impl<E> From<E> for TableCell
+where
+    E: Into<SharedString>,
+{
+    fn from(e: E) -> Self {
+        TableCell::String(e.into())
+    }
+}
+
+impl ComponentPreview for Table {
+    fn description() -> impl Into<Option<&'static str>> {
+        "Used for showing tabular data. Tables may show both text and elements in their cells."
+    }
+
+    fn example_label_side() -> ExampleLabelSide {
+        ExampleLabelSide::Top
+    }
+
+    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+        vec![
+            example_group(vec![
+                single_example(
+                    "Simple Table",
+                    Table::new(vec!["Name", "Age", "City"])
+                        .width(px(400.))
+                        .row(vec!["Alice", "28", "New York"])
+                        .row(vec!["Bob", "32", "San Francisco"])
+                        .row(vec!["Charlie", "25", "London"]),
+                ),
+                single_example(
+                    "Two Column Table",
+                    Table::new(vec!["Category", "Value"])
+                        .width(px(300.))
+                        .row(vec!["Revenue", "$100,000"])
+                        .row(vec!["Expenses", "$75,000"])
+                        .row(vec!["Profit", "$25,000"]),
+                ),
+            ]),
+            example_group(vec![single_example(
+                "Striped Table",
+                Table::new(vec!["Product", "Price", "Stock"])
+                    .width(px(600.))
+                    .striped()
+                    .row(vec!["Laptop", "$999", "In Stock"])
+                    .row(vec!["Phone", "$599", "Low Stock"])
+                    .row(vec!["Tablet", "$399", "Out of Stock"])
+                    .row(vec!["Headphones", "$199", "In Stock"]),
+            )]),
+            example_group_with_title(
+                "Mixed Content Table",
+                vec![single_example(
+                    "Table with Elements",
+                    Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"])
+                        .width(px(840.))
+                        .row(vec![
+                            element_cell(Indicator::dot().color(Color::Success).into_any_element()),
+                            string_cell("Project A"),
+                            string_cell("High"),
+                            string_cell("2023-12-31"),
+                            element_cell(
+                                Button::new("view_a", "View")
+                                    .style(ButtonStyle::Filled)
+                                    .full_width()
+                                    .into_any_element(),
+                            ),
+                        ])
+                        .row(vec![
+                            element_cell(Indicator::dot().color(Color::Warning).into_any_element()),
+                            string_cell("Project B"),
+                            string_cell("Medium"),
+                            string_cell("2024-03-15"),
+                            element_cell(
+                                Button::new("view_b", "View")
+                                    .style(ButtonStyle::Filled)
+                                    .full_width()
+                                    .into_any_element(),
+                            ),
+                        ])
+                        .row(vec![
+                            element_cell(Indicator::dot().color(Color::Error).into_any_element()),
+                            string_cell("Project C"),
+                            string_cell("Low"),
+                            string_cell("2024-06-30"),
+                            element_cell(
+                                Button::new("view_c", "View")
+                                    .style(ButtonStyle::Filled)
+                                    .full_width()
+                                    .into_any_element(),
+                            ),
+                        ]),
+                )],
+            ),
+        ]
+    }
+}

crates/ui/src/traits/component_preview.rs 🔗

@@ -2,6 +2,20 @@
 use crate::prelude::*;
 use gpui::{AnyElement, SharedString};
 
+/// Which side of the preview to show labels on
+#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExampleLabelSide {
+    /// Left side
+    Left,
+    /// Right side
+    Right,
+    /// Top side
+    Top,
+    #[default]
+    /// Bottom side
+    Bottom,
+}
+
 /// Implement this trait to enable rich UI previews with metadata in the Theme Preview tool.
 pub trait ComponentPreview: IntoElement {
     fn title() -> &'static str {
@@ -12,6 +26,10 @@ pub trait ComponentPreview: IntoElement {
         None
     }
 
+    fn example_label_side() -> ExampleLabelSide {
+        ExampleLabelSide::default()
+    }
+
     fn examples() -> Vec<ComponentExampleGroup<Self>>;
 
     fn component_previews() -> Vec<AnyElement> {
@@ -62,7 +80,9 @@ pub trait ComponentPreview: IntoElement {
     fn render_example_group(group: ComponentExampleGroup<Self>) -> AnyElement {
         v_flex()
             .gap_2()
-            .child(Label::new(group.title).size(LabelSize::Small))
+            .when_some(group.title, |this, title| {
+                this.child(Headline::new(title).size(HeadlineSize::Small))
+            })
             .child(
                 h_flex()
                     .gap_6()
@@ -73,8 +93,16 @@ pub trait ComponentPreview: IntoElement {
     }
 
     fn render_example(example: ComponentExample<Self>) -> AnyElement {
-        v_flex()
-            .gap_1()
+        let base = div().flex();
+
+        let base = match Self::example_label_side() {
+            ExampleLabelSide::Right => base.flex_row(),
+            ExampleLabelSide::Left => base.flex_row_reverse(),
+            ExampleLabelSide::Bottom => base.flex_col(),
+            ExampleLabelSide::Top => base.flex_col_reverse(),
+        };
+
+        base.gap_1()
             .child(example.element)
             .child(
                 Label::new(example.variant_name)
@@ -103,15 +131,22 @@ impl<T> ComponentExample<T> {
 
 /// A group of component examples.
 pub struct ComponentExampleGroup<T> {
-    pub title: SharedString,
+    pub title: Option<SharedString>,
     pub examples: Vec<ComponentExample<T>>,
 }
 
 impl<T> ComponentExampleGroup<T> {
     /// Create a new group of examples with the given title.
-    pub fn new(title: impl Into<SharedString>, examples: Vec<ComponentExample<T>>) -> Self {
+    pub fn new(examples: Vec<ComponentExample<T>>) -> Self {
         Self {
-            title: title.into(),
+            title: None,
+            examples,
+        }
+    }
+
+    pub fn with_title(title: impl Into<SharedString>, examples: Vec<ComponentExample<T>>) -> Self {
+        Self {
+            title: Some(title.into()),
             examples,
         }
     }
@@ -122,10 +157,15 @@ pub fn single_example<T>(variant_name: impl Into<SharedString>, example: T) -> C
     ComponentExample::new(variant_name, example)
 }
 
-/// Create a group of examples
-pub fn example_group<T>(
+/// Create a group of examples without a title
+pub fn example_group<T>(examples: Vec<ComponentExample<T>>) -> ComponentExampleGroup<T> {
+    ComponentExampleGroup::new(examples)
+}
+
+/// Create a group of examples with a title
+pub fn example_group_with_title<T>(
     title: impl Into<SharedString>,
     examples: Vec<ComponentExample<T>>,
 ) -> ComponentExampleGroup<T> {
-    ComponentExampleGroup::new(title, examples)
+    ComponentExampleGroup::with_title(title, examples)
 }

crates/workspace/src/theme_preview.rs 🔗

@@ -1,11 +1,11 @@
 #![allow(unused, dead_code)]
-use gpui::{actions, AppContext, EventEmitter, FocusHandle, FocusableView, Hsla};
+use gpui::{actions, hsla, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, Hsla};
 use strum::IntoEnumIterator;
 use theme::all_theme_colors;
 use ui::{
-    prelude::*, utils::calculate_contrast_ratio, AudioStatus, Availability, Avatar,
-    AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, Checkbox, ElevationIndex,
-    Facepile, Indicator, TintColor, Tooltip,
+    element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio, AudioStatus,
+    Availability, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
+    Checkbox, ElevationIndex, Facepile, Indicator, Table, TintColor, Tooltip,
 };
 
 use crate::{Item, Workspace};
@@ -514,6 +514,7 @@ impl ThemePreview {
             .child(Button::render_component_previews(cx))
             .child(Indicator::render_component_previews(cx))
             .child(Icon::render_component_previews(cx))
+            .child(Table::render_component_previews(cx))
             .child(self.render_avatars(cx))
             .child(self.render_buttons(layer, cx))
     }