mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
## 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>
414 lines
13 KiB
Rust
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));
|
|
}
|
|
}
|