Cargo.lock 🔗
@@ -8515,6 +8515,7 @@ dependencies = [
"serde_derive",
"serde_json",
"settings",
+ "smallvec",
"theme",
"ui",
"util",
Bennet Bo Fenner created
See #12673
https://github.com/user-attachments/assets/94079afc-a851-4206-9c9b-4fad3542334e
TODO:
- [x] Make active indent guides work for autofolded directories
- [x] Figure out which theme colors to use
- [x] Fix horizontal scrolling
- [x] Make indent guides easier to click
- [x] Fix selected background flashing when hovering over entry/indent
guide
- [x] Docs
Release Notes:
- Added indent guides to the project panel
Cargo.lock | 1
assets/settings/default.json | 2
crates/gpui/src/elements/uniform_list.rs | 58 +
crates/project_panel/Cargo.toml | 1
crates/project_panel/src/project_panel.rs | 325 +++++++++-
crates/project_panel/src/project_panel_settings.rs | 5
crates/storybook/src/stories/indent_guides.rs | 83 ++
crates/theme/src/default_colors.rs | 6
crates/theme/src/fallback_themes.rs | 3
crates/theme/src/schema.rs | 21
crates/theme/src/styles/colors.rs | 3
crates/ui/src/components.rs | 2
crates/ui/src/components/indent_guides.rs | 504 ++++++++++++++++
13 files changed, 974 insertions(+), 40 deletions(-)
@@ -8515,6 +8515,7 @@ dependencies = [
"serde_derive",
"serde_json",
"settings",
+ "smallvec",
"theme",
"ui",
"util",
@@ -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.
@@ -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<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
+ decorations: Vec<Box<dyn UniformListDecoration>>,
interactivity: Interactivity,
scroll_handle: Option<UniformListScrollHandle>,
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<usize>,
+ bounds: Bounds<Pixels>,
+ 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<usize>) -> 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<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
if self.item_count == 0 {
return Size::default();
@@ -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
@@ -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<Self>) {
- 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 Some((worktree, entry)) = self.selected_entry_handle(cx) else {
+ return;
+ };
+ self.collapse_entry(entry.clone(), worktree, cx)
+ }
+
+ fn collapse_entry(
+ &mut self,
+ entry: Entry,
+ worktree: Model<Worktree>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ 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 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;
+ };
- 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();
+ 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<usize>,
+ cx: &mut ViewContext<ProjectPanel>,
+ mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
+ ) {
+ 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<usize>,
@@ -2816,6 +2900,70 @@ impl ProjectPanel {
cx.notify();
}
}
+
+ fn find_active_indent_guide(
+ &self,
+ indent_guides: &[IndentGuideLayout],
+ cx: &AppContext,
+ ) -> Option<usize> {
+ 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<Self>) -> 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)
@@ -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<f32>,
+ /// Whether to show indent guides in the project panel.
+ ///
+ /// Default: true
+ pub indent_guides: Option<bool>,
/// Whether to reveal it in the project panel automatically,
/// when a corresponding project entry becomes active.
/// Gitignored entries are never auto revealed.
@@ -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<usize>,
+}
+
+impl IndentGuidesStory {
+ pub fn view(cx: &mut WindowContext) -> View<Self> {
+ 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<Self>) -> 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()
+ },
+ )),
+ ),
+ )
+ }
+}
@@ -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(),
@@ -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(),
@@ -322,6 +322,15 @@ pub struct ThemeColorsContent {
#[serde(rename = "panel.focused_border")]
pub panel_focused_border: Option<String>,
+ #[serde(rename = "panel.indent_guide")]
+ pub panel_indent_guide: Option<String>,
+
+ #[serde(rename = "panel.indent_guide_hover")]
+ pub panel_indent_guide_hover: Option<String>,
+
+ #[serde(rename = "panel.indent_guide_active")]
+ pub panel_indent_guide_active: Option<String>,
+
#[serde(rename = "pane.focused_border")]
pub pane_focused_border: Option<String>,
@@ -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()
@@ -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.
@@ -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::*;
@@ -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<dyn Fn(Range<usize>, &mut WindowContext) -> SmallVec<[usize; 64]>>,
+ render_fn: Option<
+ Box<
+ dyn Fn(
+ RenderIndentGuideParams,
+ &mut WindowContext,
+ ) -> SmallVec<[RenderedIndentGuide; 12]>,
+ >,
+ >,
+ on_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
+}
+
+pub fn indent_guides<V: Render>(
+ view: View<V>,
+ indent_size: Pixels,
+ colors: IndentGuideColors,
+ compute_indents_fn: impl Fn(&mut V, Range<usize>, &mut ViewContext<V>) -> 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<V: Render>(
+ mut self,
+ view: View<V>,
+ 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<Pixels>,
+ /// 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<Bounds<Pixels>>,
+}
+
+/// 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<usize>,
+ /// 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<usize>,
+ bounds: Bounds<Pixels>,
+ 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<SmallVec<[RenderedIndentGuide; 12]>>,
+ on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
+ }
+
+ struct IndentGuidesElementPrepaintState {
+ hitboxes: SmallVec<[Hitbox; 12]>,
+ }
+
+ impl Element for IndentGuidesElement {
+ type RequestLayoutState = ();
+ type PrepaintState = IndentGuidesElementPrepaintState;
+
+ fn id(&self) -> Option<ElementId> {
+ 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<Pixels>,
+ _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<Pixels>,
+ _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<IndentGuideLayout>,
+ ) {
+ use std::collections::HashSet;
+ assert_eq!(
+ compute_indent_guides(input, offset, includes_trailing_indent)
+ .into_vec()
+ .into_iter()
+ .collect::<HashSet<_>>(),
+ expected.into_iter().collect::<HashSet<_>>(),
+ );
+ }
+
+ 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,
+ }],
+ );
+ }
+}