feat: Implement resizeable columns

HalavicH created

Change summary

crates/git_graph/src/git_graph.rs      |   1 
crates/ui/src/components/data_table.rs | 328 ++++++++++++++++++++++++++-
2 files changed, 313 insertions(+), 16 deletions(-)

Detailed changes

crates/git_graph/src/git_graph.rs 🔗

@@ -2599,6 +2599,7 @@ impl Render for GitGraph {
                             ),
                             header_context,
                             Some(header_resize_info),
+                            None,
                             Some(self.column_widths.entity_id()),
                             cx,
                         ))

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

@@ -1,19 +1,19 @@
 use std::{ops::Range, rc::Rc};
 
 use gpui::{
-    DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior,
-    ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list,
-    transparent_black, uniform_list,
+    AbsoluteLength, AppContext as _, ClickEvent, DefiniteLength, DragMoveEvent, Empty, Entity,
+    EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState,
+    Point, ScrollHandle, Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black,
+    uniform_list,
 };
-
 use crate::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
     ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo,
     Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState,
     RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString,
-    StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar,
-    bind_redistributable_columns, div, example_group_with_title, h_flex, px,
-    render_redistributable_columns_resize_handles, single_example,
+    StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, TableResizeBehavior,
+    Window, WithScrollbar, bind_redistributable_columns, div, example_group_with_title, h_flex,
+    px, render_redistributable_columns_resize_handles, single_example,
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
@@ -22,10 +22,98 @@ pub mod table_row;
 #[cfg(test)]
 mod tests;
 
+const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
+const RESIZE_COLUMN_WIDTH: f32 = 8.0;
+
+/// Used as the drag payload when resizing columns in `Resizable` mode.
+#[derive(Debug)]
+pub(crate) struct DraggedResizableColumn(pub(crate) usize);
+
 /// Represents an unchecked table row, which is a vector of elements.
 /// Will be converted into `TableRow<T>` internally
 pub type UncheckedTableRow<T> = Vec<T>;
 
+/// State for independently resizable columns (spreadsheet-style).
+///
+/// Each column has its own absolute width; dragging a resize handle changes only
+/// that column's width, growing or shrinking the overall table width.
+pub struct ResizableColumnsState {
+    initial_widths: TableRow<AbsoluteLength>,
+    widths: TableRow<AbsoluteLength>,
+    resize_behavior: TableRow<TableResizeBehavior>,
+}
+
+impl ResizableColumnsState {
+    pub fn new(
+        cols: usize,
+        initial_widths: Vec<impl Into<AbsoluteLength>>,
+        resize_behavior: Vec<TableResizeBehavior>,
+    ) -> Self {
+        let widths: TableRow<AbsoluteLength> = initial_widths
+            .into_iter()
+            .map(Into::into)
+            .collect::<Vec<_>>()
+            .into_table_row(cols);
+        Self {
+            initial_widths: widths.clone(),
+            widths,
+            resize_behavior: resize_behavior.into_table_row(cols),
+        }
+    }
+
+    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
+        &self.resize_behavior
+    }
+
+    pub(crate) fn on_drag_move(
+        &mut self,
+        drag_event: &DragMoveEvent<DraggedResizableColumn>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let col_idx = drag_event.drag(cx).0;
+        let rem_size = window.rem_size();
+        let drag_x = drag_event.event.position.x - drag_event.bounds.left();
+
+        let left_edge: Pixels = self.widths.as_slice()[..col_idx]
+            .iter()
+            .map(|width| width.to_pixels(rem_size))
+            .fold(px(0.), |acc, x| acc + x)
+            + px(col_idx as f32 * RESIZE_DIVIDER_WIDTH);
+
+        let new_width = drag_x - left_edge;
+        let new_width = self.apply_min_size(new_width, self.resize_behavior[col_idx], rem_size);
+
+        self.widths[col_idx] = AbsoluteLength::Pixels(new_width);
+        cx.notify();
+    }
+
+    pub fn on_double_click(&mut self, col_idx: usize, _window: &mut Window) {
+        self.widths[col_idx] = self.initial_widths[col_idx];
+    }
+
+    fn apply_min_size(
+        &self,
+        width: Pixels,
+        behavior: TableResizeBehavior,
+        rem_size: Pixels,
+    ) -> Pixels {
+        match behavior.min_size() {
+            Some(min_rems) => {
+                let min_px = rem_size * min_rems;
+                width.max(min_px)
+            }
+            None => width,
+        }
+    }
+}
+
+/// Info passed to `render_table_header` for resizable-column double-click reset.
+pub struct ResizableHeaderInfo {
+    pub entity: WeakEntity<ResizableColumnsState>,
+    pub resize_behavior: TableRow<TableResizeBehavior>,
+}
+
 struct UniformListData {
     render_list_of_rows_fn:
         Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<UncheckedTableRow<AnyElement>>>,
@@ -71,6 +159,7 @@ impl TableContents {
 pub struct TableInteractionState {
     pub focus_handle: FocusHandle,
     pub scroll_handle: UniformListScrollHandle,
+    pub horizontal_scroll_handle: ScrollHandle,
     pub custom_scrollbar: Option<Scrollbars>,
 }
 
@@ -79,6 +168,7 @@ impl TableInteractionState {
         Self {
             focus_handle: cx.focus_handle(),
             scroll_handle: UniformListScrollHandle::new(),
+            horizontal_scroll_handle: ScrollHandle::new(),
             custom_scrollbar: None,
         }
     }
@@ -120,6 +210,9 @@ pub enum ColumnWidthConfig {
         columns_state: Entity<RedistributableColumnsState>,
         table_width: Option<DefiniteLength>,
     },
+    /// Independently resizable columns — dragging changes absolute column widths
+    /// and thus the overall table width. Like spreadsheets.
+    Resizable(Entity<ResizableColumnsState>),
 }
 
 pub enum StaticColumnWidths {
@@ -184,24 +277,47 @@ impl ColumnWidthConfig {
                 columns_state: entity,
                 ..
             } => Some(entity.read(cx).widths_to_render()),
+            ColumnWidthConfig::Resizable(entity) => {
+                let state = entity.read(cx);
+                Some(state.widths.map_cloned(|abs| {
+                    Length::Definite(DefiniteLength::Absolute(abs))
+                }))
+            }
         }
     }
 
     /// Table-level width.
-    pub fn table_width(&self) -> Option<Length> {
+    pub fn table_width(&self, window: &Window, cx: &App) -> Option<Length> {
         match self {
             ColumnWidthConfig::Static { table_width, .. }
             | ColumnWidthConfig::Redistributable { table_width, .. } => {
                 table_width.map(Length::Definite)
             }
+            ColumnWidthConfig::Resizable(entity) => {
+                let state = entity.read(cx);
+                let rem_size = window.rem_size();
+                let total: Pixels = state
+                    .widths
+                    .as_slice()
+                    .iter()
+                    .map(|abs| abs.to_pixels(rem_size))
+                    .fold(px(0.), |acc, x| acc + x)
+                    + px((state.widths.cols().saturating_sub(1)) as f32 * RESIZE_DIVIDER_WIDTH);
+                Some(Length::Definite(DefiniteLength::Absolute(
+                    AbsoluteLength::Pixels(total),
+                )))
+            }
         }
     }
 
     /// ListHorizontalSizingBehavior for uniform_list.
-    pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior {
-        match self.table_width() {
-            Some(_) => ListHorizontalSizingBehavior::Unconstrained,
-            None => ListHorizontalSizingBehavior::FitList,
+    pub fn list_horizontal_sizing(&self, window: &Window, cx: &App) -> ListHorizontalSizingBehavior {
+        match self {
+            ColumnWidthConfig::Resizable(_) => ListHorizontalSizingBehavior::FitList,
+            _ => match self.table_width(window, cx) {
+                Some(_) => ListHorizontalSizingBehavior::Unconstrained,
+                None => ListHorizontalSizingBehavior::FitList,
+            },
         }
     }
 }
@@ -473,6 +589,7 @@ pub fn render_table_header(
     headers: TableRow<impl IntoElement>,
     table_context: TableRenderContext,
     resize_info: Option<HeaderResizeInfo>,
+    resizable_info: Option<ResizableHeaderInfo>,
     entity_id: Option<EntityId>,
     cx: &mut App,
 ) -> impl IntoElement {
@@ -528,6 +645,22 @@ pub fn render_table_header(
                                 this
                             }
                         })
+                        .when_some(resizable_info.as_ref(), |this, info| {
+                            if info.resize_behavior[header_idx].is_resizable() {
+                                let entity = info.entity.clone();
+                                this.on_click(move |event: &ClickEvent, window, cx| {
+                                    if event.click_count() > 1 {
+                                        entity
+                                            .update(cx, |state, _| {
+                                                state.on_double_click(header_idx, window);
+                                            })
+                                            .ok();
+                                    }
+                                })
+                            } else {
+                                this
+                            }
+                        })
                 }),
         )
 }
@@ -572,6 +705,101 @@ impl TableRenderContext {
     }
 }
 
+fn render_resize_handles_resizable(
+    columns_state: &Entity<ResizableColumnsState>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (column_widths, resize_behavior) = {
+        let state = columns_state.read(cx);
+        (
+            state
+                .widths
+                .map_cloned(|abs| Length::Definite(DefiniteLength::Absolute(abs))),
+            state.resize_behavior.clone(),
+        )
+    };
+
+    let resize_behavior = Rc::new(resize_behavior);
+    // Each column contributes a spacer; between columns there is a resize divider.
+    // Structure: [spacer_0][divider_0][spacer_1][divider_1]...[spacer_N-1]
+    let n_cols = column_widths.cols();
+    let mut elements: Vec<AnyElement> = Vec::with_capacity(n_cols * 2 - 1);
+
+    for (col_idx, width) in column_widths.as_slice().iter().copied().enumerate() {
+        elements.push(div().w(width).h_full().into_any_element());
+
+        // Add a resize divider after every column except the last.
+        if col_idx + 1 < n_cols {
+            let resize_behavior = Rc::clone(&resize_behavior);
+            let columns_state = columns_state.clone();
+            let divider = window.with_id(col_idx, |window| {
+                let mut resize_divider = div()
+                    .id(col_idx)
+                    .relative()
+                    .top_0()
+                    .w(px(RESIZE_DIVIDER_WIDTH))
+                    .h_full()
+                    .bg(cx.theme().colors().border.opacity(0.8));
+
+                let mut resize_handle = div()
+                    .id("column-resize-handle")
+                    .absolute()
+                    .left_neg_0p5()
+                    .w(px(RESIZE_COLUMN_WIDTH))
+                    .h_full();
+
+                if resize_behavior[col_idx].is_resizable() {
+                    let is_highlighted = window.use_state(cx, |_window, _cx| false);
+
+                    resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
+                        div.bg(cx.theme().colors().border_focused)
+                    });
+
+                    resize_handle = resize_handle
+                        .on_hover({
+                            let is_highlighted = is_highlighted.clone();
+                            move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
+                        })
+                        .cursor_col_resize()
+                        .on_click({
+                            let columns_state = columns_state.clone();
+                            move |event: &ClickEvent, window, cx| {
+                                if event.click_count() >= 2 {
+                                    columns_state.update(cx, |state, _| {
+                                        state.on_double_click(col_idx, window);
+                                    });
+                                }
+                                cx.stop_propagation();
+                            }
+                        })
+                        .on_drag(DraggedResizableColumn(col_idx), {
+                            let is_highlighted = is_highlighted.clone();
+                            move |_, _offset, _window, cx| {
+                                is_highlighted.write(cx, true);
+                                cx.new(|_cx| Empty)
+                            }
+                        })
+                        .on_drop::<DraggedResizableColumn>(move |_, _, cx| {
+                            is_highlighted.write(cx, false);
+                        });
+                }
+
+                resize_divider.child(resize_handle).into_any_element()
+            });
+            elements.push(divider);
+        }
+    }
+
+    h_flex()
+        .id("resize-handles")
+        .absolute()
+        .inset_0()
+        .w_full()
+        .children(elements)
+        .into_any_element()
+}
+
 impl RenderOnce for Table {
     fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let table_context = TableRenderContext::new(&self, cx);
@@ -587,8 +815,19 @@ impl RenderOnce for Table {
                     _ => None,
                 });
 
-        let table_width = self.column_width_config.table_width();
-        let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
+        let resizable_header_info =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Resizable(entity) => Some(ResizableHeaderInfo {
+                        entity: entity.downgrade(),
+                        resize_behavior: entity.read(cx).resize_behavior.clone(),
+                    }),
+                    _ => None,
+                });
+
+        let table_width = self.column_width_config.table_width(window, cx);
+        let horizontal_sizing = self.column_width_config.list_horizontal_sizing(window, cx);
         let no_rows_rendered = self.rows.is_empty();
 
         // Extract redistributable entity for drag/drop/prepaint handlers
@@ -603,6 +842,17 @@ impl RenderOnce for Table {
                     _ => None,
                 });
 
+        // Extract resizable entity for drag-move handler
+        let resizable_entity =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Resizable(entity) => Some(entity.clone()),
+                    _ => None,
+                });
+
+        let is_resizable = resizable_entity.is_some();
+
         let resize_handles =
             interaction_state
                 .as_ref()
@@ -610,11 +860,17 @@ impl RenderOnce for Table {
                     ColumnWidthConfig::Redistributable { columns_state, .. } => Some(
                         render_redistributable_columns_resize_handles(columns_state, window, cx),
                     ),
+                    ColumnWidthConfig::Resizable(entity) => {
+                        Some(render_resize_handles_resizable(entity, window, cx))
+                    }
                     _ => None,
                 });
 
         let table = div()
-            .when_some(table_width, |this, width| this.w(width))
+            .when_some(
+                if is_resizable { None } else { table_width },
+                |this, width| this.w(width),
+            )
             .h_full()
             .v_flex()
             .when_some(self.headers.take(), |this, headers| {
@@ -622,6 +878,7 @@ impl RenderOnce for Table {
                     headers,
                     table_context.clone(),
                     header_resize_info,
+                    resizable_header_info,
                     interaction_state.as_ref().map(Entity::entity_id),
                     cx,
                 ))
@@ -629,6 +886,11 @@ impl RenderOnce for Table {
             .when_some(redistributable_entity, |this, widths| {
                 bind_redistributable_columns(this, widths)
             })
+            .when_some(resizable_entity, |this, entity| {
+                this.on_drag_move::<DraggedResizableColumn>(move |event, window, cx| {
+                    entity.update(cx, |state, cx| state.on_drag_move(event, window, cx));
+                })
+            })
             .child({
                 let content = div()
                     .flex_grow()
@@ -742,7 +1004,41 @@ impl RenderOnce for Table {
                 },
             );
 
-        if let Some(interaction_state) = interaction_state.as_ref() {
+        // For resizable mode, wrap in a horizontal-scroll container
+        let table_wrapper = div().size_full();
+
+        if is_resizable {
+            if let Some(state) = interaction_state.as_ref() {
+                let h_scroll_container = div()
+                    .id("table-h-scroll")
+                    .overflow_x_scroll()
+                    .flex_grow()
+                    .h_full()
+                    .when_some(table_width, |this, width| this.w(width))
+                    .track_scroll(&state.read(cx).horizontal_scroll_handle)
+                    .child(table);
+
+                let outer = table_wrapper
+                    .child(h_scroll_container)
+                    .custom_scrollbars(
+                        Scrollbars::new(ScrollAxes::Horizontal)
+                            .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle),
+                        window,
+                        cx,
+                    );
+
+                if let Some(interaction_state) = interaction_state.as_ref() {
+                    outer
+                        .track_focus(&interaction_state.read(cx).focus_handle)
+                        .id(("table", interaction_state.entity_id()))
+                        .into_any_element()
+                } else {
+                    outer.into_any_element()
+                }
+            } else {
+                table.into_any_element()
+            }
+        } else if let Some(interaction_state) = interaction_state.as_ref() {
             table
                 .track_focus(&interaction_state.read(cx).focus_handle)
                 .id(("table", interaction_state.entity_id()))