diff --git a/book/src/configuration.md b/book/src/configuration.md index da1d1bf6eacd..4c152bf4da48 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -347,3 +347,11 @@ max-wrap = 25 # increase value to reduce forced mid-word wrapping max-indent-retain = 0 wrap-indicator = "" # set wrap-indicator to "" to hide it ``` + +### `[editor.smart-tab]` Section + + +| Key | Description | Default | +|------------|-------------|---------| +| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` | +| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` | diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 2b29f36de451..6c4f3f535f8d 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -16,7 +16,7 @@ use crate::{ syntax::LanguageConfiguration, text_annotations::TextAnnotations, textobject::TextObject, - visual_offset_from_block, Range, RopeSlice, + visual_offset_from_block, Range, RopeSlice, Selection, Syntax, }; #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -556,6 +556,85 @@ pub fn goto_treesitter_object( last_range } +fn find_parent_start(mut node: Node) -> Option { + let start = node.start_byte(); + + while node.start_byte() >= start || !node.is_named() { + node = node.parent()?; + } + + Some(node) +} + +pub fn move_parent_node_end( + syntax: &Syntax, + text: RopeSlice, + selection: Selection, + dir: Direction, + movement: Movement, +) -> Selection { + let tree = syntax.tree(); + + selection.transform(|range| { + let start_from = text.char_to_byte(range.from()); + let start_to = text.char_to_byte(range.to()); + + let mut node = match tree + .root_node() + .named_descendant_for_byte_range(start_from, start_to) + { + Some(node) => node, + None => { + log::debug!( + "no descendant found for byte range: {} - {}", + start_from, + start_to + ); + return range; + } + }; + + let mut end_head = match dir { + // moving forward, we always want to move one past the end of the + // current node, so use the end byte of the current node, which is an exclusive + // end of the range + Direction::Forward => text.byte_to_char(node.end_byte()), + + // moving backward, we want the cursor to land on the start char of + // the current node, or if it is already at the start of a node, to traverse up to + // the parent + Direction::Backward => { + let end_head = text.byte_to_char(node.start_byte()); + + // if we're already on the beginning, look up to the parent + if end_head == range.cursor(text) { + node = find_parent_start(node).unwrap_or(node); + text.byte_to_char(node.start_byte()) + } else { + end_head + } + } + }; + + if movement == Movement::Move { + // preserve direction of original range + if range.direction() == Direction::Forward { + Range::new(end_head, end_head + 1) + } else { + Range::new(end_head + 1, end_head) + } + } else { + // if we end up with a forward range, then adjust it to be one past + // where we want + if end_head >= range.anchor { + end_head += 1; + } + + Range::new(range.anchor, end_head) + } + }) +} + #[cfg(test)] mod test { use ropey::Rope; diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index dc4611989122..a97ae5033dcb 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -33,12 +33,9 @@ use crate::{ }; use log::{debug, error, warn}; -use std::{ - collections::btree_map::Entry, - io::{stdin, stdout}, - path::Path, - sync::Arc, -}; +#[cfg(not(feature = "integration"))] +use std::io::stdout; +use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc}; use anyhow::{Context, Error}; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index bf60ad71a468..7fb17ed8e7df 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -247,6 +247,8 @@ impl MappableCommand { move_prev_long_word_start, "Move to start of previous long word", move_next_long_word_end, "Move to end of next long word", move_prev_long_word_end, "Move to end of previous long word", + move_parent_node_end, "Move to end of the parent node", + move_parent_node_start, "Move to beginning of the parent node", extend_next_word_start, "Extend to start of next word", extend_prev_word_start, "Extend to start of previous word", extend_next_word_end, "Extend to end of next word", @@ -255,6 +257,8 @@ impl MappableCommand { extend_prev_long_word_start, "Extend to start of previous long word", extend_next_long_word_end, "Extend to end of next long word", extend_prev_long_word_end, "Extend to end of prev long word", + extend_parent_node_end, "Extend to end of the parent node", + extend_parent_node_start, "Extend to beginning of the parent node", find_till_char, "Move till next occurrence of char", find_next_char, "Move to next occurrence of char", extend_till_char, "Extend till next occurrence of char", @@ -361,6 +365,7 @@ impl MappableCommand { extend_to_line_end, "Extend to line end", extend_to_line_end_newline, "Extend to line end", signature_help, "Show signature help", + smart_tab, "Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command.", insert_tab, "Insert tab char", insert_newline, "Insert newline char", delete_char_backward, "Delete previous char", @@ -2517,6 +2522,10 @@ fn insert_mode(cx: &mut Context) { .transform(|range| Range::new(range.to(), range.from())); doc.set_selection(view.id, selection); + + // [TODO] temporary workaround until we're not using the idle timer to + // trigger auto completions any more + cx.editor.clear_idle_timer(); } // inserts at the end of each selection @@ -3440,6 +3449,7 @@ pub mod insert { } use helix_core::auto_pairs; + use helix_view::editor::SmartTabConfig; pub fn insert_char(cx: &mut Context, c: char) { let (view, doc) = current_ref!(cx.editor); @@ -3465,6 +3475,31 @@ pub mod insert { } } + pub fn smart_tab(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let view_id = view.id; + + if matches!( + cx.editor.config().smart_tab, + Some(SmartTabConfig { enable: true, .. }) + ) { + let cursors_after_whitespace = doc.selection(view_id).ranges().iter().all(|range| { + let cursor = range.cursor(doc.text().slice(..)); + let current_line_num = doc.text().char_to_line(cursor); + let current_line_start = doc.text().line_to_char(current_line_num); + let left = doc.text().slice(current_line_start..cursor); + left.chars().all(|c| c.is_whitespace()) + }); + + if !cursors_after_whitespace { + move_parent_node_end(cx); + return; + } + } + + insert_tab(cx); + } + pub fn insert_tab(cx: &mut Context) { let (view, doc) = current!(cx.editor); // TODO: round out to nearest indentation level (for example a line with 3 spaces should @@ -4605,6 +4640,49 @@ fn select_prev_sibling(cx: &mut Context) { select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) } +fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) { + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let current_selection = doc.selection(view.id); + + let selection = movement::move_parent_node_end( + syntax, + text, + current_selection.clone(), + dir, + movement, + ); + + doc.set_selection(view.id, selection); + + // [TODO] temporary workaround until we're not using the idle timer to + // trigger auto completions any more + editor.clear_idle_timer(); + } + }; + + cx.editor.apply_motion(motion); +} + +pub fn move_parent_node_end(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Forward, Movement::Move) +} + +pub fn move_parent_node_start(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Backward, Movement::Move) +} + +pub fn extend_parent_node_end(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Forward, Movement::Extend) +} + +pub fn extend_parent_node_start(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Backward, Movement::Extend) +} + fn match_brackets(cx: &mut Context) { let (view, doc) = current!(cx.editor); let is_select = cx.editor.mode == Mode::Select; diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index f37b03ec7b43..bcba8d8e1d45 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -109,6 +109,7 @@ impl Config { )?, } } + // these are just two io errors return the one for the global config (Err(err), Err(_)) => return Err(err), }; diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 379833525b33..763ed4ae71ce 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -88,6 +88,8 @@ pub fn default() -> HashMap { "A-i" | "A-down" => shrink_selection, "A-p" | "A-left" => select_prev_sibling, "A-n" | "A-right" => select_next_sibling, + "A-e" => move_parent_node_end, + "A-b" => move_parent_node_start, "%" => select_all, "x" => extend_line_below, @@ -336,6 +338,9 @@ pub fn default() -> HashMap { "B" => extend_prev_long_word_start, "E" => extend_next_long_word_end, + "A-e" => extend_parent_node_end, + "A-b" => extend_parent_node_start, + "n" => extend_search_next, "N" => extend_search_prev, @@ -368,7 +373,8 @@ pub fn default() -> HashMap { "C-h" | "backspace" | "S-backspace" => delete_char_backward, "C-d" | "del" => delete_char_forward, "C-j" | "ret" => insert_newline, - "tab" => insert_tab, + "tab" => smart_tab, + "S-tab" => insert_tab, "up" => move_visual_line_up, "down" => move_visual_line_down, diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index bdad2e408392..c73e7bed259a 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -11,7 +11,7 @@ pub use tui::widgets::{Cell, Row}; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; -use helix_view::{graphics::Rect, Editor}; +use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor}; use tui::layout::Constraint; pub trait Item { @@ -247,6 +247,21 @@ impl Component for Menu { compositor.pop(); })); + // Ignore tab key when supertab is turned on in order not to interfere + // with it. (Is there a better way to do this?) + if (event == key!(Tab) || event == shift!(Tab)) + && cx.editor.config().auto_completion + && matches!( + cx.editor.config().smart_tab, + Some(SmartTabConfig { + enable: true, + supersede_menu: true, + }) + ) + { + return EventResult::Ignored(None); + } + match event { // esc or ctrl-c aborts the completion and closes the menu key!(Esc) | ctrl!('c') => { diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index b13c37bcdcb6..b3e135510632 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -2,6 +2,7 @@ use helix_term::application::Application; use super::*; +mod movement; mod write; #[tokio::test(flavor = "multi_thread")] diff --git a/helix-term/tests/test/commands/movement.rs b/helix-term/tests/test/commands/movement.rs new file mode 100644 index 000000000000..5be68837c64c --- /dev/null +++ b/helix-term/tests/test/commands/movement.rs @@ -0,0 +1,452 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_parent_node_end() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + "no#["|]# + } + } + "##}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\" + }#[\n|]# + } + "}), + ), + // select mode extends + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[\"no\" + }\n|]# + } + "}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_parent_node_start() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + "no#["|]# + } + } + "##}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[\"|]#no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[{|]# + \"no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[{|]# + \"no\" + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } #[e|]#lse { + \"no\" + } + } + "}), + ), + // select mode extends + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[|{ + ]#\"no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } #[|else { + ]#\"no\" + } + } + "}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_smart_tab_move_parent_node_end() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + "no#["|]# + } + } + "##}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[|\n]# + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\" + }#[|\n]# + } + "}), + ), + // appending to the end of a line should still look at the current + // line, not the next one + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no#[\"|]# + } + } + "}), + "a", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\" + }#[\n|]# + } + "}), + ), + // before cursor is all whitespace, so insert tab + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[\"no\"|]# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[|\"no\"]# + } + } + "}), + ), + // if selection spans multiple lines, it should still only look at the + // line on which the head is + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[\"yes\" + } else { + \"no\"|]# + } + } + "}), + "a", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\" + }#[\n|]# + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[\"yes\" + } else { + \"no\"|]# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[|\"yes\" + } else { + \"no\"]# + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + #[l|]#et result = if true { + #(\"yes\" + } else { + \"no\"|)# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + #[|l]#et result = if true { + #(|\"yes\" + } else { + \"no\")# + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\"#[\n|]# + } else { + \"no\"#(\n|)# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + }#[| ]#else { + \"no\" + }#(|\n)# + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[\"yes\"|]# + } else { + #(\"no\"|)# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[|\"yes\"]# + } else { + #(|\"no\")# + } + } + "}), + ), + // if any cursors are not preceded by all whitespace, then do the + // smart_tab action + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[\"yes\"\n|]# + } else { + \"no#(\"\n|)# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + }#[| ]#else { + \"no\" + }#(|\n)# + } + "}), + ), + // Ctrl-tab always inserts a tab + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[\"yes\"\n|]# + } else { + \"no#(\"\n|)# + } + } + "}), + "i", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + #[|\"yes\"\n]# + } else { + \"no #(|\"\n)# + } + } + "}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 4ea1c49f536e..2152ff9bbcee 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -287,6 +287,24 @@ pub struct Config { pub workspace_lsp_roots: Vec, /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`. pub default_line_ending: LineEndingConfig, + /// Enables smart tab + pub smart_tab: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case", default)] +pub struct SmartTabConfig { + pub enable: bool, + pub supersede_menu: bool, +} + +impl Default for SmartTabConfig { + fn default() -> Self { + SmartTabConfig { + enable: true, + supersede_menu: false, + } + } } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -820,6 +838,7 @@ impl Default for Config { completion_replace: false, workspace_lsp_roots: Vec::new(), default_line_ending: LineEndingConfig::default(), + smart_tab: Some(SmartTabConfig::default()), } } }