outline panel: Add indent guides (#19719)

See #12673

| File | Search |
|--------|--------|
| <img width="302" alt="image"
src="https://github.com/user-attachments/assets/44b8d5f9-8446-41b5-8c0f-e438050f0ac9">
| <img width="301" alt="image"
src="https://github.com/user-attachments/assets/a2e6f77b-6d3b-4f1c-8fcb-16bd35274807">
|



Release Notes:

- Added indent guides to the outline panel
This commit is contained in:
Bennet Bo Fenner 2024-10-28 09:54:18 +01:00 committed by GitHub
parent e86b096b92
commit 888fec9299
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 236 additions and 86 deletions

2
Cargo.lock generated
View file

@ -7728,8 +7728,10 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"settings", "settings",
"smallvec",
"smol", "smol",
"theme", "theme",
"ui",
"util", "util",
"workspace", "workspace",
"worktree", "worktree",

View file

@ -388,6 +388,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 outline panel.
"indent_guides": true,
// Whether to reveal it in the outline panel automatically, // Whether to reveal it in the outline panel automatically,
// when a corresponding outline entry becomes active. // when a corresponding outline entry becomes active.
// Gitignored entries are never auto revealed. // Gitignored entries are never auto revealed.

View file

@ -340,6 +340,7 @@ impl Element for UniformList {
visible_range.clone(), visible_range.clone(),
bounds, bounds,
item_height, item_height,
self.item_count,
cx, cx,
); );
let available_space = size( let available_space = size(
@ -396,6 +397,7 @@ pub trait UniformListDecoration {
visible_range: Range<usize>, visible_range: Range<usize>,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
item_height: Pixels, item_height: Pixels,
item_count: usize,
cx: &mut WindowContext, cx: &mut WindowContext,
) -> AnyElement; ) -> AnyElement;
} }

View file

@ -30,8 +30,10 @@ search.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
settings.workspace = true settings.workspace = true
smallvec.workspace = true
smol.workspace = true smol.workspace = true
theme.workspace = true theme.workspace = true
ui.workspace = true
util.workspace = true util.workspace = true
worktree.workspace = true worktree.workspace = true
workspace.workspace = true workspace.workspace = true

View file

@ -24,12 +24,12 @@ use editor::{
use file_icons::FileIcons; use file_icons::FileIcons;
use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
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, ElementId, AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement, IntoElement, Div, ElementId, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement,
KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, IntoElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, Render, SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
VisualContext, WeakView, WindowContext, ViewContext, VisualContext, WeakView, WindowContext,
}; };
use itertools::Itertools; use itertools::Itertools;
use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
@ -42,6 +42,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use smol::channel; use smol::channel;
use theme::{SyntaxTheme, ThemeSettings}; use theme::{SyntaxTheme, ThemeSettings};
use ui::{IndentGuideColors, IndentGuideLayout};
use util::{debug_panic, RangeExt, ResultExt, TryFutureExt}; use util::{debug_panic, RangeExt, ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
@ -254,14 +255,14 @@ impl SearchState {
#[derive(Debug)] #[derive(Debug)]
enum SelectedEntry { enum SelectedEntry {
Invalidated(Option<PanelEntry>), Invalidated(Option<PanelEntry>),
Valid(PanelEntry), Valid(PanelEntry, usize),
None, None,
} }
impl SelectedEntry { impl SelectedEntry {
fn invalidate(&mut self) { fn invalidate(&mut self) {
match std::mem::replace(self, SelectedEntry::None) { match std::mem::replace(self, SelectedEntry::None) {
Self::Valid(entry) => *self = Self::Invalidated(Some(entry)), Self::Valid(entry, _) => *self = Self::Invalidated(Some(entry)),
Self::None => *self = Self::Invalidated(None), Self::None => *self = Self::Invalidated(None),
other => *self = other, other => *self = other,
} }
@ -3568,7 +3569,7 @@ impl OutlinePanel {
fn selected_entry(&self) -> Option<&PanelEntry> { fn selected_entry(&self) -> Option<&PanelEntry> {
match &self.selected_entry { match &self.selected_entry {
SelectedEntry::Invalidated(entry) => entry.as_ref(), SelectedEntry::Invalidated(entry) => entry.as_ref(),
SelectedEntry::Valid(entry) => Some(entry), SelectedEntry::Valid(entry, _) => Some(entry),
SelectedEntry::None => None, SelectedEntry::None => None,
} }
} }
@ -3577,7 +3578,16 @@ impl OutlinePanel {
if focus { if focus {
self.focus_handle.focus(cx); self.focus_handle.focus(cx);
} }
self.selected_entry = SelectedEntry::Valid(entry); let ix = self
.cached_entries
.iter()
.enumerate()
.find(|(_, cached_entry)| &cached_entry.entry == &entry)
.map(|(i, _)| i)
.unwrap_or_default();
self.selected_entry = SelectedEntry::Valid(entry, ix);
self.autoscroll(cx); self.autoscroll(cx);
cx.notify(); cx.notify();
} }
@ -3736,6 +3746,9 @@ impl Render for OutlinePanel {
let project = self.project.read(cx); let project = self.project.read(cx);
let query = self.query(cx); let query = self.query(cx);
let pinned = self.pinned; let pinned = self.pinned;
let settings = OutlinePanelSettings::get_global(cx);
let indent_size = settings.indent_size;
let show_indent_guides = settings.indent_guides;
let outline_panel = v_flex() let outline_panel = v_flex()
.id("outline-panel") .id("outline-panel")
@ -3901,6 +3914,61 @@ impl Render for OutlinePanel {
}) })
.size_full() .size_full()
.track_scroll(self.scroll_handle.clone()) .track_scroll(self.scroll_handle.clone())
.when(show_indent_guides, |list| {
list.with_decoration(
ui::indent_guides(
cx.view().clone(),
px(indent_size),
IndentGuideColors::panel(cx),
|outline_panel, range, _| {
let entries = outline_panel.cached_entries.get(range);
if let Some(entries) = entries {
entries.into_iter().map(|item| item.depth).collect()
} else {
smallvec::SmallVec::new()
}
},
)
.with_render_fn(
cx.view().clone(),
move |outline_panel, params, _| {
const LEFT_OFFSET: f32 = 14.;
let indent_size = params.indent_size;
let item_height = params.item_height;
let active_indent_guide_ix = find_active_indent_guide_ix(
outline_panel,
&params.indent_guides,
);
params
.indent_guides
.into_iter()
.enumerate()
.map(|(ix, layout)| {
let bounds = Bounds::new(
point(
px(layout.offset.x as f32) * indent_size
+ px(LEFT_OFFSET),
px(layout.offset.y as f32) * item_height,
),
size(
px(1.),
px(layout.length as f32) * item_height,
),
);
ui::RenderedIndentGuide {
bounds,
layout,
is_active: active_indent_guide_ix == Some(ix),
hitbox: None,
}
})
.collect()
},
),
)
})
}) })
} }
.children(self.context_menu.as_ref().map(|(menu, position, _)| { .children(self.context_menu.as_ref().map(|(menu, position, _)| {
@ -3945,6 +4013,40 @@ impl Render for OutlinePanel {
} }
} }
fn find_active_indent_guide_ix(
outline_panel: &OutlinePanel,
candidates: &[IndentGuideLayout],
) -> Option<usize> {
let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
return None;
};
let target_depth = outline_panel
.cached_entries
.get(*target_ix)
.map(|cached_entry| cached_entry.depth)?;
let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
.cached_entries
.get(target_ix + 1)
.filter(|cached_entry| cached_entry.depth > target_depth)
.map(|entry| entry.depth)
{
(target_ix + 1, target_depth.saturating_sub(1))
} else {
(*target_ix, target_depth.saturating_sub(1))
};
candidates
.iter()
.enumerate()
.find(|(_, guide)| {
guide.offset.y <= target_ix
&& target_ix < guide.offset.y + guide.length
&& guide.offset.x == target_depth
})
.map(|(ix, _)| ix)
}
fn subscribe_for_editor_events( fn subscribe_for_editor_events(
editor: &View<Editor>, editor: &View<Editor>,
cx: &mut ViewContext<OutlinePanel>, cx: &mut ViewContext<OutlinePanel>,

View file

@ -19,6 +19,7 @@ pub struct OutlinePanelSettings {
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,
} }
@ -53,6 +54,10 @@ pub struct OutlinePanelSettingsContent {
/// ///
/// Default: 20 /// Default: 20
pub indent_size: Option<f32>, pub indent_size: Option<f32>,
/// Whether to show indent guides in the outline panel.
///
/// Default: true
pub indent_guides: Option<bool>,
/// Whether to reveal it in the outline panel automatically, /// Whether to reveal it in the outline 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.

View file

@ -140,13 +140,18 @@ mod uniform_list {
visible_range: Range<usize>, visible_range: Range<usize>,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
item_height: Pixels, item_height: Pixels,
item_count: usize,
cx: &mut WindowContext, cx: &mut WindowContext,
) -> AnyElement { ) -> AnyElement {
let mut visible_range = visible_range.clone(); let mut visible_range = visible_range.clone();
let includes_trailing_indent = visible_range.end < item_count;
// Check if we have entries after the visible range,
// if so extend the visible range so we can fetch a trailing indent,
// which is needed to compute indent guides correctly.
if includes_trailing_indent {
visible_range.end += 1; visible_range.end += 1;
}
let visible_entries = &(self.compute_indents_fn)(visible_range.clone(), cx); 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( let indent_guides = compute_indent_guides(
&visible_entries, &visible_entries,
visible_range.start, visible_range.start,
@ -198,8 +203,12 @@ mod uniform_list {
on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>, on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
} }
struct IndentGuidesElementPrepaintState { enum IndentGuidesElementPrepaintState {
hitboxes: SmallVec<[Hitbox; 12]>, Static,
Interactive {
hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>,
},
} }
impl Element for IndentGuidesElement { impl Element for IndentGuidesElement {
@ -225,11 +234,21 @@ mod uniform_list {
_request_layout: &mut Self::RequestLayoutState, _request_layout: &mut Self::RequestLayoutState,
cx: &mut WindowContext, cx: &mut WindowContext,
) -> Self::PrepaintState { ) -> Self::PrepaintState {
let mut hitboxes = SmallVec::new(); if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone()
for guide in self.indent_guides.as_ref().iter() { {
hitboxes.push(cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false)); let hitboxes = self
.indent_guides
.as_ref()
.iter()
.map(|guide| cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false))
.collect();
Self::PrepaintState::Interactive {
hitboxes: Rc::new(hitboxes),
on_hovered_indent_guide_click,
}
} else {
Self::PrepaintState::Static
} }
Self::PrepaintState { hitboxes }
} }
fn paint( fn paint(
@ -240,11 +259,26 @@ mod uniform_list {
prepaint: &mut Self::PrepaintState, prepaint: &mut Self::PrepaintState,
cx: &mut WindowContext, cx: &mut WindowContext,
) { ) {
let callback = self.on_hovered_indent_guide_click.clone(); match prepaint {
if let Some(callback) = callback { IndentGuidesElementPrepaintState::Static => {
for indent_guide in self.indent_guides.as_ref() {
let fill_color = if indent_guide.is_active {
self.colors.active
} else {
self.colors.default
};
cx.paint_quad(fill(indent_guide.bounds, fill_color));
}
}
IndentGuidesElementPrepaintState::Interactive {
hitboxes,
on_hovered_indent_guide_click,
} => {
cx.on_mouse_event({ cx.on_mouse_event({
let hitboxes = prepaint.hitboxes.clone(); let hitboxes = hitboxes.clone();
let indent_guides = self.indent_guides.clone(); let indent_guides = self.indent_guides.clone();
let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
move |event: &MouseDownEvent, phase, cx| { move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble && event.button == MouseButton::Left { if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
let mut active_hitbox_ix = None; let mut active_hitbox_ix = None;
@ -260,17 +294,15 @@ mod uniform_list {
}; };
let active_indent_guide = &indent_guides[active_hitbox_ix].layout; let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
callback(active_indent_guide, cx); on_hovered_indent_guide_click(active_indent_guide, cx);
cx.stop_propagation(); cx.stop_propagation();
cx.prevent_default(); cx.prevent_default();
} }
} }
}); });
}
let mut hovered_hitbox_id = None; let mut hovered_hitbox_id = None;
for (i, hitbox) in prepaint.hitboxes.iter().enumerate() { for (i, hitbox) in hitboxes.iter().enumerate() {
cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox); cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
let indent_guide = &self.indent_guides[i]; let indent_guide = &self.indent_guides[i];
let fill_color = if hitbox.is_hovered(cx) { let fill_color = if hitbox.is_hovered(cx) {
@ -287,10 +319,10 @@ mod uniform_list {
cx.on_mouse_event({ cx.on_mouse_event({
let prev_hovered_hitbox_id = hovered_hitbox_id; let prev_hovered_hitbox_id = hovered_hitbox_id;
let hitboxes = prepaint.hitboxes.clone(); let hitboxes = hitboxes.clone();
move |_: &MouseMoveEvent, phase, cx| { move |_: &MouseMoveEvent, phase, cx| {
let mut hovered_hitbox_id = None; let mut hovered_hitbox_id = None;
for hitbox in &hitboxes { for hitbox in hitboxes.as_ref() {
if hitbox.is_hovered(cx) { if hitbox.is_hovered(cx) {
hovered_hitbox_id = Some(hitbox.id); hovered_hitbox_id = Some(hitbox.id);
break; break;
@ -317,6 +349,8 @@ mod uniform_list {
}); });
} }
} }
}
}
impl IntoElement for IndentGuidesElement { impl IntoElement for IndentGuidesElement {
type Element = Self; type Element = Self;

View file

@ -2237,6 +2237,7 @@ Run the `theme selector: toggle` action in the command palette to see a current
"folder_icons": true, "folder_icons": true,
"git_status": true, "git_status": true,
"indent_size": 20, "indent_size": 20,
"indent_guides": true,
"auto_reveal_entries": true, "auto_reveal_entries": true,
"auto_fold_dirs": true, "auto_fold_dirs": true,
} }