From 4cca593925a2a9ea31e9eea606026c4212dd5816 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 09:37:02 +0200 Subject: [PATCH 01/17] feat: Implement resizeable columns --- crates/git_graph/src/git_graph.rs | 1 + crates/ui/src/components/data_table.rs | 328 +++++++++++++++++++++++-- 2 files changed, 313 insertions(+), 16 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index aa5f6bc6e1293c..55cb7c0d06bcef 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -2599,6 +2599,7 @@ impl Render for GitGraph { ), header_context, Some(header_resize_info), + None, Some(self.column_widths.entity_id()), cx, )) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index e5a14a3ddabc0d..07998623f5e426 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,19 +1,19 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, - ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list, - transparent_black, uniform_list, + AbsoluteLength, AppContext as _, ClickEvent, DefiniteLength, DragMoveEvent, Empty, Entity, + EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, + Point, ScrollHandle, Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, + uniform_list, }; - use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, - StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar, - bind_redistributable_columns, div, example_group_with_title, h_flex, px, - render_redistributable_columns_resize_handles, single_example, + StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, TableResizeBehavior, + Window, WithScrollbar, bind_redistributable_columns, div, example_group_with_title, h_flex, + px, render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -22,10 +22,98 @@ pub mod table_row; #[cfg(test)] mod tests; +const RESIZE_DIVIDER_WIDTH: f32 = 1.0; +const RESIZE_COLUMN_WIDTH: f32 = 8.0; + +/// Used as the drag payload when resizing columns in `Resizable` mode. +#[derive(Debug)] +pub(crate) struct DraggedResizableColumn(pub(crate) usize); + /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; +/// State for independently resizable columns (spreadsheet-style). +/// +/// Each column has its own absolute width; dragging a resize handle changes only +/// that column's width, growing or shrinking the overall table width. +pub struct ResizableColumnsState { + initial_widths: TableRow, + widths: TableRow, + resize_behavior: TableRow, +} + +impl ResizableColumnsState { + pub fn new( + cols: usize, + initial_widths: Vec>, + resize_behavior: Vec, + ) -> Self { + let widths: TableRow = initial_widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols); + Self { + initial_widths: widths.clone(), + widths, + resize_behavior: resize_behavior.into_table_row(cols), + } + } + + pub fn resize_behavior(&self) -> &TableRow { + &self.resize_behavior + } + + pub(crate) fn on_drag_move( + &mut self, + drag_event: &DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let col_idx = drag_event.drag(cx).0; + let rem_size = window.rem_size(); + let drag_x = drag_event.event.position.x - drag_event.bounds.left(); + + let left_edge: Pixels = self.widths.as_slice()[..col_idx] + .iter() + .map(|width| width.to_pixels(rem_size)) + .fold(px(0.), |acc, x| acc + x) + + px(col_idx as f32 * RESIZE_DIVIDER_WIDTH); + + let new_width = drag_x - left_edge; + let new_width = self.apply_min_size(new_width, self.resize_behavior[col_idx], rem_size); + + self.widths[col_idx] = AbsoluteLength::Pixels(new_width); + cx.notify(); + } + + pub fn on_double_click(&mut self, col_idx: usize, _window: &mut Window) { + self.widths[col_idx] = self.initial_widths[col_idx]; + } + + fn apply_min_size( + &self, + width: Pixels, + behavior: TableResizeBehavior, + rem_size: Pixels, + ) -> Pixels { + match behavior.min_size() { + Some(min_rems) => { + let min_px = rem_size * min_rems; + width.max(min_px) + } + None => width, + } + } +} + +/// Info passed to `render_table_header` for resizable-column double-click reset. +pub struct ResizableHeaderInfo { + pub entity: WeakEntity, + pub resize_behavior: TableRow, +} + struct UniformListData { render_list_of_rows_fn: Box, &mut Window, &mut App) -> Vec>>, @@ -71,6 +159,7 @@ impl TableContents { pub struct TableInteractionState { pub focus_handle: FocusHandle, pub scroll_handle: UniformListScrollHandle, + pub horizontal_scroll_handle: ScrollHandle, pub custom_scrollbar: Option, } @@ -79,6 +168,7 @@ impl TableInteractionState { Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), + horizontal_scroll_handle: ScrollHandle::new(), custom_scrollbar: None, } } @@ -120,6 +210,9 @@ pub enum ColumnWidthConfig { columns_state: Entity, table_width: Option, }, + /// Independently resizable columns — dragging changes absolute column widths + /// and thus the overall table width. Like spreadsheets. + Resizable(Entity), } pub enum StaticColumnWidths { @@ -184,24 +277,47 @@ impl ColumnWidthConfig { columns_state: entity, .. } => Some(entity.read(cx).widths_to_render()), + ColumnWidthConfig::Resizable(entity) => { + let state = entity.read(cx); + Some(state.widths.map_cloned(|abs| { + Length::Definite(DefiniteLength::Absolute(abs)) + })) + } } } /// Table-level width. - pub fn table_width(&self) -> Option { + pub fn table_width(&self, window: &Window, cx: &App) -> Option { match self { ColumnWidthConfig::Static { table_width, .. } | ColumnWidthConfig::Redistributable { table_width, .. } => { table_width.map(Length::Definite) } + ColumnWidthConfig::Resizable(entity) => { + let state = entity.read(cx); + let rem_size = window.rem_size(); + let total: Pixels = state + .widths + .as_slice() + .iter() + .map(|abs| abs.to_pixels(rem_size)) + .fold(px(0.), |acc, x| acc + x) + + px((state.widths.cols().saturating_sub(1)) as f32 * RESIZE_DIVIDER_WIDTH); + Some(Length::Definite(DefiniteLength::Absolute( + AbsoluteLength::Pixels(total), + ))) + } } } /// ListHorizontalSizingBehavior for uniform_list. - pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior { - match self.table_width() { - Some(_) => ListHorizontalSizingBehavior::Unconstrained, - None => ListHorizontalSizingBehavior::FitList, + pub fn list_horizontal_sizing(&self, window: &Window, cx: &App) -> ListHorizontalSizingBehavior { + match self { + ColumnWidthConfig::Resizable(_) => ListHorizontalSizingBehavior::FitList, + _ => match self.table_width(window, cx) { + Some(_) => ListHorizontalSizingBehavior::Unconstrained, + None => ListHorizontalSizingBehavior::FitList, + }, } } } @@ -473,6 +589,7 @@ pub fn render_table_header( headers: TableRow, table_context: TableRenderContext, resize_info: Option, + resizable_info: Option, entity_id: Option, cx: &mut App, ) -> impl IntoElement { @@ -528,6 +645,22 @@ pub fn render_table_header( this } }) + .when_some(resizable_info.as_ref(), |this, info| { + if info.resize_behavior[header_idx].is_resizable() { + let entity = info.entity.clone(); + this.on_click(move |event: &ClickEvent, window, cx| { + if event.click_count() > 1 { + entity + .update(cx, |state, _| { + state.on_double_click(header_idx, window); + }) + .ok(); + } + }) + } else { + this + } + }) }), ) } @@ -572,6 +705,101 @@ impl TableRenderContext { } } +fn render_resize_handles_resizable( + columns_state: &Entity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let (column_widths, resize_behavior) = { + let state = columns_state.read(cx); + ( + state + .widths + .map_cloned(|abs| Length::Definite(DefiniteLength::Absolute(abs))), + state.resize_behavior.clone(), + ) + }; + + let resize_behavior = Rc::new(resize_behavior); + // Each column contributes a spacer; between columns there is a resize divider. + // Structure: [spacer_0][divider_0][spacer_1][divider_1]...[spacer_N-1] + let n_cols = column_widths.cols(); + let mut elements: Vec = Vec::with_capacity(n_cols * 2 - 1); + + for (col_idx, width) in column_widths.as_slice().iter().copied().enumerate() { + elements.push(div().w(width).h_full().into_any_element()); + + // Add a resize divider after every column except the last. + if col_idx + 1 < n_cols { + let resize_behavior = Rc::clone(&resize_behavior); + let columns_state = columns_state.clone(); + let divider = window.with_id(col_idx, |window| { + let mut resize_divider = div() + .id(col_idx) + .relative() + .top_0() + .w(px(RESIZE_DIVIDER_WIDTH)) + .h_full() + .bg(cx.theme().colors().border.opacity(0.8)); + + let mut resize_handle = div() + .id("column-resize-handle") + .absolute() + .left_neg_0p5() + .w(px(RESIZE_COLUMN_WIDTH)) + .h_full(); + + if resize_behavior[col_idx].is_resizable() { + let is_highlighted = window.use_state(cx, |_window, _cx| false); + + resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| { + div.bg(cx.theme().colors().border_focused) + }); + + resize_handle = resize_handle + .on_hover({ + let is_highlighted = is_highlighted.clone(); + move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered) + }) + .cursor_col_resize() + .on_click({ + let columns_state = columns_state.clone(); + move |event: &ClickEvent, window, cx| { + if event.click_count() >= 2 { + columns_state.update(cx, |state, _| { + state.on_double_click(col_idx, window); + }); + } + cx.stop_propagation(); + } + }) + .on_drag(DraggedResizableColumn(col_idx), { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }) + .on_drop::(move |_, _, cx| { + is_highlighted.write(cx, false); + }); + } + + resize_divider.child(resize_handle).into_any_element() + }); + elements.push(divider); + } + } + + h_flex() + .id("resize-handles") + .absolute() + .inset_0() + .w_full() + .children(elements) + .into_any_element() +} + impl RenderOnce for Table { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let table_context = TableRenderContext::new(&self, cx); @@ -587,8 +815,19 @@ impl RenderOnce for Table { _ => None, }); - let table_width = self.column_width_config.table_width(); - let horizontal_sizing = self.column_width_config.list_horizontal_sizing(); + let resizable_header_info = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Resizable(entity) => Some(ResizableHeaderInfo { + entity: entity.downgrade(), + resize_behavior: entity.read(cx).resize_behavior.clone(), + }), + _ => None, + }); + + let table_width = self.column_width_config.table_width(window, cx); + let horizontal_sizing = self.column_width_config.list_horizontal_sizing(window, cx); let no_rows_rendered = self.rows.is_empty(); // Extract redistributable entity for drag/drop/prepaint handlers @@ -603,6 +842,17 @@ impl RenderOnce for Table { _ => None, }); + // Extract resizable entity for drag-move handler + let resizable_entity = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Resizable(entity) => Some(entity.clone()), + _ => None, + }); + + let is_resizable = resizable_entity.is_some(); + let resize_handles = interaction_state .as_ref() @@ -610,11 +860,17 @@ impl RenderOnce for Table { ColumnWidthConfig::Redistributable { columns_state, .. } => Some( render_redistributable_columns_resize_handles(columns_state, window, cx), ), + ColumnWidthConfig::Resizable(entity) => { + Some(render_resize_handles_resizable(entity, window, cx)) + } _ => None, }); let table = div() - .when_some(table_width, |this, width| this.w(width)) + .when_some( + if is_resizable { None } else { table_width }, + |this, width| this.w(width), + ) .h_full() .v_flex() .when_some(self.headers.take(), |this, headers| { @@ -622,6 +878,7 @@ impl RenderOnce for Table { headers, table_context.clone(), header_resize_info, + resizable_header_info, interaction_state.as_ref().map(Entity::entity_id), cx, )) @@ -629,6 +886,11 @@ impl RenderOnce for Table { .when_some(redistributable_entity, |this, widths| { bind_redistributable_columns(this, widths) }) + .when_some(resizable_entity, |this, entity| { + this.on_drag_move::(move |event, window, cx| { + entity.update(cx, |state, cx| state.on_drag_move(event, window, cx)); + }) + }) .child({ let content = div() .flex_grow() @@ -742,7 +1004,41 @@ impl RenderOnce for Table { }, ); - if let Some(interaction_state) = interaction_state.as_ref() { + // For resizable mode, wrap in a horizontal-scroll container + let table_wrapper = div().size_full(); + + if is_resizable { + if let Some(state) = interaction_state.as_ref() { + let h_scroll_container = div() + .id("table-h-scroll") + .overflow_x_scroll() + .flex_grow() + .h_full() + .when_some(table_width, |this, width| this.w(width)) + .track_scroll(&state.read(cx).horizontal_scroll_handle) + .child(table); + + let outer = table_wrapper + .child(h_scroll_container) + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Horizontal) + .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle), + window, + cx, + ); + + if let Some(interaction_state) = interaction_state.as_ref() { + outer + .track_focus(&interaction_state.read(cx).focus_handle) + .id(("table", interaction_state.entity_id())) + .into_any_element() + } else { + outer.into_any_element() + } + } else { + table.into_any_element() + } + } else if let Some(interaction_state) = interaction_state.as_ref() { table .track_focus(&interaction_state.read(cx).focus_handle) .id(("table", interaction_state.entity_id())) From 56142407af11bf3367ea7780913f5190bd86360b Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 10:02:52 +0200 Subject: [PATCH 02/17] feat: Update CSV preview with resizable column --- crates/csv_preview/src/csv_preview.rs | 31 +++++++------------ .../csv_preview/src/renderer/render_table.rs | 10 +++--- crates/ui/src/components/data_table.rs | 4 +++ 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 1b99139b004a94..02e48e9f749f1a 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -10,8 +10,8 @@ use std::{ use crate::table_data_engine::TableDataEngine; use ui::{ - AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString, - TableInteractionState, TableResizeBehavior, prelude::*, + AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, + TableResizeBehavior, prelude::*, }; use workspace::{Item, SplitDirection, Workspace}; @@ -56,27 +56,20 @@ pub fn init(cx: &mut App) { impl CsvPreviewView { pub(crate) fn sync_column_widths(&self, cx: &mut Context) { - // plus 1 for the rows column + // plus 1 for the row identifier column let cols = self.engine.contents.headers.cols() + 1; - let remaining_col_number = cols.saturating_sub(1); - let fraction = if remaining_col_number > 0 { - 1. / remaining_col_number as f32 - } else { - 1. - }; - let mut widths = vec![DefiniteLength::Fraction(fraction); cols]; let line_number_width = self.calculate_row_identifier_column_width(); - widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into())); + + let mut widths: Vec = + vec![AbsoluteLength::Pixels(px(150.)); cols]; + widths[0] = AbsoluteLength::Pixels(px(line_number_width)); let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; resize_behaviors[0] = TableResizeBehavior::None; self.column_widths.widths.update(cx, |state, _cx| { - if state.cols() != cols - || state.initial_widths().as_slice() != widths.as_slice() - || state.resize_behavior().as_slice() != resize_behaviors.as_slice() - { - *state = RedistributableColumnsState::new(cols, widths, resize_behaviors); + if state.cols() != cols { + *state = ResizableColumnsState::new(cols, widths, resize_behaviors); } }); } @@ -313,16 +306,16 @@ impl PerformanceMetrics { /// Holds state of column widths for a table component in CSV preview. pub(crate) struct ColumnWidths { - pub widths: Entity, + pub widths: Entity, } impl ColumnWidths { pub(crate) fn new(cx: &mut Context, cols: usize) -> Self { Self { widths: cx.new(|_cx| { - RedistributableColumnsState::new( + ResizableColumnsState::new( cols, - vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols], + vec![AbsoluteLength::Pixels(px(150.)); cols], vec![ui::TableResizeBehavior::Resizable; cols], ) }), diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index fb3d7e5fc603ba..7987aa16d56826 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -2,7 +2,7 @@ use crate::types::TableCell; use gpui::{AnyElement, Entity}; use std::ops::Range; use ui::{ - ColumnWidthConfig, RedistributableColumnsState, Table, UncheckedTableRow, div, prelude::*, + ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*, }; use crate::{ @@ -13,10 +13,10 @@ use crate::{ impl CsvPreviewView { /// Creates a new table. - /// Column number is derived from the `RedistributableColumnsState` entity. + /// Column number is derived from the `ResizableColumnsState` entity. pub(crate) fn create_table( &self, - current_widths: &Entity, + current_widths: &Entity, cx: &mut Context, ) -> AnyElement { self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx) @@ -25,7 +25,7 @@ impl CsvPreviewView { fn create_table_inner( &self, row_count: usize, - current_widths: &Entity, + current_widths: &Entity, cx: &mut Context, ) -> AnyElement { let cols = current_widths.read(cx).cols(); @@ -54,7 +54,7 @@ impl CsvPreviewView { Table::new(cols) .interactable(&self.table_interaction_state) .striped() - .width_config(ColumnWidthConfig::redistributable(current_widths.clone())) + .width_config(ColumnWidthConfig::Resizable(current_widths.clone())) .header(headers) .disable_base_style() .map(|table| { diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 07998623f5e426..a5b831968bc025 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -61,6 +61,10 @@ impl ResizableColumnsState { } } + pub fn cols(&self) -> usize { + self.widths.cols() + } + pub fn resize_behavior(&self) -> &TableRow { &self.resize_behavior } From 3341af74785826facb4138004c535c297d888f23 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 10:43:37 +0200 Subject: [PATCH 03/17] fix: Horizontal scrolling --- crates/csv_preview/src/renderer/render_table.rs | 4 +--- crates/ui/src/components/data_table.rs | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index 7987aa16d56826..71bb9b84c8955c 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -1,9 +1,7 @@ use crate::types::TableCell; use gpui::{AnyElement, Entity}; use std::ops::Range; -use ui::{ - ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*, -}; +use ui::{ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*}; use crate::{ CsvPreviewView, diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index a5b831968bc025..fbf2f542989807 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -871,10 +871,7 @@ impl RenderOnce for Table { }); let table = div() - .when_some( - if is_resizable { None } else { table_width }, - |this, width| this.w(width), - ) + .when_some(table_width, |this, width| this.w(width)) .h_full() .v_flex() .when_some(self.headers.take(), |this, headers| { @@ -1018,7 +1015,6 @@ impl RenderOnce for Table { .overflow_x_scroll() .flex_grow() .h_full() - .when_some(table_width, |this, width| this.w(width)) .track_scroll(&state.read(cx).horizontal_scroll_handle) .child(table); From 43762a659c15d9d3a3d4edee29306c093c926279 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 11:04:35 +0200 Subject: [PATCH 04/17] fix: Resize handles --- crates/ui/src/components/data_table.rs | 52 +++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index fbf2f542989807..a6d21191ea0216 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -82,8 +82,7 @@ impl ResizableColumnsState { let left_edge: Pixels = self.widths.as_slice()[..col_idx] .iter() .map(|width| width.to_pixels(rem_size)) - .fold(px(0.), |acc, x| acc + x) - + px(col_idx as f32 * RESIZE_DIVIDER_WIDTH); + .fold(px(0.), |acc, x| acc + x); let new_width = drag_x - left_edge; let new_width = self.apply_min_size(new_width, self.resize_behavior[col_idx], rem_size); @@ -305,8 +304,7 @@ impl ColumnWidthConfig { .as_slice() .iter() .map(|abs| abs.to_pixels(rem_size)) - .fold(px(0.), |acc, x| acc + x) - + px((state.widths.cols().saturating_sub(1)) as f32 * RESIZE_DIVIDER_WIDTH); + .fold(px(0.), |acc, x| acc + x); Some(Length::Definite(DefiniteLength::Absolute( AbsoluteLength::Pixels(total), ))) @@ -714,34 +712,38 @@ fn render_resize_handles_resizable( window: &mut Window, cx: &mut App, ) -> AnyElement { - let (column_widths, resize_behavior) = { + let (widths, resize_behavior) = { let state = columns_state.read(cx); - ( - state - .widths - .map_cloned(|abs| Length::Definite(DefiniteLength::Absolute(abs))), - state.resize_behavior.clone(), - ) + (state.widths.clone(), state.resize_behavior.clone()) }; + let rem_size = window.rem_size(); let resize_behavior = Rc::new(resize_behavior); - // Each column contributes a spacer; between columns there is a resize divider. - // Structure: [spacer_0][divider_0][spacer_1][divider_1]...[spacer_N-1] - let n_cols = column_widths.cols(); - let mut elements: Vec = Vec::with_capacity(n_cols * 2 - 1); - - for (col_idx, width) in column_widths.as_slice().iter().copied().enumerate() { - elements.push(div().w(width).h_full().into_any_element()); - - // Add a resize divider after every column except the last. - if col_idx + 1 < n_cols { + let n_cols = widths.cols(); + let mut dividers: Vec = Vec::with_capacity(n_cols); + let mut accumulated_px = px(0.); + + for col_idx in 0..n_cols { + let col_width_px = widths[col_idx].to_pixels(rem_size); + accumulated_px = accumulated_px + col_width_px; + + // Add a resize divider after every column, including the last. + // For the last column the divider is pulled 1px inward so it isn't clipped + // by the overflow_hidden content container. + { + let divider_left = if col_idx + 1 == n_cols { + accumulated_px - px(RESIZE_DIVIDER_WIDTH) + } else { + accumulated_px + }; let resize_behavior = Rc::clone(&resize_behavior); let columns_state = columns_state.clone(); let divider = window.with_id(col_idx, |window| { let mut resize_divider = div() .id(col_idx) - .relative() + .absolute() .top_0() + .left(divider_left) .w(px(RESIZE_DIVIDER_WIDTH)) .h_full() .bg(cx.theme().colors().border.opacity(0.8)); @@ -791,16 +793,16 @@ fn render_resize_handles_resizable( resize_divider.child(resize_handle).into_any_element() }); - elements.push(divider); + dividers.push(divider); } } - h_flex() + div() .id("resize-handles") .absolute() .inset_0() .w_full() - .children(elements) + .children(dividers) .into_any_element() } From 47318f27dcc37fe972acc7da36965a327941892a Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 11:39:01 +0200 Subject: [PATCH 05/17] fix: Update the line height for variable list mode --- crates/gpui/src/elements/list.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 5a88d81c18db5e..a8a233157eb714 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -1333,11 +1333,12 @@ impl Element for List { let height = bounds.size.height; let scroll_top = prepaint.layout.scroll_top; let hitbox_id = prepaint.hitbox.id; + let line_height = window.line_height(); let mut accumulated_scroll_delta = ScrollDelta::default(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) { accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta); - let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.)); + let pixel_delta = accumulated_scroll_delta.pixel_delta(line_height); list_state.0.borrow_mut().scroll( &scroll_top, height, From 3d0adb1d3320f72b6a9b43903e5ed80737f646ac Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 11:39:32 +0200 Subject: [PATCH 06/17] fix: Update the scrollbar selection in different render mode --- crates/ui/src/components/data_table.rs | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index a6d21191ea0216..bc01781bca6f71 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -835,6 +835,11 @@ impl RenderOnce for Table { let table_width = self.column_width_config.table_width(window, cx); let horizontal_sizing = self.column_width_config.list_horizontal_sizing(window, cx); let no_rows_rendered = self.rows.is_empty(); + let variable_list_state = if let TableContents::VariableRowHeightList(data) = &self.rows { + Some(data.list_state.clone()) + } else { + None + }; // Extract redistributable entity for drag/drop/prepaint handlers let redistributable_entity = @@ -980,13 +985,23 @@ impl RenderOnce for Table { .custom_scrollbar .clone() .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - content - .custom_scrollbars( - scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), - window, - cx, - ) - .into_any_element() + if let Some(list_state) = variable_list_state { + content + .custom_scrollbars( + scrollbars.tracked_scroll_handle(&list_state), + window, + cx, + ) + .into_any_element() + } else { + content + .custom_scrollbars( + scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), + window, + cx, + ) + .into_any_element() + } } else { content.into_any_element() } From 177473581a13a841b01bf706d11d4c345e6ab489 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 12:16:12 +0200 Subject: [PATCH 07/17] fix: Move scrollbar to the right edge of the screen & fix diagonal scroll --- crates/ui/src/components/data_table.rs | 65 +++++++++++++++----------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index bc01781bca6f71..e0a8db96b13fc0 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -979,32 +979,7 @@ impl RenderOnce for Table { }) .when_some(resize_handles, |parent, handles| parent.child(handles)); - if let Some(state) = interaction_state.as_ref() { - let scrollbars = state - .read(cx) - .custom_scrollbar - .clone() - .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - if let Some(list_state) = variable_list_state { - content - .custom_scrollbars( - scrollbars.tracked_scroll_handle(&list_state), - window, - cx, - ) - .into_any_element() - } else { - content - .custom_scrollbars( - scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), - window, - cx, - ) - .into_any_element() - } - } else { - content.into_any_element() - } + content.into_any_element() }) .when_some( no_rows_rendered @@ -1027,13 +1002,14 @@ impl RenderOnce for Table { if is_resizable { if let Some(state) = interaction_state.as_ref() { - let h_scroll_container = div() + let mut h_scroll_container = div() .id("table-h-scroll") .overflow_x_scroll() .flex_grow() .h_full() .track_scroll(&state.read(cx).horizontal_scroll_handle) .child(table); + h_scroll_container.style().restrict_scroll_to_axis = Some(true); let outer = table_wrapper .child(h_scroll_container) @@ -1044,6 +1020,25 @@ impl RenderOnce for Table { cx, ); + let scrollbars = state + .read(cx) + .custom_scrollbar + .clone() + .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); + let outer = if let Some(list_state) = variable_list_state { + outer.custom_scrollbars( + scrollbars.tracked_scroll_handle(&list_state), + window, + cx, + ) + } else { + outer.custom_scrollbars( + scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), + window, + cx, + ) + }; + if let Some(interaction_state) = interaction_state.as_ref() { outer .track_focus(&interaction_state.read(cx).focus_handle) @@ -1056,7 +1051,21 @@ impl RenderOnce for Table { table.into_any_element() } } else if let Some(interaction_state) = interaction_state.as_ref() { - table + let scrollbars = interaction_state + .read(cx) + .custom_scrollbar + .clone() + .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); + let table_with_scrollbar = if let Some(list_state) = variable_list_state { + table.custom_scrollbars(scrollbars.tracked_scroll_handle(&list_state), window, cx) + } else { + table.custom_scrollbars( + scrollbars.tracked_scroll_handle(&interaction_state.read(cx).scroll_handle), + window, + cx, + ) + }; + table_with_scrollbar .track_focus(&interaction_state.read(cx).focus_handle) .id(("table", interaction_state.entity_id())) .into_any_element() From 141b75efca5693fe812c59e003d414d8c4e04ed0 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 12:47:37 +0200 Subject: [PATCH 08/17] fix: Update the variable list mode with measuring of all rows --- crates/csv_preview/src/csv_preview.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 02e48e9f749f1a..5287a19f9f7503 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -172,7 +172,8 @@ impl CsvPreviewView { column_widths: ColumnWidths::new(cx, 1), parsing_task: None, performance_metrics: PerformanceMetrics::default(), - list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)), + list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) + .measure_all(), settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -200,7 +201,8 @@ impl CsvPreviewView { // Update list state with filtered row count let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.)); + self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.)) + .measure_all(); } pub fn resolve_active_item_as_csv_editor( From 8c2586cfce8d8a458723ee5f57919b227bd44a22 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Tue, 7 Apr 2026 12:48:04 +0200 Subject: [PATCH 09/17] fix: Fix diagonal scroll on horizontal scrolling --- crates/ui/src/components/data_table.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index e0a8db96b13fc0..8c2e19d8ba5a02 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1025,7 +1025,7 @@ impl RenderOnce for Table { .custom_scrollbar .clone() .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - let outer = if let Some(list_state) = variable_list_state { + let mut outer = if let Some(list_state) = variable_list_state { outer.custom_scrollbars( scrollbars.tracked_scroll_handle(&list_state), window, @@ -1038,6 +1038,10 @@ impl RenderOnce for Table { cx, ) }; + // Prevent horizontal scroll events from being routed to the vertical axis + // (the overflow_x_scroll added by custom_scrollbars for the H scrollbar would + // otherwise trigger the fallback delta_y = delta.x when overflow.y != Scroll). + outer.style().restrict_scroll_to_axis = Some(true); if let Some(interaction_state) = interaction_state.as_ref() { outer @@ -1056,7 +1060,7 @@ impl RenderOnce for Table { .custom_scrollbar .clone() .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - let table_with_scrollbar = if let Some(list_state) = variable_list_state { + let mut table_with_scrollbar = if let Some(list_state) = variable_list_state { table.custom_scrollbars(scrollbars.tracked_scroll_handle(&list_state), window, cx) } else { table.custom_scrollbars( @@ -1065,6 +1069,8 @@ impl RenderOnce for Table { cx, ) }; + // Prevent horizontal events from routing into the vertical scroll axis via fallback. + table_with_scrollbar.style().restrict_scroll_to_axis = Some(true); table_with_scrollbar .track_focus(&interaction_state.read(cx).focus_handle) .id(("table", interaction_state.entity_id())) From 9fae86dede4361bb8508b929b6ef1f8b0c013e3b Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 00:57:58 -0400 Subject: [PATCH 10/17] Start work on combining both header states --- crates/git_graph/src/git_graph.rs | 4 +- crates/ui/src/components/data_table.rs | 101 ++++++------------ .../src/components/redistributable_columns.rs | 46 +++++++- 3 files changed, 78 insertions(+), 73 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 55cb7c0d06bcef..3f3a4cc8265cc3 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -2556,7 +2556,8 @@ impl Render for GitGraph { this.child(self.render_loading_spinner(cx)) }) } else { - let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx); + let header_resize_info = + HeaderResizeInfo::from_redistributable(&self.column_widths, cx); let header_context = TableRenderContext::for_column_widths( Some(self.column_widths.read(cx).widths_to_render()), true, @@ -2599,7 +2600,6 @@ impl Render for GitGraph { ), header_context, Some(header_resize_info), - None, Some(self.column_widths.entity_id()), cx, )) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 8c2e19d8ba5a02..379db6e2dad848 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,22 +1,22 @@ use std::{ops::Range, rc::Rc}; -use gpui::{ - AbsoluteLength, AppContext as _, ClickEvent, DefiniteLength, DragMoveEvent, Empty, Entity, - EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, - Point, ScrollHandle, Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, - uniform_list, -}; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, TableResizeBehavior, - Window, WithScrollbar, bind_redistributable_columns, div, example_group_with_title, h_flex, - px, render_redistributable_columns_resize_handles, single_example, + 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, }; +use gpui::{ + AbsoluteLength, AppContext as _, ClickEvent, DefiniteLength, DragMoveEvent, Empty, Entity, + EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, + Point, ScrollHandle, Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, + uniform_list, +}; pub mod table_row; #[cfg(test)] @@ -91,7 +91,7 @@ impl ResizableColumnsState { cx.notify(); } - pub fn on_double_click(&mut self, col_idx: usize, _window: &mut Window) { + pub fn reset_column_to_initial_width(&mut self, col_idx: usize) { self.widths[col_idx] = self.initial_widths[col_idx]; } @@ -111,12 +111,6 @@ impl ResizableColumnsState { } } -/// Info passed to `render_table_header` for resizable-column double-click reset. -pub struct ResizableHeaderInfo { - pub entity: WeakEntity, - pub resize_behavior: TableRow, -} - struct UniformListData { render_list_of_rows_fn: Box, &mut Window, &mut App) -> Vec>>, @@ -282,9 +276,11 @@ impl ColumnWidthConfig { } => Some(entity.read(cx).widths_to_render()), ColumnWidthConfig::Resizable(entity) => { let state = entity.read(cx); - Some(state.widths.map_cloned(|abs| { - Length::Definite(DefiniteLength::Absolute(abs)) - })) + Some( + state + .widths + .map_cloned(|abs| Length::Definite(DefiniteLength::Absolute(abs))), + ) } } } @@ -313,7 +309,11 @@ impl ColumnWidthConfig { } /// ListHorizontalSizingBehavior for uniform_list. - pub fn list_horizontal_sizing(&self, window: &Window, cx: &App) -> ListHorizontalSizingBehavior { + pub fn list_horizontal_sizing( + &self, + window: &Window, + cx: &App, + ) -> ListHorizontalSizingBehavior { match self { ColumnWidthConfig::Resizable(_) => ListHorizontalSizingBehavior::FitList, _ => match self.table_width(window, cx) { @@ -591,7 +591,6 @@ pub fn render_table_header( headers: TableRow, table_context: TableRenderContext, resize_info: Option, - resizable_info: Option, entity_id: Option, cx: &mut App, ) -> impl IntoElement { @@ -634,29 +633,7 @@ pub fn render_table_header( if info.resize_behavior[header_idx].is_resizable() { this.on_click(move |event, window, cx| { if event.click_count() > 1 { - info.columns_state - .update(cx, |column, _| { - column.reset_column_to_initial_width( - header_idx, window, - ); - }) - .ok(); - } - }) - } else { - this - } - }) - .when_some(resizable_info.as_ref(), |this, info| { - if info.resize_behavior[header_idx].is_resizable() { - let entity = info.entity.clone(); - this.on_click(move |event: &ClickEvent, window, cx| { - if event.click_count() > 1 { - entity - .update(cx, |state, _| { - state.on_double_click(header_idx, window); - }) - .ok(); + info.reset_column(header_idx, window, cx); } }) } else { @@ -770,10 +747,11 @@ fn render_resize_handles_resizable( .cursor_col_resize() .on_click({ let columns_state = columns_state.clone(); - move |event: &ClickEvent, window, cx| { + move |event: &ClickEvent, _window, cx| { if event.click_count() >= 2 { - columns_state.update(cx, |state, _| { - state.on_double_click(col_idx, window); + columns_state.update(cx, |state, cx| { + state.reset_column_to_initial_width(col_idx); + cx.notify(); }); } cx.stop_propagation(); @@ -816,19 +794,11 @@ impl RenderOnce for Table { .as_ref() .and_then(|_| match &self.column_width_config { ColumnWidthConfig::Redistributable { columns_state, .. } => { - Some(HeaderResizeInfo::from_state(columns_state, cx)) + Some(HeaderResizeInfo::from_redistributable(columns_state, cx)) + } + ColumnWidthConfig::Resizable(entity) => { + Some(HeaderResizeInfo::from_resizable(entity, cx)) } - _ => None, - }); - - let resizable_header_info = - interaction_state - .as_ref() - .and_then(|_| match &self.column_width_config { - ColumnWidthConfig::Resizable(entity) => Some(ResizableHeaderInfo { - entity: entity.downgrade(), - resize_behavior: entity.read(cx).resize_behavior.clone(), - }), _ => None, }); @@ -886,7 +856,6 @@ impl RenderOnce for Table { headers, table_context.clone(), header_resize_info, - resizable_header_info, interaction_state.as_ref().map(Entity::entity_id), cx, )) @@ -1011,14 +980,12 @@ impl RenderOnce for Table { .child(table); h_scroll_container.style().restrict_scroll_to_axis = Some(true); - let outer = table_wrapper - .child(h_scroll_container) - .custom_scrollbars( - Scrollbars::new(ScrollAxes::Horizontal) - .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle), - window, - cx, - ); + let outer = table_wrapper.child(h_scroll_container).custom_scrollbars( + Scrollbars::new(ScrollAxes::Horizontal) + .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle), + window, + cx, + ); let scrollbars = state .read(cx) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index cd22c31e19736e..9e0e91143b209d 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -6,7 +6,10 @@ use gpui::{ }; use itertools::intersperse_with; -use super::data_table::table_row::{IntoTableRow as _, TableRow}; +use super::data_table::{ + ResizableColumnsState, + 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, @@ -40,20 +43,55 @@ impl TableResizeBehavior { } } +#[derive(Clone)] +pub(crate) enum ColumnsStateRef { + Redistributable(WeakEntity), + Resizable(WeakEntity), +} + #[derive(Clone)] pub struct HeaderResizeInfo { - pub columns_state: WeakEntity, + pub(crate) columns_state: ColumnsStateRef, pub resize_behavior: TableRow, } impl HeaderResizeInfo { - pub fn from_state(columns_state: &Entity, cx: &App) -> Self { + pub fn from_redistributable( + columns_state: &Entity, + cx: &App, + ) -> Self { + let resize_behavior = columns_state.read(cx).resize_behavior().clone(); + Self { + columns_state: ColumnsStateRef::Redistributable(columns_state.downgrade()), + resize_behavior, + } + } + + pub fn from_resizable(columns_state: &Entity, cx: &App) -> Self { let resize_behavior = columns_state.read(cx).resize_behavior().clone(); Self { - columns_state: columns_state.downgrade(), + columns_state: ColumnsStateRef::Resizable(columns_state.downgrade()), resize_behavior, } } + + pub fn reset_column(&self, col_idx: usize, window: &mut Window, cx: &mut App) { + match &self.columns_state { + ColumnsStateRef::Redistributable(weak) => { + weak.update(cx, |state, _| { + state.reset_column_to_initial_width(col_idx, window); + }) + .ok(); + } + ColumnsStateRef::Resizable(weak) => { + weak.update(cx, |state, cx| { + state.reset_column_to_initial_width(col_idx); + cx.notify(); + }) + .ok(); + } + } + } } pub struct RedistributableColumnsState { From 4a5f470f8fc85a224f57de4e1264ace8d2811b7c Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 01:27:42 -0400 Subject: [PATCH 11/17] Simplify some code --- crates/ui/src/components/data_table.rs | 69 ++++++++----------- .../src/components/redistributable_columns.rs | 10 +-- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 379db6e2dad848..b6363c82386f70 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -3,11 +3,12 @@ use std::{ops::Range, rc::Rc}; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, - Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, - RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, - StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, TableResizeBehavior, - Window, WithScrollbar, bind_redistributable_columns, div, example_group_with_title, h_flex, px, - render_redistributable_columns_resize_handles, single_example, + Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RESIZE_COLUMN_WIDTH, + RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, + ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, + StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns, + div, example_group_with_title, h_flex, px, render_redistributable_columns_resize_handles, + single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -22,9 +23,6 @@ pub mod table_row; #[cfg(test)] mod tests; -const RESIZE_DIVIDER_WIDTH: f32 = 1.0; -const RESIZE_COLUMN_WIDTH: f32 = 8.0; - /// Used as the drag payload when resizing columns in `Resizable` mode. #[derive(Debug)] pub(crate) struct DraggedResizableColumn(pub(crate) usize); @@ -811,42 +809,31 @@ impl RenderOnce for Table { None }; - // Extract redistributable entity for drag/drop/prepaint handlers - let redistributable_entity = - interaction_state - .as_ref() - .and_then(|_| match &self.column_width_config { - ColumnWidthConfig::Redistributable { - columns_state: entity, - .. - } => Some(entity.clone()), - _ => None, - }); - - // Extract resizable entity for drag-move handler - let resizable_entity = - interaction_state - .as_ref() - .and_then(|_| match &self.column_width_config { - ColumnWidthConfig::Resizable(entity) => Some(entity.clone()), - _ => None, - }); + let (redistributable_entity, resizable_entity, resize_handles) = + if let Some(_) = interaction_state.as_ref() { + match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => ( + Some(columns_state.clone()), + None, + Some(render_redistributable_columns_resize_handles( + columns_state, + window, + cx, + )), + ), + ColumnWidthConfig::Resizable(entity) => ( + None, + Some(entity.clone()), + Some(render_resize_handles_resizable(entity, window, cx)), + ), + _ => (None, None, None), + } + } else { + (None, None, None) + }; let is_resizable = resizable_entity.is_some(); - 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), - ), - ColumnWidthConfig::Resizable(entity) => { - Some(render_resize_handles_resizable(entity, window, cx)) - } - _ => None, - }); - let table = div() .when_some(table_width, |this, width| this.w(width)) .h_full() diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index 9e0e91143b209d..3253ead001275c 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -16,8 +16,8 @@ use crate::{ px, }; -const RESIZE_COLUMN_WIDTH: f32 = 8.0; -const RESIZE_DIVIDER_WIDTH: f32 = 1.0; +pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0; +pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0; #[derive(Debug)] struct DraggedColumn(usize); @@ -78,8 +78,9 @@ impl HeaderResizeInfo { pub fn reset_column(&self, col_idx: usize, window: &mut Window, cx: &mut App) { match &self.columns_state { ColumnsStateRef::Redistributable(weak) => { - weak.update(cx, |state, _| { + weak.update(cx, |state, cx| { state.reset_column_to_initial_width(col_idx, window); + cx.notify(); }) .ok(); } @@ -465,11 +466,12 @@ pub fn render_redistributable_columns_resize_handles( let columns_state = columns_state.clone(); move |event, window, cx| { if event.click_count() >= 2 { - columns_state.update(cx, |columns, _| { + columns_state.update(cx, |columns, cx| { columns.reset_column_to_initial_width( current_column_ix, window, ); + cx.notify(); }); } From 469ee785cee094ab28c46502d1aaca45f613ae7b Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 01:50:52 -0400 Subject: [PATCH 12/17] Unify on drag types --- crates/ui/src/components/data_table.rs | 49 ++++++++++--------- .../src/components/redistributable_columns.rs | 37 +++++++++----- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index b6363c82386f70..3e052296aca29e 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -2,13 +2,13 @@ use std::{ops::Range, rc::Rc}; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, - ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, - Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RESIZE_COLUMN_WIDTH, - RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, - ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, - StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns, - div, example_group_with_title, h_flex, px, render_redistributable_columns_resize_handles, - single_example, + ComponentScope, Context, Div, DraggedColumn, ElementId, FixedWidth as _, FluentBuilder as _, + HeaderResizeInfo, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, + RESIZE_COLUMN_WIDTH, RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, + RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, + Styled, StyledExt as _, StyledTypography, TableResizeBehavior, Window, WithScrollbar, + bind_redistributable_columns, div, example_group_with_title, h_flex, px, + render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -23,10 +23,6 @@ pub mod table_row; #[cfg(test)] mod tests; -/// Used as the drag payload when resizing columns in `Resizable` mode. -#[derive(Debug)] -pub(crate) struct DraggedResizableColumn(pub(crate) usize); - /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; @@ -69,11 +65,11 @@ impl ResizableColumnsState { pub(crate) fn on_drag_move( &mut self, - drag_event: &DragMoveEvent, + drag_event: &DragMoveEvent, window: &mut Window, cx: &mut Context, ) { - let col_idx = drag_event.drag(cx).0; + let col_idx = drag_event.drag(cx).col_idx; let rem_size = window.rem_size(); let drag_x = drag_event.event.position.x - drag_event.bounds.left(); @@ -755,14 +751,20 @@ fn render_resize_handles_resizable( cx.stop_propagation(); } }) - .on_drag(DraggedResizableColumn(col_idx), { - let is_highlighted = is_highlighted.clone(); - move |_, _offset, _window, cx| { - is_highlighted.write(cx, true); - cx.new(|_cx| Empty) - } - }) - .on_drop::(move |_, _, cx| { + .on_drag( + DraggedColumn { + col_idx, + state_id: columns_state.entity_id(), + }, + { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }, + ) + .on_drop::(move |_, _, cx| { is_highlighted.write(cx, false); }); } @@ -851,7 +853,10 @@ impl RenderOnce for Table { bind_redistributable_columns(this, widths) }) .when_some(resizable_entity, |this, entity| { - this.on_drag_move::(move |event, window, cx| { + this.on_drag_move::(move |event, window, cx| { + if event.drag(cx).state_id != entity.entity_id() { + return; + } entity.update(cx, |state, cx| state.on_drag_move(event, window, cx)); }) }) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index 3253ead001275c..e73711feca2740 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length, - WeakEntity, + AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, + EntityId, Length, WeakEntity, }; use itertools::intersperse_with; @@ -19,8 +19,14 @@ use crate::{ pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0; pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0; +/// Drag payload for column resize handles. +/// Includes the `EntityId` of the owning column state so that +/// `on_drag_move` handlers on unrelated tables ignore the event. #[derive(Debug)] -struct DraggedColumn(usize); +pub(crate) struct DraggedColumn { + pub(crate) col_idx: usize, + pub(crate) state_id: EntityId, +} #[derive(Debug, Copy, Clone, PartialEq)] pub enum TableResizeBehavior { @@ -276,7 +282,7 @@ impl RedistributableColumnsState { let mut col_position = 0.0; let rem_size = window.rem_size(); - let col_idx = drag_event.drag(cx).0; + let col_idx = drag_event.drag(cx).col_idx; let divider_width = Self::get_fraction( &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), @@ -387,6 +393,9 @@ pub fn bind_redistributable_columns( .on_drag_move::({ let columns_state = columns_state.clone(); move |event, window, cx| { + if event.drag(cx).state_id != columns_state.entity_id() { + return; + } columns_state.update(cx, |columns, cx| { columns.on_drag_move(event, window, cx); }); @@ -478,13 +487,19 @@ pub fn render_redistributable_columns_resize_handles( 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_drag( + DraggedColumn { + col_idx: current_column_ix, + state_id: columns_state.entity_id(), + }, + { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }, + ) .on_drop::(move |_, _, cx| { is_highlighted.write(cx, false); columns_state.update(cx, |state, _| { From 731b710dcb8fe9a4a6817312a0c59ced902519f7 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 02:15:07 -0400 Subject: [PATCH 13/17] Share more of the Table::render code for both resize column states --- crates/ui/src/components/data_table.rs | 85 +++++++++----------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 3e052296aca29e..43db2ebd3b9fa7 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -958,11 +958,9 @@ impl RenderOnce for Table { }, ); - // For resizable mode, wrap in a horizontal-scroll container - let table_wrapper = div().size_full(); - - if is_resizable { - if let Some(state) = interaction_state.as_ref() { + if let Some(state) = interaction_state.as_ref() { + // Resizable mode: wrap table in a horizontal scroll container first + let content = if is_resizable { let mut h_scroll_container = div() .id("table-h-scroll") .overflow_x_scroll() @@ -971,68 +969,41 @@ impl RenderOnce for Table { .track_scroll(&state.read(cx).horizontal_scroll_handle) .child(table); h_scroll_container.style().restrict_scroll_to_axis = Some(true); - - let outer = table_wrapper.child(h_scroll_container).custom_scrollbars( - Scrollbars::new(ScrollAxes::Horizontal) - .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle), - window, - cx, - ); - - let scrollbars = state - .read(cx) - .custom_scrollbar - .clone() - .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - let mut outer = if let Some(list_state) = variable_list_state { - outer.custom_scrollbars( - scrollbars.tracked_scroll_handle(&list_state), - window, - cx, - ) - } else { - outer.custom_scrollbars( - scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), - window, - cx, - ) - }; - // Prevent horizontal scroll events from being routed to the vertical axis - // (the overflow_x_scroll added by custom_scrollbars for the H scrollbar would - // otherwise trigger the fallback delta_y = delta.x when overflow.y != Scroll). - outer.style().restrict_scroll_to_axis = Some(true); - - if let Some(interaction_state) = interaction_state.as_ref() { - outer - .track_focus(&interaction_state.read(cx).focus_handle) - .id(("table", interaction_state.entity_id())) - .into_any_element() - } else { - outer.into_any_element() - } + div().size_full().child(h_scroll_container) } else { - table.into_any_element() - } - } else if let Some(interaction_state) = interaction_state.as_ref() { - let scrollbars = interaction_state + table + }; + + // Attach vertical scrollbars (converts Div → Stateful
) + let scrollbars = state .read(cx) .custom_scrollbar .clone() .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both)); - let mut table_with_scrollbar = if let Some(list_state) = variable_list_state { - table.custom_scrollbars(scrollbars.tracked_scroll_handle(&list_state), window, cx) + let mut content = if let Some(list_state) = variable_list_state { + content.custom_scrollbars(scrollbars.tracked_scroll_handle(&list_state), window, cx) } else { - table.custom_scrollbars( - scrollbars.tracked_scroll_handle(&interaction_state.read(cx).scroll_handle), + content.custom_scrollbars( + scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle), window, cx, ) }; - // Prevent horizontal events from routing into the vertical scroll axis via fallback. - table_with_scrollbar.style().restrict_scroll_to_axis = Some(true); - table_with_scrollbar - .track_focus(&interaction_state.read(cx).focus_handle) - .id(("table", interaction_state.entity_id())) + + // Add horizontal scrollbar when in resizable mode + if is_resizable { + content = content.custom_scrollbars( + Scrollbars::new(ScrollAxes::Horizontal) + .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle), + window, + cx, + ); + } + content.style().restrict_scroll_to_axis = Some(true); + + content + .track_focus(&state.read(cx).focus_handle) + .id(("table", state.entity_id())) .into_any_element() } else { table.into_any_element() From 90fc09808a4e73beda0c70dbf0c2aabd57193134 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 02:28:54 -0400 Subject: [PATCH 14/17] combine resize handle renderers --- crates/ui/src/components/data_table.rs | 99 +++------- .../src/components/redistributable_columns.rs | 174 +++++++++++------- 2 files changed, 133 insertions(+), 140 deletions(-) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 43db2ebd3b9fa7..905c6290c1f2ca 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -4,19 +4,18 @@ use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Context, Div, DraggedColumn, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, - RESIZE_COLUMN_WIDTH, RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, - RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, - Styled, StyledExt as _, StyledTypography, TableResizeBehavior, Window, WithScrollbar, - bind_redistributable_columns, div, example_group_with_title, h_flex, px, + RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, + ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, + StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns, + div, example_group_with_title, h_flex, px, render_column_resize_divider, render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; use gpui::{ - AbsoluteLength, AppContext as _, ClickEvent, DefiniteLength, DragMoveEvent, Empty, Entity, - EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, - Point, ScrollHandle, Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, - uniform_list, + AbsoluteLength, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, Length, + ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, ScrollHandle, Stateful, + UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, }; pub mod table_row; @@ -707,71 +706,27 @@ fn render_resize_handles_resizable( } else { accumulated_px }; - let resize_behavior = Rc::clone(&resize_behavior); - let columns_state = columns_state.clone(); - let divider = window.with_id(col_idx, |window| { - let mut resize_divider = div() - .id(col_idx) - .absolute() - .top_0() - .left(divider_left) - .w(px(RESIZE_DIVIDER_WIDTH)) - .h_full() - .bg(cx.theme().colors().border.opacity(0.8)); - - let mut resize_handle = div() - .id("column-resize-handle") - .absolute() - .left_neg_0p5() - .w(px(RESIZE_COLUMN_WIDTH)) - .h_full(); - - if resize_behavior[col_idx].is_resizable() { - let is_highlighted = window.use_state(cx, |_window, _cx| false); - - resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| { - div.bg(cx.theme().colors().border_focused) + let divider = div().id(col_idx).absolute().top_0().left(divider_left); + let entity_id = columns_state.entity_id(); + let on_reset: Rc = { + let columns_state = columns_state.clone(); + Rc::new(move |_window, cx| { + columns_state.update(cx, |state, cx| { + state.reset_column_to_initial_width(col_idx); + cx.notify(); }); - - resize_handle = resize_handle - .on_hover({ - let is_highlighted = is_highlighted.clone(); - move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered) - }) - .cursor_col_resize() - .on_click({ - let columns_state = columns_state.clone(); - move |event: &ClickEvent, _window, cx| { - if event.click_count() >= 2 { - columns_state.update(cx, |state, cx| { - state.reset_column_to_initial_width(col_idx); - cx.notify(); - }); - } - cx.stop_propagation(); - } - }) - .on_drag( - DraggedColumn { - col_idx, - state_id: columns_state.entity_id(), - }, - { - let is_highlighted = is_highlighted.clone(); - move |_, _offset, _window, cx| { - is_highlighted.write(cx, true); - cx.new(|_cx| Empty) - } - }, - ) - .on_drop::(move |_, _, cx| { - is_highlighted.write(cx, false); - }); - } - - resize_divider.child(resize_handle).into_any_element() - }); - dividers.push(divider); + }) + }; + dividers.push(render_column_resize_divider( + divider, + col_idx, + resize_behavior[col_idx].is_resizable(), + entity_id, + on_reset, + None, + window, + cx, + )); } } diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index e73711feca2740..941017774a7d9e 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gpui::{ AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, - EntityId, Length, WeakEntity, + EntityId, Length, Stateful, WeakEntity, }; use itertools::intersperse_with; @@ -442,74 +442,35 @@ pub fn render_redistributable_columns_resize_handles( 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, cx| { - columns.reset_column_to_initial_width( - current_column_ix, - window, - ); - cx.notify(); - }); - } - - cx.stop_propagation(); - } - }) - .on_drag( - DraggedColumn { - col_idx: current_column_ix, - state_id: columns_state.entity_id(), - }, - { - let is_highlighted = is_highlighted.clone(); - move |_, _offset, _window, cx| { - is_highlighted.write(cx, true); - cx.new(|_cx| Empty) - } - }, - ) - .on_drop::(move |_, _, cx| { - is_highlighted.write(cx, false); - columns_state.update(cx, |state, _| { - state.commit_preview(); - }); + { + let divider = div().id(current_column_ix).relative().top_0(); + let entity_id = columns_state.entity_id(); + let on_reset: Rc = { + let columns_state = columns_state.clone(); + Rc::new(move |window, cx| { + columns_state.update(cx, |columns, cx| { + columns.reset_column_to_initial_width(current_column_ix, window); + cx.notify(); }); - } - - resize_divider.child(resize_handle).into_any_element() - }) + }) + }; + let on_drag_end: Option> = { + let columns_state = columns_state.clone(); + Some(Rc::new(move |cx| { + columns_state.update(cx, |state, _| state.commit_preview()); + })) + }; + render_column_resize_divider( + divider, + current_column_ix, + resize_behavior[current_column_ix].is_resizable(), + entity_id, + on_reset, + on_drag_end, + window, + cx, + ) + } }, ); @@ -522,6 +483,83 @@ pub fn render_redistributable_columns_resize_handles( .into_any_element() } +/// Builds a single column resize divider with an interactive drag handle. +/// +/// The caller provides: +/// - `divider`: a pre-positioned divider element (with absolute or relative positioning) +/// - `col_idx`: which column this divider is for +/// - `is_resizable`: whether the column supports resizing +/// - `entity_id`: the `EntityId` of the owning column state (for the drag payload) +/// - `on_reset`: called on double-click to reset the column to its initial width +/// - `on_drag_end`: called when the drag ends (e.g. to commit preview widths) +pub(crate) fn render_column_resize_divider( + divider: Stateful
, + col_idx: usize, + is_resizable: bool, + entity_id: EntityId, + on_reset: Rc, + on_drag_end: Option>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + window.with_id(col_idx, |window| { + let mut resize_divider = divider.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 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(move |event, window, cx| { + if event.click_count() >= 2 { + on_reset(window, cx); + } + cx.stop_propagation(); + }) + .on_drag( + DraggedColumn { + col_idx, + state_id: entity_id, + }, + { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }, + ) + .on_drop::(move |_, _, cx| { + is_highlighted.write(cx, false); + if let Some(on_drag_end) = &on_drag_end { + on_drag_end(cx); + } + }); + } + + resize_divider.child(resize_handle).into_any_element() + }) +} + fn resize_spacer(width: Length) -> Div { div().w(width).h_full() } From 7a834116600c2868420d31e60f3a5af57a92a2fb Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 02:40:23 -0400 Subject: [PATCH 15/17] Revert list height change --- crates/gpui/src/elements/list.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index a8a233157eb714..5a88d81c18db5e 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -1333,12 +1333,11 @@ impl Element for List { let height = bounds.size.height; let scroll_top = prepaint.layout.scroll_top; let hitbox_id = prepaint.hitbox.id; - let line_height = window.line_height(); let mut accumulated_scroll_delta = ScrollDelta::default(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) { accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta); - let pixel_delta = accumulated_scroll_delta.pixel_delta(line_height); + let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.)); list_state.0.borrow_mut().scroll( &scroll_top, height, From 3f4f365a2f2de8d5b34e5469481c64d33404f14e Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 02:52:32 -0400 Subject: [PATCH 16/17] Fix clippy --- crates/ui/src/components/redistributable_columns.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index 941017774a7d9e..cb5da35d565185 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -455,7 +455,6 @@ pub fn render_redistributable_columns_resize_handles( }) }; let on_drag_end: Option> = { - let columns_state = columns_state.clone(); Some(Rc::new(move |cx| { columns_state.update(cx, |state, _| state.commit_preview()); })) From 4057b1eb4c46df5d5b7e1b28ee2714ddb85075a0 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Wed, 8 Apr 2026 03:01:39 -0400 Subject: [PATCH 17/17] Final clean up --- crates/csv_preview/src/csv_preview.rs | 15 +++++++++------ crates/csv_preview/src/settings.rs | 8 ++++---- crates/ui/src/components/data_table.rs | 12 ++++++++++++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 5287a19f9f7503..a1b10feea074f6 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -60,8 +60,7 @@ impl CsvPreviewView { let cols = self.engine.contents.headers.cols() + 1; let line_number_width = self.calculate_row_identifier_column_width(); - let mut widths: Vec = - vec![AbsoluteLength::Pixels(px(150.)); cols]; + let mut widths: Vec = vec![AbsoluteLength::Pixels(px(150.)); cols]; widths[0] = AbsoluteLength::Pixels(px(line_number_width)); let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; @@ -70,6 +69,12 @@ impl CsvPreviewView { self.column_widths.widths.update(cx, |state, _cx| { if state.cols() != cols { *state = ResizableColumnsState::new(cols, widths, resize_behaviors); + } else { + state.set_column_configuration( + 0, + AbsoluteLength::Pixels(px(line_number_width)), + TableResizeBehavior::None, + ); } }); } @@ -172,8 +177,7 @@ impl CsvPreviewView { column_widths: ColumnWidths::new(cx, 1), parsing_task: None, performance_metrics: PerformanceMetrics::default(), - list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .measure_all(), + list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)), settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -201,8 +205,7 @@ impl CsvPreviewView { // Update list state with filtered row count let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.)) - .measure_all(); + self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)); } pub fn resolve_active_item_as_csv_editor( diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index e627b3cc994a84..9c64f6e9cfc8ff 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -1,10 +1,10 @@ #[derive(Default, Clone, Copy)] pub enum RowRenderMechanism { - /// Default behaviour - #[default] - VariableList, - /// More performance oriented, but all rows are same height + /// More correct for multiline content, but slower. #[allow(dead_code)] // Will be used when settings ui is added + VariableList, + /// Default behaviour for now while resizable columns are being stabilized. + #[default] UniformList, } diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 905c6290c1f2ca..594cc188f5489e 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -84,6 +84,18 @@ impl ResizableColumnsState { cx.notify(); } + pub fn set_column_configuration( + &mut self, + col_idx: usize, + width: impl Into, + resize_behavior: TableResizeBehavior, + ) { + let width = width.into(); + self.initial_widths[col_idx] = width; + self.widths[col_idx] = width; + self.resize_behavior[col_idx] = resize_behavior; + } + pub fn reset_column_to_initial_width(&mut self, col_idx: usize) { self.widths[col_idx] = self.initial_widths[col_idx]; }