From 15e07d4db893aec7b9e117c1f88400a68ba98ae2 Mon Sep 17 00:00:00 2001 From: Skyler Hawthorne Date: Sat, 8 Oct 2022 18:14:49 -0400 Subject: [PATCH] feat: smart_tab Implement `smart_tab`, which optionally makes the tab key run the `move_parent_node_start` command when the cursor has non- whitespace to its left. --- book/src/configuration.md | 8 + helix-term/src/application.rs | 9 +- helix-term/src/commands.rs | 38 +++- helix-term/src/config.rs | 1 + helix-term/src/keymap/default.rs | 3 +- helix-term/src/ui/menu.rs | 17 +- helix-term/tests/test/commands/movement.rs | 253 +++++++++++++++++++++ helix-view/src/editor.rs | 19 ++ 8 files changed, 338 insertions(+), 10 deletions(-) diff --git a/book/src/configuration.md b/book/src/configuration.md index da1d1bf6..4c152bf4 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-term/src/application.rs b/helix-term/src/application.rs index dc461198..a97ae503 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 fc01eec7..7fb17ed8 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -365,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", @@ -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 @@ 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); @@ -3469,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 @@ -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 f37b03ec..bcba8d8e 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 419e376f..763ed4ae 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 bdad2e40..c73e7bed 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/movement.rs b/helix-term/tests/test/commands/movement.rs index 03dc7ba9..5be68837 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -197,3 +197,256 @@ async fn test_move_parent_node_start() -> anyhow::Result<()> { 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 4ea1c49f..2152ff9b 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()), } } }