diff --git a/book/src/configuration.md b/book/src/configuration.md index da1d1bf6e..4c152bf4d 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -347,3 +347,11 @@ ### `[editor.soft-wrap]` Section 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-term/src/application.rs b/helix-term/src/application.rs index dc4611989..a97ae5033 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -33,12 +33,9 @@ }; 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 fc01eec79..7fb17ed8e 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -365,6 +365,7 @@ pub fn doc(&self) -> &str { 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", @@ -2521,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 @@ -3444,6 +3449,7 @@ fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option { } 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); @@ -3469,6 +3475,31 @@ pub fn insert_char(cx: &mut Context, c: char) { } } + 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 @@ -4626,11 +4657,14 @@ fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: 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(); } }; - motion(cx.editor); - cx.editor.last_motion = Some(Motion(Box::new(motion))); + cx.editor.apply_motion(motion); } pub fn move_parent_node_end(cx: &mut Context) { diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index f37b03ec7..bcba8d8e1 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -109,6 +109,7 @@ pub fn load( )?, } } + // 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 419e376ff..763ed4ae7 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -373,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 bdad2e408..c73e7bed2 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -11,7 +11,7 @@ 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 @@ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { 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/movement.rs b/helix-term/tests/test/commands/movement.rs index 03dc7ba9c..5be68837c 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -197,3 +197,256 @@ fn foo() { 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 4ea1c49f5..2152ff9bb 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 @@ fn default() -> Self { completion_replace: false, workspace_lsp_roots: Vec::new(), default_line_ending: LineEndingConfig::default(), + smart_tab: Some(SmartTabConfig::default()), } } }