From dca5976f82a6816f126cbab2cb16568a7c173fcf Mon Sep 17 00:00:00 2001
From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com>
Date: Fri, 15 May 2026 13:12:41 +0200
Subject: [PATCH] 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:
Scrolled horizontally (name column dissapeared, line numbers column
stayed pinned):
## 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
---
.../csv_preview/src/renderer/render_table.rs | 1 +
crates/ui/src/components/data_table.rs | 522 ++++++++++++++----
crates/ui/src/components/data_table/tests.rs | 97 +++-
3 files changed, 511 insertions(+), 109 deletions(-)
diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs
index 92afb9f836d..3a7e1b3a046 100644
--- a/crates/csv_preview/src/renderer/render_table.rs
+++ b/crates/csv_preview/src/renderer/render_table.rs
@@ -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 {
diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs
index 594cc188f54..1700699a875 100644
--- a/crates/ui/src/components/data_table.rs
+++ b/crates/ui/src/components/data_table.rs
@@ -65,13 +65,22 @@ impl ResizableColumnsState {
pub(crate) fn on_drag_move(
&mut self,
drag_event: &DragMoveEvent,
+ h_scroll_offset: Pixels,
window: &mut Window,
cx: &mut Context,
) {
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
), &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) -> Div {
div()
.px_1p5()
@@ -523,6 +549,51 @@ fn base_cell_style_text(width: Option, 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, 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,
+ header_idx: usize,
+ shared_element_id: &SharedString,
+ resize_info: Option<&HeaderResizeInfo>,
+ use_ui_font: bool,
+ cx: &App,
+) -> Stateful