csv_preview: Implement data_table columns pining (#56619)

## Summary

Adds column pinning (freeze) capability to data tables, allowing the
first N columns to remain visible while scrolling horizontally through
the rest of the table content.

Common spreadsheet/data table UX pattern. When viewing wide tables with
many columns, users need to see identifying information (row labels,
IDs) while scrolling right to explore data.

## Demo
Idle state:
<img width="559" height="179" alt="image"
src="https://github.com/user-attachments/assets/b3f89221-8aa9-4e8a-9a39-6f06cc8a4eea"
/>
Scrolled horizontally (name column dissapeared, line numbers column
stayed pinned):
<img width="522" height="174" alt="image"
src="https://github.com/user-attachments/assets/c6695a00-5e40-49b8-81d7-0b30e26bb7bb"
/>

## Implementation

- New `pin_cols(n: usize)` builder method on `Table` to specify how many
columns to pin
- Pinned columns render in a fixed section that doesn't scroll
horizontally
- Scrollable columns render separately with independent scroll state
- Horizontal scroll offset adjustments for proper column resize handle
positioning with pinned sections
- Pinned section stays at viewport left edge while scrollable section
scrolls independently
- Supports 0 < pinned_cols < total_cols (partial pinning)
- Applied to CSV preview for better UX with wide datasets

## Context

Part of CSV preview feature series, following PR #53496 (settings UI).

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)~~
no ui changes besides pinning itself. UI improvements out of scope of
this PR

Release Notes:

- Improved CSV preview with column pinning to keep identifiers visible
while scrolling

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Oleksandr Kholiavko 2026-05-15 13:12:41 +02:00 committed by GitHub
parent bf68bf79ff
commit dca5976f82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 511 additions and 109 deletions

View file

@ -55,6 +55,7 @@ impl CsvPreviewView {
.width_config(ColumnWidthConfig::Resizable(current_widths.clone()))
.header(headers)
.disable_base_style()
.pin_cols(1)
.map(|table| {
let row_identifier_text_color = cx.theme().colors().editor_line_number;
match self.settings.rendering_with {

View file

@ -65,13 +65,22 @@ impl ResizableColumnsState {
pub(crate) fn on_drag_move(
&mut self,
drag_event: &DragMoveEvent<DraggedColumn>,
h_scroll_offset: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
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();
// h_scroll_offset is negative when scrolled right; subtracting it maps drag_x to the
// column's natural (unscrolled) position. Only scrollable columns reach this path —
// pinned dividers are rendered non-interactive.
let drag_x = drag_event.event.position.x - drag_event.bounds.left() - h_scroll_offset;
self.drag_to(col_idx, drag_x, window.rem_size());
cx.notify();
}
/// Resizes `col_idx` such that its right edge sits at `drag_x`, where `drag_x` is in the
/// column-strip's natural (unscrolled) coordinate space, relative to its left edge.
pub(crate) fn drag_to(&mut self, col_idx: usize, drag_x: Pixels, rem_size: Pixels) {
let left_edge: Pixels = self.widths.as_slice()[..col_idx]
.iter()
.map(|width| width.to_pixels(rem_size))
@ -81,7 +90,6 @@ impl ResizableColumnsState {
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 set_column_configuration(
@ -345,6 +353,7 @@ pub struct Table {
/// The number of columns in the table. Used to assert column numbers in `TableRow` collections
cols: usize,
disable_base_cell_style: bool,
pinned_cols: usize,
}
impl Table {
@ -363,6 +372,7 @@ impl Table {
empty_table_callback: None,
disable_base_cell_style: false,
column_width_config: ColumnWidthConfig::auto(),
pinned_cols: 0,
}
}
@ -484,6 +494,16 @@ impl Table {
self
}
/// Pins the first `n` columns so they remain visible during horizontal scrolling.
///
/// Pinned columns are rendered in the same list item as scrollable columns, so row heights
/// remain consistent across all columns including variable-height rows.
/// Only supported when using `ColumnWidthConfig::Resizable`.
pub fn pin_cols(mut self, n: usize) -> Self {
self.pinned_cols = n;
self
}
pub fn map_row(
mut self,
callback: impl Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement + 'static,
@ -509,6 +529,12 @@ impl Table {
}
}
/// True when the table should render a pinned section and a separate scrollable section.
/// `pinned_cols == 0` and `pinned_cols >= cols` both fall back to a single-section layout.
fn is_pinned_layout(pinned_cols: usize, cols: usize) -> bool {
pinned_cols > 0 && pinned_cols < cols
}
fn base_cell_style(width: Option<Length>) -> Div {
div()
.px_1p5()
@ -523,6 +549,51 @@ fn base_cell_style_text(width: Option<Length>, use_ui_font: bool, cx: &App) -> D
base_cell_style(width).when(use_ui_font, |el| el.text_ui(cx))
}
fn render_cell(width: Option<Length>, cell: AnyElement, ctx: &TableRenderContext, cx: &App) -> Div {
if ctx.disable_base_cell_style {
div()
.when_some(width, |this, width| this.w(width))
.when(width.is_none(), |this| this.flex_1())
.overflow_hidden()
.child(cell)
} else {
base_cell_style_text(width, ctx.use_ui_font, cx)
.px_1()
.py_0p5()
.child(cell)
}
}
fn render_header_cell(
header: AnyElement,
width: Option<Length>,
header_idx: usize,
shared_element_id: &SharedString,
resize_info: Option<&HeaderResizeInfo>,
use_ui_font: bool,
cx: &App,
) -> Stateful<Div> {
base_cell_style_text(width, use_ui_font, cx)
.px_1()
.py_0p5()
.child(header)
.id(ElementId::NamedInteger(
shared_element_id.clone(),
header_idx as u64,
))
.when_some(resize_info.cloned(), |this, info| {
if info.resize_behavior[header_idx].is_resizable() {
this.on_click(move |event, window, cx| {
if event.click_count() > 1 {
info.reset_column(header_idx, window, cx);
}
})
} else {
this
}
})
}
pub fn render_table_row(
row_index: usize,
items: TableRow<impl IntoElement>,
@ -538,11 +609,10 @@ pub fn render_table_row(
None
};
let cols = items.cols();
let column_widths = table_context
.column_widths
.map_or(vec![None; cols].into_table_row(cols), |widths| {
widths.map(Some)
});
let column_widths = match &table_context.column_widths {
Some(widths) => widths.clone().map(Some),
None => vec![None; cols].into_table_row(cols),
};
let mut row = div()
// NOTE: `h_flex()` sneakily applies `items_center()` which is not default behavior for div element.
@ -561,27 +631,55 @@ pub fn render_table_row(
.when(!is_last, |row| row.border_color(cx.theme().colors().border))
});
row = row.children(
items
.map(IntoElement::into_any_element)
.into_vec()
.into_iter()
.zip(column_widths.into_vec())
.map(|(cell, width)| {
if table_context.disable_base_cell_style {
div()
.when_some(width, |this, width| this.w(width))
.when(width.is_none(), |this| this.flex_1())
.overflow_hidden()
.child(cell)
} else {
base_cell_style_text(width, table_context.use_ui_font, cx)
.px_1()
.py_0p5()
.child(cell)
}
}),
);
let pinned_cols = table_context.pinned_cols;
if is_pinned_layout(pinned_cols, cols) {
let mut items_vec: Vec<AnyElement> = items.map(IntoElement::into_any_element).into_vec();
let mut widths_vec: Vec<Option<Length>> = column_widths.into_vec();
let scrollable_items: Vec<AnyElement> = items_vec.drain(pinned_cols..).collect();
let scrollable_widths: Vec<Option<Length>> = widths_vec.drain(pinned_cols..).collect();
let pinned_section = div().flex().flex_row().flex_shrink_0().children(
items_vec
.into_iter()
.zip(widths_vec)
.map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
);
// Scrollable section: overflow_x_scroll + track_scroll so GPUI handles the visual
// shift natively without requiring per-scroll re-renders of list items.
// restrict_scroll_to_axis lets vertical scroll events pass through to the list.
let mut scrollable_section = div()
.id(("table-row-scrollable", row_index as u64))
.flex_grow()
.overflow_x_scroll()
.flex()
.child(
div().flex().flex_row().children(
scrollable_items
.into_iter()
.zip(scrollable_widths)
.map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
),
);
if let Some(ref handle) = table_context.h_scroll_handle {
scrollable_section = scrollable_section.track_scroll(handle);
}
scrollable_section.style().restrict_scroll_to_axis = Some(true);
row = row.child(pinned_section).child(scrollable_section);
} else {
row = row.children(
items
.map(IntoElement::into_any_element)
.into_vec()
.into_iter()
.zip(column_widths.into_vec())
.map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
);
}
let row = if let Some(map_row) = table_context.map_row {
map_row((row_index, row), window, cx)
@ -598,7 +696,7 @@ pub fn render_table_header(
resize_info: Option<HeaderResizeInfo>,
entity_id: Option<EntityId>,
cx: &mut App,
) -> impl IntoElement {
) -> AnyElement {
let cols = headers.cols();
let column_widths = table_context
.column_widths
@ -611,42 +709,102 @@ pub fn render_table_header(
.unwrap_or_default();
let shared_element_id: SharedString = format!("table-{}", element_id).into();
let pinned_cols = table_context.pinned_cols;
div()
let outer = div()
.flex()
.flex_row()
.items_center()
.w_full()
.border_b_1()
.border_color(cx.theme().colors().border)
.children(
headers
.into_vec()
.border_color(cx.theme().colors().border);
let use_ui_font = table_context.use_ui_font;
let resize_info_ref = resize_info.as_ref();
if is_pinned_layout(pinned_cols, cols) {
let mut headers_vec: Vec<AnyElement> = headers
.into_vec()
.into_iter()
.map(IntoElement::into_any_element)
.collect();
let mut widths_vec: Vec<Option<Length>> = column_widths.into_vec();
let scrollable_headers: Vec<AnyElement> = headers_vec.drain(pinned_cols..).collect();
let scrollable_widths: Vec<Option<Length>> = widths_vec.drain(pinned_cols..).collect();
let pinned_section =
div().flex().flex_row().flex_shrink_0().children(
headers_vec.into_iter().enumerate().zip(widths_vec).map(
|((header_idx, h), width)| {
render_header_cell(
h,
width,
header_idx,
&shared_element_id,
resize_info_ref,
use_ui_font,
cx,
)
},
),
);
let inner = div().flex().flex_row().children(
scrollable_headers
.into_iter()
.enumerate()
.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(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.reset_column(header_idx, window, cx);
}
})
} else {
this
}
})
.zip(scrollable_widths)
.map(|((rel_idx, h), width)| {
render_header_cell(
h,
width,
pinned_cols + rel_idx,
&shared_element_id,
resize_info_ref,
use_ui_font,
cx,
)
}),
)
);
let mut scrollable_section = div()
.id("table-header-scrollable")
.flex_grow()
.overflow_x_scroll()
.flex()
.child(inner);
if let Some(ref handle) = table_context.h_scroll_handle {
scrollable_section = scrollable_section.track_scroll(handle);
}
scrollable_section.style().restrict_scroll_to_axis = Some(true);
outer
.child(pinned_section)
.child(scrollable_section)
.into_any_element()
} else {
outer
.children(
headers
.into_vec()
.into_iter()
.enumerate()
.zip(column_widths.into_vec())
.map(|((header_idx, h), width)| {
render_header_cell(
h.into_any_element(),
width,
header_idx,
&shared_element_id,
resize_info_ref,
use_ui_font,
cx,
)
}),
)
.into_any_element()
}
}
#[derive(Clone)]
@ -659,10 +817,15 @@ pub struct TableRenderContext {
pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
pub use_ui_font: bool,
pub disable_base_cell_style: bool,
pub pinned_cols: usize,
/// Scroll handle shared by all scrollable sections in rows and headers.
/// When `pinned_cols > 0`, each row's scrollable section tracks this handle so all rows
/// scroll together without requiring per-scroll re-renders.
pub h_scroll_handle: Option<ScrollHandle>,
}
impl TableRenderContext {
fn new(table: &Table, cx: &App) -> Self {
fn new(table: &Table, h_scroll_handle: Option<ScrollHandle>, cx: &App) -> Self {
Self {
striped: table.striped,
show_row_borders: table.show_row_borders,
@ -672,6 +835,8 @@ impl TableRenderContext {
map_row: table.map_row.clone(),
use_ui_font: table.use_ui_font,
disable_base_cell_style: table.disable_base_cell_style,
pinned_cols: table.pinned_cols,
h_scroll_handle,
}
}
@ -685,12 +850,69 @@ impl TableRenderContext {
map_row: None,
use_ui_font,
disable_base_cell_style: false,
pinned_cols: 0,
h_scroll_handle: None,
}
}
}
/// Builds resize dividers for the given column range, positioned absolutely from `left: 0`.
/// When `interactive` is false, dividers render as plain visual lines with no drag/click handlers.
fn build_resize_dividers(
columns_state: &Entity<ResizableColumnsState>,
widths: &TableRow<AbsoluteLength>,
resize_behavior: &TableRow<TableResizeBehavior>,
range: Range<usize>,
interactive: bool,
rem_size: Pixels,
window: &mut Window,
cx: &mut App,
) -> Vec<AnyElement> {
let entity_id = columns_state.entity_id();
let last = range.end.saturating_sub(1);
let mut dividers = Vec::with_capacity(range.end - range.start);
let mut accumulated = px(0.);
for col_idx in range {
accumulated = accumulated + widths[col_idx].to_pixels(rem_size);
// 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 == last {
accumulated - px(RESIZE_DIVIDER_WIDTH)
} else {
accumulated
};
let divider = div().id(col_idx).absolute().top_0().left(divider_left);
let on_reset: Rc<dyn Fn(&mut Window, &mut App)> = {
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();
});
})
};
let is_resizable = interactive && resize_behavior[col_idx].is_resizable();
dividers.push(render_column_resize_divider(
divider,
col_idx,
is_resizable,
entity_id,
on_reset,
None,
window,
cx,
));
}
dividers
}
fn render_resize_handles_resizable(
columns_state: &Entity<ResizableColumnsState>,
pinned_cols: usize,
h_scroll_handle: Option<&ScrollHandle>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
@ -700,61 +922,122 @@ fn render_resize_handles_resizable(
};
let rem_size = window.rem_size();
let resize_behavior = Rc::new(resize_behavior);
let n_cols = widths.cols();
let mut dividers: Vec<AnyElement> = Vec::with_capacity(n_cols);
let mut accumulated_px = px(0.);
let pinned_cols = pinned_cols.min(n_cols);
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 divider = div().id(col_idx).absolute().top_0().left(divider_left);
let entity_id = columns_state.entity_id();
let on_reset: Rc<dyn Fn(&mut Window, &mut App)> = {
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();
});
})
};
dividers.push(render_column_resize_divider(
divider,
col_idx,
resize_behavior[col_idx].is_resizable(),
entity_id,
on_reset,
None,
window,
cx,
));
}
if pinned_cols == 0 {
let dividers = build_resize_dividers(
columns_state,
&widths,
&resize_behavior,
0..n_cols,
true,
rem_size,
window,
cx,
);
return div()
.id("resize-handles")
.absolute()
.inset_0()
.w_full()
.children(dividers)
.into_any_element();
}
div()
let pinned_width: Pixels = widths[..pinned_cols]
.iter()
.map(|w| w.to_pixels(rem_size))
.fold(px(0.), |acc, x| acc + x);
let total_scrollable_width: Pixels = widths[pinned_cols..]
.iter()
.map(|w| w.to_pixels(rem_size))
.fold(px(0.), |acc, x| acc + x);
// Non-interactive: pinned columns don't visually shift with scroll, so resizing them would
// need separate drag-math from the scrollable columns. Header double-click reset still works.
let pinned_dividers = build_resize_dividers(
columns_state,
&widths,
&resize_behavior,
0..pinned_cols,
false,
rem_size,
window,
cx,
);
let pinned_overlay = div()
.id("resize-handles-pinned")
.absolute()
.top_0()
.bottom_0()
.left_0()
.w(pinned_width)
.children(pinned_dividers);
let scrollable_dividers = build_resize_dividers(
columns_state,
&widths,
&resize_behavior,
pinned_cols..n_cols,
true,
rem_size,
window,
cx,
);
// Sized inner div gives the overflow container something to scroll against.
let inner = div()
.relative()
.w(total_scrollable_width)
.h_full()
.children(scrollable_dividers);
let mut overlay = div()
.id("resize-handles")
.absolute()
.top_0()
.bottom_0()
.left(pinned_width)
.right_0()
.overflow_x_scroll()
.child(inner);
if let Some(handle) = h_scroll_handle {
overlay = overlay.track_scroll(handle);
}
overlay.style().restrict_scroll_to_axis = Some(true);
div()
.id("resize-handles-wrapper")
.absolute()
.inset_0()
.w_full()
.children(dividers)
.child(pinned_overlay)
.child(overlay)
.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);
let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
let interaction_state = self
.interaction_state
.clone()
.and_then(|state| state.upgrade());
let pinned_cols = self.pinned_cols;
let uses_pinned_layout = is_pinned_layout(pinned_cols, self.cols);
let resize_handle_pinned_cols = if uses_pinned_layout { pinned_cols } else { 0 };
// Shared by every row's scrollable section so they scroll in lockstep, and read by
// on_drag_move to compensate drag_x for the horizontal scroll offset.
let h_scroll_handle = if uses_pinned_layout {
interaction_state
.as_ref()
.map(|s| s.read(cx).horizontal_scroll_handle.clone())
} else {
None
};
let table_context = TableRenderContext::new(&self, h_scroll_handle.clone(), cx);
let header_resize_info =
interaction_state
@ -769,8 +1052,19 @@ impl RenderOnce for Table {
_ => None,
});
let table_width = self.column_width_config.table_width(window, cx);
let horizontal_sizing = self.column_width_config.list_horizontal_sizing(window, cx);
// Pinned mode sizes each row internally, so no fixed table_width or h_scroll_container.
let table_width = if uses_pinned_layout {
None
} else {
self.column_width_config.table_width(window, cx)
};
let horizontal_sizing = if uses_pinned_layout {
ListHorizontalSizingBehavior::FitList
} else {
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())
@ -793,7 +1087,13 @@ impl RenderOnce for Table {
ColumnWidthConfig::Resizable(entity) => (
None,
Some(entity.clone()),
Some(render_resize_handles_resizable(entity, window, cx)),
Some(render_resize_handles_resizable(
entity,
resize_handle_pinned_cols,
h_scroll_handle.as_ref(),
window,
cx,
)),
),
_ => (None, None, None),
}
@ -820,11 +1120,18 @@ impl RenderOnce for Table {
bind_redistributable_columns(this, widths)
})
.when_some(resizable_entity, |this, entity| {
let scroll_handle_for_drag = h_scroll_handle.clone();
this.on_drag_move::<DraggedColumn>(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));
let h_scroll_offset = scroll_handle_for_drag
.as_ref()
.map(|h| h.offset().x)
.unwrap_or(px(0.));
entity.update(cx, |state, cx| {
state.on_drag_move(event, h_scroll_offset, window, cx)
});
})
})
.child({
@ -926,8 +1233,7 @@ impl RenderOnce for Table {
);
if let Some(state) = interaction_state.as_ref() {
// Resizable mode: wrap table in a horizontal scroll container first
let content = if is_resizable {
let content = if is_resizable && !uses_pinned_layout {
let mut h_scroll_container = div()
.id("table-h-scroll")
.overflow_x_scroll()
@ -957,7 +1263,7 @@ impl RenderOnce for Table {
)
};
// Add horizontal scrollbar when in resizable mode
// Works for both modes since they share horizontal_scroll_handle.
if is_resizable {
content = content.custom_scrollbars(
Scrollbars::new(ScrollAxes::Horizontal)

View file

@ -1,5 +1,6 @@
use super::table_row::TableRow;
use crate::{RedistributableColumnsState, TableResizeBehavior};
use crate::{RedistributableColumnsState, ResizableColumnsState, TableResizeBehavior};
use gpui::{AbsoluteLength, px};
fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
@ -317,3 +318,97 @@ mod drag_handle {
minimums: "X|*|*|*|*",
);
}
mod resizable_drag {
use super::*;
const REM: f32 = 16.;
fn state(widths_px: &[f32], behavior: Vec<TableResizeBehavior>) -> ResizableColumnsState {
let widths: Vec<AbsoluteLength> = widths_px
.iter()
.map(|w| AbsoluteLength::Pixels(px(*w)))
.collect();
ResizableColumnsState::new(widths.len(), widths, behavior)
}
fn widths_px(state: &ResizableColumnsState) -> Vec<f32> {
state
.widths
.as_slice()
.iter()
.map(|w| f32::from(w.to_pixels(px(REM))))
.collect()
}
#[test]
fn drag_first_column_right() {
let mut s = state(&[100., 100., 100.], vec![TableResizeBehavior::None; 3]);
s.drag_to(0, px(150.), px(REM));
assert_eq!(widths_px(&s), vec![150., 100., 100.]);
}
#[test]
fn drag_middle_column_right() {
let mut s = state(&[100., 100., 100.], vec![TableResizeBehavior::None; 3]);
s.drag_to(1, px(250.), px(REM));
assert_eq!(widths_px(&s), vec![100., 150., 100.]);
}
#[test]
fn drag_does_not_affect_other_columns() {
let mut s = state(&[100., 100., 100.], vec![TableResizeBehavior::None; 3]);
s.drag_to(1, px(280.), px(REM));
let w = widths_px(&s);
assert_eq!(w[0], 100.);
assert_eq!(w[2], 100.);
}
#[test]
fn drag_below_min_clamps_to_min_size() {
// MinSize(2.0) with rem=16 → min_px = 32
let mut s = state(
&[100., 100.],
vec![TableResizeBehavior::MinSize(2.0), TableResizeBehavior::None],
);
s.drag_to(0, px(5.), px(REM));
assert_eq!(widths_px(&s), vec![32., 100.]);
}
#[test]
fn drag_x_below_left_edge_clamps_via_min() {
// drag_x < left_edge would yield negative width; min clamping must catch it.
let mut s = state(
&[100., 100.],
vec![TableResizeBehavior::MinSize(1.0), TableResizeBehavior::None],
);
s.drag_to(0, px(-50.), px(REM));
assert_eq!(widths_px(&s), vec![16., 100.]);
}
}
mod pin_layout {
use super::super::is_pinned_layout;
#[test]
fn zero_pinned_falls_back_to_single_section() {
assert!(!is_pinned_layout(0, 5));
}
#[test]
fn all_pinned_falls_back_to_single_section() {
assert!(!is_pinned_layout(5, 5));
}
#[test]
fn more_than_total_falls_back_to_single_section() {
assert!(!is_pinned_layout(6, 5));
}
#[test]
fn partial_pinning_uses_split_layout() {
assert!(is_pinned_layout(1, 5));
assert!(is_pinned_layout(2, 5));
assert!(is_pinned_layout(4, 5));
}
}