on master help sign in
feat: smart_tab
15e07d4Skyler Hawthorne
book/src/configuration.md+8
@@ -347,3 +347,11 @@ max-wrap = 25 # increase value to reduce forced mid-word wrapping
347max-indent-retain = 0
347max-indent-retain = 0
348wrap-indicator = "" # set wrap-indicator to "" to hide it
348wrap-indicator = "" # set wrap-indicator to "" to hide it
349```
349```
350+
351+### `[editor.smart-tab]` Section
352+
353+
354+| Key | Description | Default |
355+|------------|-------------|---------|
356+| `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` |
357+| `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` |
helix-term/src/application.rs+3−6
@@ -33,12 +33,9 @@ use crate::{
33};
33};
34
34
35use log::{debug, error, warn};
35use log::{debug, error, warn};
36−use std::{
36+#[cfg(not(feature = "integration"))]
37+use std::io::stdout;
37− collections::btree_map::Entry,
38+use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc};
38− io::{stdin, stdout},
39− path::Path,
40− sync::Arc,
41−};
42
39
43use anyhow::{Context, Error};
40use anyhow::{Context, Error};
44
41
helix-term/src/commands.rs+36−2
@@ -365,6 +365,7 @@ impl MappableCommand {
365 extend_to_line_end, "Extend to line end",
365 extend_to_line_end, "Extend to line end",
366 extend_to_line_end_newline, "Extend to line end",
366 extend_to_line_end_newline, "Extend to line end",
367 signature_help, "Show signature help",
367 signature_help, "Show signature help",
368+ smart_tab, "Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command.",
368 insert_tab, "Insert tab char",
369 insert_tab, "Insert tab char",
369 insert_newline, "Insert newline char",
370 insert_newline, "Insert newline char",
370 delete_char_backward, "Delete previous char",
371 delete_char_backward, "Delete previous char",
@@ -2521,6 +2522,10 @@ fn insert_mode(cx: &mut Context) {
2521 .transform(|range| Range::new(range.to(), range.from()));
2522 .transform(|range| Range::new(range.to(), range.from()));
2522
2523
2523 doc.set_selection(view.id, selection);
2524 doc.set_selection(view.id, selection);
2525+
2526+ // [TODO] temporary workaround until we're not using the idle timer to
2527+ // trigger auto completions any more
2528+ cx.editor.clear_idle_timer();
2524}
2529}
2525
2530
2526// inserts at the end of each selection
2531// inserts at the end of each selection
@@ -3444,6 +3449,7 @@ pub mod insert {
3444 }
3449 }
3445
3450
3446 use helix_core::auto_pairs;
3451 use helix_core::auto_pairs;
3452+ use helix_view::editor::SmartTabConfig;
3447
3453
3448 pub fn insert_char(cx: &mut Context, c: char) {
3454 pub fn insert_char(cx: &mut Context, c: char) {
3449 let (view, doc) = current_ref!(cx.editor);
3455 let (view, doc) = current_ref!(cx.editor);
@@ -3469,6 +3475,31 @@ pub mod insert {
3469 }
3475 }
3470 }
3476 }
3471
3477
3478+ pub fn smart_tab(cx: &mut Context) {
3479+ let (view, doc) = current_ref!(cx.editor);
3480+ let view_id = view.id;
3481+
3482+ if matches!(
3483+ cx.editor.config().smart_tab,
3484+ Some(SmartTabConfig { enable: true, .. })
3485+ ) {
3486+ let cursors_after_whitespace = doc.selection(view_id).ranges().iter().all(|range| {
3487+ let cursor = range.cursor(doc.text().slice(..));
3488+ let current_line_num = doc.text().char_to_line(cursor);
3489+ let current_line_start = doc.text().line_to_char(current_line_num);
3490+ let left = doc.text().slice(current_line_start..cursor);
3491+ left.chars().all(|c| c.is_whitespace())
3492+ });
3493+
3494+ if !cursors_after_whitespace {
3495+ move_parent_node_end(cx);
3496+ return;
3497+ }
3498+ }
3499+
3500+ insert_tab(cx);
3501+ }
3502+
3472 pub fn insert_tab(cx: &mut Context) {
3503 pub fn insert_tab(cx: &mut Context) {
3473 let (view, doc) = current!(cx.editor);
3504 let (view, doc) = current!(cx.editor);
3474 // TODO: round out to nearest indentation level (for example a line with 3 spaces should
3505 // 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) {
4626 );
4657 );
4627
4658
4628 doc.set_selection(view.id, selection);
4659 doc.set_selection(view.id, selection);
4660+
4661+ // [TODO] temporary workaround until we're not using the idle timer to
4662+ // trigger auto completions any more
4663+ editor.clear_idle_timer();
4629 }
4664 }
4630 };
4665 };
4631
4666
4632− motion(cx.editor);
4633− cx.editor.last_motion = Some(Motion(Box::new(motion)));
4667+ cx.editor.apply_motion(motion);
4634}
4668}
4635
4669
4636pub fn move_parent_node_end(cx: &mut Context) {
4670pub fn move_parent_node_end(cx: &mut Context) {
helix-term/src/config.rs+1
@@ -109,6 +109,7 @@ impl Config {
109 )?,
109 )?,
110 }
110 }
111 }
111 }
112+
112 // these are just two io errors return the one for the global config
113 // these are just two io errors return the one for the global config
113 (Err(err), Err(_)) => return Err(err),
114 (Err(err), Err(_)) => return Err(err),
114 };
115 };
helix-term/src/keymap/default.rs+2−1
@@ -373,7 +373,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
373 "C-h" | "backspace" | "S-backspace" => delete_char_backward,
373 "C-h" | "backspace" | "S-backspace" => delete_char_backward,
374 "C-d" | "del" => delete_char_forward,
374 "C-d" | "del" => delete_char_forward,
375 "C-j" | "ret" => insert_newline,
375 "C-j" | "ret" => insert_newline,
376+ "tab" => smart_tab,
376− "tab" => insert_tab,
377+ "S-tab" => insert_tab,
377
378
378 "up" => move_visual_line_up,
379 "up" => move_visual_line_up,
379 "down" => move_visual_line_down,
380 "down" => move_visual_line_down,
helix-term/src/ui/menu.rs+16−1
@@ -11,7 +11,7 @@ pub use tui::widgets::{Cell, Row};
11use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
11use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
12use fuzzy_matcher::FuzzyMatcher;
12use fuzzy_matcher::FuzzyMatcher;
13
13
14−use helix_view::{graphics::Rect, Editor};
14+use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
15use tui::layout::Constraint;
15use tui::layout::Constraint;
16
16
17pub trait Item {
17pub trait Item {
@@ -247,6 +247,21 @@ impl<T: Item + 'static> Component for Menu<T> {
247 compositor.pop();
247 compositor.pop();
248 }));
248 }));
249
249
250+ // Ignore tab key when supertab is turned on in order not to interfere
251+ // with it. (Is there a better way to do this?)
252+ if (event == key!(Tab) || event == shift!(Tab))
253+ && cx.editor.config().auto_completion
254+ && matches!(
255+ cx.editor.config().smart_tab,
256+ Some(SmartTabConfig {
257+ enable: true,
258+ supersede_menu: true,
259+ })
260+ )
261+ {
262+ return EventResult::Ignored(None);
263+ }
264+
250 match event {
265 match event {
251 // esc or ctrl-c aborts the completion and closes the menu
266 // esc or ctrl-c aborts the completion and closes the menu
252 key!(Esc) | ctrl!('c') => {
267 key!(Esc) | ctrl!('c') => {
helix-term/tests/test/commands/movement.rs+253
@@ -197,3 +197,256 @@ async fn test_move_parent_node_start() -> anyhow::Result<()> {
197
197
198 Ok(())
198 Ok(())
199}
199}
200+
201+#[tokio::test(flavor = "multi_thread")]
202+async fn test_smart_tab_move_parent_node_end() -> anyhow::Result<()> {
203+ let tests = vec![
204+ // single cursor stays single cursor, first goes to end of current
205+ // node, then parent
206+ (
207+ helpers::platform_line(indoc! {r##"
208+ fn foo() {
209+ let result = if true {
210+ "yes"
211+ } else {
212+ "no#["|]#
213+ }
214+ }
215+ "##}),
216+ "i<tab>",
217+ helpers::platform_line(indoc! {"\
218+ fn foo() {
219+ let result = if true {
220+ \"yes\"
221+ } else {
222+ \"no\"#[|\n]#
223+ }
224+ }
225+ "}),
226+ ),
227+ (
228+ helpers::platform_line(indoc! {"\
229+ fn foo() {
230+ let result = if true {
231+ \"yes\"
232+ } else {
233+ \"no\"#[\n|]#
234+ }
235+ }
236+ "}),
237+ "i<tab>",
238+ helpers::platform_line(indoc! {"\
239+ fn foo() {
240+ let result = if true {
241+ \"yes\"
242+ } else {
243+ \"no\"
244+ }#[|\n]#
245+ }
246+ "}),
247+ ),
248+ // appending to the end of a line should still look at the current
249+ // line, not the next one
250+ (
251+ helpers::platform_line(indoc! {"\
252+ fn foo() {
253+ let result = if true {
254+ \"yes\"
255+ } else {
256+ \"no#[\"|]#
257+ }
258+ }
259+ "}),
260+ "a<tab>",
261+ helpers::platform_line(indoc! {"\
262+ fn foo() {
263+ let result = if true {
264+ \"yes\"
265+ } else {
266+ \"no\"
267+ }#[\n|]#
268+ }
269+ "}),
270+ ),
271+ // before cursor is all whitespace, so insert tab
272+ (
273+ helpers::platform_line(indoc! {"\
274+ fn foo() {
275+ let result = if true {
276+ \"yes\"
277+ } else {
278+ #[\"no\"|]#
279+ }
280+ }
281+ "}),
282+ "i<tab>",
283+ helpers::platform_line(indoc! {"\
284+ fn foo() {
285+ let result = if true {
286+ \"yes\"
287+ } else {
288+ #[|\"no\"]#
289+ }
290+ }
291+ "}),
292+ ),
293+ // if selection spans multiple lines, it should still only look at the
294+ // line on which the head is
295+ (
296+ helpers::platform_line(indoc! {"\
297+ fn foo() {
298+ let result = if true {
299+ #[\"yes\"
300+ } else {
301+ \"no\"|]#
302+ }
303+ }
304+ "}),
305+ "a<tab>",
306+ helpers::platform_line(indoc! {"\
307+ fn foo() {
308+ let result = if true {
309+ \"yes\"
310+ } else {
311+ \"no\"
312+ }#[\n|]#
313+ }
314+ "}),
315+ ),
316+ (
317+ helpers::platform_line(indoc! {"\
318+ fn foo() {
319+ let result = if true {
320+ #[\"yes\"
321+ } else {
322+ \"no\"|]#
323+ }
324+ }
325+ "}),
326+ "i<tab>",
327+ helpers::platform_line(indoc! {"\
328+ fn foo() {
329+ let result = if true {
330+ #[|\"yes\"
331+ } else {
332+ \"no\"]#
333+ }
334+ }
335+ "}),
336+ ),
337+ (
338+ helpers::platform_line(indoc! {"\
339+ fn foo() {
340+ #[l|]#et result = if true {
341+ #(\"yes\"
342+ } else {
343+ \"no\"|)#
344+ }
345+ }
346+ "}),
347+ "i<tab>",
348+ helpers::platform_line(indoc! {"\
349+ fn foo() {
350+ #[|l]#et result = if true {
351+ #(|\"yes\"
352+ } else {
353+ \"no\")#
354+ }
355+ }
356+ "}),
357+ ),
358+ (
359+ helpers::platform_line(indoc! {"\
360+ fn foo() {
361+ let result = if true {
362+ \"yes\"#[\n|]#
363+ } else {
364+ \"no\"#(\n|)#
365+ }
366+ }
367+ "}),
368+ "i<tab>",
369+ helpers::platform_line(indoc! {"\
370+ fn foo() {
371+ let result = if true {
372+ \"yes\"
373+ }#[| ]#else {
374+ \"no\"
375+ }#(|\n)#
376+ }
377+ "}),
378+ ),
379+ (
380+ helpers::platform_line(indoc! {"\
381+ fn foo() {
382+ let result = if true {
383+ #[\"yes\"|]#
384+ } else {
385+ #(\"no\"|)#
386+ }
387+ }
388+ "}),
389+ "i<tab>",
390+ helpers::platform_line(indoc! {"\
391+ fn foo() {
392+ let result = if true {
393+ #[|\"yes\"]#
394+ } else {
395+ #(|\"no\")#
396+ }
397+ }
398+ "}),
399+ ),
400+ // if any cursors are not preceded by all whitespace, then do the
401+ // smart_tab action
402+ (
403+ helpers::platform_line(indoc! {"\
404+ fn foo() {
405+ let result = if true {
406+ #[\"yes\"\n|]#
407+ } else {
408+ \"no#(\"\n|)#
409+ }
410+ }
411+ "}),
412+ "i<tab>",
413+ helpers::platform_line(indoc! {"\
414+ fn foo() {
415+ let result = if true {
416+ \"yes\"
417+ }#[| ]#else {
418+ \"no\"
419+ }#(|\n)#
420+ }
421+ "}),
422+ ),
423+ // Ctrl-tab always inserts a tab
424+ (
425+ helpers::platform_line(indoc! {"\
426+ fn foo() {
427+ let result = if true {
428+ #[\"yes\"\n|]#
429+ } else {
430+ \"no#(\"\n|)#
431+ }
432+ }
433+ "}),
434+ "i<S-tab>",
435+ helpers::platform_line(indoc! {"\
436+ fn foo() {
437+ let result = if true {
438+ #[|\"yes\"\n]#
439+ } else {
440+ \"no #(|\"\n)#
441+ }
442+ }
443+ "}),
444+ ),
445+ ];
446+
447+ for test in tests {
448+ test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
449+ }
450+
451+ Ok(())
452+}
helix-view/src/editor.rs+19
@@ -287,6 +287,24 @@ pub struct Config {
287 pub workspace_lsp_roots: Vec<PathBuf>,
287 pub workspace_lsp_roots: Vec<PathBuf>,
288 /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
288 /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
289 pub default_line_ending: LineEndingConfig,
289 pub default_line_ending: LineEndingConfig,
290+ /// Enables smart tab
291+ pub smart_tab: Option<SmartTabConfig>,
292+}
293+
294+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
295+#[serde(rename_all = "kebab-case", default)]
296+pub struct SmartTabConfig {
297+ pub enable: bool,
298+ pub supersede_menu: bool,
299+}
300+
301+impl Default for SmartTabConfig {
302+ fn default() -> Self {
303+ SmartTabConfig {
304+ enable: true,
305+ supersede_menu: false,
306+ }
307+ }
290}
308}
291
309
292#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -820,6 +838,7 @@ impl Default for Config {
820 completion_replace: false,
838 completion_replace: false,
821 workspace_lsp_roots: Vec::new(),
839 workspace_lsp_roots: Vec::new(),
822 default_line_ending: LineEndingConfig::default(),
840 default_line_ending: LineEndingConfig::default(),
841+ smart_tab: Some(SmartTabConfig::default()),
823 }
842 }
824 }
843 }
825}
844}