From 829015b9413287791304bb826b071c0f629e66f8 Mon Sep 17 00:00:00 2001 From: Skyler Hawthorne Date: Sat, 8 Oct 2022 18:14:49 -0400 Subject: [PATCH] feat: supertab Implement "supertab", which optionally makes the `insert_tab` command run a different command when the cursor has non-whitespace to its left, i.e. you are in the middle of the line's content, and not trying to indent. --- Cargo.lock | 10 +- book/src/configuration.md | 1 + helix-term/src/commands.rs | 33 ++- helix-term/src/ui/menu.rs | 11 + helix-term/tests/test/commands/movement.rs | 246 +++++++++++++++++++++ helix-term/tests/test/helpers.rs | 16 +- helix-view/src/editor.rs | 5 + 7 files changed, 312 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea385b85927b5..f06754d7b9426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,7 +1124,7 @@ dependencies = [ "hashbrown 0.13.2", "helix-loader", "imara-diff", - "indoc", + "indoc 1.0.9", "log", "once_cell", "quickcheck", @@ -1215,7 +1215,7 @@ dependencies = [ "helix-vcs", "helix-view", "ignore", - "indoc", + "indoc 2.0.0", "log", "once_cell", "pulldown-cmark", @@ -1390,6 +1390,12 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + [[package]] name = "indoc" version = "2.0.0" diff --git a/book/src/configuration.md b/book/src/configuration.md index ee886c5c6ffc9..1bc0524b3d501 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -57,6 +57,7 @@ on unix operating systems. | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | | `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` | | `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | +| `supertab` | If set to a command name, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run the given command. If there is only whitespace to the left, then it inserts a tab as normal. One can emulate a tabout behavior with this with the `move_parent_node_end` command. | `None` | ### `[editor.statusline]` Section diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2c881b235fbc0..0f4f94231518b 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3084,6 +3084,8 @@ fn goto_next_change_impl(cx: &mut Context, direction: Direction) { } pub mod insert { + use std::str::FromStr; + use super::*; pub type Hook = fn(&Rope, &Selection, char) -> Option; pub type PostHook = fn(&mut Context, char); @@ -3223,14 +3225,41 @@ pub mod insert { } pub fn insert_tab(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let view_id = view.id; + + if let Some(ref cmd) = cx.editor.config().supertab { + 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); + log::debug!("left: {:?}", left); + left.chars().all(|c| c.is_whitespace()) + }); + + if !cursors_after_whitespace { + let cmd = match MappableCommand::from_str(cmd) { + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + Ok(cmd) => cmd, + }; + + cmd.execute(cx); + return; + } + } + let (view, doc) = current!(cx.editor); + // TODO: round out to nearest indentation level (for example a line with 3 spaces should // indent by one to reach 4 spaces). - let indent = Tendril::from(doc.indent_style.as_str()); let transaction = Transaction::insert( doc.text(), - &doc.selection(view.id).clone().cursors(doc.text().slice(..)), + &doc.selection(view_id).clone().cursors(doc.text().slice(..)), indent, ); doc.apply(&transaction, view.id); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index da00aa89f9b15..aa7ff42f9079c 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -245,6 +245,17 @@ impl Component for Menu { compositor.pop(); })); + let config = cx.editor.config(); + + // Ignore tab key when supertab is turned on in order not to interfere + // with it. (Is there a better way to do this?) + if config.supertab.is_some() + && config.auto_completion + && (event == key!(Tab) || event == shift!(Tab)) + { + 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 3737488bc359d..8c09dfd8dee2a 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -250,3 +250,249 @@ async fn test_move_parent_node_start() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_supertab_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 + // supertab 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)# + } + "}), + ), + ]; + + for test in tests { + test_with_config( + Args { + files: vec![(PathBuf::from("foo.rs"), Position::default())], + ..Default::default() + }, + Config { + editor: helix_view::editor::Config { + supertab: Some("move_parent_node_end".into()), + ..helpers::test_editor_config() + }, + ..helpers::test_config() + }, + helpers::test_syntax_conf(None), + test, + ) + .await?; + } + + Ok(()) +} diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs index c1c5f9bd763c9..331fa2e9e3f67 100644 --- a/helix-term/tests/test/helpers.rs +++ b/helix-term/tests/test/helpers.rs @@ -232,15 +232,19 @@ pub fn temp_file_with_contents>( /// Generates a config with defaults more suitable for integration tests pub fn test_config() -> Config { merge_keys(Config { - editor: helix_view::editor::Config { - lsp: LspConfig { - enable: false, - ..Default::default() - }, + editor: test_editor_config(), + ..Default::default() + }) +} + +pub fn test_editor_config() -> helix_view::editor::Config { + helix_view::editor::Config { + lsp: LspConfig { + enable: false, ..Default::default() }, ..Default::default() - }) + } } /// Replaces all LF chars with the system's appropriate line feed diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 09b5e160ddbac..ad4f86640ff3c 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -271,6 +271,10 @@ pub struct Config { /// Whether to color modes with different colors. Defaults to `false`. pub color_modes: bool, pub soft_wrap: SoftWrap, + + /// An alternative command to run when tab is pressed and the cursor has + /// text other than whitespace to its left on the current line. + pub supertab: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -761,6 +765,7 @@ impl Default for Config { indent_guides: IndentGuidesConfig::default(), color_modes: false, soft_wrap: SoftWrap::default(), + supertab: None, } } }