Cargo.lock 🔗
@@ -7728,8 +7728,10 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "smallvec",
"smol",
"theme",
+ "ui",
"util",
"workspace",
"worktree",
Bennet Bo Fenner created
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
Cargo.lock | 2
assets/settings/default.json | 2
crates/gpui/src/elements/uniform_list.rs | 2
crates/outline_panel/Cargo.toml | 2
crates/outline_panel/src/outline_panel.rs | 122 ++++++++++
crates/outline_panel/src/outline_panel_settings.rs | 5
crates/ui/src/components/indent_guides.rs | 176 +++++++++------
docs/src/configuring-zed.md | 1
8 files changed, 231 insertions(+), 81 deletions(-)
@@ -7728,8 +7728,10 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "smallvec",
"smol",
"theme",
+ "ui",
"util",
"workspace",
"worktree",
@@ -388,6 +388,8 @@
"git_status": true,
// Amount of indentation for nested items.
"indent_size": 20,
+ // Whether to show indent guides in the outline panel.
+ "indent_guides": true,
// Whether to reveal it in the outline panel automatically,
// when a corresponding outline entry becomes active.
// Gitignored entries are never auto revealed.
@@ -340,6 +340,7 @@ impl Element for UniformList {
visible_range.clone(),
bounds,
item_height,
+ self.item_count,
cx,
);
let available_space = size(
@@ -396,6 +397,7 @@ pub trait UniformListDecoration {
visible_range: Range<usize>,
bounds: Bounds<Pixels>,
item_height: Pixels,
+ item_count: usize,
cx: &mut WindowContext,
) -> AnyElement;
}
@@ -30,8 +30,10 @@ search.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
+smallvec.workspace = true
smol.workspace = true
theme.workspace = true
+ui.workspace = true
util.workspace = true
worktree.workspace = true
workspace.workspace = true
@@ -24,12 +24,12 @@ use editor::{
use file_icons::FileIcons;
use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
use gpui::{
- actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
- AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, ElementId,
- EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement, IntoElement,
- KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
- SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
- VisualContext, WeakView, WindowContext,
+ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
+ AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
+ Div, ElementId, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement,
+ IntoElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
+ Render, SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
+ ViewContext, VisualContext, WeakView, WindowContext,
};
use itertools::Itertools;
use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
@@ -42,6 +42,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use smol::channel;
use theme::{SyntaxTheme, ThemeSettings};
+use ui::{IndentGuideColors, IndentGuideLayout};
use util::{debug_panic, RangeExt, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
@@ -254,14 +255,14 @@ impl SearchState {
#[derive(Debug)]
enum SelectedEntry {
Invalidated(Option<PanelEntry>),
- Valid(PanelEntry),
+ Valid(PanelEntry, usize),
None,
}
impl SelectedEntry {
fn invalidate(&mut self) {
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),
other => *self = other,
}
@@ -3568,7 +3569,7 @@ impl OutlinePanel {
fn selected_entry(&self) -> Option<&PanelEntry> {
match &self.selected_entry {
SelectedEntry::Invalidated(entry) => entry.as_ref(),
- SelectedEntry::Valid(entry) => Some(entry),
+ SelectedEntry::Valid(entry, _) => Some(entry),
SelectedEntry::None => None,
}
}
@@ -3577,7 +3578,16 @@ impl OutlinePanel {
if focus {
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);
cx.notify();
}
@@ -3736,6 +3746,9 @@ impl Render for OutlinePanel {
let project = self.project.read(cx);
let query = self.query(cx);
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()
.id("outline-panel")
@@ -3901,6 +3914,61 @@ impl Render for OutlinePanel {
})
.size_full()
.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,
+ ¶ms.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, _)| {
@@ -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(
editor: &View<Editor>,
cx: &mut ViewContext<OutlinePanel>,
@@ -19,6 +19,7 @@ pub struct OutlinePanelSettings {
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,
}
@@ -53,6 +54,10 @@ pub struct OutlinePanelSettingsContent {
///
/// Default: 20
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,
/// when a corresponding project entry becomes active.
/// Gitignored entries are never auto revealed.
@@ -140,13 +140,18 @@ mod uniform_list {
visible_range: Range<usize>,
bounds: Bounds<Pixels>,
item_height: Pixels,
+ item_count: usize,
cx: &mut WindowContext,
) -> AnyElement {
let mut visible_range = visible_range.clone();
- visible_range.end += 1;
+ 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;
+ }
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,
@@ -198,8 +203,12 @@ mod uniform_list {
on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
}
- struct IndentGuidesElementPrepaintState {
- hitboxes: SmallVec<[Hitbox; 12]>,
+ enum IndentGuidesElementPrepaintState {
+ Static,
+ Interactive {
+ hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
+ on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>,
+ },
}
impl Element for IndentGuidesElement {
@@ -225,11 +234,21 @@ mod uniform_list {
_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));
+ if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone()
+ {
+ 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(
@@ -240,81 +259,96 @@ mod uniform_list {
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;
+ match prepaint {
+ 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({
+ let hitboxes = hitboxes.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| {
+ 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 Some(active_hitbox_ix) = active_hitbox_ix else {
+ return;
+ };
- let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
- callback(active_indent_guide, cx);
+ let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
+ on_hovered_indent_guide_click(active_indent_guide, cx);
- cx.stop_propagation();
- cx.prevent_default();
+ 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) {
+ for (i, hitbox) in 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);
- break;
- }
+ 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));
}
- 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();
+
+ cx.on_mouse_event({
+ let prev_hovered_hitbox_id = hovered_hitbox_id;
+ let hitboxes = hitboxes.clone();
+ move |_: &MouseMoveEvent, phase, cx| {
+ let mut hovered_hitbox_id = None;
+ for hitbox in hitboxes.as_ref() {
+ if hitbox.is_hovered(cx) {
+ hovered_hitbox_id = Some(hitbox.id);
+ break;
}
}
- (None, Some(_)) => {
- cx.refresh();
- }
- (Some(_), None) => {
- cx.refresh();
+ 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) => {}
+ }
}
- (None, None) => {}
}
- }
+ });
}
- });
+ }
}
}
@@ -2237,6 +2237,7 @@ Run the `theme selector: toggle` action in the command palette to see a current
"folder_icons": true,
"git_status": true,
"indent_size": 20,
+ "indent_guides": true,
"auto_reveal_entries": true,
"auto_fold_dirs": true,
}