project panel: Add indent guides (#18260)
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
This commit is contained in:
parent
e040b200bc
commit
4214ed927f
13 changed files with 975 additions and 41 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -8515,6 +8515,7 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"settings",
|
"settings",
|
||||||
|
"smallvec",
|
||||||
"theme",
|
"theme",
|
||||||
"ui",
|
"ui",
|
||||||
"util",
|
"util",
|
||||||
|
|
|
@ -346,6 +346,8 @@
|
||||||
"git_status": true,
|
"git_status": true,
|
||||||
// Amount of indentation for nested items.
|
// Amount of indentation for nested items.
|
||||||
"indent_size": 20,
|
"indent_size": 20,
|
||||||
|
// Whether to show indent guides in the project panel.
|
||||||
|
"indent_guides": true,
|
||||||
// Whether to reveal it in the project panel automatically,
|
// Whether to reveal it in the project panel automatically,
|
||||||
// when a corresponding project entry becomes active.
|
// when a corresponding project entry becomes active.
|
||||||
// Gitignored entries are never auto revealed.
|
// Gitignored entries are never auto revealed.
|
||||||
|
|
|
@ -48,6 +48,7 @@ where
|
||||||
item_count,
|
item_count,
|
||||||
item_to_measure_index: 0,
|
item_to_measure_index: 0,
|
||||||
render_items: Box::new(render_range),
|
render_items: Box::new(render_range),
|
||||||
|
decorations: Vec::new(),
|
||||||
interactivity: Interactivity {
|
interactivity: Interactivity {
|
||||||
element_id: Some(id),
|
element_id: Some(id),
|
||||||
base_style: Box::new(base_style),
|
base_style: Box::new(base_style),
|
||||||
|
@ -69,6 +70,7 @@ pub struct UniformList {
|
||||||
item_to_measure_index: usize,
|
item_to_measure_index: usize,
|
||||||
render_items:
|
render_items:
|
||||||
Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
|
Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
|
||||||
|
decorations: Vec<Box<dyn UniformListDecoration>>,
|
||||||
interactivity: Interactivity,
|
interactivity: Interactivity,
|
||||||
scroll_handle: Option<UniformListScrollHandle>,
|
scroll_handle: Option<UniformListScrollHandle>,
|
||||||
sizing_behavior: ListSizingBehavior,
|
sizing_behavior: ListSizingBehavior,
|
||||||
|
@ -78,6 +80,7 @@ pub struct UniformList {
|
||||||
/// Frame state used by the [UniformList].
|
/// Frame state used by the [UniformList].
|
||||||
pub struct UniformListFrameState {
|
pub struct UniformListFrameState {
|
||||||
items: SmallVec<[AnyElement; 32]>,
|
items: SmallVec<[AnyElement; 32]>,
|
||||||
|
decorations: SmallVec<[AnyElement; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A handle for controlling the scroll position of a uniform list.
|
/// A handle for controlling the scroll position of a uniform list.
|
||||||
|
@ -185,6 +188,7 @@ impl Element for UniformList {
|
||||||
layout_id,
|
layout_id,
|
||||||
UniformListFrameState {
|
UniformListFrameState {
|
||||||
items: SmallVec::new(),
|
items: SmallVec::new(),
|
||||||
|
decorations: SmallVec::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -292,9 +296,10 @@ impl Element for UniformList {
|
||||||
..cmp::min(last_visible_element_ix, self.item_count);
|
..cmp::min(last_visible_element_ix, self.item_count);
|
||||||
|
|
||||||
let mut items = (self.render_items)(visible_range.clone(), cx);
|
let mut items = (self.render_items)(visible_range.clone(), cx);
|
||||||
|
|
||||||
let content_mask = ContentMask { bounds };
|
let content_mask = ContentMask { bounds };
|
||||||
cx.with_content_mask(Some(content_mask), |cx| {
|
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
|
let item_origin = padded_bounds.origin
|
||||||
+ point(
|
+ point(
|
||||||
if can_scroll_horizontally {
|
if can_scroll_horizontally {
|
||||||
|
@ -317,6 +322,34 @@ impl Element for UniformList {
|
||||||
item.prepaint_at(item_origin, cx);
|
item.prepaint_at(item_origin, cx);
|
||||||
frame_state.items.push(item);
|
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 {
|
for item in &mut request_layout.items {
|
||||||
item.paint(cx);
|
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 {
|
impl UniformList {
|
||||||
/// Selects a specific list item for measurement.
|
/// Selects a specific list item for measurement.
|
||||||
pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
|
pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
|
||||||
|
@ -382,6 +432,12 @@ impl UniformList {
|
||||||
self
|
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> {
|
fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
|
||||||
if self.item_count == 0 {
|
if self.item_count == 0 {
|
||||||
return Size::default();
|
return Size::default();
|
||||||
|
|
|
@ -30,6 +30,7 @@ serde.workspace = true
|
||||||
serde_derive.workspace = true
|
serde_derive.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
settings.workspace = true
|
settings.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
theme.workspace = true
|
theme.workspace = true
|
||||||
ui.workspace = true
|
ui.workspace = true
|
||||||
util.workspace = true
|
util.workspace = true
|
||||||
|
|
|
@ -16,12 +16,13 @@ use anyhow::{anyhow, Context as _, Result};
|
||||||
use collections::{hash_map, BTreeSet, HashMap};
|
use collections::{hash_map, BTreeSet, HashMap};
|
||||||
use git::repository::GitFileStatus;
|
use git::repository::GitFileStatus;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
|
actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
|
||||||
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
|
AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
|
||||||
EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
|
Div, DragMoveEvent, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
|
||||||
ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
|
InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model,
|
||||||
ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
|
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
|
||||||
UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
|
Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
|
||||||
|
WeakView, WindowContext,
|
||||||
};
|
};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
|
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
|
||||||
|
@ -31,6 +32,7 @@ use project::{
|
||||||
};
|
};
|
||||||
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
|
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use smallvec::SmallVec;
|
||||||
use std::{
|
use std::{
|
||||||
cell::OnceCell,
|
cell::OnceCell,
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
|
@ -41,7 +43,10 @@ use std::{
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use theme::ThemeSettings;
|
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 util::{maybe, ResultExt, TryFutureExt};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
dock::{DockPosition, Panel, PanelEvent},
|
dock::{DockPosition, Panel, PanelEvent},
|
||||||
|
@ -654,42 +659,52 @@ impl ProjectPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
|
fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
|
||||||
if let Some((worktree, mut entry)) = self.selected_entry(cx) {
|
let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
|
||||||
if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
|
return;
|
||||||
if folded_ancestors.current_ancestor_depth + 1
|
};
|
||||||
< folded_ancestors.max_ancestor_depth()
|
self.collapse_entry(entry.clone(), worktree, cx)
|
||||||
{
|
}
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
fn collapse_entry(
|
||||||
let entry_id = entry.id;
|
&mut self,
|
||||||
match expanded_dir_ids.binary_search(&entry_id) {
|
entry: Entry,
|
||||||
Ok(ix) => {
|
worktree: Model<Worktree>,
|
||||||
expanded_dir_ids.remove(ix);
|
cx: &mut ViewContext<Self>,
|
||||||
self.update_visible_entries(Some((worktree_id, entry_id)), cx);
|
) {
|
||||||
cx.notify();
|
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;
|
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()
|
.copied()
|
||||||
.unwrap_or(id)
|
.unwrap_or(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_entry<'a>(
|
pub fn selected_entry<'a>(
|
||||||
&self,
|
&self,
|
||||||
cx: &'a AppContext,
|
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(
|
fn for_each_visible_entry(
|
||||||
&self,
|
&self,
|
||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
|
@ -2816,6 +2900,70 @@ impl ProjectPanel {
|
||||||
cx.notify();
|
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 {
|
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 {
|
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||||
let has_worktree = !self.visible_entries.is_empty();
|
let has_worktree = !self.visible_entries.is_empty();
|
||||||
let project = self.project.read(cx);
|
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();
|
let is_local = project.is_local();
|
||||||
|
|
||||||
if has_worktree {
|
if has_worktree {
|
||||||
|
@ -2934,6 +3084,103 @@ impl Render for ProjectPanel {
|
||||||
items
|
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()
|
.size_full()
|
||||||
.with_sizing_behavior(ListSizingBehavior::Infer)
|
.with_sizing_behavior(ListSizingBehavior::Infer)
|
||||||
.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
|
.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
|
||||||
|
|
|
@ -20,6 +20,7 @@ pub struct ProjectPanelSettings {
|
||||||
pub folder_icons: bool,
|
pub folder_icons: bool,
|
||||||
pub git_status: bool,
|
pub git_status: bool,
|
||||||
pub indent_size: f32,
|
pub indent_size: f32,
|
||||||
|
pub indent_guides: bool,
|
||||||
pub auto_reveal_entries: bool,
|
pub auto_reveal_entries: bool,
|
||||||
pub auto_fold_dirs: bool,
|
pub auto_fold_dirs: bool,
|
||||||
pub scrollbar: ScrollbarSettings,
|
pub scrollbar: ScrollbarSettings,
|
||||||
|
@ -71,6 +72,10 @@ pub struct ProjectPanelSettingsContent {
|
||||||
///
|
///
|
||||||
/// Default: 20
|
/// Default: 20
|
||||||
pub indent_size: Option<f32>,
|
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,
|
/// Whether to reveal it in the project panel automatically,
|
||||||
/// when a corresponding project entry becomes active.
|
/// when a corresponding project entry becomes active.
|
||||||
/// Gitignored entries are never auto revealed.
|
/// Gitignored entries are never auto revealed.
|
||||||
|
|
83
crates/storybook/src/stories/indent_guides.rs
Normal file
83
crates/storybook/src/stories/indent_guides.rs
Normal file
|
@ -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(),
|
search_match_background: neutral().light().step_5(),
|
||||||
panel_background: neutral().light().step_2(),
|
panel_background: neutral().light().step_2(),
|
||||||
panel_focused_border: blue().light().step_5(),
|
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_focused_border: blue().light().step_5(),
|
||||||
pane_group_border: neutral().light().step_6(),
|
pane_group_border: neutral().light().step_6(),
|
||||||
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
||||||
|
@ -162,6 +165,9 @@ impl ThemeColors {
|
||||||
search_match_background: neutral().dark().step_5(),
|
search_match_background: neutral().dark().step_5(),
|
||||||
panel_background: neutral().dark().step_2(),
|
panel_background: neutral().dark().step_2(),
|
||||||
panel_focused_border: blue().dark().step_5(),
|
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_focused_border: blue().dark().step_5(),
|
||||||
pane_group_border: neutral().dark().step_6(),
|
pane_group_border: neutral().dark().step_6(),
|
||||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
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(),
|
terminal_ansi_dim_white: crate::neutral().dark().step_10(),
|
||||||
panel_background: bg,
|
panel_background: bg,
|
||||||
panel_focused_border: blue,
|
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_focused_border: blue,
|
||||||
pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
|
pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
|
||||||
scrollbar_thumb_background: gpui::transparent_black(),
|
scrollbar_thumb_background: gpui::transparent_black(),
|
||||||
|
|
|
@ -322,6 +322,15 @@ pub struct ThemeColorsContent {
|
||||||
#[serde(rename = "panel.focused_border")]
|
#[serde(rename = "panel.focused_border")]
|
||||||
pub panel_focused_border: Option<String>,
|
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")]
|
#[serde(rename = "pane.focused_border")]
|
||||||
pub pane_focused_border: Option<String>,
|
pub pane_focused_border: Option<String>,
|
||||||
|
|
||||||
|
@ -710,6 +719,18 @@ impl ThemeColorsContent {
|
||||||
.panel_focused_border
|
.panel_focused_border
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|color| try_parse_color(color).ok()),
|
.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: self
|
||||||
.pane_focused_border
|
.pane_focused_border
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
|
@ -123,6 +123,9 @@ pub struct ThemeColors {
|
||||||
pub search_match_background: Hsla,
|
pub search_match_background: Hsla,
|
||||||
pub panel_background: Hsla,
|
pub panel_background: Hsla,
|
||||||
pub panel_focused_border: 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_focused_border: Hsla,
|
||||||
pub pane_group_border: Hsla,
|
pub pane_group_border: Hsla,
|
||||||
/// The color of the scrollbar thumb.
|
/// The color of the scrollbar thumb.
|
||||||
|
|
|
@ -8,6 +8,7 @@ mod dropdown_menu;
|
||||||
mod facepile;
|
mod facepile;
|
||||||
mod icon;
|
mod icon;
|
||||||
mod image;
|
mod image;
|
||||||
|
mod indent_guides;
|
||||||
mod indicator;
|
mod indicator;
|
||||||
mod keybinding;
|
mod keybinding;
|
||||||
mod label;
|
mod label;
|
||||||
|
@ -40,6 +41,7 @@ pub use dropdown_menu::*;
|
||||||
pub use facepile::*;
|
pub use facepile::*;
|
||||||
pub use icon::*;
|
pub use icon::*;
|
||||||
pub use image::*;
|
pub use image::*;
|
||||||
|
pub use indent_guides::*;
|
||||||
pub use indicator::*;
|
pub use indicator::*;
|
||||||
pub use keybinding::*;
|
pub use keybinding::*;
|
||||||
pub use label::*;
|
pub use label::*;
|
||||||
|
|
504
crates/ui/src/components/indent_guides.rs
Normal file
504
crates/ui/src/components/indent_guides.rs
Normal file
|
@ -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,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue