diff --git a/crates/story/src/stories/data_table_story.rs b/crates/story/src/stories/data_table_story.rs index 1e552f676..3d9fd4d05 100644 --- a/crates/story/src/stories/data_table_story.rs +++ b/crates/story/src/stories/data_table_story.rs @@ -742,6 +742,7 @@ pub struct DataTableStory { num_extra_cols_input: Entity, stripe: bool, refresh_data: bool, + group_by_enabled: bool, size: Size, _subscriptions: Vec, @@ -841,6 +842,7 @@ impl DataTableStory { num_extra_cols_input, stripe: false, refresh_data: false, + group_by_enabled: false, size: Size::default(), _subscriptions, _load_task, @@ -964,6 +966,22 @@ impl DataTableStory { cx.notify(); } + fn toggle_group_by(&mut self, checked: &bool, _: &mut Window, cx: &mut Context) { + self.group_by_enabled = *checked; + self.table.update(cx, |table, cx| { + if *checked { + table + .delegate_mut() + .stocks + .sort_by(|a, b| a.counter.market.cmp(&b.counter.market)); + table.set_group_by(&[1], cx); + } else { + table.set_group_by(&[], cx); + } + }); + cx.notify(); + } + fn on_change_size(&mut self, a: &ChangeSize, _: &mut Window, cx: &mut Context) { self.size = a.0; cx.notify(); @@ -1011,6 +1029,9 @@ impl DataTableStory { TableEvent::ClearSelection => { println!("Selection cleared"); } + TableEvent::ToggleGroup(key, expanded) => { + println!("Toggle group: {}, expanded={}", key, expanded); + } } } @@ -1166,6 +1187,12 @@ impl Render for DataTableStory { .label("Group Headers") .checked(self.table.read(cx).delegate().show_group_headers) .on_click(cx.listener(Self::toggle_group_headers)), + ) + .child( + Checkbox::new("group-by-market") + .label("Group by Market") + .selected(self.group_by_enabled) + .on_click(cx.listener(Self::toggle_group_by)), ), ) .child( diff --git a/crates/ui/src/table/data_table.rs b/crates/ui/src/table/data_table.rs index 9bf4ed688..47384f1be 100644 --- a/crates/ui/src/table/data_table.rs +++ b/crates/ui/src/table/data_table.rs @@ -98,7 +98,10 @@ where { /// Create a new DataTable element with the given [`TableState`]. pub fn new(state: &Entity>) -> Self { - Self { state: state.clone(), options: TableOptions::default() } + Self { + state: state.clone(), + options: TableOptions::default(), + } } /// Set to use stripe style of the table, default to false. @@ -115,8 +118,11 @@ where /// Set scrollbar visibility. pub fn scrollbar_visible(mut self, vertical: bool, horizontal: bool) -> Self { - self.options.scrollbar_visible = - Edges { right: vertical, bottom: horizontal, ..Default::default() }; + self.options.scrollbar_visible = Edges { + right: vertical, + bottom: horizontal, + ..Default::default() + }; self } } @@ -158,7 +164,9 @@ where .on_action(window.listener_for(&self.state, TableState::action_select_page_down)) .bg(cx.theme().table) .when(bordered, |this| { - this.rounded(cx.theme().radius).border_1().border_color(cx.theme().border) + this.rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) }) .child(self.state) } diff --git a/crates/ui/src/table/delegate.rs b/crates/ui/src/table/delegate.rs index 89d319c41..19b77851f 100644 --- a/crates/ui/src/table/delegate.rs +++ b/crates/ui/src/table/delegate.rs @@ -226,4 +226,41 @@ pub trait TableDelegate: Sized + 'static { fn cell_text(&self, row_ix: usize, col_ix: usize, cx: &App) -> String { String::new() } + + // ── Row Grouping ──────────────────────────────────────────────── + + /// Render the toggle icon (chevron) for a group header row. + /// + /// The default implementation draws a `ChevronRight` / `ChevronDown` + /// icon. Override to customise the icon or spacing. + fn render_row_toggle( + &mut self, + is_expanded: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + let icon = if is_expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + div() + .flex() + .items_center() + .justify_center() + .child(Icon::new(icon).size_3()) + } + + /// Render a group header row. Called for each group row + /// produced by [`TableState::group_by`]. + /// + /// The default renders a label showing the group value and count. + fn render_group_tr( + &mut self, + group: &crate::table::GroupInfo, + _window: &mut Window, + _cx: &mut Context>, + ) -> Stateful
{ + div().id(group.key.clone()).child(group.label.clone()) + } } diff --git a/crates/ui/src/table/state.rs b/crates/ui/src/table/state.rs index c68a06bb1..a63c6c2ba 100644 --- a/crates/ui/src/table/state.rs +++ b/crates/ui/src/table/state.rs @@ -1,4 +1,4 @@ -use std::{ops::Range, rc::Rc, time::Duration}; +use std::{collections::HashSet, ops::Range, rc::Rc, time::Duration}; use crate::{ ActiveTheme, ElementExt, Icon, IconName, StyleSized as _, StyledExt, VirtualListScrollHandle, @@ -13,8 +13,8 @@ use crate::{ }; use gpui::{ AppContext, Axis, Bounds, ClickEvent, Context, Div, DragMoveEvent, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, ListSizingBehavior, MouseButton, MouseDownEvent, - ParentElement, Pixels, Point, Render, ScrollStrategy, SharedString, Stateful, + Focusable, InteractiveElement as _, IntoElement, ListSizingBehavior, MouseButton, + MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Task, UniformListScrollHandle, Window, div, prelude::FluentBuilder, px, uniform_list, }; @@ -94,6 +94,35 @@ pub enum TableEvent { /// /// This event is emitted when the selection is cleared. ClearSelection, + /// A group has been toggled (expanded or collapsed). + /// + /// The `SharedString` is the group key, and the `bool` is the new + /// expanded state (`true` = expanded). + ToggleGroup(SharedString, bool), +} + +/// A grouping level produced by TableState::group_by. +#[derive(Debug, Clone)] +pub struct GroupInfo { + pub key: SharedString, // e.g. "US" or "US/Technology" + pub label: SharedString, // display label + pub depth: usize, // nesting depth + pub count: usize, // data row count + pub start: usize, // first data row index + pub children: Vec, // sub-groups +} + +/// Row type in visible-row list. +#[derive(Debug, Clone)] +pub(crate) enum VisibleRow { + Group { + key: SharedString, + label: SharedString, + depth: usize, + }, + Data { + row_ix: usize, + }, } /// The visible range of the rows and columns. @@ -241,6 +270,14 @@ pub struct TableState { _measure: Vec, _load_more_task: Task<()>, + + // Grouping state + pub(super) group_columns: Vec, + groups: Vec, + visible_rows: Vec, + collapsed_groups: HashSet, + rows_count_cache: usize, + cached_group_columns: Vec, } impl TableState @@ -278,6 +315,12 @@ where col_fixed: true, _load_more_task: Task::ready(()), _measure: Vec::new(), + group_columns: Vec::new(), + groups: Vec::new(), + visible_rows: Vec::new(), + collapsed_groups: HashSet::new(), + rows_count_cache: 0, + cached_group_columns: Vec::new(), }; this.prepare_col_groups(cx); @@ -372,6 +415,111 @@ where self.prepare_col_groups(cx); } + /// Enable row grouping by the given column indices. + /// + /// Rows are grouped by comparing [`TableDelegate::cell_text`] + /// values. Data must be sorted by the group columns for groups + /// to form contiguously. Multiple indices create nested groups + /// (first index = outermost). + pub fn group_by(mut self, columns: &[usize]) -> Self { + self.group_columns = columns.to_vec(); + self + } + + /// Enable or disable row grouping at runtime. + pub fn set_group_by(&mut self, columns: &[usize], cx: &mut Context) { + self.group_columns = columns.to_vec(); + self.invalidate_groups(); + cx.notify(); + } + + /// Rebuild the group tree from current data. Cached until row count + /// or group columns change; call [`Self::invalidate_groups`] to force. + fn compute_groups(&mut self, cx: &App) { + if self.group_columns.is_empty() { + return; + } + let rows_count = self.delegate.rows_count(cx); + if rows_count == 0 { + self.groups.clear(); + self.visible_rows.clear(); + return; + } + let cached = rows_count == self.rows_count_cache + && self.group_columns == self.cached_group_columns + && !self.groups.is_empty(); + if cached { + return; + } + self.rows_count_cache = rows_count; + self.cached_group_columns = self.group_columns.clone(); + self.groups = build_group_tree( + &self.delegate, + &self.group_columns, + 0, + rows_count, + 0, + "", + cx, + ); + self.compute_visible_rows(); + } + + /// Invalidate group cache — call after the delegate's data changes. + pub fn invalidate_groups(&mut self) { + self.rows_count_cache = 0; + } + + /// Returns all group keys visible in the current flat layout. + pub fn groups(&self) -> Vec<&GroupInfo> { + self.groups.iter().collect() + } + + /// Flatten group tree into visible-row list, honouring collapsed state. + fn compute_visible_rows(&mut self) { + self.visible_rows.clear(); + let groups: Vec = self.groups.clone(); + for group in &groups { + self.collect_group(group); + } + } + + fn collect_group(&mut self, group: &GroupInfo) { + self.visible_rows.push(VisibleRow::Group { + key: group.key.clone(), + label: group.label.clone(), + depth: group.depth, + }); + if !self.collapsed_groups.contains(&group.key) { + for child in &group.children { + self.collect_group(child); + } + for row_ix in group.start..(group.start + group.count) { + self.visible_rows.push(VisibleRow::Data { row_ix }); + } + } + } + + /// Toggle group expand/collapse by key. + pub fn toggle_group(&mut self, key: &SharedString, cx: &mut Context) { + let expanded = self.collapsed_groups.contains(key); + if expanded { + self.collapsed_groups.remove(key); + } else { + self.collapsed_groups.insert(key.clone()); + } + self.compute_visible_rows(); + cx.emit(TableEvent::ToggleGroup(key.clone(), !expanded)); + cx.notify(); + } + + /// Expand all groups. + pub fn expand_all(&mut self, cx: &mut Context) { + self.collapsed_groups.clear(); + self.compute_visible_rows(); + cx.notify(); + } + /// Scroll to the row at the given index. pub fn scroll_to_row(&mut self, row_ix: usize, cx: &mut Context) { self.vertical_scroll_handle @@ -2124,6 +2272,63 @@ where .h(Scrollbar::width()) .child(Scrollbar::horizontal(&self.horizontal_scroll_handle)) } + + /// Render a group header row with toggle on the right. + fn render_group_row( + &mut self, + key: &SharedString, + label: &SharedString, + depth: usize, + is_expanded: bool, + window: &mut Window, + cx: &mut Context, + ) -> Stateful
{ + let row_height = self.options.size.table_row_height(); + let group = GroupInfo { + key: key.clone(), + label: label.clone(), + depth, + count: 0, + start: 0, + children: Vec::new(), + }; + let mut tr = self.delegate.render_group_tr(&group, window, cx); + let style = tr.style().clone(); + + tr.h_flex() + .w_full() + .h(row_height) + .px_2() + .justify_between() + .items_center() + .border_b_1() + .border_color(cx.theme().table_row_border) + .refine_style(&style) + .child(self.render_group_toggle(key, is_expanded, window, cx)) + } + + fn render_group_toggle( + &mut self, + key: &SharedString, + is_expanded: bool, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let key = key.clone(); + div() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + this.toggle_group(&key, cx); + }), + ) + .child(self.delegate.render_row_toggle(is_expanded, window, cx)) + } } impl Focusable for TableState @@ -2152,6 +2357,17 @@ where let rows_count = self.delegate.rows_count(cx); let loading = self.delegate.loading(cx); + // Compute groups and visible rows when group_by is enabled + if !self.group_columns.is_empty() { + self.compute_groups(cx); + } else { + self.visible_rows = (0..rows_count) + .map(|i| VisibleRow::Data { row_ix: i }) + .collect(); + } + let visible_rows_for_closure: Rc> = Rc::new(self.visible_rows.clone()); + let visible_rows_count = visible_rows_for_closure.len(); + let row_height = self.options.size.table_row_height(); let total_height = self .vertical_scroll_handle @@ -2161,13 +2377,13 @@ where .bounds() .size .height; - let actual_height = row_height * rows_count as f32; + let actual_height = row_height * visible_rows_count as f32; let extra_rows_count = self.calculate_extra_rows_needed(total_height, actual_height, row_height); let render_rows_count = if self.options.stripe { - rows_count + extra_rows_count + visible_rows_count + extra_rows_count } else { - rows_count + visible_rows_count }; let right_clicked_row = self.right_clicked_row; let is_filled = total_height > Pixels::ZERO && total_height <= actual_height; @@ -2182,7 +2398,7 @@ where None }; - let empty_view = if rows_count == 0 { + let empty_view = if visible_rows_count == 0 { Some( div() .size_full() @@ -2211,7 +2427,8 @@ where } }) .map(|this| { - if rows_count == 0 { + let visible_rows = visible_rows_for_closure.clone(); + if visible_rows_count == 0 { this.children(empty_view) } else { this.child( @@ -2221,6 +2438,7 @@ where render_rows_count, cx.processor( move |table, visible_range: Range, window, cx| { + let visible_rows = &visible_rows; // Use `col.width` (always up-to-date) rather than // `col.bounds.size.width`, which is only set after // prepaint and is therefore zero on the first frame. @@ -2249,13 +2467,13 @@ where cx, ); - if visible_range.end > rows_count { - table.scroll_to_row( + if visible_range.end > visible_rows_count { + table.vertical_scroll_handle.scroll_to_item( std::cmp::min( visible_range.start, - rows_count.saturating_sub(1), + visible_rows_count.saturating_sub(1), ), - cx, + ScrollStrategy::Top, ); } @@ -2263,19 +2481,32 @@ where visible_range.end.saturating_sub(visible_range.start), ); - // Render fake rows to fill the table - visible_range.for_each(|row_ix| { - // Render real rows for available data - items.push(table.render_table_row( - row_ix, - rows_count, - left_columns_count, - col_sizes.clone(), - columns_count, - is_filled, - window, - cx, - )); + visible_range.for_each(|render_ix| { + let visible_row = visible_rows + .get(render_ix) + .cloned() + .unwrap_or(VisibleRow::Data { row_ix: rows_count }); + match visible_row { + VisibleRow::Group { key, label, depth } => { + let expanded = + !table.collapsed_groups.contains(&key); + items.push(table.render_group_row( + &key, &label, depth, expanded, window, cx, + )); + } + VisibleRow::Data { row_ix } => { + items.push(table.render_table_row( + row_ix, + rows_count, + left_columns_count, + col_sizes.clone(), + columns_count, + is_filled, + window, + cx, + )); + } + } }); items @@ -2322,10 +2553,75 @@ where this.child(self.render_horizontal_scrollbar(window, cx)) }) .when( - self.options.scrollbar_visible.right && rows_count > 0, + self.options.scrollbar_visible.right && visible_rows_count > 0, |this| this.children(self.render_vertical_scrollbar(window, cx)), ), ) }) } } + +/// Build group tree by scanning data rows and detecting changes in cell_text. +fn build_group_tree( + delegate: &D, + columns: &[usize], + data_start: usize, + data_end: usize, + depth: usize, + parent_key: &str, + cx: &App, +) -> Vec { + let mut groups = Vec::new(); + if columns.is_empty() || data_start >= data_end { + return groups; + } + let col_ix = columns[0]; + let mut current_start = data_start; + let mut current_key: SharedString = delegate.cell_text(data_start, col_ix, cx).into(); + + for row_ix in (data_start + 1)..=data_end { + let key: SharedString = if row_ix < data_end { + delegate.cell_text(row_ix, col_ix, cx).into() + } else { + SharedString::new("") + }; + let group_ends = row_ix == data_end || key != current_key; + if group_ends { + let label = current_key.clone(); + let full_key: SharedString = if parent_key.is_empty() { + current_key.clone() + } else { + format!("{}/{}", parent_key, current_key).into() + }; + let count = row_ix - current_start; + let children = if columns.len() > 1 { + build_group_tree( + delegate, + &columns[1..], + current_start, + row_ix, + depth + 1, + &full_key, + cx, + ) + } else { + Vec::new() + }; + // Append sub-groups to the flat tree + groups.push(GroupInfo { + key: full_key, + label, + depth, + count, + start: current_start, + children, + }); + // Don't extend with children — they're in the tree now + if row_ix < data_end { + current_start = row_ix; + current_key = key; + } + } + } + groups +}