Rework column/table width API in data table (#51060)

data_table: Replace column width builder API with `ColumnWidthConfig`
enum

This PR consolidates the data table width configuration API from three
separate builder methods (`.column_widths()`, `.resizable_columns()`,
`.width()`) into a single `.width_config(ColumnWidthConfig)` call. This
makes invalid state combinations unrepresentable and clarifies the two
distinct width management modes.

**What changed:**

- Introduces `ColumnWidthConfig` enum with two variants:
  - `Static`: Fixed column widths, no resize handles
- `Redistributable`: Drag-to-resize columns that redistribute space
within a fixed table width
- Introduces `TableResizeBehavior` enum (`None`, `Resizable`,
`MinSize(f32)`) for per-column resize policy
- Renames `TableColumnWidths` → `RedistributableColumnsState` to better
reflect its purpose
- Extracts all width management logic into a new `width_management.rs`
module
- Updates all callers: `csv_preview`, `git_graph`, `keymap_editor`,
`edit_prediction_context_view`

```rust
pub enum ColumnWidthConfig {
    /// Static column widths (no resize handles).
    Static {
        widths: StaticColumnWidths,
        /// Controls widths of the whole table.
        table_width: Option<DefiniteLength>,
    },
    /// Redistributable columns — dragging redistributes the fixed available space
    /// among columns without changing the overall table width.
    Redistributable {
        entity: Entity<RedistributableColumnsState>,
        table_width: Option<DefiniteLength>,
    },
}
```

**Why:**

The old API allowed callers to combine methods incorrectly. The new
enum-based design enforces correct usage at compile time and provides a
clearer path for adding independently resizable columns in PR #3.

**Context:**

This is part 2 of a 3-PR series improving data table column width
handling:
1. [#51059](https://github.com/zed-industries/zed/pull/51059) - Extract
modules into separate files (mechanical change)
2. **This PR**: Introduce width config enum for redistributable column
widths (API rework)
3. Implement independently resizable column widths (new feature)

The series builds on previously merged infrastructure:
- [#46341](https://github.com/zed-industries/zed/pull/46341) - Data
table dynamic column support
- [#46190](https://github.com/zed-industries/zed/pull/46190) - Variable
row height mode for data tables

Primary beneficiary: CSV preview feature
([#48207](https://github.com/zed-industries/zed/pull/48207))


### Anthony's note

This PR also fixes the table dividers being a couple pixels off, and the
csv preview from having double line rendering for a single column in
some cases.

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
This commit is contained in:
Oleksandr Kholiavko 2026-04-01 07:13:24 +02:00 committed by GitHub
parent 0b275eaa44
commit a3964a565c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 456 additions and 417 deletions

View file

@ -9,7 +9,10 @@ use std::{
};
use crate::table_data_engine::TableDataEngine;
use ui::{SharedString, TableColumnWidths, TableInteractionState, prelude::*};
use ui::{
AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString,
TableInteractionState, TableResizeBehavior, prelude::*,
};
use workspace::{Item, SplitDirection, Workspace};
use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent};
@ -52,6 +55,32 @@ pub fn init(cx: &mut App) {
}
impl CsvPreviewView {
pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
// plus 1 for the rows 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 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);
}
});
}
pub fn register(workspace: &mut Workspace) {
workspace.register_action_renderer(|div, _, _, cx| {
div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
@ -286,18 +315,19 @@ impl PerformanceMetrics {
/// Holds state of column widths for a table component in CSV preview.
pub(crate) struct ColumnWidths {
pub widths: Entity<TableColumnWidths>,
pub widths: Entity<RedistributableColumnsState>,
}
impl ColumnWidths {
pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
Self {
widths: cx.new(|cx| TableColumnWidths::new(cols, cx)),
widths: cx.new(|_cx| {
RedistributableColumnsState::new(
cols,
vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols],
vec![ui::TableResizeBehavior::Resizable; cols],
)
}),
}
}
/// Replace the current `TableColumnWidths` entity with a new one for the given column count.
pub(crate) fn replace(&self, cx: &mut Context<CsvPreviewView>, cols: usize) {
self.widths
.update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx));
}
}

View file

@ -80,11 +80,8 @@ impl CsvPreviewView {
.insert("Parsing", (parse_duration, Instant::now()));
log::debug!("Parsed {} rows", parsed_csv.rows.len());
// Update table width so it can be rendered properly
let cols = parsed_csv.headers.cols();
view.column_widths.replace(cx, cols + 1); // Add 1 for the line number column
view.engine.contents = parsed_csv;
view.sync_column_widths(cx);
view.last_parse_end_time = Some(parse_end_time);
view.apply_filter_sort();

View file

@ -1,11 +1,9 @@
use crate::types::TableCell;
use gpui::{AnyElement, Entity};
use std::ops::Range;
use ui::Table;
use ui::TableColumnWidths;
use ui::TableResizeBehavior;
use ui::UncheckedTableRow;
use ui::{DefiniteLength, div, prelude::*};
use ui::{
ColumnWidthConfig, RedistributableColumnsState, Table, UncheckedTableRow, div, prelude::*,
};
use crate::{
CsvPreviewView,
@ -15,44 +13,22 @@ use crate::{
impl CsvPreviewView {
/// Creates a new table.
/// Column number is derived from the `TableColumnWidths` entity.
/// Column number is derived from the `RedistributableColumnsState` entity.
pub(crate) fn create_table(
&self,
current_widths: &Entity<TableColumnWidths>,
current_widths: &Entity<RedistributableColumnsState>,
cx: &mut Context<Self>,
) -> AnyElement {
let cols = current_widths.read(cx).cols();
let remaining_col_number = cols - 1;
let fraction = if remaining_col_number > 0 {
1. / remaining_col_number as f32
} else {
1. // only column with line numbers is present. Put 100%, but it will be overwritten anyways :D
};
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 resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
resize_behaviors[0] = TableResizeBehavior::None;
self.create_table_inner(
self.engine.contents.rows.len(),
widths,
resize_behaviors,
current_widths,
cx,
)
self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx)
}
fn create_table_inner(
&self,
row_count: usize,
widths: UncheckedTableRow<DefiniteLength>,
resize_behaviors: UncheckedTableRow<TableResizeBehavior>,
current_widths: &Entity<TableColumnWidths>,
current_widths: &Entity<RedistributableColumnsState>,
cx: &mut Context<Self>,
) -> AnyElement {
let cols = widths.len();
let cols = current_widths.read(cx).cols();
// Create headers array with interactive elements
let mut headers = Vec::with_capacity(cols);
@ -78,8 +54,7 @@ impl CsvPreviewView {
Table::new(cols)
.interactable(&self.table_interaction_state)
.striped()
.column_widths(widths)
.resizable_columns(resize_behaviors, current_widths, cx)
.width_config(ColumnWidthConfig::redistributable(current_widths.clone()))
.header(headers)
.disable_base_style()
.map(|table| {

View file

@ -139,6 +139,7 @@ impl CsvPreviewView {
RowIdentifiers::SrcLines => RowIdentifiers::RowNum,
RowIdentifiers::RowNum => RowIdentifiers::SrcLines,
};
this.sync_column_widths(cx);
cx.notify();
});
}),

View file

@ -53,7 +53,6 @@ fn create_table_cell(
.px_1()
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_r_1()
.border_color(cx.theme().colors().border_variant)
.map(|div| match vertical_alignment {
VerticalAlignment::Top => div.items_start(),

View file

@ -41,9 +41,9 @@ use theme::AccentColors;
use theme_settings::ThemeSettings;
use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
use ui::{
ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel,
ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior,
Tooltip, WithScrollbar, prelude::*,
ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
TableResizeBehavior, Tooltip, WithScrollbar, prelude::*,
};
use workspace::{
Workspace,
@ -901,7 +901,7 @@ pub struct GitGraph {
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
row_height: Pixels,
table_interaction_state: Entity<TableInteractionState>,
table_column_widths: Entity<TableColumnWidths>,
table_column_widths: Entity<RedistributableColumnsState>,
horizontal_scroll_offset: Pixels,
graph_viewport_width: Pixels,
selected_entry_idx: Option<usize>,
@ -972,7 +972,23 @@ impl GitGraph {
});
let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx));
let table_column_widths = cx.new(|_cx| {
RedistributableColumnsState::new(
4,
vec![
DefiniteLength::Fraction(0.72),
DefiniteLength::Fraction(0.12),
DefiniteLength::Fraction(0.10),
DefiniteLength::Fraction(0.06),
],
vec![
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
],
)
});
let mut row_height = Self::row_height(cx);
cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
@ -2459,11 +2475,6 @@ impl Render for GitGraph {
self.search_state.state = QueryState::Empty;
self.search(query, cx);
}
let description_width_fraction = 0.72;
let date_width_fraction = 0.12;
let author_width_fraction = 0.10;
let commit_width_fraction = 0.06;
let (commit_count, is_loading) = match self.graph_data.max_commit_count {
AllCommitCount::Loaded(count) => (count, true),
AllCommitCount::NotLoaded => {
@ -2523,7 +2534,10 @@ impl Render for GitGraph {
.flex_col()
.child(
div()
.p_2()
.flex()
.items_center()
.px_1()
.py_0p5()
.border_b_1()
.whitespace_nowrap()
.border_color(cx.theme().colors().border)
@ -2565,25 +2579,9 @@ impl Render for GitGraph {
Label::new("Author").color(Color::Muted).into_any_element(),
Label::new("Commit").color(Color::Muted).into_any_element(),
])
.column_widths(
[
DefiniteLength::Fraction(description_width_fraction),
DefiniteLength::Fraction(date_width_fraction),
DefiniteLength::Fraction(author_width_fraction),
DefiniteLength::Fraction(commit_width_fraction),
]
.to_vec(),
)
.resizable_columns(
vec![
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
],
&self.table_column_widths,
cx,
)
.width_config(ColumnWidthConfig::redistributable(
self.table_column_widths.clone(),
))
.map_row(move |(index, row), window, cx| {
let is_selected = selected_entry_idx == Some(index);
let is_hovered = hovered_entry_idx == Some(index);

View file

@ -31,10 +31,10 @@ use settings::{
BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size,
};
use ui::{
ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, IconPosition,
Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section,
SharedString, Styled as _, Table, TableColumnWidths, TableInteractionState,
TableResizeBehavior, Tooltip, Window, prelude::*,
ActiveTheme as _, App, Banner, BorrowAppContext, ColumnWidthConfig, ContextMenu,
IconButtonShape, IconPosition, Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _,
PopoverMenu, RedistributableColumnsState, Render, Section, SharedString, Styled as _, Table,
TableInteractionState, TableResizeBehavior, Tooltip, Window, prelude::*,
};
use ui_input::InputField;
use util::ResultExt;
@ -450,7 +450,7 @@ struct KeymapEditor {
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
previous_edit: Option<PreviousEdit>,
humanized_action_names: HumanizedActionNameCache,
current_widths: Entity<TableColumnWidths>,
current_widths: Entity<RedistributableColumnsState>,
show_hover_menus: bool,
actions_with_schemas: HashSet<&'static str>,
/// In order for the JSON LSP to run in the actions arguments editor, we
@ -623,7 +623,27 @@ impl KeymapEditor {
actions_with_schemas: HashSet::default(),
action_args_temp_dir: None,
action_args_temp_dir_worktree: None,
current_widths: cx.new(|cx| TableColumnWidths::new(COLS, cx)),
current_widths: cx.new(|_cx| {
RedistributableColumnsState::new(
COLS,
vec![
DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))),
DefiniteLength::Fraction(0.25),
DefiniteLength::Fraction(0.20),
DefiniteLength::Fraction(0.14),
DefiniteLength::Fraction(0.45),
DefiniteLength::Fraction(0.08),
],
vec![
TableResizeBehavior::None,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
],
)
}),
};
this.on_keymap_changed(window, cx);
@ -2095,26 +2115,9 @@ impl Render for KeymapEditor {
let this = cx.entity();
move |window, cx| this.read(cx).render_no_matches_hint(window, cx)
})
.column_widths(vec![
DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))),
DefiniteLength::Fraction(0.25),
DefiniteLength::Fraction(0.20),
DefiniteLength::Fraction(0.14),
DefiniteLength::Fraction(0.45),
DefiniteLength::Fraction(0.08),
])
.resizable_columns(
vec![
TableResizeBehavior::None,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable,
TableResizeBehavior::Resizable, // this column doesn't matter
],
&self.current_widths,
cx,
)
.width_config(ColumnWidthConfig::redistributable(
self.current_widths.clone(),
))
.header(vec!["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
.uniform_list(
"keymap-editor-table",

View file

@ -1,14 +1,15 @@
use std::{ops::Range, rc::Rc};
use gpui::{
AbsoluteLength, AppContext, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point,
Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle,
Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful,
UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
};
use itertools::intersperse_with;
use crate::{
ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
@ -16,20 +17,20 @@ use crate::{
table_row::{IntoTableRow as _, TableRow},
v_flex,
};
use itertools::intersperse_with;
pub mod table_row;
#[cfg(test)]
mod tests;
const RESIZE_COLUMN_WIDTH: f32 = 8.0;
const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
/// Represents an unchecked table row, which is a vector of elements.
/// Will be converted into `TableRow<T>` internally
pub type UncheckedTableRow<T> = Vec<T>;
#[derive(Debug)]
struct DraggedColumn(usize);
pub(crate) struct DraggedColumn(pub(crate) usize);
struct UniformListData {
render_list_of_rows_fn:
@ -110,106 +111,103 @@ impl TableInteractionState {
view.update(cx, |view, cx| f(view, e, window, cx)).ok();
}
}
}
/// Renders invisible resize handles overlaid on top of table content.
///
/// - Spacer: invisible element that matches the width of table column content
/// - Divider: contains the actual resize handle that users can drag to resize columns
///
/// Structure: [spacer] [divider] [spacer] [divider] [spacer]
///
/// Business logic:
/// 1. Creates spacers matching each column width
/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
/// 4. Returns an absolute-positioned overlay that sits on top of table content
fn render_resize_handles(
&self,
column_widths: &TableRow<Length>,
resizable_columns: &TableRow<TableResizeBehavior>,
initial_sizes: &TableRow<DefiniteLength>,
columns: Option<Entity<TableColumnWidths>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let spacers = column_widths
.as_slice()
.iter()
.map(|width| base_cell_style(Some(*width)).into_any_element());
/// Renders invisible resize handles overlaid on top of table content.
///
/// - Spacer: invisible element that matches the width of table column content
/// - Divider: contains the actual resize handle that users can drag to resize columns
///
/// Structure: [spacer] [divider] [spacer] [divider] [spacer]
///
/// Business logic:
/// 1. Creates spacers matching each column width
/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
/// 4. Returns an absolute-positioned overlay that sits on top of table content
fn render_resize_handles(
column_widths: &TableRow<Length>,
resizable_columns: &TableRow<TableResizeBehavior>,
initial_sizes: &TableRow<DefiniteLength>,
columns: Option<Entity<RedistributableColumnsState>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let spacers = column_widths
.as_slice()
.iter()
.map(|width| base_cell_style(Some(*width)).into_any_element());
let mut column_ix = 0;
let resizable_columns_shared = Rc::new(resizable_columns.clone());
let initial_sizes_shared = Rc::new(initial_sizes.clone());
let mut resizable_columns_iter = resizable_columns.as_slice().iter();
let mut column_ix = 0;
let resizable_columns_shared = Rc::new(resizable_columns.clone());
let initial_sizes_shared = Rc::new(initial_sizes.clone());
let mut resizable_columns_iter = resizable_columns.as_slice().iter();
// Insert dividers between spacers (column content)
let dividers = intersperse_with(spacers, || {
let resizable_columns = Rc::clone(&resizable_columns_shared);
let initial_sizes = Rc::clone(&initial_sizes_shared);
window.with_id(column_ix, |window| {
let mut resize_divider = div()
// This is required because this is evaluated at a different time than the use_state call above
.id(column_ix)
.relative()
.top_0()
.w_px()
.h_full()
.bg(cx.theme().colors().border.opacity(0.8));
let dividers = intersperse_with(spacers, || {
let resizable_columns = Rc::clone(&resizable_columns_shared);
let initial_sizes = Rc::clone(&initial_sizes_shared);
window.with_id(column_ix, |window| {
let mut resize_divider = div()
.id(column_ix)
.relative()
.top_0()
.w(px(RESIZE_DIVIDER_WIDTH))
.h_full()
.bg(cx.theme().colors().border.opacity(0.8));
let mut resize_handle = div()
.id("column-resize-handle")
.absolute()
.left_neg_0p5()
.w(px(RESIZE_COLUMN_WIDTH))
.h_full();
let mut resize_handle = div()
.id("column-resize-handle")
.absolute()
.left_neg_0p5()
.w(px(RESIZE_COLUMN_WIDTH))
.h_full();
if resizable_columns_iter
.next()
.is_some_and(TableResizeBehavior::is_resizable)
{
let hovered = window.use_state(cx, |_window, _cx| false);
if resizable_columns_iter
.next()
.is_some_and(TableResizeBehavior::is_resizable)
{
let hovered = window.use_state(cx, |_window, _cx| false);
resize_divider = resize_divider.when(*hovered.read(cx), |div| {
div.bg(cx.theme().colors().border_focused)
});
resize_divider = resize_divider.when(*hovered.read(cx), |div| {
div.bg(cx.theme().colors().border_focused)
});
resize_handle = resize_handle
.on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
.cursor_col_resize()
.when_some(columns.clone(), |this, columns| {
this.on_click(move |event, window, cx| {
if event.click_count() >= 2 {
columns.update(cx, |columns, _| {
columns.on_double_click(
column_ix,
&initial_sizes,
&resizable_columns,
window,
);
})
}
resize_handle = resize_handle
.on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
.cursor_col_resize()
.when_some(columns.clone(), |this, columns| {
this.on_click(move |event, window, cx| {
if event.click_count() >= 2 {
columns.update(cx, |columns, _| {
columns.on_double_click(
column_ix,
&initial_sizes,
&resizable_columns,
window,
);
})
}
cx.stop_propagation();
})
cx.stop_propagation();
})
.on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
cx.new(|_cx| gpui::Empty)
})
}
})
.on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
cx.new(|_cx| gpui::Empty)
})
}
column_ix += 1;
resize_divider.child(resize_handle).into_any_element()
})
});
column_ix += 1;
resize_divider.child(resize_handle).into_any_element()
})
});
h_flex()
.id("resize-handles")
.absolute()
.inset_0()
.w_full()
.children(dividers)
.into_any_element()
}
h_flex()
.id("resize-handles")
.absolute()
.inset_0()
.w_full()
.children(dividers)
.into_any_element()
}
#[derive(Debug, Copy, Clone, PartialEq)]
@ -233,25 +231,181 @@ impl TableResizeBehavior {
}
}
pub struct TableColumnWidths {
widths: TableRow<DefiniteLength>,
visible_widths: TableRow<DefiniteLength>,
cached_bounds_width: Pixels,
initialized: bool,
pub enum ColumnWidthConfig {
/// Static column widths (no resize handles).
Static {
widths: StaticColumnWidths,
/// Controls widths of the whole table.
table_width: Option<DefiniteLength>,
},
/// Redistributable columns — dragging redistributes the fixed available space
/// among columns without changing the overall table width.
Redistributable {
columns_state: Entity<RedistributableColumnsState>,
table_width: Option<DefiniteLength>,
},
}
impl TableColumnWidths {
pub fn new(cols: usize, _: &mut App) -> Self {
pub enum StaticColumnWidths {
/// All columns share space equally (flex-1 / Length::Auto).
Auto,
/// Each column has a specific width.
Explicit(TableRow<DefiniteLength>),
}
impl ColumnWidthConfig {
/// Auto-width columns, auto-size table.
pub fn auto() -> Self {
ColumnWidthConfig::Static {
widths: StaticColumnWidths::Auto,
table_width: None,
}
}
/// Redistributable columns with no fixed table width.
pub fn redistributable(columns_state: Entity<RedistributableColumnsState>) -> Self {
ColumnWidthConfig::Redistributable {
columns_state,
table_width: None,
}
}
/// Auto-width columns, fixed table width.
pub fn auto_with_table_width(width: impl Into<DefiniteLength>) -> Self {
ColumnWidthConfig::Static {
widths: StaticColumnWidths::Auto,
table_width: Some(width.into()),
}
}
/// Column widths for rendering.
pub fn widths_to_render(&self, cx: &App) -> Option<TableRow<Length>> {
match self {
ColumnWidthConfig::Static {
widths: StaticColumnWidths::Auto,
..
} => None,
ColumnWidthConfig::Static {
widths: StaticColumnWidths::Explicit(widths),
..
} => Some(widths.map_cloned(Length::Definite)),
ColumnWidthConfig::Redistributable {
columns_state: entity,
..
} => {
let state = entity.read(cx);
Some(state.preview_widths.map_cloned(Length::Definite))
}
}
}
/// Table-level width.
pub fn table_width(&self) -> Option<Length> {
match self {
ColumnWidthConfig::Static { table_width, .. }
| ColumnWidthConfig::Redistributable { table_width, .. } => {
table_width.map(Length::Definite)
}
}
}
/// ListHorizontalSizingBehavior for uniform_list.
pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior {
match self.table_width() {
Some(_) => ListHorizontalSizingBehavior::Unconstrained,
None => ListHorizontalSizingBehavior::FitList,
}
}
/// Render resize handles overlay if applicable.
pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
match self {
ColumnWidthConfig::Redistributable {
columns_state: entity,
..
} => {
let (column_widths, resize_behavior, initial_widths) = {
let state = entity.read(cx);
(
state.preview_widths.map_cloned(Length::Definite),
state.resize_behavior.clone(),
state.initial_widths.clone(),
)
};
Some(render_resize_handles(
&column_widths,
&resize_behavior,
&initial_widths,
Some(entity.clone()),
window,
cx,
))
}
_ => None,
}
}
/// Returns info needed for header double-click-to-reset, if applicable.
pub fn header_resize_info(&self, cx: &App) -> Option<HeaderResizeInfo> {
match self {
ColumnWidthConfig::Redistributable { columns_state, .. } => {
let state = columns_state.read(cx);
Some(HeaderResizeInfo {
columns_state: columns_state.downgrade(),
resize_behavior: state.resize_behavior.clone(),
initial_widths: state.initial_widths.clone(),
})
}
_ => None,
}
}
}
#[derive(Clone)]
pub struct HeaderResizeInfo {
pub columns_state: WeakEntity<RedistributableColumnsState>,
pub resize_behavior: TableRow<TableResizeBehavior>,
pub initial_widths: TableRow<DefiniteLength>,
}
pub struct RedistributableColumnsState {
pub(crate) initial_widths: TableRow<DefiniteLength>,
pub(crate) committed_widths: TableRow<DefiniteLength>,
pub(crate) preview_widths: TableRow<DefiniteLength>,
pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
pub(crate) cached_table_width: Pixels,
}
impl RedistributableColumnsState {
pub fn new(
cols: usize,
initial_widths: UncheckedTableRow<impl Into<DefiniteLength>>,
resize_behavior: UncheckedTableRow<TableResizeBehavior>,
) -> Self {
let widths: TableRow<DefiniteLength> = initial_widths
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into_table_row(cols);
Self {
widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
visible_widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
cached_bounds_width: Default::default(),
initialized: false,
initial_widths: widths.clone(),
committed_widths: widths.clone(),
preview_widths: widths,
resize_behavior: resize_behavior.into_table_row(cols),
cached_table_width: Default::default(),
}
}
pub fn cols(&self) -> usize {
self.widths.cols()
self.committed_widths.cols()
}
pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
&self.initial_widths
}
pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
&self.resize_behavior
}
fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
@ -264,19 +418,19 @@ impl TableColumnWidths {
}
}
fn on_double_click(
pub(crate) fn on_double_click(
&mut self,
double_click_position: usize,
initial_sizes: &TableRow<DefiniteLength>,
resize_behavior: &TableRow<TableResizeBehavior>,
window: &mut Window,
) {
let bounds_width = self.cached_bounds_width;
let bounds_width = self.cached_table_width;
let rem_size = window.rem_size();
let initial_sizes =
initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let widths = self
.widths
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let updated_widths = Self::reset_to_initial_size(
@ -285,53 +439,16 @@ impl TableColumnWidths {
initial_sizes,
resize_behavior,
);
self.widths = updated_widths.map(DefiniteLength::Fraction);
self.visible_widths = self.widths.clone(); // previously was copy
self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
self.preview_widths = self.committed_widths.clone();
}
fn reset_to_initial_size(
pub(crate) fn reset_to_initial_size(
col_idx: usize,
mut widths: TableRow<f32>,
initial_sizes: TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) -> TableRow<f32> {
// 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 =
@ -376,10 +493,9 @@ impl TableColumnWidths {
widths
}
fn on_drag_move(
pub(crate) fn on_drag_move(
&mut self,
drag_event: &DragMoveEvent<DraggedColumn>,
resize_behavior: &TableRow<TableResizeBehavior>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -391,43 +507,42 @@ impl TableColumnWidths {
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))),
let divider_width = Self::get_fraction(
&DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
bounds_width,
rem_size,
);
let mut widths = self
.widths
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
for length in widths[0..=col_idx].iter() {
col_position += length + column_handle_width;
col_position += length + divider_width;
}
let mut total_length_ratio = col_position;
for length in widths[col_idx + 1..].iter() {
total_length_ratio += length;
}
let cols = resize_behavior.cols();
total_length_ratio += (cols - 1 - col_idx) as f32 * column_handle_width;
let cols = self.resize_behavior.cols();
total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
let drag_fraction = drag_fraction * total_length_ratio;
let diff = drag_fraction - col_position - column_handle_width / 2.0;
let diff = drag_fraction - col_position - divider_width / 2.0;
Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior);
Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
self.visible_widths = widths.map(DefiniteLength::Fraction);
self.preview_widths = widths.map(DefiniteLength::Fraction);
}
fn drag_column_handle(
pub(crate) fn drag_column_handle(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) {
// if diff > 0.0 then go right
if diff > 0.0 {
Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
} else {
@ -435,7 +550,7 @@ impl TableColumnWidths {
}
}
fn propagate_resize_diff(
pub(crate) fn propagate_resize_diff(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
@ -493,44 +608,16 @@ impl TableColumnWidths {
}
}
pub struct TableWidths {
initial: TableRow<DefiniteLength>,
current: Option<Entity<TableColumnWidths>>,
resizable: TableRow<TableResizeBehavior>,
}
impl TableWidths {
pub fn new(widths: TableRow<impl Into<DefiniteLength>>) -> Self {
let widths = widths.map(Into::into);
let expected_length = widths.cols();
TableWidths {
initial: widths,
current: None,
resizable: vec![TableResizeBehavior::None; expected_length]
.into_table_row(expected_length),
}
}
fn lengths(&self, cx: &App) -> TableRow<Length> {
self.current
.as_ref()
.map(|entity| entity.read(cx).visible_widths.map_cloned(Length::Definite))
.unwrap_or_else(|| self.initial.map_cloned(Length::Definite))
}
}
/// A table component
#[derive(RegisterComponent, IntoElement)]
pub struct Table {
striped: bool,
show_row_borders: bool,
show_row_hover: bool,
width: Option<Length>,
headers: Option<TableRow<AnyElement>>,
rows: TableContents,
interaction_state: Option<WeakEntity<TableInteractionState>>,
col_widths: Option<TableWidths>,
column_width_config: ColumnWidthConfig,
map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
use_ui_font: bool,
empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
@ -547,15 +634,14 @@ impl Table {
striped: false,
show_row_borders: true,
show_row_hover: true,
width: None,
headers: None,
rows: TableContents::Vec(Vec::new()),
interaction_state: None,
map_row: None,
use_ui_font: true,
empty_table_callback: None,
col_widths: None,
disable_base_cell_style: false,
column_width_config: ColumnWidthConfig::auto(),
}
}
@ -626,10 +712,18 @@ impl Table {
self
}
/// Sets the width of the table.
/// Will enable horizontal scrolling if [`Self::interactable`] is also called.
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = Some(width.into());
/// Sets a fixed table width with auto column widths.
///
/// This is a shorthand for `.width_config(ColumnWidthConfig::auto_with_table_width(width))`.
/// For resizable columns or explicit column widths, use [`Table::width_config`] directly.
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.column_width_config = ColumnWidthConfig::auto_with_table_width(width);
self
}
/// Sets the column width configuration for the table.
pub fn width_config(mut self, config: ColumnWidthConfig) -> Self {
self.column_width_config = config;
self
}
@ -637,10 +731,8 @@ impl Table {
///
/// Vertical scrolling will be enabled by default if the table is taller than its container.
///
/// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise
/// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`]
/// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will
/// be set to [`ListHorizontalSizingBehavior::FitList`].
/// Horizontal scrolling will only be enabled if a table width is set via [`ColumnWidthConfig`],
/// otherwise the list will always shrink the table columns to fit their contents.
pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
self.interaction_state = Some(interaction_state.downgrade());
self
@ -666,36 +758,6 @@ impl Table {
self
}
pub fn column_widths(mut self, widths: UncheckedTableRow<impl Into<DefiniteLength>>) -> Self {
if self.col_widths.is_none() {
self.col_widths = Some(TableWidths::new(widths.into_table_row(self.cols)));
}
self
}
pub fn resizable_columns(
mut self,
resizable: UncheckedTableRow<TableResizeBehavior>,
column_widths: &Entity<TableColumnWidths>,
cx: &mut App,
) -> Self {
if let Some(table_widths) = self.col_widths.as_mut() {
table_widths.resizable = resizable.into_table_row(self.cols);
let column_widths = table_widths
.current
.get_or_insert_with(|| column_widths.clone());
column_widths.update(cx, |widths, _| {
if !widths.initialized {
widths.initialized = true;
widths.widths = table_widths.initial.clone();
widths.visible_widths = widths.widths.clone();
}
})
}
self
}
pub fn no_ui_font(mut self) -> Self {
self.use_ui_font = false;
self
@ -812,11 +874,7 @@ pub fn render_table_row(
pub fn render_table_header(
headers: TableRow<impl IntoElement>,
table_context: TableRenderContext,
columns_widths: Option<(
WeakEntity<TableColumnWidths>,
TableRow<TableResizeBehavior>,
TableRow<DefiniteLength>,
)>,
resize_info: Option<HeaderResizeInfo>,
entity_id: Option<EntityId>,
cx: &mut App,
) -> impl IntoElement {
@ -837,9 +895,7 @@ pub fn render_table_header(
.flex()
.flex_row()
.items_center()
.justify_between()
.w_full()
.p_2()
.border_b_1()
.border_color(cx.theme().colors().border)
.children(
@ -850,34 +906,33 @@ pub fn render_table_header(
.zip(column_widths.into_vec())
.map(|((header_idx, h), width)| {
base_cell_style_text(width, table_context.use_ui_font, cx)
.px_1()
.py_0p5()
.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.click_count() > 1 {
column_widths
.update(cx, |column, _| {
column.on_double_click(
header_idx,
&initial_sizes,
&resizables,
window,
);
})
.ok();
}
})
} else {
this
}
},
)
.when_some(resize_info.as_ref().cloned(), |this, info| {
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.on_double_click(
header_idx,
&info.initial_widths,
&info.resize_behavior,
window,
);
})
.ok();
}
})
} else {
this
}
})
}),
)
}
@ -901,7 +956,7 @@ impl TableRenderContext {
show_row_borders: table.show_row_borders,
show_row_hover: table.show_row_hover,
total_row_count: table.rows.len(),
column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
column_widths: table.column_width_config.widths_to_render(cx),
map_row: table.map_row.clone(),
use_ui_font: table.use_ui_font,
disable_base_cell_style: table.disable_base_cell_style,
@ -913,48 +968,52 @@ impl RenderOnce for Table {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let table_context = TableRenderContext::new(&self, cx);
let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
let current_widths = self
.col_widths
.as_ref()
.and_then(|widths| Some((widths.current.as_ref()?, widths.resizable.clone())))
.map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
let current_widths_with_initial_sizes = self
.col_widths
let header_resize_info = interaction_state
.as_ref()
.and_then(|widths| {
Some((
widths.current.as_ref()?,
widths.resizable.clone(),
widths.initial.clone(),
))
})
.map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
.and_then(|_| self.column_width_config.header_resize_info(cx));
let width = self.width;
let table_width = self.column_width_config.table_width();
let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
let no_rows_rendered = self.rows.is_empty();
// 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.downgrade()),
_ => None,
});
let resize_handles = interaction_state
.as_ref()
.and_then(|_| self.column_width_config.render_resize_handles(window, cx));
let table = div()
.when_some(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| {
this.child(render_table_header(
headers,
table_context.clone(),
current_widths_with_initial_sizes,
header_resize_info,
interaction_state.as_ref().map(Entity::entity_id),
cx,
))
})
.when_some(current_widths, {
|this, (widths, resize_behavior)| {
.when_some(redistributable_entity, {
|this, widths| {
this.on_drag_move::<DraggedColumn>({
let widths = widths.clone();
move |e, window, cx| {
widths
.update(cx, |widths, cx| {
widths.on_drag_move(e, &resize_behavior, window, cx);
widths.on_drag_move(e, window, cx);
})
.ok();
}
@ -965,7 +1024,7 @@ impl RenderOnce for Table {
widths
.update(cx, |widths, _| {
// This works because all children x axis bounds are the same
widths.cached_bounds_width =
widths.cached_table_width =
bounds[0].right() - bounds[0].left();
})
.ok();
@ -974,10 +1033,9 @@ impl RenderOnce for Table {
.on_drop::<DraggedColumn>(move |_, _, cx| {
widths
.update(cx, |widths, _| {
widths.widths = widths.visible_widths.clone();
widths.committed_widths = widths.preview_widths.clone();
})
.ok();
// Finish the resize operation
})
}
})
@ -1029,11 +1087,7 @@ impl RenderOnce for Table {
.size_full()
.flex_grow()
.with_sizing_behavior(ListSizingBehavior::Auto)
.with_horizontal_sizing_behavior(if width.is_some() {
ListHorizontalSizingBehavior::Unconstrained
} else {
ListHorizontalSizingBehavior::FitList
})
.with_horizontal_sizing_behavior(horizontal_sizing)
.when_some(
interaction_state.as_ref(),
|this, state| {
@ -1063,25 +1117,7 @@ impl RenderOnce for Table {
.with_sizing_behavior(ListSizingBehavior::Auto),
),
})
.when_some(
self.col_widths.as_ref().zip(interaction_state.as_ref()),
|parent, (table_widths, state)| {
parent.child(state.update(cx, |state, cx| {
let resizable_columns = &table_widths.resizable;
let column_widths = table_widths.lengths(cx);
let columns = table_widths.current.clone();
let initial_sizes = &table_widths.initial;
state.render_resize_handles(
&column_widths,
resizable_columns,
initial_sizes,
columns,
window,
cx,
)
}))
},
);
.when_some(resize_handles, |parent, handles| parent.child(handles));
if let Some(state) = interaction_state.as_ref() {
let scrollbars = state

View file

@ -82,7 +82,7 @@ mod reset_column_size {
let cols = initial_sizes.len();
let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
let result = TableColumnWidths::reset_to_initial_size(
let result = RedistributableColumnsState::reset_to_initial_size(
column_index,
TableRow::from_vec(widths, cols),
TableRow::from_vec(initial_sizes, cols),
@ -259,7 +259,7 @@ mod drag_handle {
let distance = distance as f32 / total_1;
let mut widths_table_row = TableRow::from_vec(widths, cols);
TableColumnWidths::drag_column_handle(
RedistributableColumnsState::drag_column_handle(
distance,
column_index,
&mut widths_table_row,