Files
zed/crates/ui/src/components/redistributable_columns.rs
T
ed99ce3836 git_ui: make git graph columns toggleable (#59850)
# Objective

TODO

## Solution

TODO

## Self-Review Checklist:

- [ ] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

## Showcase


https://github.com/user-attachments/assets/ae1e2759-2ae9-4c6f-aba3-b6b557c673bc

---

cc @Anthony-Eid 

Release Notes:

- Git graph: Make columns toggleable

---------

Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
2026-07-07 08:01:05 +00:00

749 lines
25 KiB
Rust

use super::data_table::{
ResizableColumnsState,
table_row::{IntoTableRow as _, TableRow},
};
use crate::{
ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement,
IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
px,
};
use gpui::{
AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity,
EntityId, Length, Stateful, WeakEntity,
};
use std::rc::Rc;
pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0;
pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
/// Drag payload for column resize handles.
/// Includes the `EntityId` of the owning column state so that
/// `on_drag_move` handlers on unrelated tables ignore the event.
#[derive(Debug)]
pub(crate) struct DraggedColumn {
pub(crate) col_idx: usize,
pub(crate) state_id: EntityId,
}
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum TableResizeBehavior {
None,
Resizable,
MinSize(f32),
}
impl TableResizeBehavior {
pub fn is_resizable(&self) -> bool {
*self != TableResizeBehavior::None
}
pub fn min_size(&self) -> Option<f32> {
match self {
TableResizeBehavior::None => None,
TableResizeBehavior::Resizable => Some(0.05),
TableResizeBehavior::MinSize(min_size) => Some(*min_size),
}
}
}
#[derive(Clone)]
pub(crate) enum ColumnsStateRef {
Redistributable(WeakEntity<RedistributableColumnsState>),
Resizable(WeakEntity<ResizableColumnsState>),
}
#[derive(Clone)]
pub struct HeaderResizeInfo {
pub(crate) columns_state: ColumnsStateRef,
pub resize_behavior: TableRow<TableResizeBehavior>,
}
impl HeaderResizeInfo {
pub fn from_redistributable(
columns_state: &Entity<RedistributableColumnsState>,
cx: &App,
) -> Self {
let resize_behavior = columns_state.read(cx).resize_behavior().clone();
Self {
columns_state: ColumnsStateRef::Redistributable(columns_state.downgrade()),
resize_behavior,
}
}
pub fn from_resizable(columns_state: &Entity<ResizableColumnsState>, cx: &App) -> Self {
let resize_behavior = columns_state.read(cx).resize_behavior().clone();
Self {
columns_state: ColumnsStateRef::Resizable(columns_state.downgrade()),
resize_behavior,
}
}
pub fn reset_column(&self, col_idx: usize, window: &mut Window, cx: &mut App) {
match &self.columns_state {
ColumnsStateRef::Redistributable(weak) => {
weak.update(cx, |state, cx| {
state.reset_column_to_initial_width(col_idx, window);
cx.notify();
})
.ok();
}
ColumnsStateRef::Resizable(weak) => {
weak.update(cx, |state, cx| {
state.reset_column_to_initial_width(col_idx);
cx.notify();
})
.ok();
}
}
}
}
pub struct RedistributableColumnsState {
pub(crate) initial_widths: TableRow<DefiniteLength>,
pub(crate) committed_widths: TableRow<DefiniteLength>,
pub(crate) preview_widths: TableRow<DefiniteLength>,
pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
pub(crate) cached_container_width: Pixels,
}
impl RedistributableColumnsState {
pub fn new(
cols: usize,
initial_widths: Vec<impl Into<DefiniteLength>>,
resize_behavior: Vec<TableResizeBehavior>,
) -> Self {
let widths: TableRow<DefiniteLength> = initial_widths
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into_table_row(cols);
Self {
initial_widths: widths.clone(),
committed_widths: widths.clone(),
preview_widths: widths,
resize_behavior: resize_behavior.into_table_row(cols),
cached_container_width: Default::default(),
}
}
pub fn cols(&self) -> usize {
self.committed_widths.cols()
}
pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
&self.initial_widths
}
pub fn preview_widths(&self) -> &TableRow<DefiniteLength> {
&self.preview_widths
}
pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
&self.resize_behavior
}
pub fn widths_to_render(&self) -> TableRow<Length> {
self.preview_widths.map_cloned(Length::Definite)
}
pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow<f32> {
if self.cached_container_width > px(0.) {
self.preview_widths
.map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size))
} else {
self.preview_widths.map_ref(|length| match length {
DefiniteLength::Fraction(fraction) => *fraction,
DefiniteLength::Absolute(_) => 0.0,
})
}
}
pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option<Pixels> {
let width = self.preview_widths().as_slice().get(column_index)?;
match width {
DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => {
Some(self.cached_container_width * *fraction)
}
DefiniteLength::Fraction(_) => None,
DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels),
DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
Some(rems_width.to_pixels(window.rem_size()))
}
}
}
pub fn cached_container_width(&self) -> Pixels {
self.cached_container_width
}
pub fn set_cached_container_width(&mut self, width: Pixels) {
self.cached_container_width = width;
}
pub fn commit_preview(&mut self) {
self.committed_widths = self.preview_widths.clone();
}
pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) {
let bounds_width = self.cached_container_width;
if bounds_width <= px(0.) {
return;
}
let rem_size = window.rem_size();
let initial_sizes = self
.initial_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let widths = self
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let updated_widths =
Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior);
self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
self.preview_widths = self.committed_widths.clone();
}
fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
match length {
DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
rems_width.to_pixels(rem_size) / bounds_width
}
DefiniteLength::Fraction(fraction) => *fraction,
}
}
pub(crate) fn reset_to_initial_size(
col_idx: usize,
mut widths: TableRow<f32>,
initial_sizes: TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) -> TableRow<f32> {
let diff = initial_sizes[col_idx] - widths[col_idx];
let left_diff =
initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
- widths[col_idx + 1..].iter().sum::<f32>();
let go_left_first = if diff < 0.0 {
left_diff > right_diff
} else {
left_diff < right_diff
};
if !go_left_first {
let diff_remaining =
Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
if diff_remaining != 0.0 && col_idx > 0 {
Self::propagate_resize_diff(
diff_remaining,
col_idx,
&mut widths,
resize_behavior,
-1,
);
}
} else {
let diff_remaining =
Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
if diff_remaining != 0.0 {
Self::propagate_resize_diff(
diff_remaining,
col_idx,
&mut widths,
resize_behavior,
1,
);
}
}
widths
}
fn on_drag_move(
&mut self,
drag_event: &DragMoveEvent<DraggedColumn>,
hidden: Option<&TableRow<bool>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let drag_position = drag_event.event.position;
let bounds = drag_event.bounds;
let bounds_width = bounds.right() - bounds.left();
if bounds_width <= px(0.) {
return;
}
let rem_size = window.rem_size();
let col_idx = drag_event.drag(cx).col_idx;
let divider_width = Self::get_fraction(
&DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
bounds_width,
rem_size,
);
let widths = self
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
let widths = Self::compute_drag_preview(
widths,
&self.resize_behavior,
hidden,
col_idx,
drag_fraction,
divider_width,
);
self.preview_widths = widths.map(DefiniteLength::Fraction);
}
/// Computes the preview column fractions produced by dragging the divider after `col_idx`
/// to `drag_fraction` (the cursor's x position expressed as a fraction of the container
/// width). `divider_width` is the resize-divider width as a fraction of the container.
///
/// The on-screen layout only contains the visible columns, with the hidden columns' width
/// budget redistributed across them (see [`redistribute_hidden_widths`]), so the geometry
/// here is done in that visible/redistributed space: the raw `widths` are compacted to the
/// visible columns and scaled to match the rendered layout, the drag is applied there (which
/// also makes neighbor propagation skip hidden columns), and the result is mapped back to
/// the raw widths, leaving hidden columns untouched.
///
/// Extracted as a pure function so the drag math can be unit tested, mirroring the
/// `drag_column_handle` / `propagate_resize_diff` helpers.
pub(crate) fn compute_drag_preview(
mut widths: TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
hidden: Option<&TableRow<bool>>,
col_idx: usize,
drag_fraction: f32,
divider_width: f32,
) -> TableRow<f32> {
let visible_cols: Vec<usize> = (0..widths.cols())
.filter(|idx| !is_column_hidden(hidden, *idx))
.collect();
// Dividers are only rendered after visible columns, so a hidden `col_idx` should be
// impossible; bail out rather than resizing the wrong column.
let Some(divider_position) = visible_cols.iter().position(|&idx| idx == col_idx) else {
return widths;
};
let total_sum: f32 = widths.as_slice().iter().sum();
let visible_sum: f32 = visible_cols.iter().map(|&idx| widths[idx]).sum();
// The drag only moves width between visible columns, so `visible_sum` (and therefore
// this scale) is the same before and after the drag, making the mapping back exact.
let scale = if visible_sum > 0.0 {
total_sum / visible_sum
} else {
1.0
};
let mut rendered_widths = TableRow::from_vec(
visible_cols
.iter()
.map(|&idx| widths[idx] * scale)
.collect(),
visible_cols.len(),
);
let rendered_behavior = TableRow::from_vec(
visible_cols
.iter()
.map(|&idx| resize_behavior[idx])
.collect(),
visible_cols.len(),
);
let mut col_position = 0.0;
for length in rendered_widths[0..=divider_position].iter() {
col_position += length + divider_width;
}
let mut total_length_ratio = col_position;
for length in rendered_widths[divider_position + 1..].iter() {
total_length_ratio += length;
}
let cols = rendered_behavior.cols();
total_length_ratio += (cols - 1 - divider_position) as f32 * divider_width;
let drag_fraction = drag_fraction * total_length_ratio;
let diff = drag_fraction - col_position - divider_width / 2.0;
Self::drag_column_handle(
diff,
divider_position,
&mut rendered_widths,
&rendered_behavior,
);
for (visible_position, &idx) in visible_cols.iter().enumerate() {
widths[idx] = rendered_widths[visible_position] / scale;
}
widths
}
pub(crate) fn drag_column_handle(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) {
if diff > 0.0 {
Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
} else {
Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
}
}
pub(crate) fn propagate_resize_diff(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
direction: i8,
) -> f32 {
let mut diff_remaining = diff;
if resize_behavior[col_idx].min_size().is_none() {
return diff;
}
let step_right;
let step_left;
if direction < 0 {
step_right = 0;
step_left = 1;
} else {
step_right = 1;
step_left = 0;
}
if col_idx == 0 && direction < 0 {
return diff;
}
let mut curr_column = col_idx + step_right - step_left;
while diff_remaining != 0.0 && curr_column < widths.cols() {
let Some(min_size) = resize_behavior[curr_column].min_size() else {
if curr_column == 0 {
break;
}
curr_column -= step_left;
curr_column += step_right;
continue;
};
let curr_width = widths[curr_column] - diff_remaining;
widths[curr_column] = curr_width;
if min_size > curr_width {
diff_remaining = min_size - curr_width;
widths[curr_column] = min_size;
} else {
diff_remaining = 0.0;
break;
}
if curr_column == 0 {
break;
}
curr_column -= step_left;
curr_column += step_right;
}
widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
diff_remaining
}
}
/// Returns `true` when the column at `idx` is hidden by `hidden`.
pub fn is_column_hidden(hidden: Option<&TableRow<bool>>, idx: usize) -> bool {
hidden
.and_then(|mask| mask.get(idx).copied())
.unwrap_or(false)
}
/// Redistributes the fractional width budget of hidden columns across the visible columns so the
/// visible columns fill the container instead of leaving a gap. Hidden columns keep their stored
/// width (they are never rendered, so the value is not shown) and `Absolute` widths are left
/// untouched. Returns the widths unchanged when no column is hidden.
pub fn redistribute_hidden_widths(
widths: &TableRow<Length>,
hidden: Option<&TableRow<bool>>,
) -> TableRow<Length> {
if !(0..widths.cols()).any(|idx| is_column_hidden(hidden, idx)) {
return widths.clone();
}
let mut total_fraction_sum = 0.0;
let mut visible_fraction_sum = 0.0;
for (idx, width) in widths.as_slice().iter().enumerate() {
if let Length::Definite(DefiniteLength::Fraction(fraction)) = width {
total_fraction_sum += *fraction;
if !is_column_hidden(hidden, idx) {
visible_fraction_sum += *fraction;
}
}
}
let scale = if visible_fraction_sum > 0.0 {
total_fraction_sum / visible_fraction_sum
} else {
1.0
};
let scaled: Vec<Length> = widths
.as_slice()
.iter()
.enumerate()
.map(|(idx, width)| match width {
Length::Definite(DefiniteLength::Fraction(fraction))
if !is_column_hidden(hidden, idx) =>
{
Length::Definite(DefiniteLength::Fraction(fraction * scale))
}
other => *other,
})
.collect();
TableRow::from_vec(scaled, widths.cols())
}
/// Fraction-valued counterpart of [`redistribute_hidden_widths`].
pub fn redistribute_hidden_fractions(
fractions: &TableRow<f32>,
hidden: Option<&TableRow<bool>>,
) -> TableRow<f32> {
if !(0..fractions.cols()).any(|idx| is_column_hidden(hidden, idx)) {
return fractions.clone();
}
let total_sum: f32 = fractions.as_slice().iter().sum();
let visible_sum: f32 = fractions
.as_slice()
.iter()
.enumerate()
.filter(|(idx, _)| !is_column_hidden(hidden, *idx))
.map(|(_, fraction)| *fraction)
.sum();
let scale = if visible_sum > 0.0 {
total_sum / visible_sum
} else {
1.0
};
let scaled: Vec<f32> = fractions
.as_slice()
.iter()
.enumerate()
.map(|(idx, fraction)| {
if is_column_hidden(hidden, idx) {
*fraction
} else {
fraction * scale
}
})
.collect();
TableRow::from_vec(scaled, fractions.cols())
}
pub fn bind_redistributable_columns(
container: Div,
columns_state: Entity<RedistributableColumnsState>,
hidden: Option<TableRow<bool>>,
) -> Div {
container
.on_drag_move::<DraggedColumn>({
let columns_state = columns_state.clone();
move |event, window, cx| {
if event.drag(cx).state_id != columns_state.entity_id() {
return;
}
columns_state.update(cx, |columns, cx| {
columns.on_drag_move(event, hidden.as_ref(), window, cx);
});
}
})
.on_children_prepainted({
let columns_state = columns_state.clone();
move |bounds, _, cx| {
if let Some(width) = child_bounds_width(&bounds) {
columns_state.update(cx, |columns, _| {
columns.set_cached_container_width(width);
});
}
}
})
.on_drop::<DraggedColumn>(move |_, _, cx| {
columns_state.update(cx, |columns, _| {
columns.commit_preview();
});
})
}
pub fn render_redistributable_columns_resize_handles(
columns_state: &Entity<RedistributableColumnsState>,
hidden: Option<&TableRow<bool>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (column_widths, resize_behavior) = {
let state = columns_state.read(cx);
(
redistribute_hidden_widths(&state.widths_to_render(), hidden),
state.resize_behavior().clone(),
)
};
// Only the visible columns participate in the layout; filtered columns are skipped entirely
// (no spacer, no divider) so we don't draw a stray resize line where a hidden column was.
let visible_cols: Vec<usize> = (0..column_widths.cols())
.filter(|idx| !is_column_hidden(hidden, *idx))
.collect();
let mut children: Vec<AnyElement> = Vec::with_capacity(visible_cols.len() * 2);
for (position, &col_idx) in visible_cols.iter().enumerate() {
children.push(resize_spacer(column_widths[col_idx]).into_any_element());
// A divider is rendered after every visible column except the last, mirroring the
// original `intersperse` behavior but in terms of visible columns.
let is_last_visible = position + 1 == visible_cols.len();
if is_last_visible {
continue;
}
let columns_state = columns_state.clone();
let divider = div().id(col_idx).relative().top_0();
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, |columns, cx| {
columns.reset_column_to_initial_width(col_idx, window);
cx.notify();
});
})
};
let on_drag_end: Option<Rc<dyn Fn(&mut App)>> = {
Some(Rc::new(move |cx| {
columns_state.update(cx, |state, _| state.commit_preview());
}))
};
children.push(render_column_resize_divider(
divider,
col_idx,
resize_behavior[col_idx].is_resizable(),
entity_id,
on_reset,
on_drag_end,
window,
cx,
));
}
h_flex()
.id("resize-handles")
.absolute()
.inset_0()
.w_full()
.children(children)
.into_any_element()
}
/// Builds a single column resize divider with an interactive drag handle.
///
/// The caller provides:
/// - `divider`: a pre-positioned divider element (with absolute or relative positioning)
/// - `col_idx`: which column this divider is for
/// - `is_resizable`: whether the column supports resizing
/// - `entity_id`: the `EntityId` of the owning column state (for the drag payload)
/// - `on_reset`: called on double-click to reset the column to its initial width
/// - `on_drag_end`: called when the drag ends (e.g. to commit preview widths)
pub(crate) fn render_column_resize_divider(
divider: Stateful<Div>,
col_idx: usize,
is_resizable: bool,
entity_id: EntityId,
on_reset: Rc<dyn Fn(&mut Window, &mut App)>,
on_drag_end: Option<Rc<dyn Fn(&mut App)>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
window.with_id(col_idx, |window| {
let mut resize_divider = divider.w(px(RESIZE_DIVIDER_WIDTH)).h_full().bg(cx
.theme()
.colors()
.border
.opacity(0.8));
let mut resize_handle = div()
.id("column-resize-handle")
.absolute()
.left_neg_0p5()
.w(px(RESIZE_COLUMN_WIDTH))
.h_full();
if is_resizable {
let is_highlighted = window.use_state(cx, |_window, _cx| false);
resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
div.bg(cx.theme().colors().border_focused)
});
resize_handle = resize_handle
.on_hover({
let is_highlighted = is_highlighted.clone();
move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
})
.cursor_col_resize()
.on_click(move |event, window, cx| {
if event.click_count() >= 2 {
on_reset(window, cx);
}
cx.stop_propagation();
})
.on_drag(
DraggedColumn {
col_idx,
state_id: entity_id,
},
{
let is_highlighted = is_highlighted.clone();
move |_, _offset, _window, cx| {
is_highlighted.write(cx, true);
cx.new(|_cx| Empty)
}
},
)
.on_drop::<DraggedColumn>(move |_, _, cx| {
is_highlighted.write(cx, false);
if let Some(on_drag_end) = &on_drag_end {
on_drag_end(cx);
}
});
}
resize_divider.child(resize_handle).into_any_element()
})
}
fn resize_spacer(width: Length) -> Div {
div().w(width).h_full()
}
fn child_bounds_width(bounds: &[Bounds<Pixels>]) -> Option<Pixels> {
let first_bounds = bounds.first()?;
let mut left = first_bounds.left();
let mut right = first_bounds.right();
for bound in bounds.iter().skip(1) {
left = left.min(bound.left());
right = right.max(bound.right());
}
Some(right - left)
}