From feea607bac1ae99c26bd10e4bf4cd28448e3ecfe Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 23 May 2024 15:50:59 +0200 Subject: [PATCH] Indent guides (#11503) 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 --- assets/settings/default.json | 19 + assets/themes/gruvbox/gruvbox.json | 54 ++ crates/assistant/src/assistant_panel.rs | 1 + .../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, 1705 insertions(+), 65 deletions(-) create mode 100644 crates/editor/src/indent_guides.rs create mode 100644 crates/theme/src/styles/accents.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index ab10a3c517..a1cc720e8c 100644 --- a/assets/settings/default.json +++ b/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 diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 5089f6c2da..d0dd396457 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/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", diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 32cc6995bb..24dc91ddd7 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/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 }); diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index a4e71cc312..294f467843 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/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) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 9ac9c7c83c..ce74880e47 100644 --- a/crates/editor/src/display_map.rs +++ b/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 { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 26ee9e5fce..e2b0f78d79 100644 --- a/crates/editor/src/editor.rs +++ b/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, show_code_actions: Option, show_wrap_guides: Option, + show_indent_guides: Option, placeholder_text: Option>, highlight_order: usize, highlighted_rows: HashMap>, background_highlights: TreeMap, scrollbar_marker_state: ScrollbarMarkerState, + active_indent_guides_state: ActiveIndentGuidesState, nav_history: Option, context_menu: RwLock>, mouse_context_menu: Option, @@ -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.show_indent_guides = Some(show_indent_guides); + cx.notify(); + } + pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { 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) { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fb19424fc4..c433cfaabe 100644 --- a/crates/editor/src/editor_tests.rs +++ b/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, + expected: Vec, + active_indices: Option>, + 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::>(), + 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, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2b69874a75..8379451c40 100644 --- a/crates/editor/src/element.rs +++ b/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, + text_origin: gpui::Point, + visible_buffer_range: Range, + scroll_pixel_position: gpui::Point, + line_height: Pixels, + snapshot: &DisplaySnapshot, + cx: &mut WindowContext, + ) -> Option> { + 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, + 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, mode: EditorMode, wrap_guides: SmallVec<[(Pixels, bool); 2]>, + indent_guides: Option>, visible_display_row_range: Range, active_rows: BTreeMap, highlighted_rows: BTreeMap, @@ -4795,6 +5012,15 @@ fn layout_line( ) } +#[derive(Debug)] +pub struct IndentGuideLayout { + origin: gpui::Point, + length: Pixels, + indent_size: Pixels, + depth: u32, + active: bool, +} + pub struct CursorLayout { origin: gpui::Point, block_width: Pixels, diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs new file mode 100644 index 0000000000..453f5f3b8f --- /dev/null +++ b/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, + indent: u32, +} + +#[derive(Default)] +pub struct ActiveIndentGuidesState { + pub dirty: bool, + cursor_row: MultiBufferRow, + pending_refresh: Option>, + active_indent_range: Option, +} + +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, + snapshot: &DisplaySnapshot, + cx: &AppContext, + ) -> Option> { + 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, + ) -> Option> { + let selection = self.selections.newest::(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, + snapshot: &DisplaySnapshot, + cx: &AppContext, +) -> Vec { + 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 { + 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, + }) +} diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs index 2f288657ed..42dc808b1f 100644 --- a/crates/feedback/src/feedback_modal.rs +++ b/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); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 13cfe2b0e1..ab7775960a 100644 --- a/crates/language/src/buffer.rs +++ b/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>(base_text: T, cx: &mut ModelContext) -> Self { @@ -3059,6 +3090,236 @@ impl BufferSnapshot { }) } + pub fn indent_guides_in_range( + &self, + range: Range, + cx: &AppContext, + ) -> Vec { + 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, 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( diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 97a1f9c993..38b3f7c6bc 100644 --- a/crates/language/src/buffer_tests.rs +++ b/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, + buffer_row: u32, + cx: &mut TestAppContext, + ) -> Option<(Range, 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") diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index b414bb4edd..9b9415ac8a 100644 --- a/crates/language/src/language_settings.rs +++ b/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, + /// 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>, + /// Indent guide related settings. + #[serde(default)] + pub indent_guides: Option, /// Whether or not to perform a buffer format before saving. /// /// Default: on @@ -411,6 +416,59 @@ pub enum Formatter { CodeActions(HashMap), } +/// 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(), diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index a2ffa246d8..93e970033f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/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, + 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 { + self.snapshot + .borrow() + .excerpts + .iter() + .map(|entry| entry.buffer_id) + .collect() + } + pub fn excerpt_ids(&self) -> Vec { self.snapshot .borrow() @@ -3182,6 +3205,52 @@ impl MultiBufferSnapshot { }) } + pub fn indent_guides_in_range( + &self, + range: Range, + cx: &AppContext, + ) -> Vec { + // 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 } diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 668f5ae343..2c41f9fc9f 100644 --- a/crates/rope/src/rope.rs +++ b/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)] diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index b3fc0c9197..141c57a10c 100644 --- a/crates/text/src/text.rs +++ b/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, + ) -> impl Iterator + '_ { + 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, + ) -> impl Iterator + '_ { + 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()) diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 5d80d3c885..70c8e8cc79 100644 --- a/crates/theme/src/default_colors.rs +++ b/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(), diff --git a/crates/theme/src/default_theme.rs b/crates/theme/src/default_theme.rs index de2bb06ff6..6722b847ba 100644 --- a/crates/theme/src/default_theme.rs +++ b/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(), }, } } diff --git a/crates/theme/src/one_themes.rs b/crates/theme/src/one_themes.rs index 9d24d784ad..51a78c9293 100644 --- a/crates/theme/src/one_themes.rs +++ b/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], }, } } diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index 281cf79495..55f61f7eb2 100644 --- a/crates/theme/src/registry.rs +++ b/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(), }, } })); diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index b2bd68c0cd..b4b494a803 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -75,6 +75,9 @@ pub struct ThemeStyleContent { #[serde(default, rename = "background.appearance")] pub window_background_appearance: Option, + #[serde(default)] + pub accents: Vec, + #[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, + #[serde(rename = "editor.indent_guide")] + pub editor_indent_guide: Option, + + #[serde(rename = "editor.indent_guide_active")] + pub editor_indent_guide_active: Option, + /// 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); + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PlayerColorContent { pub cursor: Option, diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index d327ff6c5d..25109b96b7 100644 --- a/crates/theme/src/settings.rs +++ b/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()); diff --git a/crates/theme/src/styles.rs b/crates/theme/src/styles.rs index 13a59f486d..137603113a 100644 --- a/crates/theme/src/styles.rs +++ b/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::*; diff --git a/crates/theme/src/styles/accents.rs b/crates/theme/src/styles/accents.rs new file mode 100644 index 0000000000..dfcd19911b --- /dev/null +++ b/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); + +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::>(); + + if !colors.is_empty() { + self.0 = colors; + } + } +} diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 348ad5971d..c550a4fb88 100644 --- a/crates/theme/src/styles/colors.rs +++ b/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, + pub accents: AccentColors, #[refineable] pub colors: ThemeColors, @@ -251,6 +255,7 @@ pub struct ThemeStyles { pub status: StatusColors, pub player: PlayerColors, + pub syntax: Arc, } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 01347007a5..4e3e6ae1bd 100644 --- a/crates/theme/src/theme.rs +++ b/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 { diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index 84cdabe44a..b323354965 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/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(),