### Summary This PR integrates the git graph canvas with the `Table` component’s `RedistributableColumnsState`, making the graph column resizable while preserving the table’s existing resize behavior. In particular, column resizing continues to use the same cascading redistribution behavior as the table. This is also the last PR needed to remove the feature flag on the git graph! ### Table API changes I pulled the redistributable column logic out of `Table` into reusable UI helpers so layouts outside of `Table` can participate in the same column resizing behavior. This adds a shared `RedistributableColumnsState` API, along with helpers for binding drag/drop behavior, rendering resize handles, and constructing header resize metadata. I also added `ColumnWidthConfig::explicit` and `TableRenderContext::for_column_widths` so callers can render table like headers and content with externally managed column widths. The reason for this change is that the git graph now renders a custom split layout: a graph canvas on the left and table content on the right. By reusing the same column state and resize machinery, the graph column can resize together with the table columns while preserving the existing table behavior, including cascading column redistribution and double click reset to default sizing. I also adjusted the resize handle interaction styling so the divider stays in its hovered/highlighted state while a drag is active, which makes the drag target feel more stable and visually consistent during resizing. ### Preview https://github.com/user-attachments/assets/347eed71-0cc1-4db4-9dee-a86ee5ab6f91 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ...
320 lines
10 KiB
Rust
320 lines
10 KiB
Rust
use super::table_row::TableRow;
|
|
use crate::{RedistributableColumnsState, TableResizeBehavior};
|
|
|
|
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|*|*|*|*",
|
|
);
|
|
}
|