diff --git a/Cargo.lock b/Cargo.lock index 7c73ec0cff..4e86627d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8515,6 +8515,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "smallvec", "theme", "ui", "util", diff --git a/assets/settings/default.json b/assets/settings/default.json index 8da7abe18f..32f46ce714 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -346,6 +346,8 @@ "git_status": true, // Amount of indentation for nested items. "indent_size": 20, + // Whether to show indent guides in the project panel. + "indent_guides": true, // Whether to reveal it in the project panel automatically, // when a corresponding project entry becomes active. // Gitignored entries are never auto revealed. diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index b6fcf91e53..9ce85aab23 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -48,6 +48,7 @@ where item_count, item_to_measure_index: 0, render_items: Box::new(render_range), + decorations: Vec::new(), interactivity: Interactivity { element_id: Some(id), base_style: Box::new(base_style), @@ -69,6 +70,7 @@ pub struct UniformList { item_to_measure_index: usize, render_items: Box Fn(Range, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>, + decorations: Vec>, interactivity: Interactivity, scroll_handle: Option, sizing_behavior: ListSizingBehavior, @@ -78,6 +80,7 @@ pub struct UniformList { /// Frame state used by the [UniformList]. pub struct UniformListFrameState { items: SmallVec<[AnyElement; 32]>, + decorations: SmallVec<[AnyElement; 1]>, } /// A handle for controlling the scroll position of a uniform list. @@ -185,6 +188,7 @@ impl Element for UniformList { layout_id, UniformListFrameState { items: SmallVec::new(), + decorations: SmallVec::new(), }, ) } @@ -292,9 +296,10 @@ impl Element for UniformList { ..cmp::min(last_visible_element_ix, self.item_count); let mut items = (self.render_items)(visible_range.clone(), cx); + let content_mask = ContentMask { bounds }; cx.with_content_mask(Some(content_mask), |cx| { - for (mut item, ix) in items.into_iter().zip(visible_range) { + for (mut item, ix) in items.into_iter().zip(visible_range.clone()) { let item_origin = padded_bounds.origin + point( if can_scroll_horizontally { @@ -317,6 +322,34 @@ impl Element for UniformList { item.prepaint_at(item_origin, cx); frame_state.items.push(item); } + + let bounds = Bounds::new( + padded_bounds.origin + + point( + if can_scroll_horizontally { + scroll_offset.x + padding.left + } else { + scroll_offset.x + }, + scroll_offset.y + padding.top, + ), + padded_bounds.size, + ); + for decoration in &self.decorations { + let mut decoration = decoration.as_ref().compute( + visible_range.clone(), + bounds, + item_height, + cx, + ); + let available_space = size( + AvailableSpace::Definite(bounds.size.width), + AvailableSpace::Definite(bounds.size.height), + ); + decoration.layout_as_root(available_space, cx); + decoration.prepaint_at(bounds.origin, cx); + frame_state.decorations.push(decoration); + } }); } @@ -338,6 +371,9 @@ impl Element for UniformList { for item in &mut request_layout.items { item.paint(cx); } + for decoration in &mut request_layout.decorations { + decoration.paint(cx); + } }) } } @@ -350,6 +386,20 @@ impl IntoElement for UniformList { } } +/// A decoration for a [`UniformList`]. This can be used for various things, +/// such as rendering indent guides, or other visual effects. +pub trait UniformListDecoration { + /// Compute the decoration element, given the visible range of list items, + /// the bounds of the list, and the height of each item. + fn compute( + &self, + visible_range: Range, + bounds: Bounds, + item_height: Pixels, + cx: &mut WindowContext, + ) -> AnyElement; +} + impl UniformList { /// Selects a specific list item for measurement. pub fn with_width_from_item(mut self, item_index: Option) -> Self { @@ -382,6 +432,12 @@ impl UniformList { self } + /// Adds a decoration element to the list. + pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self { + self.decorations.push(Box::new(decoration)); + self + } + fn measure_item(&self, list_width: Option, cx: &mut WindowContext) -> Size { if self.item_count == 0 { return Size::default(); diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 11c7364e58..23241a0f88 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -30,6 +30,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +smallvec.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index bbd1664b9d..0f503c696b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -16,12 +16,13 @@ use anyhow::{anyhow, Context as _, Result}; use collections::{hash_map, BTreeSet, HashMap}; use git::repository::GitFileStatus; use gpui::{ - actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement, - AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent, - EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext, - ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent, - ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task, - UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext, + actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, + AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, + Div, DragMoveEvent, EventEmitter, ExternalPaths, FocusHandle, FocusableView, + InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, + MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, + Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, + WeakView, WindowContext, }; use indexmap::IndexMap; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; @@ -31,6 +32,7 @@ use project::{ }; use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings}; use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; use std::{ cell::OnceCell, collections::HashSet, @@ -41,7 +43,10 @@ use std::{ time::Duration, }; use theme::ThemeSettings; -use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip}; +use ui::{ + prelude::*, v_flex, ContextMenu, Icon, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, + ListItem, Tooltip, +}; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -654,42 +659,52 @@ impl ProjectPanel { } fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext) { - if let Some((worktree, mut entry)) = self.selected_entry(cx) { - if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) { - if folded_ancestors.current_ancestor_depth + 1 - < folded_ancestors.max_ancestor_depth() - { - folded_ancestors.current_ancestor_depth += 1; - cx.notify(); - return; - } - } - let worktree_id = worktree.id(); - let expanded_dir_ids = - if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) { - expanded_dir_ids - } else { - return; - }; + let Some((worktree, entry)) = self.selected_entry_handle(cx) else { + return; + }; + self.collapse_entry(entry.clone(), worktree, cx) + } - loop { - let entry_id = entry.id; - match expanded_dir_ids.binary_search(&entry_id) { - Ok(ix) => { - expanded_dir_ids.remove(ix); - self.update_visible_entries(Some((worktree_id, entry_id)), cx); - cx.notify(); + fn collapse_entry( + &mut self, + entry: Entry, + worktree: Model, + cx: &mut ViewContext, + ) { + let worktree = worktree.read(cx); + if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) { + if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() { + folded_ancestors.current_ancestor_depth += 1; + cx.notify(); + return; + } + } + let worktree_id = worktree.id(); + let expanded_dir_ids = + if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) { + expanded_dir_ids + } else { + return; + }; + + let mut entry = &entry; + loop { + let entry_id = entry.id; + match expanded_dir_ids.binary_search(&entry_id) { + Ok(ix) => { + expanded_dir_ids.remove(ix); + self.update_visible_entries(Some((worktree_id, entry_id)), cx); + cx.notify(); + break; + } + Err(_) => { + if let Some(parent_entry) = + entry.path.parent().and_then(|p| worktree.entry_for_path(p)) + { + entry = parent_entry; + } else { break; } - Err(_) => { - if let Some(parent_entry) = - entry.path.parent().and_then(|p| worktree.entry_for_path(p)) - { - entry = parent_entry; - } else { - break; - } - } } } } @@ -1727,6 +1742,7 @@ impl ProjectPanel { .copied() .unwrap_or(id) } + pub fn selected_entry<'a>( &self, cx: &'a AppContext, @@ -2144,6 +2160,74 @@ impl ProjectPanel { } } + fn index_for_entry( + &self, + entry_id: ProjectEntryId, + worktree_id: WorktreeId, + ) -> Option<(usize, usize, usize)> { + let mut worktree_ix = 0; + let mut total_ix = 0; + for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries { + if worktree_id != *current_worktree_id { + total_ix += visible_worktree_entries.len(); + worktree_ix += 1; + continue; + } + + return visible_worktree_entries + .iter() + .enumerate() + .find(|(_, entry)| entry.id == entry_id) + .map(|(ix, _)| (worktree_ix, ix, total_ix + ix)); + } + None + } + + fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, &Entry)> { + let mut offset = 0; + for (worktree_id, visible_worktree_entries, _) in &self.visible_entries { + if visible_worktree_entries.len() > offset + index { + return visible_worktree_entries + .get(index) + .map(|entry| (*worktree_id, entry)); + } + offset += visible_worktree_entries.len(); + } + None + } + + fn iter_visible_entries( + &self, + range: Range, + cx: &mut ViewContext, + mut callback: impl FnMut(&Entry, &HashSet>, &mut ViewContext), + ) { + let mut ix = 0; + for (_, visible_worktree_entries, entries_paths) in &self.visible_entries { + if ix >= range.end { + return; + } + + if ix + visible_worktree_entries.len() <= range.start { + ix += visible_worktree_entries.len(); + continue; + } + + let end_ix = range.end.min(ix + visible_worktree_entries.len()); + let entry_range = range.start.saturating_sub(ix)..end_ix - ix; + let entries = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| (e.path.clone())) + .collect() + }); + for entry in visible_worktree_entries[entry_range].iter() { + callback(entry, entries, cx); + } + ix = end_ix; + } + } + fn for_each_visible_entry( &self, range: Range, @@ -2816,6 +2900,70 @@ impl ProjectPanel { cx.notify(); } } + + fn find_active_indent_guide( + &self, + indent_guides: &[IndentGuideLayout], + cx: &AppContext, + ) -> Option { + let (worktree, entry) = self.selected_entry(cx)?; + + // Find the parent entry of the indent guide, this will either be the + // expanded folder we have selected, or the parent of the currently + // selected file/collapsed directory + let mut entry = entry; + loop { + let is_expanded_dir = entry.is_dir() + && self + .expanded_dir_ids + .get(&worktree.id()) + .map(|ids| ids.binary_search(&entry.id).is_ok()) + .unwrap_or(false); + if is_expanded_dir { + break; + } + entry = worktree.entry_for_path(&entry.path.parent()?)?; + } + + let (active_indent_range, depth) = { + let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?; + let child_paths = &self.visible_entries[worktree_ix].1; + let mut child_count = 0; + let depth = entry.path.ancestors().count(); + while let Some(entry) = child_paths.get(child_offset + child_count + 1) { + if entry.path.ancestors().count() <= depth { + break; + } + child_count += 1; + } + + let start = ix + 1; + let end = start + child_count; + + let (_, entries, paths) = &self.visible_entries[worktree_ix]; + let visible_worktree_entries = + paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect()); + + // Calculate the actual depth of the entry, taking into account that directories can be auto-folded. + let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries); + (start..end, depth) + }; + + let candidates = indent_guides + .iter() + .enumerate() + .filter(|(_, indent_guide)| indent_guide.offset.x == depth); + + for (i, indent) in candidates { + // Find matches that are either an exact match, partially on screen, or inside the enclosing indent + if active_indent_range.start <= indent.offset.y + indent.length + && indent.offset.y <= active_indent_range.end + { + return Some(i); + } + } + None + } } fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize { @@ -2831,6 +2979,8 @@ impl Render for ProjectPanel { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { let has_worktree = !self.visible_entries.is_empty(); let project = self.project.read(cx); + let indent_size = ProjectPanelSettings::get_global(cx).indent_size; + let indent_guides = ProjectPanelSettings::get_global(cx).indent_guides; let is_local = project.is_local(); if has_worktree { @@ -2934,6 +3084,103 @@ impl Render for ProjectPanel { items } }) + .when(indent_guides, |list| { + list.with_decoration( + ui::indent_guides( + cx.view().clone(), + px(indent_size), + IndentGuideColors::panel(cx), + |this, range, cx| { + let mut items = + SmallVec::with_capacity(range.end - range.start); + this.iter_visible_entries(range, cx, |entry, entries, _| { + let (depth, _) = + Self::calculate_depth_and_difference(entry, entries); + items.push(depth); + }); + items + }, + ) + .on_click(cx.listener( + |this, active_indent_guide: &IndentGuideLayout, cx| { + if cx.modifiers().secondary() { + let ix = active_indent_guide.offset.y; + let Some((target_entry, worktree)) = maybe!({ + let (worktree_id, entry) = this.entry_at_index(ix)?; + let worktree = this + .project + .read(cx) + .worktree_for_id(worktree_id, cx)?; + let target_entry = worktree + .read(cx) + .entry_for_path(&entry.path.parent()?)?; + Some((target_entry, worktree)) + }) else { + return; + }; + + this.collapse_entry(target_entry.clone(), worktree, cx); + } + }, + )) + .with_render_fn( + cx.view().clone(), + move |this, params, cx| { + const LEFT_OFFSET: f32 = 14.; + const PADDING_Y: f32 = 4.; + const HITBOX_OVERDRAW: f32 = 3.; + + let active_indent_guide_index = + this.find_active_indent_guide(¶ms.indent_guides, cx); + + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .enumerate() + .map(|(idx, layout)| { + let offset = if layout.continues_offscreen { + px(0.) + } else { + px(PADDING_Y) + }; + let bounds = Bounds::new( + point( + px(layout.offset.x as f32) * indent_size + + px(LEFT_OFFSET), + px(layout.offset.y as f32) * item_height + + offset, + ), + size( + px(1.), + px(layout.length as f32) * item_height + - px(offset.0 * 2.), + ), + ); + ui::RenderedIndentGuide { + bounds, + layout, + is_active: Some(idx) == active_indent_guide_index, + hitbox: Some(Bounds::new( + point( + bounds.origin.x - px(HITBOX_OVERDRAW), + bounds.origin.y, + ), + size( + bounds.size.width + + px(2. * HITBOX_OVERDRAW), + bounds.size.height, + ), + )), + } + }) + .collect() + }, + ), + ) + }) .size_full() .with_sizing_behavior(ListSizingBehavior::Infer) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 0114b3968d..16980c00d1 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -20,6 +20,7 @@ pub struct ProjectPanelSettings { pub folder_icons: bool, pub git_status: bool, pub indent_size: f32, + pub indent_guides: bool, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, @@ -71,6 +72,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: 20 pub indent_size: Option, + /// Whether to show indent guides in the project panel. + /// + /// Default: true + pub indent_guides: Option, /// Whether to reveal it in the project panel automatically, /// when a corresponding project entry becomes active. /// Gitignored entries are never auto revealed. diff --git a/crates/storybook/src/stories/indent_guides.rs b/crates/storybook/src/stories/indent_guides.rs new file mode 100644 index 0000000000..cd4d9d7f58 --- /dev/null +++ b/crates/storybook/src/stories/indent_guides.rs @@ -0,0 +1,83 @@ +use std::fmt::format; + +use gpui::{ + colors, div, prelude::*, uniform_list, DefaultColor, DefaultThemeAppearance, Hsla, Render, + View, ViewContext, WindowContext, +}; +use story::Story; +use strum::IntoEnumIterator; +use ui::{ + h_flex, px, v_flex, AbsoluteLength, ActiveTheme, Color, DefiniteLength, Label, LabelCommon, +}; + +const LENGTH: usize = 100; + +pub struct IndentGuidesStory { + depths: Vec, +} + +impl IndentGuidesStory { + pub fn view(cx: &mut WindowContext) -> View { + let mut depths = Vec::new(); + depths.push(0); + depths.push(1); + depths.push(2); + for _ in 0..LENGTH - 6 { + depths.push(3); + } + depths.push(2); + depths.push(1); + depths.push(0); + + cx.new_view(|_cx| Self { depths }) + } +} + +impl Render for IndentGuidesStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + Story::container() + .child(Story::title("Indent guides")) + .child( + v_flex().size_full().child( + uniform_list( + cx.view().clone(), + "some-list", + self.depths.len(), + |this, range, cx| { + this.depths + .iter() + .enumerate() + .skip(range.start) + .take(range.end - range.start) + .map(|(i, depth)| { + div() + .pl(DefiniteLength::Absolute(AbsoluteLength::Pixels(px( + 16. * (*depth as f32), + )))) + .child(Label::new(format!("Item {}", i)).color(Color::Info)) + }) + .collect() + }, + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Infer) + .with_decoration(ui::indent_guides( + cx.view().clone(), + px(16.), + ui::IndentGuideColors { + default: Color::Info.color(cx), + hovered: Color::Accent.color(cx), + active: Color::Accent.color(cx), + }, + |this, range, cx| { + this.depths + .iter() + .skip(range.start) + .take(range.end - range.start) + .cloned() + .collect() + }, + )), + ), + ) + } +} diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 49c216c0e0..05dd6cd1e7 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -59,6 +59,9 @@ impl ThemeColors { search_match_background: neutral().light().step_5(), panel_background: neutral().light().step_2(), panel_focused_border: blue().light().step_5(), + panel_indent_guide: neutral().light_alpha().step_5(), + panel_indent_guide_hover: neutral().light_alpha().step_6(), + panel_indent_guide_active: neutral().light_alpha().step_6(), pane_focused_border: blue().light().step_5(), pane_group_border: neutral().light().step_6(), scrollbar_thumb_background: neutral().light_alpha().step_3(), @@ -162,6 +165,9 @@ impl ThemeColors { search_match_background: neutral().dark().step_5(), panel_background: neutral().dark().step_2(), panel_focused_border: blue().dark().step_5(), + panel_indent_guide: neutral().dark_alpha().step_4(), + panel_indent_guide_hover: neutral().dark_alpha().step_6(), + panel_indent_guide_active: neutral().dark_alpha().step_6(), pane_focused_border: blue().dark().step_5(), pane_group_border: neutral().dark().step_6(), scrollbar_thumb_background: neutral().dark_alpha().step_3(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 553c756233..9f665ea965 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -136,6 +136,9 @@ pub(crate) fn zed_default_dark() -> Theme { terminal_ansi_dim_white: crate::neutral().dark().step_10(), panel_background: bg, panel_focused_border: blue, + panel_indent_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.), + panel_indent_guide_hover: hsla(225. / 360., 13. / 100., 12. / 100., 1.), + panel_indent_guide_active: hsla(225. / 360., 13. / 100., 12. / 100., 1.), pane_focused_border: blue, pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.), scrollbar_thumb_background: gpui::transparent_black(), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index af334d8aed..88e24f08ff 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -322,6 +322,15 @@ pub struct ThemeColorsContent { #[serde(rename = "panel.focused_border")] pub panel_focused_border: Option, + #[serde(rename = "panel.indent_guide")] + pub panel_indent_guide: Option, + + #[serde(rename = "panel.indent_guide_hover")] + pub panel_indent_guide_hover: Option, + + #[serde(rename = "panel.indent_guide_active")] + pub panel_indent_guide_active: Option, + #[serde(rename = "pane.focused_border")] pub pane_focused_border: Option, @@ -710,6 +719,18 @@ impl ThemeColorsContent { .panel_focused_border .as_ref() .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide: self + .panel_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_hover: self + .panel_indent_guide_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_active: self + .panel_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), pane_focused_border: self .pane_focused_border .as_ref() diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 881a68334d..485a8e4b9e 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -123,6 +123,9 @@ pub struct ThemeColors { pub search_match_background: Hsla, pub panel_background: Hsla, pub panel_focused_border: Hsla, + pub panel_indent_guide: Hsla, + pub panel_indent_guide_hover: Hsla, + pub panel_indent_guide_active: Hsla, pub pane_focused_border: Hsla, pub pane_group_border: Hsla, /// The color of the scrollbar thumb. diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 98d103e163..7a13ff6917 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -8,6 +8,7 @@ mod dropdown_menu; mod facepile; mod icon; mod image; +mod indent_guides; mod indicator; mod keybinding; mod label; @@ -40,6 +41,7 @@ pub use dropdown_menu::*; pub use facepile::*; pub use icon::*; pub use image::*; +pub use indent_guides::*; pub use indicator::*; pub use keybinding::*; pub use label::*; diff --git a/crates/ui/src/components/indent_guides.rs b/crates/ui/src/components/indent_guides.rs new file mode 100644 index 0000000000..e45404429c --- /dev/null +++ b/crates/ui/src/components/indent_guides.rs @@ -0,0 +1,504 @@ +#![allow(missing_docs)] +use std::{cmp::Ordering, ops::Range, rc::Rc}; + +use gpui::{ + fill, point, size, AnyElement, AppContext, Bounds, Hsla, Point, UniformListDecoration, View, +}; +use smallvec::SmallVec; + +use crate::prelude::*; + +/// Represents the colors used for different states of indent guides. +#[derive(Debug, Clone)] +pub struct IndentGuideColors { + /// The color of the indent guide when it's neither active nor hovered. + pub default: Hsla, + /// The color of the indent guide when it's hovered. + pub hover: Hsla, + /// The color of the indent guide when it's active. + pub active: Hsla, +} + +impl IndentGuideColors { + /// Returns the indent guide colors that should be used for panels. + pub fn panel(cx: &AppContext) -> Self { + Self { + default: cx.theme().colors().panel_indent_guide, + hover: cx.theme().colors().panel_indent_guide_hover, + active: cx.theme().colors().panel_indent_guide_active, + } + } +} + +pub struct IndentGuides { + colors: IndentGuideColors, + indent_size: Pixels, + compute_indents_fn: Box, &mut WindowContext) -> SmallVec<[usize; 64]>>, + render_fn: Option< + Box< + dyn Fn( + RenderIndentGuideParams, + &mut WindowContext, + ) -> SmallVec<[RenderedIndentGuide; 12]>, + >, + >, + on_click: Option>, +} + +pub fn indent_guides( + view: View, + indent_size: Pixels, + colors: IndentGuideColors, + compute_indents_fn: impl Fn(&mut V, Range, &mut ViewContext) -> SmallVec<[usize; 64]> + + 'static, +) -> IndentGuides { + let compute_indents_fn = Box::new(move |range, cx: &mut WindowContext| { + view.update(cx, |this, cx| compute_indents_fn(this, range, cx)) + }); + IndentGuides { + colors, + indent_size, + compute_indents_fn, + render_fn: None, + on_click: None, + } +} + +impl IndentGuides { + /// Sets the callback that will be called when the user clicks on an indent guide. + pub fn on_click( + mut self, + on_click: impl Fn(&IndentGuideLayout, &mut WindowContext) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(on_click)); + self + } + + /// Sets a custom callback that will be called when the indent guides need to be rendered. + pub fn with_render_fn( + mut self, + view: View, + render_fn: impl Fn( + &mut V, + RenderIndentGuideParams, + &mut WindowContext, + ) -> SmallVec<[RenderedIndentGuide; 12]> + + 'static, + ) -> Self { + let render_fn = move |params, cx: &mut WindowContext| { + view.update(cx, |this, cx| render_fn(this, params, cx)) + }; + self.render_fn = Some(Box::new(render_fn)); + self + } +} + +/// Parameters for rendering indent guides. +pub struct RenderIndentGuideParams { + /// The calculated layouts for the indent guides to be rendered. + pub indent_guides: SmallVec<[IndentGuideLayout; 12]>, + /// The size of each indentation level in pixels. + pub indent_size: Pixels, + /// The height of each item in pixels. + pub item_height: Pixels, +} + +/// Represents a rendered indent guide with its visual properties and interaction areas. +pub struct RenderedIndentGuide { + /// The bounds of the rendered indent guide in pixels. + pub bounds: Bounds, + /// The layout information for the indent guide. + pub layout: IndentGuideLayout, + /// Indicates whether the indent guide is currently active. + pub is_active: bool, + /// Can be used to customize the hitbox of the indent guide, + /// if this is set to `None`, the bounds of the indent guide will be used. + pub hitbox: Option>, +} + +/// Represents the layout information for an indent guide. +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct IndentGuideLayout { + /// The starting position of the indent guide, where x is the indentation level + /// and y is the starting row. + pub offset: Point, + /// The length of the indent guide in rows. + pub length: usize, + /// Indicates whether the indent guide continues beyond the visible bounds. + pub continues_offscreen: bool, +} + +/// Implements the necessary functionality for rendering indent guides inside a uniform list. +mod uniform_list { + use gpui::{DispatchPhase, Hitbox, MouseButton, MouseDownEvent, MouseMoveEvent}; + + use super::*; + + impl UniformListDecoration for IndentGuides { + fn compute( + &self, + visible_range: Range, + bounds: Bounds, + item_height: Pixels, + cx: &mut WindowContext, + ) -> AnyElement { + let mut visible_range = visible_range.clone(); + visible_range.end += 1; + let visible_entries = &(self.compute_indents_fn)(visible_range.clone(), cx); + // Check if we have an additional indent that is outside of the visible range + let includes_trailing_indent = visible_entries.len() == visible_range.len(); + let indent_guides = compute_indent_guides( + &visible_entries, + visible_range.start, + includes_trailing_indent, + ); + let mut indent_guides = if let Some(ref custom_render) = self.render_fn { + let params = RenderIndentGuideParams { + indent_guides, + indent_size: self.indent_size, + item_height, + }; + custom_render(params, cx) + } else { + indent_guides + .into_iter() + .map(|layout| RenderedIndentGuide { + bounds: Bounds::new( + point( + px(layout.offset.x as f32) * self.indent_size, + px(layout.offset.y as f32) * item_height, + ), + size(px(1.), px(layout.length as f32) * item_height), + ), + layout, + is_active: false, + hitbox: None, + }) + .collect() + }; + for guide in &mut indent_guides { + guide.bounds.origin += bounds.origin; + if let Some(hitbox) = guide.hitbox.as_mut() { + hitbox.origin += bounds.origin; + } + } + + let indent_guides = IndentGuidesElement { + indent_guides: Rc::new(indent_guides), + colors: self.colors.clone(), + on_hovered_indent_guide_click: self.on_click.clone(), + }; + indent_guides.into_any_element() + } + } + + struct IndentGuidesElement { + colors: IndentGuideColors, + indent_guides: Rc>, + on_hovered_indent_guide_click: Option>, + } + + struct IndentGuidesElementPrepaintState { + hitboxes: SmallVec<[Hitbox; 12]>, + } + + impl Element for IndentGuidesElement { + type RequestLayoutState = (); + type PrepaintState = IndentGuidesElementPrepaintState; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (cx.request_layout(gpui::Style::default(), []), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { + let mut hitboxes = SmallVec::new(); + for guide in self.indent_guides.as_ref().iter() { + hitboxes.push(cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false)); + } + Self::PrepaintState { hitboxes } + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + let callback = self.on_hovered_indent_guide_click.clone(); + if let Some(callback) = callback { + cx.on_mouse_event({ + let hitboxes = prepaint.hitboxes.clone(); + let indent_guides = self.indent_guides.clone(); + move |event: &MouseDownEvent, phase, cx| { + if phase == DispatchPhase::Bubble && event.button == MouseButton::Left { + let mut active_hitbox_ix = None; + for (i, hitbox) in hitboxes.iter().enumerate() { + if hitbox.is_hovered(cx) { + active_hitbox_ix = Some(i); + break; + } + } + + let Some(active_hitbox_ix) = active_hitbox_ix else { + return; + }; + + let active_indent_guide = &indent_guides[active_hitbox_ix].layout; + callback(active_indent_guide, cx); + + cx.stop_propagation(); + cx.prevent_default(); + } + } + }); + } + + let mut hovered_hitbox_id = None; + for (i, hitbox) in prepaint.hitboxes.iter().enumerate() { + cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox); + let indent_guide = &self.indent_guides[i]; + let fill_color = if hitbox.is_hovered(cx) { + hovered_hitbox_id = Some(hitbox.id); + self.colors.hover + } else if indent_guide.is_active { + self.colors.active + } else { + self.colors.default + }; + + cx.paint_quad(fill(indent_guide.bounds, fill_color)); + } + + cx.on_mouse_event({ + let prev_hovered_hitbox_id = hovered_hitbox_id; + let hitboxes = prepaint.hitboxes.clone(); + move |_: &MouseMoveEvent, phase, cx| { + let mut hovered_hitbox_id = None; + for hitbox in &hitboxes { + if hitbox.is_hovered(cx) { + hovered_hitbox_id = Some(hitbox.id); + break; + } + } + if phase == DispatchPhase::Capture { + // If the hovered hitbox has changed, we need to re-paint the indent guides. + match (prev_hovered_hitbox_id, hovered_hitbox_id) { + (Some(prev_id), Some(id)) => { + if prev_id != id { + cx.refresh(); + } + } + (None, Some(_)) => { + cx.refresh(); + } + (Some(_), None) => { + cx.refresh(); + } + (None, None) => {} + } + } + } + }); + } + } + + impl IntoElement for IndentGuidesElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } +} + +fn compute_indent_guides( + indents: &[usize], + offset: usize, + includes_trailing_indent: bool, +) -> SmallVec<[IndentGuideLayout; 12]> { + let mut indent_guides = SmallVec::<[IndentGuideLayout; 12]>::new(); + let mut indent_stack = SmallVec::<[IndentGuideLayout; 8]>::new(); + + let mut min_depth = usize::MAX; + for (row, &depth) in indents.iter().enumerate() { + if includes_trailing_indent && row == indents.len() - 1 { + continue; + } + + let current_row = row + offset; + let current_depth = indent_stack.len(); + if depth < min_depth { + min_depth = depth; + } + + match depth.cmp(¤t_depth) { + Ordering::Less => { + for _ in 0..(current_depth - depth) { + if let Some(guide) = indent_stack.pop() { + indent_guides.push(guide); + } + } + } + Ordering::Greater => { + for new_depth in current_depth..depth { + indent_stack.push(IndentGuideLayout { + offset: Point::new(new_depth, current_row), + length: current_row, + continues_offscreen: false, + }); + } + } + _ => {} + } + + for indent in indent_stack.iter_mut() { + indent.length = current_row - indent.offset.y + 1; + } + } + + indent_guides.extend(indent_stack); + + for guide in indent_guides.iter_mut() { + if includes_trailing_indent + && guide.offset.y + guide.length == offset + indents.len().saturating_sub(1) + { + guide.continues_offscreen = indents + .last() + .map(|last_indent| guide.offset.x < *last_indent) + .unwrap_or(false); + } + } + + indent_guides +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_indent_guides() { + fn assert_compute_indent_guides( + input: &[usize], + offset: usize, + includes_trailing_indent: bool, + expected: Vec, + ) { + use std::collections::HashSet; + assert_eq!( + compute_indent_guides(input, offset, includes_trailing_indent) + .into_vec() + .into_iter() + .collect::>(), + expected.into_iter().collect::>(), + ); + } + + assert_compute_indent_guides( + &[0, 1, 2, 2, 1, 0], + 0, + false, + vec![ + IndentGuideLayout { + offset: Point::new(0, 1), + length: 4, + continues_offscreen: false, + }, + IndentGuideLayout { + offset: Point::new(1, 2), + length: 2, + continues_offscreen: false, + }, + ], + ); + + assert_compute_indent_guides( + &[2, 2, 2, 1, 1], + 0, + false, + vec![ + IndentGuideLayout { + offset: Point::new(0, 0), + length: 5, + continues_offscreen: false, + }, + IndentGuideLayout { + offset: Point::new(1, 0), + length: 3, + continues_offscreen: false, + }, + ], + ); + + assert_compute_indent_guides( + &[1, 2, 3, 2, 1], + 0, + false, + vec![ + IndentGuideLayout { + offset: Point::new(0, 0), + length: 5, + continues_offscreen: false, + }, + IndentGuideLayout { + offset: Point::new(1, 1), + length: 3, + continues_offscreen: false, + }, + IndentGuideLayout { + offset: Point::new(2, 2), + length: 1, + continues_offscreen: false, + }, + ], + ); + + assert_compute_indent_guides( + &[0, 1, 0], + 0, + true, + vec![IndentGuideLayout { + offset: Point::new(0, 1), + length: 1, + continues_offscreen: false, + }], + ); + + assert_compute_indent_guides( + &[0, 1, 1], + 0, + true, + vec![IndentGuideLayout { + offset: Point::new(0, 1), + length: 1, + continues_offscreen: true, + }], + ); + assert_compute_indent_guides( + &[0, 1, 2], + 0, + true, + vec![IndentGuideLayout { + offset: Point::new(0, 1), + length: 1, + continues_offscreen: true, + }], + ); + } +}