diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index d473fbbec618c6e7b309ab2ff9dc9eb5787ddc43..bb1566aa29eeae016d31ac549434e7b92d50eb4d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -42,8 +42,10 @@ use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, - HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, - TableResizeBehavior, Tooltip, WithScrollbar, prelude::*, + HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, + TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, + bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, + render_table_header, table_row::TableRow, }; use workspace::{ Workspace, @@ -901,9 +903,8 @@ pub struct GitGraph { context_menu: Option<(Entity, Point, Subscription)>, row_height: Pixels, table_interaction_state: Entity, - table_column_widths: Entity, + column_widths: Entity, horizontal_scroll_offset: Pixels, - graph_viewport_width: Pixels, selected_entry_idx: Option, hovered_entry_idx: Option, graph_canvas_bounds: Rc>>>, @@ -933,8 +934,60 @@ impl GitGraph { font_size + px(12.0) } - fn graph_content_width(&self) -> Pixels { - (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0 + fn graph_canvas_content_width(&self) -> Pixels { + (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0 + } + + fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] { + let fractions = self + .column_widths + .read(cx) + .preview_fractions(window.rem_size()); + [ + fractions[0], + fractions[1], + fractions[2], + fractions[3], + fractions[4], + ] + } + + fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig { + let [_, description, date, author, commit] = self.preview_column_fractions(window, cx); + let table_total = description + date + author + commit; + + let widths = if table_total > 0.0 { + vec![ + DefiniteLength::Fraction(description / table_total), + DefiniteLength::Fraction(date / table_total), + DefiniteLength::Fraction(author / table_total), + DefiniteLength::Fraction(commit / table_total), + ] + } else { + vec![ + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + ] + }; + + ColumnWidthConfig::explicit(widths) + } + + fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels { + self.column_widths + .read(cx) + .preview_column_width(0, window) + .unwrap_or_else(|| self.graph_canvas_content_width()) + } + + fn clamp_horizontal_scroll_offset(&mut self, graph_viewport_width: Pixels) { + let max_horizontal_scroll = + (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.)); + self.horizontal_scroll_offset = self + .horizontal_scroll_offset + .clamp(px(0.), max_horizontal_scroll); } pub fn new( @@ -972,20 +1025,22 @@ impl GitGraph { }); let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); - let table_column_widths = cx.new(|_cx| { + let column_widths = cx.new(|_cx| { RedistributableColumnsState::new( - 4, + 5, vec![ - DefiniteLength::Fraction(0.72), - DefiniteLength::Fraction(0.12), - DefiniteLength::Fraction(0.10), - DefiniteLength::Fraction(0.06), + DefiniteLength::Fraction(0.14), + DefiniteLength::Fraction(0.6192), + DefiniteLength::Fraction(0.1032), + DefiniteLength::Fraction(0.086), + DefiniteLength::Fraction(0.0516), ], vec![ TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, ], ) }); @@ -1020,9 +1075,8 @@ impl GitGraph { context_menu: None, row_height, table_interaction_state, - table_column_widths, + column_widths, horizontal_scroll_offset: px(0.), - graph_viewport_width: px(88.), selected_entry_idx: None, hovered_entry_idx: None, graph_canvas_bounds: Rc::new(Cell::new(None)), @@ -2089,8 +2143,12 @@ impl GitGraph { let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); let horizontal_scroll_offset = self.horizontal_scroll_offset; - let max_lanes = self.graph_data.max_lanes.max(6); - let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; + let graph_viewport_width = self.graph_viewport_width(window, cx); + let graph_width = if self.graph_canvas_content_width() > graph_viewport_width { + self.graph_canvas_content_width() + } else { + graph_viewport_width + }; let last_visible_row = first_visible_row + (viewport_height / row_height).ceil() as usize + 1; @@ -2414,9 +2472,9 @@ impl GitGraph { let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); let new_offset = Point::new(current_offset.x, new_y); - let max_lanes = self.graph_data.max_lanes.max(1); - let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; - let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.)); + let graph_viewport_width = self.graph_viewport_width(window, cx); + let max_horizontal_scroll = + (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.)); let new_horizontal_offset = (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll); @@ -2497,6 +2555,8 @@ impl Render for GitGraph { cx, ); self.graph_data.add_commits(&commits); + let graph_viewport_width = self.graph_viewport_width(window, cx); + self.clamp_horizontal_scroll_offset(graph_viewport_width); (commits.len(), is_loading) }) } else { @@ -2527,118 +2587,202 @@ impl Render for GitGraph { this.child(self.render_loading_spinner(cx)) }) } else { - div() + let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx); + let header_context = TableRenderContext::for_column_widths( + Some(self.column_widths.read(cx).widths_to_render()), + true, + ); + let [ + graph_fraction, + description_fraction, + date_fraction, + author_fraction, + commit_fraction, + ] = self.preview_column_fractions(window, cx); + let table_fraction = + description_fraction + date_fraction + author_fraction + commit_fraction; + let table_width_config = self.table_column_width_config(window, cx); + let graph_viewport_width = self.graph_viewport_width(window, cx); + self.clamp_horizontal_scroll_offset(graph_viewport_width); + + h_flex() .size_full() - .flex() - .flex_row() .child( div() - .w(self.graph_content_width()) - .h_full() + .flex_1() + .min_w_0() + .size_full() .flex() .flex_col() - .child( - div() - .flex() - .items_center() - .px_1() - .py_0p5() - .border_b_1() - .whitespace_nowrap() - .border_color(cx.theme().colors().border) - .child(Label::new("Graph").color(Color::Muted)), - ) - .child( - div() - .id("graph-canvas") - .flex_1() - .overflow_hidden() - .child(self.render_graph(window, cx)) - .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)) - .on_mouse_move(cx.listener(Self::handle_graph_mouse_move)) - .on_click(cx.listener(Self::handle_graph_click)) - .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| { - if !is_hovered && this.hovered_entry_idx.is_some() { - this.hovered_entry_idx = None; - cx.notify(); - } - })), - ), - ) - .child({ - let row_height = self.row_height; - let selected_entry_idx = self.selected_entry_idx; - let hovered_entry_idx = self.hovered_entry_idx; - let weak_self = cx.weak_entity(); - let focus_handle = self.focus_handle.clone(); - div().flex_1().size_full().child( - Table::new(4) - .interactable(&self.table_interaction_state) - .hide_row_borders() - .hide_row_hover() - .header(vec![ - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ]) - .width_config(ColumnWidthConfig::redistributable( - self.table_column_widths.clone(), - )) - .map_row(move |(index, row), window, cx| { - let is_selected = selected_entry_idx == Some(index); - let is_hovered = hovered_entry_idx == Some(index); - let is_focused = focus_handle.is_focused(window); - let weak = weak_self.clone(); - let weak_for_hover = weak.clone(); - - let hover_bg = cx.theme().colors().element_hover.opacity(0.6); - let selected_bg = if is_focused { - cx.theme().colors().element_selected - } else { - cx.theme().colors().element_hover - }; - - row.h(row_height) - .when(is_selected, |row| row.bg(selected_bg)) - .when(is_hovered && !is_selected, |row| row.bg(hover_bg)) - .on_hover(move |&is_hovered, _, cx| { - weak_for_hover - .update(cx, |this, cx| { - if is_hovered { - if this.hovered_entry_idx != Some(index) { - this.hovered_entry_idx = Some(index); - cx.notify(); - } - } else if this.hovered_entry_idx == Some(index) { - // Only clear if this row was the hovered one - this.hovered_entry_idx = None; - cx.notify(); - } - }) - .ok(); - }) - .on_click(move |event, window, cx| { - let click_count = event.click_count(); - weak.update(cx, |this, cx| { - this.select_entry(index, ScrollStrategy::Center, cx); - if click_count >= 2 { - this.open_commit_view(index, window, cx); - } - }) - .ok(); - }) - .into_any_element() - }) - .uniform_list( - "git-graph-commits", - commit_count, - cx.processor(Self::render_table_rows), + .child(render_table_header( + TableRow::from_vec( + vec![ + Label::new("Graph") + .color(Color::Muted) + .truncate() + .into_any_element(), + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date").color(Color::Muted).into_any_element(), + Label::new("Author").color(Color::Muted).into_any_element(), + Label::new("Commit").color(Color::Muted).into_any_element(), + ], + 5, ), - ) - }) + header_context, + Some(header_resize_info), + Some(self.column_widths.entity_id()), + cx, + )) + .child({ + let row_height = self.row_height; + let selected_entry_idx = self.selected_entry_idx; + let hovered_entry_idx = self.hovered_entry_idx; + let weak_self = cx.weak_entity(); + let focus_handle = self.focus_handle.clone(); + + bind_redistributable_columns( + div() + .relative() + .flex_1() + .w_full() + .overflow_hidden() + .child( + h_flex() + .size_full() + .child( + div() + .w(DefiniteLength::Fraction(graph_fraction)) + .h_full() + .min_w_0() + .overflow_hidden() + .child( + div() + .id("graph-canvas") + .size_full() + .overflow_hidden() + .child( + div() + .size_full() + .child(self.render_graph(window, cx)), + ) + .on_scroll_wheel( + cx.listener(Self::handle_graph_scroll), + ) + .on_mouse_move( + cx.listener(Self::handle_graph_mouse_move), + ) + .on_click(cx.listener(Self::handle_graph_click)) + .on_hover(cx.listener( + |this, &is_hovered: &bool, _, cx| { + if !is_hovered + && this.hovered_entry_idx.is_some() + { + this.hovered_entry_idx = None; + cx.notify(); + } + }, + )), + ), + ) + .child( + div() + .w(DefiniteLength::Fraction(table_fraction)) + .h_full() + .min_w_0() + .child( + Table::new(4) + .interactable(&self.table_interaction_state) + .hide_row_borders() + .hide_row_hover() + .width_config(table_width_config) + .map_row(move |(index, row), window, cx| { + let is_selected = + selected_entry_idx == Some(index); + let is_hovered = + hovered_entry_idx == Some(index); + let is_focused = + focus_handle.is_focused(window); + let weak = weak_self.clone(); + let weak_for_hover = weak.clone(); + + let hover_bg = cx + .theme() + .colors() + .element_hover + .opacity(0.6); + let selected_bg = if is_focused { + cx.theme().colors().element_selected + } else { + cx.theme().colors().element_hover + }; + + row.h(row_height) + .when(is_selected, |row| row.bg(selected_bg)) + .when( + is_hovered && !is_selected, + |row| row.bg(hover_bg), + ) + .on_hover(move |&is_hovered, _, cx| { + weak_for_hover + .update(cx, |this, cx| { + if is_hovered { + if this.hovered_entry_idx + != Some(index) + { + this.hovered_entry_idx = + Some(index); + cx.notify(); + } + } else if this + .hovered_entry_idx + == Some(index) + { + this.hovered_entry_idx = + None; + cx.notify(); + } + }) + .ok(); + }) + .on_click(move |event, window, cx| { + let click_count = event.click_count(); + weak.update(cx, |this, cx| { + this.select_entry( + index, + ScrollStrategy::Center, + cx, + ); + if click_count >= 2 { + this.open_commit_view( + index, + window, + cx, + ); + } + }) + .ok(); + }) + .into_any_element() + }) + .uniform_list( + "git-graph-commits", + commit_count, + cx.processor(Self::render_table_rows), + ), + ), + ), + ) + .child(render_redistributable_columns_resize_handles( + &self.column_widths, + window, + cx, + )), + self.column_widths.clone(), + ) + }), + ) .on_drag_move::(cx.listener(|this, event, window, cx| { this.commit_details_split_state.update(cx, |state, cx| { state.on_drag_move(event, window, cx); @@ -3734,9 +3878,11 @@ mod tests { }); cx.run_until_parked(); - git_graph.update_in(&mut *cx, |this, window, cx| { - this.render(window, cx); - }); + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); cx.run_until_parked(); let commit_count_after_switch_back = diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 68b1ff9beb7a8918ee3f5e1857e3cc68e15a3fc1..367d80d79c9af8722091e36c8e04bafb7ef0d8b5 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -29,6 +29,7 @@ mod notification; mod popover; mod popover_menu; mod progress; +mod redistributable_columns; mod right_click_menu; mod scrollbar; mod stack; @@ -73,6 +74,7 @@ pub use notification::*; pub use popover::*; pub use popover_menu::*; pub use progress::*; +pub use redistributable_columns::*; pub use right_click_menu::*; pub use scrollbar::*; pub use stack::*; diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 2012defc47d9cccea87849fa41470ad1183b552f..e5a14a3ddabc0d918bfe6d6bcb077e32adeb6eb4 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,19 +1,19 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, - Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful, - UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, + DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, + ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list, + transparent_black, uniform_list, }; -use itertools::intersperse_with; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, - ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, - InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, - ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, - StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex, - px, single_example, + ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, + Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, + RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, + StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar, + bind_redistributable_columns, div, example_group_with_title, h_flex, px, + render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -22,16 +22,10 @@ pub mod table_row; #[cfg(test)] mod tests; -const RESIZE_COLUMN_WIDTH: f32 = 8.0; -const RESIZE_DIVIDER_WIDTH: f32 = 1.0; - /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; -#[derive(Debug)] -pub(crate) struct DraggedColumn(pub(crate) usize); - struct UniformListData { render_list_of_rows_fn: Box, &mut Window, &mut App) -> Vec>>, @@ -113,124 +107,6 @@ impl TableInteractionState { } } -/// Renders invisible resize handles overlaid on top of table content. -/// -/// - Spacer: invisible element that matches the width of table column content -/// - Divider: contains the actual resize handle that users can drag to resize columns -/// -/// Structure: [spacer] [divider] [spacer] [divider] [spacer] -/// -/// Business logic: -/// 1. Creates spacers matching each column width -/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns) -/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize -/// 4. Returns an absolute-positioned overlay that sits on top of table content -fn render_resize_handles( - column_widths: &TableRow, - resizable_columns: &TableRow, - initial_sizes: &TableRow, - columns: Option>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let spacers = column_widths - .as_slice() - .iter() - .map(|width| base_cell_style(Some(*width)).into_any_element()); - - let mut column_ix = 0; - let resizable_columns_shared = Rc::new(resizable_columns.clone()); - let initial_sizes_shared = Rc::new(initial_sizes.clone()); - let mut resizable_columns_iter = resizable_columns.as_slice().iter(); - - let dividers = intersperse_with(spacers, || { - let resizable_columns = Rc::clone(&resizable_columns_shared); - let initial_sizes = Rc::clone(&initial_sizes_shared); - window.with_id(column_ix, |window| { - let mut resize_divider = div() - .id(column_ix) - .relative() - .top_0() - .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 resizable_columns_iter - .next() - .is_some_and(TableResizeBehavior::is_resizable) - { - let hovered = window.use_state(cx, |_window, _cx| false); - - resize_divider = resize_divider.when(*hovered.read(cx), |div| { - div.bg(cx.theme().colors().border_focused) - }); - - resize_handle = resize_handle - .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) - .cursor_col_resize() - .when_some(columns.clone(), |this, columns| { - this.on_click(move |event, window, cx| { - if event.click_count() >= 2 { - columns.update(cx, |columns, _| { - columns.on_double_click( - column_ix, - &initial_sizes, - &resizable_columns, - window, - ); - }) - } - - cx.stop_propagation(); - }) - }) - .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { - cx.new(|_cx| gpui::Empty) - }) - } - - column_ix += 1; - resize_divider.child(resize_handle).into_any_element() - }) - }); - - h_flex() - .id("resize-handles") - .absolute() - .inset_0() - .w_full() - .children(dividers) - .into_any_element() -} - -#[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 { - match self { - TableResizeBehavior::None => None, - TableResizeBehavior::Resizable => Some(0.05), - TableResizeBehavior::MinSize(min_size) => Some(*min_size), - } - } -} - pub enum ColumnWidthConfig { /// Static column widths (no resize handles). Static { @@ -278,6 +154,21 @@ impl ColumnWidthConfig { } } + /// Explicit column widths with no fixed table width. + pub fn explicit>(widths: Vec) -> Self { + let cols = widths.len(); + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Explicit( + widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols), + ), + table_width: None, + } + } + /// Column widths for rendering. pub fn widths_to_render(&self, cx: &App) -> Option> { match self { @@ -292,10 +183,7 @@ impl ColumnWidthConfig { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => { - let state = entity.read(cx); - Some(state.preview_widths.map_cloned(Length::Definite)) - } + } => Some(entity.read(cx).widths_to_render()), } } @@ -316,296 +204,6 @@ impl ColumnWidthConfig { None => ListHorizontalSizingBehavior::FitList, } } - - /// Render resize handles overlay if applicable. - pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option { - match self { - ColumnWidthConfig::Redistributable { - columns_state: entity, - .. - } => { - let (column_widths, resize_behavior, initial_widths) = { - let state = entity.read(cx); - ( - state.preview_widths.map_cloned(Length::Definite), - state.resize_behavior.clone(), - state.initial_widths.clone(), - ) - }; - Some(render_resize_handles( - &column_widths, - &resize_behavior, - &initial_widths, - Some(entity.clone()), - window, - cx, - )) - } - _ => None, - } - } - - /// Returns info needed for header double-click-to-reset, if applicable. - pub fn header_resize_info(&self, cx: &App) -> Option { - match self { - ColumnWidthConfig::Redistributable { columns_state, .. } => { - let state = columns_state.read(cx); - Some(HeaderResizeInfo { - columns_state: columns_state.downgrade(), - resize_behavior: state.resize_behavior.clone(), - initial_widths: state.initial_widths.clone(), - }) - } - _ => None, - } - } -} - -#[derive(Clone)] -pub struct HeaderResizeInfo { - pub columns_state: WeakEntity, - pub resize_behavior: TableRow, - pub initial_widths: TableRow, -} - -pub struct RedistributableColumnsState { - pub(crate) initial_widths: TableRow, - pub(crate) committed_widths: TableRow, - pub(crate) preview_widths: TableRow, - pub(crate) resize_behavior: TableRow, - pub(crate) cached_table_width: Pixels, -} - -impl RedistributableColumnsState { - pub fn new( - cols: usize, - initial_widths: UncheckedTableRow>, - resize_behavior: UncheckedTableRow, - ) -> Self { - let widths: TableRow = initial_widths - .into_iter() - .map(Into::into) - .collect::>() - .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_table_width: Default::default(), - } - } - - pub fn cols(&self) -> usize { - self.committed_widths.cols() - } - - pub fn initial_widths(&self) -> &TableRow { - &self.initial_widths - } - - pub fn resize_behavior(&self) -> &TableRow { - &self.resize_behavior - } - - 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 on_double_click( - &mut self, - double_click_position: usize, - initial_sizes: &TableRow, - resize_behavior: &TableRow, - window: &mut Window, - ) { - let bounds_width = self.cached_table_width; - let rem_size = window.rem_size(); - let initial_sizes = - initial_sizes.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( - double_click_position, - widths, - initial_sizes, - resize_behavior, - ); - self.committed_widths = updated_widths.map(DefiniteLength::Fraction); - self.preview_widths = self.committed_widths.clone(); - } - - pub(crate) fn reset_to_initial_size( - col_idx: usize, - mut widths: TableRow, - initial_sizes: TableRow, - resize_behavior: &TableRow, - ) -> TableRow { - let diff = initial_sizes[col_idx] - widths[col_idx]; - - let left_diff = - initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); - let right_diff = initial_sizes[col_idx + 1..].iter().sum::() - - widths[col_idx + 1..].iter().sum::(); - - 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 - } - - pub(crate) fn on_drag_move( - &mut self, - drag_event: &DragMoveEvent, - window: &mut Window, - cx: &mut Context, - ) { - let drag_position = drag_event.event.position; - let bounds = drag_event.bounds; - - let mut col_position = 0.0; - let rem_size = window.rem_size(); - let bounds_width = bounds.right() - bounds.left(); - let col_idx = drag_event.drag(cx).0; - - let divider_width = Self::get_fraction( - &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), - bounds_width, - rem_size, - ); - - let mut widths = self - .committed_widths - .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - - for length in widths[0..=col_idx].iter() { - col_position += length + divider_width; - } - - let mut total_length_ratio = col_position; - for length in widths[col_idx + 1..].iter() { - total_length_ratio += length; - } - let cols = self.resize_behavior.cols(); - total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; - - let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; - let drag_fraction = drag_fraction * total_length_ratio; - let diff = drag_fraction - col_position - divider_width / 2.0; - - Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); - - self.preview_widths = widths.map(DefiniteLength::Fraction); - } - - pub(crate) fn drag_column_handle( - diff: f32, - col_idx: usize, - widths: &mut TableRow, - resize_behavior: &TableRow, - ) { - 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, - resize_behavior: &TableRow, - 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 - } } /// A table component @@ -919,11 +517,8 @@ pub fn render_table_header( if event.click_count() > 1 { info.columns_state .update(cx, |column, _| { - column.on_double_click( - header_idx, - &info.initial_widths, - &info.resize_behavior, - window, + column.reset_column_to_initial_width( + header_idx, window, ); }) .ok(); @@ -962,6 +557,19 @@ impl TableRenderContext { disable_base_cell_style: table.disable_base_cell_style, } } + + pub fn for_column_widths(column_widths: Option>, use_ui_font: bool) -> Self { + Self { + striped: false, + show_row_borders: true, + show_row_hover: true, + total_row_count: 0, + column_widths, + map_row: None, + use_ui_font, + disable_base_cell_style: false, + } + } } impl RenderOnce for Table { @@ -969,9 +577,15 @@ impl RenderOnce for Table { let table_context = TableRenderContext::new(&self, cx); let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); - let header_resize_info = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.header_resize_info(cx)); + let header_resize_info = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => { + Some(HeaderResizeInfo::from_state(columns_state, cx)) + } + _ => None, + }); let table_width = self.column_width_config.table_width(); let horizontal_sizing = self.column_width_config.list_horizontal_sizing(); @@ -985,13 +599,19 @@ impl RenderOnce for Table { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => Some(entity.downgrade()), + } => Some(entity.clone()), _ => None, }); - let resize_handles = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.render_resize_handles(window, cx)); + let resize_handles = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => Some( + render_redistributable_columns_resize_handles(columns_state, window, cx), + ), + _ => None, + }); let table = div() .when_some(table_width, |this, width| this.w(width)) @@ -1006,38 +626,8 @@ impl RenderOnce for Table { cx, )) }) - .when_some(redistributable_entity, { - |this, widths| { - this.on_drag_move::({ - let widths = widths.clone(); - move |e, window, cx| { - widths - .update(cx, |widths, cx| { - widths.on_drag_move(e, window, cx); - }) - .ok(); - } - }) - .on_children_prepainted({ - let widths = widths.clone(); - move |bounds, _, cx| { - widths - .update(cx, |widths, _| { - // This works because all children x axis bounds are the same - widths.cached_table_width = - bounds[0].right() - bounds[0].left(); - }) - .ok(); - } - }) - .on_drop::(move |_, _, cx| { - widths - .update(cx, |widths, _| { - widths.committed_widths = widths.preview_widths.clone(); - }) - .ok(); - }) - } + .when_some(redistributable_entity, |this, widths| { + bind_redistributable_columns(this, widths) }) .child({ let content = div() diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs index 0936cd3088cc50bc08bf0a0a09d9a6fa7a2cdaf0..604e8b7cd1aabee85b406ec99d458c949eda599b 100644 --- a/crates/ui/src/components/data_table/tests.rs +++ b/crates/ui/src/components/data_table/tests.rs @@ -1,4 +1,5 @@ -use super::*; +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) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd22c31e19736e72e5d88676178053b49a3e65fd --- /dev/null +++ b/crates/ui/src/components/redistributable_columns.rs @@ -0,0 +1,485 @@ +use std::rc::Rc; + +use gpui::{ + AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length, + WeakEntity, +}; +use itertools::intersperse_with; + +use super::data_table::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, +}; + +const RESIZE_COLUMN_WIDTH: f32 = 8.0; +const RESIZE_DIVIDER_WIDTH: f32 = 1.0; + +#[derive(Debug)] +struct DraggedColumn(usize); + +#[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 { + match self { + TableResizeBehavior::None => None, + TableResizeBehavior::Resizable => Some(0.05), + TableResizeBehavior::MinSize(min_size) => Some(*min_size), + } + } +} + +#[derive(Clone)] +pub struct HeaderResizeInfo { + pub columns_state: WeakEntity, + pub resize_behavior: TableRow, +} + +impl HeaderResizeInfo { + pub fn from_state(columns_state: &Entity, cx: &App) -> Self { + let resize_behavior = columns_state.read(cx).resize_behavior().clone(); + Self { + columns_state: columns_state.downgrade(), + resize_behavior, + } + } +} + +pub struct RedistributableColumnsState { + pub(crate) initial_widths: TableRow, + pub(crate) committed_widths: TableRow, + pub(crate) preview_widths: TableRow, + pub(crate) resize_behavior: TableRow, + pub(crate) cached_container_width: Pixels, +} + +impl RedistributableColumnsState { + pub fn new( + cols: usize, + initial_widths: Vec>, + resize_behavior: Vec, + ) -> Self { + let widths: TableRow = initial_widths + .into_iter() + .map(Into::into) + .collect::>() + .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 { + &self.initial_widths + } + + pub fn preview_widths(&self) -> &TableRow { + &self.preview_widths + } + + pub fn resize_behavior(&self) -> &TableRow { + &self.resize_behavior + } + + pub fn widths_to_render(&self) -> TableRow { + self.preview_widths.map_cloned(Length::Definite) + } + + pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow { + 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 { + 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, + initial_sizes: TableRow, + resize_behavior: &TableRow, + ) -> TableRow { + let diff = initial_sizes[col_idx] - widths[col_idx]; + + let left_diff = + initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); + let right_diff = initial_sizes[col_idx + 1..].iter().sum::() + - widths[col_idx + 1..].iter().sum::(); + + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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 mut col_position = 0.0; + let rem_size = window.rem_size(); + let col_idx = drag_event.drag(cx).0; + + let divider_width = Self::get_fraction( + &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), + bounds_width, + rem_size, + ); + + let mut widths = self + .committed_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + + for length in widths[0..=col_idx].iter() { + col_position += length + divider_width; + } + + let mut total_length_ratio = col_position; + for length in widths[col_idx + 1..].iter() { + total_length_ratio += length; + } + let cols = self.resize_behavior.cols(); + total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; + + let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; + let drag_fraction = drag_fraction * total_length_ratio; + let diff = drag_fraction - col_position - divider_width / 2.0; + + Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); + + self.preview_widths = widths.map(DefiniteLength::Fraction); + } + + pub(crate) fn drag_column_handle( + diff: f32, + col_idx: usize, + widths: &mut TableRow, + resize_behavior: &TableRow, + ) { + 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, + resize_behavior: &TableRow, + 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 + } +} + +pub fn bind_redistributable_columns( + container: Div, + columns_state: Entity, +) -> Div { + container + .on_drag_move::({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + columns_state.update(cx, |columns, cx| { + columns.on_drag_move(event, 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::(move |_, _, cx| { + columns_state.update(cx, |columns, _| { + columns.commit_preview(); + }); + }) +} + +pub fn render_redistributable_columns_resize_handles( + columns_state: &Entity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let (column_widths, resize_behavior) = { + let state = columns_state.read(cx); + (state.widths_to_render(), state.resize_behavior().clone()) + }; + + let mut column_ix = 0; + let resize_behavior = Rc::new(resize_behavior); + let dividers = intersperse_with( + column_widths + .as_slice() + .iter() + .copied() + .map(|width| resize_spacer(width).into_any_element()), + || { + let current_column_ix = column_ix; + let resize_behavior = Rc::clone(&resize_behavior); + let columns_state = columns_state.clone(); + column_ix += 1; + + window.with_id(current_column_ix, |window| { + let mut resize_divider = div() + .id(current_column_ix) + .relative() + .top_0() + .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 resize_behavior[current_column_ix].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({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + if event.click_count() >= 2 { + columns_state.update(cx, |columns, _| { + columns.reset_column_to_initial_width( + current_column_ix, + window, + ); + }); + } + + cx.stop_propagation(); + } + }) + .on_drag(DraggedColumn(current_column_ix), { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }) + .on_drop::(move |_, _, cx| { + is_highlighted.write(cx, false); + columns_state.update(cx, |state, _| { + state.commit_preview(); + }); + }); + } + + resize_divider.child(resize_handle).into_any_element() + }) + }, + ); + + h_flex() + .id("resize-handles") + .absolute() + .inset_0() + .w_full() + .children(dividers) + .into_any_element() +} + +fn resize_spacer(width: Length) -> Div { + div().w(width).h_full() +} + +fn child_bounds_width(bounds: &[Bounds]) -> Option { + 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) +}