Indent guides (#11503)

Bennet Bo Fenner , Nate Butler , and Remco created

Builds on top of existing work from #2249, but here's a showcase:


https://github.com/zed-industries/zed/assets/53836821/4b346965-6654-496c-b379-75425d9b493f

TODO:
- [x] handle line wrapping
- [x] implement handling in multibuffer (crashes currently)
- [x] add configuration option
- [x] new theme properties? What colors to use?
- [x] Possibly support indents with different colors or background
colors
- [x] investigate edge cases (e.g. indent guides and folds continue on
empty lines even if the next indent is different)
- [x] add more tests (also test `find_active_indent_index`)
- [x] docs (will do in a follow up PR)
- [x] benchmark performance impact

Release Notes:

- Added indent guides
([#5373](https://github.com/zed-industries/zed/issues/5373))

---------

Co-authored-by: Nate Butler <1714999+iamnbutler@users.noreply.github.com>
Co-authored-by: Remco <djsmits12@gmail.com>

Change summary

assets/settings/default.json                      |  19 
assets/themes/gruvbox/gruvbox.json                |  54 +
crates/assistant/src/assistant_panel.rs           |   1 
crates/collab_ui/src/chat_panel/message_editor.rs |   3 
crates/editor/src/display_map.rs                  |  15 
crates/editor/src/editor.rs                       |  13 
crates/editor/src/editor_tests.rs                 | 503 ++++++++++++++++
crates/editor/src/element.rs                      | 228 +++++++
crates/editor/src/indent_guides.rs                | 164 +++++
crates/feedback/src/feedback_modal.rs             |   1 
crates/language/src/buffer.rs                     | 261 ++++++++
crates/language/src/buffer_tests.rs               |  65 ++
crates/language/src/language_settings.rs          |  59 +
crates/multi_buffer/src/multi_buffer.rs           |  75 ++
crates/rope/src/rope.rs                           |  45 +
crates/text/src/text.rs                           |  81 ++
crates/theme/src/default_colors.rs                |   4 
crates/theme/src/default_theme.rs                 |  34 -
crates/theme/src/one_themes.rs                    |   8 
crates/theme/src/registry.rs                      |  13 
crates/theme/src/schema.rs                        |  20 
crates/theme/src/settings.rs                      |   1 
crates/theme/src/styles.rs                        |   2 
crates/theme/src/styles/accents.rs                |  85 ++
crates/theme/src/styles/colors.rs                 |   9 
crates/theme/src/theme.rs                         |   6 
crates/theme_importer/src/vscode/converter.rs     |   1 
27 files changed, 1,705 insertions(+), 65 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -216,6 +216,25 @@
     // Whether to show fold buttons in the gutter.
     "folds": true
   },
+  "indent_guides": {
+    /// Whether to show indent guides in the editor.
+    "enabled": true,
+    /// The width of the indent guides in pixels, between 1 and 10.
+    "line_width": 1,
+    /// Determines how indent guides are colored.
+    /// This setting can take the following three values:
+    ///
+    /// 1. "disabled"
+    /// 2. "fixed"
+    /// 3. "indent_aware"
+    "coloring": "fixed",
+    /// Determines how indent guide backgrounds are colored.
+    /// This setting can take the following two values:
+    ///
+    /// 1. "disabled"
+    /// 2. "indent_aware"
+    "background_coloring": "disabled"
+  },
   // The number of lines to keep above/below the cursor when scrolling.
   "vertical_scroll_margin": 3,
   // Scroll sensitivity multiplier. This multiplier is applied

assets/themes/gruvbox/gruvbox.json 🔗

@@ -5,6 +5,15 @@
     {
       "name": "Gruvbox Dark",
       "appearance": "dark",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#5b534dff",
         "border.variant": "#494340ff",
@@ -379,6 +388,15 @@
     {
       "name": "Gruvbox Dark Hard",
       "appearance": "dark",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#5b534dff",
         "border.variant": "#494340ff",
@@ -753,6 +771,15 @@
     {
       "name": "Gruvbox Dark Soft",
       "appearance": "dark",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#5b534dff",
         "border.variant": "#494340ff",
@@ -1127,6 +1154,15 @@
     {
       "name": "Gruvbox Light",
       "appearance": "light",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#c8b899ff",
         "border.variant": "#ddcca7ff",
@@ -1501,6 +1537,15 @@
     {
       "name": "Gruvbox Light Hard",
       "appearance": "light",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#c8b899ff",
         "border.variant": "#ddcca7ff",
@@ -1875,6 +1920,15 @@
     {
       "name": "Gruvbox Light Soft",
       "appearance": "light",
+      "accents": [
+        "#cc241dff",
+        "#98971aff",
+        "#d79921ff",
+        "#458588ff",
+        "#b16286ff",
+        "#689d6aff",
+        "#d65d0eff"
+      ],
       "style": {
         "border": "#c8b899ff",
         "border.variant": "#ddcca7ff",

crates/assistant/src/assistant_panel.rs 🔗

@@ -2744,6 +2744,7 @@ impl ConversationEditor {
             editor.set_show_git_diff_gutter(false, cx);
             editor.set_show_code_actions(false, cx);
             editor.set_show_wrap_guides(false, cx);
+            editor.set_show_indent_guides(false, cx);
             editor.set_completion_provider(Box::new(completion_provider));
             editor
         });

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -100,6 +100,9 @@ impl MessageEditor {
         editor.update(cx, |editor, cx| {
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
             editor.set_use_autoclose(false);
+            editor.set_show_gutter(false, cx);
+            editor.set_show_wrap_guides(false, cx);
+            editor.set_show_indent_guides(false, cx);
             editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
             editor.set_auto_replace_emoji_shortcode(
                 MessageEditorSettings::get_global(cx)

crates/editor/src/display_map.rs 🔗

@@ -845,20 +845,7 @@ impl DisplaySnapshot {
             .buffer_line_for_row(buffer_row)
             .unwrap();
 
-        let mut indent_size = 0;
-        let mut is_blank = false;
-        for c in buffer.chars_at(Point::new(range.start.row, 0)) {
-            if c == ' ' || c == '\t' {
-                indent_size += 1;
-            } else {
-                if c == '\n' {
-                    is_blank = true;
-                }
-                break;
-            }
-        }
-
-        (indent_size, is_blank)
+        buffer.line_indent_for_row(range.start.row)
     }
 
     pub fn line_len(&self, row: DisplayRow) -> u32 {

crates/editor/src/editor.rs 🔗

@@ -26,6 +26,7 @@ mod git;
 mod highlight_matching_bracket;
 mod hover_links;
 mod hover_popover;
+mod indent_guides;
 mod inline_completion_provider;
 pub mod items;
 mod mouse_context_menu;
@@ -76,6 +77,7 @@ use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
 use hunk_diff::ExpandedHunks;
 pub(crate) use hunk_diff::HunkToExpand;
+use indent_guides::ActiveIndentGuidesState;
 use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
 pub use inline_completion_provider::*;
 pub use items::MAX_TAB_TITLE_LEN;
@@ -453,11 +455,13 @@ pub struct Editor {
     show_git_diff_gutter: Option<bool>,
     show_code_actions: Option<bool>,
     show_wrap_guides: Option<bool>,
+    show_indent_guides: Option<bool>,
     placeholder_text: Option<Arc<str>>,
     highlight_order: usize,
     highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
     background_highlights: TreeMap<TypeId, BackgroundHighlight>,
     scrollbar_marker_state: ScrollbarMarkerState,
+    active_indent_guides_state: ActiveIndentGuidesState,
     nav_history: Option<ItemNavHistory>,
     context_menu: RwLock<Option<ContextMenu>>,
     mouse_context_menu: Option<MouseContextMenu>,
@@ -1656,11 +1660,13 @@ impl Editor {
             show_git_diff_gutter: None,
             show_code_actions: None,
             show_wrap_guides: None,
+            show_indent_guides: None,
             placeholder_text: None,
             highlight_order: 0,
             highlighted_rows: HashMap::default(),
             background_highlights: Default::default(),
             scrollbar_marker_state: ScrollbarMarkerState::default(),
+            active_indent_guides_state: ActiveIndentGuidesState::default(),
             nav_history: None,
             context_menu: RwLock::new(None),
             mouse_context_menu: None,
@@ -9440,6 +9446,7 @@ impl Editor {
 
             cx.notify();
             self.scrollbar_marker_state.dirty = true;
+            self.active_indent_guides_state.dirty = true;
         }
     }
 
@@ -9668,6 +9675,11 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut ViewContext<Self>) {
+        self.show_indent_guides = Some(show_indent_guides);
+        cx.notify();
+    }
+
     pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
         if let Some(buffer) = self.buffer().read(cx).as_singleton() {
             if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) {
@@ -10303,6 +10315,7 @@ impl Editor {
                 singleton_buffer_edited,
             } => {
                 self.scrollbar_marker_state.dirty = true;
+                self.active_indent_guides_state.dirty = true;
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(cx);
                 if self.has_active_inline_completion(cx) {

crates/editor/src/editor_tests.rs 🔗

@@ -17,8 +17,10 @@ use language::{
     },
     BracketPairConfig,
     Capability::ReadWrite,
-    FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageMatcher, Override, Point,
+    FakeLspAdapter, IndentGuide, LanguageConfig, LanguageConfigOverride, LanguageMatcher, Override,
+    Point,
 };
+use multi_buffer::MultiBufferIndentGuide;
 use parking_lot::Mutex;
 use project::project_settings::{LspSettings, ProjectSettings};
 use project::FakeFs;
@@ -11448,6 +11450,505 @@ async fn test_multiple_expanded_hunks_merge(
     );
 }
 
+async fn setup_indent_guides_editor(
+    text: &str,
+    cx: &mut gpui::TestAppContext,
+) -> (BufferId, EditorTestContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let buffer_id = cx.update_editor(|editor, cx| {
+        editor.set_text(text, cx);
+        let buffer_ids = editor.buffer().read(cx).excerpt_buffer_ids();
+        let buffer_id = buffer_ids[0];
+        buffer_id
+    });
+
+    (buffer_id, cx)
+}
+
+fn assert_indent_guides(
+    range: Range<u32>,
+    expected: Vec<IndentGuide>,
+    active_indices: Option<Vec<usize>>,
+    cx: &mut EditorTestContext,
+) {
+    let indent_guides = cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx).display_snapshot;
+        let mut indent_guides: Vec<_> = crate::indent_guides::indent_guides_in_range(
+            MultiBufferRow(range.start)..MultiBufferRow(range.end),
+            &snapshot,
+            cx,
+        );
+
+        indent_guides.sort_by(|a, b| {
+            a.depth.cmp(&b.depth).then(
+                a.start_row
+                    .cmp(&b.start_row)
+                    .then(a.end_row.cmp(&b.end_row)),
+            )
+        });
+        indent_guides
+    });
+
+    if let Some(expected) = active_indices {
+        let active_indices = cx.update_editor(|editor, cx| {
+            let snapshot = editor.snapshot(cx).display_snapshot;
+            editor.find_active_indent_guide_indices(&indent_guides, &snapshot, cx)
+        });
+
+        assert_eq!(
+            active_indices.unwrap().into_iter().collect::<Vec<_>>(),
+            expected,
+            "Active indent guide indices do not match"
+        );
+    }
+
+    let expected: Vec<_> = expected
+        .into_iter()
+        .map(|guide| MultiBufferIndentGuide {
+            multibuffer_row_range: MultiBufferRow(guide.start_row)..MultiBufferRow(guide.end_row),
+            buffer: guide,
+        })
+        .collect();
+
+    assert_eq!(indent_guides, expected, "Indent guides do not match");
+}
+
+#[gpui::test]
+async fn test_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..3,
+        vec![IndentGuide::new(buffer_id, 1, 1, 0, 4)],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_simple_block(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+        let b = 2;
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..4,
+        vec![IndentGuide::new(buffer_id, 1, 2, 0, 4)],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_nested(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+        if a == 3 {
+            let b = 2;
+        } else {
+            let c = 3;
+        }
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..8,
+        vec![
+            IndentGuide::new(buffer_id, 1, 6, 0, 4),
+            IndentGuide::new(buffer_id, 3, 3, 1, 4),
+            IndentGuide::new(buffer_id, 5, 5, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_tab(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+            let b = 2;
+        let c = 3;
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..5,
+        vec![
+            IndentGuide::new(buffer_id, 1, 3, 0, 4),
+            IndentGuide::new(buffer_id, 2, 2, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_continues_on_empty_line(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        fn main() {
+            let a = 1;
+
+            let c = 3;
+        }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..5,
+        vec![IndentGuide::new(buffer_id, 1, 3, 0, 4)],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_complex(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        fn main() {
+            let a = 1;
+
+            let c = 3;
+
+            if a == 3 {
+                let b = 2;
+            } else {
+                let c = 3;
+            }
+        }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..11,
+        vec![
+            IndentGuide::new(buffer_id, 1, 9, 0, 4),
+            IndentGuide::new(buffer_id, 6, 6, 1, 4),
+            IndentGuide::new(buffer_id, 8, 8, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_starts_off_screen(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        fn main() {
+            let a = 1;
+
+            let c = 3;
+
+            if a == 3 {
+                let b = 2;
+            } else {
+                let c = 3;
+            }
+        }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        1..11,
+        vec![
+            IndentGuide::new(buffer_id, 1, 9, 0, 4),
+            IndentGuide::new(buffer_id, 6, 6, 1, 4),
+            IndentGuide::new(buffer_id, 8, 8, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_ends_off_screen(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        fn main() {
+            let a = 1;
+
+            let c = 3;
+
+            if a == 3 {
+                let b = 2;
+            } else {
+                let c = 3;
+            }
+        }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        1..10,
+        vec![
+            IndentGuide::new(buffer_id, 1, 9, 0, 4),
+            IndentGuide::new(buffer_id, 6, 6, 1, 4),
+            IndentGuide::new(buffer_id, 8, 8, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_without_brackets(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        block1
+            block2
+                block3
+                    block4
+            block2
+        block1
+        block1"
+            .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        1..10,
+        vec![
+            IndentGuide::new(buffer_id, 1, 4, 0, 4),
+            IndentGuide::new(buffer_id, 2, 3, 1, 4),
+            IndentGuide::new(buffer_id, 3, 3, 2, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_ends_before_empty_line(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        block1
+            block2
+                block3
+
+        block1
+        block1"
+            .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..6,
+        vec![
+            IndentGuide::new(buffer_id, 1, 2, 0, 4),
+            IndentGuide::new(buffer_id, 2, 2, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_indent_guides_continuing_off_screen(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        block1
+
+
+
+            block2
+        "
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..1,
+        vec![IndentGuide::new(buffer_id, 1, 1, 0, 4)],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_active_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(1, 0)..Point::new(1, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..3,
+        vec![IndentGuide::new(buffer_id, 1, 1, 0, 4)],
+        Some(vec![0]),
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_active_indent_guides_respect_indented_range(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        if 1 == 2 {
+            let a = 1;
+        }
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(1, 0)..Point::new(1, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..4,
+        vec![
+            IndentGuide::new(buffer_id, 1, 3, 0, 4),
+            IndentGuide::new(buffer_id, 2, 2, 1, 4),
+        ],
+        Some(vec![1]),
+        &mut cx,
+    );
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(2, 0)..Point::new(2, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..4,
+        vec![
+            IndentGuide::new(buffer_id, 1, 3, 0, 4),
+            IndentGuide::new(buffer_id, 2, 2, 1, 4),
+        ],
+        Some(vec![1]),
+        &mut cx,
+    );
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(3, 0)..Point::new(3, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..4,
+        vec![
+            IndentGuide::new(buffer_id, 1, 3, 0, 4),
+            IndentGuide::new(buffer_id, 2, 2, 1, 4),
+        ],
+        Some(vec![0]),
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_active_indent_guides_empty_line(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    fn main() {
+        let a = 1;
+
+        let b = 2;
+    }"
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(2, 0)..Point::new(2, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..5,
+        vec![IndentGuide::new(buffer_id, 1, 3, 0, 4)],
+        Some(vec![0]),
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_active_indent_guides_non_matching_indent(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+    def m:
+        a = 1
+        pass"
+            .unindent(),
+        cx,
+    )
+    .await;
+
+    cx.update_editor(|editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(1, 0)..Point::new(1, 0)])
+        });
+    });
+
+    assert_indent_guides(
+        0..3,
+        vec![IndentGuide::new(buffer_id, 1, 2, 0, 4)],
+        Some(vec![0]),
+        &mut cx,
+    );
+}
+
 #[gpui::test]
 fn test_flap_insertion_and_rendering(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/element.rs 🔗

@@ -38,7 +38,9 @@ use gpui::{
     ViewContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
-use language::language_settings::ShowWhitespaceSetting;
+use language::language_settings::{
+    IndentGuideBackgroundColoring, IndentGuideColoring, ShowWhitespaceSetting,
+};
 use lsp::DiagnosticSeverity;
 use multi_buffer::{Anchor, MultiBufferPoint, MultiBufferRow};
 use project::{
@@ -1460,6 +1462,118 @@ impl EditorElement {
         Some(shaped_lines)
     }
 
+    #[allow(clippy::too_many_arguments)]
+    fn layout_indent_guides(
+        &self,
+        content_origin: gpui::Point<Pixels>,
+        text_origin: gpui::Point<Pixels>,
+        visible_buffer_range: Range<MultiBufferRow>,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        snapshot: &DisplaySnapshot,
+        cx: &mut WindowContext,
+    ) -> Option<Vec<IndentGuideLayout>> {
+        let indent_guides =
+            self.editor
+                .read(cx)
+                .indent_guides(visible_buffer_range, snapshot, cx)?;
+
+        let active_indent_guide_indices = self.editor.update(cx, |editor, cx| {
+            editor
+                .find_active_indent_guide_indices(&indent_guides, snapshot, cx)
+                .unwrap_or_default()
+        });
+
+        Some(
+            indent_guides
+                .into_iter()
+                .enumerate()
+                .filter_map(|(i, indent_guide)| {
+                    let indent_size = self.column_pixels(indent_guide.indent_size as usize, cx);
+                    let total_width = indent_size * px(indent_guide.depth as f32);
+
+                    let start_x = content_origin.x + total_width - scroll_pixel_position.x;
+                    if start_x >= text_origin.x {
+                        let (offset_y, length) = Self::calculate_indent_guide_bounds(
+                            indent_guide.multibuffer_row_range.clone(),
+                            line_height,
+                            snapshot,
+                        );
+
+                        let start_y = content_origin.y + offset_y - scroll_pixel_position.y;
+
+                        Some(IndentGuideLayout {
+                            origin: point(start_x, start_y),
+                            length,
+                            indent_size,
+                            depth: indent_guide.depth,
+                            active: active_indent_guide_indices.contains(&i),
+                        })
+                    } else {
+                        None
+                    }
+                })
+                .collect(),
+        )
+    }
+
+    fn calculate_indent_guide_bounds(
+        row_range: Range<MultiBufferRow>,
+        line_height: Pixels,
+        snapshot: &DisplaySnapshot,
+    ) -> (gpui::Pixels, gpui::Pixels) {
+        let start_point = Point::new(row_range.start.0, 0);
+        let end_point = Point::new(row_range.end.0, 0);
+
+        let row_range = start_point.to_display_point(snapshot).row()
+            ..end_point.to_display_point(snapshot).row();
+
+        let mut prev_line = start_point;
+        prev_line.row = prev_line.row.saturating_sub(1);
+        let prev_line = prev_line.to_display_point(snapshot).row();
+
+        let mut cons_line = end_point;
+        cons_line.row += 1;
+        let cons_line = cons_line.to_display_point(snapshot).row();
+
+        let mut offset_y = row_range.start.0 as f32 * line_height;
+        let mut length = (cons_line.0.saturating_sub(row_range.start.0)) as f32 * line_height;
+
+        // If there is a block (e.g. diagnostic) in between the start of the indent guide and the line above,
+        // we want to extend the indent guide to the start of the block.
+        let mut block_height = 0;
+        let mut block_offset = 0;
+        let mut found_excerpt_header = false;
+        for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) {
+            if matches!(block, TransformBlock::ExcerptHeader { .. }) {
+                found_excerpt_header = true;
+                break;
+            }
+            block_offset += block.height();
+            block_height += block.height();
+        }
+        if !found_excerpt_header {
+            offset_y -= block_offset as f32 * line_height;
+            length += block_height as f32 * line_height;
+        }
+
+        // If there is a block (e.g. diagnostic) at the end of an multibuffer excerpt,
+        // we want to ensure that the indent guide stops before the excerpt header.
+        let mut block_height = 0;
+        let mut found_excerpt_header = false;
+        for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) {
+            if matches!(block, TransformBlock::ExcerptHeader { .. }) {
+                found_excerpt_header = true;
+            }
+            block_height += block.height();
+        }
+        if found_excerpt_header {
+            length -= block_height as f32 * line_height;
+        }
+
+        (offset_y, length)
+    }
+
     fn layout_run_indicators(
         &self,
         line_height: Pixels,
@@ -2500,6 +2614,91 @@ impl EditorElement {
         })
     }
 
+    fn paint_indent_guides(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) {
+        let Some(indent_guides) = &layout.indent_guides else {
+            return;
+        };
+
+        let settings = self
+            .editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .settings_at(0, cx)
+            .indent_guides;
+
+        let faded_color = |color: Hsla, alpha: f32| {
+            let mut faded = color;
+            faded.a = alpha;
+            faded
+        };
+
+        for indent_guide in indent_guides {
+            let indent_accent_colors = cx.theme().accents().color_for_index(indent_guide.depth);
+
+            // TODO fixed for now, expose them through themes later
+            const INDENT_AWARE_ALPHA: f32 = 0.2;
+            const INDENT_AWARE_ACTIVE_ALPHA: f32 = 0.4;
+            const INDENT_AWARE_BACKGROUND_ALPHA: f32 = 0.1;
+            const INDENT_AWARE_BACKGROUND_ACTIVE_ALPHA: f32 = 0.2;
+
+            let line_color = match (&settings.coloring, indent_guide.active) {
+                (IndentGuideColoring::Disabled, _) => None,
+                (IndentGuideColoring::Fixed, false) => {
+                    Some(cx.theme().colors().editor_indent_guide)
+                }
+                (IndentGuideColoring::Fixed, true) => {
+                    Some(cx.theme().colors().editor_indent_guide_active)
+                }
+                (IndentGuideColoring::IndentAware, false) => {
+                    Some(faded_color(indent_accent_colors, INDENT_AWARE_ALPHA))
+                }
+                (IndentGuideColoring::IndentAware, true) => {
+                    Some(faded_color(indent_accent_colors, INDENT_AWARE_ACTIVE_ALPHA))
+                }
+            };
+
+            let background_color = match (&settings.background_coloring, indent_guide.active) {
+                (IndentGuideBackgroundColoring::Disabled, _) => None,
+                (IndentGuideBackgroundColoring::IndentAware, false) => Some(faded_color(
+                    indent_accent_colors,
+                    INDENT_AWARE_BACKGROUND_ALPHA,
+                )),
+                (IndentGuideBackgroundColoring::IndentAware, true) => Some(faded_color(
+                    indent_accent_colors,
+                    INDENT_AWARE_BACKGROUND_ACTIVE_ALPHA,
+                )),
+            };
+
+            let requested_line_width = settings.line_width.clamp(1, 10);
+            let mut line_indicator_width = 0.;
+            if let Some(color) = line_color {
+                cx.paint_quad(fill(
+                    Bounds {
+                        origin: indent_guide.origin,
+                        size: size(px(requested_line_width as f32), indent_guide.length),
+                    },
+                    color,
+                ));
+                line_indicator_width = requested_line_width as f32;
+            }
+
+            if let Some(color) = background_color {
+                let width = indent_guide.indent_size - px(line_indicator_width);
+                cx.paint_quad(fill(
+                    Bounds {
+                        origin: point(
+                            indent_guide.origin.x + px(line_indicator_width),
+                            indent_guide.origin.y,
+                        ),
+                        size: size(width, indent_guide.length),
+                    },
+                    color,
+                ));
+            }
+        }
+    }
+
     fn paint_gutter(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) {
         let line_height = layout.position_map.line_height;
 
@@ -4146,6 +4345,21 @@ impl Element for EditorElement {
                         scroll_position.y * line_height,
                     );
 
+                    let start_buffer_row =
+                        MultiBufferRow(start_anchor.to_point(&snapshot.buffer_snapshot).row);
+                    let end_buffer_row =
+                        MultiBufferRow(end_anchor.to_point(&snapshot.buffer_snapshot).row);
+
+                    let indent_guides = self.layout_indent_guides(
+                        content_origin,
+                        text_hitbox.origin,
+                        start_buffer_row..end_buffer_row,
+                        scroll_pixel_position,
+                        line_height,
+                        &snapshot,
+                        cx,
+                    );
+
                     let flap_trailers = cx.with_element_namespace("flap_trailers", |cx| {
                         self.prepaint_flap_trailers(
                             flap_trailers,
@@ -4403,6 +4617,7 @@ impl Element for EditorElement {
                         }),
                         visible_display_row_range: start_row..end_row,
                         wrap_guides,
+                        indent_guides,
                         hitbox,
                         text_hitbox,
                         gutter_hitbox,
@@ -4492,6 +4707,7 @@ impl Element for EditorElement {
                 cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
                     self.paint_mouse_listeners(layout, hovered_hunk, cx);
                     self.paint_background(layout, cx);
+                    self.paint_indent_guides(layout, cx);
                     if layout.gutter_hitbox.size.width > Pixels::ZERO {
                         self.paint_gutter(layout, cx)
                     }
@@ -4530,6 +4746,7 @@ pub struct EditorLayout {
     scrollbar_layout: Option<ScrollbarLayout>,
     mode: EditorMode,
     wrap_guides: SmallVec<[(Pixels, bool); 2]>,
+    indent_guides: Option<Vec<IndentGuideLayout>>,
     visible_display_row_range: Range<DisplayRow>,
     active_rows: BTreeMap<DisplayRow, bool>,
     highlighted_rows: BTreeMap<DisplayRow, Hsla>,
@@ -4795,6 +5012,15 @@ fn layout_line(
     )
 }
 
+#[derive(Debug)]
+pub struct IndentGuideLayout {
+    origin: gpui::Point<Pixels>,
+    length: Pixels,
+    indent_size: Pixels,
+    depth: u32,
+    active: bool,
+}
+
 pub struct CursorLayout {
     origin: gpui::Point<Pixels>,
     block_width: Pixels,

crates/editor/src/indent_guides.rs 🔗

@@ -0,0 +1,164 @@
+use std::{ops::Range, time::Duration};
+
+use collections::HashSet;
+use gpui::{AppContext, Task};
+use language::BufferRow;
+use multi_buffer::{MultiBufferIndentGuide, MultiBufferRow};
+use text::{BufferId, Point};
+use ui::ViewContext;
+use util::ResultExt;
+
+use crate::{DisplaySnapshot, Editor};
+
+struct ActiveIndentedRange {
+    buffer_id: BufferId,
+    row_range: Range<BufferRow>,
+    indent: u32,
+}
+
+#[derive(Default)]
+pub struct ActiveIndentGuidesState {
+    pub dirty: bool,
+    cursor_row: MultiBufferRow,
+    pending_refresh: Option<Task<()>>,
+    active_indent_range: Option<ActiveIndentedRange>,
+}
+
+impl ActiveIndentGuidesState {
+    pub fn should_refresh(&self, cursor_row: MultiBufferRow) -> bool {
+        self.pending_refresh.is_none() && (self.cursor_row != cursor_row || self.dirty)
+    }
+}
+
+impl Editor {
+    pub fn indent_guides(
+        &self,
+        visible_buffer_range: Range<MultiBufferRow>,
+        snapshot: &DisplaySnapshot,
+        cx: &AppContext,
+    ) -> Option<Vec<MultiBufferIndentGuide>> {
+        if self.show_indent_guides == Some(false) {
+            return None;
+        }
+
+        let settings = self.buffer.read(cx).settings_at(0, cx);
+        if settings.indent_guides.enabled {
+            Some(indent_guides_in_range(visible_buffer_range, snapshot, cx))
+        } else {
+            None
+        }
+    }
+
+    pub fn find_active_indent_guide_indices(
+        &mut self,
+        indent_guides: &[MultiBufferIndentGuide],
+        snapshot: &DisplaySnapshot,
+        cx: &mut ViewContext<Editor>,
+    ) -> Option<HashSet<usize>> {
+        let selection = self.selections.newest::<Point>(cx);
+        let cursor_row = MultiBufferRow(selection.head().row);
+
+        let state = &mut self.active_indent_guides_state;
+        if state.cursor_row != cursor_row {
+            state.cursor_row = cursor_row;
+            state.dirty = true;
+        }
+
+        if state.should_refresh(cursor_row) {
+            let snapshot = snapshot.clone();
+            state.dirty = false;
+
+            let task = cx
+                .background_executor()
+                .spawn(resolve_indented_range(snapshot, cursor_row));
+
+            // Try to resolve the indent in a short amount of time, otherwise move it to a background task.
+            match cx
+                .background_executor()
+                .block_with_timeout(Duration::from_micros(200), task)
+            {
+                Ok(result) => state.active_indent_range = result,
+                Err(future) => {
+                    state.pending_refresh = Some(cx.spawn(|editor, mut cx| async move {
+                        let result = cx.background_executor().spawn(future).await;
+                        editor
+                            .update(&mut cx, |editor, _| {
+                                editor.active_indent_guides_state.active_indent_range = result;
+                                editor.active_indent_guides_state.pending_refresh = None;
+                            })
+                            .log_err();
+                    }));
+                    return None;
+                }
+            }
+        }
+
+        let active_indent_range = state.active_indent_range.as_ref()?;
+
+        let candidates = indent_guides
+            .iter()
+            .enumerate()
+            .filter(|(_, indent_guide)| {
+                indent_guide.buffer_id == active_indent_range.buffer_id
+                    && indent_guide.indent_width() == active_indent_range.indent
+            });
+
+        let mut matches = HashSet::default();
+        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.row_range.start <= indent.end_row
+                && indent.start_row <= active_indent_range.row_range.end
+            {
+                matches.insert(i);
+            }
+        }
+        Some(matches)
+    }
+}
+
+pub fn indent_guides_in_range(
+    visible_buffer_range: Range<MultiBufferRow>,
+    snapshot: &DisplaySnapshot,
+    cx: &AppContext,
+) -> Vec<MultiBufferIndentGuide> {
+    let start_anchor = snapshot
+        .buffer_snapshot
+        .anchor_before(Point::new(visible_buffer_range.start.0, 0));
+    let end_anchor = snapshot
+        .buffer_snapshot
+        .anchor_after(Point::new(visible_buffer_range.end.0, 0));
+
+    snapshot
+        .buffer_snapshot
+        .indent_guides_in_range(start_anchor..end_anchor, cx)
+        .into_iter()
+        .filter(|indent_guide| {
+            // Filter out indent guides that are inside a fold
+            !snapshot.is_line_folded(indent_guide.multibuffer_row_range.start)
+        })
+        .collect()
+}
+
+async fn resolve_indented_range(
+    snapshot: DisplaySnapshot,
+    buffer_row: MultiBufferRow,
+) -> Option<ActiveIndentedRange> {
+    let (buffer_row, buffer_snapshot, buffer_id) =
+        if let Some((_, buffer_id, snapshot)) = snapshot.buffer_snapshot.as_singleton() {
+            (buffer_row.0, snapshot, buffer_id)
+        } else {
+            let (snapshot, point) = snapshot.buffer_snapshot.buffer_line_for_row(buffer_row)?;
+
+            let buffer_id = snapshot.remote_id();
+            (point.start.row, snapshot, buffer_id)
+        };
+
+    buffer_snapshot
+        .enclosing_indent(buffer_row)
+        .await
+        .map(|(row_range, indent)| ActiveIndentedRange {
+            row_range,
+            indent,
+            buffer_id,
+        })
+}

crates/feedback/src/feedback_modal.rs 🔗

@@ -185,6 +185,7 @@ impl FeedbackModal {
                 cx,
             );
             editor.set_show_gutter(false, cx);
+            editor.set_show_indent_guides(false, cx);
             editor.set_show_inline_completions(false);
             editor.set_vertical_scroll_margin(5, cx);
             editor.set_use_modal_editing(false);

crates/language/src/buffer.rs 🔗

@@ -512,6 +512,37 @@ pub struct Runnable {
     pub buffer: BufferId,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct IndentGuide {
+    pub buffer_id: BufferId,
+    pub start_row: BufferRow,
+    pub end_row: BufferRow,
+    pub depth: u32,
+    pub indent_size: u32,
+}
+
+impl IndentGuide {
+    pub fn new(
+        buffer_id: BufferId,
+        start_row: BufferRow,
+        end_row: BufferRow,
+        depth: u32,
+        indent_size: u32,
+    ) -> Self {
+        Self {
+            buffer_id,
+            start_row,
+            end_row,
+            depth,
+            indent_size,
+        }
+    }
+
+    pub fn indent_width(&self) -> u32 {
+        self.indent_size * self.depth
+    }
+}
+
 impl Buffer {
     /// Create a new buffer with the given base text.
     pub fn local<T: Into<String>>(base_text: T, cx: &mut ModelContext<Self>) -> Self {
@@ -3059,6 +3090,236 @@ impl BufferSnapshot {
         })
     }
 
+    pub fn indent_guides_in_range(
+        &self,
+        range: Range<Anchor>,
+        cx: &AppContext,
+    ) -> Vec<IndentGuide> {
+        fn indent_size_for_row(this: &BufferSnapshot, row: BufferRow, cx: &AppContext) -> u32 {
+            let language = this.language_at(Point::new(row, 0));
+            language_settings(language, None, cx).tab_size.get() as u32
+        }
+
+        let start_row = range.start.to_point(self).row;
+        let end_row = range.end.to_point(self).row;
+        let row_range = start_row..end_row + 1;
+
+        let mut row_indents = self.line_indents_in_row_range(row_range.clone());
+
+        let mut result_vec = Vec::new();
+        let mut indent_stack = SmallVec::<[IndentGuide; 8]>::new();
+
+        // TODO: This should be calculated for every row but it is pretty expensive
+        let indent_size = indent_size_for_row(self, start_row, cx);
+
+        while let Some((first_row, mut line_indent, empty)) = row_indents.next() {
+            let current_depth = indent_stack.len() as u32;
+
+            // When encountering empty, continue until found useful line indent
+            // then add to the indent stack with the depth found
+            let mut found_indent = false;
+            let mut last_row = first_row;
+            if empty {
+                let mut trailing_row = end_row;
+                while !found_indent {
+                    let (target_row, new_line_indent, empty) =
+                        if let Some(display_row) = row_indents.next() {
+                            display_row
+                        } else {
+                            // This means we reached the end of the given range and found empty lines at the end.
+                            // We need to traverse further until we find a non-empty line to know if we need to add
+                            // an indent guide for the last visible indent.
+                            trailing_row += 1;
+
+                            const TRAILING_ROW_SEARCH_LIMIT: u32 = 25;
+                            if trailing_row > self.max_point().row
+                                || trailing_row > end_row + TRAILING_ROW_SEARCH_LIMIT
+                            {
+                                break;
+                            }
+                            let (new_line_indent, empty) = self.line_indent_for_row(trailing_row);
+                            (trailing_row, new_line_indent, empty)
+                        };
+
+                    if empty {
+                        continue;
+                    }
+                    last_row = target_row.min(end_row);
+                    line_indent = new_line_indent;
+                    found_indent = true;
+                    break;
+                }
+            } else {
+                found_indent = true
+            }
+
+            let depth = if found_indent {
+                line_indent / indent_size + ((line_indent % indent_size) > 0) as u32
+            } else {
+                current_depth
+            };
+
+            if depth < current_depth {
+                for _ in 0..(current_depth - depth) {
+                    let mut indent = indent_stack.pop().unwrap();
+                    if last_row != first_row {
+                        // In this case, we landed on an empty row, had to seek forward,
+                        // and discovered that the indent we where on is ending.
+                        // This means that the last display row must
+                        // be on line that ends this indent range, so we
+                        // should display the range up to the first non-empty line
+                        indent.end_row = first_row.saturating_sub(1);
+                    }
+
+                    result_vec.push(indent)
+                }
+            } else if depth > current_depth {
+                for next_depth in current_depth..depth {
+                    indent_stack.push(IndentGuide {
+                        buffer_id: self.remote_id(),
+                        start_row: first_row,
+                        end_row: last_row,
+                        depth: next_depth,
+                        indent_size,
+                    });
+                }
+            }
+
+            for indent in indent_stack.iter_mut() {
+                indent.end_row = last_row;
+            }
+        }
+
+        result_vec.extend(indent_stack);
+
+        result_vec
+    }
+
+    pub async fn enclosing_indent(
+        &self,
+        mut buffer_row: BufferRow,
+    ) -> Option<(Range<BufferRow>, u32)> {
+        let max_row = self.max_point().row;
+        if buffer_row >= max_row {
+            return None;
+        }
+
+        let (mut target_indent_size, is_blank) = self.line_indent_for_row(buffer_row);
+
+        // If the current row is at the start of an indented block, we want to return this
+        // block as the enclosing indent.
+        if !is_blank && buffer_row < max_row {
+            let (next_line_indent, is_blank) = self.line_indent_for_row(buffer_row + 1);
+            if !is_blank && target_indent_size < next_line_indent {
+                target_indent_size = next_line_indent;
+                buffer_row += 1;
+            }
+        }
+
+        const SEARCH_ROW_LIMIT: u32 = 25000;
+        const SEARCH_WHITESPACE_ROW_LIMIT: u32 = 2500;
+        const YIELD_INTERVAL: u32 = 100;
+
+        let mut accessed_row_counter = 0;
+
+        // If there is a blank line at the current row, search for the next non indented lines
+        if is_blank {
+            let start = buffer_row.saturating_sub(SEARCH_WHITESPACE_ROW_LIMIT);
+            let end = (max_row + 1).min(buffer_row + SEARCH_WHITESPACE_ROW_LIMIT);
+
+            let mut non_empty_line_above = None;
+            for (row, indent_size, is_blank) in self
+                .text
+                .reversed_line_indents_in_row_range(start..buffer_row)
+            {
+                accessed_row_counter += 1;
+                if accessed_row_counter == YIELD_INTERVAL {
+                    accessed_row_counter = 0;
+                    yield_now().await;
+                }
+                if !is_blank {
+                    non_empty_line_above = Some((row, indent_size));
+                    break;
+                }
+            }
+
+            let mut non_empty_line_below = None;
+            for (row, indent_size, is_blank) in
+                self.text.line_indents_in_row_range((buffer_row + 1)..end)
+            {
+                accessed_row_counter += 1;
+                if accessed_row_counter == YIELD_INTERVAL {
+                    accessed_row_counter = 0;
+                    yield_now().await;
+                }
+                if !is_blank {
+                    non_empty_line_below = Some((row, indent_size));
+                    break;
+                }
+            }
+
+            let (row, indent_size) = match (non_empty_line_above, non_empty_line_below) {
+                (Some((above_row, above_indent)), Some((below_row, below_indent))) => {
+                    if above_indent >= below_indent {
+                        (above_row, above_indent)
+                    } else {
+                        (below_row, below_indent)
+                    }
+                }
+                (Some(above), None) => above,
+                (None, Some(below)) => below,
+                _ => return None,
+            };
+
+            target_indent_size = indent_size;
+            buffer_row = row;
+        }
+
+        let start = buffer_row.saturating_sub(SEARCH_ROW_LIMIT);
+        let end = (max_row + 1).min(buffer_row + SEARCH_ROW_LIMIT);
+
+        let mut start_indent = None;
+        for (row, indent_size, is_blank) in self
+            .text
+            .reversed_line_indents_in_row_range(start..buffer_row)
+        {
+            accessed_row_counter += 1;
+            if accessed_row_counter == YIELD_INTERVAL {
+                accessed_row_counter = 0;
+                yield_now().await;
+            }
+            if !is_blank && indent_size < target_indent_size {
+                start_indent = Some((row, indent_size));
+                break;
+            }
+        }
+        let (start_row, start_indent_size) = start_indent?;
+
+        let mut end_indent = (end, None);
+        for (row, indent_size, is_blank) in
+            self.text.line_indents_in_row_range((buffer_row + 1)..end)
+        {
+            accessed_row_counter += 1;
+            if accessed_row_counter == YIELD_INTERVAL {
+                accessed_row_counter = 0;
+                yield_now().await;
+            }
+            if !is_blank && indent_size < target_indent_size {
+                end_indent = (row.saturating_sub(1), Some(indent_size));
+                break;
+            }
+        }
+        let (end_row, end_indent_size) = end_indent;
+
+        let indent_size = if let Some(end_indent_size) = end_indent_size {
+            start_indent_size.max(end_indent_size)
+        } else {
+            start_indent_size
+        };
+
+        Some((start_row..end_row, indent_size))
+    }
+
     /// Returns selections for remote peers intersecting the given range.
     #[allow(clippy::type_complexity)]
     pub fn remote_selections_in_range(

crates/language/src/buffer_tests.rs 🔗

@@ -2052,6 +2052,71 @@ fn test_serialization(cx: &mut gpui::AppContext) {
     assert_eq!(buffer2.read(cx).text(), "abcDF");
 }
 
+#[gpui::test]
+async fn test_find_matching_indent(cx: &mut TestAppContext) {
+    cx.update(|cx| init_settings(cx, |_| {}));
+
+    async fn enclosing_indent(
+        text: impl Into<String>,
+        buffer_row: u32,
+        cx: &mut TestAppContext,
+    ) -> Option<(Range<u32>, u32)> {
+        let buffer = cx.new_model(|cx| Buffer::local(text, cx));
+        let snapshot = cx.read(|cx| buffer.read(cx).snapshot());
+        snapshot.enclosing_indent(buffer_row).await
+    }
+
+    assert_eq!(
+        enclosing_indent(
+            "
+        fn b() {
+            if c {
+                let d = 2;
+            }
+        }"
+            .unindent(),
+            1,
+            cx,
+        )
+        .await,
+        Some((1..2, 4))
+    );
+
+    assert_eq!(
+        enclosing_indent(
+            "
+        fn b() {
+            if c {
+                let d = 2;
+            }
+        }"
+            .unindent(),
+            2,
+            cx,
+        )
+        .await,
+        Some((1..2, 4))
+    );
+
+    assert_eq!(
+        enclosing_indent(
+            "
+        fn b() {
+            if c {
+                let d = 2;
+
+                let e = 5;
+            }
+        }"
+            .unindent(),
+            3,
+            cx,
+        )
+        .await,
+        Some((1..4, 4))
+    );
+}
+
 #[gpui::test(iterations = 100)]
 fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
     let min_peers = env::var("MIN_PEERS")

crates/language/src/language_settings.rs 🔗

@@ -78,6 +78,8 @@ pub struct LanguageSettings {
     pub show_wrap_guides: bool,
     /// Character counts at which to show wrap guides in the editor.
     pub wrap_guides: Vec<usize>,
+    /// Indent guide related settings.
+    pub indent_guides: IndentGuideSettings,
     /// Whether or not to perform a buffer format before saving.
     pub format_on_save: FormatOnSave,
     /// Whether or not to remove any trailing whitespace from lines of a buffer
@@ -242,6 +244,9 @@ pub struct LanguageSettingsContent {
     /// Default: []
     #[serde(default)]
     pub wrap_guides: Option<Vec<usize>>,
+    /// Indent guide related settings.
+    #[serde(default)]
+    pub indent_guides: Option<IndentGuideSettings>,
     /// Whether or not to perform a buffer format before saving.
     ///
     /// Default: on
@@ -411,6 +416,59 @@ pub enum Formatter {
     CodeActions(HashMap<String, bool>),
 }
 
+/// The settings for indent guides.
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct IndentGuideSettings {
+    /// Whether to display indent guides in the editor.
+    ///
+    /// Default: true
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// The width of the indent guides in pixels, between 1 and 10.
+    ///
+    /// Default: 1
+    #[serde(default = "line_width")]
+    pub line_width: u32,
+    /// Determines how indent guides are colored.
+    ///
+    /// Default: Fixed
+    #[serde(default)]
+    pub coloring: IndentGuideColoring,
+    /// Determines how indent guide backgrounds are colored.
+    ///
+    /// Default: Disabled
+    #[serde(default)]
+    pub background_coloring: IndentGuideBackgroundColoring,
+}
+
+fn line_width() -> u32 {
+    1
+}
+
+/// Determines how indent guides are colored.
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum IndentGuideColoring {
+    /// Do not render any lines for indent guides.
+    Disabled,
+    /// Use the same color for all indentation levels.
+    #[default]
+    Fixed,
+    /// Use a different color for each indentation level.
+    IndentAware,
+}
+
+/// Determines how indent guide backgrounds are colored.
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum IndentGuideBackgroundColoring {
+    /// Do not render any background for indent guides.
+    #[default]
+    Disabled,
+    /// Use a different color for each indentation level.
+    IndentAware,
+}
+
 /// The settings for inlay hints.
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 pub struct InlayHintSettings {
@@ -715,6 +773,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
     );
     merge(&mut settings.show_wrap_guides, src.show_wrap_guides);
     merge(&mut settings.wrap_guides, src.wrap_guides.clone());
+    merge(&mut settings.indent_guides, src.indent_guides);
     merge(
         &mut settings.code_actions_on_format,
         src.code_actions_on_format.clone(),

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -12,9 +12,9 @@ use language::{
     char_kind,
     language_settings::{language_settings, LanguageSettings},
     AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharKind, Chunk,
-    CursorShape, DiagnosticEntry, File, IndentSize, Language, LanguageScope, OffsetRangeExt,
-    OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
-    ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
+    CursorShape, DiagnosticEntry, File, IndentGuide, IndentSize, Language, LanguageScope,
+    OffsetRangeExt, OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension,
+    ToOffset as _, ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
 };
 use smallvec::SmallVec;
 use std::{
@@ -281,6 +281,20 @@ struct ExcerptBytes<'a> {
     reversed: bool,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct MultiBufferIndentGuide {
+    pub multibuffer_row_range: Range<MultiBufferRow>,
+    pub buffer: IndentGuide,
+}
+
+impl std::ops::Deref for MultiBufferIndentGuide {
+    type Target = IndentGuide;
+
+    fn deref(&self) -> &Self::Target {
+        &self.buffer
+    }
+}
+
 impl MultiBuffer {
     pub fn new(replica_id: ReplicaId, capability: Capability) -> Self {
         Self {
@@ -1255,6 +1269,15 @@ impl MultiBuffer {
         excerpts
     }
 
+    pub fn excerpt_buffer_ids(&self) -> Vec<BufferId> {
+        self.snapshot
+            .borrow()
+            .excerpts
+            .iter()
+            .map(|entry| entry.buffer_id)
+            .collect()
+    }
+
     pub fn excerpt_ids(&self) -> Vec<ExcerptId> {
         self.snapshot
             .borrow()
@@ -3182,6 +3205,52 @@ impl MultiBufferSnapshot {
             })
     }
 
+    pub fn indent_guides_in_range(
+        &self,
+        range: Range<Anchor>,
+        cx: &AppContext,
+    ) -> Vec<MultiBufferIndentGuide> {
+        // Fast path for singleton buffers, we can skip the conversion between offsets.
+        if let Some((_, _, snapshot)) = self.as_singleton() {
+            return snapshot
+                .indent_guides_in_range(range.start.text_anchor..range.end.text_anchor, cx)
+                .into_iter()
+                .map(|guide| MultiBufferIndentGuide {
+                    multibuffer_row_range: MultiBufferRow(guide.start_row)
+                        ..MultiBufferRow(guide.end_row),
+                    buffer: guide,
+                })
+                .collect();
+        }
+
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+
+        self.excerpts_for_range(range.clone())
+            .flat_map(move |(excerpt, excerpt_offset)| {
+                let excerpt_buffer_start_row =
+                    excerpt.range.context.start.to_point(&excerpt.buffer).row;
+                let excerpt_offset_row = crate::ToPoint::to_point(&excerpt_offset, self).row;
+
+                excerpt
+                    .buffer
+                    .indent_guides_in_range(excerpt.range.context.clone(), cx)
+                    .into_iter()
+                    .map(move |indent_guide| {
+                        let start_row = excerpt_offset_row
+                            + (indent_guide.start_row - excerpt_buffer_start_row);
+                        let end_row =
+                            excerpt_offset_row + (indent_guide.end_row - excerpt_buffer_start_row);
+
+                        MultiBufferIndentGuide {
+                            multibuffer_row_range: MultiBufferRow(start_row)
+                                ..MultiBufferRow(end_row),
+                            buffer: indent_guide,
+                        }
+                    })
+            })
+            .collect()
+    }
+
     pub fn diagnostics_update_count(&self) -> usize {
         self.diagnostics_update_count
     }

crates/rope/src/rope.rs 🔗

@@ -619,10 +619,12 @@ impl<'a> Chunks<'a> {
     }
 
     pub fn lines(self) -> Lines<'a> {
+        let reversed = self.reversed;
         Lines {
             chunks: self,
             current_line: String::new(),
             done: false,
+            reversed,
         }
     }
 }
@@ -726,6 +728,7 @@ pub struct Lines<'a> {
     chunks: Chunks<'a>,
     current_line: String,
     done: bool,
+    reversed: bool,
 }
 
 impl<'a> Lines<'a> {
@@ -737,13 +740,26 @@ impl<'a> Lines<'a> {
         self.current_line.clear();
 
         while let Some(chunk) = self.chunks.peek() {
-            let mut lines = chunk.split('\n').peekable();
-            while let Some(line) = lines.next() {
-                self.current_line.push_str(line);
-                if lines.peek().is_some() {
-                    self.chunks
-                        .seek(self.chunks.offset() + line.len() + "\n".len());
-                    return Some(&self.current_line);
+            let lines = chunk.split('\n');
+            if self.reversed {
+                let mut lines = lines.rev().peekable();
+                while let Some(line) = lines.next() {
+                    self.current_line.insert_str(0, line);
+                    if lines.peek().is_some() {
+                        self.chunks
+                            .seek(self.chunks.offset() - line.len() - "\n".len());
+                        return Some(&self.current_line);
+                    }
+                }
+            } else {
+                let mut lines = lines.peekable();
+                while let Some(line) = lines.next() {
+                    self.current_line.push_str(line);
+                    if lines.peek().is_some() {
+                        self.chunks
+                            .seek(self.chunks.offset() + line.len() + "\n".len());
+                        return Some(&self.current_line);
+                    }
                 }
             }
 
@@ -1355,6 +1371,21 @@ mod tests {
         assert_eq!(lines.next(), Some("hi"));
         assert_eq!(lines.next(), Some(""));
         assert_eq!(lines.next(), None);
+
+        let rope = Rope::from("abc\ndefg\nhi");
+        let mut lines = rope.reversed_chunks_in_range(0..rope.len()).lines();
+        assert_eq!(lines.next(), Some("hi"));
+        assert_eq!(lines.next(), Some("defg"));
+        assert_eq!(lines.next(), Some("abc"));
+        assert_eq!(lines.next(), None);
+
+        let rope = Rope::from("abc\ndefg\nhi\n");
+        let mut lines = rope.reversed_chunks_in_range(0..rope.len()).lines();
+        assert_eq!(lines.next(), Some(""));
+        assert_eq!(lines.next(), Some("hi"));
+        assert_eq!(lines.next(), Some("defg"));
+        assert_eq!(lines.next(), Some("abc"));
+        assert_eq!(lines.next(), None);
     }
 
     #[gpui::test(iterations = 100)]

crates/text/src/text.rs 🔗

@@ -1865,6 +1865,87 @@ impl BufferSnapshot {
         (row_end_offset - row_start_offset) as u32
     }
 
+    pub fn line_indents_in_row_range(
+        &self,
+        row_range: Range<u32>,
+    ) -> impl Iterator<Item = (u32, u32, bool)> + '_ {
+        let start = Point::new(row_range.start, 0).to_offset(self);
+        let end = Point::new(row_range.end, 0).to_offset(self);
+
+        let mut lines = self.as_rope().chunks_in_range(start..end).lines();
+        let mut row = row_range.start;
+        std::iter::from_fn(move || {
+            if let Some(line) = lines.next() {
+                let mut indent_size = 0;
+                let mut is_blank = true;
+
+                for c in line.chars() {
+                    is_blank = false;
+                    if c == ' ' || c == '\t' {
+                        indent_size += 1;
+                    } else {
+                        break;
+                    }
+                }
+
+                row += 1;
+                Some((row - 1, indent_size, is_blank))
+            } else {
+                None
+            }
+        })
+    }
+
+    pub fn reversed_line_indents_in_row_range(
+        &self,
+        row_range: Range<u32>,
+    ) -> impl Iterator<Item = (u32, u32, bool)> + '_ {
+        let start = Point::new(row_range.start, 0).to_offset(self);
+        let end = Point::new(row_range.end, 0)
+            .to_offset(self)
+            .saturating_sub(1);
+
+        let mut lines = self.as_rope().reversed_chunks_in_range(start..end).lines();
+        let mut row = row_range.end;
+        std::iter::from_fn(move || {
+            if let Some(line) = lines.next() {
+                let mut indent_size = 0;
+                let mut is_blank = true;
+
+                for c in line.chars() {
+                    is_blank = false;
+                    if c == ' ' || c == '\t' {
+                        indent_size += 1;
+                    } else {
+                        break;
+                    }
+                }
+
+                row = row.saturating_sub(1);
+                Some((row, indent_size, is_blank))
+            } else {
+                None
+            }
+        })
+    }
+
+    pub fn line_indent_for_row(&self, row: u32) -> (u32, bool) {
+        let mut indent_size = 0;
+        let mut is_blank = false;
+        for c in self.chars_at(Point::new(row, 0)) {
+            if c == ' ' || c == '\t' {
+                indent_size += 1;
+            } else {
+                if c == '\n' {
+                    is_blank = true;
+                }
+                break;
+            }
+        }
+
+        (indent_size, is_blank)
+    }
+
     pub fn is_line_blank(&self, row: u32) -> bool {
         self.text_for_range(Point::new(row, 0)..Point::new(row, self.line_len(row)))
             .all(|chunk| chunk.matches(|c: char| !c.is_whitespace()).next().is_none())

crates/theme/src/default_colors.rs 🔗

@@ -75,6 +75,8 @@ impl ThemeColors {
             editor_invisible: neutral().light().step_10(),
             editor_wrap_guide: neutral().light_alpha().step_7(),
             editor_active_wrap_guide: neutral().light_alpha().step_8(),
+            editor_indent_guide: neutral().light_alpha().step_5(),
+            editor_indent_guide_active: neutral().light_alpha().step_6(),
             editor_document_highlight_read_background: neutral().light_alpha().step_3(),
             editor_document_highlight_write_background: neutral().light_alpha().step_4(),
             terminal_background: neutral().light().step_1(),
@@ -170,6 +172,8 @@ impl ThemeColors {
             editor_invisible: neutral().dark_alpha().step_4(),
             editor_wrap_guide: neutral().dark_alpha().step_4(),
             editor_active_wrap_guide: neutral().dark_alpha().step_4(),
+            editor_indent_guide: neutral().dark_alpha().step_4(),
+            editor_indent_guide_active: neutral().dark_alpha().step_6(),
             editor_document_highlight_read_background: neutral().dark_alpha().step_4(),
             editor_document_highlight_write_background: neutral().dark_alpha().step_4(),
             terminal_background: neutral().dark().step_1(),

crates/theme/src/default_theme.rs 🔗

@@ -2,7 +2,7 @@ use std::sync::Arc;
 
 use gpui::WindowBackgroundAppearance;
 
-use crate::prelude::*;
+use crate::AccentColors;
 
 use crate::{
     default_color_scales,
@@ -23,21 +23,7 @@ fn zed_pro_daylight() -> Theme {
             status: StatusColors::light(),
             player: PlayerColors::light(),
             syntax: Arc::new(SyntaxTheme::default()),
-            accents: vec![
-                blue().light().step_9(),
-                orange().light().step_9(),
-                pink().light().step_9(),
-                lime().light().step_9(),
-                purple().light().step_9(),
-                amber().light().step_9(),
-                jade().light().step_9(),
-                tomato().light().step_9(),
-                cyan().light().step_9(),
-                gold().light().step_9(),
-                grass().light().step_9(),
-                indigo().light().step_9(),
-                iris().light().step_9(),
-            ],
+            accents: AccentColors::light(),
         },
     }
 }
@@ -54,21 +40,7 @@ pub(crate) fn zed_pro_moonlight() -> Theme {
             status: StatusColors::dark(),
             player: PlayerColors::dark(),
             syntax: Arc::new(SyntaxTheme::default()),
-            accents: vec![
-                blue().dark().step_9(),
-                orange().dark().step_9(),
-                pink().dark().step_9(),
-                lime().dark().step_9(),
-                purple().dark().step_9(),
-                amber().dark().step_9(),
-                jade().dark().step_9(),
-                tomato().dark().step_9(),
-                cyan().dark().step_9(),
-                gold().dark().step_9(),
-                grass().dark().step_9(),
-                indigo().dark().step_9(),
-                iris().dark().step_9(),
-            ],
+            accents: AccentColors::dark(),
         },
     }
 }

crates/theme/src/one_themes.rs 🔗

@@ -3,8 +3,8 @@ use std::sync::Arc;
 use gpui::{hsla, FontStyle, FontWeight, HighlightStyle, WindowBackgroundAppearance};
 
 use crate::{
-    default_color_scales, Appearance, PlayerColors, StatusColors, SyntaxTheme, SystemColors, Theme,
-    ThemeColors, ThemeFamily, ThemeStyles,
+    default_color_scales, AccentColors, Appearance, PlayerColors, StatusColors, SyntaxTheme,
+    SystemColors, Theme, ThemeColors, ThemeFamily, ThemeStyles,
 };
 
 // Note: This theme family is not the one you see in Zed at the moment.
@@ -42,6 +42,7 @@ pub(crate) fn one_dark() -> Theme {
         styles: ThemeStyles {
             window_background_appearance: WindowBackgroundAppearance::Opaque,
             system: SystemColors::default(),
+            accents: AccentColors(vec![blue, orange, purple, teal, red, green, yellow]),
             colors: ThemeColors {
                 border: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
                 border_variant: hsla(228. / 360., 8. / 100., 25. / 100., 1.),
@@ -91,6 +92,8 @@ pub(crate) fn one_dark() -> Theme {
                 editor_invisible: hsla(222.0 / 360., 11.5 / 100., 34.1 / 100., 1.0),
                 editor_wrap_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.),
                 editor_active_wrap_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.),
+                editor_indent_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.),
+                editor_indent_guide_active: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
                 editor_document_highlight_read_background: hsla(
                     207.8 / 360.,
                     81. / 100.,
@@ -249,7 +252,6 @@ pub(crate) fn one_dark() -> Theme {
                     ("variant".into(), HighlightStyle::default()),
                 ],
             }),
-            accents: vec![blue, orange, purple, teal],
         },
     }
 }

crates/theme/src/registry.rs 🔗

@@ -12,8 +12,9 @@ use refineable::Refineable;
 use util::ResultExt;
 
 use crate::{
-    try_parse_color, Appearance, AppearanceContent, PlayerColors, StatusColors, SyntaxTheme,
-    SystemColors, Theme, ThemeColors, ThemeContent, ThemeFamily, ThemeFamilyContent, ThemeStyles,
+    try_parse_color, AccentColors, Appearance, AppearanceContent, PlayerColors, StatusColors,
+    SyntaxTheme, SystemColors, Theme, ThemeColors, ThemeContent, ThemeFamily, ThemeFamilyContent,
+    ThemeStyles,
 };
 
 #[derive(Debug, Clone)]
@@ -118,6 +119,12 @@ impl ThemeRegistry {
             };
             player_colors.merge(&user_theme.style.players);
 
+            let mut accent_colors = match user_theme.appearance {
+                AppearanceContent::Light => AccentColors::light(),
+                AppearanceContent::Dark => AccentColors::dark(),
+            };
+            accent_colors.merge(&user_theme.style.accents);
+
             let syntax_highlights = user_theme
                 .style
                 .syntax
@@ -156,11 +163,11 @@ impl ThemeRegistry {
                 styles: ThemeStyles {
                     system: SystemColors::default(),
                     window_background_appearance,
+                    accents: accent_colors,
                     colors: theme_colors,
                     status: status_colors,
                     player: player_colors,
                     syntax: syntax_theme,
-                    accents: Vec::new(),
                 },
             }
         }));

crates/theme/src/schema.rs 🔗

@@ -75,6 +75,9 @@ pub struct ThemeStyleContent {
     #[serde(default, rename = "background.appearance")]
     pub window_background_appearance: Option<WindowBackgroundContent>,
 
+    #[serde(default)]
+    pub accents: Vec<AccentContent>,
+
     #[serde(flatten, default)]
     pub colors: ThemeColorsContent,
 
@@ -381,6 +384,12 @@ pub struct ThemeColorsContent {
     #[serde(rename = "editor.active_wrap_guide")]
     pub editor_active_wrap_guide: Option<String>,
 
+    #[serde(rename = "editor.indent_guide")]
+    pub editor_indent_guide: Option<String>,
+
+    #[serde(rename = "editor.indent_guide_active")]
+    pub editor_indent_guide_active: Option<String>,
+
     /// Read-access of a symbol, like reading a variable.
     ///
     /// A document highlight is a range inside a text document which deserves
@@ -747,6 +756,14 @@ impl ThemeColorsContent {
                 .editor_active_wrap_guide
                 .as_ref()
                 .and_then(|color| try_parse_color(color).ok()),
+            editor_indent_guide: self
+                .editor_indent_guide
+                .as_ref()
+                .and_then(|color| try_parse_color(color).ok()),
+            editor_indent_guide_active: self
+                .editor_indent_guide_active
+                .as_ref()
+                .and_then(|color| try_parse_color(color).ok()),
             editor_document_highlight_read_background: self
                 .editor_document_highlight_read_background
                 .as_ref()
@@ -1196,6 +1213,9 @@ impl StatusColorsContent {
     }
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct AccentContent(pub Option<String>);
+
 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 pub struct PlayerColorContent {
     pub cursor: Option<String>,

crates/theme/src/settings.rs 🔗

@@ -325,6 +325,7 @@ impl ThemeSettings {
                 .status
                 .refine(&theme_overrides.status_colors_refinement());
             base_theme.styles.player.merge(&theme_overrides.players);
+            base_theme.styles.accents.merge(&theme_overrides.accents);
             base_theme.styles.syntax =
                 SyntaxTheme::merge(base_theme.styles.syntax, theme_overrides.syntax_overrides());
 

crates/theme/src/styles.rs 🔗

@@ -1,3 +1,4 @@
+mod accents;
 mod colors;
 mod players;
 mod status;
@@ -7,6 +8,7 @@ mod system;
 #[cfg(feature = "stories")]
 mod stories;
 
+pub use accents::*;
 pub use colors::*;
 pub use players::*;
 pub use status::*;

crates/theme/src/styles/accents.rs 🔗

@@ -0,0 +1,85 @@
+use gpui::Hsla;
+use serde_derive::Deserialize;
+
+use crate::{
+    amber, blue, cyan, gold, grass, indigo, iris, jade, lime, orange, pink, purple, tomato,
+    try_parse_color, AccentContent,
+};
+
+/// A collection of colors that are used to color indent aware lines in the editor.
+#[derive(Clone, Deserialize)]
+pub struct AccentColors(pub Vec<Hsla>);
+
+impl Default for AccentColors {
+    /// Don't use this!
+    /// We have to have a default to be `[refineable::Refinable]`.
+    /// TODO "Find a way to not need this for Refinable"
+    fn default() -> Self {
+        Self::dark()
+    }
+}
+
+impl AccentColors {
+    pub fn dark() -> Self {
+        Self(vec![
+            blue().dark().step_9(),
+            orange().dark().step_9(),
+            pink().dark().step_9(),
+            lime().dark().step_9(),
+            purple().dark().step_9(),
+            amber().dark().step_9(),
+            jade().dark().step_9(),
+            tomato().dark().step_9(),
+            cyan().dark().step_9(),
+            gold().dark().step_9(),
+            grass().dark().step_9(),
+            indigo().dark().step_9(),
+            iris().dark().step_9(),
+        ])
+    }
+
+    pub fn light() -> Self {
+        Self(vec![
+            blue().light().step_9(),
+            orange().light().step_9(),
+            pink().light().step_9(),
+            lime().light().step_9(),
+            purple().light().step_9(),
+            amber().light().step_9(),
+            jade().light().step_9(),
+            tomato().light().step_9(),
+            cyan().light().step_9(),
+            gold().light().step_9(),
+            grass().light().step_9(),
+            indigo().light().step_9(),
+            iris().light().step_9(),
+        ])
+    }
+}
+
+impl AccentColors {
+    pub fn color_for_index(&self, index: u32) -> Hsla {
+        self.0[index as usize % self.0.len()]
+    }
+
+    /// Merges the given accent colors into this [`AccentColors`] instance.
+    pub fn merge(&mut self, accent_colors: &[AccentContent]) {
+        if accent_colors.is_empty() {
+            return;
+        }
+
+        let colors = accent_colors
+            .iter()
+            .filter_map(|accent_color| {
+                accent_color
+                    .0
+                    .as_ref()
+                    .and_then(|color| try_parse_color(color).ok())
+            })
+            .collect::<Vec<_>>();
+
+        if !colors.is_empty() {
+            self.0 = colors;
+        }
+    }
+}

crates/theme/src/styles/colors.rs 🔗

@@ -2,7 +2,9 @@ use gpui::{Hsla, WindowBackgroundAppearance};
 use refineable::Refineable;
 use std::sync::Arc;
 
-use crate::{PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme, SystemColors};
+use crate::{
+    AccentColors, PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme, SystemColors,
+};
 
 #[derive(Refineable, Clone, Debug)]
 #[refineable(Debug, serde::Deserialize)]
@@ -154,6 +156,8 @@ pub struct ThemeColors {
     pub editor_invisible: Hsla,
     pub editor_wrap_guide: Hsla,
     pub editor_active_wrap_guide: Hsla,
+    pub editor_indent_guide: Hsla,
+    pub editor_indent_guide_active: Hsla,
     /// Read-access of a symbol, like reading a variable.
     ///
     /// A document highlight is a range inside a text document which deserves
@@ -242,7 +246,7 @@ pub struct ThemeStyles {
     /// An array of colors used for theme elements that iterate through a series of colors.
     ///
     /// Example: Player colors, rainbow brackets and indent guides, etc.
-    pub accents: Vec<Hsla>,
+    pub accents: AccentColors,
 
     #[refineable]
     pub colors: ThemeColors,
@@ -251,6 +255,7 @@ pub struct ThemeStyles {
     pub status: StatusColors,
 
     pub player: PlayerColors,
+
     pub syntax: Arc<SyntaxTheme>,
 }
 

crates/theme/src/theme.rs 🔗

@@ -125,6 +125,12 @@ impl Theme {
         &self.styles.system
     }
 
+    /// Returns the [`AccentColors`] for the theme.
+    #[inline(always)]
+    pub fn accents(&self) -> &AccentColors {
+        &self.styles.accents
+    }
+
     /// Returns the [`PlayerColors`] for the theme.
     #[inline(always)]
     pub fn players(&self) -> &PlayerColors {

crates/theme_importer/src/vscode/converter.rs 🔗

@@ -57,6 +57,7 @@ impl VsCodeThemeConverter {
             appearance,
             style: ThemeStyleContent {
                 window_background_appearance: Some(theme::WindowBackgroundContent::Opaque),
+                accents: Vec::new(), //TODO can we read this from the theme?
                 colors: theme_colors,
                 status: status_colors,
                 players: Vec::new(),