From 178269be63420783c5337ef09613f1c294d4518a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 24 Oct 2025 19:03:09 +0800 Subject: [PATCH] editor: Add indent guides. (#1426) image --- crates/story/examples/editor.rs | 24 ++ crates/ui/src/input/element.rs | 8 + crates/ui/src/input/indent.rs | 391 ++++++++++++++++++++++++++++++++ crates/ui/src/input/mod.rs | 3 +- crates/ui/src/input/mode.rs | 69 +----- crates/ui/src/input/state.rs | 192 +--------------- 6 files changed, 435 insertions(+), 252 deletions(-) create mode 100644 crates/ui/src/input/indent.rs diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index 392b5119..37af4d59 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -46,6 +46,7 @@ pub struct Example { language_state: Entity>>, language: Lang, line_number: bool, + indent_guides: bool, need_update: bool, soft_wrap: bool, lsp_store: ExampleLspStore, @@ -671,6 +672,7 @@ impl Example { let mut editor = InputState::new(window, cx) .code_editor(default_language.0.name().to_string()) .line_number(true) + .indent_guides(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, @@ -725,6 +727,7 @@ impl Example { language_state, language: default_language.0, line_number: true, + indent_guides: true, need_update: false, soft_wrap: false, lsp_store, @@ -804,6 +807,19 @@ impl Example { cx.notify(); } + fn toggle_indent_guides( + &mut self, + _: &ClickEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.indent_guides = !self.indent_guides; + self.editor.update(cx, |state, cx| { + state.set_indent_guides(self.indent_guides, window, cx); + }); + cx.notify(); + } + fn lint_document(&mut self, cx: &mut Context) { let language = self.language.name().to_string(); let lsp_store = self.lsp_store.clone(); @@ -985,6 +1001,14 @@ impl Render for Example { .label("Soft Wrap") .selected(self.soft_wrap) .on_click(cx.listener(Self::toggle_soft_wrap)) + }) + .child({ + Button::new("indent-guides") + .ghost() + .xsmall() + .label("Indent Guides") + .selected(self.indent_guides) + .on_click(cx.listener(Self::toggle_indent_guides)) }), ) .child({ diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 8730eebc..b7c5e880 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -712,6 +712,7 @@ pub(super) struct PrepaintState { search_match_paths: Vec<(Path, bool)>, document_color_paths: Vec<(Path, Hsla)>, hover_definition_hitbox: Option, + indent_guides_path: Option>, bounds: Bounds, } @@ -1083,6 +1084,7 @@ impl Element for TextElement { }; let hover_definition_hitbox = self.layout_hover_definition_hitbox(state, window, cx); + let indent_guides_path = self.layout_indent_guides(state, &last_layout); PrepaintState { bounds, @@ -1097,6 +1099,7 @@ impl Element for TextElement { hover_highlight_path, hover_definition_hitbox, document_color_paths, + indent_guides_path, } } @@ -1189,6 +1192,11 @@ impl Element for TextElement { } } + // Paint indent guides + if let Some(path) = prepaint.indent_guides_path.take() { + window.paint_path(path, cx.theme().secondary); + } + // Paint selections if window.is_window_active() { let secondary_selection = cx.theme().selection.saturation(0.1); diff --git a/crates/ui/src/input/indent.rs b/crates/ui/src/input/indent.rs new file mode 100644 index 00000000..8183f176 --- /dev/null +++ b/crates/ui/src/input/indent.rs @@ -0,0 +1,391 @@ +use gpui::{ + point, px, Context, EntityInputHandler as _, Path, PathBuilder, Pixels, SharedString, Window, +}; +use ropey::RopeSlice; + +use crate::{ + input::{ + element::TextElement, mode::InputMode, Indent, IndentInline, InputState, LastLayout, + Outdent, OutdentInline, + }, + RopeExt, +}; + +#[derive(Debug, Copy, Clone)] +pub struct TabSize { + /// Default is 2 + pub tab_size: usize, + /// Set true to use `\t` as tab indent, default is false + pub hard_tabs: bool, +} + +impl Default for TabSize { + fn default() -> Self { + Self { + tab_size: 2, + hard_tabs: false, + } + } +} + +impl TabSize { + pub(super) fn to_string(&self) -> SharedString { + if self.hard_tabs { + "\t".into() + } else { + " ".repeat(self.tab_size).into() + } + } + + /// Count the indent size of the line in spaces. + pub fn indent_count(&self, line: &RopeSlice) -> usize { + let mut count = 0; + for ch in line.chars() { + match ch { + '\t' => count += self.tab_size, + ' ' => count += 1, + _ => break, + } + } + + count + } +} + +impl InputMode { + #[inline] + pub(super) fn is_indentable(&self) -> bool { + matches!( + self, + InputMode::MultiLine { .. } | InputMode::CodeEditor { .. } + ) + } + + #[inline] + pub(super) fn has_indent_guides(&self) -> bool { + match self { + InputMode::CodeEditor { indent_guides, .. } => *indent_guides, + _ => false, + } + } + + #[inline] + pub(super) fn tab_size(&self) -> TabSize { + match self { + InputMode::MultiLine { tab, .. } => *tab, + InputMode::CodeEditor { tab, .. } => *tab, + _ => TabSize::default(), + } + } +} + +impl TextElement { + pub(super) fn layout_indent_guides( + &self, + state: &InputState, + last_layout: &LastLayout, + ) -> Option> { + if !state.mode.has_indent_guides() { + return None; + } + + let tab_size = state.mode.tab_size(); + let line_height = last_layout.line_height; + let visible_range = last_layout.visible_range.clone(); + let mut builder = PathBuilder::stroke(px(1.)); + let mut offset_y = + last_layout.visible_top + state.scroll_handle.offset().y + (line_height * 2 - px(3.)); + let mut last_indents = vec![]; + for ix in visible_range { + let line = state.text.slice_line(ix); + let line_layout = last_layout.line(ix).expect("line layout should exist"); + let mut current_indents = vec![]; + if line.len() > 0 { + let indent_count = tab_size.indent_count(&line); + for offset in (0..indent_count).step_by(tab_size.tab_size) { + let mut pos = line_layout + .position_for_index(offset, line_height) + .unwrap_or(point(px(0.), px(0.))); + + pos.x += last_layout.line_number_width; + pos.y += offset_y; + + builder.move_to(pos); + builder.line_to(point(pos.x, pos.y + line_height)); + current_indents.push(pos.x); + } + } else if last_indents.len() > 0 { + for x in &last_indents { + let pos = point(*x, offset_y); + builder.move_to(pos); + builder.line_to(point(pos.x, pos.y + line_height)); + } + current_indents = last_indents.clone(); + } + + offset_y += line_layout.wrapped_lines.len() * line_height; + last_indents = current_indents; + } + + let path = builder.build().unwrap(); + Some(path) + } +} + +impl InputState { + /// Set whether to show indent guides in code editor mode, default is true. + /// + /// Only for [`InputMode::CodeEditor`] mode. + pub fn indent_guides(mut self, indent_guides: bool) -> Self { + debug_assert!(self.mode.is_code_editor()); + if let InputMode::CodeEditor { + indent_guides: l, .. + } = &mut self.mode + { + *l = indent_guides; + } + self + } + + /// Set indent guides in code editor mode. + /// + /// Only for [`InputMode::CodeEditor`] mode. + pub fn set_indent_guides( + &mut self, + indent_guides: bool, + _: &mut Window, + cx: &mut Context, + ) { + debug_assert!(self.mode.is_code_editor()); + if let InputMode::CodeEditor { + indent_guides: l, .. + } = &mut self.mode + { + *l = indent_guides; + } + cx.notify(); + } + + /// Set the tab size for the input. + /// + /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. + pub fn tab_size(mut self, tab: TabSize) -> Self { + debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor()); + match &mut self.mode { + InputMode::MultiLine { tab: t, .. } => *t = tab, + InputMode::CodeEditor { tab: t, .. } => *t = tab, + _ => {} + } + self + } + + pub(super) fn indent_inline( + &mut self, + _: &IndentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.indent(false, window, cx); + } + + pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { + self.indent(true, window, cx); + } + + pub(super) fn outdent_inline( + &mut self, + _: &OutdentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(false, window, cx); + } + + pub(super) fn outdent_block( + &mut self, + _: &Outdent, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(true, window, cx); + } + + pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + if !self.mode.is_indentable() { + cx.propagate(); + return; + }; + + let tab_indent = self.mode.tab_size().to_string(); + let selected_range = self.selected_range; + let mut added_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len += tab_indent.len(); + // +1 for "\n", the `\r` is included in the `line`. + offset += line.len() + tab_indent.len() + 1; + } + + if is_selected { + self.selected_range = (start_offset..selected_range.end + added_len).into(); + } else { + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); + } + } else { + // Selected none + let offset = self.selected_range.start; + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len = tab_indent.len(); + + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); + } + } + + pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + if !self.mode.is_indentable() { + cx.propagate(); + return; + }; + + let tab_indent = self.mode.tab_size().to_string(); + let selected_range = self.selected_range; + let mut removed_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + if line.starts_with(tab_indent.as_ref()) { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len += tab_indent.len(); + + // +1 for "\n" + offset += line.len().saturating_sub(tab_indent.len()) + 1; + } else { + offset += line.len() + 1; + } + } + + if is_selected { + self.selected_range = + (start_offset..selected_range.end.saturating_sub(removed_len)).into(); + } else { + self.selected_range = (selected_range.start.saturating_sub(removed_len) + ..selected_range.end.saturating_sub(removed_len)) + .into(); + } + } else { + // Selected none + let start_offset = self.selected_range.start; + let offset = self.start_of_line_of_selection(window, cx); + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); + // FIXME: To improve performance + if self + .text + .slice(offset..self.text.len()) + .to_string() + .starts_with(tab_indent.as_ref()) + { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len = tab_indent.len(); + let new_offset = start_offset.saturating_sub(removed_len); + self.selected_range = (new_offset..new_offset).into(); + } + } + } +} + +#[cfg(test)] +mod tests { + use ropey::RopeSlice; + + use super::TabSize; + + #[test] + fn test_tab_size() { + let tab = TabSize { + tab_size: 2, + hard_tabs: false, + }; + assert_eq!(tab.to_string(), " "); + let tab = TabSize { + tab_size: 4, + hard_tabs: false, + }; + assert_eq!(tab.to_string(), " "); + + let tab = TabSize { + tab_size: 2, + hard_tabs: true, + }; + assert_eq!(tab.to_string(), "\t"); + let tab = TabSize { + tab_size: 4, + hard_tabs: true, + }; + assert_eq!(tab.to_string(), "\t"); + } + + #[test] + fn test_tab_size_indent_count() { + let tab = TabSize { + tab_size: 4, + hard_tabs: false, + }; + assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); + assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2); + assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4); + assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4); + assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6); + assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6); + assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index eaf670da..e3a22528 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -3,6 +3,7 @@ mod change; mod clear_button; mod cursor; mod element; +mod indent; mod lsp; mod mask_pattern; mod mode; @@ -18,9 +19,9 @@ mod text_wrapper; pub(crate) use clear_button::*; pub use cursor::*; +pub use indent::TabSize; pub use lsp::*; pub use mask_pattern::MaskPattern; -pub use mode::TabSize; pub use number_input::{NumberInput, NumberInputEvent, StepAction}; pub use otp_input::*; pub use state::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index cabdb22c..4017281f 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -8,34 +8,7 @@ use tree_sitter::InputEdit; use super::text_wrapper::TextWrapper; use crate::highlighter::DiagnosticSet; use crate::highlighter::SyntaxHighlighter; -use crate::input::RopeExt as _; - -#[derive(Debug, Copy, Clone)] -pub struct TabSize { - /// Default is 2 - pub tab_size: usize, - /// Set true to use `\t` as tab indent, default is false - pub hard_tabs: bool, -} - -impl Default for TabSize { - fn default() -> Self { - Self { - tab_size: 2, - hard_tabs: false, - } - } -} - -impl TabSize { - pub(super) fn to_string(&self) -> SharedString { - if self.hard_tabs { - "\t".into() - } else { - " ".repeat(self.tab_size).into() - } - } -} +use crate::input::{RopeExt as _, TabSize}; #[derive(Default, Clone)] pub enum InputMode { @@ -56,6 +29,7 @@ pub enum InputMode { /// Show line number line_number: bool, language: SharedString, + indent_guides: bool, highlighter: Rc>>, diagnostics: DiagnosticSet, }, @@ -155,15 +129,6 @@ impl InputMode { } } - #[inline] - pub(super) fn tab_size(&self) -> Option<&TabSize> { - match self { - InputMode::MultiLine { tab, .. } => Some(tab), - InputMode::CodeEditor { tab, .. } => Some(tab), - _ => None, - } - } - pub(super) fn update_highlighter( &mut self, selected_range: &Range, @@ -237,33 +202,3 @@ impl InputMode { } } } - -#[cfg(test)] -mod tests { - use super::TabSize; - - #[test] - fn test_tab_size() { - let tab = TabSize { - tab_size: 2, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - let tab = TabSize { - tab_size: 4, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - - let tab = TabSize { - tab_size: 2, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - let tab = TabSize { - tab_size: 4, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - } -} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 737a9ac8..9f1a18ab 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -19,13 +19,8 @@ use sum_tree::Bias; use unicode_segmentation::*; use super::{ - blink_cursor::BlinkCursor, - change::Change, - element::TextElement, - mask_pattern::MaskPattern, - mode::{InputMode, TabSize}, - number_input, - text_wrapper::TextWrapper, + blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern, + mode::InputMode, number_input, text_wrapper::TextWrapper, TabSize, }; use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::{ @@ -468,6 +463,7 @@ impl InputState { language, highlighter: Rc::new(RefCell::new(None)), line_number: true, + indent_guides: true, diagnostics: DiagnosticSet::new(&Rope::new()), }; self.searchable = true; @@ -505,19 +501,6 @@ impl InputState { cx.notify(); } - /// Set the tab size for the input. - /// - /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. - pub fn tab_size(mut self, tab: TabSize) -> Self { - debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor()); - match &mut self.mode { - InputMode::MultiLine { tab: t, .. } => *t = tab, - InputMode::CodeEditor { tab: t, .. } => *t = tab, - _ => {} - } - self - } - /// Set the number of rows for the multi-line Textarea. /// /// This is only used when `multi_line` is set to true. @@ -992,7 +975,11 @@ impl InputState { /// Get start line of selection start or end (The min value). /// /// This is means is always get the first line of selection. - fn start_of_line_of_selection(&mut self, window: &mut Window, cx: &mut Context) -> usize { + pub(super) fn start_of_line_of_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> usize { if self.mode.is_single_line() { return 0; } @@ -1165,169 +1152,6 @@ impl InputState { }); } - pub(super) fn indent_inline( - &mut self, - _: &IndentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.indent(false, window, cx); - } - - pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { - self.indent(true, window, cx); - } - - pub(super) fn outdent_inline( - &mut self, - _: &OutdentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(false, window, cx); - } - - pub(super) fn outdent_block( - &mut self, - _: &Outdent, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(true, window, cx); - } - - pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - let Some(tab_size) = self.mode.tab_size() else { - cx.propagate(); - return; - }; - - let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range; - let mut added_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len += tab_indent.len(); - // +1 for "\n", the `\r` is included in the `line`. - offset += line.len() + tab_indent.len() + 1; - } - - if is_selected { - self.selected_range = (start_offset..selected_range.end + added_len).into(); - } else { - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } - } else { - // Selected none - let offset = self.selected_range.start; - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len = tab_indent.len(); - - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } - } - - pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - let Some(tab_size) = self.mode.tab_size() else { - cx.propagate(); - return; - }; - - let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range; - let mut removed_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - if line.starts_with(tab_indent.as_ref()) { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len += tab_indent.len(); - - // +1 for "\n" - offset += line.len().saturating_sub(tab_indent.len()) + 1; - } else { - offset += line.len() + 1; - } - } - - if is_selected { - self.selected_range = - (start_offset..selected_range.end.saturating_sub(removed_len)).into(); - } else { - self.selected_range = (selected_range.start.saturating_sub(removed_len) - ..selected_range.end.saturating_sub(removed_len)) - .into(); - } - } else { - // Selected none - let start_offset = self.selected_range.start; - let offset = self.start_of_line_of_selection(window, cx); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - // FIXME: To improve performance - if self - .text - .slice(offset..self.text.len()) - .to_string() - .starts_with(tab_indent.as_ref()) - { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len = tab_indent.len(); - let new_offset = start_offset.saturating_sub(removed_len); - self.selected_range = (new_offset..new_offset).into(); - } - } - } - pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); self.selected_range = (0..0).into();