zed/crates/ui/src/components/data_table/tests.rs
Oleksandr Kholiavko dca5976f82
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>
2026-05-15 11:12:41 +00:00

414 lines
13 KiB
Rust

use super::table_row::TableRow;
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)
}
fn cols_to_str(cols: &[f32], total_size: f32) -> String {
cols.iter()
.map(|f| "*".repeat(f32::round(f * total_size) as usize))
.collect::<Vec<String>>()
.join("|")
}
fn parse_resize_behavior(
input: &str,
total_size: f32,
expected_cols: usize,
) -> Vec<TableResizeBehavior> {
let mut resize_behavior = Vec::with_capacity(expected_cols);
for col in input.split('|') {
if col.starts_with('X') || col.is_empty() {
resize_behavior.push(TableResizeBehavior::None);
} else if col.starts_with('*') {
resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size));
} else {
panic!("invalid test input: unrecognized resize behavior: {}", col);
}
}
if resize_behavior.len() != expected_cols {
panic!(
"invalid test input: expected {} columns, got {}",
expected_cols,
resize_behavior.len()
);
}
resize_behavior
}
mod reset_column_size {
use super::*;
fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
let mut widths = Vec::new();
let mut column_index = None;
for (index, col) in input.split('|').enumerate() {
widths.push(col.len() as f32);
if col.starts_with('X') {
column_index = Some(index);
}
}
for w in &widths {
assert!(w.is_finite(), "incorrect number of columns");
}
let total = widths.iter().sum::<f32>();
for width in &mut widths {
*width /= total;
}
(widths, total, column_index)
}
#[track_caller]
fn check_reset_size(initial_sizes: &str, widths: &str, expected: &str, resize_behavior: &str) {
let (initial_sizes, total_1, None) = parse(initial_sizes) else {
panic!("invalid test input: initial sizes should not be marked");
};
let (widths, total_2, Some(column_index)) = parse(widths) else {
panic!("invalid test input: widths should be marked");
};
assert_eq!(
total_1, total_2,
"invalid test input: total width not the same {total_1}, {total_2}"
);
let (expected, total_3, None) = parse(expected) else {
panic!("invalid test input: expected should not be marked: {expected:?}");
};
assert_eq!(
total_2, total_3,
"invalid test input: total width not the same"
);
let 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 = RedistributableColumnsState::reset_to_initial_size(
column_index,
TableRow::from_vec(widths, cols),
TableRow::from_vec(initial_sizes, cols),
&resize_behavior,
);
let result_slice = result.as_slice();
let is_eq = is_almost_eq(result_slice, &expected);
if !is_eq {
let result_str = cols_to_str(result_slice, total_1);
let expected_str = cols_to_str(&expected, total_1);
panic!(
"resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
);
}
}
macro_rules! check_reset_size {
(columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
check_reset_size($initial, $current, $expected, $resizing);
};
($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
#[test]
fn $name() {
check_reset_size($initial, $current, $expected, $resizing);
}
};
}
check_reset_size!(
basic_right,
columns: 5,
starting: "**|**|**|**|**",
snapshot: "**|**|X|***|**",
expected: "**|**|**|**|**",
minimums: "X|*|*|*|*",
);
check_reset_size!(
basic_left,
columns: 5,
starting: "**|**|**|**|**",
snapshot: "**|**|***|X|**",
expected: "**|**|**|**|**",
minimums: "X|*|*|*|**",
);
check_reset_size!(
squashed_left_reset_col2,
columns: 6,
starting: "*|***|**|**|****|*",
snapshot: "*|*|X|*|*|********",
expected: "*|*|**|*|*|*******",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
grow_cascading_right,
columns: 6,
starting: "*|***|****|**|***|*",
snapshot: "*|***|X|**|**|*****",
expected: "*|***|****|*|*|****",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
squashed_right_reset_col4,
columns: 6,
starting: "*|***|**|**|****|*",
snapshot: "*|********|*|*|X|*",
expected: "*|*****|*|*|****|*",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
reset_col6_right,
columns: 6,
starting: "*|***|**|***|***|**",
snapshot: "*|***|**|***|**|XXX",
expected: "*|***|**|***|***|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
reset_col6_left,
columns: 6,
starting: "*|***|**|***|***|**",
snapshot: "*|***|**|***|****|X",
expected: "*|***|**|***|***|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
last_column_grow_cascading,
columns: 6,
starting: "*|***|**|**|**|***",
snapshot: "*|*******|*|**|*|X",
expected: "*|******|*|*|*|***",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
goes_left_when_left_has_extreme_diff,
columns: 6,
starting: "*|***|****|**|**|***",
snapshot: "*|********|X|*|**|**",
expected: "*|*****|****|*|**|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
basic_shrink_right,
columns: 6,
starting: "**|**|**|**|**|**",
snapshot: "**|**|XXX|*|**|**",
expected: "**|**|**|**|**|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
shrink_should_go_left,
columns: 6,
starting: "*|***|**|*|*|*",
snapshot: "*|*|XXX|**|*|*",
expected: "*|**|**|**|*|*",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
shrink_should_go_right,
columns: 6,
starting: "*|***|**|**|**|*",
snapshot: "*|****|XXX|*|*|*",
expected: "*|****|**|**|*|*",
minimums: "X|*|*|*|*|*",
);
}
mod drag_handle {
use super::*;
fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
let mut widths = Vec::new();
let column_index = input.replace("*", "").find("I");
for col in input.replace("I", "|").split('|') {
widths.push(col.len() as f32);
}
for w in &widths {
assert!(w.is_finite(), "incorrect number of columns");
}
let total = widths.iter().sum::<f32>();
for width in &mut widths {
*width /= total;
}
(widths, total, column_index)
}
#[track_caller]
fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) {
let (widths, total_1, Some(column_index)) = parse(widths) else {
panic!("invalid test input: widths should be marked");
};
let (expected, total_2, None) = parse(expected) else {
panic!("invalid test input: expected should not be marked: {expected:?}");
};
assert_eq!(
total_1, total_2,
"invalid test input: total width not the same"
);
let cols = widths.len();
let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
let distance = distance as f32 / total_1;
let mut widths_table_row = TableRow::from_vec(widths, cols);
RedistributableColumnsState::drag_column_handle(
distance,
column_index,
&mut widths_table_row,
&resize_behavior,
);
let result_widths = widths_table_row.as_slice();
let is_eq = is_almost_eq(result_widths, &expected);
if !is_eq {
let result_str = cols_to_str(result_widths, total_1);
let expected_str = cols_to_str(&expected, total_1);
panic!(
"resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
);
}
}
macro_rules! check {
(columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
check($dist, $current, $expected, $resizing);
};
($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
#[test]
fn $name() {
check($dist, $current, $expected, $resizing);
}
};
}
check!(
basic_right_drag,
columns: 3,
distance: 1,
snapshot: "**|**I**",
expected: "**|***|*",
minimums: "X|*|*",
);
check!(
drag_left_against_mins,
columns: 5,
distance: -1,
snapshot: "*|*|*|*I*******",
expected: "*|*|*|*|*******",
minimums: "X|*|*|*|*",
);
check!(
drag_left,
columns: 5,
distance: -2,
snapshot: "*|*|*|*****I***",
expected: "*|*|*|***|*****",
minimums: "X|*|*|*|*",
);
}
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));
}
}