git_graph: Make the graph canvas resizable (#52953)

Anthony Eid created

### Summary

This PR integrates the git graph canvas with the `Table` component’s
`RedistributableColumnsState`, making the graph column resizable while
preserving the table’s existing resize behavior. In particular, column
resizing continues to use the same cascading redistribution behavior as
the table. This is also the last PR needed to remove the feature flag on
the git graph!

### Table API changes

I pulled the redistributable column logic out of `Table` into reusable
UI helpers so layouts outside of `Table` can participate in the same
column resizing behavior. This adds a shared
`RedistributableColumnsState` API, along with helpers for binding
drag/drop behavior, rendering resize handles, and constructing header
resize metadata. I also added `ColumnWidthConfig::explicit` and
`TableRenderContext::for_column_widths` so callers can render table like
headers and content with externally managed column widths.

The reason for this change is that the git graph now renders a custom
split layout: a graph canvas on the left and table content on the right.
By reusing the same column state and resize machinery, the graph column
can resize together with the table columns while preserving the existing
table behavior, including cascading column redistribution and double
click reset to default sizing.

I also adjusted the resize handle interaction styling so the divider
stays in its hovered/highlighted state while a drag is active, which
makes the drag target feel more stable and visually consistent during
resizing.

### Preview

https://github.com/user-attachments/assets/347eed71-0cc1-4db4-9dee-a86ee5ab6f91



Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A or Added/Fixed/Improved ...

Change summary

crates/git_graph/src/git_graph.rs                   | 402 +++++++---
crates/ui/src/components.rs                         |   2 
crates/ui/src/components/data_table.rs              | 532 +-------------
crates/ui/src/components/data_table/tests.rs        |   3 
crates/ui/src/components/redistributable_columns.rs | 485 +++++++++++++
5 files changed, 824 insertions(+), 600 deletions(-)

Detailed changes

crates/git_graph/src/git_graph.rs 🔗

@@ -42,8 +42,10 @@ use theme_settings::ThemeSettings;
 use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
 use ui::{
     ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
-    HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
-    TableResizeBehavior, Tooltip, WithScrollbar, prelude::*,
+    HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table,
+    TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar,
+    bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles,
+    render_table_header, table_row::TableRow,
 };
 use workspace::{
     Workspace,
@@ -901,9 +903,8 @@ pub struct GitGraph {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     row_height: Pixels,
     table_interaction_state: Entity<TableInteractionState>,
-    table_column_widths: Entity<RedistributableColumnsState>,
+    column_widths: Entity<RedistributableColumnsState>,
     horizontal_scroll_offset: Pixels,
-    graph_viewport_width: Pixels,
     selected_entry_idx: Option<usize>,
     hovered_entry_idx: Option<usize>,
     graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
@@ -933,8 +934,60 @@ impl GitGraph {
         font_size + px(12.0)
     }
 
-    fn graph_content_width(&self) -> Pixels {
-        (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
+    fn graph_canvas_content_width(&self) -> Pixels {
+        (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0
+    }
+
+    fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
+        let fractions = self
+            .column_widths
+            .read(cx)
+            .preview_fractions(window.rem_size());
+        [
+            fractions[0],
+            fractions[1],
+            fractions[2],
+            fractions[3],
+            fractions[4],
+        ]
+    }
+
+    fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig {
+        let [_, description, date, author, commit] = self.preview_column_fractions(window, cx);
+        let table_total = description + date + author + commit;
+
+        let widths = if table_total > 0.0 {
+            vec![
+                DefiniteLength::Fraction(description / table_total),
+                DefiniteLength::Fraction(date / table_total),
+                DefiniteLength::Fraction(author / table_total),
+                DefiniteLength::Fraction(commit / table_total),
+            ]
+        } else {
+            vec![
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+            ]
+        };
+
+        ColumnWidthConfig::explicit(widths)
+    }
+
+    fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
+        self.column_widths
+            .read(cx)
+            .preview_column_width(0, window)
+            .unwrap_or_else(|| self.graph_canvas_content_width())
+    }
+
+    fn clamp_horizontal_scroll_offset(&mut self, graph_viewport_width: Pixels) {
+        let max_horizontal_scroll =
+            (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
+        self.horizontal_scroll_offset = self
+            .horizontal_scroll_offset
+            .clamp(px(0.), max_horizontal_scroll);
     }
 
     pub fn new(
@@ -972,20 +1025,22 @@ impl GitGraph {
         });
 
         let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
-        let table_column_widths = cx.new(|_cx| {
+        let column_widths = cx.new(|_cx| {
             RedistributableColumnsState::new(
-                4,
+                5,
                 vec![
-                    DefiniteLength::Fraction(0.72),
-                    DefiniteLength::Fraction(0.12),
-                    DefiniteLength::Fraction(0.10),
-                    DefiniteLength::Fraction(0.06),
+                    DefiniteLength::Fraction(0.14),
+                    DefiniteLength::Fraction(0.6192),
+                    DefiniteLength::Fraction(0.1032),
+                    DefiniteLength::Fraction(0.086),
+                    DefiniteLength::Fraction(0.0516),
                 ],
                 vec![
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
+                    TableResizeBehavior::Resizable,
                 ],
             )
         });
@@ -1020,9 +1075,8 @@ impl GitGraph {
             context_menu: None,
             row_height,
             table_interaction_state,
-            table_column_widths,
+            column_widths,
             horizontal_scroll_offset: px(0.),
-            graph_viewport_width: px(88.),
             selected_entry_idx: None,
             hovered_entry_idx: None,
             graph_canvas_bounds: Rc::new(Cell::new(None)),
@@ -2089,8 +2143,12 @@ impl GitGraph {
         let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
         let horizontal_scroll_offset = self.horizontal_scroll_offset;
 
-        let max_lanes = self.graph_data.max_lanes.max(6);
-        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
+        let graph_viewport_width = self.graph_viewport_width(window, cx);
+        let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
+            self.graph_canvas_content_width()
+        } else {
+            graph_viewport_width
+        };
         let last_visible_row =
             first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
 
@@ -2414,9 +2472,9 @@ impl GitGraph {
         let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
         let new_offset = Point::new(current_offset.x, new_y);
 
-        let max_lanes = self.graph_data.max_lanes.max(1);
-        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
-        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
+        let graph_viewport_width = self.graph_viewport_width(window, cx);
+        let max_horizontal_scroll =
+            (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
 
         let new_horizontal_offset =
             (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
@@ -2497,6 +2555,8 @@ impl Render for GitGraph {
                             cx,
                         );
                         self.graph_data.add_commits(&commits);
+                        let graph_viewport_width = self.graph_viewport_width(window, cx);
+                        self.clamp_horizontal_scroll_offset(graph_viewport_width);
                         (commits.len(), is_loading)
                     })
                 } else {
@@ -2527,118 +2587,202 @@ impl Render for GitGraph {
                     this.child(self.render_loading_spinner(cx))
                 })
         } else {
-            div()
+            let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx);
+            let header_context = TableRenderContext::for_column_widths(
+                Some(self.column_widths.read(cx).widths_to_render()),
+                true,
+            );
+            let [
+                graph_fraction,
+                description_fraction,
+                date_fraction,
+                author_fraction,
+                commit_fraction,
+            ] = self.preview_column_fractions(window, cx);
+            let table_fraction =
+                description_fraction + date_fraction + author_fraction + commit_fraction;
+            let table_width_config = self.table_column_width_config(window, cx);
+            let graph_viewport_width = self.graph_viewport_width(window, cx);
+            self.clamp_horizontal_scroll_offset(graph_viewport_width);
+
+            h_flex()
                 .size_full()
-                .flex()
-                .flex_row()
                 .child(
                     div()
-                        .w(self.graph_content_width())
-                        .h_full()
+                        .flex_1()
+                        .min_w_0()
+                        .size_full()
                         .flex()
                         .flex_col()
-                        .child(
-                            div()
-                                .flex()
-                                .items_center()
-                                .px_1()
-                                .py_0p5()
-                                .border_b_1()
-                                .whitespace_nowrap()
-                                .border_color(cx.theme().colors().border)
-                                .child(Label::new("Graph").color(Color::Muted)),
-                        )
-                        .child(
-                            div()
-                                .id("graph-canvas")
-                                .flex_1()
-                                .overflow_hidden()
-                                .child(self.render_graph(window, cx))
-                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
-                                .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
-                                .on_click(cx.listener(Self::handle_graph_click))
-                                .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
-                                    if !is_hovered && this.hovered_entry_idx.is_some() {
-                                        this.hovered_entry_idx = None;
-                                        cx.notify();
-                                    }
-                                })),
-                        ),
-                )
-                .child({
-                    let row_height = self.row_height;
-                    let selected_entry_idx = self.selected_entry_idx;
-                    let hovered_entry_idx = self.hovered_entry_idx;
-                    let weak_self = cx.weak_entity();
-                    let focus_handle = self.focus_handle.clone();
-                    div().flex_1().size_full().child(
-                        Table::new(4)
-                            .interactable(&self.table_interaction_state)
-                            .hide_row_borders()
-                            .hide_row_hover()
-                            .header(vec![
-                                Label::new("Description")
-                                    .color(Color::Muted)
-                                    .into_any_element(),
-                                Label::new("Date").color(Color::Muted).into_any_element(),
-                                Label::new("Author").color(Color::Muted).into_any_element(),
-                                Label::new("Commit").color(Color::Muted).into_any_element(),
-                            ])
-                            .width_config(ColumnWidthConfig::redistributable(
-                                self.table_column_widths.clone(),
-                            ))
-                            .map_row(move |(index, row), window, cx| {
-                                let is_selected = selected_entry_idx == Some(index);
-                                let is_hovered = hovered_entry_idx == Some(index);
-                                let is_focused = focus_handle.is_focused(window);
-                                let weak = weak_self.clone();
-                                let weak_for_hover = weak.clone();
-
-                                let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
-                                let selected_bg = if is_focused {
-                                    cx.theme().colors().element_selected
-                                } else {
-                                    cx.theme().colors().element_hover
-                                };
-
-                                row.h(row_height)
-                                    .when(is_selected, |row| row.bg(selected_bg))
-                                    .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
-                                    .on_hover(move |&is_hovered, _, cx| {
-                                        weak_for_hover
-                                            .update(cx, |this, cx| {
-                                                if is_hovered {
-                                                    if this.hovered_entry_idx != Some(index) {
-                                                        this.hovered_entry_idx = Some(index);
-                                                        cx.notify();
-                                                    }
-                                                } else if this.hovered_entry_idx == Some(index) {
-                                                    // Only clear if this row was the hovered one
-                                                    this.hovered_entry_idx = None;
-                                                    cx.notify();
-                                                }
-                                            })
-                                            .ok();
-                                    })
-                                    .on_click(move |event, window, cx| {
-                                        let click_count = event.click_count();
-                                        weak.update(cx, |this, cx| {
-                                            this.select_entry(index, ScrollStrategy::Center, cx);
-                                            if click_count >= 2 {
-                                                this.open_commit_view(index, window, cx);
-                                            }
-                                        })
-                                        .ok();
-                                    })
-                                    .into_any_element()
-                            })
-                            .uniform_list(
-                                "git-graph-commits",
-                                commit_count,
-                                cx.processor(Self::render_table_rows),
+                        .child(render_table_header(
+                            TableRow::from_vec(
+                                vec![
+                                    Label::new("Graph")
+                                        .color(Color::Muted)
+                                        .truncate()
+                                        .into_any_element(),
+                                    Label::new("Description")
+                                        .color(Color::Muted)
+                                        .into_any_element(),
+                                    Label::new("Date").color(Color::Muted).into_any_element(),
+                                    Label::new("Author").color(Color::Muted).into_any_element(),
+                                    Label::new("Commit").color(Color::Muted).into_any_element(),
+                                ],
+                                5,
                             ),
-                    )
-                })
+                            header_context,
+                            Some(header_resize_info),
+                            Some(self.column_widths.entity_id()),
+                            cx,
+                        ))
+                        .child({
+                            let row_height = self.row_height;
+                            let selected_entry_idx = self.selected_entry_idx;
+                            let hovered_entry_idx = self.hovered_entry_idx;
+                            let weak_self = cx.weak_entity();
+                            let focus_handle = self.focus_handle.clone();
+
+                            bind_redistributable_columns(
+                                div()
+                                    .relative()
+                                    .flex_1()
+                                    .w_full()
+                                    .overflow_hidden()
+                                    .child(
+                                        h_flex()
+                                            .size_full()
+                                            .child(
+                                                div()
+                                                    .w(DefiniteLength::Fraction(graph_fraction))
+                                                    .h_full()
+                                                    .min_w_0()
+                                                    .overflow_hidden()
+                                                    .child(
+                                                        div()
+                                                            .id("graph-canvas")
+                                                            .size_full()
+                                                            .overflow_hidden()
+                                                            .child(
+                                                                div()
+                                                                    .size_full()
+                                                                    .child(self.render_graph(window, cx)),
+                                                            )
+                                                            .on_scroll_wheel(
+                                                                cx.listener(Self::handle_graph_scroll),
+                                                            )
+                                                            .on_mouse_move(
+                                                                cx.listener(Self::handle_graph_mouse_move),
+                                                            )
+                                                            .on_click(cx.listener(Self::handle_graph_click))
+                                                            .on_hover(cx.listener(
+                                                                |this, &is_hovered: &bool, _, cx| {
+                                                                    if !is_hovered
+                                                                        && this.hovered_entry_idx.is_some()
+                                                                    {
+                                                                        this.hovered_entry_idx = None;
+                                                                        cx.notify();
+                                                                    }
+                                                                },
+                                                            )),
+                                                    ),
+                                            )
+                                            .child(
+                                                div()
+                                                    .w(DefiniteLength::Fraction(table_fraction))
+                                                    .h_full()
+                                                    .min_w_0()
+                                                    .child(
+                                                        Table::new(4)
+                                                            .interactable(&self.table_interaction_state)
+                                                            .hide_row_borders()
+                                                            .hide_row_hover()
+                                                            .width_config(table_width_config)
+                                                            .map_row(move |(index, row), window, cx| {
+                                                                let is_selected =
+                                                                    selected_entry_idx == Some(index);
+                                                                let is_hovered =
+                                                                    hovered_entry_idx == Some(index);
+                                                                let is_focused =
+                                                                    focus_handle.is_focused(window);
+                                                                let weak = weak_self.clone();
+                                                                let weak_for_hover = weak.clone();
+
+                                                                let hover_bg = cx
+                                                                    .theme()
+                                                                    .colors()
+                                                                    .element_hover
+                                                                    .opacity(0.6);
+                                                                let selected_bg = if is_focused {
+                                                                    cx.theme().colors().element_selected
+                                                                } else {
+                                                                    cx.theme().colors().element_hover
+                                                                };
+
+                                                                row.h(row_height)
+                                                                    .when(is_selected, |row| row.bg(selected_bg))
+                                                                    .when(
+                                                                        is_hovered && !is_selected,
+                                                                        |row| row.bg(hover_bg),
+                                                                    )
+                                                                    .on_hover(move |&is_hovered, _, cx| {
+                                                                        weak_for_hover
+                                                                            .update(cx, |this, cx| {
+                                                                                if is_hovered {
+                                                                                    if this.hovered_entry_idx
+                                                                                        != Some(index)
+                                                                                    {
+                                                                                        this.hovered_entry_idx =
+                                                                                            Some(index);
+                                                                                        cx.notify();
+                                                                                    }
+                                                                                } else if this
+                                                                                    .hovered_entry_idx
+                                                                                    == Some(index)
+                                                                                {
+                                                                                    this.hovered_entry_idx =
+                                                                                        None;
+                                                                                    cx.notify();
+                                                                                }
+                                                                            })
+                                                                            .ok();
+                                                                    })
+                                                                    .on_click(move |event, window, cx| {
+                                                                        let click_count = event.click_count();
+                                                                        weak.update(cx, |this, cx| {
+                                                                            this.select_entry(
+                                                                                index,
+                                                                                ScrollStrategy::Center,
+                                                                                cx,
+                                                                            );
+                                                                            if click_count >= 2 {
+                                                                                this.open_commit_view(
+                                                                                    index,
+                                                                                    window,
+                                                                                    cx,
+                                                                                );
+                                                                            }
+                                                                        })
+                                                                        .ok();
+                                                                    })
+                                                                    .into_any_element()
+                                                            })
+                                                            .uniform_list(
+                                                                "git-graph-commits",
+                                                                commit_count,
+                                                                cx.processor(Self::render_table_rows),
+                                                            ),
+                                                    ),
+                                            ),
+                                    )
+                                    .child(render_redistributable_columns_resize_handles(
+                                        &self.column_widths,
+                                        window,
+                                        cx,
+                                    )),
+                                self.column_widths.clone(),
+                            )
+                        }),
+                )
                 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
                     this.commit_details_split_state.update(cx, |state, cx| {
                         state.on_drag_move(event, window, cx);
@@ -3734,9 +3878,11 @@ mod tests {
         });
         cx.run_until_parked();
 
-        git_graph.update_in(&mut *cx, |this, window, cx| {
-            this.render(window, cx);
-        });
+        cx.draw(
+            point(px(0.), px(0.)),
+            gpui::size(px(1200.), px(800.)),
+            |_, _| git_graph.clone().into_any_element(),
+        );
         cx.run_until_parked();
 
         let commit_count_after_switch_back =

crates/ui/src/components.rs 🔗

@@ -29,6 +29,7 @@ mod notification;
 mod popover;
 mod popover_menu;
 mod progress;
+mod redistributable_columns;
 mod right_click_menu;
 mod scrollbar;
 mod stack;
@@ -73,6 +74,7 @@ pub use notification::*;
 pub use popover::*;
 pub use popover_menu::*;
 pub use progress::*;
+pub use redistributable_columns::*;
 pub use right_click_menu::*;
 pub use scrollbar::*;
 pub use stack::*;

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

@@ -1,19 +1,19 @@
 use std::{ops::Range, rc::Rc};
 
 use gpui::{
-    AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle,
-    Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful,
-    UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
+    DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior,
+    ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list,
+    transparent_black, uniform_list,
 };
-use itertools::intersperse_with;
 
 use crate::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
-    ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
-    InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
-    StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
-    px, single_example,
+    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,
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
@@ -22,16 +22,10 @@ pub mod table_row;
 #[cfg(test)]
 mod tests;
 
-const RESIZE_COLUMN_WIDTH: f32 = 8.0;
-const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
-
 /// Represents an unchecked table row, which is a vector of elements.
 /// Will be converted into `TableRow<T>` internally
 pub type UncheckedTableRow<T> = Vec<T>;
 
-#[derive(Debug)]
-pub(crate) struct DraggedColumn(pub(crate) usize);
-
 struct UniformListData {
     render_list_of_rows_fn:
         Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<UncheckedTableRow<AnyElement>>>,
@@ -113,124 +107,6 @@ impl TableInteractionState {
     }
 }
 
-/// Renders invisible resize handles overlaid on top of table content.
-///
-/// - Spacer: invisible element that matches the width of table column content
-/// - Divider: contains the actual resize handle that users can drag to resize columns
-///
-/// Structure: [spacer] [divider] [spacer] [divider] [spacer]
-///
-/// Business logic:
-/// 1. Creates spacers matching each column width
-/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
-/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
-/// 4. Returns an absolute-positioned overlay that sits on top of table content
-fn render_resize_handles(
-    column_widths: &TableRow<Length>,
-    resizable_columns: &TableRow<TableResizeBehavior>,
-    initial_sizes: &TableRow<DefiniteLength>,
-    columns: Option<Entity<RedistributableColumnsState>>,
-    window: &mut Window,
-    cx: &mut App,
-) -> AnyElement {
-    let spacers = column_widths
-        .as_slice()
-        .iter()
-        .map(|width| base_cell_style(Some(*width)).into_any_element());
-
-    let mut column_ix = 0;
-    let resizable_columns_shared = Rc::new(resizable_columns.clone());
-    let initial_sizes_shared = Rc::new(initial_sizes.clone());
-    let mut resizable_columns_iter = resizable_columns.as_slice().iter();
-
-    let dividers = intersperse_with(spacers, || {
-        let resizable_columns = Rc::clone(&resizable_columns_shared);
-        let initial_sizes = Rc::clone(&initial_sizes_shared);
-        window.with_id(column_ix, |window| {
-            let mut resize_divider = div()
-                .id(column_ix)
-                .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 resizable_columns_iter
-                .next()
-                .is_some_and(TableResizeBehavior::is_resizable)
-            {
-                let hovered = window.use_state(cx, |_window, _cx| false);
-
-                resize_divider = resize_divider.when(*hovered.read(cx), |div| {
-                    div.bg(cx.theme().colors().border_focused)
-                });
-
-                resize_handle = resize_handle
-                    .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
-                    .cursor_col_resize()
-                    .when_some(columns.clone(), |this, columns| {
-                        this.on_click(move |event, window, cx| {
-                            if event.click_count() >= 2 {
-                                columns.update(cx, |columns, _| {
-                                    columns.on_double_click(
-                                        column_ix,
-                                        &initial_sizes,
-                                        &resizable_columns,
-                                        window,
-                                    );
-                                })
-                            }
-
-                            cx.stop_propagation();
-                        })
-                    })
-                    .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
-                        cx.new(|_cx| gpui::Empty)
-                    })
-            }
-
-            column_ix += 1;
-            resize_divider.child(resize_handle).into_any_element()
-        })
-    });
-
-    h_flex()
-        .id("resize-handles")
-        .absolute()
-        .inset_0()
-        .w_full()
-        .children(dividers)
-        .into_any_element()
-}
-
-#[derive(Debug, Copy, Clone, PartialEq)]
-pub enum TableResizeBehavior {
-    None,
-    Resizable,
-    MinSize(f32),
-}
-
-impl TableResizeBehavior {
-    pub fn is_resizable(&self) -> bool {
-        *self != TableResizeBehavior::None
-    }
-
-    pub fn min_size(&self) -> Option<f32> {
-        match self {
-            TableResizeBehavior::None => None,
-            TableResizeBehavior::Resizable => Some(0.05),
-            TableResizeBehavior::MinSize(min_size) => Some(*min_size),
-        }
-    }
-}
-
 pub enum ColumnWidthConfig {
     /// Static column widths (no resize handles).
     Static {
@@ -278,6 +154,21 @@ impl ColumnWidthConfig {
         }
     }
 
+    /// Explicit column widths with no fixed table width.
+    pub fn explicit<T: Into<DefiniteLength>>(widths: Vec<T>) -> Self {
+        let cols = widths.len();
+        ColumnWidthConfig::Static {
+            widths: StaticColumnWidths::Explicit(
+                widths
+                    .into_iter()
+                    .map(Into::into)
+                    .collect::<Vec<_>>()
+                    .into_table_row(cols),
+            ),
+            table_width: None,
+        }
+    }
+
     /// Column widths for rendering.
     pub fn widths_to_render(&self, cx: &App) -> Option<TableRow<Length>> {
         match self {
@@ -292,10 +183,7 @@ impl ColumnWidthConfig {
             ColumnWidthConfig::Redistributable {
                 columns_state: entity,
                 ..
-            } => {
-                let state = entity.read(cx);
-                Some(state.preview_widths.map_cloned(Length::Definite))
-            }
+            } => Some(entity.read(cx).widths_to_render()),
         }
     }
 
@@ -316,296 +204,6 @@ impl ColumnWidthConfig {
             None => ListHorizontalSizingBehavior::FitList,
         }
     }
-
-    /// Render resize handles overlay if applicable.
-    pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
-        match self {
-            ColumnWidthConfig::Redistributable {
-                columns_state: entity,
-                ..
-            } => {
-                let (column_widths, resize_behavior, initial_widths) = {
-                    let state = entity.read(cx);
-                    (
-                        state.preview_widths.map_cloned(Length::Definite),
-                        state.resize_behavior.clone(),
-                        state.initial_widths.clone(),
-                    )
-                };
-                Some(render_resize_handles(
-                    &column_widths,
-                    &resize_behavior,
-                    &initial_widths,
-                    Some(entity.clone()),
-                    window,
-                    cx,
-                ))
-            }
-            _ => None,
-        }
-    }
-
-    /// Returns info needed for header double-click-to-reset, if applicable.
-    pub fn header_resize_info(&self, cx: &App) -> Option<HeaderResizeInfo> {
-        match self {
-            ColumnWidthConfig::Redistributable { columns_state, .. } => {
-                let state = columns_state.read(cx);
-                Some(HeaderResizeInfo {
-                    columns_state: columns_state.downgrade(),
-                    resize_behavior: state.resize_behavior.clone(),
-                    initial_widths: state.initial_widths.clone(),
-                })
-            }
-            _ => None,
-        }
-    }
-}
-
-#[derive(Clone)]
-pub struct HeaderResizeInfo {
-    pub columns_state: WeakEntity<RedistributableColumnsState>,
-    pub resize_behavior: TableRow<TableResizeBehavior>,
-    pub initial_widths: TableRow<DefiniteLength>,
-}
-
-pub struct RedistributableColumnsState {
-    pub(crate) initial_widths: TableRow<DefiniteLength>,
-    pub(crate) committed_widths: TableRow<DefiniteLength>,
-    pub(crate) preview_widths: TableRow<DefiniteLength>,
-    pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
-    pub(crate) cached_table_width: Pixels,
-}
-
-impl RedistributableColumnsState {
-    pub fn new(
-        cols: usize,
-        initial_widths: UncheckedTableRow<impl Into<DefiniteLength>>,
-        resize_behavior: UncheckedTableRow<TableResizeBehavior>,
-    ) -> Self {
-        let widths: TableRow<DefiniteLength> = initial_widths
-            .into_iter()
-            .map(Into::into)
-            .collect::<Vec<_>>()
-            .into_table_row(cols);
-        Self {
-            initial_widths: widths.clone(),
-            committed_widths: widths.clone(),
-            preview_widths: widths,
-            resize_behavior: resize_behavior.into_table_row(cols),
-            cached_table_width: Default::default(),
-        }
-    }
-
-    pub fn cols(&self) -> usize {
-        self.committed_widths.cols()
-    }
-
-    pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
-        &self.initial_widths
-    }
-
-    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
-        &self.resize_behavior
-    }
-
-    fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
-        match length {
-            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
-            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
-                rems_width.to_pixels(rem_size) / bounds_width
-            }
-            DefiniteLength::Fraction(fraction) => *fraction,
-        }
-    }
-
-    pub(crate) fn on_double_click(
-        &mut self,
-        double_click_position: usize,
-        initial_sizes: &TableRow<DefiniteLength>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-        window: &mut Window,
-    ) {
-        let bounds_width = self.cached_table_width;
-        let rem_size = window.rem_size();
-        let initial_sizes =
-            initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-        let widths = self
-            .committed_widths
-            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-
-        let updated_widths = Self::reset_to_initial_size(
-            double_click_position,
-            widths,
-            initial_sizes,
-            resize_behavior,
-        );
-        self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
-        self.preview_widths = self.committed_widths.clone();
-    }
-
-    pub(crate) fn reset_to_initial_size(
-        col_idx: usize,
-        mut widths: TableRow<f32>,
-        initial_sizes: TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-    ) -> TableRow<f32> {
-        let diff = initial_sizes[col_idx] - widths[col_idx];
-
-        let left_diff =
-            initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
-        let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
-            - widths[col_idx + 1..].iter().sum::<f32>();
-
-        let go_left_first = if diff < 0.0 {
-            left_diff > right_diff
-        } else {
-            left_diff < right_diff
-        };
-
-        if !go_left_first {
-            let diff_remaining =
-                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
-
-            if diff_remaining != 0.0 && col_idx > 0 {
-                Self::propagate_resize_diff(
-                    diff_remaining,
-                    col_idx,
-                    &mut widths,
-                    resize_behavior,
-                    -1,
-                );
-            }
-        } else {
-            let diff_remaining =
-                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
-
-            if diff_remaining != 0.0 {
-                Self::propagate_resize_diff(
-                    diff_remaining,
-                    col_idx,
-                    &mut widths,
-                    resize_behavior,
-                    1,
-                );
-            }
-        }
-
-        widths
-    }
-
-    pub(crate) fn on_drag_move(
-        &mut self,
-        drag_event: &DragMoveEvent<DraggedColumn>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let drag_position = drag_event.event.position;
-        let bounds = drag_event.bounds;
-
-        let mut col_position = 0.0;
-        let rem_size = window.rem_size();
-        let bounds_width = bounds.right() - bounds.left();
-        let col_idx = drag_event.drag(cx).0;
-
-        let divider_width = Self::get_fraction(
-            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
-            bounds_width,
-            rem_size,
-        );
-
-        let mut widths = self
-            .committed_widths
-            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-
-        for length in widths[0..=col_idx].iter() {
-            col_position += length + divider_width;
-        }
-
-        let mut total_length_ratio = col_position;
-        for length in widths[col_idx + 1..].iter() {
-            total_length_ratio += length;
-        }
-        let cols = self.resize_behavior.cols();
-        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
-
-        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
-        let drag_fraction = drag_fraction * total_length_ratio;
-        let diff = drag_fraction - col_position - divider_width / 2.0;
-
-        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
-
-        self.preview_widths = widths.map(DefiniteLength::Fraction);
-    }
-
-    pub(crate) fn drag_column_handle(
-        diff: f32,
-        col_idx: usize,
-        widths: &mut TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-    ) {
-        if diff > 0.0 {
-            Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
-        } else {
-            Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
-        }
-    }
-
-    pub(crate) fn propagate_resize_diff(
-        diff: f32,
-        col_idx: usize,
-        widths: &mut TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-        direction: i8,
-    ) -> f32 {
-        let mut diff_remaining = diff;
-        if resize_behavior[col_idx].min_size().is_none() {
-            return diff;
-        }
-
-        let step_right;
-        let step_left;
-        if direction < 0 {
-            step_right = 0;
-            step_left = 1;
-        } else {
-            step_right = 1;
-            step_left = 0;
-        }
-        if col_idx == 0 && direction < 0 {
-            return diff;
-        }
-        let mut curr_column = col_idx + step_right - step_left;
-
-        while diff_remaining != 0.0 && curr_column < widths.cols() {
-            let Some(min_size) = resize_behavior[curr_column].min_size() else {
-                if curr_column == 0 {
-                    break;
-                }
-                curr_column -= step_left;
-                curr_column += step_right;
-                continue;
-            };
-
-            let curr_width = widths[curr_column] - diff_remaining;
-            widths[curr_column] = curr_width;
-
-            if min_size > curr_width {
-                diff_remaining = min_size - curr_width;
-                widths[curr_column] = min_size;
-            } else {
-                diff_remaining = 0.0;
-                break;
-            }
-            if curr_column == 0 {
-                break;
-            }
-            curr_column -= step_left;
-            curr_column += step_right;
-        }
-        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
-
-        diff_remaining
-    }
 }
 
 /// A table component
@@ -919,11 +517,8 @@ pub fn render_table_header(
                                     if event.click_count() > 1 {
                                         info.columns_state
                                             .update(cx, |column, _| {
-                                                column.on_double_click(
-                                                    header_idx,
-                                                    &info.initial_widths,
-                                                    &info.resize_behavior,
-                                                    window,
+                                                column.reset_column_to_initial_width(
+                                                    header_idx, window,
                                                 );
                                             })
                                             .ok();
@@ -962,6 +557,19 @@ impl TableRenderContext {
             disable_base_cell_style: table.disable_base_cell_style,
         }
     }
+
+    pub fn for_column_widths(column_widths: Option<TableRow<Length>>, use_ui_font: bool) -> Self {
+        Self {
+            striped: false,
+            show_row_borders: true,
+            show_row_hover: true,
+            total_row_count: 0,
+            column_widths,
+            map_row: None,
+            use_ui_font,
+            disable_base_cell_style: false,
+        }
+    }
 }
 
 impl RenderOnce for Table {
@@ -969,9 +577,15 @@ impl RenderOnce for Table {
         let table_context = TableRenderContext::new(&self, cx);
         let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
 
-        let header_resize_info = interaction_state
-            .as_ref()
-            .and_then(|_| self.column_width_config.header_resize_info(cx));
+        let header_resize_info =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable { columns_state, .. } => {
+                        Some(HeaderResizeInfo::from_state(columns_state, cx))
+                    }
+                    _ => None,
+                });
 
         let table_width = self.column_width_config.table_width();
         let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
@@ -985,13 +599,19 @@ impl RenderOnce for Table {
                     ColumnWidthConfig::Redistributable {
                         columns_state: entity,
                         ..
-                    } => Some(entity.downgrade()),
+                    } => Some(entity.clone()),
                     _ => None,
                 });
 
-        let resize_handles = interaction_state
-            .as_ref()
-            .and_then(|_| self.column_width_config.render_resize_handles(window, cx));
+        let resize_handles =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable { columns_state, .. } => Some(
+                        render_redistributable_columns_resize_handles(columns_state, window, cx),
+                    ),
+                    _ => None,
+                });
 
         let table = div()
             .when_some(table_width, |this, width| this.w(width))
@@ -1006,38 +626,8 @@ impl RenderOnce for Table {
                     cx,
                 ))
             })
-            .when_some(redistributable_entity, {
-                |this, widths| {
-                    this.on_drag_move::<DraggedColumn>({
-                        let widths = widths.clone();
-                        move |e, window, cx| {
-                            widths
-                                .update(cx, |widths, cx| {
-                                    widths.on_drag_move(e, window, cx);
-                                })
-                                .ok();
-                        }
-                    })
-                    .on_children_prepainted({
-                        let widths = widths.clone();
-                        move |bounds, _, cx| {
-                            widths
-                                .update(cx, |widths, _| {
-                                    // This works because all children x axis bounds are the same
-                                    widths.cached_table_width =
-                                        bounds[0].right() - bounds[0].left();
-                                })
-                                .ok();
-                        }
-                    })
-                    .on_drop::<DraggedColumn>(move |_, _, cx| {
-                        widths
-                            .update(cx, |widths, _| {
-                                widths.committed_widths = widths.preview_widths.clone();
-                            })
-                            .ok();
-                    })
-                }
+            .when_some(redistributable_entity, |this, widths| {
+                bind_redistributable_columns(this, widths)
             })
             .child({
                 let content = div()

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

@@ -1,4 +1,5 @@
-use super::*;
+use super::table_row::TableRow;
+use crate::{RedistributableColumnsState, TableResizeBehavior};
 
 fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
     a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)

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

@@ -0,0 +1,485 @@
+use std::rc::Rc;
+
+use gpui::{
+    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length,
+    WeakEntity,
+};
+use itertools::intersperse_with;
+
+use super::data_table::table_row::{IntoTableRow as _, TableRow};
+use crate::{
+    ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement,
+    IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
+    px,
+};
+
+const RESIZE_COLUMN_WIDTH: f32 = 8.0;
+const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
+
+#[derive(Debug)]
+struct DraggedColumn(usize);
+
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub enum TableResizeBehavior {
+    None,
+    Resizable,
+    MinSize(f32),
+}
+
+impl TableResizeBehavior {
+    pub fn is_resizable(&self) -> bool {
+        *self != TableResizeBehavior::None
+    }
+
+    pub fn min_size(&self) -> Option<f32> {
+        match self {
+            TableResizeBehavior::None => None,
+            TableResizeBehavior::Resizable => Some(0.05),
+            TableResizeBehavior::MinSize(min_size) => Some(*min_size),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct HeaderResizeInfo {
+    pub columns_state: WeakEntity<RedistributableColumnsState>,
+    pub resize_behavior: TableRow<TableResizeBehavior>,
+}
+
+impl HeaderResizeInfo {
+    pub fn from_state(columns_state: &Entity<RedistributableColumnsState>, cx: &App) -> Self {
+        let resize_behavior = columns_state.read(cx).resize_behavior().clone();
+        Self {
+            columns_state: columns_state.downgrade(),
+            resize_behavior,
+        }
+    }
+}
+
+pub struct RedistributableColumnsState {
+    pub(crate) initial_widths: TableRow<DefiniteLength>,
+    pub(crate) committed_widths: TableRow<DefiniteLength>,
+    pub(crate) preview_widths: TableRow<DefiniteLength>,
+    pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
+    pub(crate) cached_container_width: Pixels,
+}
+
+impl RedistributableColumnsState {
+    pub fn new(
+        cols: usize,
+        initial_widths: Vec<impl Into<DefiniteLength>>,
+        resize_behavior: Vec<TableResizeBehavior>,
+    ) -> Self {
+        let widths: TableRow<DefiniteLength> = initial_widths
+            .into_iter()
+            .map(Into::into)
+            .collect::<Vec<_>>()
+            .into_table_row(cols);
+        Self {
+            initial_widths: widths.clone(),
+            committed_widths: widths.clone(),
+            preview_widths: widths,
+            resize_behavior: resize_behavior.into_table_row(cols),
+            cached_container_width: Default::default(),
+        }
+    }
+
+    pub fn cols(&self) -> usize {
+        self.committed_widths.cols()
+    }
+
+    pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
+        &self.initial_widths
+    }
+
+    pub fn preview_widths(&self) -> &TableRow<DefiniteLength> {
+        &self.preview_widths
+    }
+
+    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
+        &self.resize_behavior
+    }
+
+    pub fn widths_to_render(&self) -> TableRow<Length> {
+        self.preview_widths.map_cloned(Length::Definite)
+    }
+
+    pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow<f32> {
+        if self.cached_container_width > px(0.) {
+            self.preview_widths
+                .map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size))
+        } else {
+            self.preview_widths.map_ref(|length| match length {
+                DefiniteLength::Fraction(fraction) => *fraction,
+                DefiniteLength::Absolute(_) => 0.0,
+            })
+        }
+    }
+
+    pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option<Pixels> {
+        let width = self.preview_widths().as_slice().get(column_index)?;
+        match width {
+            DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => {
+                Some(self.cached_container_width * *fraction)
+            }
+            DefiniteLength::Fraction(_) => None,
+            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels),
+            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
+                Some(rems_width.to_pixels(window.rem_size()))
+            }
+        }
+    }
+
+    pub fn cached_container_width(&self) -> Pixels {
+        self.cached_container_width
+    }
+
+    pub fn set_cached_container_width(&mut self, width: Pixels) {
+        self.cached_container_width = width;
+    }
+
+    pub fn commit_preview(&mut self) {
+        self.committed_widths = self.preview_widths.clone();
+    }
+
+    pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) {
+        let bounds_width = self.cached_container_width;
+        if bounds_width <= px(0.) {
+            return;
+        }
+
+        let rem_size = window.rem_size();
+        let initial_sizes = self
+            .initial_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+        let widths = self
+            .committed_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+
+        let updated_widths =
+            Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior);
+        self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
+        self.preview_widths = self.committed_widths.clone();
+    }
+
+    fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
+        match length {
+            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
+            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
+                rems_width.to_pixels(rem_size) / bounds_width
+            }
+            DefiniteLength::Fraction(fraction) => *fraction,
+        }
+    }
+
+    pub(crate) fn reset_to_initial_size(
+        col_idx: usize,
+        mut widths: TableRow<f32>,
+        initial_sizes: TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+    ) -> TableRow<f32> {
+        let diff = initial_sizes[col_idx] - widths[col_idx];
+
+        let left_diff =
+            initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
+        let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
+            - widths[col_idx + 1..].iter().sum::<f32>();
+
+        let go_left_first = if diff < 0.0 {
+            left_diff > right_diff
+        } else {
+            left_diff < right_diff
+        };
+
+        if !go_left_first {
+            let diff_remaining =
+                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
+
+            if diff_remaining != 0.0 && col_idx > 0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
+                    &mut widths,
+                    resize_behavior,
+                    -1,
+                );
+            }
+        } else {
+            let diff_remaining =
+                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
+
+            if diff_remaining != 0.0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
+                    &mut widths,
+                    resize_behavior,
+                    1,
+                );
+            }
+        }
+
+        widths
+    }
+
+    fn on_drag_move(
+        &mut self,
+        drag_event: &DragMoveEvent<DraggedColumn>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let drag_position = drag_event.event.position;
+        let bounds = drag_event.bounds;
+        let bounds_width = bounds.right() - bounds.left();
+        if bounds_width <= px(0.) {
+            return;
+        }
+
+        let mut col_position = 0.0;
+        let rem_size = window.rem_size();
+        let col_idx = drag_event.drag(cx).0;
+
+        let divider_width = Self::get_fraction(
+            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
+            bounds_width,
+            rem_size,
+        );
+
+        let mut widths = self
+            .committed_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+
+        for length in widths[0..=col_idx].iter() {
+            col_position += length + divider_width;
+        }
+
+        let mut total_length_ratio = col_position;
+        for length in widths[col_idx + 1..].iter() {
+            total_length_ratio += length;
+        }
+        let cols = self.resize_behavior.cols();
+        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
+
+        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
+        let drag_fraction = drag_fraction * total_length_ratio;
+        let diff = drag_fraction - col_position - divider_width / 2.0;
+
+        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
+
+        self.preview_widths = widths.map(DefiniteLength::Fraction);
+    }
+
+    pub(crate) fn drag_column_handle(
+        diff: f32,
+        col_idx: usize,
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+    ) {
+        if diff > 0.0 {
+            Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
+        } else {
+            Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
+        }
+    }
+
+    pub(crate) fn propagate_resize_diff(
+        diff: f32,
+        col_idx: usize,
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+        direction: i8,
+    ) -> f32 {
+        let mut diff_remaining = diff;
+        if resize_behavior[col_idx].min_size().is_none() {
+            return diff;
+        }
+
+        let step_right;
+        let step_left;
+        if direction < 0 {
+            step_right = 0;
+            step_left = 1;
+        } else {
+            step_right = 1;
+            step_left = 0;
+        }
+        if col_idx == 0 && direction < 0 {
+            return diff;
+        }
+        let mut curr_column = col_idx + step_right - step_left;
+
+        while diff_remaining != 0.0 && curr_column < widths.cols() {
+            let Some(min_size) = resize_behavior[curr_column].min_size() else {
+                if curr_column == 0 {
+                    break;
+                }
+                curr_column -= step_left;
+                curr_column += step_right;
+                continue;
+            };
+
+            let curr_width = widths[curr_column] - diff_remaining;
+            widths[curr_column] = curr_width;
+
+            if min_size > curr_width {
+                diff_remaining = min_size - curr_width;
+                widths[curr_column] = min_size;
+            } else {
+                diff_remaining = 0.0;
+                break;
+            }
+            if curr_column == 0 {
+                break;
+            }
+            curr_column -= step_left;
+            curr_column += step_right;
+        }
+        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
+
+        diff_remaining
+    }
+}
+
+pub fn bind_redistributable_columns(
+    container: Div,
+    columns_state: Entity<RedistributableColumnsState>,
+) -> Div {
+    container
+        .on_drag_move::<DraggedColumn>({
+            let columns_state = columns_state.clone();
+            move |event, window, cx| {
+                columns_state.update(cx, |columns, cx| {
+                    columns.on_drag_move(event, window, cx);
+                });
+            }
+        })
+        .on_children_prepainted({
+            let columns_state = columns_state.clone();
+            move |bounds, _, cx| {
+                if let Some(width) = child_bounds_width(&bounds) {
+                    columns_state.update(cx, |columns, _| {
+                        columns.set_cached_container_width(width);
+                    });
+                }
+            }
+        })
+        .on_drop::<DraggedColumn>(move |_, _, cx| {
+            columns_state.update(cx, |columns, _| {
+                columns.commit_preview();
+            });
+        })
+}
+
+pub fn render_redistributable_columns_resize_handles(
+    columns_state: &Entity<RedistributableColumnsState>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (column_widths, resize_behavior) = {
+        let state = columns_state.read(cx);
+        (state.widths_to_render(), state.resize_behavior().clone())
+    };
+
+    let mut column_ix = 0;
+    let resize_behavior = Rc::new(resize_behavior);
+    let dividers = intersperse_with(
+        column_widths
+            .as_slice()
+            .iter()
+            .copied()
+            .map(|width| resize_spacer(width).into_any_element()),
+        || {
+            let current_column_ix = column_ix;
+            let resize_behavior = Rc::clone(&resize_behavior);
+            let columns_state = columns_state.clone();
+            column_ix += 1;
+
+            window.with_id(current_column_ix, |window| {
+                let mut resize_divider = div()
+                    .id(current_column_ix)
+                    .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[current_column_ix].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, window, cx| {
+                                if event.click_count() >= 2 {
+                                    columns_state.update(cx, |columns, _| {
+                                        columns.reset_column_to_initial_width(
+                                            current_column_ix,
+                                            window,
+                                        );
+                                    });
+                                }
+
+                                cx.stop_propagation();
+                            }
+                        })
+                        .on_drag(DraggedColumn(current_column_ix), {
+                            let is_highlighted = is_highlighted.clone();
+                            move |_, _offset, _window, cx| {
+                                is_highlighted.write(cx, true);
+                                cx.new(|_cx| Empty)
+                            }
+                        })
+                        .on_drop::<DraggedColumn>(move |_, _, cx| {
+                            is_highlighted.write(cx, false);
+                            columns_state.update(cx, |state, _| {
+                                state.commit_preview();
+                            });
+                        });
+                }
+
+                resize_divider.child(resize_handle).into_any_element()
+            })
+        },
+    );
+
+    h_flex()
+        .id("resize-handles")
+        .absolute()
+        .inset_0()
+        .w_full()
+        .children(dividers)
+        .into_any_element()
+}
+
+fn resize_spacer(width: Length) -> Div {
+    div().w(width).h_full()
+}
+
+fn child_bounds_width(bounds: &[Bounds<Pixels>]) -> Option<Pixels> {
+    let first_bounds = bounds.first()?;
+    let mut left = first_bounds.left();
+    let mut right = first_bounds.right();
+
+    for bound in bounds.iter().skip(1) {
+        left = left.min(bound.left());
+        right = right.max(bound.right());
+    }
+
+    Some(right - left)
+}