diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 02327045fdb2279597342a5d838d356d7b738c73..25f033469d7b00e1b351c7a0385b2de5bc10d9ad 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -45,3 +45,6 @@ ui_input.workspace = true util.workspace = true workspace-hack.workspace = true workspace.workspace = true + +[dev-dependencies] +db = {"workspace"= true, "features" = ["test-support"]} diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index 69207f559b89b83b6709bd41ab861e3a71be6616..65778c20eb4bc60f22dd15b634de433dc781cd97 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -2,9 +2,9 @@ use std::{ops::Range, rc::Rc, time::Duration}; use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide}; use gpui::{ - AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, FocusHandle, - Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, Stateful, Task, - UniformListScrollHandle, WeakEntity, transparent_black, uniform_list, + AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, EntityId, + FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, + Stateful, Task, UniformListScrollHandle, WeakEntity, transparent_black, uniform_list, }; use itertools::intersperse_with; @@ -13,10 +13,12 @@ use ui::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, - Scrollbar, ScrollbarState, StatefulInteractiveElement, Styled, StyledExt as _, + Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex, }; +const RESIZE_COLUMN_WIDTH: f32 = 5.0; + #[derive(Debug)] struct DraggedColumn(usize); @@ -227,7 +229,7 @@ impl TableInteractionState { .id("column-resize-handle") .absolute() .left_neg_0p5() - .w(px(5.0)) + .w(px(RESIZE_COLUMN_WIDTH)) .h_full(); if resizable_columns @@ -478,6 +480,7 @@ impl ResizeBehavior { pub struct ColumnWidths { widths: [DefiniteLength; COLS], + visible_widths: [DefiniteLength; COLS], cached_bounds_width: Pixels, initialized: bool, } @@ -486,6 +489,7 @@ impl ColumnWidths { pub fn new(_: &mut App) -> Self { Self { widths: [DefiniteLength::default(); COLS], + visible_widths: [DefiniteLength::default(); COLS], cached_bounds_width: Default::default(), initialized: false, } @@ -512,46 +516,105 @@ impl ColumnWidths { let rem_size = window.rem_size(); let initial_sizes = initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size)); - let mut widths = self + let widths = self .widths .map(|length| Self::get_fraction(&length, bounds_width, rem_size)); - let diff = initial_sizes[double_click_position] - widths[double_click_position]; + let updated_widths = Self::reset_to_initial_size( + double_click_position, + widths, + initial_sizes, + resize_behavior, + ); + self.widths = updated_widths.map(DefiniteLength::Fraction); + self.visible_widths = self.widths; + } - if diff > 0.0 { - let diff_remaining = self.propagate_resize_diff_right( - diff, - double_click_position, - &mut widths, - resize_behavior, - ); + fn reset_to_initial_size( + col_idx: usize, + mut widths: [f32; COLS], + initial_sizes: [f32; COLS], + resize_behavior: &[ResizeBehavior; COLS], + ) -> [f32; COLS] { + // RESET: + // Part 1: + // Figure out if we should shrink/grow the selected column + // Get diff which represents the change in column we want to make initial size delta curr_size = diff + // + // Part 2: We need to decide which side column we should move and where + // + // If we want to grow our column we should check the left/right columns diff to see what side + // has a greater delta than their initial size. Likewise, if we shrink our column we should check + // the left/right column diffs to see what side has the smallest delta. + // + // Part 3: resize + // + // col_idx represents the column handle to the right of an active column + // + // If growing and right has the greater delta { + // shift col_idx to the right + // } else if growing and left has the greater delta { + // shift col_idx - 1 to the left + // } else if shrinking and the right has the greater delta { + // shift + // } { + // + // } + // } + // + // if we need to shrink, then if the right + // + + // DRAGGING + // we get diff which represents the change in the _drag handle_ position + // -diff => dragging left -> + // grow the column to the right of the handle as much as we can shrink columns to the left of the handle + // +diff => dragging right -> growing handles column + // grow the column to the left of the handle as much as we can shrink columns to the right of the handle + // + + 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 && double_click_position > 0 { - self.propagate_resize_diff_left( - -diff_remaining, - double_click_position - 1, + if diff_remaining != 0.0 && col_idx > 0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, &mut widths, resize_behavior, + -1, ); } - } else if double_click_position > 0 { - let diff_remaining = self.propagate_resize_diff_left( - diff, - double_click_position, - &mut widths, - resize_behavior, - ); + } 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_right( - -diff_remaining, - double_click_position, + if diff_remaining != 0.0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, &mut widths, resize_behavior, + 1, ); } } - self.widths = widths.map(DefiniteLength::Fraction); + + widths } fn on_drag_move( @@ -569,98 +632,102 @@ impl ColumnWidths { let bounds_width = bounds.right() - bounds.left(); let col_idx = drag_event.drag(cx).0; + let column_handle_width = Self::get_fraction( + &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))), + bounds_width, + rem_size, + ); + let mut widths = self .widths .map(|length| Self::get_fraction(&length, bounds_width, rem_size)); for length in widths[0..=col_idx].iter() { - col_position += length; + col_position += length + column_handle_width; } let mut total_length_ratio = col_position; for length in widths[col_idx + 1..].iter() { total_length_ratio += length; } + total_length_ratio += (COLS - 1 - col_idx) as f32 * column_handle_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; + let diff = drag_fraction - col_position - column_handle_width / 2.0; - let is_dragging_right = diff > 0.0; + Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior); - if is_dragging_right { - self.propagate_resize_diff_right(diff, col_idx, &mut widths, resize_behavior); - } else { - // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space - self.propagate_resize_diff_left(diff, col_idx, &mut widths, resize_behavior); - } - self.widths = widths.map(DefiniteLength::Fraction); + self.visible_widths = widths.map(DefiniteLength::Fraction); } - fn propagate_resize_diff_right( - &self, + fn drag_column_handle( diff: f32, col_idx: usize, widths: &mut [f32; COLS], resize_behavior: &[ResizeBehavior; COLS], - ) -> f32 { - let mut diff_remaining = diff; - let mut curr_column = col_idx + 1; - - while diff_remaining > 0.0 && curr_column < COLS { - let Some(min_size) = resize_behavior[curr_column - 1].min_size() else { - curr_column += 1; - continue; - }; - - let mut curr_width = widths[curr_column] - diff_remaining; - - diff_remaining = 0.0; - if min_size > curr_width { - diff_remaining += min_size - curr_width; - curr_width = min_size; - } - widths[curr_column] = curr_width; - curr_column += 1; + ) { + // if diff > 0.0 then go right + 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); } - - widths[col_idx] = widths[col_idx] + (diff - diff_remaining); - return diff_remaining; } - fn propagate_resize_diff_left( - &mut self, + fn propagate_resize_diff( diff: f32, - mut curr_column: usize, + col_idx: usize, widths: &mut [f32; COLS], resize_behavior: &[ResizeBehavior; COLS], + direction: i8, ) -> f32 { let mut diff_remaining = diff; - let col_idx = curr_column; - while diff_remaining < 0.0 { + 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 < COLS { let Some(min_size) = resize_behavior[curr_column].min_size() else { if curr_column == 0 { break; } - curr_column -= 1; + curr_column -= step_left; + curr_column += step_right; continue; }; - let mut curr_width = widths[curr_column] + diff_remaining; + let curr_width = widths[curr_column] - diff_remaining; + widths[curr_column] = curr_width; - diff_remaining = 0.0; - if curr_width < min_size { - diff_remaining = curr_width - min_size; - curr_width = min_size + if min_size > curr_width { + diff_remaining = min_size - curr_width; + widths[curr_column] = min_size; + } else { + diff_remaining = 0.0; + break; } - - widths[curr_column] = curr_width; if curr_column == 0 { break; } - curr_column -= 1; + curr_column -= step_left; + curr_column += step_right; } - widths[col_idx + 1] = widths[col_idx + 1] - (diff - diff_remaining); + widths[col_idx] = widths[col_idx] + (diff - diff_remaining); return diff_remaining; } @@ -686,7 +753,7 @@ impl TableWidths { fn lengths(&self, cx: &App) -> [Length; COLS] { self.current .as_ref() - .map(|entity| entity.read(cx).widths.map(Length::Definite)) + .map(|entity| entity.read(cx).visible_widths.map(Length::Definite)) .unwrap_or(self.initial.map(Length::Definite)) } } @@ -799,6 +866,7 @@ impl Table { if !widths.initialized { widths.initialized = true; widths.widths = table_widths.initial; + widths.visible_widths = widths.widths; } }) } @@ -888,11 +956,24 @@ pub fn render_row( pub fn render_header( headers: [impl IntoElement; COLS], table_context: TableRenderContext, + columns_widths: Option<( + WeakEntity>, + [ResizeBehavior; COLS], + [DefiniteLength; COLS], + )>, + entity_id: Option, cx: &mut App, ) -> impl IntoElement { let column_widths = table_context .column_widths .map_or([None; COLS], |widths| widths.map(Some)); + + let element_id = entity_id + .map(|entity| entity.to_string()) + .unwrap_or_default(); + + let shared_element_id: SharedString = format!("table-{}", element_id).into(); + div() .flex() .flex_row() @@ -902,12 +983,39 @@ pub fn render_header( .p_2() .border_b_1() .border_color(cx.theme().colors().border) - .children( - headers - .into_iter() - .zip(column_widths) - .map(|(h, width)| base_cell_style_text(width, cx).child(h)), - ) + .children(headers.into_iter().enumerate().zip(column_widths).map( + |((header_idx, h), width)| { + base_cell_style_text(width, cx) + .child(h) + .id(ElementId::NamedInteger( + shared_element_id.clone(), + header_idx as u64, + )) + .when_some( + columns_widths.as_ref().cloned(), + |this, (column_widths, resizables, initial_sizes)| { + if resizables[header_idx].is_resizable() { + this.on_click(move |event, window, cx| { + if event.down.click_count > 1 { + column_widths + .update(cx, |column, _| { + column.on_double_click( + header_idx, + &initial_sizes, + &resizables, + window, + ); + }) + .ok(); + } + }) + } else { + this + } + }, + ) + }, + )) } #[derive(Clone)] @@ -939,6 +1047,12 @@ impl RenderOnce for Table { .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable))) .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior)); + let current_widths_with_initial_sizes = self + .col_widths + .as_ref() + .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial))) + .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial)); + let scroll_track_size = px(16.); let h_scroll_offset = if interaction_state .as_ref() @@ -958,7 +1072,13 @@ impl RenderOnce for Table { .h_full() .v_flex() .when_some(self.headers.take(), |this, headers| { - this.child(render_header(headers, table_context.clone(), cx)) + this.child(render_header( + headers, + table_context.clone(), + current_widths_with_initial_sizes, + interaction_state.as_ref().map(Entity::entity_id), + cx, + )) }) .when_some(current_widths, { |this, (widths, resize_behavior)| { @@ -972,19 +1092,28 @@ impl RenderOnce for Table { .ok(); } }) - .on_children_prepainted(move |bounds, _, cx| { + .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_bounds_width = + bounds[0].right() - bounds[0].left(); + }) + .ok(); + } + }) + .on_drop::(move |_, _, cx| { widths .update(cx, |widths, _| { - // This works because all children x axis bounds are the same - widths.cached_bounds_width = bounds[0].right() - bounds[0].left(); + widths.widths = widths.visible_widths; }) .ok(); + // Finish the resize operation }) } }) - .on_drop::(|_, _, _| { - // Finish the resize operation - }) .child( div() .flex_grow() @@ -1313,3 +1442,323 @@ impl Component for Table<3> { ) } } + +#[cfg(test)] +mod test { + use super::*; + + 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; COLS], total_size: f32) -> String { + cols.map(|f| "*".repeat(f32::round(f * total_size) as usize)) + .join("|") + } + + fn parse_resize_behavior( + input: &str, + total_size: f32, + ) -> [ResizeBehavior; COLS] { + let mut resize_behavior = [ResizeBehavior::None; COLS]; + let mut max_index = 0; + for (index, col) in input.split('|').enumerate() { + if col.starts_with('X') || col.is_empty() { + resize_behavior[index] = ResizeBehavior::None; + } else if col.starts_with('*') { + resize_behavior[index] = ResizeBehavior::MinSize(col.len() as f32 / total_size); + } else { + panic!("invalid test input: unrecognized resize behavior: {}", col); + } + max_index = index; + } + + if max_index + 1 != COLS { + panic!("invalid test input: too many columns"); + } + resize_behavior + } + + mod reset_column_size { + use super::*; + + fn parse(input: &str) -> ([f32; COLS], f32, Option) { + let mut widths = [f32::NAN; COLS]; + let mut column_index = None; + for (index, col) in input.split('|').enumerate() { + widths[index] = 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::(); + 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 resize_behavior = parse_resize_behavior::(resize_behavior, total_1); + let result = ColumnWidths::reset_to_initial_size( + column_index, + widths, + initial_sizes, + &resize_behavior, + ); + let is_eq = is_almost_eq(&result, &expected); + if !is_eq { + let result_str = cols_to_str(&result, total_1); + let expected_str = cols_to_str(&expected, total_1); + panic!( + "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\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::<$cols>($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::<$cols>($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) -> ([f32; COLS], f32, Option) { + let mut widths = [f32::NAN; COLS]; + let column_index = input.replace("*", "").find("I"); + for (index, col) in input.replace("I", "|").split('|').enumerate() { + widths[index] = col.len() as f32; + } + + for w in widths { + assert!(w.is_finite(), "incorrect number of columns"); + } + let total = widths.iter().sum::(); + 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 (mut 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 resize_behavior = parse_resize_behavior::(resize_behavior, total_1); + + let distance = distance as f32 / total_1; + + let result = ColumnWidths::drag_column_handle( + distance, + column_index, + &mut widths, + &resize_behavior, + ); + + let is_eq = is_almost_eq(&widths, &expected); + if !is_eq { + let result_str = cols_to_str(&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:?}\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!($cols, $dist, $snapshot, $expected, $resizing); + }; + ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { + #[test] + fn $name() { + check::<$cols>($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|*|*|*|*", + ); + } +}