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.
pull/7803/head
Skyler Hawthorne 2 years ago committed by Michael Davis
parent 93acb53812
commit 15e07d4db8

@ -347,3 +347,11 @@ max-wrap = 25 # increase value to reduce forced mid-word wrapping
max-indent-retain = 0 max-indent-retain = 0
wrap-indicator = "" # set wrap-indicator to "" to hide it 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` |

@ -33,12 +33,9 @@ use crate::{
}; };
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::{ #[cfg(not(feature = "integration"))]
collections::btree_map::Entry, use std::io::stdout;
io::{stdin, stdout}, use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc};
path::Path,
sync::Arc,
};
use anyhow::{Context, Error}; use anyhow::{Context, Error};

@ -365,6 +365,7 @@ impl MappableCommand {
extend_to_line_end, "Extend to line end", extend_to_line_end, "Extend to line end",
extend_to_line_end_newline, "Extend to line end", extend_to_line_end_newline, "Extend to line end",
signature_help, "Show signature help", 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_tab, "Insert tab char",
insert_newline, "Insert newline char", insert_newline, "Insert newline char",
delete_char_backward, "Delete previous 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())); .transform(|range| Range::new(range.to(), range.from()));
doc.set_selection(view.id, selection); 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 // inserts at the end of each selection
@ -3444,6 +3449,7 @@ pub mod insert {
} }
use helix_core::auto_pairs; use helix_core::auto_pairs;
use helix_view::editor::SmartTabConfig;
pub fn insert_char(cx: &mut Context, c: char) { pub fn insert_char(cx: &mut Context, c: char) {
let (view, doc) = current_ref!(cx.editor); 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) { pub fn insert_tab(cx: &mut Context) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
// TODO: round out to nearest indentation level (for example a line with 3 spaces should // 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); 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.apply_motion(motion);
cx.editor.last_motion = Some(Motion(Box::new(motion)));
} }
pub fn move_parent_node_end(cx: &mut Context) { pub fn move_parent_node_end(cx: &mut Context) {

@ -109,6 +109,7 @@ impl Config {
)?, )?,
} }
} }
// these are just two io errors return the one for the global config // these are just two io errors return the one for the global config
(Err(err), Err(_)) => return Err(err), (Err(err), Err(_)) => return Err(err),
}; };

@ -373,7 +373,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"C-h" | "backspace" | "S-backspace" => delete_char_backward, "C-h" | "backspace" | "S-backspace" => delete_char_backward,
"C-d" | "del" => delete_char_forward, "C-d" | "del" => delete_char_forward,
"C-j" | "ret" => insert_newline, "C-j" | "ret" => insert_newline,
"tab" => insert_tab, "tab" => smart_tab,
"S-tab" => insert_tab,
"up" => move_visual_line_up, "up" => move_visual_line_up,
"down" => move_visual_line_down, "down" => move_visual_line_down,

@ -11,7 +11,7 @@ pub use tui::widgets::{Cell, Row};
use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use helix_view::{graphics::Rect, Editor}; use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
use tui::layout::Constraint; use tui::layout::Constraint;
pub trait Item { pub trait Item {
@ -247,6 +247,21 @@ impl<T: Item + 'static> Component for Menu<T> {
compositor.pop(); 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 { match event {
// esc or ctrl-c aborts the completion and closes the menu // esc or ctrl-c aborts the completion and closes the menu
key!(Esc) | ctrl!('c') => { key!(Esc) | ctrl!('c') => {

@ -197,3 +197,256 @@ async fn test_move_parent_node_start() -> anyhow::Result<()> {
Ok(()) 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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<tab>",
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<S-tab>",
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(())
}

@ -287,6 +287,24 @@ pub struct Config {
pub workspace_lsp_roots: Vec<PathBuf>, pub workspace_lsp_roots: Vec<PathBuf>,
/// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`. /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
pub default_line_ending: LineEndingConfig, pub default_line_ending: LineEndingConfig,
/// Enables smart tab
pub smart_tab: Option<SmartTabConfig>,
}
#[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)] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -820,6 +838,7 @@ impl Default for Config {
completion_replace: false, completion_replace: false,
workspace_lsp_roots: Vec::new(), workspace_lsp_roots: Vec::new(),
default_line_ending: LineEndingConfig::default(), default_line_ending: LineEndingConfig::default(),
smart_tab: Some(SmartTabConfig::default()),
} }
} }
} }

Loading…
Cancel
Save