From 765a9e4595dc5c6127b9242c4888b89856e65a72 Mon Sep 17 00:00:00 2001 From: JJ Date: Mon, 17 Jul 2023 10:00:43 -0700 Subject: Add patches from helix/staging --- 0001-Make-initial-editing-mode-configurable.patch | 64 +- 0002-Fix-writes-from-insert-mode.patch | 15 +- 0003-Add-support-for-moving-lines.patch | 519 +++ 0004-Add-support-for-Unicode-input.patch | 612 +++ 0011-Add-file-explorer-and-tree-helper.patch | 4729 +++++++++++++++++++++ 0012-Add-rainbow-tree-sitter-matches.patch | 2432 +++++++++++ 0013-Add-rainbow-indentation-guides.patch | 190 + 0014-Add-unbind-default-keys-config-option.patch | 106 + 8 files changed, 8642 insertions(+), 25 deletions(-) create mode 100644 0003-Add-support-for-moving-lines.patch create mode 100644 0004-Add-support-for-Unicode-input.patch create mode 100644 0011-Add-file-explorer-and-tree-helper.patch create mode 100644 0012-Add-rainbow-tree-sitter-matches.patch create mode 100644 0013-Add-rainbow-indentation-guides.patch create mode 100644 0014-Add-unbind-default-keys-config-option.patch diff --git a/0001-Make-initial-editing-mode-configurable.patch b/0001-Make-initial-editing-mode-configurable.patch index 471c9488..4a8beb40 100644 --- a/0001-Make-initial-editing-mode-configurable.patch +++ b/0001-Make-initial-editing-mode-configurable.patch @@ -1,30 +1,31 @@ -From 2ea1169d0d1acec6b8d7368cb79fbb776e1dfbc6 Mon Sep 17 00:00:00 2001 -From: j-james -Date: Thu, 29 Sep 2022 16:17:52 -0700 -Subject: [PATCH] Make initial editing mode configurable +From d3ce6c32f4b4957759209ee6cb215e7968c343e8 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 17:40:45 -0700 +Subject: [PATCH 1/2] Make initial editing mode configurable +ref: https://github.com/helix-editor/helix/pull/3366 --- book/src/configuration.md | 1 + helix-view/src/editor.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/book/src/configuration.md b/book/src/configuration.md -index fdabe768..a55e7d65 100644 +index 1b94ae85..b69bb486 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md -@@ -49,6 +49,7 @@ ### `[editor]` Section - | `auto-info` | Whether to display infoboxes | `true` | - | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | - | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | +@@ -59,6 +59,7 @@ ### `[editor]` Section + | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` | + | `undercurl` | Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative | `false` | + | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` | +| `initial-mode` | The initial mode for newly opened editors. | `"normal"` | | `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` | - + | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs -index 5eff9983..331b17b9 100644 +index affe87dd..3c00a61e 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs -@@ -166,6 +166,8 @@ pub struct Config { +@@ -281,6 +281,8 @@ pub struct Config { pub bufferline: BufferLine, /// Vertical indent width guides. pub indent_guides: IndentGuidesConfig, @@ -32,16 +33,16 @@ index 5eff9983..331b17b9 100644 + pub initial_mode: Mode, /// Whether to color modes with different colors. Defaults to `false`. pub color_modes: bool, - } -@@ -576,6 +578,7 @@ fn default() -> Self { + pub soft_wrap: SoftWrap, +@@ -812,6 +814,7 @@ fn default() -> Self { whitespace: WhitespaceConfig::default(), bufferline: BufferLine::default(), indent_guides: IndentGuidesConfig::default(), + initial_mode: Mode::Normal, color_modes: false, - } - } -@@ -993,6 +996,7 @@ pub fn switch(&mut self, id: DocumentId, action: Action) { + soft_wrap: SoftWrap { + enable: Some(false), +@@ -1363,6 +1366,7 @@ pub fn switch(&mut self, id: DocumentId, action: Action) { /// Generate an id for a new document and register it. fn new_document(&mut self, mut doc: Document) -> DocumentId { @@ -50,5 +51,32 @@ index 5eff9983..331b17b9 100644 // Safety: adding 1 from 1 is fine, probably impossible to reach usize max self.next_document_id = -- -2.37.3 +2.41.0 + + +From 96f81b33f6a1380a4acb5b3ca379e30a0fd29e48 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 23:40:55 -0700 +Subject: [PATCH 2/2] bandaid for enter_normal_mode panicking upon launch + +--- + helix-view/src/editor.rs | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs +index 5fce5446..1d1f6bf7 100644 +--- a/helix-view/src/editor.rs ++++ b/helix-view/src/editor.rs +@@ -1332,7 +1332,8 @@ pub fn switch(&mut self, id: DocumentId, action: Action) { + return; + } + +- self.enter_normal_mode(); ++ // panics upon launch. todo: debug ++ // self.enter_normal_mode(); + + match action { + Action::Replace => { +-- +2.41.0 diff --git a/0002-Fix-writes-from-insert-mode.patch b/0002-Fix-writes-from-insert-mode.patch index f64c3b7f..22b00cf3 100644 --- a/0002-Fix-writes-from-insert-mode.patch +++ b/0002-Fix-writes-from-insert-mode.patch @@ -1,15 +1,16 @@ -From 9c4369da34bbbb1a6d26ba35be41739f36fb722b Mon Sep 17 00:00:00 2001 +From 6ef36e958254b873684621bb91d1ba299e2d9d7d Mon Sep 17 00:00:00 2001 From: JJ -Date: Sat, 3 Jun 2023 23:03:58 -0700 +Date: Sat, 15 Jul 2023 17:42:04 -0700 Subject: [PATCH 1/2] Fix writes from insert mode not properly updating the revision history +ref: https://github.com/helix-editor/helix/pull/7226 --- helix-term/src/commands/typed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs -index 706442e4..8e043385 100644 +index dfc71dfd..3f746bf0 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -350,6 +350,8 @@ fn write_impl( @@ -25,9 +26,9 @@ index 706442e4..8e043385 100644 2.41.0 -From e2a877432ce7f75eba60d5dd579e5a0ab45b73e8 Mon Sep 17 00:00:00 2001 +From 8305a40ec1fba47295987e8e8278a4aa2061009e Mon Sep 17 00:00:00 2001 From: JJ -Date: Sat, 3 Jun 2023 23:06:02 -0700 +Date: Sat, 15 Jul 2023 17:44:04 -0700 Subject: [PATCH 2/2] Write pre-manipulation pastes to the revision history --- @@ -35,10 +36,10 @@ Subject: [PATCH 2/2] Write pre-manipulation pastes to the revision history 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs -index 43b5d1af..ef4299b4 100644 +index 5b5cda93..252aff0d 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs -@@ -1235,15 +1235,9 @@ fn handle_event( +@@ -1240,15 +1240,9 @@ fn handle_event( cx.editor.count = None; let config = cx.editor.config(); diff --git a/0003-Add-support-for-moving-lines.patch b/0003-Add-support-for-moving-lines.patch new file mode 100644 index 00000000..afa15cf2 --- /dev/null +++ b/0003-Add-support-for-moving-lines.patch @@ -0,0 +1,519 @@ +From a46db95dc5cdae4deca31b8e021805768d1a8390 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 17:55:37 -0700 +Subject: [PATCH 1/2] Add support for moving lines and selections above and + below + +ref: https://github.com/helix-editor/helix/pull/4545 +--- + helix-term/src/commands.rs | 213 ++++++++++++++++++++++++++++++- + helix-term/src/keymap/default.rs | 2 + + 2 files changed, 214 insertions(+), 1 deletion(-) + +diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs +index 58c17296..dc8b5b5c 100644 +--- a/helix-term/src/commands.rs ++++ b/helix-term/src/commands.rs +@@ -28,7 +28,7 @@ + textobject, + tree_sitter::Node, + unicode::width::UnicodeWidthChar, +- visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, ++ visual_offset_from_block, Change, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, + RopeReader, RopeSlice, Selection, SmallVec, Tendril, Transaction, + }; + use helix_view::{ +@@ -326,6 +326,8 @@ pub fn doc(&self) -> &str { + goto_declaration, "Goto declaration", + add_newline_above, "Add newline above", + add_newline_below, "Add newline below", ++ move_selection_above, "Move current line or selection up", ++ move_selection_below, "Move current line or selection down", + goto_type_definition, "Goto type definition", + goto_implementation, "Goto implementation", + goto_file_start, "Goto line number else file start", +@@ -5510,6 +5512,215 @@ fn add_newline_impl(cx: &mut Context, open: Open) { + doc.apply(&transaction, view.id); + } + ++#[derive(Debug, PartialEq, Eq)] ++pub enum MoveSelection { ++ Below, ++ Above, ++} ++ ++type ExtendedChange = (usize, usize, Option, Option<(usize, usize)>); ++ ++/// Move line or block of text in specified direction. ++/// The function respects single line, single selection, multiple lines using ++/// several cursors and multiple selections. ++fn move_selection(cx: &mut Context, direction: MoveSelection) { ++ let (view, doc) = current!(cx.editor); ++ let selection = doc.selection(view.id); ++ let text = doc.text(); ++ let slice = text.slice(..); ++ let mut last_step_changes: Vec = vec![]; ++ let mut at_doc_edge = false; ++ let all_changes = selection.into_iter().map(|range| { ++ let (start, end) = range.line_range(slice); ++ let line_start = text.line_to_char(start); ++ let line_end = line_end_char_index(&slice, end); ++ let line = text.slice(line_start..line_end).to_string(); ++ ++ let next_line = match direction { ++ MoveSelection::Above => start.saturating_sub(1), ++ MoveSelection::Below => end + 1, ++ }; ++ ++ let rel_pos_anchor = range.anchor - line_start; ++ let rel_pos_head = range.head - line_start; ++ ++ if next_line == start || next_line >= text.len_lines() || at_doc_edge { ++ at_doc_edge = true; ++ let cursor_rel_pos = (rel_pos_anchor, rel_pos_head); ++ let changes = vec![( ++ line_start, ++ line_end, ++ Some(line.into()), ++ Some(cursor_rel_pos), ++ )]; ++ last_step_changes = changes.clone(); ++ changes ++ } else { ++ let next_line_start = text.line_to_char(next_line); ++ let next_line_end = line_end_char_index(&slice, next_line); ++ let next_line_text = text.slice(next_line_start..next_line_end).to_string(); ++ ++ let cursor_rel_pos = (rel_pos_anchor, rel_pos_head); ++ let changes = match direction { ++ MoveSelection::Above => vec![ ++ ( ++ next_line_start, ++ next_line_end, ++ Some(line.into()), ++ Some(cursor_rel_pos), ++ ), ++ (line_start, line_end, Some(next_line_text.into()), None), ++ ], ++ MoveSelection::Below => vec![ ++ (line_start, line_end, Some(next_line_text.into()), None), ++ ( ++ next_line_start, ++ next_line_end, ++ Some(line.into()), ++ Some(cursor_rel_pos), ++ ), ++ ], ++ }; ++ ++ let changes = if last_step_changes.len() > 1 { ++ evaluate_changes(last_step_changes.clone(), changes.clone(), &direction) ++ } else { ++ changes ++ }; ++ last_step_changes = changes.clone(); ++ changes ++ } ++ }); ++ ++ /// Merge changes from subsequent cursors ++ fn evaluate_changes( ++ mut last_changes: Vec, ++ current_changes: Vec, ++ direction: &MoveSelection, ++ ) -> Vec { ++ let mut current_it = current_changes.into_iter(); ++ ++ if let (Some(mut last), Some(mut current_first), Some(current_last)) = ++ (last_changes.pop(), current_it.next(), current_it.next()) ++ { ++ if last.0 == current_first.0 { ++ match direction { ++ MoveSelection::Above => { ++ last.0 = current_last.0; ++ last.1 = current_last.1; ++ if let Some(first) = last_changes.pop() { ++ last_changes.push(first) ++ } ++ last_changes.extend(vec![current_first, last.to_owned()]); ++ last_changes ++ } ++ MoveSelection::Below => { ++ current_first.0 = last_changes[0].0; ++ current_first.1 = last_changes[0].1; ++ last_changes[0] = current_first; ++ last_changes.extend(vec![last.to_owned(), current_last]); ++ last_changes ++ } ++ } ++ } else { ++ if let Some(first) = last_changes.pop() { ++ last_changes.push(first) ++ } ++ last_changes.extend(vec![last.to_owned(), current_first, current_last]); ++ last_changes ++ } ++ } else { ++ last_changes ++ } ++ } ++ ++ let mut flattened: Vec> = all_changes.into_iter().collect(); ++ let last_changes = flattened.pop().unwrap_or(vec![]); ++ ++ let acc_cursors = get_adjusted_selection(&doc, &last_changes, direction, at_doc_edge); ++ ++ let changes: Vec = last_changes ++ .into_iter() ++ .map(|change| (change.0, change.1, change.2.to_owned())) ++ .collect(); ++ ++ let new_sel = Selection::new(acc_cursors.into(), 0); ++ let transaction = Transaction::change(doc.text(), changes.into_iter()); ++ ++ doc.apply(&transaction, view.id); ++ doc.set_selection(view.id, new_sel); ++} ++ ++/// Returns selection range that is valid for the updated document ++/// This logic is necessary because it's not possible to apply changes ++/// to the document first and then set selection. ++fn get_adjusted_selection( ++ doc: &Document, ++ last_changes: &Vec, ++ direction: MoveSelection, ++ at_doc_edge: bool, ++) -> Vec { ++ let mut first_change_len = 0; ++ let mut next_start = 0; ++ let mut acc_cursors: Vec = vec![]; ++ ++ for change in last_changes.iter() { ++ let change_len = change.2.as_ref().map_or(0, |x| x.len()); ++ ++ if let Some((rel_anchor, rel_head)) = change.3 { ++ let (anchor, head) = if at_doc_edge { ++ let anchor = change.0 + rel_anchor; ++ let head = change.0 + rel_head; ++ (anchor, head) ++ } else { ++ match direction { ++ MoveSelection::Above => { ++ if next_start == 0 { ++ next_start = change.0; ++ } ++ let anchor = next_start + rel_anchor; ++ let head = next_start + rel_head; ++ ++ // If there is next cursor below, selection position should be adjusted ++ // according to the length of the current line. ++ next_start += change_len + doc.line_ending.len_chars(); ++ (anchor, head) ++ } ++ MoveSelection::Below => { ++ let anchor = change.0 + first_change_len + rel_anchor - change_len; ++ let head = change.0 + first_change_len + rel_head - change_len; ++ (anchor, head) ++ } ++ } ++ }; ++ ++ let cursor = Range::new(anchor, head); ++ if let Some(last) = acc_cursors.pop() { ++ if cursor.overlaps(&last) { ++ acc_cursors.push(last); ++ } else { ++ acc_cursors.push(last); ++ acc_cursors.push(cursor); ++ }; ++ } else { ++ acc_cursors.push(cursor); ++ }; ++ } else { ++ first_change_len = change.2.as_ref().map_or(0, |x| x.len()); ++ next_start = 0; ++ }; ++ } ++ acc_cursors ++} ++ ++fn move_selection_below(cx: &mut Context) { ++ move_selection(cx, MoveSelection::Below) ++} ++ ++fn move_selection_above(cx: &mut Context) { ++ move_selection(cx, MoveSelection::Above) ++} ++ + enum IncrementDirection { + Increase, + Decrease, +diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs +index c84c616c..f384c868 100644 +--- a/helix-term/src/keymap/default.rs ++++ b/helix-term/src/keymap/default.rs +@@ -321,6 +321,8 @@ pub fn default() -> HashMap { + + "C-a" => increment, + "C-x" => decrement, ++ "C-k" => move_selection_above, ++ "C-j" => move_selection_below, + }); + let mut select = normal.clone(); + select.merge_nodes(keymap!({ "Select mode" +-- +2.41.0 + + +From 3888d20b74bf8a723af276973485493231ab0f85 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 17:55:58 -0700 +Subject: [PATCH 2/2] Unit test moving lines and selections + +--- + helix-term/tests/test/commands.rs | 186 ++++++++++++++++++++++++++++-- + 1 file changed, 176 insertions(+), 10 deletions(-) + +diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs +index b13c37bc..4ca7e9f2 100644 +--- a/helix-term/tests/test/commands.rs ++++ b/helix-term/tests/test/commands.rs +@@ -96,6 +96,172 @@ async fn test_selection_duplication() -> anyhow::Result<()> { + Ok(()) + } + ++// Line selection movement tests ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_single_selection_up() -> anyhow::Result<()> { ++ test(( ++ platform_line(indoc! {" ++ aaaaaa ++ bbbbbb ++ cc#[|c]#ccc ++ dddddd ++ "}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ aaaaaa ++ cc#[|c]#ccc ++ bbbbbb ++ dddddd ++ "}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_single_selection_down() -> anyhow::Result<()> { ++ test(( ++ platform_line(indoc! {" ++ aa#[|a]#aaa ++ bbbbbb ++ cccccc ++ dddddd ++ "}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ bbbbbb ++ aa#[|a]#aaa ++ cccccc ++ dddddd ++ "}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_single_selection_top_up() -> anyhow::Result<()> { ++ // if already on top of the file and going up, nothing should change ++ test(( ++ platform_line(indoc! {" ++ aa#[|a]#aaa ++ bbbbbb ++ cccccc ++ dddddd"}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ aa#[|a]#aaa ++ bbbbbb ++ cccccc ++ dddddd"}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_single_selection_bottom_down() -> anyhow::Result<()> { ++ // If going down on the bottom line, nothing should change ++ // Note that platform_line is not used here, because it inserts trailing ++ // linebreak, making it impossible to test ++ test(( ++ "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd", ++ "", ++ "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd", ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_block_up() -> anyhow::Result<()> { ++ test(( ++ platform_line(indoc! {" ++ aaaaaa ++ bb#[bbbb ++ ccc|]#ccc ++ dddddd ++ eeeeee ++ "}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ bb#[bbbb ++ ccc|]#ccc ++ aaaaaa ++ dddddd ++ eeeeee ++ "}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_selection_block_down() -> anyhow::Result<()> { ++ test(( ++ platform_line(indoc! {" ++ #[|aaaaaa ++ bbbbbb ++ ccc]#ccc ++ dddddd ++ eeeeee ++ "}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ dddddd ++ #[|aaaaaa ++ bbbbbb ++ ccc]#ccc ++ eeeeee ++ "}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread")] ++async fn test_move_two_cursors_down() -> anyhow::Result<()> { ++ test(( ++ platform_line(indoc! {" ++ aaaaaa ++ bb#[|b]#bbb ++ cccccc ++ d#(dd|)#ddd ++ eeeeee ++ "}) ++ .as_str(), ++ "", ++ platform_line(indoc! {" ++ aaaaaa ++ cccccc ++ bb#[|b]#bbb ++ eeeeee ++ d#(dd|)#ddd ++ "}) ++ .as_str(), ++ )) ++ .await?; ++ ++ Ok(()) ++} ++ + #[tokio::test(flavor = "multi_thread")] + async fn test_goto_file_impl() -> anyhow::Result<()> { + let file = tempfile::NamedTempFile::new()?; +@@ -187,11 +353,11 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> { + "|echo foo", + platform_line(indoc! {"\ + #[|foo\n]# +- ++ + #(|foo\n)# +- ++ + #(|foo\n)# +- ++ + "}), + )) + .await?; +@@ -225,11 +391,11 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> { + "echo foo", + platform_line(indoc! {"\ + lorem#[|foo\n]# +- ++ + ipsum#(|foo\n)# +- ++ + dolor#(|foo\n)# +- ++ + "}), + )) + .await?; +@@ -273,14 +439,14 @@ async fn test_extend_line() -> anyhow::Result<()> { + #[l|]#orem + ipsum + dolor +- ++ + "}), + "x2x", + platform_line(indoc! {"\ + #[lorem + ipsum + dolor\n|]# +- ++ + "}), + )) + .await?; +@@ -290,13 +456,13 @@ async fn test_extend_line() -> anyhow::Result<()> { + platform_line(indoc! {"\ + #[l|]#orem + ipsum +- ++ + "}), + "2x", + platform_line(indoc! {"\ + #[lorem + ipsum\n|]# +- ++ + "}), + )) + .await?; +-- +2.41.0 + diff --git a/0004-Add-support-for-Unicode-input.patch b/0004-Add-support-for-Unicode-input.patch new file mode 100644 index 00000000..e27ed7e4 --- /dev/null +++ b/0004-Add-support-for-Unicode-input.patch @@ -0,0 +1,612 @@ +From 9a032b407260c8bd685b42da300d13afffb12d94 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 22:10:15 -0700 +Subject: [PATCH] Add support for digraphs + +ref: https://github.com/helix-editor/helix/pull/2852 +--- + book/src/configuration.md | 12 + + book/src/keymap.md | 1 + + helix-term/src/commands.rs | 48 ++++ + helix-term/src/keymap/default.rs | 2 + + helix-view/src/digraph.rs | 437 +++++++++++++++++++++++++++++++ + helix-view/src/editor.rs | 4 + + helix-view/src/lib.rs | 1 + + 7 files changed, 505 insertions(+) + create mode 100644 helix-view/src/digraph.rs + +diff --git a/book/src/configuration.md b/book/src/configuration.md +index 6609c30e..0af686f4 100644 +--- a/book/src/configuration.md ++++ b/book/src/configuration.md +@@ -360,3 +360,15 @@ ### `[editor.explorer]` Section + | -------------- | ------------------------------------------- | ------- | + | `column-width` | explorer side width | 30 | + | `position` | explorer widget position, `left` or `right` | `left` | ++ ++### `[editor.digraphs]` Section ++ ++By default, special characters can be input using the `insert_digraphs` command, bound to `\` in normal mode. ++Custom digraphs can be added to the `editor.digraphs` section of the config. ++ ++```toml ++[editor.digraphs] ++ka = "か" ++ku = { symbols = "く", description = "The japanese character Ku" } ++shrug = "¯\\_(ツ)_/¯" ++``` +diff --git a/book/src/keymap.md b/book/src/keymap.md +index 4e6e878d..11ad358a 100644 +--- a/book/src/keymap.md ++++ b/book/src/keymap.md +@@ -72,6 +72,7 @@ ### Changes + | `a` | Insert after selection (append) | `append_mode` | + | `I` | Insert at the start of the line | `insert_at_line_start` | + | `A` | Insert at the end of the line | `insert_at_line_end` | ++| `\` | Insert digraphs | `insert_digraph` | + | `o` | Open new line below selection | `open_below` | + | `O` | Open new line above selection | `open_above` | + | `.` | Repeat last insert | N/A | +diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs +index edeb419b..771c73bc 100644 +--- a/helix-term/src/commands.rs ++++ b/helix-term/src/commands.rs +@@ -490,6 +490,7 @@ pub fn doc(&self) -> &str { + command_palette, "Open command palette", + open_or_focus_explorer, "Open or focus explorer", + reveal_current_file, "Reveal current file in explorer", ++ insert_digraph, "Insert Unicode characters with prompt", + ); + } + +@@ -5901,3 +5902,50 @@ fn replay_macro(cx: &mut Context) { + cx.editor.macro_replaying.pop(); + })); + } ++ ++fn insert_digraph(cx: &mut Context) { ++ ui::prompt( ++ cx, ++ "digraph:".into(), ++ Some('K'), // todo: decide on register to use ++ move |editor, input| { ++ editor ++ .config() ++ .digraphs ++ .search(input) ++ .take(10) ++ .map(|entry| { ++ // todo: Prompt does not currently allow additional text as part ++ // of it's suggestions. Show the user the symbol and description ++ // once prompt has been made more robust ++ #[allow(clippy::useless_format)] ++ ((0..), Cow::from(format!("{}", entry.sequence))) ++ }) ++ .collect() ++ }, ++ move |cx, input, event| { ++ match event { ++ PromptEvent::Validate => (), ++ _ => return, ++ } ++ let config = cx.editor.config(); ++ let symbols = if let Some(entry) = config.digraphs.get(input) { ++ &entry.symbols ++ } else { ++ cx.editor.set_error("Digraph not found"); ++ return; ++ }; ++ ++ let (view, doc) = current!(cx.editor); ++ let selection = doc.selection(view.id); ++ let mut changes = Vec::with_capacity(selection.len()); ++ ++ for range in selection.ranges() { ++ changes.push((range.from(), range.from(), Some(symbols.clone().into()))); ++ } ++ let trans = Transaction::change(doc.text(), changes.into_iter()); ++ doc.apply(&trans, view.id); ++ doc.append_changes_to_history(view); ++ }, ++ ) ++} +diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs +index 43d101fe..9702c627 100644 +--- a/helix-term/src/keymap/default.rs ++++ b/helix-term/src/keymap/default.rs +@@ -135,6 +135,8 @@ pub fn default() -> HashMap { + "N" => search_prev, + "*" => search_selection, + ++ "\\" => insert_digraph, ++ + "u" => undo, + "U" => redo, + "A-u" => earlier, +diff --git a/helix-view/src/digraph.rs b/helix-view/src/digraph.rs +new file mode 100644 +index 00000000..86333333 +--- /dev/null ++++ b/helix-view/src/digraph.rs +@@ -0,0 +1,437 @@ ++use anyhow::Result; ++use serde::{ser::SerializeMap, Deserialize, Serialize}; ++use std::collections::HashMap; ++ ++// Errors ++#[derive(PartialEq, Eq, Debug, Clone)] ++pub enum Error { ++ EmptyInput(String), ++ DuplicateEntry { ++ seq: String, ++ current: String, ++ existing: String, ++ }, ++ Custom(String), ++} ++ ++impl serde::de::Error for Error { ++ fn custom(msg: T) -> Self ++ where ++ T: std::fmt::Display, ++ { ++ Error::Custom(msg.to_string()) ++ } ++} ++ ++impl std::fmt::Display for Error { ++ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { ++ match self { ++ Error::EmptyInput(s) => { ++ f.write_str(&format!("No symbols were given for key sequence {}", s)) ++ } ++ Error::DuplicateEntry { ++ seq, ++ current, ++ existing, ++ } => f.write_str(&format!( ++ "Attempted to bind {} to symbols ({}) when already bound to ({})", ++ seq, current, existing ++ )), ++ Error::Custom(s) => f.write_str(s), ++ } ++ } ++} ++ ++impl std::error::Error for Error {} ++ ++/// Trie implementation for storing and searching input ++/// strings -> unicode characters defined by the user. ++#[derive(Default, Debug, Clone, PartialEq, Eq)] ++pub struct DigraphStore { ++ head: DigraphNode, ++} ++ ++#[derive(Default, Debug, Clone, PartialEq, Eq)] ++struct DigraphNode { ++ output: Option, ++ children: Option>, ++} ++ ++#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ++pub struct DigraphEntry { ++ pub symbols: String, ++ pub description: Option, ++} ++ ++#[derive(Default, Debug, Clone, PartialEq, Eq)] ++pub struct FullDigraphEntry { ++ pub sequence: String, ++ pub symbols: String, ++ pub description: Option, ++} ++ ++impl<'de> Deserialize<'de> for DigraphStore { ++ fn deserialize(deserializer: D) -> Result ++ where ++ D: serde::Deserializer<'de>, ++ { ++ #[derive(Deserialize)] ++ #[serde(untagged)] ++ enum EntryDef { ++ Full(DigraphEntry), ++ Symbols(String), ++ } ++ ++ let mut store = Self::default(); ++ HashMap::::deserialize(deserializer)? ++ .into_iter() ++ .map(|(k, d)| match d { ++ EntryDef::Symbols(symbols) => ( ++ k, ++ DigraphEntry { ++ symbols, ++ description: None, ++ }, ++ ), ++ EntryDef::Full(entry) => (k, entry), ++ }) ++ .try_for_each(|(k, v)| store.insert(&k, v)) ++ .map_err(serde::de::Error::custom)?; ++ ++ Ok(store) ++ } ++} ++ ++impl Serialize for DigraphStore { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: serde::Serializer, ++ { ++ let mut m = serializer.serialize_map(None)?; ++ ++ self.search("").try_for_each(|entry| { ++ m.serialize_entry( ++ &entry.sequence, ++ &DigraphEntry { ++ symbols: entry.symbols.clone(), ++ description: entry.description.clone(), ++ }, ++ ) ++ })?; ++ m.end() ++ } ++} ++ ++/// A Store of input -> unicode strings that can be quickly looked up and ++/// searched. ++impl DigraphStore { ++ /// Inserts a new unicode string into the store ++ pub fn insert(&mut self, input_seq: &str, entry: DigraphEntry) -> Result<(), Error> { ++ if input_seq.is_empty() { ++ return Err(Error::EmptyInput(input_seq.to_string())); ++ } ++ ++ self.head.insert( ++ input_seq, ++ FullDigraphEntry { ++ sequence: input_seq.to_string(), ++ symbols: entry.symbols, ++ description: entry.description, ++ }, ++ ) ++ } ++ ++ /// Attempts to retrieve a stored unicode string if it exists ++ pub fn get(&self, exact_seq: &str) -> Option<&FullDigraphEntry> { ++ self.head.get(exact_seq).and_then(|n| n.output.as_ref()) ++ } ++ ++ /// Returns an iterator of closest matches to the input string ++ pub fn search(&self, input_seq: &str) -> impl Iterator { ++ self.head.get(input_seq).into_iter().flat_map(|x| x.iter()) ++ } ++} ++ ++impl DigraphNode { ++ fn insert(&mut self, input_seq: &str, entry: FullDigraphEntry) -> Result<(), Error> { ++ // see if we found the spot to insert our unicode ++ if input_seq.is_empty() { ++ if let Some(existing) = &self.output { ++ return Err(Error::DuplicateEntry { ++ seq: entry.sequence, ++ existing: existing.symbols.clone(), ++ current: entry.symbols, ++ }); ++ } else { ++ self.output = Some(entry); ++ return Ok(()); ++ } ++ } ++ ++ // continue searching ++ let node = self ++ .children ++ .get_or_insert(Default::default()) ++ .entry(input_seq.chars().next().unwrap()) ++ .or_default(); ++ ++ node.insert(&input_seq[1..], entry) ++ } ++ ++ fn get(&self, exact_seq: &str) -> Option<&Self> { ++ if exact_seq.is_empty() { ++ return Some(self); ++ } ++ ++ self.children ++ .as_ref() ++ .and_then(|cm| cm.get(&exact_seq.chars().next().unwrap())) ++ .and_then(|node| node.get(&exact_seq[1..])) ++ } ++ ++ fn iter<'a>(&'a self) -> impl Iterator + 'a { ++ DigraphIter::new(self) ++ } ++} ++ ++pub struct DigraphIter<'a, 'b> ++where ++ 'a: 'b, ++{ ++ element_iter: Box + 'b>, ++ node_iter: Box + 'b>, ++} ++ ++impl<'a, 'b> DigraphIter<'a, 'b> ++where ++ 'a: 'b, ++{ ++ fn new(node: &'a DigraphNode) -> Self { ++ // do a lazy breadth-first search by keeping track of the next 'rung' of ++ // elements to produce, and the next 'rung' of nodes to refill the element ++ // iterator when empty ++ Self { ++ element_iter: Box::new(node.output.iter().chain(Self::get_child_elements(node))), ++ node_iter: Box::new(Self::get_child_nodes(node)), ++ } ++ } ++ ++ fn get_child_elements( ++ node: &'a DigraphNode, ++ ) -> impl Iterator + 'b { ++ node.children ++ .iter() ++ .flat_map(|hm| hm.iter()) ++ .flat_map(|(_, node)| node.output.as_ref()) ++ } ++ ++ fn get_child_nodes(node: &'a DigraphNode) -> impl Iterator + 'b { ++ node.children ++ .iter() ++ .flat_map(|x| x.iter().map(|(_, node)| node)) ++ } ++} ++impl<'a, 'b> Iterator for DigraphIter<'a, 'b> ++where ++ 'a: 'b, ++{ ++ type Item = &'a FullDigraphEntry; ++ ++ fn next(&mut self) -> Option { ++ loop { ++ if let Some(e) = self.element_iter.next() { ++ return Some(e); ++ } ++ ++ // We ran out of elements, fetch more by traversing the next rung of nodes ++ match self.node_iter.next() { ++ Some(node) => { ++ // todo: figure out a better way to update self's nodes ++ let mut new_nodes: Box> = ++ Box::new(std::iter::empty()); ++ std::mem::swap(&mut new_nodes, &mut self.node_iter); ++ let mut new_nodes: Box> = ++ Box::new(new_nodes.chain(Self::get_child_nodes(node))); ++ std::mem::swap(&mut new_nodes, &mut self.node_iter); ++ ++ self.element_iter = Box::new(Self::get_child_elements(node)); ++ } ++ None => return None, ++ } ++ } ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn digraph_insert() { ++ let mut dg = DigraphStore::default(); ++ dg.insert( ++ "abc", ++ DigraphEntry { ++ symbols: "testbug".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ dg.insert( ++ "abd", ++ DigraphEntry { ++ symbols: "deadbeef".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ assert_eq!( ++ dg.head ++ .children ++ .as_ref() ++ .unwrap() ++ .get(&'a') ++ .unwrap() ++ .children ++ .as_ref() ++ .unwrap() ++ .get(&'b') ++ .unwrap() ++ .children ++ .as_ref() ++ .unwrap() ++ .get(&'c') ++ .unwrap() ++ .output ++ .clone() ++ .unwrap() ++ .symbols ++ .clone(), ++ "testbug".to_string() ++ ); ++ } ++ ++ #[test] ++ fn digraph_insert_and_get() { ++ let mut dg = DigraphStore::default(); ++ dg.insert( ++ "abc", ++ DigraphEntry { ++ symbols: "testbug".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ dg.insert( ++ "abd", ++ DigraphEntry { ++ symbols: "deadbeef".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ assert_eq!( ++ dg.get("abc").map(|x| x.symbols.clone()), ++ Some("testbug".to_string()) ++ ); ++ assert_eq!( ++ dg.get("abd").map(|x| x.symbols.clone()), ++ Some("deadbeef".to_string()) ++ ); ++ assert_eq!(dg.get("abe").map(|x| x.symbols.clone()), None); ++ } ++ ++ #[test] ++ fn digraph_node_iter() { ++ let mut dg = DigraphStore::default(); ++ dg.insert( ++ "abc", ++ DigraphEntry { ++ symbols: "testbug".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ dg.insert( ++ "abd", ++ DigraphEntry { ++ symbols: "deadbeef".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ assert_eq!(dg.head.iter().count(), 2); ++ } ++ ++ #[test] ++ fn digraph_search() { ++ let mut dg = DigraphStore::default(); ++ dg.insert( ++ "abc", ++ DigraphEntry { ++ symbols: "testbug".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ dg.insert( ++ "abd", ++ DigraphEntry { ++ symbols: "deadbeef".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ dg.insert( ++ "azz", ++ DigraphEntry { ++ symbols: "qwerty".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ assert_eq!(dg.search("ab").count(), 2); ++ assert_eq!(dg.search("az").next().unwrap().symbols, "qwerty"); ++ } ++ ++ #[test] ++ fn digraph_search_breadth() { ++ let mut dg = DigraphStore::default(); ++ dg.insert( ++ "abccccc", ++ DigraphEntry { ++ symbols: "testbug".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ dg.insert( ++ "abd", ++ DigraphEntry { ++ symbols: "deadbeef".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ dg.insert( ++ "abee", ++ DigraphEntry { ++ symbols: "qwerty".into(), ++ ..Default::default() ++ }, ++ ) ++ .unwrap(); ++ ++ assert_eq!(dg.search("ab").count(), 3); ++ assert_eq!(dg.search("ab").next().unwrap().symbols, "deadbeef"); ++ } ++} +diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs +index 543c7bde..5fce5446 100644 +--- a/helix-view/src/editor.rs ++++ b/helix-view/src/editor.rs +@@ -1,6 +1,7 @@ + use crate::{ + align_view, + clipboard::{get_clipboard_provider, ClipboardProvider}, ++ digraph::DigraphStore, + document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, + graphics::{CursorKind, Rect}, + info::Info, +@@ -318,6 +319,8 @@ pub struct Config { + pub default_line_ending: LineEndingConfig, + /// Whether to render rainbow highlights. Defaults to `false`. + pub rainbow_brackets: bool, ++ /// User supplied digraphs for use with the `insert_diagraphs` command ++ pub digraphs: DigraphStore, + } + + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +@@ -864,6 +867,7 @@ fn default() -> Self { + workspace_lsp_roots: Vec::new(), + default_line_ending: LineEndingConfig::default(), + rainbow_brackets: false, ++ digraphs: Default::default(), + } + } + } +diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs +index c3f67345..0eabafa5 100644 +--- a/helix-view/src/lib.rs ++++ b/helix-view/src/lib.rs +@@ -12,6 +12,7 @@ pub mod handlers { + pub mod lsp; + } + pub mod base64; ++pub mod digraph; + pub mod info; + pub mod input; + pub mod keyboard; +-- +2.41.0 + diff --git a/0011-Add-file-explorer-and-tree-helper.patch b/0011-Add-file-explorer-and-tree-helper.patch new file mode 100644 index 00000000..15cf1743 --- /dev/null +++ b/0011-Add-file-explorer-and-tree-helper.patch @@ -0,0 +1,4729 @@ +From b03e6400a70c691720044c477968b89239ee5f89 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 18:46:53 -0700 +Subject: [PATCH 1/2] Add file explorer and tree helper + +ref: https://github.com/helix-editor/helix/pull/5768 +--- + book/src/configuration.md | 9 + + book/src/keymap.md | 5 + + helix-term/src/commands.rs | 45 ++ + helix-term/src/keymap/default.rs | 1 + + helix-term/src/ui/editor.rs | 70 +- + helix-term/src/ui/explorer.rs | 751 +++++++++++++++++++ + helix-term/src/ui/mod.rs | 4 + + helix-term/src/ui/overlay.rs | 21 +- + helix-term/src/ui/prompt.rs | 4 + + helix-term/src/ui/tree.rs | 1209 ++++++++++++++++++++++++++++++ + helix-view/src/editor.rs | 39 + + helix-view/src/graphics.rs | 28 + + 12 files changed, 2159 insertions(+), 27 deletions(-) + create mode 100644 helix-term/src/ui/explorer.rs + create mode 100644 helix-term/src/ui/tree.rs + +diff --git a/book/src/configuration.md b/book/src/configuration.md +index b69bb486..bed20b28 100644 +--- a/book/src/configuration.md ++++ b/book/src/configuration.md +@@ -348,3 +348,12 @@ ### `[editor.soft-wrap]` Section + max-indent-retain = 0 + wrap-indicator = "" # set wrap-indicator to "" to hide it + ``` ++ ++### `[editor.explorer]` Section ++ ++Sets explorer side width and style. ++ ++| Key | Description | Default | ++| -------------- | ------------------------------------------- | ------- | ++| `column-width` | explorer side width | 30 | ++| `position` | explorer widget position, `left` or `right` | `left` | +diff --git a/book/src/keymap.md b/book/src/keymap.md +index 153f3b64..4e6e878d 100644 +--- a/book/src/keymap.md ++++ b/book/src/keymap.md +@@ -296,6 +296,7 @@ #### Space mode + | `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | + | `/` | Global search in workspace folder | `global_search` | + | `?` | Open command palette | `command_palette` | ++| `e` | Reveal current file in explorer | `reveal_current_file` | + + > 💡 Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file. + +@@ -451,3 +452,7 @@ ## Prompt + | `Tab` | Select next completion item | + | `BackTab` | Select previous completion item | + | `Enter` | Open selected | ++ ++## File explorer ++ ++Press `?` to see keymaps. Remapping currently not supported. +diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs +index dc8b5b5c..edeb419b 100644 +--- a/helix-term/src/commands.rs ++++ b/helix-term/src/commands.rs +@@ -488,6 +488,8 @@ pub fn doc(&self) -> &str { + record_macro, "Record macro", + replay_macro, "Replay macro", + command_palette, "Open command palette", ++ open_or_focus_explorer, "Open or focus explorer", ++ reveal_current_file, "Reveal current file in explorer", + ); + } + +@@ -2580,6 +2582,49 @@ fn file_picker_in_current_directory(cx: &mut Context) { + cx.push_layer(Box::new(overlaid(picker))); + } + ++fn open_or_focus_explorer(cx: &mut Context) { ++ cx.callback = Some(Box::new( ++ |compositor: &mut Compositor, cx: &mut compositor::Context| { ++ if let Some(editor) = compositor.find::() { ++ match editor.explorer.as_mut() { ++ Some(explore) => explore.focus(), ++ None => match ui::Explorer::new(cx) { ++ Ok(explore) => editor.explorer = Some(explore), ++ Err(err) => cx.editor.set_error(format!("{}", err)), ++ }, ++ } ++ } ++ }, ++ )); ++} ++ ++fn reveal_file_in_explorer(cx: &mut Context, path: Option) { ++ cx.callback = Some(Box::new( ++ |compositor: &mut Compositor, cx: &mut compositor::Context| { ++ if let Some(editor) = compositor.find::() { ++ (|| match editor.explorer.as_mut() { ++ Some(explorer) => match path { ++ Some(path) => explorer.reveal_file(path), ++ None => explorer.reveal_current_file(cx), ++ }, ++ None => { ++ editor.explorer = Some(ui::Explorer::new(cx)?); ++ if let Some(explorer) = editor.explorer.as_mut() { ++ explorer.reveal_current_file(cx)?; ++ } ++ Ok(()) ++ } ++ })() ++ .unwrap_or_else(|err| cx.editor.set_error(err.to_string())) ++ } ++ }, ++ )); ++} ++ ++fn reveal_current_file(cx: &mut Context) { ++ reveal_file_in_explorer(cx, None) ++} ++ + fn buffer_picker(cx: &mut Context) { + let current = view!(cx.editor).doc; + +diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs +index f384c868..43d101fe 100644 +--- a/helix-term/src/keymap/default.rs ++++ b/helix-term/src/keymap/default.rs +@@ -275,6 +275,7 @@ pub fn default() -> HashMap { + "r" => rename_symbol, + "h" => select_references_to_symbol_under_cursor, + "?" => command_palette, ++ "e" => reveal_current_file, + }, + "z" => { "View" + "z" | "c" => align_view_center, +diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs +index 252aff0d..81f8fe22 100644 +--- a/helix-term/src/ui/editor.rs ++++ b/helix-term/src/ui/editor.rs +@@ -6,7 +6,7 @@ + keymap::{KeymapResult, Keymaps}, + ui::{ + document::{render_document, LinePos, TextRenderer, TranslatedPosition}, +- Completion, ProgressSpinners, ++ Completion, Explorer, ProgressSpinners, + }, + }; + +@@ -23,7 +23,7 @@ + }; + use helix_view::{ + document::{Mode, SavePoint, SCRATCH_BUFFER_NAME}, +- editor::{CompleteAction, CursorShapeConfig}, ++ editor::{CompleteAction, CursorShapeConfig, ExplorerPosition}, + graphics::{Color, CursorKind, Modifier, Rect, Style}, + input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, + keyboard::{KeyCode, KeyModifiers}, +@@ -43,6 +43,7 @@ pub struct EditorView { + pub(crate) last_insert: (commands::MappableCommand, Vec), + pub(crate) completion: Option, + spinners: ProgressSpinners, ++ pub(crate) explorer: Option, + } + + #[derive(Debug, Clone)] +@@ -71,6 +72,7 @@ pub fn new(keymaps: Keymaps) -> Self { + last_insert: (commands::MappableCommand::normal_mode, Vec::new()), + completion: None, + spinners: ProgressSpinners::default(), ++ explorer: None, + } + } + +@@ -1224,6 +1226,11 @@ fn handle_event( + event: &Event, + context: &mut crate::compositor::Context, + ) -> EventResult { ++ if let Some(explore) = self.explorer.as_mut() { ++ if let EventResult::Consumed(callback) = explore.handle_event(event, context) { ++ return EventResult::Consumed(callback); ++ } ++ } + let mut cx = commands::Context { + editor: context.editor, + count: None, +@@ -1380,6 +1387,8 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + surface.set_style(area, cx.editor.theme.get("ui.background")); + let config = cx.editor.config(); + ++ let editor_area = area.clip_bottom(1); ++ + // check if bufferline should be rendered + use helix_view::editor::BufferLine; + let use_bufferline = match config.bufferline { +@@ -1388,15 +1397,43 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + _ => false, + }; + +- // -1 for commandline and -1 for bufferline +- let mut editor_area = area.clip_bottom(1); +- if use_bufferline { +- editor_area = editor_area.clip_top(1); +- } ++ let editor_area = if use_bufferline { ++ editor_area.clip_top(1) ++ } else { ++ editor_area ++ }; ++ ++ let editor_area = if let Some(explorer) = &self.explorer { ++ let explorer_column_width = if explorer.is_opened() { ++ explorer.column_width().saturating_add(2) ++ } else { ++ 0 ++ }; ++ // For future developer: ++ // We should have a Dock trait that allows a component to dock to the top/left/bottom/right ++ // of another component. ++ match config.explorer.position { ++ ExplorerPosition::Left => editor_area.clip_left(explorer_column_width), ++ ExplorerPosition::Right => editor_area.clip_right(explorer_column_width), ++ } ++ } else { ++ editor_area ++ }; + + // if the terminal size suddenly changed, we need to trigger a resize + cx.editor.resize(editor_area); + ++ if let Some(explorer) = self.explorer.as_mut() { ++ if !explorer.is_focus() { ++ let area = if use_bufferline { ++ area.clip_top(1) ++ } else { ++ area ++ }; ++ explorer.render(area, surface, cx); ++ } ++ } ++ + if use_bufferline { + Self::render_bufferline(cx.editor, area.with_height(1), surface); + } +@@ -1475,9 +1512,28 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + if let Some(completion) = self.completion.as_mut() { + completion.render(area, surface, cx); + } ++ ++ if let Some(explore) = self.explorer.as_mut() { ++ if explore.is_focus() { ++ let area = if use_bufferline { ++ area.clip_top(1) ++ } else { ++ area ++ }; ++ explore.render(area, surface, cx); ++ } ++ } + } + + fn cursor(&self, _area: Rect, editor: &Editor) -> (Option, CursorKind) { ++ if let Some(explore) = &self.explorer { ++ if explore.is_focus() { ++ let cursor = explore.cursor(_area, editor); ++ if cursor.0.is_some() { ++ return cursor; ++ } ++ } ++ } + match editor.cursor() { + // All block cursors are drawn manually + (pos, CursorKind::Block) => (pos, CursorKind::Hidden), +diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs +new file mode 100644 +index 00000000..6df059b1 +--- /dev/null ++++ b/helix-term/src/ui/explorer.rs +@@ -0,0 +1,751 @@ ++use super::{Prompt, TreeOp, TreeView, TreeViewItem}; ++use crate::{ ++ compositor::{Component, Context, EventResult}, ++ ctrl, key, shift, ui, ++}; ++use anyhow::{bail, ensure, Result}; ++use helix_core::Position; ++use helix_view::{ ++ editor::{Action, ExplorerPosition}, ++ graphics::{CursorKind, Rect}, ++ info::Info, ++ input::{Event, KeyEvent}, ++ theme::Modifier, ++ Editor, ++}; ++use std::cmp::Ordering; ++use std::path::{Path, PathBuf}; ++use std::{borrow::Cow, fs::DirEntry}; ++use tui::{ ++ buffer::Buffer as Surface, ++ widgets::{Block, Borders, Widget}, ++}; ++ ++#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Copy)] ++enum FileType { ++ File, ++ Folder, ++ Root, ++} ++ ++#[derive(PartialEq, Eq, Debug, Clone)] ++struct FileInfo { ++ file_type: FileType, ++ path: PathBuf, ++} ++ ++impl FileInfo { ++ fn root(path: PathBuf) -> Self { ++ Self { ++ file_type: FileType::Root, ++ path, ++ } ++ } ++ ++ fn get_text(&self) -> Cow<'static, str> { ++ let text = match self.file_type { ++ FileType::Root => self.path.display().to_string(), ++ FileType::File | FileType::Folder => self ++ .path ++ .file_name() ++ .map_or("/".into(), |p| p.to_string_lossy().into_owned()), ++ }; ++ ++ #[cfg(test)] ++ let text = text.replace(std::path::MAIN_SEPARATOR, "/"); ++ ++ text.into() ++ } ++} ++ ++impl PartialOrd for FileInfo { ++ fn partial_cmp(&self, other: &Self) -> Option { ++ Some(self.cmp(other)) ++ } ++} ++ ++impl Ord for FileInfo { ++ fn cmp(&self, other: &Self) -> Ordering { ++ use FileType::*; ++ match (self.file_type, other.file_type) { ++ (Root, _) => return Ordering::Less, ++ (_, Root) => return Ordering::Greater, ++ _ => {} ++ }; ++ ++ if let (Some(p1), Some(p2)) = (self.path.parent(), other.path.parent()) { ++ if p1 == p2 { ++ match (self.file_type, other.file_type) { ++ (Folder, File) => return Ordering::Less, ++ (File, Folder) => return Ordering::Greater, ++ _ => {} ++ }; ++ } ++ } ++ self.path.cmp(&other.path) ++ } ++} ++ ++impl TreeViewItem for FileInfo { ++ type Params = State; ++ ++ fn get_children(&self) -> Result> { ++ match self.file_type { ++ FileType::Root | FileType::Folder => {} ++ _ => return Ok(vec![]), ++ }; ++ let ret: Vec<_> = std::fs::read_dir(&self.path)? ++ .filter_map(|entry| entry.ok()) ++ .filter_map(|entry| dir_entry_to_file_info(entry, &self.path)) ++ .collect(); ++ Ok(ret) ++ } ++ ++ fn name(&self) -> String { ++ self.get_text().to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ matches!(self.file_type, FileType::Folder | FileType::Root) ++ } ++} ++ ++fn dir_entry_to_file_info(entry: DirEntry, path: &Path) -> Option { ++ entry.metadata().ok().map(|meta| { ++ let file_type = match meta.is_dir() { ++ true => FileType::Folder, ++ false => FileType::File, ++ }; ++ FileInfo { ++ file_type, ++ path: path.join(entry.file_name()), ++ } ++ }) ++} ++ ++#[derive(Clone, Debug)] ++enum PromptAction { ++ CreateFileOrFolder, ++ RemoveFolder, ++ RemoveFile, ++ RenameFile, ++} ++ ++#[derive(Clone, Debug, Default)] ++struct State { ++ focus: bool, ++ open: bool, ++ current_root: PathBuf, ++ area_width: u16, ++} ++ ++impl State { ++ fn new(focus: bool, current_root: PathBuf) -> Self { ++ Self { ++ focus, ++ current_root, ++ open: true, ++ area_width: 0, ++ } ++ } ++} ++ ++struct ExplorerHistory { ++ tree: TreeView, ++ current_root: PathBuf, ++} ++ ++pub struct Explorer { ++ tree: TreeView, ++ history: Vec, ++ show_help: bool, ++ state: State, ++ prompt: Option<(PromptAction, Prompt)>, ++ #[allow(clippy::type_complexity)] ++ on_next_key: Option EventResult>>, ++ column_width: u16, ++} ++ ++impl Explorer { ++ pub fn new(cx: &mut Context) -> Result { ++ let current_root = std::env::current_dir() ++ .unwrap_or_else(|_| "./".into()) ++ .canonicalize()?; ++ Ok(Self { ++ tree: Self::new_tree_view(current_root.clone())?, ++ history: vec![], ++ show_help: false, ++ state: State::new(true, current_root), ++ prompt: None, ++ on_next_key: None, ++ column_width: cx.editor.config().explorer.column_width as u16, ++ }) ++ } ++ ++ #[cfg(test)] ++ fn from_path(root: PathBuf, column_width: u16) -> Result { ++ Ok(Self { ++ tree: Self::new_tree_view(root.clone())?, ++ history: vec![], ++ show_help: false, ++ state: State::new(true, root), ++ prompt: None, ++ on_next_key: None, ++ column_width, ++ }) ++ } ++ ++ fn new_tree_view(root: PathBuf) -> Result> { ++ let root = FileInfo::root(root); ++ Ok(TreeView::build_tree(root)?.with_enter_fn(Self::toggle_current)) ++ } ++ ++ fn push_history(&mut self, tree_view: TreeView, current_root: PathBuf) { ++ self.history.push(ExplorerHistory { ++ tree: tree_view, ++ current_root, ++ }); ++ const MAX_HISTORY_SIZE: usize = 20; ++ Vec::truncate(&mut self.history, MAX_HISTORY_SIZE) ++ } ++ ++ fn change_root(&mut self, root: PathBuf) -> Result<()> { ++ if self.state.current_root.eq(&root) { ++ return Ok(()); ++ } ++ let tree = Self::new_tree_view(root.clone())?; ++ let old_tree = std::mem::replace(&mut self.tree, tree); ++ self.push_history(old_tree, self.state.current_root.clone()); ++ self.state.current_root = root; ++ Ok(()) ++ } ++ ++ pub fn reveal_file(&mut self, path: PathBuf) -> Result<()> { ++ let current_root = &self.state.current_root.canonicalize()?; ++ let current_path = &path.canonicalize()?; ++ let segments = { ++ let stripped = match current_path.strip_prefix(current_root) { ++ Ok(stripped) => Ok(stripped), ++ Err(_) => { ++ let parent = path.parent().ok_or_else(|| { ++ anyhow::anyhow!("Failed get parent of '{}'", current_path.to_string_lossy()) ++ })?; ++ self.change_root(parent.into())?; ++ current_path ++ .strip_prefix(parent.canonicalize()?) ++ .map_err(|_| { ++ anyhow::anyhow!( ++ "Failed to strip prefix (parent) '{}' from '{}'", ++ parent.to_string_lossy(), ++ current_path.to_string_lossy() ++ ) ++ }) ++ } ++ }?; ++ ++ stripped ++ .components() ++ .map(|c| c.as_os_str().to_string_lossy().to_string()) ++ .collect::>() ++ }; ++ self.tree.reveal_item(segments)?; ++ Ok(()) ++ } ++ ++ pub fn reveal_current_file(&mut self, cx: &mut Context) -> Result<()> { ++ self.focus(); ++ let current_document_path = doc!(cx.editor).path().cloned(); ++ match current_document_path { ++ None => Ok(()), ++ Some(current_path) => self.reveal_file(current_path), ++ } ++ } ++ ++ pub fn focus(&mut self) { ++ self.state.focus = true; ++ self.state.open = true; ++ } ++ ++ fn unfocus(&mut self) { ++ self.state.focus = false; ++ } ++ ++ fn close(&mut self) { ++ self.state.focus = false; ++ self.state.open = false; ++ } ++ ++ pub fn is_focus(&self) -> bool { ++ self.state.focus ++ } ++ ++ fn new_create_file_or_folder_prompt(&mut self, cx: &mut Context) -> Result<()> { ++ let folder_path = self.nearest_folder()?; ++ self.prompt = Some(( ++ PromptAction::CreateFileOrFolder, ++ Prompt::new( ++ format!( ++ " New file or folder (ends with '{}'): ", ++ std::path::MAIN_SEPARATOR ++ ) ++ .into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ) ++ .with_line(format!("{}/", folder_path.to_string_lossy()), cx.editor), ++ )); ++ Ok(()) ++ } ++ ++ fn nearest_folder(&self) -> Result { ++ let current = self.tree.current()?.item(); ++ if current.is_parent() { ++ Ok(current.path.to_path_buf()) ++ } else { ++ let parent_path = current.path.parent().ok_or_else(|| { ++ anyhow::anyhow!(format!( ++ "Unable to get parent path of '{}'", ++ current.path.to_string_lossy() ++ )) ++ })?; ++ Ok(parent_path.to_path_buf()) ++ } ++ } ++ ++ fn new_remove_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current()?.item(); ++ match item.file_type { ++ FileType::Folder => self.new_remove_folder_prompt(), ++ FileType::File => self.new_remove_file_prompt(), ++ FileType::Root => bail!("Root is not removable"), ++ } ++ } ++ ++ fn new_rename_prompt(&mut self, cx: &mut Context) -> Result<()> { ++ let path = self.tree.current_item()?.path.clone(); ++ self.prompt = Some(( ++ PromptAction::RenameFile, ++ Prompt::new( ++ " Rename to ".into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ) ++ .with_line(path.to_string_lossy().to_string(), cx.editor), ++ )); ++ Ok(()) ++ } ++ ++ fn new_remove_file_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ ensure!( ++ item.path.is_file(), ++ "The path '{}' is not a file", ++ item.path.to_string_lossy() ++ ); ++ self.prompt = Some(( ++ PromptAction::RemoveFile, ++ Prompt::new( ++ format!(" Delete file: '{}'? y/N: ", item.path.display()).into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ), ++ )); ++ Ok(()) ++ } ++ ++ fn new_remove_folder_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ ensure!( ++ item.path.is_dir(), ++ "The path '{}' is not a folder", ++ item.path.to_string_lossy() ++ ); ++ ++ self.prompt = Some(( ++ PromptAction::RemoveFolder, ++ Prompt::new( ++ format!(" Delete folder: '{}'? y/N: ", item.path.display()).into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ), ++ )); ++ Ok(()) ++ } ++ ++ fn toggle_current(item: &mut FileInfo, cx: &mut Context, state: &mut State) -> TreeOp { ++ (|| -> Result { ++ if item.path == Path::new("") { ++ return Ok(TreeOp::Noop); ++ } ++ let meta = std::fs::metadata(&item.path)?; ++ if meta.is_file() { ++ cx.editor.open(&item.path, Action::Replace)?; ++ state.focus = false; ++ return Ok(TreeOp::Noop); ++ } ++ ++ if item.path.is_dir() { ++ return Ok(TreeOp::GetChildsAndInsert); ++ } ++ ++ Err(anyhow::anyhow!("Unknown file type: {:?}", meta.file_type())) ++ })() ++ .unwrap_or_else(|err| { ++ cx.editor.set_error(format!("{err}")); ++ TreeOp::Noop ++ }) ++ } ++ ++ fn render_tree( ++ &mut self, ++ area: Rect, ++ prompt_area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ ) { ++ self.tree.render(area, prompt_area, surface, cx); ++ } ++ ++ fn render_embed( ++ &mut self, ++ area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ position: &ExplorerPosition, ++ ) { ++ if !self.state.open { ++ return; ++ } ++ let width = area.width.min(self.column_width + 2); ++ ++ self.state.area_width = area.width; ++ ++ let side_area = match position { ++ ExplorerPosition::Left => Rect { width, ..area }, ++ ExplorerPosition::Right => Rect { ++ x: area.width - width, ++ width, ++ ..area ++ }, ++ } ++ .clip_bottom(1); ++ let background = cx.editor.theme.get("ui.background"); ++ surface.clear_with(side_area, background); ++ ++ let prompt_area = area.clip_top(side_area.height); ++ ++ let list_area = match position { ++ ExplorerPosition::Left => { ++ render_block(side_area.clip_left(1), surface, Borders::RIGHT).clip_bottom(1) ++ } ++ ExplorerPosition::Right => { ++ render_block(side_area.clip_right(1), surface, Borders::LEFT).clip_bottom(1) ++ } ++ }; ++ self.render_tree(list_area, prompt_area, surface, cx); ++ ++ { ++ let statusline = if self.is_focus() { ++ cx.editor.theme.get("ui.statusline") ++ } else { ++ cx.editor.theme.get("ui.statusline.inactive") ++ }; ++ let area = side_area.clip_top(list_area.height); ++ let area = match position { ++ ExplorerPosition::Left => area.clip_right(1), ++ ExplorerPosition::Right => area.clip_left(1), ++ }; ++ surface.clear_with(area, statusline); ++ ++ let title_style = cx.editor.theme.get("ui.text"); ++ let title_style = if self.is_focus() { ++ title_style.add_modifier(Modifier::BOLD) ++ } else { ++ title_style ++ }; ++ surface.set_stringn( ++ area.x, ++ area.y, ++ if self.is_focus() { ++ " EXPLORER: press ? for help" ++ } else { ++ " EXPLORER" ++ }, ++ area.width.into(), ++ title_style, ++ ); ++ } ++ ++ if self.is_focus() && self.show_help { ++ let help_area = match position { ++ ExplorerPosition::Left => area, ++ ExplorerPosition::Right => area.clip_right(list_area.width.saturating_add(2)), ++ }; ++ self.render_help(help_area, surface, cx); ++ } ++ ++ if let Some((_, prompt)) = self.prompt.as_mut() { ++ prompt.render_prompt(prompt_area, surface, cx) ++ } ++ } ++ ++ fn render_help(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { ++ Info::new( ++ "Explorer", ++ &[ ++ ("?", "Toggle help"), ++ ("a", "Add file/folder"), ++ ("r", "Rename file/folder"), ++ ("d", "Delete file"), ++ ("B", "Change root to parent folder"), ++ ("]", "Change root to current folder"), ++ ("[", "Go to previous root"), ++ ("+, =", "Increase size"), ++ ("-, _", "Decrease size"), ++ ("q", "Close"), ++ ] ++ .into_iter() ++ .chain(ui::tree::tree_view_help().into_iter()) ++ .collect::>(), ++ ) ++ .render(area, surface, cx) ++ } ++ ++ fn handle_prompt_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { ++ let result = (|| -> Result { ++ let (action, mut prompt) = match self.prompt.take() { ++ Some((action, p)) => (action, p), ++ _ => return Ok(EventResult::Ignored(None)), ++ }; ++ let line = prompt.line(); ++ ++ let current_item_path = self.tree.current_item()?.path.clone(); ++ match (&action, event) { ++ (PromptAction::CreateFileOrFolder, key!(Enter)) => { ++ if line.ends_with(std::path::MAIN_SEPARATOR) { ++ self.new_folder(line)? ++ } else { ++ self.new_file(line)? ++ } ++ } ++ (PromptAction::RemoveFolder, key) => { ++ if let key!('y') = key { ++ close_documents(current_item_path, cx)?; ++ self.remove_folder()?; ++ } ++ } ++ (PromptAction::RemoveFile, key) => { ++ if let key!('y') = key { ++ close_documents(current_item_path, cx)?; ++ self.remove_file()?; ++ } ++ } ++ (PromptAction::RenameFile, key!(Enter)) => { ++ close_documents(current_item_path, cx)?; ++ self.rename_current(line)?; ++ } ++ (_, key!(Esc) | ctrl!('c')) => {} ++ _ => { ++ prompt.handle_event(&Event::Key(*event), cx); ++ self.prompt = Some((action, prompt)); ++ } ++ } ++ Ok(EventResult::Consumed(None)) ++ })(); ++ match result { ++ Ok(event_result) => event_result, ++ Err(err) => { ++ cx.editor.set_error(err.to_string()); ++ EventResult::Consumed(None) ++ } ++ } ++ } ++ ++ fn new_file(&mut self, path: &str) -> Result<()> { ++ let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); ++ if let Some(parent) = path.parent() { ++ std::fs::create_dir_all(parent)?; ++ } ++ let mut fd = std::fs::OpenOptions::new(); ++ fd.create_new(true).write(true).open(&path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn new_folder(&mut self, path: &str) -> Result<()> { ++ let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); ++ std::fs::create_dir_all(&path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn toggle_help(&mut self) { ++ self.show_help = !self.show_help ++ } ++ ++ fn go_to_previous_root(&mut self) { ++ if let Some(history) = self.history.pop() { ++ self.tree = history.tree; ++ self.state.current_root = history.current_root ++ } ++ } ++ ++ fn change_root_to_current_folder(&mut self) -> Result<()> { ++ self.change_root(self.tree.current_item()?.path.clone()) ++ } ++ ++ fn change_root_parent_folder(&mut self) -> Result<()> { ++ if let Some(parent) = self.state.current_root.parent() { ++ let path = parent.to_path_buf(); ++ self.change_root(path) ++ } else { ++ Ok(()) ++ } ++ } ++ ++ pub fn is_opened(&self) -> bool { ++ self.state.open ++ } ++ ++ pub fn column_width(&self) -> u16 { ++ self.column_width ++ } ++ ++ fn increase_size(&mut self) { ++ const EDITOR_MIN_WIDTH: u16 = 10; ++ self.column_width = std::cmp::min( ++ self.state.area_width.saturating_sub(EDITOR_MIN_WIDTH), ++ self.column_width.saturating_add(1), ++ ) ++ } ++ ++ fn decrease_size(&mut self) { ++ self.column_width = self.column_width.saturating_sub(1) ++ } ++ ++ fn rename_current(&mut self, line: &String) -> Result<()> { ++ let item = self.tree.current_item()?; ++ let path = PathBuf::from(line); ++ if let Some(parent) = path.parent() { ++ std::fs::create_dir_all(parent)?; ++ } ++ std::fs::rename(&item.path, &path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn remove_folder(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ std::fs::remove_dir_all(&item.path)?; ++ self.tree.refresh() ++ } ++ ++ fn remove_file(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ std::fs::remove_file(&item.path)?; ++ self.tree.refresh() ++ } ++} ++ ++fn close_documents(current_item_path: PathBuf, cx: &mut Context) -> Result<()> { ++ let ids = cx ++ .editor ++ .documents ++ .iter() ++ .filter_map(|(id, doc)| { ++ if doc.path()?.starts_with(¤t_item_path) { ++ Some(*id) ++ } else { ++ None ++ } ++ }) ++ .collect::>(); ++ ++ for id in ids { ++ cx.editor.close_document(id, true)?; ++ } ++ Ok(()) ++} ++ ++impl Component for Explorer { ++ /// Process input events, return true if handled. ++ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { ++ if self.tree.prompting() { ++ return self.tree.handle_event(event, cx, &mut self.state); ++ } ++ let key_event = match event { ++ Event::Key(event) => event, ++ Event::Resize(..) => return EventResult::Consumed(None), ++ _ => return EventResult::Ignored(None), ++ }; ++ if !self.is_focus() { ++ return EventResult::Ignored(None); ++ } ++ if let Some(mut on_next_key) = self.on_next_key.take() { ++ return on_next_key(cx, self, key_event); ++ } ++ ++ if let EventResult::Consumed(c) = self.handle_prompt_event(key_event, cx) { ++ return EventResult::Consumed(c); ++ } ++ ++ (|| -> Result<()> { ++ match key_event { ++ key!(Esc) => self.unfocus(), ++ key!('q') => self.close(), ++ key!('?') => self.toggle_help(), ++ key!('a') => self.new_create_file_or_folder_prompt(cx)?, ++ shift!('B') => self.change_root_parent_folder()?, ++ key!(']') => self.change_root_to_current_folder()?, ++ key!('[') => self.go_to_previous_root(), ++ key!('d') => self.new_remove_prompt()?, ++ key!('r') => self.new_rename_prompt(cx)?, ++ key!('-') | key!('_') => self.decrease_size(), ++ key!('+') | key!('=') => self.increase_size(), ++ _ => { ++ self.tree ++ .handle_event(&Event::Key(*key_event), cx, &mut self.state); ++ } ++ }; ++ Ok(()) ++ })() ++ .unwrap_or_else(|err| cx.editor.set_error(format!("{err}"))); ++ ++ EventResult::Consumed(None) ++ } ++ ++ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { ++ if area.width < 10 || area.height < 5 { ++ cx.editor.set_error("explorer render area is too small"); ++ return; ++ } ++ let config = &cx.editor.config().explorer; ++ let position = config.position; ++ self.render_embed(area, surface, cx, &position); ++ } ++ ++ fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { ++ if let Some(prompt) = self ++ .prompt ++ .as_ref() ++ .map(|(_, prompt)| prompt) ++ .or_else(|| self.tree.prompt()) ++ { ++ let (x, y) = (area.x, area.y + area.height.saturating_sub(1)); ++ prompt.cursor(Rect::new(x, y, area.width, 1), editor) ++ } else { ++ (None, CursorKind::Hidden) ++ } ++ } ++} ++ ++fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect { ++ let block = Block::default().borders(borders); ++ let inner = block.inner(area); ++ block.render(area, surface); ++ inner ++} +diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs +index 3359155d..eb6464f9 100644 +--- a/helix-term/src/ui/mod.rs ++++ b/helix-term/src/ui/mod.rs +@@ -1,6 +1,7 @@ + mod completion; + mod document; + pub(crate) mod editor; ++mod explorer; + mod fuzzy_match; + mod info; + pub mod lsp; +@@ -13,12 +14,14 @@ + mod spinner; + mod statusline; + mod text; ++mod tree; + + use crate::compositor::{Component, Compositor}; + use crate::filter_picker_entry; + use crate::job::{self, Callback}; + pub use completion::{Completion, CompletionItem}; + pub use editor::EditorView; ++pub use explorer::Explorer; + pub use markdown::Markdown; + pub use menu::Menu; + pub use picker::{DynamicPicker, FileLocation, Picker}; +@@ -26,6 +29,7 @@ + pub use prompt::{Prompt, PromptEvent}; + pub use spinner::{ProgressSpinners, Spinner}; + pub use text::Text; ++pub use tree::{TreeOp, TreeView, TreeViewItem}; + + use helix_core::regex::Regex; + use helix_core::regex::RegexBuilder; +diff --git a/helix-term/src/ui/overlay.rs b/helix-term/src/ui/overlay.rs +index ff184d40..7b9c0391 100644 +--- a/helix-term/src/ui/overlay.rs ++++ b/helix-term/src/ui/overlay.rs +@@ -19,26 +19,7 @@ pub struct Overlay { + pub fn overlaid(content: T) -> Overlay { + Overlay { + content, +- calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)), +- } +-} +- +-fn clip_rect_relative(rect: Rect, percent_horizontal: u8, percent_vertical: u8) -> Rect { +- fn mul_and_cast(size: u16, factor: u8) -> u16 { +- ((size as u32) * (factor as u32) / 100).try_into().unwrap() +- } +- +- let inner_w = mul_and_cast(rect.width, percent_horizontal); +- let inner_h = mul_and_cast(rect.height, percent_vertical); +- +- let offset_x = rect.width.saturating_sub(inner_w) / 2; +- let offset_y = rect.height.saturating_sub(inner_h) / 2; +- +- Rect { +- x: rect.x + offset_x, +- y: rect.y + offset_y, +- width: inner_w, +- height: inner_h, ++ calc_child_size: Box::new(|rect: Rect| rect.overlaid()), + } + } + +diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs +index a9ccfb73..e033112d 100644 +--- a/helix-term/src/ui/prompt.rs ++++ b/helix-term/src/ui/prompt.rs +@@ -94,6 +94,10 @@ pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self + } + ++ pub fn prompt(&self) -> &str { ++ &self.prompt.as_ref() ++ } ++ + pub fn line(&self) -> &String { + &self.line + } +diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs +new file mode 100644 +index 00000000..68783be8 +--- /dev/null ++++ b/helix-term/src/ui/tree.rs +@@ -0,0 +1,1209 @@ ++use std::cmp::Ordering; ++ ++use anyhow::Result; ++use helix_view::theme::Modifier; ++ ++use crate::{ ++ compositor::{Component, Context, EventResult}, ++ ctrl, key, shift, ui, ++}; ++use helix_core::movement::Direction; ++use helix_view::{ ++ graphics::Rect, ++ input::{Event, KeyEvent}, ++}; ++use tui::buffer::Buffer as Surface; ++ ++use super::Prompt; ++ ++pub trait TreeViewItem: Sized + Ord { ++ type Params: Default; ++ ++ fn name(&self) -> String; ++ fn is_parent(&self) -> bool; ++ ++ fn filter(&self, s: &str) -> bool { ++ self.name().to_lowercase().contains(&s.to_lowercase()) ++ } ++ ++ fn get_children(&self) -> Result>; ++} ++ ++fn tree_item_cmp(item1: &T, item2: &T) -> Ordering { ++ T::cmp(item1, item2) ++} ++ ++fn vec_to_tree(mut items: Vec) -> Vec> { ++ items.sort(); ++ index_elems( ++ 0, ++ items ++ .into_iter() ++ .map(|item| Tree::new(item, vec![])) ++ .collect(), ++ ) ++} ++ ++pub enum TreeOp { ++ Noop, ++ GetChildsAndInsert, ++} ++ ++#[derive(Debug, PartialEq, Eq)] ++pub struct Tree { ++ item: T, ++ parent_index: Option, ++ index: usize, ++ children: Vec, ++ ++ /// Why do we need this property? ++ /// Can't we just use `!children.is_empty()`? ++ /// ++ /// Because we might have for example an open folder that is empty, ++ /// and user just added a new file under that folder, ++ /// and the user refreshes the whole tree. ++ /// ++ /// Without `open`, we will not refresh any node without children, ++ /// and thus the folder still appears empty after refreshing. ++ is_opened: bool, ++} ++ ++impl Clone for Tree { ++ fn clone(&self) -> Self { ++ Self { ++ item: self.item.clone(), ++ index: self.index, ++ children: self.children.clone(), ++ is_opened: self.is_opened, ++ parent_index: self.parent_index, ++ } ++ } ++} ++ ++#[derive(Clone)] ++struct TreeIter<'a, T> { ++ current_index_forward: usize, ++ current_index_reverse: isize, ++ tree: &'a Tree, ++} ++ ++impl<'a, T> Iterator for TreeIter<'a, T> { ++ type Item = &'a Tree; ++ ++ fn next(&mut self) -> Option { ++ let index = self.current_index_forward; ++ if index > self.tree.len().saturating_sub(1) { ++ None ++ } else { ++ self.current_index_forward = self.current_index_forward.saturating_add(1); ++ self.tree.get(index) ++ } ++ } ++ ++ fn size_hint(&self) -> (usize, Option) { ++ (self.tree.len(), Some(self.tree.len())) ++ } ++} ++ ++impl<'a, T> DoubleEndedIterator for TreeIter<'a, T> { ++ fn next_back(&mut self) -> Option { ++ let index = self.current_index_reverse; ++ if index < 0 { ++ None ++ } else { ++ self.current_index_reverse = self.current_index_reverse.saturating_sub(1); ++ self.tree.get(index as usize) ++ } ++ } ++} ++ ++impl<'a, T> ExactSizeIterator for TreeIter<'a, T> {} ++ ++impl Tree { ++ fn open(&mut self) -> Result<()> { ++ if self.item.is_parent() { ++ self.children = self.get_children()?; ++ self.is_opened = true; ++ } ++ Ok(()) ++ } ++ ++ fn close(&mut self) { ++ self.is_opened = false; ++ self.children = vec![]; ++ } ++ ++ fn refresh(&mut self) -> Result<()> { ++ if !self.is_opened { ++ return Ok(()); ++ } ++ let latest_children = self.get_children()?; ++ let filtered = std::mem::take(&mut self.children) ++ .into_iter() ++ // Remove children that does not exists in latest_children ++ .filter(|tree| { ++ latest_children ++ .iter() ++ .any(|child| tree.item.name().eq(&child.item.name())) ++ }) ++ .map(|mut tree| { ++ tree.refresh()?; ++ Ok(tree) ++ }) ++ .collect::>>()?; ++ ++ // Add new children ++ let new_nodes = latest_children ++ .into_iter() ++ .filter(|child| { ++ !filtered ++ .iter() ++ .any(|child_| child.item.name().eq(&child_.item.name())) ++ }) ++ .collect::>(); ++ ++ self.children = filtered.into_iter().chain(new_nodes).collect(); ++ ++ self.sort(); ++ ++ self.regenerate_index(); ++ ++ Ok(()) ++ } ++ ++ fn get_children(&self) -> Result>> { ++ Ok(vec_to_tree(self.item.get_children()?)) ++ } ++ ++ fn sort(&mut self) { ++ self.children ++ .sort_by(|a, b| tree_item_cmp(&a.item, &b.item)) ++ } ++} ++ ++impl Tree { ++ pub fn new(item: T, children: Vec>) -> Self { ++ let is_opened = !children.is_empty(); ++ Self { ++ item, ++ index: 0, ++ parent_index: None, ++ children: index_elems(0, children), ++ is_opened, ++ } ++ } ++ ++ fn iter(&self) -> TreeIter { ++ TreeIter { ++ tree: self, ++ current_index_forward: 0, ++ current_index_reverse: (self.len() - 1) as isize, ++ } ++ } ++ ++ /// Find an element in the tree with given `predicate`. ++ /// `start_index` is inclusive if direction is `Forward`. ++ /// `start_index` is exclusive if direction is `Backward`. ++ fn find(&self, start_index: usize, direction: Direction, predicate: F) -> Option ++ where ++ F: Clone + FnMut(&Tree) -> bool, ++ { ++ match direction { ++ Direction::Forward => match self ++ .iter() ++ .skip(start_index) ++ .position(predicate.clone()) ++ .map(|index| index + start_index) ++ { ++ Some(index) => Some(index), ++ None => self.iter().position(predicate), ++ }, ++ ++ Direction::Backward => match self.iter().take(start_index).rposition(predicate.clone()) ++ { ++ Some(index) => Some(index), ++ None => self.iter().rposition(predicate), ++ }, ++ } ++ } ++ ++ pub fn item(&self) -> &T { ++ &self.item ++ } ++ ++ fn get(&self, index: usize) -> Option<&Tree> { ++ if self.index == index { ++ Some(self) ++ } else { ++ self.children.iter().find_map(|elem| elem.get(index)) ++ } ++ } ++ ++ fn get_mut(&mut self, index: usize) -> Option<&mut Tree> { ++ if self.index == index { ++ Some(self) ++ } else { ++ self.children ++ .iter_mut() ++ .find_map(|elem| elem.get_mut(index)) ++ } ++ } ++ ++ fn len(&self) -> usize { ++ (1_usize).saturating_add(self.children.iter().map(|elem| elem.len()).sum()) ++ } ++ ++ fn regenerate_index(&mut self) { ++ let items = std::mem::take(&mut self.children); ++ self.children = index_elems(0, items); ++ } ++} ++ ++#[derive(Clone, Debug)] ++struct SavedView { ++ selected: usize, ++ winline: usize, ++} ++ ++pub struct TreeView { ++ tree: Tree, ++ ++ search_prompt: Option<(Direction, Prompt)>, ++ ++ search_str: String, ++ ++ /// Selected item idex ++ selected: usize, ++ ++ backward_jumps: Vec, ++ forward_jumps: Vec, ++ ++ saved_view: Option, ++ ++ /// For implementing vertical scroll ++ winline: usize, ++ ++ /// For implementing horizontal scoll ++ column: usize, ++ ++ /// For implementing horizontal scoll ++ max_len: usize, ++ count: usize, ++ tree_symbol_style: String, ++ ++ #[allow(clippy::type_complexity)] ++ pre_render: Option>, ++ ++ #[allow(clippy::type_complexity)] ++ on_opened_fn: Option TreeOp + 'static>>, ++ ++ #[allow(clippy::type_complexity)] ++ on_folded_fn: Option>, ++ ++ #[allow(clippy::type_complexity)] ++ on_next_key: Option Result<()>>>, ++} ++ ++impl TreeView { ++ pub fn build_tree(root: T) -> Result { ++ let children = root.get_children()?; ++ let items = vec_to_tree(children); ++ Ok(Self { ++ tree: Tree::new(root, items), ++ selected: 0, ++ backward_jumps: vec![], ++ forward_jumps: vec![], ++ saved_view: None, ++ winline: 0, ++ column: 0, ++ max_len: 0, ++ count: 0, ++ tree_symbol_style: "ui.text".into(), ++ pre_render: None, ++ on_opened_fn: None, ++ on_folded_fn: None, ++ on_next_key: None, ++ search_prompt: None, ++ search_str: "".into(), ++ }) ++ } ++ ++ pub fn with_enter_fn(mut self, f: F) -> Self ++ where ++ F: FnMut(&mut T, &mut Context, &mut T::Params) -> TreeOp + 'static, ++ { ++ self.on_opened_fn = Some(Box::new(f)); ++ self ++ } ++ ++ pub fn with_folded_fn(mut self, f: F) -> Self ++ where ++ F: FnMut(&mut T, &mut Context, &mut T::Params) + 'static, ++ { ++ self.on_folded_fn = Some(Box::new(f)); ++ self ++ } ++ ++ pub fn tree_symbol_style(mut self, style: String) -> Self { ++ self.tree_symbol_style = style; ++ self ++ } ++ ++ /// Reveal item in the tree based on the given `segments`. ++ /// ++ /// The name of the root should be excluded. ++ /// ++ /// Example `segments`: ++ /// ++ /// vec!["helix-term", "src", "ui", "tree.rs"] ++ /// ++ pub fn reveal_item(&mut self, segments: Vec) -> Result<()> { ++ // Expand the tree ++ let root = self.tree.item.name(); ++ segments.iter().fold( ++ Ok(&mut self.tree), ++ |current_tree, segment| match current_tree { ++ Err(err) => Err(err), ++ Ok(current_tree) => { ++ match current_tree ++ .children ++ .iter_mut() ++ .find(|tree| tree.item.name().eq(segment)) ++ { ++ Some(tree) => { ++ if !tree.is_opened { ++ tree.open()?; ++ } ++ Ok(tree) ++ } ++ None => Err(anyhow::anyhow!(format!( ++ "Unable to find path: '{}'. current_segment = '{segment}'. current_root = '{root}'", ++ segments.join("/"), ++ ))), ++ } ++ } ++ }, ++ )?; ++ ++ // Locate the item ++ self.regenerate_index(); ++ self.set_selected( ++ segments ++ .iter() ++ .fold(&self.tree, |tree, segment| { ++ tree.children ++ .iter() ++ .find(|tree| tree.item.name().eq(segment)) ++ .expect("Should be unreachable") ++ }) ++ .index, ++ ); ++ ++ self.align_view_center(); ++ Ok(()) ++ } ++ ++ fn align_view_center(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.winline = area.height as usize / 2 ++ })) ++ } ++ ++ fn align_view_top(&mut self) { ++ self.winline = 0 ++ } ++ ++ fn align_view_bottom(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| tree.winline = area.height as usize)) ++ } ++ ++ fn regenerate_index(&mut self) { ++ self.tree.regenerate_index(); ++ } ++ ++ fn move_to_parent(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ let index = parent.index; ++ self.set_selected(index) ++ } ++ Ok(()) ++ } ++ ++ fn move_to_children(&mut self) -> Result<()> { ++ let current = self.current_mut()?; ++ if current.is_opened { ++ self.set_selected(self.selected + 1); ++ Ok(()) ++ } else { ++ current.open()?; ++ if !current.children.is_empty() { ++ self.set_selected(self.selected + 1); ++ self.regenerate_index(); ++ } ++ Ok(()) ++ } ++ } ++ ++ pub fn refresh(&mut self) -> Result<()> { ++ self.tree.refresh()?; ++ self.set_selected(self.selected); ++ Ok(()) ++ } ++ ++ fn move_to_first_line(&mut self) { ++ self.move_up(usize::MAX / 2) ++ } ++ ++ fn move_to_last_line(&mut self) { ++ self.move_down(usize::MAX / 2) ++ } ++ ++ fn move_leftmost(&mut self) { ++ self.move_left(usize::MAX / 2); ++ } ++ ++ fn move_rightmost(&mut self) { ++ self.move_right(usize::MAX / 2) ++ } ++ ++ fn restore_saved_view(&mut self) -> Result<()> { ++ if let Some(saved_view) = self.saved_view.take() { ++ self.selected = saved_view.selected; ++ self.winline = saved_view.winline; ++ self.refresh() ++ } else { ++ Ok(()) ++ } ++ } ++ ++ pub fn prompt(&self) -> Option<&Prompt> { ++ if let Some((_, prompt)) = self.search_prompt.as_ref() { ++ Some(prompt) ++ } else { ++ None ++ } ++ } ++} ++ ++pub fn tree_view_help() -> Vec<(&'static str, &'static str)> { ++ vec![ ++ ("o, Enter", "Open/Close"), ++ ("j, down, C-n", "Down"), ++ ("k, up, C-p", "Up"), ++ ("h, left", "Go to parent"), ++ ("l, right", "Expand"), ++ ("J", "Go to next sibling"), ++ ("K", "Go to previous sibling"), ++ ("H", "Go to first child"), ++ ("L", "Go to last child"), ++ ("R", "Refresh"), ++ ("/", "Search"), ++ ("n", "Go to next search match"), ++ ("N", "Go to previous search match"), ++ ("gh, Home", "Scroll to the leftmost"), ++ ("gl, End", "Scroll to the rightmost"), ++ ("C-o", "Jump backward"), ++ ("C-i, Tab", "Jump forward"), ++ ("C-d", "Half page down"), ++ ("C-u", "Half page up"), ++ ("PageDown", "Full page down"), ++ ("PageUp", "Full page up"), ++ ("zt", "Align view top"), ++ ("zz", "Align view center"), ++ ("zb", "Align view bottom"), ++ ("gg", "Go to first line"), ++ ("ge", "Go to last line"), ++ ] ++} ++ ++impl TreeView { ++ pub fn on_enter( ++ &mut self, ++ cx: &mut Context, ++ params: &mut T::Params, ++ selected_index: usize, ++ ) -> Result<()> { ++ let selected_item = self.get_mut(selected_index)?; ++ if selected_item.is_opened { ++ selected_item.close(); ++ self.regenerate_index(); ++ return Ok(()); ++ } ++ ++ if let Some(mut on_open_fn) = self.on_opened_fn.take() { ++ let mut f = || -> Result<()> { ++ let current = self.current_mut()?; ++ match on_open_fn(&mut current.item, cx, params) { ++ TreeOp::GetChildsAndInsert => { ++ if let Err(err) = current.open() { ++ cx.editor.set_error(format!("{err}")) ++ } ++ } ++ TreeOp::Noop => {} ++ }; ++ Ok(()) ++ }; ++ f()?; ++ self.regenerate_index(); ++ self.on_opened_fn = Some(on_open_fn); ++ }; ++ Ok(()) ++ } ++ ++ fn set_search_str(&mut self, s: String) { ++ self.search_str = s; ++ self.saved_view = None; ++ } ++ ++ fn saved_view(&self) -> SavedView { ++ self.saved_view.clone().unwrap_or(SavedView { ++ selected: self.selected, ++ winline: self.winline, ++ }) ++ } ++ ++ fn search_next(&mut self, s: &str) { ++ let saved_view = self.saved_view(); ++ let skip = std::cmp::max(2, saved_view.selected + 1); ++ self.set_selected( ++ self.tree ++ .find(skip, Direction::Forward, |e| e.item.filter(s)) ++ .unwrap_or(saved_view.selected), ++ ); ++ } ++ ++ fn search_previous(&mut self, s: &str) { ++ let saved_view = self.saved_view(); ++ let take = saved_view.selected; ++ self.set_selected( ++ self.tree ++ .find(take, Direction::Backward, |e| e.item.filter(s)) ++ .unwrap_or(saved_view.selected), ++ ); ++ } ++ ++ fn move_to_next_search_match(&mut self) { ++ self.search_next(&self.search_str.clone()) ++ } ++ ++ fn move_to_previous_next_match(&mut self) { ++ self.search_previous(&self.search_str.clone()) ++ } ++ ++ pub fn move_down(&mut self, rows: usize) { ++ self.set_selected(self.selected.saturating_add(rows)) ++ } ++ ++ fn set_selected(&mut self, selected: usize) { ++ let previous_selected = self.selected; ++ self.set_selected_without_history(selected); ++ if previous_selected.abs_diff(selected) > 1 { ++ self.backward_jumps.push(previous_selected) ++ } ++ } ++ ++ fn set_selected_without_history(&mut self, selected: usize) { ++ let selected = selected.clamp(0, self.tree.len().saturating_sub(1)); ++ if selected > self.selected { ++ // Move down ++ self.winline = selected.min( ++ self.winline ++ .saturating_add(selected.saturating_sub(self.selected)), ++ ); ++ } else { ++ // Move up ++ self.winline = selected.min( ++ self.winline ++ .saturating_sub(self.selected.saturating_sub(selected)), ++ ); ++ } ++ self.selected = selected ++ } ++ ++ fn jump_backward(&mut self) { ++ if let Some(index) = self.backward_jumps.pop() { ++ self.forward_jumps.push(self.selected); ++ self.set_selected_without_history(index); ++ } ++ } ++ ++ fn jump_forward(&mut self) { ++ if let Some(index) = self.forward_jumps.pop() { ++ self.set_selected(index) ++ } ++ } ++ ++ pub fn move_up(&mut self, rows: usize) { ++ self.set_selected(self.selected.saturating_sub(rows)) ++ } ++ ++ fn move_to_next_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(local_index) = parent ++ .children ++ .iter() ++ .position(|child| child.index == self.selected) ++ { ++ if let Some(next_sibling) = parent.children.get(local_index.saturating_add(1)) { ++ self.set_selected(next_sibling.index) ++ } ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_previous_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(local_index) = parent ++ .children ++ .iter() ++ .position(|child| child.index == self.selected) ++ { ++ if let Some(next_sibling) = parent.children.get(local_index.saturating_sub(1)) { ++ self.set_selected(next_sibling.index) ++ } ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_last_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(last) = parent.children.last() { ++ self.set_selected(last.index) ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_first_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(last) = parent.children.first() { ++ self.set_selected(last.index) ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_left(&mut self, cols: usize) { ++ self.column = self.column.saturating_sub(cols); ++ } ++ ++ fn move_right(&mut self, cols: usize) { ++ self.pre_render = Some(Box::new(move |tree, area| { ++ let max_scroll = tree ++ .max_len ++ .saturating_sub(area.width as usize) ++ .saturating_add(1); ++ tree.column = max_scroll.min(tree.column + cols); ++ })); ++ } ++ ++ fn move_down_half_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_down((area.height / 2) as usize); ++ })); ++ } ++ ++ fn move_up_half_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_up((area.height / 2) as usize); ++ })); ++ } ++ ++ fn move_down_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_down((area.height) as usize); ++ })); ++ } ++ ++ fn move_up_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_up((area.height) as usize); ++ })); ++ } ++ ++ fn save_view(&mut self) { ++ self.saved_view = Some(SavedView { ++ selected: self.selected, ++ winline: self.winline, ++ }) ++ } ++ ++ fn get(&self, index: usize) -> Result<&Tree> { ++ self.tree.get(index).ok_or_else(|| { ++ anyhow::anyhow!("Programming error: TreeView.get: index {index} is out of bound") ++ }) ++ } ++ ++ fn get_mut(&mut self, index: usize) -> Result<&mut Tree> { ++ self.tree.get_mut(index).ok_or_else(|| { ++ anyhow::anyhow!("Programming error: TreeView.get_mut: index {index} is out of bound") ++ }) ++ } ++ ++ pub fn current(&self) -> Result<&Tree> { ++ self.get(self.selected) ++ } ++ ++ pub fn current_mut(&mut self) -> Result<&mut Tree> { ++ self.get_mut(self.selected) ++ } ++ ++ fn current_parent(&self) -> Result>> { ++ if let Some(parent_index) = self.current()?.parent_index { ++ Ok(Some(self.get(parent_index)?)) ++ } else { ++ Ok(None) ++ } ++ } ++ ++ pub fn current_item(&self) -> Result<&T> { ++ Ok(&self.current()?.item) ++ } ++ ++ pub fn winline(&self) -> usize { ++ self.winline ++ } ++} ++ ++#[derive(Clone)] ++struct RenderedLine { ++ indent: String, ++ content: String, ++ selected: bool, ++ is_ancestor_of_current_item: bool, ++} ++struct RenderTreeParams<'a, T> { ++ tree: &'a Tree, ++ prefix: &'a String, ++ level: usize, ++ selected: usize, ++} ++ ++fn render_tree( ++ RenderTreeParams { ++ tree, ++ prefix, ++ level, ++ selected, ++ }: RenderTreeParams, ++) -> Vec { ++ let indent = if level > 0 { ++ let indicator = if tree.item().is_parent() { ++ if tree.is_opened { ++ "⏷" ++ } else { ++ "⏵" ++ } ++ } else { ++ " " ++ }; ++ format!("{}{} ", prefix, indicator) ++ } else { ++ "".to_string() ++ }; ++ let name = tree.item.name(); ++ let head = RenderedLine { ++ indent, ++ selected: selected == tree.index, ++ is_ancestor_of_current_item: selected != tree.index && tree.get(selected).is_some(), ++ content: name, ++ }; ++ let prefix = format!("{}{}", prefix, if level == 0 { "" } else { " " }); ++ vec![head] ++ .into_iter() ++ .chain(tree.children.iter().flat_map(|elem| { ++ render_tree(RenderTreeParams { ++ tree: elem, ++ prefix: &prefix, ++ level: level + 1, ++ selected, ++ }) ++ })) ++ .collect() ++} ++ ++impl TreeView { ++ pub fn render( ++ &mut self, ++ area: Rect, ++ prompt_area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ ) { ++ let style = cx.editor.theme.get(&self.tree_symbol_style); ++ if let Some((_, prompt)) = self.search_prompt.as_mut() { ++ prompt.render_prompt(prompt_area, surface, cx) ++ } ++ ++ let ancestor_style = { ++ let style = cx.editor.theme.get("ui.selection"); ++ let fg = cx.editor.theme.get("ui.text").fg; ++ match (style.fg, fg) { ++ (None, Some(fg)) => style.fg(fg), ++ _ => style, ++ } ++ }; ++ ++ let iter = self.render_lines(area).into_iter().enumerate(); ++ ++ for (index, line) in iter { ++ let area = Rect::new(area.x, area.y.saturating_add(index as u16), area.width, 1); ++ let indent_len = line.indent.chars().count() as u16; ++ surface.set_stringn( ++ area.x, ++ area.y, ++ line.indent.clone(), ++ indent_len as usize, ++ style, ++ ); ++ ++ let style = if line.selected { ++ style.add_modifier(Modifier::REVERSED) ++ } else { ++ style ++ }; ++ let x = area.x.saturating_add(indent_len); ++ surface.set_stringn( ++ x, ++ area.y, ++ line.content.clone(), ++ area.width ++ .saturating_sub(indent_len) ++ .saturating_sub(1) ++ .into(), ++ if line.is_ancestor_of_current_item { ++ ancestor_style ++ } else { ++ style ++ }, ++ ); ++ } ++ } ++ ++ #[cfg(test)] ++ pub fn render_to_string(&mut self, area: Rect) -> String { ++ let lines = self.render_lines(area); ++ lines ++ .into_iter() ++ .map(|line| { ++ let name = if line.selected { ++ format!("({})", line.content) ++ } else if line.is_ancestor_of_current_item { ++ format!("[{}]", line.content) ++ } else { ++ line.content ++ }; ++ format!("{}{}", line.indent, name) ++ }) ++ .collect::>() ++ .join("\n") ++ } ++ ++ fn render_lines(&mut self, area: Rect) -> Vec { ++ if let Some(pre_render) = self.pre_render.take() { ++ pre_render(self, area); ++ } ++ ++ self.winline = self.winline.min(area.height.saturating_sub(1) as usize); ++ let skip = self.selected.saturating_sub(self.winline); ++ let params = RenderTreeParams { ++ tree: &self.tree, ++ prefix: &"".to_string(), ++ level: 0, ++ selected: self.selected, ++ }; ++ ++ let lines = render_tree(params); ++ ++ self.max_len = lines ++ .iter() ++ .map(|line| { ++ line.indent ++ .chars() ++ .count() ++ .saturating_add(line.content.chars().count()) ++ }) ++ .max() ++ .unwrap_or(0); ++ ++ let max_width = area.width as usize; ++ ++ let take = area.height as usize; ++ ++ struct RetainAncestorResult { ++ skipped_ancestors: Vec, ++ remaining_lines: Vec, ++ } ++ fn retain_ancestors(lines: Vec, skip: usize) -> RetainAncestorResult { ++ if skip == 0 { ++ return RetainAncestorResult { ++ skipped_ancestors: vec![], ++ remaining_lines: lines, ++ }; ++ } ++ if let Some(line) = lines.get(0) { ++ if line.selected { ++ return RetainAncestorResult { ++ skipped_ancestors: vec![], ++ remaining_lines: lines, ++ }; ++ } ++ } ++ ++ let selected_index = lines.iter().position(|line| line.selected); ++ let skip = match selected_index { ++ None => skip, ++ Some(selected_index) => skip.min(selected_index), ++ }; ++ let (skipped, remaining) = lines.split_at(skip.min(lines.len().saturating_sub(1))); ++ ++ let skipped_ancestors = skipped ++ .iter() ++ .cloned() ++ .filter(|line| line.is_ancestor_of_current_item) ++ .collect::>(); ++ ++ let result = retain_ancestors(remaining.to_vec(), skipped_ancestors.len()); ++ RetainAncestorResult { ++ skipped_ancestors: skipped_ancestors ++ .into_iter() ++ .chain(result.skipped_ancestors.into_iter()) ++ .collect(), ++ remaining_lines: result.remaining_lines, ++ } ++ } ++ ++ let RetainAncestorResult { ++ skipped_ancestors, ++ remaining_lines, ++ } = retain_ancestors(lines, skip); ++ ++ let max_ancestors_len = take.saturating_sub(1); ++ ++ // Skip furthest ancestors ++ let skipped_ancestors = skipped_ancestors ++ .into_iter() ++ .rev() ++ .take(max_ancestors_len) ++ .rev() ++ .collect::>(); ++ ++ let skipped_ancestors_len = skipped_ancestors.len(); ++ ++ skipped_ancestors ++ .into_iter() ++ .chain( ++ remaining_lines ++ .into_iter() ++ .take(take.saturating_sub(skipped_ancestors_len)), ++ ) ++ // Horizontal scroll ++ .map(|line| { ++ let skip = self.column; ++ let indent_len = line.indent.chars().count(); ++ RenderedLine { ++ indent: if line.indent.is_empty() { ++ "".to_string() ++ } else { ++ line.indent ++ .chars() ++ .skip(skip) ++ .take(max_width) ++ .collect::() ++ }, ++ content: line ++ .content ++ .chars() ++ .skip(skip.saturating_sub(indent_len)) ++ .take((max_width.saturating_sub(indent_len)).clamp(0, line.content.len())) ++ .collect::(), ++ ..line ++ } ++ }) ++ .collect() ++ } ++ ++ #[cfg(test)] ++ pub fn handle_events( ++ &mut self, ++ events: &str, ++ cx: &mut Context, ++ params: &mut T::Params, ++ ) -> Result<()> { ++ use helix_view::input::parse_macro; ++ ++ for event in parse_macro(events)? { ++ self.handle_event(&Event::Key(event), cx, params); ++ } ++ Ok(()) ++ } ++ ++ pub fn handle_event( ++ &mut self, ++ event: &Event, ++ cx: &mut Context, ++ params: &mut T::Params, ++ ) -> EventResult { ++ let key_event = match event { ++ Event::Key(event) => event, ++ Event::Resize(..) => return EventResult::Consumed(None), ++ _ => return EventResult::Ignored(None), ++ }; ++ (|| -> Result { ++ if let Some(mut on_next_key) = self.on_next_key.take() { ++ on_next_key(cx, self, key_event)?; ++ return Ok(EventResult::Consumed(None)); ++ } ++ ++ if let EventResult::Consumed(c) = self.handle_search_event(key_event, cx) { ++ return Ok(EventResult::Consumed(c)); ++ } ++ ++ let count = std::mem::replace(&mut self.count, 0); ++ ++ match key_event { ++ key!(i @ '0'..='9') => { ++ self.count = i.to_digit(10).unwrap_or(0) as usize + count * 10 ++ } ++ shift!('J') => self.move_to_next_sibling()?, ++ shift!('K') => self.move_to_previous_sibling()?, ++ shift!('H') => self.move_to_first_sibling()?, ++ shift!('L') => self.move_to_last_sibling()?, ++ key!('j') | key!(Down) | ctrl!('n') => self.move_down(1.max(count)), ++ key!('k') | key!(Up) | ctrl!('p') => self.move_up(1.max(count)), ++ key!('h') | key!(Left) => self.move_to_parent()?, ++ key!('l') | key!(Right) => self.move_to_children()?, ++ key!(Enter) | key!('o') => self.on_enter(cx, params, self.selected)?, ++ ctrl!('d') => self.move_down_half_page(), ++ ctrl!('u') => self.move_up_half_page(), ++ key!('z') => { ++ self.on_next_key = Some(Box::new(|_, tree, event| { ++ match event { ++ key!('z') => tree.align_view_center(), ++ key!('t') => tree.align_view_top(), ++ key!('b') => tree.align_view_bottom(), ++ _ => {} ++ }; ++ Ok(()) ++ })); ++ } ++ key!('g') => { ++ self.on_next_key = Some(Box::new(|_, tree, event| { ++ match event { ++ key!('g') => tree.move_to_first_line(), ++ key!('e') => tree.move_to_last_line(), ++ key!('h') => tree.move_leftmost(), ++ key!('l') => tree.move_rightmost(), ++ _ => {} ++ }; ++ Ok(()) ++ })); ++ } ++ key!('/') => self.new_search_prompt(Direction::Forward), ++ key!('n') => self.move_to_next_search_match(), ++ shift!('N') => self.move_to_previous_next_match(), ++ key!(PageDown) => self.move_down_page(), ++ key!(PageUp) => self.move_up_page(), ++ shift!('R') => { ++ if let Err(error) = self.refresh() { ++ cx.editor.set_error(error.to_string()) ++ } ++ } ++ key!(Home) => self.move_leftmost(), ++ key!(End) => self.move_rightmost(), ++ ctrl!('o') => self.jump_backward(), ++ ctrl!('i') | key!(Tab) => self.jump_forward(), ++ _ => return Ok(EventResult::Ignored(None)), ++ }; ++ Ok(EventResult::Consumed(None)) ++ })() ++ .unwrap_or_else(|err| { ++ cx.editor.set_error(format!("{err}")); ++ EventResult::Consumed(None) ++ }) ++ } ++ ++ fn handle_search_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { ++ if let Some((direction, mut prompt)) = self.search_prompt.take() { ++ match event { ++ key!(Enter) => { ++ self.set_search_str(prompt.line().clone()); ++ EventResult::Consumed(None) ++ } ++ key!(Esc) => { ++ if let Err(err) = self.restore_saved_view() { ++ cx.editor.set_error(format!("{err}")) ++ } ++ EventResult::Consumed(None) ++ } ++ _ => { ++ let event = prompt.handle_event(&Event::Key(*event), cx); ++ let line = prompt.line(); ++ match direction { ++ Direction::Forward => { ++ self.search_next(line); ++ } ++ Direction::Backward => self.search_previous(line), ++ } ++ self.search_prompt = Some((direction, prompt)); ++ event ++ } ++ } ++ } else { ++ EventResult::Ignored(None) ++ } ++ } ++ ++ fn new_search_prompt(&mut self, direction: Direction) { ++ self.save_view(); ++ self.search_prompt = Some(( ++ direction, ++ Prompt::new("search: ".into(), None, ui::completers::none, |_, _, _| {}), ++ )) ++ } ++ ++ pub fn prompting(&self) -> bool { ++ self.search_prompt.is_some() || self.on_next_key.is_some() ++ } ++} ++ ++/// Recalculate the index of each item of a tree. ++/// ++/// For example: ++/// ++/// ```txt ++/// foo (0) ++/// bar (1) ++/// spam (2) ++/// jar (3) ++/// yo (4) ++/// ``` ++fn index_elems(parent_index: usize, elems: Vec>) -> Vec> { ++ fn index_elems( ++ current_index: usize, ++ elems: Vec>, ++ parent_index: usize, ++ ) -> (usize, Vec>) { ++ elems ++ .into_iter() ++ .fold((current_index, vec![]), |(current_index, trees), elem| { ++ let index = current_index; ++ let item = elem.item; ++ let (current_index, folded) = index_elems(current_index + 1, elem.children, index); ++ let tree = Tree { ++ item, ++ children: folded, ++ index, ++ is_opened: elem.is_opened, ++ parent_index: Some(parent_index), ++ }; ++ ( ++ current_index, ++ trees.into_iter().chain(vec![tree].into_iter()).collect(), ++ ) ++ }) ++ } ++ index_elems(parent_index + 1, elems, parent_index).1 ++} +diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs +index 3c00a61e..1ab5f976 100644 +--- a/helix-view/src/editor.rs ++++ b/helix-view/src/editor.rs +@@ -211,6 +211,30 @@ fn default() -> Self { + } + } + ++#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] ++pub struct ExplorerConfig { ++ pub position: ExplorerPosition, ++ /// explorer column width ++ pub column_width: usize, ++} ++ ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "kebab-case")] ++pub enum ExplorerPosition { ++ Left, ++ Right, ++} ++ ++impl Default for ExplorerConfig { ++ fn default() -> Self { ++ Self { ++ position: ExplorerPosition::Left, ++ column_width: 36, ++ } ++ } ++} ++ + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] + pub struct Config { +@@ -285,6 +309,8 @@ pub struct Config { + pub initial_mode: Mode, + /// Whether to color modes with different colors. Defaults to `false`. + pub color_modes: bool, ++ /// explore config ++ pub explorer: ExplorerConfig, + pub soft_wrap: SoftWrap, + /// Workspace specific lsp ceiling dirs + pub workspace_lsp_roots: Vec, +@@ -816,6 +842,7 @@ fn default() -> Self { + indent_guides: IndentGuidesConfig::default(), + initial_mode: Mode::Normal, + color_modes: false, ++ explorer: ExplorerConfig::default(), + soft_wrap: SoftWrap { + enable: Some(false), + ..SoftWrap::default() +@@ -988,6 +1015,18 @@ pub enum CloseError { + SaveError(anyhow::Error), + } + ++impl From for anyhow::Error { ++ fn from(error: CloseError) -> Self { ++ match error { ++ CloseError::DoesNotExist => anyhow::anyhow!("Document doesn't exist"), ++ CloseError::BufferModified(error) => { ++ anyhow::anyhow!(format!("Buffer modified: '{error}'")) ++ } ++ CloseError::SaveError(error) => anyhow::anyhow!(format!("Save error: {error}")), ++ } ++ } ++} ++ + impl Editor { + pub fn new( + mut area: Rect, +diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs +index 046db86a..fbfde635 100644 +--- a/helix-view/src/graphics.rs ++++ b/helix-view/src/graphics.rs +@@ -248,6 +248,34 @@ pub fn intersects(self, other: Rect) -> bool { + && self.y < other.y + other.height + && self.y + self.height > other.y + } ++ ++ /// Returns a smaller `Rect` with a margin of 5% on each side, and an additional 2 rows at the bottom ++ pub fn overlaid(self) -> Rect { ++ self.clip_bottom(2).clip_relative(90, 90) ++ } ++ ++ /// Returns a smaller `Rect` with width and height clipped to the given `percent_horizontal` ++ /// and `percent_vertical`. ++ /// ++ /// Value of `percent_horizontal` and `percent_vertical` is from 0 to 100. ++ pub fn clip_relative(self, percent_horizontal: u8, percent_vertical: u8) -> Rect { ++ fn mul_and_cast(size: u16, factor: u8) -> u16 { ++ ((size as u32) * (factor as u32) / 100).try_into().unwrap() ++ } ++ ++ let inner_w = mul_and_cast(self.width, percent_horizontal); ++ let inner_h = mul_and_cast(self.height, percent_vertical); ++ ++ let offset_x = self.width.saturating_sub(inner_w) / 2; ++ let offset_y = self.height.saturating_sub(inner_h) / 2; ++ ++ Rect { ++ x: self.x + offset_x, ++ y: self.y + offset_y, ++ width: inner_w, ++ height: inner_h, ++ } ++ } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] +-- +2.41.0 + + +From 3476811ef07346ccfa4ea0757306bc0df99be6b0 Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 18:48:07 -0700 +Subject: [PATCH 2/2] Unit test file explorer and tree view + +--- + helix-term/src/compositor.rs | 56 ++ + helix-term/src/ui/explorer.rs | 713 ++++++++++++++++ + helix-term/src/ui/tree.rs | 1472 +++++++++++++++++++++++++++++++++ + 3 files changed, 2241 insertions(+) + +diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs +index bcb3e449..35009a25 100644 +--- a/helix-term/src/compositor.rs ++++ b/helix-term/src/compositor.rs +@@ -34,6 +34,47 @@ pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { + tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?; + Ok(()) + } ++ ++ /// Purpose: to test `handle_event` without escalating the test case to integration test ++ /// Usage: ++ /// ``` ++ /// let mut editor = Context::dummy_editor(); ++ /// let mut jobs = Context::dummy_jobs(); ++ /// let mut cx = Context::dummy(&mut jobs, &mut editor); ++ /// ``` ++ #[cfg(test)] ++ pub fn dummy(jobs: &'a mut Jobs, editor: &'a mut helix_view::Editor) -> Context<'a> { ++ Context { ++ jobs, ++ scroll: None, ++ editor, ++ } ++ } ++ ++ #[cfg(test)] ++ pub fn dummy_jobs() -> Jobs { ++ Jobs::new() ++ } ++ ++ #[cfg(test)] ++ pub fn dummy_editor() -> Editor { ++ use crate::config::Config; ++ use arc_swap::{access::Map, ArcSwap}; ++ use helix_core::syntax::{self, Configuration}; ++ use helix_view::theme; ++ use std::{sync::Arc, collections::HashMap}; ++ ++ let config = Arc::new(ArcSwap::from_pointee(Config::default())); ++ Editor::new( ++ Rect::new(0, 0, 60, 120), ++ Arc::new(theme::Loader::new(&[])), ++ Arc::new(syntax::Loader::new(Configuration { language: vec![], language_server: HashMap::new() })), ++ Arc::new(Arc::new(Map::new( ++ Arc::clone(&config), ++ |config: &Config| &config.editor, ++ ))), ++ ) ++ } + } + + pub trait Component: Any + AnyComponent { +@@ -72,6 +113,21 @@ fn type_name(&self) -> &'static str { + fn id(&self) -> Option<&'static str> { + None + } ++ ++ #[cfg(test)] ++ /// Utility method for testing `handle_event` without using integration test. ++ /// Especially useful for testing helper components such as `Prompt`, `TreeView` etc ++ fn handle_events(&mut self, events: &str) -> anyhow::Result<()> { ++ use helix_view::input::parse_macro; ++ ++ let mut editor = Context::dummy_editor(); ++ let mut jobs = Context::dummy_jobs(); ++ let mut cx = Context::dummy(&mut jobs, &mut editor); ++ for event in parse_macro(events)? { ++ self.handle_event(&Event::Key(event), &mut cx); ++ } ++ Ok(()) ++ } + } + + pub struct Compositor { +diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs +index 6df059b1..4ad8dee7 100644 +--- a/helix-term/src/ui/explorer.rs ++++ b/helix-term/src/ui/explorer.rs +@@ -749,3 +749,716 @@ fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect { + block.render(area, surface); + inner + } ++ ++#[cfg(test)] ++mod test_explorer { ++ use crate::compositor::Component; ++ ++ use super::Explorer; ++ use helix_view::graphics::Rect; ++ use std::{fs, path::PathBuf}; ++ ++ /// This code should create the following file tree: ++ /// ++ /// ++ /// ├── index.html ++ /// ├── .gitignore ++ /// ├── scripts ++ /// │ └── main.js ++ /// └── styles ++ /// ├── style.css ++ /// └── public ++ /// └── file ++ /// ++ fn dummy_file_tree() -> PathBuf { ++ let path = tempfile::tempdir().unwrap().path().to_path_buf(); ++ if path.exists() { ++ fs::remove_dir_all(path.clone()).unwrap(); ++ } ++ fs::create_dir_all(path.clone()).unwrap(); ++ fs::write(path.join("index.html"), "").unwrap(); ++ fs::write(path.join(".gitignore"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("scripts")).unwrap(); ++ fs::write(path.join("scripts").join("main.js"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("styles")).unwrap(); ++ fs::write(path.join("styles").join("style.css"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("styles").join("public")).unwrap(); ++ fs::write(path.join("styles").join("public").join("file"), "").unwrap(); ++ ++ path ++ } ++ ++ fn render(explorer: &mut Explorer) -> String { ++ explorer.tree.render_to_string(Rect::new(0, 0, 100, 10)) ++ } ++ ++ fn new_explorer() -> (PathBuf, Explorer) { ++ let path = dummy_file_tree(); ++ (path.clone(), Explorer::from_path(path, 100).unwrap()) ++ } ++ ++ #[test] ++ fn test_reveal_file() { ++ let (path, mut explorer) = new_explorer(); ++ ++ let path_str = path.display().to_string(); ++ ++ // 0a. Expect the "scripts" folder is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}) ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 1. Reveal "scripts/main.js" ++ explorer.reveal_file(path.join("scripts/main.js")).unwrap(); ++ ++ // 1a. Expect the "scripts" folder is opened, and "main.js" is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [scripts] ++ (main.js) ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 2. Change root to "scripts" ++ explorer.tree.move_up(1); ++ explorer.change_root_to_current_folder().unwrap(); ++ ++ // 2a. Expect the current root is "scripts" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}/scripts) ++ main.js ++" ++ ) ++ .trim() ++ ); ++ ++ // 3. Reveal "styles/public/file", which is outside of the current root ++ explorer ++ .reveal_file(path.join("styles/public/file")) ++ .unwrap(); ++ ++ // 3a. Expect the current root is "public", and "file" is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles/public] ++ (file) ++" ++ ) ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_rename() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ explorer.handle_events("jjj").unwrap(); ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 0. Open the rename file prompt ++ explorer.handle_events("r").unwrap(); ++ ++ // 0.1 Expect the current prompt to be related to file renaming ++ let prompt = &explorer.prompt.as_ref().unwrap().1; ++ assert_eq!(prompt.prompt(), " Rename to "); ++ assert_eq!( ++ prompt.line().replace(std::path::MAIN_SEPARATOR, "/"), ++ format!("{path_str}/.gitignore") ++ ); ++ ++ // 1. Rename the current file to a name that is lexicographically greater than "index.html" ++ explorer.handle_events("who.is").unwrap(); ++ ++ // 1a. Expect the file is renamed, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ index.html ++ (who.is) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("who.is").exists()); ++ ++ // 2. Rename the current file into an existing folder ++ explorer ++ .handle_events(&format!( ++ "rstyles{}lol", ++ std::path::MAIN_SEPARATOR ++ )) ++ .unwrap(); ++ ++ // 2a. Expect the file is moved to the folder, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ (lol) ++ style.css ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("styles/lol").exists()); ++ ++ // 3. Rename the current file into a non-existent folder ++ explorer ++ .handle_events(&format!( ++ "r{}", ++ path.join("new_folder/sponge/bob").display() ++ )) ++ .unwrap(); ++ ++ // 3a. Expect the non-existent folder to be created, ++ // and the file is moved into it, ++ // and the renamed file is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [new_folder] ++ ⏷ [sponge] ++ (bob) ++⏵ scripts ++⏷ styles ++ ⏵ public ++ style.css ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("new_folder/sponge/bob").exists()); ++ ++ // 4. Change current root to "new_folder/sponge" ++ explorer.handle_events("k]").unwrap(); ++ ++ // 4a. Expect the current root to be "sponge" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}/new_folder/sponge) ++ bob ++" ++ ) ++ .trim() ++ ); ++ ++ // 5. Move cursor to "bob", and rename it outside of the current root ++ explorer.handle_events("j").unwrap(); ++ explorer ++ .handle_events(&format!( ++ "r{}", ++ path.join("scripts/bob").display() ++ )) ++ .unwrap(); ++ ++ // 5a. Expect the current root to be "scripts" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/scripts] ++ (bob) ++ main.js ++" ++ ) ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_new_folder() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 0. Open the add file/folder prompt ++ explorer.handle_events("a").unwrap(); ++ let prompt = &explorer.prompt.as_ref().unwrap().1; ++ fn to_forward_slash(s: &str) -> String { ++ s.replace(std::path::MAIN_SEPARATOR, "/") ++ } ++ fn to_os_main_separator(s: &str) -> String { ++ s.replace('/', format!("{}", std::path::MAIN_SEPARATOR).as_str()) ++ } ++ assert_eq!( ++ to_forward_slash(prompt.prompt()), ++ " New file or folder (ends with '/'): " ++ ); ++ assert_eq!(to_forward_slash(prompt.line()), format!("{path_str}/")); ++ ++ // 1. Add a new folder at the root ++ explorer ++ .handle_events(&to_os_main_separator("yoyo/")) ++ .unwrap(); ++ ++ // 1a. Expect the new folder is added, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++⏷ (yoyo) ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("yoyo")).is_ok()); ++ ++ // 2. Move up to "styles" ++ explorer.handle_events("k").unwrap(); ++ ++ // 3. Add a new folder ++ explorer ++ .handle_events(&to_os_main_separator("asus.sass/")) ++ .unwrap(); ++ ++ // 3a. Expect the new folder is added under "styles", although "styles" is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ ⏷ (sus.sass) ++ style.css ++⏷ yoyo ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/sus.sass")).is_ok()); ++ ++ // 4. Add a new folder with non-existent parents ++ explorer ++ .handle_events(&to_os_main_separator("aa/b/c/")) ++ .unwrap(); ++ ++ // 4a. Expect the non-existent parents are created, ++ // and the new folder is created, ++ // and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [styles] ++ ⏷ [sus.sass] ++ ⏷ [a] ++ ⏷ [b] ++ ⏷ (c) ++ style.css ++⏷ yoyo ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/sus.sass/a/b/c")).is_ok()); ++ ++ // 5. Move to "style.css" ++ explorer.handle_events("j").unwrap(); ++ ++ // 6. Add a new folder here ++ explorer ++ .handle_events(&to_os_main_separator("afoobar/")) ++ .unwrap(); ++ ++ // 6a. Expect the folder is added under "styles", ++ // because the folder of the current item, "style.css" is "styles/" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏷ (foobar) ++ ⏵ public ++ ⏷ sus.sass ++ ⏷ a ++ ⏷ b ++ ⏷ c ++ style.css ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/foobar")).is_ok()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_new_file() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Add a new file at the root ++ explorer.handle_events("ayoyo").unwrap(); ++ ++ // 1a. Expect the new file is added, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++ (yoyo) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("yoyo")).is_ok()); ++ ++ // 2. Move up to "styles" ++ explorer.tree.move_up(3); ++ ++ // 3. Add a new file ++ explorer.handle_events("asus.sass").unwrap(); ++ ++ // 3a. Expect the new file is added under "styles", although "styles" is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ style.css ++ (sus.sass) ++ .gitignore ++ index.html ++ yoyo ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/sus.sass")).is_ok()); ++ ++ // 4. Add a new file with non-existent parents ++ explorer.handle_events("aa/b/c").unwrap(); ++ ++ // 4a. Expect the non-existent parents are created, ++ // and the new file is created, ++ // and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏷ [a] ++ ⏷ [b] ++ (c) ++ ⏵ public ++ style.css ++ sus.sass ++ .gitignore ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/a/b/c")).is_ok()); ++ ++ // 5. Move to "style.css" ++ explorer.handle_events("jj").unwrap(); ++ ++ // 6. Add a new file here ++ explorer.handle_events("afoobar").unwrap(); ++ ++ // 6a. Expect the file is added under "styles", ++ // because the folder of the current item, "style.css" is "styles/" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [styles] ++ ⏷ b ++ c ++ ⏵ public ++ (foobar) ++ style.css ++ sus.sass ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/foobar")).is_ok()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_remove_file() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move to ".gitignore" ++ explorer.handle_events("/.gitignore").unwrap(); ++ ++ // 1a. Expect the cursor is at ".gitignore" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join(".gitignore")).is_ok()); ++ ++ // 2. Remove the current file ++ explorer.handle_events("dy").unwrap(); ++ ++ // 3. Expect ".gitignore" is deleted, and the cursor moved down ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (index.html) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join(".gitignore")).is_err()); ++ ++ // 3a. Expect "index.html" exists ++ assert!(fs::read_to_string(path.join("index.html")).is_ok()); ++ ++ // 4. Remove the current file ++ explorer.handle_events("dy").unwrap(); ++ ++ // 4a. Expect "index.html" is deleted, at the cursor moved up ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ (styles) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("index.html")).is_err()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_remove_folder() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move to "styles/" ++ explorer.handle_events("/styleso").unwrap(); ++ ++ // 1a. Expect the cursor is at "styles" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ (styles) ++ ⏵ public ++ style.css ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles")).is_ok()); ++ ++ // 2. Remove the current folder ++ explorer.handle_events("dy").unwrap(); ++ ++ // 3. Expect "styles" is deleted, and the cursor moved down ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles")).is_err()); ++ } ++ ++ #[test] ++ fn test_change_root() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move cursor to "styles" ++ explorer.reveal_file(path.join("styles")).unwrap(); ++ ++ // 2. Change root to current folder, and move cursor down ++ explorer.change_root_to_current_folder().unwrap(); ++ explorer.tree.move_down(1); ++ ++ // 2a. Expect the current root to be "styles", and the cursor is at "public" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles] ++⏵ (public) ++ style.css ++" ++ ) ++ .trim() ++ ); ++ ++ let current_root = explorer.state.current_root.clone(); ++ ++ // 3. Change root to the parent of current folder ++ explorer.change_root_parent_folder().unwrap(); ++ ++ // 3a. Expect the current root to be "change_root" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}) ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 4. Go back to previous root ++ explorer.go_to_previous_root(); ++ ++ // 4a. Expect the root te become "styles", and the cursor position is not forgotten ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles] ++⏵ (public) ++ style.css ++" ++ ) ++ .trim() ++ ); ++ assert_eq!(explorer.state.current_root, current_root); ++ ++ // 5. Go back to previous root again ++ explorer.go_to_previous_root(); ++ ++ // 5a. Expect the current root to be "change_root" again, ++ // but this time the "styles" folder is opened, ++ // because it was opened before any change of root ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ (styles) ++ ⏵ public ++ style.css ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ } ++} +diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs +index 68783be8..d0a9af5b 100644 +--- a/helix-term/src/ui/tree.rs ++++ b/helix-term/src/ui/tree.rs +@@ -1207,3 +1207,1475 @@ fn index_elems( + } + index_elems(parent_index + 1, elems, parent_index).1 + } ++ ++#[cfg(test)] ++mod test_tree_view { ++ ++ use helix_view::graphics::Rect; ++ ++ use crate::compositor::Context; ++ ++ use super::{TreeView, TreeViewItem}; ++ ++ #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] ++ /// The children of DivisibleItem is the division of itself. ++ /// This is used to ease the creation of a dummy tree without having to specify so many things. ++ struct DivisibleItem<'a> { ++ name: &'a str, ++ } ++ ++ fn item(name: &str) -> DivisibleItem { ++ DivisibleItem { name } ++ } ++ ++ impl<'a> TreeViewItem for DivisibleItem<'a> { ++ type Params = (); ++ ++ fn name(&self) -> String { ++ self.name.to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ self.name.len() > 2 ++ } ++ ++ fn get_children(&self) -> anyhow::Result> { ++ if self.name.eq("who_lives_in_a_pineapple_under_the_sea") { ++ Ok(vec![ ++ item("gary_the_snail"), ++ item("krabby_patty"), ++ item("larry_the_lobster"), ++ item("patrick_star"), ++ item("sandy_cheeks"), ++ item("spongebob_squarepants"), ++ item("mrs_puff"), ++ item("king_neptune"), ++ item("karen"), ++ item("plankton"), ++ ]) ++ } else if self.is_parent() { ++ let (left, right) = self.name.split_at(self.name.len() / 2); ++ Ok(vec![item(left), item(right)]) ++ } else { ++ Ok(vec![]) ++ } ++ } ++ } ++ ++ fn dummy_tree_view<'a>() -> TreeView> { ++ TreeView::build_tree(item("who_lives_in_a_pineapple_under_the_sea")).unwrap() ++ } ++ ++ fn dummy_area() -> Rect { ++ Rect::new(0, 0, 50, 5) ++ } ++ ++ fn render(view: &mut TreeView) -> String { ++ view.render_to_string(dummy_area()) ++ } ++ ++ #[test] ++ fn test_init() { ++ let mut view = dummy_tree_view(); ++ ++ // Expect the items to be sorted ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_up_down() { ++ let mut view = dummy_tree_view(); ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_down(3); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ ++ view.move_up(3); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_first_line(); ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_first_last_sibling() { ++ let mut view = dummy_tree_view(); ++ view.move_to_children().unwrap(); ++ view.move_to_children().unwrap(); ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_last_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ ++ view.move_to_first_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_previous_next_sibling() { ++ let mut view = dummy_tree_view(); ++ view.move_to_children().unwrap(); ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ (karen) ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_align_view() { ++ let mut view = dummy_tree_view(); ++ view.move_down(5); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.align_view_center(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++⏵ mrs_puff ++⏵ patrick_star ++" ++ .trim() ++ ); ++ ++ view.align_view_bottom(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_first_last() { ++ let mut view = dummy_tree_view(); ++ ++ view.move_to_last_line(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ ++ view.move_to_first_line(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_half() { ++ let mut view = dummy_tree_view(); ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ king_neptune ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ (mrs_puff) ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn move_to_children_parent() { ++ let mut view = dummy_tree_view(); ++ view.move_down(1); ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_left_right() { ++ let mut view = dummy_tree_view(); ++ ++ fn render(view: &mut TreeView) -> String { ++ view.render_to_string(dummy_area().with_width(20)) ++ } ++ ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(ho_lives_in_a_pineap) ++ gary_the_snail ++ karen ++ king_neptune ++ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(o_lives_in_a_pineapp) ++gary_the_snail ++karen ++king_neptune ++krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(_lives_in_a_pineappl) ++ary_the_snail ++aren ++ing_neptune ++rabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_left(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(o_lives_in_a_pineapp) ++gary_the_snail ++karen ++king_neptune ++krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_leftmost(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_left(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_rightmost(); ++ assert_eq!(render(&mut view), "(apple_under_the_sea)\n\n\n\n"); ++ } ++ ++ #[test] ++ fn test_move_to_parent_child() { ++ let mut view = dummy_tree_view(); ++ ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ) ++ } ++ ++ #[test] ++ fn test_search_next() { ++ let mut view = dummy_tree_view(); ++ ++ view.search_next("pat"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.search_next("larr"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.search_next("who_lives"); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_search_previous() { ++ let mut view = dummy_tree_view(); ++ ++ view.search_previous("larry"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.search_previous("krab"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_next_search_match() { ++ let mut view = dummy_tree_view(); ++ view.set_search_str("pat".to_string()); ++ view.move_to_next_search_match(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.move_to_next_search_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ ++ view.move_to_next_search_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_previous_search_match() { ++ let mut view = dummy_tree_view(); ++ view.set_search_str("pat".to_string()); ++ view.move_to_previous_next_match(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ ++ view.move_to_previous_next_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++ " ++ .trim() ++ ); ++ ++ view.move_to_previous_next_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_jump_backward_forward() { ++ let mut view = dummy_tree_view(); ++ view.move_down_half_page(); ++ render(&mut view); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_forward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_forward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ } ++ ++ mod static_tree { ++ use crate::ui::{TreeView, TreeViewItem}; ++ ++ use super::dummy_area; ++ ++ #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] ++ /// This is used for test cases where the structure of the tree has to be known upfront ++ pub struct StaticItem<'a> { ++ pub name: &'a str, ++ pub children: Option>>, ++ } ++ ++ pub fn parent<'a>(name: &'a str, children: Vec>) -> StaticItem<'a> { ++ StaticItem { ++ name, ++ children: Some(children), ++ } ++ } ++ ++ pub fn child(name: &str) -> StaticItem { ++ StaticItem { ++ name, ++ children: None, ++ } ++ } ++ ++ impl<'a> TreeViewItem for StaticItem<'a> { ++ type Params = (); ++ ++ fn name(&self) -> String { ++ self.name.to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ self.children.is_some() ++ } ++ ++ fn get_children(&self) -> anyhow::Result> { ++ match &self.children { ++ Some(children) => Ok(children.clone()), ++ None => Ok(vec![]), ++ } ++ } ++ } ++ ++ pub fn render(view: &mut TreeView>) -> String { ++ view.render_to_string(dummy_area().with_height(3)) ++ } ++ } ++ ++ #[test] ++ fn test_sticky_ancestors() { ++ // The ancestors of the current item should always be visible ++ // However, if there's not enough space, the current item will take precedence, ++ // and the nearest ancestor has higher precedence than further ancestors ++ use static_tree::*; ++ ++ let mut view = TreeView::build_tree(parent( ++ "root", ++ vec![ ++ parent("a", vec![child("aa"), child("ab")]), ++ parent( ++ "b", ++ vec![parent( ++ "ba", ++ vec![parent("baa", vec![child("baaa"), child("baab")])], ++ )], ++ ), ++ ], ++ )) ++ .unwrap(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++(root) ++⏵ a ++⏵ b ++ " ++ .trim() ++ ); ++ ++ // 1. Move down to "a", and expand it ++ view.move_down(1); ++ view.move_to_children().unwrap(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (aa) ++ " ++ .trim() ++ ); ++ ++ // 2. Move down by 1 ++ view.move_down(1); ++ ++ // 2a. Expect all ancestors (i.e. "root" and "a") are visible, ++ // and the cursor is at "ab" ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (ab) ++ " ++ .trim() ++ ); ++ ++ // 3. Move down by 1 ++ view.move_down(1); ++ ++ // 3a. Expect "a" is out of view, because it is no longer the ancestor of the current item ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++ ab ++⏵ (b) ++ " ++ .trim() ++ ); ++ ++ // 4. Move to the children of "b", which is "ba" ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [b] ++ ⏵ (ba) ++ " ++ .trim() ++ ); ++ ++ // 5. Move to the children of "ba", which is "baa" ++ view.move_to_children().unwrap(); ++ ++ // 5a. Expect the furthest ancestor "root" is out of view, ++ // because when there's no enough space, the nearest ancestor takes precedence ++ assert_eq!( ++ render(&mut view), ++ " ++⏷ [b] ++ ⏷ [ba] ++ ⏵ (baa) ++ " ++ .trim() ++ ); ++ ++ // 5.1 Move to child ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baaa) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.2 Move down ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baab) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.3 Move up ++ view.move_up(1); ++ assert_eq!(view.current_item().unwrap().name, "baaa"); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baaa) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.4 Move up ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++⏷ [b] ++ ⏷ [ba] ++ ⏷ (baa) ++ " ++ .trim() ++ ); ++ ++ // 6. Move up by one ++ view.move_up(1); ++ ++ // 6a. Expect "root" is visible again, because now there's enough space to render all ++ // ancestors ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [b] ++ ⏷ (ba) ++ " ++ .trim() ++ ); ++ ++ // 7. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ (b) ++ ⏷ ba ++ " ++ .trim() ++ ); ++ ++ // 8. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (ab) ++ " ++ .trim() ++ ); ++ ++ // 9. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (aa) ++ " ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_search_prompt() { ++ let mut editor = Context::dummy_editor(); ++ let mut jobs = Context::dummy_jobs(); ++ let mut cx = Context::dummy(&mut jobs, &mut editor); ++ let mut view = dummy_tree_view(); ++ ++ view.handle_events("/an", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++⏵ (plankton) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("t", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("/larry", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++ " ++ .trim() ++ ); ++ } ++} ++ ++#[cfg(test)] ++mod test_tree { ++ use helix_core::movement::Direction; ++ ++ use super::Tree; ++ ++ #[test] ++ fn test_get() { ++ let result = Tree::new( ++ "root", ++ vec![ ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ Tree::new( ++ "spam", ++ vec![Tree::new("jar", vec![Tree::new("yo", vec![])])], ++ ), ++ ], ++ ); ++ assert_eq!(result.get(0).unwrap().item, "root"); ++ assert_eq!(result.get(1).unwrap().item, "foo"); ++ assert_eq!(result.get(2).unwrap().item, "bar"); ++ assert_eq!(result.get(3).unwrap().item, "spam"); ++ assert_eq!(result.get(4).unwrap().item, "jar"); ++ assert_eq!(result.get(5).unwrap().item, "yo"); ++ } ++ ++ #[test] ++ fn test_iter() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ let mut iter = tree.iter(); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("spam")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("jar")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("yo")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("foo")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("bar")); ++ ++ assert_eq!(iter.next().map(|tree| tree.item), None) ++ } ++ ++ #[test] ++ fn test_iter_double_ended() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ let mut iter = tree.iter(); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("bar")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("foo")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("yo")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("jar")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("spam")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), None) ++ } ++ ++ #[test] ++ fn test_len() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ assert_eq!(tree.len(), 5) ++ } ++ ++ #[test] ++ fn test_find_forward() { ++ let tree = Tree::new( ++ ".cargo", ++ vec![ ++ Tree::new("jar", vec![Tree::new("Cargo.toml", vec![])]), ++ Tree::new("Cargo.toml", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ let result = tree.find(0, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(1, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(2, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(3, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ ++ let result = tree.find(4, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ } ++ ++ #[test] ++ fn test_find_backward() { ++ let tree = Tree::new( ++ ".cargo", ++ vec![ ++ Tree::new("jar", vec![Tree::new("Cargo.toml", vec![])]), ++ Tree::new("Cargo.toml", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ let result = tree.find(0, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ ++ let result = tree.find(1, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(2, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(3, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(4, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ } ++} +-- +2.41.0 + diff --git a/0012-Add-rainbow-tree-sitter-matches.patch b/0012-Add-rainbow-tree-sitter-matches.patch new file mode 100644 index 00000000..e4b09a6f --- /dev/null +++ b/0012-Add-rainbow-tree-sitter-matches.patch @@ -0,0 +1,2432 @@ +From bbc6c8ad315d6a8830d41f1eac59ddc8b31e799c Mon Sep 17 00:00:00 2001 +From: JJ +Date: Sat, 15 Jul 2023 19:00:54 -0700 +Subject: [PATCH 1/2] Add rainbow tree-sitter highlights + +ref: https://github.com/helix-editor/helix/pull/2857 +--- + book/src/SUMMARY.md | 1 + + book/src/configuration.md | 1 + + book/src/guides/README.md | 2 +- + book/src/guides/rainbow_bracket_queries.md | 132 +++++++ + book/src/languages.md | 2 + + book/src/themes.md | 11 + + helix-core/src/syntax.rs | 417 +++++++++++++++++---- + helix-term/src/health.rs | 11 +- + helix-term/src/ui/editor.rs | 53 +++ + helix-view/src/editor.rs | 6 +- + helix-view/src/theme.rs | 113 +++++- + xtask/src/querycheck.rs | 1 + + 12 files changed, 669 insertions(+), 81 deletions(-) + create mode 100644 book/src/guides/rainbow_bracket_queries.md + +diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md +index ba330cf7..dcd128de 100644 +--- a/book/src/SUMMARY.md ++++ b/book/src/SUMMARY.md +@@ -17,3 +17,4 @@ # Summary + - [Adding textobject queries](./guides/textobject.md) + - [Adding indent queries](./guides/indent.md) + - [Adding injection queries](./guides/injection.md) ++ - [Adding rainbow bracket queries](./guides/rainbow_bracket_queries.md) +diff --git a/book/src/configuration.md b/book/src/configuration.md +index bed20b28..32a4aac4 100644 +--- a/book/src/configuration.md ++++ b/book/src/configuration.md +@@ -65,6 +65,7 @@ ### `[editor]` Section + | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | + | `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` | + | `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` | ++| `rainbow-brackets` | Whether to render rainbow colors for matching brackets. Requires tree-sitter `rainbows.scm` queries for the language. | `false` | + + ### `[editor.statusline]` Section + +diff --git a/book/src/guides/README.md b/book/src/guides/README.md +index c25768e6..e53983d6 100644 +--- a/book/src/guides/README.md ++++ b/book/src/guides/README.md +@@ -1,4 +1,4 @@ + # Guides + + This section contains guides for adding new language server configurations, +-tree-sitter grammars, textobject queries, and other similar items. ++tree-sitter grammars, textobject and rainbow bracket queries, and other similar items. +diff --git a/book/src/guides/rainbow_bracket_queries.md b/book/src/guides/rainbow_bracket_queries.md +new file mode 100644 +index 00000000..1cba6a99 +--- /dev/null ++++ b/book/src/guides/rainbow_bracket_queries.md +@@ -0,0 +1,132 @@ ++# Adding Rainbow Bracket Queries ++ ++Helix uses `rainbows.scm` tree-sitter query files to provide rainbow bracket ++functionality. ++ ++Tree-sitter queries are documented in the tree-sitter online documentation. ++If you're writing queries for the first time, be sure to check out the section ++on [syntax highlighting queries] and on [query syntax]. ++ ++Rainbow queries have two captures: `@rainbow.scope` and `@rainbow.bracket`. ++`@rainbow.scope` should capture any node that increases the nesting level ++while `@rainbow.bracket` should capture any bracket nodes. Put another way: ++`@rainbow.scope` switches to the next rainbow color for all nodes in the tree ++under it while `@rainbow.bracket` paints captured nodes with the current ++rainbow color. ++ ++For an example, let's add rainbow queries for the tree-sitter query (TSQ) ++language itself. These queries will go into a ++`runtime/queries/tsq/rainbows.scm` file in the repository root. ++ ++First we'll add the `@rainbow.bracket` captures. TSQ only has parentheses and ++square brackets: ++ ++```tsq ++["(" ")" "[" "]"] @rainbow.bracket ++``` ++ ++The ordering of the nodes within the alternation (square brackets) is not ++taken into consideration. ++ ++> Note: Why are these nodes quoted? Most syntax highlights capture text ++> surrounded by parentheses. These are _named nodes_ and correspond to the ++> names of rules in the grammar. Brackets are usually written in tree-sitter ++> grammars as literal strings, for example: ++> ++> ```js ++> { ++> // ... ++> arguments: seq("(", repeat($.argument), ")"), ++> // ... ++> } ++> ``` ++> ++> Nodes written as literal strings in tree-sitter grammars may be captured ++> in queries with those same literal strings. ++ ++Then we'll add `@rainbow.scope` captures. The easiest way to do this is to ++view the `grammar.js` file in the tree-sitter grammar's repository. For TSQ, ++that file is [here][tsq grammar.js]. As we scroll down the `grammar.js`, we ++see that the `(alternation)`, (L36) `(group)` (L57), `(named_node)` (L59), ++`(predicate)` (L87) and `(wildcard_node)` (L97) nodes all contain literal ++parentheses or square brackets in their definitions. These nodes are all ++direct parents of brackets and happen to also be the nodes we want to change ++to the next rainbow color, so we capture them as `@rainbow.scope`. ++ ++```tsq ++[ ++ (group) ++ (named_node) ++ (wildcard_node) ++ (predicate) ++ (alternation) ++] @rainbow.scope ++``` ++ ++This strategy works as a rule of thumb for most programming and configuration ++languages. Markup languages can be trickier and may take additional ++experimentation to find the correct nodes to use for scopes and brackets. ++ ++The `:tree-sitter-subtree` command shows the syntax tree under the primary ++selection in S-expression format and can be a useful tool for determining how ++to write a query. ++ ++### Properties ++ ++The `rainbow.include-children` property may be applied to `@rainbow.scope` ++captures. By default, all `@rainbow.bracket` captures must be direct descendant ++of a node captured with `@rainbow.scope` in a syntax tree in order to be ++highlighted. The `rainbow.include-children` property disables that check and ++allows `@rainbow.bracket` captures to be highlighted if they are direct or ++indirect descendants of some node captured with `@rainbow.scope`. ++ ++For example, this property is used in the HTML rainbow queries. ++ ++For a document like `link`, the syntax tree is: ++ ++```tsq ++(element ; link ++ (start_tag ; ++ (tag_name)) ; a ++ (text) ; link ++ (end_tag ; ++ (tag_name))) ; a ++``` ++ ++If we want to highlight the `<`, `>` and `" "` and `` and ``, and `>, ++ ++ /// If set, overrides rainbow brackets for a language. ++ pub rainbow_brackets: Option, + } + + #[derive(Debug, PartialEq, Eq, Hash)] +@@ -624,6 +627,8 @@ fn initialize_highlight(&self, scopes: &[String]) -> Option Option( ++ query: &'a Query, ++ root: Node<'tree>, ++ source: RopeSlice<'a>, ++ range: Option>, ++) -> (QueryCursor, QueryCaptures<'a, 'tree, RopeProvider<'a>>) { ++ // Reuse a cursor from the pool if available. ++ let mut cursor = PARSER.with(|ts_parser| { ++ let highlighter = &mut ts_parser.borrow_mut(); ++ highlighter.cursors.pop().unwrap_or_else(QueryCursor::new) ++ }); ++ ++ // This is the unsafe line: ++ // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which ++ // prevents them from being moved. But both of these values are really just ++ // pointers, so it's actually ok to move them. ++ let cursor_ref = mem::transmute::<_, &'static mut QueryCursor>(&mut cursor); ++ ++ // if reusing cursors & no range this resets to whole range ++ cursor_ref.set_byte_range(range.unwrap_or(0..usize::MAX)); ++ cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT); ++ ++ let captures = cursor_ref.captures(query, root, RopeProvider(source)); ++ ++ (cursor, captures) ++} ++ + #[derive(Debug)] + pub struct Syntax { + layers: HopSlotMap, +@@ -1248,6 +1284,46 @@ pub fn tree(&self) -> &Tree { + self.layers[self.root].tree() + } + ++ /// Iterate over all captures for a query across injection layers. ++ fn query_iter<'a, F>( ++ &'a self, ++ query_fn: F, ++ source: RopeSlice<'a>, ++ range: Option>, ++ ) -> impl Iterator, usize)> ++ where ++ F: Fn(&'a HighlightConfiguration) -> &'a Query, ++ { ++ let mut layers: Vec<_> = self ++ .layers ++ .iter() ++ .filter_map(|(_, layer)| { ++ let (cursor, captures) = unsafe { ++ query_captures( ++ query_fn(&layer.config), ++ layer.tree().root_node(), ++ source, ++ range.clone(), ++ ) ++ }; ++ let mut captures = captures.peekable(); ++ ++ // If there aren't any captures for this layer, skip the layer. ++ captures.peek()?; ++ ++ Some(QueryIterLayer { ++ cursor, ++ captures: RefCell::new(captures), ++ layer, ++ }) ++ }) ++ .collect(); ++ ++ layers.sort_unstable_by_key(|layer| layer.sort_key()); ++ ++ QueryIter { layers } ++ } ++ + /// Iterate over the highlighted regions for a given slice of source code. + pub fn highlight_iter<'a>( + &'a self, +@@ -1255,37 +1331,23 @@ pub fn highlight_iter<'a>( + range: Option>, + cancellation_flag: Option<&'a AtomicUsize>, + ) -> impl Iterator> + 'a { +- let mut layers = self ++ let mut layers: Vec<_> = self + .layers + .iter() + .filter_map(|(_, layer)| { + // TODO: if range doesn't overlap layer range, skip it + +- // Reuse a cursor from the pool if available. +- let mut cursor = PARSER.with(|ts_parser| { +- let highlighter = &mut ts_parser.borrow_mut(); +- highlighter.cursors.pop().unwrap_or_else(QueryCursor::new) +- }); +- +- // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which +- // prevents them from being moved. But both of these values are really just +- // pointers, so it's actually ok to move them. +- let cursor_ref = +- unsafe { mem::transmute::<_, &'static mut QueryCursor>(&mut cursor) }; +- +- // if reusing cursors & no range this resets to whole range +- cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); +- cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT); +- +- let mut captures = cursor_ref +- .captures( ++ let (cursor, captures) = unsafe { ++ query_captures( + &layer.config.query, + layer.tree().root_node(), +- RopeProvider(source), ++ source, ++ range.clone(), + ) +- .peekable(); ++ }; ++ let mut captures = captures.peekable(); + +- // If there's no captures, skip the layer ++ // If there are no captures, skip the layer + captures.peek()?; + + Some(HighlightIterLayer { +@@ -1302,11 +1364,13 @@ pub fn highlight_iter<'a>( + depth: layer.depth, // TODO: just reuse `layer` + }) + }) +- .collect::>(); ++ .collect(); + + layers.sort_unstable_by_key(|layer| layer.sort_key()); + +- let mut result = HighlightIter { ++ sort_layers(&mut layers); ++ ++ HighlightIter { + source, + byte_offset: range.map_or(0, |r| r.start), + cancellation_flag, +@@ -1314,9 +1378,95 @@ pub fn highlight_iter<'a>( + layers, + next_event: None, + last_highlight_range: None, +- }; +- result.sort_layers(); +- result ++ } ++ } ++ ++ /// Queries for rainbow highlights in the given range. ++ pub fn rainbow_spans<'a>( ++ &'a self, ++ source: RopeSlice<'a>, ++ query_range: Option>, ++ rainbow_length: usize, ++ ) -> Vec<(usize, std::ops::Range)> { ++ struct RainbowScope { ++ end: usize, ++ node_id: Option, ++ highlight: usize, ++ } ++ ++ let mut spans = Vec::new(); ++ let mut scope_stack: Vec = Vec::new(); ++ ++ // Calculating rainbow highlights is similar to determining local highlights ++ // in the highlight iterator. We iterate over the query captures for ++ // `@rainbow.scope` and `@rainbow.bracket`: ++ // ++ // * `@rainbow.scope`: pushes a new `RainbowScope` onto the `scope_stack` ++ // stack. The number of `RainbowScope`s is the level of nesting within ++ // brackets and determines which color of the rainbow should be used as ++ // a highlight: `scope_stack.len() % rainbow_length`. ++ // ++ // * `@rainbow.bracket`: adds a new highlight span to the `spans` Vec. ++ // A `@rainbow.bracket` capture only creates a new highlight if that node ++ // is a child node of the latest node captured with `@rainbow.scope`, ++ // or if the last `RainbowScope` on the `scope_stack` was captured with ++ // the `(set! rainbow.include-children)` property. ++ // ++ // The iterator over the query captures returns captures across injection ++ // layers sorted by the earliest captures in the document first, so ++ // highlight colors are calculated correctly across injection layers. ++ ++ // Iterate over all of the captures for rainbow queries across injections. ++ for (layer, match_, capture_index) in ++ self.query_iter(|config| &config.rainbow_query, source, query_range) ++ { ++ let capture = match_.captures[capture_index]; ++ let range = capture.node.byte_range(); ++ ++ // If any scope in the stack ends before this new capture begins, ++ // pop the scope out of the scope stack. ++ while let Some(scope) = scope_stack.last() { ++ if range.start >= scope.end { ++ scope_stack.pop(); ++ } else { ++ break; ++ } ++ } ++ ++ if Some(capture.index) == layer.config.rainbow_scope_capture_index { ++ // If the capture is a "rainbow.scope", push it onto the scope stack. ++ let mut scope = RainbowScope { ++ end: range.end, ++ node_id: Some(capture.node.id()), ++ highlight: scope_stack.len() % rainbow_length, ++ }; ++ for prop in layer ++ .config ++ .rainbow_query ++ .property_settings(match_.pattern_index) ++ { ++ if prop.key.as_ref() == "rainbow.include-children" { ++ scope.node_id = None; ++ } ++ } ++ scope_stack.push(scope); ++ } else if Some(capture.index) == layer.config.rainbow_bracket_capture_index { ++ // If the capture is a "rainbow.bracket", check that the top of the scope stack ++ // is a valid scope for the bracket. The scope is valid if: ++ // * The scope's node is the direct parent of the captured node. ++ // * The scope has the "rainbow.include-children" property set. This allows the ++ // scope to match all descendant nodes in its range. ++ if let Some(scope) = scope_stack.last() { ++ if scope.node_id.is_none() ++ || capture.node.parent().map(|p| p.id()) == scope.node_id ++ { ++ spans.push((scope.highlight, range)); ++ } ++ } ++ } ++ } ++ ++ spans + } + + // Commenting +@@ -1331,6 +1481,18 @@ pub fn highlight_iter<'a>( + // TODO: Folding + } + ++/// Finds the child of `node` which contains the given byte range `range`. ++pub fn child_for_byte_range(node: Node, range: std::ops::Range) -> Option { ++ for child in node.children(&mut node.walk()) { ++ let child_range = child.byte_range(); ++ if range.start >= child_range.start && range.end <= child_range.end { ++ return Some(child); ++ } ++ } ++ ++ None ++} ++ + bitflags! { + /// Flags that track the status of a layer + /// in the `Sytaxn::update` function +@@ -1558,7 +1720,8 @@ pub enum HighlightEvent { + #[derive(Debug)] + pub struct HighlightConfiguration { + pub language: Grammar, +- pub query: Query, ++ query: Query, ++ rainbow_query: Query, + injections_query: Query, + combined_injections_query: Option, + highlights_pattern_index: usize, +@@ -1572,6 +1735,8 @@ pub struct HighlightConfiguration { + local_def_capture_index: Option, + local_def_value_capture_index: Option, + local_ref_capture_index: Option, ++ rainbow_scope_capture_index: Option, ++ rainbow_bracket_capture_index: Option, + } + + #[derive(Debug)] +@@ -1656,6 +1821,7 @@ impl HighlightConfiguration { + pub fn new( + language: Grammar, + highlights_query: &str, ++ rainbow_query: &str, + injection_query: &str, + locals_query: &str, + ) -> Result { +@@ -1675,6 +1841,7 @@ pub fn new( + highlights_pattern_index += 1; + } + } ++ let rainbow_query = Query::new(language, rainbow_query)?; + + let mut injections_query = Query::new(language, injection_query)?; + +@@ -1717,6 +1884,8 @@ pub fn new( + let mut local_def_value_capture_index = None; + let mut local_ref_capture_index = None; + let mut local_scope_capture_index = None; ++ let mut rainbow_scope_capture_index = None; ++ let mut rainbow_bracket_capture_index = None; + for (i, name) in query.capture_names().iter().enumerate() { + let i = Some(i as u32); + match name.as_str() { +@@ -1728,6 +1897,15 @@ pub fn new( + } + } + ++ for (i, name) in rainbow_query.capture_names().iter().enumerate() { ++ let i = Some(i as u32); ++ match name.as_str() { ++ "rainbow.scope" => rainbow_scope_capture_index = i, ++ "rainbow.bracket" => rainbow_bracket_capture_index = i, ++ _ => {} ++ } ++ } ++ + for (i, name) in injections_query.capture_names().iter().enumerate() { + let i = Some(i as u32); + match name.as_str() { +@@ -1743,6 +1921,7 @@ pub fn new( + Ok(Self { + language, + query, ++ rainbow_query, + injections_query, + combined_injections_query, + highlights_pattern_index, +@@ -1756,6 +1935,8 @@ pub fn new( + local_def_capture_index, + local_def_value_capture_index, + local_ref_capture_index, ++ rainbow_scope_capture_index, ++ rainbow_bracket_capture_index, + }) + } + +@@ -1896,11 +2077,21 @@ fn injection_for_match<'a>( + } + } + +-impl<'a> HighlightIterLayer<'a> { +- // First, sort scope boundaries by their byte offset in the document. At a +- // given position, emit scope endings before scope beginnings. Finally, emit +- // scope boundaries from deeper layers first. +- fn sort_key(&self) -> Option<(usize, bool, isize)> { ++trait IterLayer { ++ type SortKey: PartialOrd; ++ ++ fn sort_key(&self) -> Option; ++ ++ fn cursor(self) -> QueryCursor; ++} ++ ++impl<'a> IterLayer for HighlightIterLayer<'a> { ++ type SortKey = (usize, bool, isize); ++ ++ fn sort_key(&self) -> Option { ++ // First, sort scope boundaries by their byte offset in the document. At a ++ // given position, emit scope endings before scope beginnings. Finally, emit ++ // scope boundaries from deeper layers first. + let depth = -(self.depth as isize); + let next_start = self + .captures +@@ -1921,6 +2112,82 @@ fn sort_key(&self) -> Option<(usize, bool, isize)> { + _ => None, + } + } ++ ++ fn cursor(self) -> QueryCursor { ++ self.cursor ++ } ++} ++ ++impl<'a> IterLayer for QueryIterLayer<'a> { ++ type SortKey = (usize, isize); ++ ++ fn sort_key(&self) -> Option { ++ // Sort the layers so that the first layer in the Vec has the next ++ // capture ordered by start byte and depth (descending). ++ let depth = -(self.layer.depth as isize); ++ let mut captures = self.captures.borrow_mut(); ++ let (match_, capture_index) = captures.peek()?; ++ let start = match_.captures[*capture_index].node.start_byte(); ++ ++ Some((start, depth)) ++ } ++ ++ fn cursor(self) -> QueryCursor { ++ self.cursor ++ } ++} ++ ++/// Re-sort the given layers so that the next capture for the `layers[0]` is ++/// the earliest capture in the document for all layers. ++/// ++/// This function assumes that `layers` is already sorted except for the ++/// first layer in the `Vec`. This function shifts the first layer later in ++/// the `Vec` after any layers with earlier captures. ++/// ++/// This is quicker than a regular full sort: it can only take as many ++/// iterations as the number of layers and usually takes many fewer than ++/// the full number of layers. The case when `layers[0]` is already the ++/// layer with the earliest capture and the sort is a no-op is a fast-lane ++/// which only takes one comparison operation. ++/// ++/// This function also removes any layers which have no more query captures ++/// to emit. ++fn sort_layers(layers: &mut Vec) { ++ while !layers.is_empty() { ++ // If `Layer::sort_key` returns `None`, the layer has no more captures ++ // to emit and can be removed. ++ if let Some(sort_key) = layers[0].sort_key() { ++ let mut i = 0; ++ while i + 1 < layers.len() { ++ if let Some(next_offset) = layers[i + 1].sort_key() { ++ // Compare `0`'s sort key to `i + 1`'s. If `i + 1` comes ++ // before `0`, shift the `0` layer so it comes after the ++ // `i + 1` layers. ++ if next_offset < sort_key { ++ i += 1; ++ continue; ++ } ++ } else { ++ let layer = layers.remove(i + 1); ++ PARSER.with(|ts_parser| { ++ let highlighter = &mut ts_parser.borrow_mut(); ++ highlighter.cursors.push(layer.cursor()); ++ }); ++ } ++ break; ++ } ++ if i > 0 { ++ layers[0..(i + 1)].rotate_left(1); ++ } ++ break; ++ } else { ++ let layer = layers.remove(0); ++ PARSER.with(|ts_parser| { ++ let highlighter = &mut ts_parser.borrow_mut(); ++ highlighter.cursors.push(layer.cursor()); ++ }); ++ } ++ } + } + + #[derive(Clone)] +@@ -2051,42 +2318,9 @@ fn emit_event( + } else { + result = event.map(Ok); + } +- self.sort_layers(); ++ sort_layers(&mut self.layers); + result + } +- +- fn sort_layers(&mut self) { +- while !self.layers.is_empty() { +- if let Some(sort_key) = self.layers[0].sort_key() { +- let mut i = 0; +- while i + 1 < self.layers.len() { +- if let Some(next_offset) = self.layers[i + 1].sort_key() { +- if next_offset < sort_key { +- i += 1; +- continue; +- } +- } else { +- let layer = self.layers.remove(i + 1); +- PARSER.with(|ts_parser| { +- let highlighter = &mut ts_parser.borrow_mut(); +- highlighter.cursors.push(layer.cursor); +- }); +- } +- break; +- } +- if i > 0 { +- self.layers[0..(i + 1)].rotate_left(1); +- } +- break; +- } else { +- let layer = self.layers.remove(0); +- PARSER.with(|ts_parser| { +- let highlighter = &mut ts_parser.borrow_mut(); +- highlighter.cursors.push(layer.cursor); +- }); +- } +- } +- } + } + + impl<'a> Iterator for HighlightIter<'a> { +@@ -2238,7 +2472,7 @@ fn next(&mut self) -> Option { + } + } + +- self.sort_layers(); ++ sort_layers(&mut self.layers); + continue 'main; + } + +@@ -2247,7 +2481,7 @@ fn next(&mut self) -> Option { + // a different layer, then skip over this one. + if let Some((last_start, last_end, last_depth)) = self.last_highlight_range { + if range.start == last_start && range.end == last_end && layer.depth < last_depth { +- self.sort_layers(); ++ sort_layers(&mut self.layers); + continue 'main; + } + } +@@ -2265,7 +2499,7 @@ fn next(&mut self) -> Option { + } + } + +- self.sort_layers(); ++ sort_layers(&mut self.layers); + continue 'main; + } + } +@@ -2300,7 +2534,7 @@ fn next(&mut self) -> Option { + .emit_event(range.start, Some(HighlightEvent::HighlightStart(highlight))); + } + +- self.sort_layers(); ++ sort_layers(&mut self.layers); + } + } + } +@@ -2510,6 +2744,42 @@ fn pretty_print_tree_impl( + Ok(()) + } + ++struct QueryIterLayer<'a> { ++ cursor: QueryCursor, ++ captures: RefCell>>>, ++ layer: &'a LanguageLayer, ++} ++ ++impl<'a> fmt::Debug for QueryIterLayer<'a> { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("QueryIterLayer").finish() ++ } ++} ++ ++#[derive(Debug)] ++pub struct QueryIter<'a> { ++ layers: Vec>, ++} ++ ++impl<'a> Iterator for QueryIter<'a> { ++ type Item = (&'a LanguageLayer, QueryMatch<'a, 'a>, usize); ++ ++ fn next(&mut self) -> Option { ++ // Sort the layers so that the first layer contains the next capture. ++ sort_layers(&mut self.layers); ++ ++ // Emit the next capture from the lowest layer. If there are no more ++ // layers, terminate. ++ let layer = self.layers.get_mut(0)?; ++ let inner = layer.layer; ++ layer ++ .captures ++ .borrow_mut() ++ .next() ++ .map(|(match_, index)| (inner, match_, index)) ++ } ++} ++ + #[cfg(test)] + mod test { + use super::*; +@@ -2539,7 +2809,7 @@ fn test_textobject_queries() { + let textobject = TextObjectQuery { query }; + let mut cursor = QueryCursor::new(); + +- let config = HighlightConfiguration::new(language, "", "", "").unwrap(); ++ let config = HighlightConfiguration::new(language, "", "", "", "").unwrap(); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)).unwrap(); + + let root = syntax.tree().root_node(); +@@ -2601,6 +2871,7 @@ fn test_parser() { + language, + &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") + .unwrap(), ++ "", // rainbows.scm + &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/injections.scm") + .unwrap(), + "", // locals.scm +@@ -2703,7 +2974,7 @@ fn assert_pretty_print( + }); + let language = get_language(language_name).unwrap(); + +- let config = HighlightConfiguration::new(language, "", "", "").unwrap(); ++ let config = HighlightConfiguration::new(language, "", "", "", "").unwrap(); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)).unwrap(); + + let root = syntax +diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs +index 8f921877..462cdf3f 100644 +--- a/helix-term/src/health.rs ++++ b/helix-term/src/health.rs +@@ -12,11 +12,17 @@ pub enum TsFeature { + Highlight, + TextObject, + AutoIndent, ++ RainbowBrackets, + } + + impl TsFeature { + pub fn all() -> &'static [Self] { +- &[Self::Highlight, Self::TextObject, Self::AutoIndent] ++ &[ ++ Self::Highlight, ++ Self::TextObject, ++ Self::AutoIndent, ++ Self::RainbowBrackets, ++ ] + } + + pub fn runtime_filename(&self) -> &'static str { +@@ -24,6 +30,7 @@ pub fn runtime_filename(&self) -> &'static str { + Self::Highlight => "highlights.scm", + Self::TextObject => "textobjects.scm", + Self::AutoIndent => "indents.scm", ++ Self::RainbowBrackets => "rainbows.scm", + } + } + +@@ -32,6 +39,7 @@ pub fn long_title(&self) -> &'static str { + Self::Highlight => "Syntax Highlighting", + Self::TextObject => "Treesitter Textobjects", + Self::AutoIndent => "Auto Indent", ++ Self::RainbowBrackets => "Rainbow Brackets", + } + } + +@@ -40,6 +48,7 @@ pub fn short_title(&self) -> &'static str { + Self::Highlight => "Highlight", + Self::TextObject => "Textobject", + Self::AutoIndent => "Indent", ++ Self::RainbowBrackets => "Rainbow", + } + } + } +diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs +index 81f8fe22..63590e94 100644 +--- a/helix-term/src/ui/editor.rs ++++ b/helix-term/src/ui/editor.rs +@@ -94,6 +94,11 @@ pub fn render_view( + let theme = &editor.theme; + let config = editor.config(); + ++ let should_render_rainbow_brackets = doc ++ .language_config() ++ .and_then(|lang_config| lang_config.rainbow_brackets) ++ .unwrap_or(config.rainbow_brackets); ++ + let text_annotations = view.text_annotations(doc, Some(theme)); + let mut line_decorations: Vec> = Vec::new(); + let mut translated_positions: Vec = Vec::new(); +@@ -125,6 +130,12 @@ pub fn render_view( + + let mut highlights = + Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme); ++ if should_render_rainbow_brackets { ++ highlights = Box::new(syntax::merge( ++ highlights, ++ Self::doc_rainbow_highlights(doc, view.offset.anchor, inner.height, theme), ++ )); ++ } + let overlay_highlights = Self::overlay_syntax_highlights( + doc, + view.offset.anchor, +@@ -330,6 +341,48 @@ pub fn doc_syntax_highlights<'doc>( + } + } + ++ pub fn doc_rainbow_highlights( ++ doc: &Document, ++ anchor: usize, ++ height: u16, ++ theme: &Theme, ++ ) -> Vec<(usize, std::ops::Range)> { ++ let syntax = match doc.syntax() { ++ Some(syntax) => syntax, ++ None => return Vec::new(), ++ }; ++ ++ let text = doc.text().slice(..); ++ let row = text.char_to_line(anchor.min(text.len_chars())); ++ ++ // calculate viewport byte ranges ++ let last_line = doc.text().len_lines().saturating_sub(1); ++ let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); ++ let visible_start = text.line_to_byte(row.min(last_line)); ++ let visible_end = text.line_to_byte(last_visible_line + 1); ++ ++ // The calculation for the current nesting level for rainbow highlights ++ // depends on where we start the iterator from. For accuracy, we start ++ // the iterator further back than the viewport: at the start of the containing ++ // non-root syntax-tree node. Any spans that are off-screen are truncated when ++ // the spans are merged via [syntax::merge]. ++ let syntax_node_start = ++ syntax::child_for_byte_range(syntax.tree().root_node(), visible_start..visible_start) ++ .map_or(visible_start, |node| node.byte_range().start); ++ let syntax_node_range = syntax_node_start..visible_end; ++ ++ let mut spans = syntax.rainbow_spans(text, Some(syntax_node_range), theme.rainbow_length()); ++ ++ for (_highlight, range) in spans.iter_mut() { ++ let start = text.byte_to_char(ensure_grapheme_boundary_next_byte(text, range.start)); ++ let end = text.byte_to_char(ensure_grapheme_boundary_next_byte(text, range.end)); ++ ++ *range = start..end; ++ } ++ ++ spans ++ } ++ + /// Get highlight spans for document diagnostics + pub fn doc_diagnostics_highlights( + doc: &Document, +diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs +index 1ab5f976..c0410f7d 100644 +--- a/helix-view/src/editor.rs ++++ b/helix-view/src/editor.rs +@@ -316,6 +316,8 @@ 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, ++ /// Whether to render rainbow highlights. Defaults to `false`. ++ pub rainbow_brackets: bool, + } + + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +@@ -851,6 +853,7 @@ fn default() -> Self { + completion_replace: false, + workspace_lsp_roots: Vec::new(), + default_line_ending: LineEndingConfig::default(), ++ rainbow_brackets: false, + } + } + } +@@ -1181,8 +1184,7 @@ fn set_theme_impl(&mut self, theme: Theme, preview: ThemeAction) { + return; + } + +- let scopes = theme.scopes(); +- self.syn_loader.set_scopes(scopes.to_vec()); ++ self.syn_loader.set_scopes(theme.scopes().to_vec()); + + match preview { + ThemeAction::Preview => { +diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs +index bf3379ca..2b024384 100644 +--- a/helix-view/src/theme.rs ++++ b/helix-view/src/theme.rs +@@ -216,17 +216,19 @@ pub struct Theme { + // tree-sitter highlight styles are stored in a Vec to optimize lookups + scopes: Vec, + highlights: Vec