Resizable columns (#34794)

Mikayla Maki , Anthony , and Remco Smits created

This PR adds resizable columns to the keymap editor and the ability to
double-click on a resizable column to set a column back to its default
size.

The table uses a column's width to calculate what position it should be
laid out at. So `column[i]` x position is calculated by the summation of
`column[..i]`. When resizing `column[i]`, `column[i+1]`’s size is
adjusted to keep all columns’ relative positions the same. If
`column[i+1]` is at its minimum size, we keep seeking to the right to
find a column with space left to take.

An improvement to resizing behavior and double-clicking could be made by
checking both column ranges `0..i-1` and `i+1..COLS`, since only one
range of columns is checked for resize capacity.

Release Notes:

- N/A

---------

Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Remco Smits <djsmits12@gmail.com>

Change summary

Cargo.lock                                    |   1 
crates/settings_ui/Cargo.toml                 |   1 
crates/settings_ui/src/keybindings.rs         |  30 +
crates/settings_ui/src/ui_components/table.rs | 455 +++++++++++++++++++-
crates/workspace/src/pane_group.rs            |  11 
5 files changed, 449 insertions(+), 49 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14779,6 +14779,7 @@ dependencies = [
  "fs",
  "fuzzy",
  "gpui",
+ "itertools 0.14.0",
  "language",
  "log",
  "menu",

crates/settings_ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ feature_flags.workspace = true
 fs.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+itertools.workspace = true
 language.workspace = true
 log.workspace = true
 menu.workspace = true

crates/settings_ui/src/keybindings.rs 🔗

@@ -13,8 +13,8 @@ use gpui::{
     Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context,
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero,
     KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
-    ScrollWheelEvent, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, actions,
-    anchored, deferred, div,
+    ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity,
+    actions, anchored, deferred, div,
 };
 use language::{Language, LanguageConfig, ToOffset as _};
 use notifications::status_toast::{StatusToast, ToastIcon};
@@ -36,7 +36,7 @@ use workspace::{
 
 use crate::{
     keybindings::persistence::KEYBINDING_EDITORS,
-    ui_components::table::{Table, TableInteractionState},
+    ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState},
 };
 
 const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static("<no arguments>");
@@ -284,6 +284,7 @@ struct KeymapEditor {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     previous_edit: Option<PreviousEdit>,
     humanized_action_names: HumanizedActionNameCache,
+    current_widths: Entity<ColumnWidths<6>>,
     show_hover_menus: bool,
     /// In order for the JSON LSP to run in the actions arguments editor, we
     /// require a backing file In order to avoid issues (primarily log spam)
@@ -400,6 +401,7 @@ impl KeymapEditor {
             show_hover_menus: true,
             action_args_temp_dir: None,
             action_args_temp_dir_worktree: None,
+            current_widths: cx.new(|cx| ColumnWidths::new(cx)),
         };
 
         this.on_keymap_changed(window, cx);
@@ -1433,6 +1435,18 @@ impl Render for KeymapEditor {
                         DefiniteLength::Fraction(0.45),
                         DefiniteLength::Fraction(0.08),
                     ])
+                    .resizable_columns(
+                        [
+                            ResizeBehavior::None,
+                            ResizeBehavior::Resizable,
+                            ResizeBehavior::Resizable,
+                            ResizeBehavior::Resizable,
+                            ResizeBehavior::Resizable,
+                            ResizeBehavior::Resizable, // this column doesn't matter
+                        ],
+                        &self.current_widths,
+                        cx,
+                    )
                     .header(["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
                     .uniform_list(
                         "keymap-editor-table",
@@ -1594,15 +1608,14 @@ impl Render for KeymapEditor {
                                 .collect()
                         }),
                     )
-                    .map_row(
-                        cx.processor(|this, (row_index, row): (usize, Div), _window, cx| {
+                    .map_row(cx.processor(
+                        |this, (row_index, row): (usize, Stateful<Div>), _window, cx| {
                             let is_conflict = this.has_conflict(row_index);
                             let is_selected = this.selected_index == Some(row_index);
 
                             let row_id = row_group_id(row_index);
 
                             let row = row
-                                .id(row_id.clone())
                                 .on_any_mouse_down(cx.listener(
                                     move |this,
                                           mouse_down_event: &gpui::MouseDownEvent,
@@ -1636,11 +1649,12 @@ impl Render for KeymapEditor {
                                 })
                                 .when(is_selected, |row| {
                                     row.border_color(cx.theme().colors().panel_focused_border)
+                                        .border_2()
                                 });
 
                             row.into_any_element()
-                        }),
-                    ),
+                        },
+                    )),
             )
             .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
                 // This ensures that the menu is not dismissed in cases where scroll events

crates/settings_ui/src/ui_components/table.rs 🔗

@@ -2,19 +2,24 @@ use std::{ops::Range, rc::Rc, time::Duration};
 
 use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
 use gpui::{
-    AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior,
-    ListSizingBehavior, MouseButton, Point, Task, UniformListScrollHandle, WeakEntity,
-    transparent_black, uniform_list,
+    AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, FocusHandle,
+    Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, Stateful, Task,
+    UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
 };
+
+use itertools::intersperse_with;
 use settings::Settings as _;
 use ui::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
     ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
-    InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
+    InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
+    Scrollbar, ScrollbarState, StatefulInteractiveElement, Styled, StyledExt as _,
     StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
 };
 
+#[derive(Debug)]
+struct DraggedColumn(usize);
+
 struct UniformListData<const COLS: usize> {
     render_item_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
     element_id: ElementId,
@@ -191,6 +196,87 @@ impl TableInteractionState {
         }
     }
 
+    fn render_resize_handles<const COLS: usize>(
+        &self,
+        column_widths: &[Length; COLS],
+        resizable_columns: &[ResizeBehavior; COLS],
+        initial_sizes: [DefiniteLength; COLS],
+        columns: Option<Entity<ColumnWidths<COLS>>>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        let spacers = column_widths
+            .iter()
+            .map(|width| base_cell_style(Some(*width)).into_any_element());
+
+        let mut column_ix = 0;
+        let resizable_columns_slice = *resizable_columns;
+        let mut resizable_columns = resizable_columns.into_iter();
+        let dividers = intersperse_with(spacers, || {
+            window.with_id(column_ix, |window| {
+                let mut resize_divider = div()
+                    // This is required because this is evaluated at a different time than the use_state call above
+                    .id(column_ix)
+                    .relative()
+                    .top_0()
+                    .w_0p5()
+                    .h_full()
+                    .bg(cx.theme().colors().border.opacity(0.5));
+
+                let mut resize_handle = div()
+                    .id("column-resize-handle")
+                    .absolute()
+                    .left_neg_0p5()
+                    .w(px(5.0))
+                    .h_full();
+
+                if resizable_columns
+                    .next()
+                    .is_some_and(ResizeBehavior::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.down.click_count >= 2 {
+                                    columns.update(cx, |columns, _| {
+                                        columns.on_double_click(
+                                            column_ix,
+                                            &initial_sizes,
+                                            &resizable_columns_slice,
+                                            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()
+            })
+        });
+
+        div()
+            .id("resize-handles")
+            .h_flex()
+            .absolute()
+            .w_full()
+            .inset_0()
+            .children(dividers)
+            .into_any_element()
+    }
+
     fn render_vertical_scrollbar_track(
         this: &Entity<Self>,
         parent: Div,
@@ -369,6 +455,217 @@ impl TableInteractionState {
     }
 }
 
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub enum ResizeBehavior {
+    None,
+    Resizable,
+    MinSize(f32),
+}
+
+impl ResizeBehavior {
+    pub fn is_resizable(&self) -> bool {
+        *self != ResizeBehavior::None
+    }
+
+    pub fn min_size(&self) -> Option<f32> {
+        match self {
+            ResizeBehavior::None => None,
+            ResizeBehavior::Resizable => Some(0.05),
+            ResizeBehavior::MinSize(min_size) => Some(*min_size),
+        }
+    }
+}
+
+pub struct ColumnWidths<const COLS: usize> {
+    widths: [DefiniteLength; COLS],
+    cached_bounds_width: Pixels,
+    initialized: bool,
+}
+
+impl<const COLS: usize> ColumnWidths<COLS> {
+    pub fn new(_: &mut App) -> Self {
+        Self {
+            widths: [DefiniteLength::default(); COLS],
+            cached_bounds_width: Default::default(),
+            initialized: false,
+        }
+    }
+
+    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,
+        }
+    }
+
+    fn on_double_click(
+        &mut self,
+        double_click_position: usize,
+        initial_sizes: &[DefiniteLength; COLS],
+        resize_behavior: &[ResizeBehavior; COLS],
+        window: &mut Window,
+    ) {
+        let bounds_width = self.cached_bounds_width;
+        let rem_size = window.rem_size();
+
+        let diff =
+            Self::get_fraction(
+                &initial_sizes[double_click_position],
+                bounds_width,
+                rem_size,
+            ) - Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size);
+
+        let mut curr_column = double_click_position + 1;
+        let mut diff_left = diff;
+
+        while diff != 0.0 && curr_column < COLS {
+            let Some(min_size) = resize_behavior[curr_column].min_size() else {
+                curr_column += 1;
+                continue;
+            };
+
+            let mut curr_width =
+                Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) - diff_left;
+
+            diff_left = 0.0;
+            if min_size > curr_width {
+                diff_left += min_size - curr_width;
+                curr_width = min_size;
+            }
+            self.widths[curr_column] = DefiniteLength::Fraction(curr_width);
+            curr_column += 1;
+        }
+
+        self.widths[double_click_position] = DefiniteLength::Fraction(
+            Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size)
+                + (diff - diff_left),
+        );
+    }
+
+    fn on_drag_move(
+        &mut self,
+        drag_event: &DragMoveEvent<DraggedColumn>,
+        resize_behavior: &[ResizeBehavior; COLS],
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        // - [ ] Fix bugs in resize
+        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;
+
+        for length in self.widths[0..=col_idx].iter() {
+            col_position += Self::get_fraction(length, bounds_width, rem_size);
+        }
+
+        let mut total_length_ratio = col_position;
+        for length in self.widths[col_idx + 1..].iter() {
+            total_length_ratio += Self::get_fraction(length, bounds_width, rem_size);
+        }
+
+        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
+        let drag_fraction = drag_fraction * total_length_ratio;
+        let diff = drag_fraction - col_position;
+
+        let is_dragging_right = diff > 0.0;
+
+        let mut diff_left = diff;
+        let mut curr_column = col_idx + 1;
+
+        if is_dragging_right {
+            while diff_left > 0.0 && curr_column < COLS {
+                let Some(min_size) = resize_behavior[curr_column - 1].min_size() else {
+                    curr_column += 1;
+                    continue;
+                };
+
+                let mut curr_width =
+                    Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size)
+                        - diff_left;
+
+                diff_left = 0.0;
+                if min_size > curr_width {
+                    diff_left += min_size - curr_width;
+                    curr_width = min_size;
+                }
+                self.widths[curr_column] = DefiniteLength::Fraction(curr_width);
+                curr_column += 1;
+            }
+
+            self.widths[col_idx] = DefiniteLength::Fraction(
+                Self::get_fraction(&self.widths[col_idx], bounds_width, rem_size)
+                    + (diff - diff_left),
+            );
+        } else {
+            curr_column = col_idx;
+            // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space
+            while diff_left < 0.0 {
+                let Some(min_size) = resize_behavior[curr_column.saturating_sub(1)].min_size()
+                else {
+                    if curr_column == 0 {
+                        break;
+                    }
+                    curr_column -= 1;
+                    continue;
+                };
+
+                let mut curr_width =
+                    Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size)
+                        + diff_left;
+
+                diff_left = 0.0;
+                if curr_width < min_size {
+                    diff_left = curr_width - min_size;
+                    curr_width = min_size
+                }
+
+                self.widths[curr_column] = DefiniteLength::Fraction(curr_width);
+                if curr_column == 0 {
+                    break;
+                }
+                curr_column -= 1;
+            }
+
+            self.widths[col_idx + 1] = DefiniteLength::Fraction(
+                Self::get_fraction(&self.widths[col_idx + 1], bounds_width, rem_size)
+                    - (diff - diff_left),
+            );
+        }
+    }
+}
+
+pub struct TableWidths<const COLS: usize> {
+    initial: [DefiniteLength; COLS],
+    current: Option<Entity<ColumnWidths<COLS>>>,
+    resizable: [ResizeBehavior; COLS],
+}
+
+impl<const COLS: usize> TableWidths<COLS> {
+    pub fn new(widths: [impl Into<DefiniteLength>; COLS]) -> Self {
+        let widths = widths.map(Into::into);
+
+        TableWidths {
+            initial: widths,
+            current: None,
+            resizable: [ResizeBehavior::None; COLS],
+        }
+    }
+
+    fn lengths(&self, cx: &App) -> [Length; COLS] {
+        self.current
+            .as_ref()
+            .map(|entity| entity.read(cx).widths.map(Length::Definite))
+            .unwrap_or(self.initial.map(Length::Definite))
+    }
+}
+
 /// A table component
 #[derive(RegisterComponent, IntoElement)]
 pub struct Table<const COLS: usize = 3> {
@@ -377,23 +674,23 @@ pub struct Table<const COLS: usize = 3> {
     headers: Option<[AnyElement; COLS]>,
     rows: TableContents<COLS>,
     interaction_state: Option<WeakEntity<TableInteractionState>>,
-    column_widths: Option<[Length; COLS]>,
-    map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
+    col_widths: Option<TableWidths<COLS>>,
+    map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
     empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
 }
 
 impl<const COLS: usize> Table<COLS> {
     /// number of headers provided.
     pub fn new() -> Self {
-        Table {
+        Self {
             striped: false,
             width: None,
             headers: None,
             rows: TableContents::Vec(Vec::new()),
             interaction_state: None,
-            column_widths: None,
             map_row: None,
             empty_table_callback: None,
+            col_widths: None,
         }
     }
 
@@ -454,14 +751,38 @@ impl<const COLS: usize> Table<COLS> {
         self
     }
 
-    pub fn column_widths(mut self, widths: [impl Into<Length>; COLS]) -> Self {
-        self.column_widths = Some(widths.map(Into::into));
+    pub fn column_widths(mut self, widths: [impl Into<DefiniteLength>; COLS]) -> Self {
+        if self.col_widths.is_none() {
+            self.col_widths = Some(TableWidths::new(widths));
+        }
+        self
+    }
+
+    pub fn resizable_columns(
+        mut self,
+        resizable: [ResizeBehavior; COLS],
+        column_widths: &Entity<ColumnWidths<COLS>>,
+        cx: &mut App,
+    ) -> Self {
+        if let Some(table_widths) = self.col_widths.as_mut() {
+            table_widths.resizable = resizable;
+            let column_widths = table_widths
+                .current
+                .get_or_insert_with(|| column_widths.clone());
+
+            column_widths.update(cx, |widths, _| {
+                if !widths.initialized {
+                    widths.initialized = true;
+                    widths.widths = table_widths.initial;
+                }
+            })
+        }
         self
     }
 
     pub fn map_row(
         mut self,
-        callback: impl Fn((usize, Div), &mut Window, &mut App) -> AnyElement + 'static,
+        callback: impl Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement + 'static,
     ) -> Self {
         self.map_row = Some(Rc::new(callback));
         self
@@ -477,18 +798,21 @@ impl<const COLS: usize> Table<COLS> {
     }
 }
 
-fn base_cell_style(width: Option<Length>, cx: &App) -> Div {
+fn base_cell_style(width: Option<Length>) -> Div {
     div()
         .px_1p5()
         .when_some(width, |this, width| this.w(width))
         .when(width.is_none(), |this| this.flex_1())
         .justify_start()
-        .text_ui(cx)
         .whitespace_nowrap()
         .text_ellipsis()
         .overflow_hidden()
 }
 
+fn base_cell_style_text(width: Option<Length>, cx: &App) -> Div {
+    base_cell_style(width).text_ui(cx)
+}
+
 pub fn render_row<const COLS: usize>(
     row_index: usize,
     items: [impl IntoElement; COLS],
@@ -507,33 +831,33 @@ pub fn render_row<const COLS: usize>(
         .column_widths
         .map_or([None; COLS], |widths| widths.map(Some));
 
-    let row = div().w_full().child(
-        h_flex()
-            .id("table_row")
-            .w_full()
-            .justify_between()
-            .px_1p5()
-            .py_1()
-            .when_some(bg, |row, bg| row.bg(bg))
-            .when(!is_striped, |row| {
-                row.border_b_1()
-                    .border_color(transparent_black())
-                    .when(!is_last, |row| row.border_color(cx.theme().colors().border))
-            })
-            .children(
-                items
-                    .map(IntoElement::into_any_element)
-                    .into_iter()
-                    .zip(column_widths)
-                    .map(|(cell, width)| base_cell_style(width, cx).child(cell)),
-            ),
+    let mut row = h_flex()
+        .h_full()
+        .id(("table_row", row_index))
+        .w_full()
+        .justify_between()
+        .when_some(bg, |row, bg| row.bg(bg))
+        .when(!is_striped, |row| {
+            row.border_b_1()
+                .border_color(transparent_black())
+                .when(!is_last, |row| row.border_color(cx.theme().colors().border))
+        });
+
+    row = row.children(
+        items
+            .map(IntoElement::into_any_element)
+            .into_iter()
+            .zip(column_widths)
+            .map(|(cell, width)| base_cell_style_text(width, cx).px_1p5().py_1().child(cell)),
     );
 
-    if let Some(map_row) = table_context.map_row {
+    let row = if let Some(map_row) = table_context.map_row {
         map_row((row_index, row), window, cx)
     } else {
         row.into_any_element()
-    }
+    };
+
+    div().h_full().w_full().child(row).into_any_element()
 }
 
 pub fn render_header<const COLS: usize>(
@@ -557,7 +881,7 @@ pub fn render_header<const COLS: usize>(
             headers
                 .into_iter()
                 .zip(column_widths)
-                .map(|(h, width)| base_cell_style(width, cx).child(h)),
+                .map(|(h, width)| base_cell_style_text(width, cx).child(h)),
         )
 }
 
@@ -566,15 +890,15 @@ pub struct TableRenderContext<const COLS: usize> {
     pub striped: bool,
     pub total_row_count: usize,
     pub column_widths: Option<[Length; COLS]>,
-    pub map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
+    pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
 }
 
 impl<const COLS: usize> TableRenderContext<COLS> {
-    fn new(table: &Table<COLS>) -> Self {
+    fn new(table: &Table<COLS>, cx: &App) -> Self {
         Self {
             striped: table.striped,
             total_row_count: table.rows.len(),
-            column_widths: table.column_widths,
+            column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
             map_row: table.map_row.clone(),
         }
     }
@@ -582,8 +906,13 @@ impl<const COLS: usize> TableRenderContext<COLS> {
 
 impl<const COLS: usize> RenderOnce for Table<COLS> {
     fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let table_context = TableRenderContext::new(&self);
+        let table_context = TableRenderContext::new(&self, cx);
         let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
+        let current_widths = self
+            .col_widths
+            .as_ref()
+            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable)))
+            .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
 
         let scroll_track_size = px(16.);
         let h_scroll_offset = if interaction_state
@@ -606,6 +935,31 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
             .when_some(self.headers.take(), |this, headers| {
                 this.child(render_header(headers, table_context.clone(), cx))
             })
+            .when_some(current_widths, {
+                |this, (widths, resize_behavior)| {
+                    this.on_drag_move::<DraggedColumn>({
+                        let widths = widths.clone();
+                        move |e, window, cx| {
+                            widths
+                                .update(cx, |widths, cx| {
+                                    widths.on_drag_move(e, &resize_behavior, window, cx);
+                                })
+                                .ok();
+                        }
+                    })
+                    .on_children_prepainted(move |bounds, _, cx| {
+                        widths
+                            .update(cx, |widths, _| {
+                                // This works because all children x axis bounds are the same
+                                widths.cached_bounds_width = bounds[0].right() - bounds[0].left();
+                            })
+                            .ok();
+                    })
+                }
+            })
+            .on_drop::<DraggedColumn>(|_, _, _| {
+                // Finish the resize operation
+            })
             .child(
                 div()
                     .flex_grow()
@@ -660,6 +1014,25 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                             ),
                         ),
                     })
+                    .when_some(
+                        self.col_widths.as_ref().zip(interaction_state.as_ref()),
+                        |parent, (table_widths, state)| {
+                            parent.child(state.update(cx, |state, cx| {
+                                let resizable_columns = table_widths.resizable;
+                                let column_widths = table_widths.lengths(cx);
+                                let columns = table_widths.current.clone();
+                                let initial_sizes = table_widths.initial;
+                                state.render_resize_handles(
+                                    &column_widths,
+                                    &resizable_columns,
+                                    initial_sizes,
+                                    columns,
+                                    window,
+                                    cx,
+                                )
+                            }))
+                        },
+                    )
                     .when_some(interaction_state.as_ref(), |this, interaction_state| {
                         this.map(|this| {
                             TableInteractionState::render_vertical_scrollbar_track(

crates/workspace/src/pane_group.rs 🔗

@@ -943,6 +943,8 @@ mod element {
     pub struct PaneAxisElement {
         axis: Axis,
         basis: usize,
+        /// Equivalent to ColumnWidths (but in terms of flexes instead of percentages)
+        /// For example, flexes "1.33, 1, 1", instead of "40%, 30%, 30%"
         flexes: Arc<Mutex<Vec<f32>>>,
         bounding_boxes: Arc<Mutex<Vec<Option<Bounds<Pixels>>>>>,
         children: SmallVec<[AnyElement; 2]>,
@@ -998,6 +1000,7 @@ mod element {
             let mut flexes = flexes.lock();
             debug_assert!(flex_values_in_bounds(flexes.as_slice()));
 
+            // Math to convert a flex value to a pixel value
             let size = move |ix, flexes: &[f32]| {
                 container_size.along(axis) * (flexes[ix] / flexes.len() as f32)
             };
@@ -1007,9 +1010,13 @@ mod element {
                 return;
             }
 
+            // This is basically a "bucket" of pixel changes that need to be applied in response to this
+            // mouse event. Probably a small, fractional number like 0.5 or 1.5 pixels
             let mut proposed_current_pixel_change =
                 (e.position - child_start).along(axis) - size(ix, flexes.as_slice());
 
+            // This takes a pixel change, and computes the flex changes that correspond to this pixel change
+            // as well as the next one, for some reason
             let flex_changes = |pixel_dx, target_ix, next: isize, flexes: &[f32]| {
                 let flex_change = pixel_dx / container_size.along(axis);
                 let current_target_flex = flexes[target_ix] + flex_change;
@@ -1017,6 +1024,9 @@ mod element {
                 (current_target_flex, next_target_flex)
             };
 
+            // Generate the list of flex successors, from the current index.
+            // If you're dragging column 3 forward, out of 6 columns, then this code will produce [4, 5, 6]
+            // If you're dragging column 3 backward, out of 6 columns, then this code will produce [2, 1, 0]
             let mut successors = iter::from_fn({
                 let forward = proposed_current_pixel_change > px(0.);
                 let mut ix_offset = 0;
@@ -1034,6 +1044,7 @@ mod element {
                 }
             });
 
+            // Now actually loop over these, and empty our bucket of pixel changes
             while proposed_current_pixel_change.abs() > px(0.) {
                 let Some(current_ix) = successors.next() else {
                     break;