From 3bff36ab90aba7de8bb5bff7dbb8230d81cdf582 Mon Sep 17 00:00:00 2001 From: wongjiahau Date: Wed, 1 May 2024 13:42:31 -0700 Subject: Add file explorer and tree helper ref: https://github.com/helix-editor/helix/issues/200 ref: https://github.com/helix-editor/helix/pull/2377 ref: https://github.com/helix-editor/helix/pull/5566 ref: https://github.com/helix-editor/helix/pull/5768 Co-authored-by: cossonleo Co-authored-by: JJ Co-authored-by: Quan Tong --- book/src/configuration.md | 9 + book/src/keymap.md | 5 + helix-term/src/commands.rs | 45 + helix-term/src/compositor.rs | 65 + helix-term/src/keymap/default.rs | 1 + helix-term/src/ui/editor.rs | 69 +- helix-term/src/ui/explorer.rs | 1464 +++++++++++++++++++++ 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 | 2681 ++++++++++++++++++++++++++++++++++++++ helix-view/src/editor.rs | 39 + helix-view/src/graphics.rs | 28 + 13 files changed, 4407 insertions(+), 28 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 5e22cebf..e3dbce95 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -402,3 +402,12 @@ S-tab = "move_parent_node_start" tab = "extend_parent_node_end" S-tab = "extend_parent_node_start" ``` + +### `[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 3a5ccca5..52c179f5 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -301,6 +301,7 @@ This layer is a kludge of mappings, mostly pickers. | `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. @@ -457,3 +458,7 @@ Keys to use within prompt, Remapping currently not supported. | `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 d545480b..5c59962d 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -507,6 +507,8 @@ impl MappableCommand { command_palette, "Open command palette", goto_word, "Jump to a two-character label", extend_to_word, "Extend to a two-character label", + open_or_focus_explorer, "Open or focus explorer", + reveal_current_file, "Reveal current file in explorer", ); } @@ -2834,6 +2836,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.push(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.push(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/compositor.rs b/helix-term/src/compositor.rs index 3dcb5f2b..c21c0b23 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -35,6 +35,56 @@ impl<'a> Context<'a> { 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 crate::handlers; + use arc_swap::{access::Map, ArcSwap}; + use helix_core::syntax::{self, Configuration}; + use helix_view::theme; + use std::{collections::HashMap, sync::Arc}; + + let config = Arc::new(ArcSwap::from_pointee(Config::default())); + let handlers = handlers::setup(config.clone()); + Editor::new( + Rect::new(0, 0, 60, 120), + Arc::new(theme::Loader::new(&[])), + Arc::new(ArcSwap::from_pointee( + syntax::Loader::new(Configuration { + language: vec![], + language_server: HashMap::new(), + }) + .unwrap(), + )), + Arc::new(Arc::new(Map::new( + Arc::clone(&config), + |config: &Config| &config.editor, + ))), + handlers, + ) + } } pub trait Component: Any + AnyComponent { @@ -73,6 +123,21 @@ pub trait Component: Any + AnyComponent { 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/keymap/default.rs b/helix-term/src/keymap/default.rs index ca5a21d2..fbe2388b 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -281,6 +281,7 @@ pub fn default() -> HashMap { "C" => toggle_block_comments, "A-c" => toggle_line_comments, "?" => 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 ad7aa5c5..7d28b62c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -6,7 +6,7 @@ use crate::{ keymap::{KeymapResult, Keymaps}, ui::{ document::{render_document, LinePos, TextRenderer, TranslatedPosition}, - Completion, ProgressSpinners, + Completion, Explorer, ProgressSpinners, }, }; @@ -23,7 +23,7 @@ use helix_core::{ }; 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}, @@ -42,6 +42,7 @@ pub struct EditorView { pseudo_pending: Vec, pub(crate) last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, + pub(crate) explorer: Option, spinners: ProgressSpinners, /// Tracks if the terminal window is focused by reaction to terminal focus events terminal_focused: bool, @@ -72,6 +73,7 @@ impl EditorView { pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, + explorer: None, spinners: ProgressSpinners::default(), terminal_focused: true, } @@ -1288,6 +1290,11 @@ impl Component for EditorView { 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, @@ -1445,6 +1452,8 @@ impl Component for EditorView { 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 { @@ -1453,17 +1462,47 @@ impl Component for EditorView { _ => 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 bufferline_area = area.with_height(1); + let (editor_area, bufferline_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), + bufferline_area.clip_left(explorer_column_width), + ), + ExplorerPosition::Right => ( + editor_area.clip_right(explorer_column_width), + bufferline_area.clip_left(explorer_column_width), + ), + } + } else { + (editor_area, bufferline_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() { + explorer.render(area, surface, cx); + } + } + if use_bufferline { - Self::render_bufferline(cx.editor, area.with_height(1), surface); + Self::render_bufferline(cx.editor, bufferline_area, surface); } for (view, is_focused) in cx.editor.tree.views() { @@ -1540,9 +1579,23 @@ impl Component for EditorView { 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() { + 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) => { diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs new file mode 100644 index 00000000..6a7b631d --- /dev/null +++ b/helix-term/src/ui/explorer.rs @@ -0,0 +1,1464 @@ +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_stdx::path::normalize(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_stdx::path::normalize(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 +} + +#[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/mod.rs b/helix-term/src/ui/mod.rs index b5969818..7e210a70 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 info; pub mod lsp; mod markdown; @@ -12,12 +13,14 @@ mod prompt; 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; use helix_stdx::rope; pub use markdown::Markdown; pub use menu::Menu; @@ -26,6 +29,7 @@ pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; pub use text::Text; +pub use tree::{TreeOp, TreeView, TreeViewItem}; use helix_view::Editor; 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 a6ee7f05..78e97c8c 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -108,6 +108,10 @@ impl Prompt { 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..d0a9af5b --- /dev/null +++ b/helix-term/src/ui/tree.rs @@ -0,0 +1,2681 @@ +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 +} + +#[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)); + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index dd360a78..44dd1041 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -229,6 +229,30 @@ where Ok(chars) } +#[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 { @@ -325,6 +349,8 @@ pub struct Config { /// labels characters used in jumpmode #[serde(skip_serializing, deserialize_with = "deserialize_alphabet")] pub jump_label_alphabet: Vec, + /// Explorer configuration. + pub explorer: ExplorerConfig, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -902,6 +928,7 @@ impl Default for Config { popup_border: PopupBorderConfig::None, indent_heuristic: IndentationHeuristic::default(), jump_label_alphabet: ('a'..='z').collect(), + explorer: ExplorerConfig::default(), } } } @@ -1065,6 +1092,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 @@ impl Rect { && 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)] -- cgit v1.2.3-70-g09d2