keymap ui: Resize columns on double click improvement (#35095)

Anthony Eid and Ben Kunkle created

This PR improves the behavior of resetting a column's size by
double-clicking on its column handle. We now shrink/grow to the side
that has more leftover/additional space.

We also improved the below

1. dragging was a couple of pixels off to the left because we didn't
take the column handleโ€™s width into account.
2. Column dragging now has memory and will shift things to their exact
position when reversing a drag before letting the drag handle go.
3. Improved our test infrastructure.
4. Double clicking on a column's header resizes the column

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/settings_ui/Cargo.toml                 |   3 
crates/settings_ui/src/ui_components/table.rs | 635 +++++++++++++++++---
2 files changed, 545 insertions(+), 93 deletions(-)

Detailed changes

crates/settings_ui/Cargo.toml ๐Ÿ”—

@@ -45,3 +45,6 @@ ui_input.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true
+
+[dev-dependencies]
+db = {"workspace"= true, "features" = ["test-support"]}

crates/settings_ui/src/ui_components/table.rs ๐Ÿ”—

@@ -2,9 +2,9 @@ use std::{ops::Range, rc::Rc, time::Duration};
 
 use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
 use gpui::{
-    AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, FocusHandle,
-    Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, Stateful, Task,
-    UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
+    AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
+    FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point,
+    Stateful, Task, UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
 };
 
 use itertools::intersperse_with;
@@ -13,10 +13,12 @@ use ui::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
     ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
     InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    Scrollbar, ScrollbarState, StatefulInteractiveElement, Styled, StyledExt as _,
+    Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
     StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
 };
 
+const RESIZE_COLUMN_WIDTH: f32 = 5.0;
+
 #[derive(Debug)]
 struct DraggedColumn(usize);
 
@@ -227,7 +229,7 @@ impl TableInteractionState {
                     .id("column-resize-handle")
                     .absolute()
                     .left_neg_0p5()
-                    .w(px(5.0))
+                    .w(px(RESIZE_COLUMN_WIDTH))
                     .h_full();
 
                 if resizable_columns
@@ -478,6 +480,7 @@ impl ResizeBehavior {
 
 pub struct ColumnWidths<const COLS: usize> {
     widths: [DefiniteLength; COLS],
+    visible_widths: [DefiniteLength; COLS],
     cached_bounds_width: Pixels,
     initialized: bool,
 }
@@ -486,6 +489,7 @@ impl<const COLS: usize> ColumnWidths<COLS> {
     pub fn new(_: &mut App) -> Self {
         Self {
             widths: [DefiniteLength::default(); COLS],
+            visible_widths: [DefiniteLength::default(); COLS],
             cached_bounds_width: Default::default(),
             initialized: false,
         }
@@ -512,46 +516,105 @@ impl<const COLS: usize> ColumnWidths<COLS> {
         let rem_size = window.rem_size();
         let initial_sizes =
             initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size));
-        let mut widths = self
+        let widths = self
             .widths
             .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
 
-        let diff = initial_sizes[double_click_position] - widths[double_click_position];
+        let updated_widths = Self::reset_to_initial_size(
+            double_click_position,
+            widths,
+            initial_sizes,
+            resize_behavior,
+        );
+        self.widths = updated_widths.map(DefiniteLength::Fraction);
+        self.visible_widths = self.widths;
+    }
 
-        if diff > 0.0 {
-            let diff_remaining = self.propagate_resize_diff_right(
-                diff,
-                double_click_position,
-                &mut widths,
-                resize_behavior,
-            );
+    fn reset_to_initial_size(
+        col_idx: usize,
+        mut widths: [f32; COLS],
+        initial_sizes: [f32; COLS],
+        resize_behavior: &[ResizeBehavior; COLS],
+    ) -> [f32; COLS] {
+        // RESET:
+        // Part 1:
+        // Figure out if we should shrink/grow the selected column
+        // Get diff which represents the change in column we want to make initial size delta curr_size = diff
+        //
+        // Part 2: We need to decide which side column we should move and where
+        //
+        // If we want to grow our column we should check the left/right columns diff to see what side
+        // has a greater delta than their initial size. Likewise, if we shrink our column we should check
+        // the left/right column diffs to see what side has the smallest delta.
+        //
+        // Part 3: resize
+        //
+        // col_idx represents the column handle to the right of an active column
+        //
+        // If growing and right has the greater delta {
+        //    shift col_idx to the right
+        // } else if growing and left has the greater delta {
+        //  shift col_idx - 1 to the left
+        // } else if shrinking and the right has the greater delta {
+        //  shift
+        // } {
+        //
+        // }
+        // }
+        //
+        // if we need to shrink, then if the right
+        //
+
+        // DRAGGING
+        // we get diff which represents the change in the _drag handle_ position
+        // -diff => dragging left ->
+        //      grow the column to the right of the handle as much as we can shrink columns to the left of the handle
+        // +diff => dragging right -> growing handles column
+        //      grow the column to the left of the handle as much as we can shrink columns to the right of the handle
+        //
+
+        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 && double_click_position > 0 {
-                self.propagate_resize_diff_left(
-                    -diff_remaining,
-                    double_click_position - 1,
+            if diff_remaining != 0.0 && col_idx > 0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
                     &mut widths,
                     resize_behavior,
+                    -1,
                 );
             }
-        } else if double_click_position > 0 {
-            let diff_remaining = self.propagate_resize_diff_left(
-                diff,
-                double_click_position,
-                &mut widths,
-                resize_behavior,
-            );
+        } 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_right(
-                    -diff_remaining,
-                    double_click_position,
+            if diff_remaining != 0.0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
                     &mut widths,
                     resize_behavior,
+                    1,
                 );
             }
         }
-        self.widths = widths.map(DefiniteLength::Fraction);
+
+        widths
     }
 
     fn on_drag_move(
@@ -569,98 +632,102 @@ impl<const COLS: usize> ColumnWidths<COLS> {
         let bounds_width = bounds.right() - bounds.left();
         let col_idx = drag_event.drag(cx).0;
 
+        let column_handle_width = Self::get_fraction(
+            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))),
+            bounds_width,
+            rem_size,
+        );
+
         let mut widths = self
             .widths
             .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
 
         for length in widths[0..=col_idx].iter() {
-            col_position += length;
+            col_position += length + column_handle_width;
         }
 
         let mut total_length_ratio = col_position;
         for length in widths[col_idx + 1..].iter() {
             total_length_ratio += length;
         }
+        total_length_ratio += (COLS - 1 - col_idx) as f32 * column_handle_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;
+        let diff = drag_fraction - col_position - column_handle_width / 2.0;
 
-        let is_dragging_right = diff > 0.0;
+        Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior);
 
-        if is_dragging_right {
-            self.propagate_resize_diff_right(diff, col_idx, &mut widths, resize_behavior);
-        } else {
-            // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space
-            self.propagate_resize_diff_left(diff, col_idx, &mut widths, resize_behavior);
-        }
-        self.widths = widths.map(DefiniteLength::Fraction);
+        self.visible_widths = widths.map(DefiniteLength::Fraction);
     }
 
-    fn propagate_resize_diff_right(
-        &self,
+    fn drag_column_handle(
         diff: f32,
         col_idx: usize,
         widths: &mut [f32; COLS],
         resize_behavior: &[ResizeBehavior; COLS],
-    ) -> f32 {
-        let mut diff_remaining = diff;
-        let mut curr_column = col_idx + 1;
-
-        while diff_remaining > 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 = widths[curr_column] - diff_remaining;
-
-            diff_remaining = 0.0;
-            if min_size > curr_width {
-                diff_remaining += min_size - curr_width;
-                curr_width = min_size;
-            }
-            widths[curr_column] = curr_width;
-            curr_column += 1;
+    ) {
+        // if diff > 0.0 then go right
+        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);
         }
-
-        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
-        return diff_remaining;
     }
 
-    fn propagate_resize_diff_left(
-        &mut self,
+    fn propagate_resize_diff(
         diff: f32,
-        mut curr_column: usize,
+        col_idx: usize,
         widths: &mut [f32; COLS],
         resize_behavior: &[ResizeBehavior; COLS],
+        direction: i8,
     ) -> f32 {
         let mut diff_remaining = diff;
-        let col_idx = curr_column;
-        while diff_remaining < 0.0 {
+        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 < COLS {
             let Some(min_size) = resize_behavior[curr_column].min_size() else {
                 if curr_column == 0 {
                     break;
                 }
-                curr_column -= 1;
+                curr_column -= step_left;
+                curr_column += step_right;
                 continue;
             };
 
-            let mut curr_width = widths[curr_column] + diff_remaining;
+            let curr_width = widths[curr_column] - diff_remaining;
+            widths[curr_column] = curr_width;
 
-            diff_remaining = 0.0;
-            if curr_width < min_size {
-                diff_remaining = curr_width - min_size;
-                curr_width = min_size
+            if min_size > curr_width {
+                diff_remaining = min_size - curr_width;
+                widths[curr_column] = min_size;
+            } else {
+                diff_remaining = 0.0;
+                break;
             }
-
-            widths[curr_column] = curr_width;
             if curr_column == 0 {
                 break;
             }
-            curr_column -= 1;
+            curr_column -= step_left;
+            curr_column += step_right;
         }
-        widths[col_idx + 1] = widths[col_idx + 1] - (diff - diff_remaining);
+        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
 
         return diff_remaining;
     }
@@ -686,7 +753,7 @@ impl<const COLS: usize> TableWidths<COLS> {
     fn lengths(&self, cx: &App) -> [Length; COLS] {
         self.current
             .as_ref()
-            .map(|entity| entity.read(cx).widths.map(Length::Definite))
+            .map(|entity| entity.read(cx).visible_widths.map(Length::Definite))
             .unwrap_or(self.initial.map(Length::Definite))
     }
 }
@@ -799,6 +866,7 @@ impl<const COLS: usize> Table<COLS> {
                 if !widths.initialized {
                     widths.initialized = true;
                     widths.widths = table_widths.initial;
+                    widths.visible_widths = widths.widths;
                 }
             })
         }
@@ -888,11 +956,24 @@ pub fn render_row<const COLS: usize>(
 pub fn render_header<const COLS: usize>(
     headers: [impl IntoElement; COLS],
     table_context: TableRenderContext<COLS>,
+    columns_widths: Option<(
+        WeakEntity<ColumnWidths<COLS>>,
+        [ResizeBehavior; COLS],
+        [DefiniteLength; COLS],
+    )>,
+    entity_id: Option<EntityId>,
     cx: &mut App,
 ) -> impl IntoElement {
     let column_widths = table_context
         .column_widths
         .map_or([None; COLS], |widths| widths.map(Some));
+
+    let element_id = entity_id
+        .map(|entity| entity.to_string())
+        .unwrap_or_default();
+
+    let shared_element_id: SharedString = format!("table-{}", element_id).into();
+
     div()
         .flex()
         .flex_row()
@@ -902,12 +983,39 @@ pub fn render_header<const COLS: usize>(
         .p_2()
         .border_b_1()
         .border_color(cx.theme().colors().border)
-        .children(
-            headers
-                .into_iter()
-                .zip(column_widths)
-                .map(|(h, width)| base_cell_style_text(width, cx).child(h)),
-        )
+        .children(headers.into_iter().enumerate().zip(column_widths).map(
+            |((header_idx, h), width)| {
+                base_cell_style_text(width, cx)
+                    .child(h)
+                    .id(ElementId::NamedInteger(
+                        shared_element_id.clone(),
+                        header_idx as u64,
+                    ))
+                    .when_some(
+                        columns_widths.as_ref().cloned(),
+                        |this, (column_widths, resizables, initial_sizes)| {
+                            if resizables[header_idx].is_resizable() {
+                                this.on_click(move |event, window, cx| {
+                                    if event.down.click_count > 1 {
+                                        column_widths
+                                            .update(cx, |column, _| {
+                                                column.on_double_click(
+                                                    header_idx,
+                                                    &initial_sizes,
+                                                    &resizables,
+                                                    window,
+                                                );
+                                            })
+                                            .ok();
+                                    }
+                                })
+                            } else {
+                                this
+                            }
+                        },
+                    )
+            },
+        ))
 }
 
 #[derive(Clone)]
@@ -939,6 +1047,12 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
             .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable)))
             .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
 
+        let current_widths_with_initial_sizes = self
+            .col_widths
+            .as_ref()
+            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial)))
+            .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
+
         let scroll_track_size = px(16.);
         let h_scroll_offset = if interaction_state
             .as_ref()
@@ -958,7 +1072,13 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
             .h_full()
             .v_flex()
             .when_some(self.headers.take(), |this, headers| {
-                this.child(render_header(headers, table_context.clone(), cx))
+                this.child(render_header(
+                    headers,
+                    table_context.clone(),
+                    current_widths_with_initial_sizes,
+                    interaction_state.as_ref().map(Entity::entity_id),
+                    cx,
+                ))
             })
             .when_some(current_widths, {
                 |this, (widths, resize_behavior)| {
@@ -972,19 +1092,28 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                                 .ok();
                         }
                     })
-                    .on_children_prepainted(move |bounds, _, cx| {
+                    .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_bounds_width =
+                                        bounds[0].right() - bounds[0].left();
+                                })
+                                .ok();
+                        }
+                    })
+                    .on_drop::<DraggedColumn>(move |_, _, 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();
+                                widths.widths = widths.visible_widths;
                             })
                             .ok();
+                        // Finish the resize operation
                     })
                 }
             })
-            .on_drop::<DraggedColumn>(|_, _, _| {
-                // Finish the resize operation
-            })
             .child(
                 div()
                     .flex_grow()
@@ -1313,3 +1442,323 @@ impl Component for Table<3> {
         )
     }
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    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)
+    }
+
+    fn cols_to_str<const COLS: usize>(cols: &[f32; COLS], total_size: f32) -> String {
+        cols.map(|f| "*".repeat(f32::round(f * total_size) as usize))
+            .join("|")
+    }
+
+    fn parse_resize_behavior<const COLS: usize>(
+        input: &str,
+        total_size: f32,
+    ) -> [ResizeBehavior; COLS] {
+        let mut resize_behavior = [ResizeBehavior::None; COLS];
+        let mut max_index = 0;
+        for (index, col) in input.split('|').enumerate() {
+            if col.starts_with('X') || col.is_empty() {
+                resize_behavior[index] = ResizeBehavior::None;
+            } else if col.starts_with('*') {
+                resize_behavior[index] = ResizeBehavior::MinSize(col.len() as f32 / total_size);
+            } else {
+                panic!("invalid test input: unrecognized resize behavior: {}", col);
+            }
+            max_index = index;
+        }
+
+        if max_index + 1 != COLS {
+            panic!("invalid test input: too many columns");
+        }
+        resize_behavior
+    }
+
+    mod reset_column_size {
+        use super::*;
+
+        fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
+            let mut widths = [f32::NAN; COLS];
+            let mut column_index = None;
+            for (index, col) in input.split('|').enumerate() {
+                widths[index] = col.len() as f32;
+                if col.starts_with('X') {
+                    column_index = Some(index);
+                }
+            }
+
+            for w in widths {
+                assert!(w.is_finite(), "incorrect number of columns");
+            }
+            let total = widths.iter().sum::<f32>();
+            for width in &mut widths {
+                *width /= total;
+            }
+            (widths, total, column_index)
+        }
+
+        #[track_caller]
+        fn check_reset_size<const COLS: usize>(
+            initial_sizes: &str,
+            widths: &str,
+            expected: &str,
+            resize_behavior: &str,
+        ) {
+            let (initial_sizes, total_1, None) = parse::<COLS>(initial_sizes) else {
+                panic!("invalid test input: initial sizes should not be marked");
+            };
+            let (widths, total_2, Some(column_index)) = parse::<COLS>(widths) else {
+                panic!("invalid test input: widths should be marked");
+            };
+            assert_eq!(
+                total_1, total_2,
+                "invalid test input: total width not the same {total_1}, {total_2}"
+            );
+            let (expected, total_3, None) = parse::<COLS>(expected) else {
+                panic!("invalid test input: expected should not be marked: {expected:?}");
+            };
+            assert_eq!(
+                total_2, total_3,
+                "invalid test input: total width not the same"
+            );
+            let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
+            let result = ColumnWidths::reset_to_initial_size(
+                column_index,
+                widths,
+                initial_sizes,
+                &resize_behavior,
+            );
+            let is_eq = is_almost_eq(&result, &expected);
+            if !is_eq {
+                let result_str = cols_to_str(&result, total_1);
+                let expected_str = cols_to_str(&expected, total_1);
+                panic!(
+                    "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
+                );
+            }
+        }
+
+        macro_rules! check_reset_size {
+            (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
+                check_reset_size::<$cols>($initial, $current, $expected, $resizing);
+            };
+            ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
+                #[test]
+                fn $name() {
+                    check_reset_size::<$cols>($initial, $current, $expected, $resizing);
+                }
+            };
+        }
+
+        check_reset_size!(
+            basic_right,
+            columns: 5,
+            starting: "**|**|**|**|**",
+            snapshot: "**|**|X|***|**",
+            expected: "**|**|**|**|**",
+            minimums: "X|*|*|*|*",
+        );
+
+        check_reset_size!(
+            basic_left,
+            columns: 5,
+            starting: "**|**|**|**|**",
+            snapshot: "**|**|***|X|**",
+            expected: "**|**|**|**|**",
+            minimums: "X|*|*|*|**",
+        );
+
+        check_reset_size!(
+            squashed_left_reset_col2,
+            columns: 6,
+            starting: "*|***|**|**|****|*",
+            snapshot: "*|*|X|*|*|********",
+            expected: "*|*|**|*|*|*******",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            grow_cascading_right,
+            columns: 6,
+            starting: "*|***|****|**|***|*",
+            snapshot: "*|***|X|**|**|*****",
+            expected: "*|***|****|*|*|****",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+           squashed_right_reset_col4,
+           columns: 6,
+           starting: "*|***|**|**|****|*",
+           snapshot: "*|********|*|*|X|*",
+           expected: "*|*****|*|*|****|*",
+           minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            reset_col6_right,
+            columns: 6,
+            starting: "*|***|**|***|***|**",
+            snapshot: "*|***|**|***|**|XXX",
+            expected: "*|***|**|***|***|**",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            reset_col6_left,
+            columns: 6,
+            starting: "*|***|**|***|***|**",
+            snapshot: "*|***|**|***|****|X",
+            expected: "*|***|**|***|***|**",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            last_column_grow_cascading,
+            columns: 6,
+            starting: "*|***|**|**|**|***",
+            snapshot: "*|*******|*|**|*|X",
+            expected: "*|******|*|*|*|***",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            goes_left_when_left_has_extreme_diff,
+            columns: 6,
+            starting: "*|***|****|**|**|***",
+            snapshot: "*|********|X|*|**|**",
+            expected: "*|*****|****|*|**|**",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            basic_shrink_right,
+            columns: 6,
+            starting: "**|**|**|**|**|**",
+            snapshot: "**|**|XXX|*|**|**",
+            expected: "**|**|**|**|**|**",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            shrink_should_go_left,
+            columns: 6,
+            starting: "*|***|**|*|*|*",
+            snapshot: "*|*|XXX|**|*|*",
+            expected: "*|**|**|**|*|*",
+            minimums: "X|*|*|*|*|*",
+        );
+
+        check_reset_size!(
+            shrink_should_go_right,
+            columns: 6,
+            starting: "*|***|**|**|**|*",
+            snapshot: "*|****|XXX|*|*|*",
+            expected: "*|****|**|**|*|*",
+            minimums: "X|*|*|*|*|*",
+        );
+    }
+
+    mod drag_handle {
+        use super::*;
+
+        fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
+            let mut widths = [f32::NAN; COLS];
+            let column_index = input.replace("*", "").find("I");
+            for (index, col) in input.replace("I", "|").split('|').enumerate() {
+                widths[index] = col.len() as f32;
+            }
+
+            for w in widths {
+                assert!(w.is_finite(), "incorrect number of columns");
+            }
+            let total = widths.iter().sum::<f32>();
+            for width in &mut widths {
+                *width /= total;
+            }
+            (widths, total, column_index)
+        }
+
+        #[track_caller]
+        fn check<const COLS: usize>(
+            distance: i32,
+            widths: &str,
+            expected: &str,
+            resize_behavior: &str,
+        ) {
+            let (mut widths, total_1, Some(column_index)) = parse::<COLS>(widths) else {
+                panic!("invalid test input: widths should be marked");
+            };
+            let (expected, total_2, None) = parse::<COLS>(expected) else {
+                panic!("invalid test input: expected should not be marked: {expected:?}");
+            };
+            assert_eq!(
+                total_1, total_2,
+                "invalid test input: total width not the same"
+            );
+            let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
+
+            let distance = distance as f32 / total_1;
+
+            let result = ColumnWidths::drag_column_handle(
+                distance,
+                column_index,
+                &mut widths,
+                &resize_behavior,
+            );
+
+            let is_eq = is_almost_eq(&widths, &expected);
+            if !is_eq {
+                let result_str = cols_to_str(&widths, total_1);
+                let expected_str = cols_to_str(&expected, total_1);
+                panic!(
+                    "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
+                );
+            }
+        }
+
+        macro_rules! check {
+            (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
+                check!($cols, $dist, $snapshot, $expected, $resizing);
+            };
+            ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
+                #[test]
+                fn $name() {
+                    check::<$cols>($dist, $current, $expected, $resizing);
+                }
+            };
+        }
+
+        check!(
+            basic_right_drag,
+            columns: 3,
+            distance: 1,
+            snapshot: "**|**I**",
+            expected: "**|***|*",
+            minimums: "X|*|*",
+        );
+
+        check!(
+            drag_left_against_mins,
+            columns: 5,
+            distance: -1,
+            snapshot: "*|*|*|*I*******",
+            expected: "*|*|*|*|*******",
+            minimums: "X|*|*|*|*",
+        );
+
+        check!(
+            drag_left,
+            columns: 5,
+            distance: -2,
+            snapshot: "*|*|*|*****I***",
+            expected: "*|*|*|***|*****",
+            minimums: "X|*|*|*|*",
+        );
+    }
+}