Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/commands.rs')
| -rw-r--r-- | helix-term/src/commands.rs | 3987 |
1 files changed, 1325 insertions, 2662 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 430d4430..daf961d1 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,84 +1,69 @@ pub(crate) mod dap; pub(crate) mod lsp; -pub(crate) mod syntax; pub(crate) mod typed; pub use dap::*; -use futures_util::FutureExt; -use helix_event::status; -use helix_stdx::{ - path::{self, find_paths}, - rope::{self, RopeSliceExt}, -}; -use helix_vcs::{FileChange, Hunk}; +use helix_vcs::Hunk; pub use lsp::*; -pub use syntax::*; -use tui::{ - text::{Span, Spans}, - widgets::Cell, -}; +use tokio::sync::oneshot; +use tui::widgets::Row; pub use typed::*; use helix_core::{ - char_idx_at_visual_offset, - chars::char_is_word, - command_line::{self, Args}, - comment, + char_idx_at_visual_offset, comment, doc_formatter::TextFormat, - encoding, find_workspace, - graphemes::{self, next_grapheme_boundary}, + encoding, find_first_non_whitespace_char, find_workspace, graphemes, history::UndoKind, - increment, - indent::{self, IndentStyle}, - line_ending::{get_line_ending_of_str, line_end_char_index}, + increment, indent, + indent::IndentStyle, + line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, movement::{self, move_vertically_visual, Direction}, object, pos_at_coords, - regex::{self, Regex}, + regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, - selection, surround, - syntax::config::{BlockCommentToken, LanguageServerFeature}, - text_annotations::{Overlay, TextAnnotations}, + selection, shellwords, surround, + syntax::LanguageServerFeature, + text_annotations::TextAnnotations, textobject, + tree_sitter::Node, unicode::width::UnicodeWidthChar, - visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeReader, RopeSlice, - Selection, SmallVec, Syntax, Tendril, Transaction, + visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, + RopeSlice, Selection, SmallVec, Tendril, Transaction, }; use helix_view::{ + clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, - editor::Action, - expansion, + editor::{Action, CompleteAction, Motion}, info::Info, input::KeyEvent, keyboard::KeyCode, - theme::Style, tree, view::View, Document, DocumentId, Editor, ViewId, }; use anyhow::{anyhow, bail, ensure, Context as _}; +use fuzzy_matcher::FuzzyMatcher; use insert::*; use movement::Movement; use crate::{ - compositor::{self, Component, Compositor}, + args, + compositor::{self, Component, Compositor, EventResult}, filter_picker_entry, job::Callback, - ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent}, + keymap::{Keymaps, ReverseKeymap}, + ui::{ + self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker, + Popup, Prompt, PromptEvent, + }, }; use crate::job::{self, Jobs}; -use std::{ - char::{ToLowercase, ToUppercase}, - cmp::Ordering, - collections::{HashMap, HashSet}, - error::Error, - fmt, - future::Future, - io::Read, - num::NonZeroUsize, -}; +use futures_util::{stream::FuturesUnordered, StreamExt, TryStreamExt}; +use std::{collections::HashMap, fmt, future::Future}; +use std::{collections::HashSet, num::NonZeroUsize}; use std::{ borrow::Cow, @@ -87,44 +72,39 @@ use std::{ use once_cell::sync::Lazy; use serde::de::{self, Deserialize, Deserializer}; -use url::Url; use grep_regex::RegexMatcherBuilder; use grep_searcher::{sinks, BinaryDetection, SearcherBuilder}; use ignore::{DirEntry, WalkBuilder, WalkState}; +use tokio_stream::wrappers::UnboundedReceiverStream; pub type OnKeyCallback = Box<dyn FnOnce(&mut Context, KeyEvent)>; -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -pub enum OnKeyCallbackKind { - PseudoPending, - Fallback, -} pub struct Context<'a> { + pub keymaps: &'a mut Keymaps, + pub register: Option<char>, pub count: Option<NonZeroUsize>, pub editor: &'a mut Editor, - pub callback: Vec<crate::compositor::Callback>, - pub on_next_key_callback: Option<(OnKeyCallback, OnKeyCallbackKind)>, + pub callback: Option<crate::compositor::Callback>, + pub on_next_key_callback: Option<OnKeyCallback>, pub jobs: &'a mut Jobs, } -impl Context<'_> { +impl<'a> Context<'a> { /// Push a new component onto the compositor. pub fn push_layer(&mut self, component: Box<dyn Component>) { - self.callback - .push(Box::new(|compositor: &mut Compositor, _| { - compositor.push(component) - })); + self.callback = Some(Box::new(|compositor: &mut Compositor, _| { + compositor.push(component) + })); } /// Call `replace_or_push` on the Compositor pub fn replace_or_push_layer<T: Component>(&mut self, id: &'static str, component: T) { - self.callback - .push(Box::new(move |compositor: &mut Compositor, _| { - compositor.replace_or_push(id, component); - })); + self.callback = Some(Box::new(move |compositor: &mut Compositor, _| { + compositor.replace_or_push(id, component); + })); } #[inline] @@ -132,28 +112,16 @@ impl Context<'_> { &mut self, on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static, ) { - self.on_next_key_callback = Some(( - Box::new(on_next_key_callback), - OnKeyCallbackKind::PseudoPending, - )); - } - - #[inline] - pub fn on_next_key_fallback( - &mut self, - on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static, - ) { - self.on_next_key_callback = - Some((Box::new(on_next_key_callback), OnKeyCallbackKind::Fallback)); + self.on_next_key_callback = Some(Box::new(on_next_key_callback)); } #[inline] pub fn callback<T, F>( &mut self, - call: impl Future<Output = helix_lsp::Result<T>> + 'static + Send, + call: impl Future<Output = helix_lsp::Result<serde_json::Value>> + 'static + Send, callback: F, ) where - T: Send + 'static, + T: for<'de> serde::Deserialize<'de> + Send + 'static, F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, { self.jobs.callback(make_job_callback(call, callback)); @@ -164,30 +132,20 @@ impl Context<'_> { pub fn count(&self) -> usize { self.count.map_or(1, |v| v.get()) } - - /// Waits on all pending jobs, and then tries to flush all pending write - /// operations for all documents. - pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { - compositor::Context { - editor: self.editor, - jobs: self.jobs, - scroll: None, - } - .block_try_flush_writes() - } } #[inline] fn make_job_callback<T, F>( - call: impl Future<Output = helix_lsp::Result<T>> + 'static + Send, + call: impl Future<Output = helix_lsp::Result<serde_json::Value>> + 'static + Send, callback: F, ) -> std::pin::Pin<Box<impl Future<Output = Result<Callback, anyhow::Error>>>> where - T: Send + 'static, + T: for<'de> serde::Deserialize<'de> + Send + 'static, F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, { Box::pin(async move { - let response = call.await?; + let json = call.await?; + let response = serde_json::from_value(json)?; let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { callback(editor, compositor, response) @@ -199,21 +157,14 @@ where use helix_view::{align_view, Align}; -/// MappableCommands are commands that can be bound to keys, executable in -/// normal, insert or select mode. -/// -/// There are three kinds: -/// -/// * Static: commands usually bound to keys and used for editing, movement, -/// etc., for example `move_char_left`. -/// * Typable: commands executable from command mode, prefixed with a `:`, -/// for example `:write!`. -/// * Macro: a sequence of keys to execute, for example `@miw`. +/// A MappableCommand is either a static command like "jump_view_up" or a Typable command like +/// :format. It causes a side-effect on the state (usually by creating and applying a transaction). +/// Both of these types of commands can be mapped with keybindings in the config.toml. #[derive(Clone)] pub enum MappableCommand { Typable { name: String, - args: String, + args: Vec<String>, doc: String, }, Static { @@ -221,9 +172,10 @@ pub enum MappableCommand { fun: fn(cx: &mut Context), doc: &'static str, }, - Macro { - name: String, - keys: Vec<KeyEvent>, + Component { + name: &'static str, + fun: fn(&mut dyn crate::compositor::Component, &mut compositor::Context) -> EventResult, + doc: &'static str, }, } @@ -248,39 +200,21 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { + let args: Vec<Cow<str>> = args.iter().map(Cow::from).collect(); if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { + keymaps: cx.keymaps, editor: cx.editor, jobs: cx.jobs, scroll: None, }; - if let Err(e) = - typed::execute_command(&mut cx, command, args, PromptEvent::Validate) - { + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { cx.editor.set_error(format!("{}", e)); } - } else { - cx.editor.set_error(format!("no such command: '{name}'")); } } Self::Static { fun, .. } => (fun)(cx), - Self::Macro { keys, .. } => { - // Protect against recursive macros. - if cx.editor.macro_replaying.contains(&'@') { - cx.editor.set_error( - "Cannot execute macro because the [@] register is already playing a macro", - ); - return; - } - cx.editor.macro_replaying.push('@'); - let keys = keys.clone(); - cx.callback.push(Box::new(move |compositor, cx| { - for key in keys.into_iter() { - compositor.handle_event(&compositor::Event::Key(key), cx); - } - cx.editor.macro_replaying.pop(); - })); - } + Self::Component { .. } => unimplemented!(), } } @@ -288,7 +222,7 @@ impl MappableCommand { match &self { Self::Typable { name, .. } => name, Self::Static { name, .. } => name, - Self::Macro { name, .. } => name, + Self::Component { .. } => unimplemented!(), } } @@ -296,10 +230,18 @@ impl MappableCommand { match &self { Self::Typable { doc, .. } => doc, Self::Static { doc, .. } => doc, - Self::Macro { name, .. } => name, + Self::Component { .. } => unimplemented!(), } } + // TODO: macro for this... + #[allow(non_upper_case_globals)] + pub const close_buffer_in_buffer_picker: Self = Self::Component { + name: "close_buffer_in_buffer_picker", + fun: crate::ui::picker::close_buffer_in_buffer_picker, + doc: "Closes the currently focused buffer", + }; + #[rustfmt::skip] static_commands!( no_op, "Do nothing", @@ -325,12 +267,6 @@ impl MappableCommand { move_prev_long_word_start, "Move to start of previous long word", move_next_long_word_end, "Move to end of next long word", move_prev_long_word_end, "Move to end of previous long word", - move_next_sub_word_start, "Move to start of next sub word", - move_prev_sub_word_start, "Move to start of previous sub word", - move_next_sub_word_end, "Move to end of next sub word", - move_prev_sub_word_end, "Move to end of previous sub word", - move_parent_node_end, "Move to end of the parent node", - move_parent_node_start, "Move to beginning of the parent node", extend_next_word_start, "Extend to start of next word", extend_prev_word_start, "Extend to start of previous word", extend_next_word_end, "Extend to end of next word", @@ -339,12 +275,6 @@ impl MappableCommand { extend_prev_long_word_start, "Extend to start of previous long word", extend_next_long_word_end, "Extend to end of next long word", extend_prev_long_word_end, "Extend to end of prev long word", - extend_next_sub_word_start, "Extend to start of next sub word", - extend_prev_sub_word_start, "Extend to start of previous sub word", - extend_next_sub_word_end, "Extend to end of next sub word", - extend_prev_sub_word_end, "Extend to end of prev sub word", - extend_parent_node_end, "Extend to end of the parent node", - extend_parent_node_start, "Extend to beginning of the parent node", find_till_char, "Move till next occurrence of char", find_next_char, "Move to next occurrence of char", extend_till_char, "Extend till next occurrence of char", @@ -362,10 +292,6 @@ impl MappableCommand { page_down, "Move page down", half_page_up, "Move half page up", half_page_down, "Move half page down", - page_cursor_up, "Move page and cursor up", - page_cursor_down, "Move page and cursor down", - page_cursor_half_up, "Move page and cursor half up", - page_cursor_half_down, "Move page and cursor half down", select_all, "Select whole document", select_regex, "Select all regex matches inside selections", split_selection, "Split selections on regex matches", @@ -379,14 +305,11 @@ impl MappableCommand { extend_search_next, "Add next search match to selection", extend_search_prev, "Add previous search match to selection", search_selection, "Use current selection as search pattern", - search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries", make_search_word_bounded, "Modify current search to make it word bounded", global_search, "Global search in workspace folder", extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", extend_line_above, "Select current line, if already selected, extend to previous line", - select_line_above, "Select current line, if already selected, extend or shrink line above based on the anchor", - select_line_below, "Select current line, if already selected, extend or shrink line below based on the anchor", extend_to_line_bounds, "Extend selection to line bounds", shrink_to_line_bounds, "Shrink selection to line bounds", delete_selection, "Delete selection", @@ -400,22 +323,14 @@ impl MappableCommand { append_mode, "Append after selection", command_mode, "Enter command mode", file_picker, "Open file picker", - file_picker_in_current_buffer_directory, "Open file picker at current buffer's directory", + file_picker_in_current_buffer_directory, "Open file picker at current buffers's directory", file_picker_in_current_directory, "Open file picker at current working directory", - file_explorer, "Open file explorer in workspace root", - file_explorer_in_current_buffer_directory, "Open file explorer at current buffer's directory", - file_explorer_in_current_directory, "Open file explorer at current working directory", code_action, "Perform code action", buffer_picker, "Open buffer picker", jumplist_picker, "Open jumplist picker", symbol_picker, "Open symbol picker", - syntax_symbol_picker, "Open symbol picker from syntax information", - lsp_or_syntax_symbol_picker, "Open symbol picker from LSP or syntax information", - changed_file_picker, "Open changed file picker", select_references_to_symbol_under_cursor, "Select symbol references", workspace_symbol_picker, "Open workspace symbol picker", - syntax_workspace_symbol_picker, "Open workspace symbol picker from syntax information", - lsp_or_syntax_workspace_symbol_picker, "Open workspace symbol picker from LSP or syntax information", diagnostics_picker, "Open diagnostic picker", workspace_diagnostics_picker, "Open workspace diagnostic picker", last_picker, "Open last picker", @@ -434,11 +349,9 @@ impl MappableCommand { goto_implementation, "Goto implementation", goto_file_start, "Goto line number <n> else file start", goto_file_end, "Goto file end", - extend_to_file_start, "Extend to line number<n> else file start", - extend_to_file_end, "Extend to file end", - goto_file, "Goto files/URLs in selections", - goto_file_hsplit, "Goto files in selections (hsplit)", - goto_file_vsplit, "Goto files in selections (vsplit)", + goto_file, "Goto files in selection", + goto_file_hsplit, "Goto files in selection (hsplit)", + goto_file_vsplit, "Goto files in selection (vsplit)", goto_reference, "Goto references", goto_window_top, "Goto window top", goto_window_center, "Goto window center", @@ -448,7 +361,6 @@ impl MappableCommand { goto_last_modification, "Goto last modification", goto_line, "Goto line", goto_last_line, "Goto last line", - extend_to_last_line, "Extend to last line", goto_first_diag, "Goto first diagnostic", goto_last_diag, "Goto last diagnostic", goto_next_diag, "Goto next diagnostic", @@ -459,8 +371,6 @@ impl MappableCommand { goto_last_change, "Goto last change", goto_line_start, "Goto line start", goto_line_end, "Goto line end", - goto_column, "Goto column", - extend_to_column, "Extend to column", goto_next_buffer, "Goto next buffer", goto_previous_buffer, "Goto previous buffer", goto_line_end_newline, "Goto newline at line end", @@ -471,11 +381,8 @@ impl MappableCommand { extend_to_line_end, "Extend to line end", extend_to_line_end_newline, "Extend to line end", signature_help, "Show signature help", - smart_tab, "Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command.", insert_tab, "Insert tab char", insert_newline, "Insert newline char", - insert_char_interactive, "Insert an interactively-chosen char", - append_char_interactive, "Append an interactively-chosen char", delete_char_backward, "Delete previous char", delete_char_forward, "Delete next char", delete_word_backward, "Delete previous word", @@ -488,8 +395,6 @@ impl MappableCommand { later, "Move forward in history", commit_undo_checkpoint, "Commit changes to new checkpoint", yank, "Yank selection", - yank_to_clipboard, "Yank selections to clipboard", - yank_to_primary_clipboard, "Yank selections to primary clipboard", yank_joined, "Join and yank selections", yank_joined_to_clipboard, "Join and yank selections to clipboard", yank_main_selection_to_clipboard, "Yank main selection to clipboard", @@ -517,8 +422,6 @@ impl MappableCommand { completion, "Invoke completion popup", hover, "Show docs for item under cursor", toggle_comments, "Comment/uncomment selections", - toggle_line_comments, "Line comment/uncomment selections", - toggle_block_comments, "Block comment/uncomment selections", rotate_selections_forward, "Rotate selections forward", rotate_selections_backward, "Rotate selections backward", rotate_selection_contents_forward, "Rotate selection contents forward", @@ -526,10 +429,8 @@ impl MappableCommand { reverse_selection_contents, "Reverse selections contents", expand_selection, "Expand selection to parent syntax node", shrink_selection, "Shrink selection to previously expanded syntax node", - select_next_sibling, "Select next sibling in the syntax tree", - select_prev_sibling, "Select previous sibling the in syntax tree", - select_all_siblings, "Select all siblings of the current node", - select_all_children, "Select all children of the current node", + select_next_sibling, "Select next sibling in syntax tree", + select_prev_sibling, "Select previous sibling in syntax tree", jump_forward, "Jump forward on jumplist", jump_backward, "Jump backward on jumplist", save_selection, "Save current selection to jumplist", @@ -552,7 +453,6 @@ impl MappableCommand { wonly, "Close windows except current", select_register, "Select register", insert_register, "Insert register", - copy_between_registers, "Copy between two registers", align_view_middle, "Align view middle", align_view_top, "Align view top", align_view_center, "Align view center", @@ -575,10 +475,6 @@ impl MappableCommand { goto_prev_comment, "Goto previous comment", goto_next_test, "Goto next test", goto_prev_test, "Goto previous test", - goto_next_xml_element, "Goto next (X)HTML element", - goto_prev_xml_element, "Goto previous (X)HTML element", - goto_next_entry, "Goto next pairing", - goto_prev_entry, "Goto previous pairing", goto_next_paragraph, "Goto next paragraph", goto_prev_paragraph, "Goto previous paragraph", dap_launch, "Launch debug target", @@ -609,12 +505,6 @@ impl MappableCommand { record_macro, "Record macro", replay_macro, "Replay macro", command_palette, "Open command palette", - goto_word, "Jump to a two-character label", - extend_to_word, "Extend to a two-character label", - goto_next_tabstop, "Goto next snippet placeholder", - goto_prev_tabstop, "Goto next snippet placeholder", - rotate_selections_first, "Make the first selection your primary one", - rotate_selections_last, "Make the last selection your primary one", ); } @@ -629,11 +519,7 @@ impl fmt::Debug for MappableCommand { .field(name) .field(args) .finish(), - MappableCommand::Macro { name, keys, .. } => f - .debug_tuple("MappableCommand") - .field(name) - .field(keys) - .finish(), + Self::Component { .. } => unimplemented!(), } } } @@ -649,28 +535,21 @@ impl std::str::FromStr for MappableCommand { fn from_str(s: &str) -> Result<Self, Self::Err> { if let Some(suffix) = s.strip_prefix(':') { - let (name, args, _) = command_line::split(suffix); - ensure!(!name.is_empty(), "Expected typable command name"); + let mut typable_command = suffix.split(' ').map(|arg| arg.trim()); + let name = typable_command + .next() + .ok_or_else(|| anyhow!("Expected typable command name"))?; + let args = typable_command + .map(|s| s.to_owned()) + .collect::<Vec<String>>(); typed::TYPABLE_COMMAND_MAP .get(name) - .map(|cmd| { - let doc = if args.is_empty() { - cmd.doc.to_string() - } else { - format!(":{} {:?}", cmd.name, args) - }; - MappableCommand::Typable { - name: cmd.name.to_owned(), - doc, - args: args.to_string(), - } + .map(|cmd| MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: format!(":{} {:?}", cmd.name, args), + args, }) .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) - } else if let Some(suffix) = s.strip_prefix('@') { - helix_view::input::parse_macro(suffix).map(|keys| Self::Macro { - name: s.to_string(), - keys, - }) } else { MappableCommand::STATIC_COMMAND_LIST .iter() @@ -742,7 +621,6 @@ fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movem &mut annotations, ) }); - drop(annotations); doc.set_selection(view.id, selection); } @@ -906,29 +784,28 @@ fn goto_line_start(cx: &mut Context) { } fn goto_next_buffer(cx: &mut Context) { - goto_buffer(cx.editor, Direction::Forward, cx.count()); + goto_buffer(cx.editor, Direction::Forward); } fn goto_previous_buffer(cx: &mut Context) { - goto_buffer(cx.editor, Direction::Backward, cx.count()); + goto_buffer(cx.editor, Direction::Backward); } -fn goto_buffer(editor: &mut Editor, direction: Direction, count: usize) { +fn goto_buffer(editor: &mut Editor, direction: Direction) { let current = view!(editor).doc; let id = match direction { Direction::Forward => { let iter = editor.documents.keys(); - // skip 'count' times past current buffer - iter.cycle().skip_while(|id| *id != ¤t).nth(count) + let mut iter = iter.skip_while(|id| *id != ¤t); + iter.next(); // skip current item + iter.next().or_else(|| editor.documents.keys().next()) } Direction::Backward => { let iter = editor.documents.keys(); - // skip 'count' times past current buffer - iter.rev() - .cycle() - .skip_while(|id| *id != ¤t) - .nth(count) + let mut iter = iter.rev().skip_while(|id| *id != ¤t); + iter.next(); // skip current item + iter.next().or_else(|| editor.documents.keys().rev().next()) } } .unwrap(); @@ -953,7 +830,7 @@ fn kill_to_line_start(cx: &mut Context) { let head = if anchor == first_char && line != 0 { // select until previous line line_end_char_index(&text, line - 1) - } else if let Some(pos) = text.line(line).first_non_whitespace_char() { + } else if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { if first_char + pos < anchor { // select until first non-blank in line if cursor is after it first_char + pos @@ -1015,7 +892,7 @@ fn goto_first_nonwhitespace_impl(view: &mut View, doc: &mut Document, movement: let selection = doc.selection(view.id).clone().transform(|range| { let line = range.cursor_line(text); - if let Some(pos) = text.line(line).first_non_whitespace_char() { + if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { let pos = pos + text.line_to_char(line); range.put_cursor(text, pos, movement == Movement::Extend) } else { @@ -1123,14 +1000,12 @@ fn align_selections(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); doc.apply(&transaction, view.id); - exit_select_mode(cx); } fn goto_window(cx: &mut Context, align: Align) { let count = cx.count() - 1; let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - let view_offset = doc.view_offset(view.id); let height = view.inner_height(); @@ -1143,15 +1018,15 @@ fn goto_window(cx: &mut Context, align: Align) { let last_visual_line = view.last_visual_line(doc); let visual_line = match align { - Align::Top => view_offset.vertical_offset + scrolloff + count, - Align::Center => view_offset.vertical_offset + (last_visual_line / 2), + Align::Top => view.offset.vertical_offset + scrolloff + count, + Align::Center => view.offset.vertical_offset + (last_visual_line / 2), Align::Bottom => { - view_offset.vertical_offset + last_visual_line.saturating_sub(scrolloff + count) + view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff + count) } }; let visual_line = visual_line - .max(view_offset.vertical_offset + scrolloff) - .min(view_offset.vertical_offset + last_visual_line.saturating_sub(scrolloff)); + .max(view.offset.vertical_offset + scrolloff) + .min(view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff)); let pos = view .pos_at_visual_coords(doc, visual_line as u16, 0, false) @@ -1224,22 +1099,6 @@ fn move_next_long_word_end(cx: &mut Context) { move_word_impl(cx, movement::move_next_long_word_end) } -fn move_next_sub_word_start(cx: &mut Context) { - move_word_impl(cx, movement::move_next_sub_word_start) -} - -fn move_prev_sub_word_start(cx: &mut Context) { - move_word_impl(cx, movement::move_prev_sub_word_start) -} - -fn move_prev_sub_word_end(cx: &mut Context) { - move_word_impl(cx, movement::move_prev_sub_word_end) -} - -fn move_next_sub_word_end(cx: &mut Context) { - move_word_impl(cx, movement::move_next_sub_word_end) -} - fn goto_para_impl<F>(cx: &mut Context, move_fn: F) where F: Fn(RopeSlice, Range, usize, Movement) -> Range + 'static, @@ -1260,7 +1119,8 @@ where .transform(|range| move_fn(text, range, count, behavior)); doc.set_selection(view.id, selection); }; - cx.editor.apply_motion(motion) + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } fn goto_prev_paragraph(cx: &mut Context) { @@ -1272,44 +1132,28 @@ fn goto_next_paragraph(cx: &mut Context) { } fn goto_file_start(cx: &mut Context) { - goto_file_start_impl(cx, Movement::Move); -} - -fn extend_to_file_start(cx: &mut Context) { - goto_file_start_impl(cx, Movement::Extend); -} - -fn goto_file_start_impl(cx: &mut Context, movement: Movement) { if cx.count.is_some() { - goto_line_impl(cx, movement); + goto_line(cx); } else { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc .selection(view.id) .clone() - .transform(|range| range.put_cursor(text, 0, movement == Movement::Extend)); + .transform(|range| range.put_cursor(text, 0, cx.editor.mode == Mode::Select)); push_jump(view, doc); doc.set_selection(view.id, selection); } } fn goto_file_end(cx: &mut Context) { - goto_file_end_impl(cx, Movement::Move); -} - -fn extend_to_file_end(cx: &mut Context) { - goto_file_end_impl(cx, Movement::Extend) -} - -fn goto_file_end_impl(cx: &mut Context, movement: Movement) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let pos = doc.text().len_chars(); let selection = doc .selection(view.id) .clone() - .transform(|range| range.put_cursor(text, pos, movement == Movement::Extend)); + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); push_jump(view, doc); doc.set_selection(view.id, selection); } @@ -1329,93 +1173,39 @@ fn goto_file_vsplit(cx: &mut Context) { /// Goto files in selection. fn goto_file_impl(cx: &mut Context, action: Action) { let (view, doc) = current_ref!(cx.editor); - let text = doc.text().slice(..); + let text = doc.text(); let selections = doc.selection(view.id); + let mut paths: Vec<_> = selections + .iter() + .map(|r| text.slice(r.from()..r.to()).to_string()) + .collect(); let primary = selections.primary(); - let rel_path = doc - .relative_path() - .map(|path| path.parent().unwrap().to_path_buf()) - .unwrap_or_default(); - - let paths: Vec<_> = if selections.len() == 1 && primary.len() == 1 { - // Cap the search at roughly 1k bytes around the cursor. - let lookaround = 1000; - let pos = text.char_to_byte(primary.cursor(text)); - let search_start = text - .line_to_byte(text.byte_to_line(pos)) - .max(text.floor_char_boundary(pos.saturating_sub(lookaround))); - let search_end = text - .line_to_byte(text.byte_to_line(pos) + 1) - .min(text.ceil_char_boundary(pos + lookaround)); - let search_range = text.byte_slice(search_start..search_end); - // we also allow paths that are next to the cursor (can be ambiguous but - // rarely so in practice) so that gf on quoted/braced path works (not sure about this - // but apparently that is how gf has worked historically in helix) - let path = find_paths(search_range, true) - .take_while(|range| search_start + range.start <= pos + 1) - .find(|range| pos <= search_start + range.end) - .map(|range| Cow::from(search_range.byte_slice(range))); - log::debug!("goto_file auto-detected path: {path:?}"); - let path = path.unwrap_or_else(|| primary.fragment(text)); - vec![path.into_owned()] - } else { - // Otherwise use each selection, trimmed. - selections - .fragments(text) - .map(|sel| sel.trim().to_owned()) - .filter(|sel| !sel.is_empty()) - .collect() - }; - - for sel in paths { - if let Ok(url) = Url::parse(&sel) { - open_url(cx, url, action); - continue; - } - - let path = path::expand(&sel); - let path = &rel_path.join(path); - if path.is_dir() { - let picker = ui::file_picker(cx.editor, path.into()); - cx.push_layer(Box::new(overlaid(picker))); - } else if let Err(e) = cx.editor.open(path, action) { - cx.editor.set_error(format!("Open file failed: {:?}", e)); - } - } -} - -/// Opens the given url. If the URL points to a valid textual file it is open in helix. -// Otherwise, the file is open using external program. -fn open_url(cx: &mut Context, url: Url, action: Action) { - let doc = doc!(cx.editor); - let rel_path = doc - .relative_path() - .map(|path| path.parent().unwrap().to_path_buf()) - .unwrap_or_default(); - - if url.scheme() != "file" { - return cx.jobs.callback(crate::open_external_url_callback(url)); + // Checks whether there is only one selection with a width of 1 + if selections.len() == 1 && primary.len() == 1 { + let count = cx.count(); + let text_slice = text.slice(..); + // In this case it selects the WORD under the cursor + let current_word = textobject::textobject_word( + text_slice, + primary, + textobject::TextObject::Inside, + count, + true, + ); + // Trims some surrounding chars so that the actual file is opened. + let surrounding_chars: &[_] = &['\'', '"', '(', ')']; + paths.clear(); + paths.push( + current_word + .fragment(text_slice) + .trim_matches(surrounding_chars) + .to_string(), + ); } - - let content_type = std::fs::File::open(url.path()).and_then(|file| { - // Read up to 1kb to detect the content type - let mut read_buffer = Vec::new(); - let n = file.take(1024).read_to_end(&mut read_buffer)?; - Ok(content_inspector::inspect(&read_buffer[..n])) - }); - - // we attempt to open binary files - files that can't be open in helix - using external - // program as well, e.g. pdf files or images - match content_type { - Ok(content_inspector::ContentType::BINARY) => { - cx.jobs.callback(crate::open_external_url_callback(url)) - } - Ok(_) | Err(_) => { - let path = &rel_path.join(url.path()); - if path.is_dir() { - let picker = ui::file_picker(cx.editor, path.into()); - cx.push_layer(Box::new(overlaid(picker))); - } else if let Err(e) = cx.editor.open(path, action) { + for sel in paths { + let p = sel.trim(); + if !p.is_empty() { + if let Err(e) = cx.editor.open(&PathBuf::from(p), action) { cx.editor.set_error(format!("Open file failed: {:?}", e)); } } @@ -1470,82 +1260,10 @@ fn extend_next_long_word_end(cx: &mut Context) { extend_word_impl(cx, movement::move_next_long_word_end) } -fn extend_next_sub_word_start(cx: &mut Context) { - extend_word_impl(cx, movement::move_next_sub_word_start) -} - -fn extend_prev_sub_word_start(cx: &mut Context) { - extend_word_impl(cx, movement::move_prev_sub_word_start) -} - -fn extend_prev_sub_word_end(cx: &mut Context) { - extend_word_impl(cx, movement::move_prev_sub_word_end) -} - -fn extend_next_sub_word_end(cx: &mut Context) { - extend_word_impl(cx, movement::move_next_sub_word_end) -} - -/// Separate branch to find_char designed only for `<ret>` char. -// -// This is necessary because the one document can have different line endings inside. And we -// cannot predict what character to find when <ret> is pressed. On the current line it can be `lf` -// but on the next line it can be `crlf`. That's why [`find_char_impl`] cannot be applied here. -fn find_char_line_ending( - cx: &mut Context, - count: usize, - direction: Direction, - inclusive: bool, - extend: bool, -) { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - let selection = doc.selection(view.id).clone().transform(|range| { - let cursor = range.cursor(text); - let cursor_line = range.cursor_line(text); - - // Finding the line where we're going to find <ret>. Depends mostly on - // `count`, but also takes into account edge cases where we're already at the end - // of a line or the beginning of a line - let find_on_line = match direction { - Direction::Forward => { - let on_edge = line_end_char_index(&text, cursor_line) == cursor; - let line = cursor_line + count - 1 + (on_edge as usize); - if line >= text.len_lines() - 1 { - return range; - } else { - line - } - } - Direction::Backward => { - let on_edge = text.line_to_char(cursor_line) == cursor && !inclusive; - let line = cursor_line as isize - (count as isize - 1 + on_edge as isize); - if line <= 0 { - return range; - } else { - line as usize - } - } - }; - - let pos = match (direction, inclusive) { - (Direction::Forward, true) => line_end_char_index(&text, find_on_line), - (Direction::Forward, false) => line_end_char_index(&text, find_on_line) - 1, - (Direction::Backward, true) => line_end_char_index(&text, find_on_line - 1), - (Direction::Backward, false) => text.line_to_char(find_on_line), - }; - - if extend { - range.put_cursor(text, pos, true) - } else { - Range::point(range.cursor(text)).put_cursor(text, pos, true) - } - }); - doc.set_selection(view.id, selection); -} - -fn find_char(cx: &mut Context, direction: Direction, inclusive: bool, extend: bool) { +fn will_find_char<F>(cx: &mut Context, search_fn: F, inclusive: bool, extend: bool) +where + F: Fn(RopeSlice, char, usize, usize, bool) -> Option<usize> + 'static, +{ // TODO: count is reset to 1 before next key so we move it into the closure here. // Would be nice to carry over. let count = cx.count(); @@ -1558,9 +1276,13 @@ fn find_char(cx: &mut Context, direction: Direction, inclusive: bool, extend: bo KeyEvent { code: KeyCode::Enter, .. - } => { - find_char_line_ending(cx, count, direction, inclusive, extend); - return; + } => + // TODO: this isn't quite correct when CRLF is involved. + // This hack will work in most cases, since documents don't + // usually mix line endings. But we should fix it eventually + // anyway. + { + doc!(cx.editor).line_ending.as_str().chars().next().unwrap() } KeyEvent { @@ -1573,18 +1295,11 @@ fn find_char(cx: &mut Context, direction: Direction, inclusive: bool, extend: bo } => ch, _ => return, }; - let motion = move |editor: &mut Editor| { - match direction { - Direction::Forward => { - find_char_impl(editor, &find_next_char_impl, inclusive, extend, ch, count) - } - Direction::Backward => { - find_char_impl(editor, &find_prev_char_impl, inclusive, extend, ch, count) - } - }; - }; - cx.editor.apply_motion(motion); + find_char_impl(cx.editor, &search_fn, inclusive, extend, ch, count); + cx.editor.last_motion = Some(Motion(Box::new(move |editor: &mut Editor| { + find_char_impl(editor, &search_fn, inclusive, extend, ch, 1); + }))); }) } @@ -1663,39 +1378,46 @@ fn find_prev_char_impl( } fn find_till_char(cx: &mut Context) { - find_char(cx, Direction::Forward, false, false); + will_find_char(cx, find_next_char_impl, false, false) } fn find_next_char(cx: &mut Context) { - find_char(cx, Direction::Forward, true, false) + will_find_char(cx, find_next_char_impl, true, false) } fn extend_till_char(cx: &mut Context) { - find_char(cx, Direction::Forward, false, true) + will_find_char(cx, find_next_char_impl, false, true) } fn extend_next_char(cx: &mut Context) { - find_char(cx, Direction::Forward, true, true) + will_find_char(cx, find_next_char_impl, true, true) } fn till_prev_char(cx: &mut Context) { - find_char(cx, Direction::Backward, false, false) + will_find_char(cx, find_prev_char_impl, false, false) } fn find_prev_char(cx: &mut Context) { - find_char(cx, Direction::Backward, true, false) + will_find_char(cx, find_prev_char_impl, true, false) } fn extend_till_prev_char(cx: &mut Context) { - find_char(cx, Direction::Backward, false, true) + will_find_char(cx, find_prev_char_impl, false, true) } fn extend_prev_char(cx: &mut Context) { - find_char(cx, Direction::Backward, true, true) + will_find_char(cx, find_prev_char_impl, true, true) } fn repeat_last_motion(cx: &mut Context) { - cx.editor.repeat_last_motion(cx.count()) + let count = cx.count(); + let last_motion = cx.editor.last_motion.take(); + if let Some(m) = &last_motion { + for _ in 0..count { + m.run(cx.editor); + } + cx.editor.last_motion = last_motion; + } } fn replace(cx: &mut Context) { @@ -1724,13 +1446,19 @@ fn replace(cx: &mut Context) { if let Some(ch) = ch { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { if !range.is_empty() { - let text: Tendril = doc - .text() - .slice(range.from()..range.to()) - .graphemes() - .map(|_g| ch) - .collect(); - (range.from(), range.to(), Some(text)) + let text: String = + RopeGraphemes::new(doc.text().slice(range.from()..range.to())) + .map(|g| { + let cow: Cow<str> = g.into(); + if str_is_line_ending(&cow) { + cow + } else { + ch.into() + } + }) + .collect(); + + (range.from(), range.to(), Some(text.into())) } else { // No change. (range.from(), range.to(), None) @@ -1756,51 +1484,19 @@ where }); doc.apply(&transaction, view.id); - exit_select_mode(cx); -} - -enum CaseSwitcher { - Upper(ToUppercase), - Lower(ToLowercase), - Keep(Option<char>), } -impl Iterator for CaseSwitcher { - type Item = char; - - fn next(&mut self) -> Option<Self::Item> { - match self { - CaseSwitcher::Upper(upper) => upper.next(), - CaseSwitcher::Lower(lower) => lower.next(), - CaseSwitcher::Keep(ch) => ch.take(), - } - } - - fn size_hint(&self) -> (usize, Option<usize>) { - match self { - CaseSwitcher::Upper(upper) => upper.size_hint(), - CaseSwitcher::Lower(lower) => lower.size_hint(), - CaseSwitcher::Keep(ch) => { - let n = if ch.is_some() { 1 } else { 0 }; - (n, Some(n)) - } - } - } -} - -impl ExactSizeIterator for CaseSwitcher {} - fn switch_case(cx: &mut Context) { switch_case_impl(cx, |string| { string .chars() .flat_map(|ch| { if ch.is_lowercase() { - CaseSwitcher::Upper(ch.to_uppercase()) + ch.to_uppercase().collect() } else if ch.is_uppercase() { - CaseSwitcher::Lower(ch.to_lowercase()) + ch.to_lowercase().collect() } else { - CaseSwitcher::Keep(Some(ch)) + vec![ch] } }) .collect() @@ -1819,11 +1515,10 @@ fn switch_to_lowercase(cx: &mut Context) { }); } -pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor: bool) { +pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { use Direction::*; let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - let mut view_offset = doc.view_offset(view.id); let range = doc.selection(view.id).primary(); let text = doc.text().slice(..); @@ -1840,46 +1535,15 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor let doc_text = doc.text().slice(..); let viewport = view.inner_area(doc); let text_fmt = doc.text_format(viewport.width, None); - (view_offset.anchor, view_offset.vertical_offset) = char_idx_at_visual_offset( + let annotations = view.text_annotations(doc, None); + (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( doc_text, - view_offset.anchor, - view_offset.vertical_offset as isize + offset, + view.offset.anchor, + view.offset.vertical_offset as isize + offset, 0, &text_fmt, - // &annotations, - &view.text_annotations(&*doc, None), + &annotations, ); - doc.set_view_offset(view.id, view_offset); - - let doc_text = doc.text().slice(..); - let mut annotations = view.text_annotations(&*doc, None); - - if sync_cursor { - let movement = match cx.editor.mode { - Mode::Select => Movement::Extend, - _ => Movement::Move, - }; - // TODO: When inline diagnostics gets merged- 1. move_vertically_visual removes - // line annotations/diagnostics so the cursor may jump further than the view. - // 2. If the cursor lands on a complete line of virtual text, the cursor will - // jump a different distance than the view. - let selection = doc.selection(view.id).clone().transform(|range| { - move_vertically_visual( - doc_text, - range, - direction, - offset.unsigned_abs(), - movement, - &text_fmt, - &mut annotations, - ) - }); - drop(annotations); - doc.set_selection(view.id, selection); - return; - } - - let view_offset = doc.view_offset(view.id); let mut head; match direction { @@ -1887,8 +1551,8 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor let off; (head, off) = char_idx_at_visual_offset( doc_text, - view_offset.anchor, - (view_offset.vertical_offset + scrolloff) as isize, + view.offset.anchor, + (view.offset.vertical_offset + scrolloff) as isize, 0, &text_fmt, &annotations, @@ -1901,8 +1565,8 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor Backward => { head = char_idx_at_visual_offset( doc_text, - view_offset.anchor, - (view_offset.vertical_offset + height - scrolloff - 1) as isize, + view.offset.anchor, + (view.offset.vertical_offset + height - scrolloff - 1) as isize, 0, &text_fmt, &annotations, @@ -1925,56 +1589,31 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor let mut sel = doc.selection(view.id).clone(); let idx = sel.primary_index(); sel = sel.replace(idx, prim_sel); - drop(annotations); doc.set_selection(view.id, sel); } fn page_up(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height(); - scroll(cx, offset, Direction::Backward, false); + scroll(cx, offset, Direction::Backward); } fn page_down(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height(); - scroll(cx, offset, Direction::Forward, false); + scroll(cx, offset, Direction::Forward); } fn half_page_up(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Backward, false); + scroll(cx, offset, Direction::Backward); } fn half_page_down(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Forward, false); -} - -fn page_cursor_up(cx: &mut Context) { - let view = view!(cx.editor); - let offset = view.inner_height(); - scroll(cx, offset, Direction::Backward, true); -} - -fn page_cursor_down(cx: &mut Context) { - let view = view!(cx.editor); - let offset = view.inner_height(); - scroll(cx, offset, Direction::Forward, true); -} - -fn page_cursor_half_up(cx: &mut Context) { - let view = view!(cx.editor); - let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Backward, true); -} - -fn page_cursor_half_down(cx: &mut Context) { - let view = view!(cx.editor); - let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Forward, true); + scroll(cx, offset, Direction::Forward); } #[allow(deprecated)] @@ -2086,8 +1725,8 @@ fn select_regex(cx: &mut Context) { "select:".into(), Some(reg), ui::completers::none, - move |cx, regex, event| { - let (view, doc) = current!(cx.editor); + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -2096,8 +1735,6 @@ fn select_regex(cx: &mut Context) { selection::select_on_matches(text, doc.selection(view.id), ®ex) { doc.set_selection(view.id, selection); - } else { - cx.editor.set_error("nothing selected"); } }, ); @@ -2110,8 +1747,8 @@ fn split_selection(cx: &mut Context) { "split:".into(), Some(reg), ui::completers::none, - move |cx, regex, event| { - let (view, doc) = current!(cx.editor); + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -2125,7 +1762,11 @@ fn split_selection(cx: &mut Context) { fn split_selection_on_newline(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - let selection = selection::split_on_newline(text, doc.selection(view.id)); + // only compile the regex once + #[allow(clippy::trivial_regex)] + static REGEX: Lazy<Regex> = + Lazy::new(|| Regex::new(r"\r\n|[\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}]").unwrap()); + let selection = selection::split_on_matches(text, doc.selection(view.id), ®EX); doc.set_selection(view.id, selection); } @@ -2144,7 +1785,8 @@ fn merge_consecutive_selections(cx: &mut Context) { #[allow(clippy::too_many_arguments)] fn search_impl( editor: &mut Editor, - regex: &rope::Regex, + contents: &str, + regex: &Regex, movement: Movement, direction: Direction, scrolloff: usize, @@ -2172,20 +1814,23 @@ fn search_impl( // do a reverse search and wraparound to the end, we don't need to search // the text before the current cursor position for matches, but by slicing // it out, we need to add it back to the position of the selection. - let doc = doc!(editor).text().slice(..); + let mut offset = 0; // use find_at to find the next match after the cursor, loop around the end // Careful, `Regex` uses `bytes` as offsets, not character indices! let mut mat = match direction { - Direction::Forward => regex.find(doc.regex_input_at_bytes(start..)), - Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(..start)).last(), + Direction::Forward => regex.find_at(contents, start), + Direction::Backward => regex.find_iter(&contents[..start]).last(), }; if mat.is_none() { if wrap_around { mat = match direction { - Direction::Forward => regex.find(doc.regex_input()), - Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(start..)).last(), + Direction::Forward => regex.find(contents), + Direction::Backward => { + offset = start; + regex.find_iter(&contents[start..]).last() + } }; } if show_warnings { @@ -2202,8 +1847,8 @@ fn search_impl( let selection = doc.selection(view.id); if let Some(mat) = mat { - let start = text.byte_to_char(mat.start()); - let end = text.byte_to_char(mat.end()); + let start = text.byte_to_char(mat.start() + offset); + let end = text.byte_to_char(mat.end() + offset); if end == 0 { // skip empty matches that don't make sense @@ -2226,11 +1871,11 @@ fn search_impl( fn search_completions(cx: &mut Context, reg: Option<char>) -> Vec<String> { let mut items = reg - .and_then(|reg| cx.editor.registers.read(reg, cx.editor)) - .map_or(Vec::new(), |reg| reg.take(200).collect()); + .and_then(|reg| cx.editor.registers.get(reg)) + .map_or(Vec::new(), |reg| reg.read().iter().take(200).collect()); items.sort_unstable(); items.dedup(); - items.into_iter().map(|value| value.to_string()).collect() + items.into_iter().cloned().collect() } fn search(cx: &mut Context) { @@ -2246,13 +1891,14 @@ fn searcher(cx: &mut Context, direction: Direction) { let config = cx.editor.config(); let scrolloff = config.scrolloff; let wrap_around = config.search.wrap_around; - let movement = if cx.editor.mode() == Mode::Select { - Movement::Extend - } else { - Movement::Move - }; + + let doc = doc!(cx.editor); // TODO: could probably share with select_on_matches? + + // HAXX: sadly we can't avoid allocating a single string for the whole buffer since we can't + // feed chunks into the regex yet + let contents = doc.text().slice(..).to_string(); let completions = search_completions(cx, Some(reg)); ui::regex_prompt( @@ -2263,19 +1909,18 @@ fn searcher(cx: &mut Context, direction: Direction) { completions .iter() .filter(|comp| comp.starts_with(input)) - .map(|comp| (0.., comp.clone().into())) + .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |cx, regex, event| { - if event == PromptEvent::Validate { - cx.editor.registers.last_search_register = reg; - } else if event != PromptEvent::Update { + move |editor, regex, event| { + if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } search_impl( - cx.editor, + editor, + &contents, ®ex, - movement, + Movement::Move, direction, scrolloff, wrap_around, @@ -2287,12 +1932,12 @@ fn searcher(cx: &mut Context, direction: Direction) { fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) { let count = cx.count(); - let register = cx - .register - .unwrap_or(cx.editor.registers.last_search_register); let config = cx.editor.config(); let scrolloff = config.scrolloff; - if let Some(query) = cx.editor.registers.first(register, cx.editor) { + let (_, doc) = current!(cx.editor); + let registers = &cx.editor.registers; + if let Some(query) = registers.read('/').and_then(|query| query.last()) { + let contents = doc.text().slice(..).to_string(); let search_config = &config.search; let case_insensitive = if search_config.smart_case { !query.chars().any(char::is_uppercase) @@ -2300,17 +1945,15 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir false }; let wrap_around = search_config.wrap_around; - if let Ok(regex) = rope::RegexBuilder::new() - .syntax( - rope::Config::new() - .case_insensitive(case_insensitive) - .multi_line(true), - ) - .build(&query) + if let Ok(regex) = RegexBuilder::new(query) + .case_insensitive(case_insensitive) + .multi_line(true) + .build() { for _ in 0..count { search_impl( cx.editor, + &contents, ®ex, movement, direction, @@ -2342,82 +1985,25 @@ fn extend_search_prev(cx: &mut Context) { } fn search_selection(cx: &mut Context) { - search_selection_impl(cx, false) -} - -fn search_selection_detect_word_boundaries(cx: &mut Context) { - search_selection_impl(cx, true) -} - -fn search_selection_impl(cx: &mut Context, detect_word_boundaries: bool) { - fn is_at_word_start(text: RopeSlice, index: usize) -> bool { - // This can happen when the cursor is at the last character in - // the document +1 (ge + j), in this case text.char(index) will panic as - // it will index out of bounds. See https://github.com/helix-editor/helix/issues/12609 - if index == text.len_chars() { - return false; - } - let ch = text.char(index); - if index == 0 { - return char_is_word(ch); - } - let prev_ch = text.char(index - 1); - - !char_is_word(prev_ch) && char_is_word(ch) - } - - fn is_at_word_end(text: RopeSlice, index: usize) -> bool { - if index == 0 || index == text.len_chars() { - return false; - } - let ch = text.char(index); - let prev_ch = text.char(index - 1); - - char_is_word(prev_ch) && !char_is_word(ch) - } - - let register = cx.register.unwrap_or('/'); let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); + let contents = doc.text().slice(..); let regex = doc .selection(view.id) .iter() - .map(|selection| { - let add_boundary_prefix = - detect_word_boundaries && is_at_word_start(text, selection.from()); - let add_boundary_suffix = - detect_word_boundaries && is_at_word_end(text, selection.to()); - - let prefix = if add_boundary_prefix { "\\b" } else { "" }; - let suffix = if add_boundary_suffix { "\\b" } else { "" }; - - let word = regex::escape(&selection.fragment(text)); - format!("{}{}{}", prefix, word, suffix) - }) + .map(|selection| regex::escape(&selection.fragment(contents))) .collect::<HashSet<_>>() // Collect into hashset to deduplicate identical regexes .into_iter() .collect::<Vec<_>>() .join("|"); - let msg = format!("register '{}' set to '{}'", register, ®ex); - match cx.editor.registers.push(register, regex) { - Ok(_) => { - cx.editor.registers.last_search_register = register; - cx.editor.set_status(msg) - } - Err(err) => cx.editor.set_error(err.to_string()), - } + let msg = format!("register '{}' set to '{}'", '/', ®ex); + cx.editor.registers.push('/', regex); + cx.editor.set_status(msg); } fn make_search_word_bounded(cx: &mut Context) { - // Defaults to the active search register instead `/` to be more ergonomic assuming most people - // would use this command following `search_selection`. This avoids selecting the register - // twice. - let register = cx - .register - .unwrap_or(cx.editor.registers.last_search_register); - let regex = match cx.editor.registers.first(register, cx.editor) { + let regex = match cx.editor.registers.last('/') { Some(regex) => regex, None => return, }; @@ -2435,19 +2021,14 @@ fn make_search_word_bounded(cx: &mut Context) { if !start_anchored { new_regex.push_str("\\b"); } - new_regex.push_str(®ex); + new_regex.push_str(regex); if !end_anchored { new_regex.push_str("\\b"); } - let msg = format!("register '{}' set to '{}'", register, &new_regex); - match cx.editor.registers.push(register, new_regex) { - Ok(_) => { - cx.editor.registers.last_search_register = register; - cx.editor.set_status(msg) - } - Err(err) => cx.editor.set_error(err.to_string()), - } + let msg = format!("register '{}' set to '{}'", '/', &new_regex); + cx.editor.registers.push('/', new_regex); + cx.editor.set_status(msg); } fn global_search(cx: &mut Context) { @@ -2467,217 +2048,171 @@ fn global_search(cx: &mut Context) { } } - struct GlobalSearchConfig { - smart_case: bool, - file_picker_config: helix_view::editor::FilePickerConfig, - directory_style: Style, - number_style: Style, - colon_style: Style, + impl ui::menu::Item for FileResult { + type Data = Option<PathBuf>; + + fn format(&self, current_path: &Self::Data) -> Row { + let relative_path = helix_core::path::get_relative_path(&self.path) + .to_string_lossy() + .into_owned(); + if current_path + .as_ref() + .map(|p| p == &self.path) + .unwrap_or(false) + { + format!("{} (*)", relative_path).into() + } else { + relative_path.into() + } + } } + let (all_matches_sx, all_matches_rx) = tokio::sync::mpsc::unbounded_channel::<FileResult>(); let config = cx.editor.config(); - let config = GlobalSearchConfig { - smart_case: config.search.smart_case, - file_picker_config: config.file_picker.clone(), - directory_style: cx.editor.theme.get("ui.text.directory"), - number_style: cx.editor.theme.get("constant.numeric.integer"), - colon_style: cx.editor.theme.get("punctuation"), - }; - - let columns = [ - PickerColumn::new("path", |item: &FileResult, config: &GlobalSearchConfig| { - let path = helix_stdx::path::get_relative_path(&item.path); - - let directories = path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR)) - .unwrap_or_default(); + let smart_case = config.search.smart_case; + let file_picker_config = config.file_picker.clone(); - let filename = item - .path - .file_name() - .expect("global search paths are normalized (can't end in `..`)") - .to_string_lossy(); - - Cell::from(Spans::from(vec![ - Span::styled(directories, config.directory_style), - Span::raw(filename), - Span::styled(":", config.colon_style), - Span::styled((item.line_num + 1).to_string(), config.number_style), - ])) - }), - PickerColumn::hidden("contents"), - ]; - - let get_files = |query: &str, - editor: &mut Editor, - config: std::sync::Arc<GlobalSearchConfig>, - injector: &ui::picker::Injector<_, _>| { - if query.is_empty() { - return async { Ok(()) }.boxed(); - } - - let search_root = helix_stdx::env::current_working_dir(); - if !search_root.exists() { - return async { Err(anyhow::anyhow!("Current working directory does not exist")) } - .boxed(); - } - - let documents: Vec<_> = editor - .documents() - .map(|doc| (doc.path().cloned(), doc.text().to_owned())) - .collect(); + let reg = cx.register.unwrap_or('/'); - let matcher = match RegexMatcherBuilder::new() - .case_smart(config.smart_case) - .build(query) - { - Ok(matcher) => { - // Clear any "Failed to compile regex" errors out of the statusline. - editor.clear_status(); - matcher - } - Err(err) => { - log::info!("Failed to compile search pattern in global search: {}", err); - return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed(); + let completions = search_completions(cx, Some(reg)); + ui::regex_prompt( + cx, + "global-search:".into(), + Some(reg), + move |_editor: &Editor, input: &str| { + completions + .iter() + .filter(|comp| comp.starts_with(input)) + .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) + .collect() + }, + move |_editor, regex, event| { + if event != PromptEvent::Validate { + return; } - }; - let dedup_symlinks = config.file_picker_config.deduplicate_links; - let absolute_root = search_root - .canonicalize() - .unwrap_or_else(|_| search_root.clone()); - - let injector = injector.clone(); - async move { - let searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(b'\x00')) - .build(); - WalkBuilder::new(search_root) - .hidden(config.file_picker_config.hidden) - .parents(config.file_picker_config.parents) - .ignore(config.file_picker_config.ignore) - .follow_links(config.file_picker_config.follow_symlinks) - .git_ignore(config.file_picker_config.git_ignore) - .git_global(config.file_picker_config.git_global) - .git_exclude(config.file_picker_config.git_exclude) - .max_depth(config.file_picker_config.max_depth) - .filter_entry(move |entry| { - filter_picker_entry(entry, &absolute_root, dedup_symlinks) - }) - .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) - .add_custom_ignore_filename(".helix/ignore") - .build_parallel() - .run(|| { - let mut searcher = searcher.clone(); - let matcher = matcher.clone(); - let injector = injector.clone(); - let documents = &documents; - Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState { - let entry = match entry { - Ok(entry) => entry, - Err(_) => return WalkState::Continue, - }; - - match entry.file_type() { - Some(entry) if entry.is_file() => {} - // skip everything else - _ => return WalkState::Continue, - }; - - let mut stop = false; - let sink = sinks::UTF8(|line_num, _line_content| { - stop = injector - .push(FileResult::new(entry.path(), line_num as usize - 1)) - .is_err(); - - Ok(!stop) - }); - let doc = documents.iter().find(|&(doc_path, _)| { - doc_path - .as_ref() - .is_some_and(|doc_path| doc_path == entry.path()) - }); - - let result = if let Some((_, doc)) = doc { - // there is already a buffer for this file - // search the buffer instead of the file because it's faster - // and captures new edits without requiring a save - if searcher.multi_line_with_matcher(&matcher) { - // in this case a continuous buffer is required - // convert the rope to a string - let text = doc.to_string(); - searcher.search_slice(&matcher, text.as_bytes(), sink) - } else { - searcher.search_reader( - &matcher, - RopeReader::new(doc.slice(..)), - sink, - ) + if let Ok(matcher) = RegexMatcherBuilder::new() + .case_smart(smart_case) + .build(regex.as_str()) + { + let searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .build(); + + let search_root = std::env::current_dir() + .expect("Global search error: Failed to get current dir"); + let dedup_symlinks = file_picker_config.deduplicate_links; + let absolute_root = search_root + .canonicalize() + .unwrap_or_else(|_| search_root.clone()); + + WalkBuilder::new(search_root) + .hidden(file_picker_config.hidden) + .parents(file_picker_config.parents) + .ignore(file_picker_config.ignore) + .follow_links(file_picker_config.follow_symlinks) + .git_ignore(file_picker_config.git_ignore) + .git_global(file_picker_config.git_global) + .git_exclude(file_picker_config.git_exclude) + .max_depth(file_picker_config.max_depth) + .filter_entry(move |entry| { + filter_picker_entry(entry, &absolute_root, dedup_symlinks) + }) + .build_parallel() + .run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let all_matches_sx = all_matches_sx.clone(); + Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; + + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let result = searcher.search_path( + &matcher, + entry.path(), + sinks::UTF8(|line_num, _| { + all_matches_sx + .send(FileResult::new(entry.path(), line_num as usize - 1)) + .unwrap(); + + Ok(true) + }), + ); + + if let Err(err) = result { + log::error!( + "Global search error: {}, {}", + entry.path().display(), + err + ); } - } else { - searcher.search_path(&matcher, entry.path(), sink) - }; - - if let Err(err) = result { - log::error!("Global search error: {}, {}", entry.path().display(), err); - } - if stop { - WalkState::Quit - } else { WalkState::Continue - } - }) - }); - Ok(()) - } - .boxed() - }; + }) + }); + } else { + // Otherwise do nothing + // log::warn!("Global Search Invalid Pattern") + } + }, + ); - let reg = cx.register.unwrap_or('/'); - cx.editor.registers.last_search_register = reg; + let current_path = doc_mut!(cx.editor).path().cloned(); - let picker = Picker::new( - columns, - 1, // contents - [], - config, - move |cx, FileResult { path, line_num, .. }, action| { - let doc = match cx.editor.open(path, action) { - Ok(id) => doc_mut!(cx.editor, &id), - Err(e) => { - cx.editor - .set_error(format!("Failed to open file '{}': {}", path.display(), e)); + let show_picker = async move { + let all_matches: Vec<FileResult> = + UnboundedReceiverStream::new(all_matches_rx).collect().await; + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + if all_matches.is_empty() { + editor.set_status("No matches found"); return; } - }; - - let line_num = *line_num; - let view = view_mut!(cx.editor); - let text = doc.text(); - if line_num >= text.len_lines() { - cx.editor.set_error( - "The line you jumped to does not exist anymore because the file has changed.", - ); - return; - } - let start = text.line_to_char(line_num); - let end = text.line_to_char((line_num + 1).min(text.len_lines())); - doc.set_selection(view.id, Selection::single(start, end)); - if action.align_view(view, doc.id()) { - align_view(doc, view, Align::Center); - } - }, - ) - .with_preview(|_editor, FileResult { path, line_num, .. }| { - Some((path.as_path().into(), Some((*line_num, *line_num)))) - }) - .with_history_register(Some(reg)) - .with_dynamic_query(get_files, Some(275)); + let picker = Picker::new( + all_matches, + current_path, + move |cx, FileResult { path, line_num }, action| { + match cx.editor.open(path, action) { + Ok(_) => {} + Err(e) => { + cx.editor.set_error(format!( + "Failed to open file '{}': {}", + path.display(), + e + )); + return; + } + } - cx.push_layer(Box::new(overlaid(picker))); + let line_num = *line_num; + let (view, doc) = current!(cx.editor); + let text = doc.text(); + if line_num >= text.len_lines() { + cx.editor.set_error("The line you jumped to does not exist anymore because the file has changed."); + return; + } + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); + + doc.set_selection(view.id, Selection::single(start, end)); + align_view(doc, view, Align::Center); + }).with_preview(|_editor, FileResult { path, line_num }| { + Some((path.clone().into(), Some((*line_num, *line_num)))) + }); + compositor.push(Box::new(overlaid(picker))); + }, + )); + Ok(call) + }; + cx.jobs.callback(show_picker); } enum Extend { @@ -2701,6 +2236,7 @@ fn extend_line_below(cx: &mut Context) { fn extend_line_above(cx: &mut Context) { extend_line_impl(cx, Extend::Above); } + fn extend_line_impl(cx: &mut Context, extend: Extend) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -2739,59 +2275,6 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { doc.set_selection(view.id, selection); } -fn select_line_below(cx: &mut Context) { - select_line_impl(cx, Extend::Below); -} -fn select_line_above(cx: &mut Context) { - select_line_impl(cx, Extend::Above); -} -fn select_line_impl(cx: &mut Context, extend: Extend) { - let mut count = cx.count(); - let (view, doc) = current!(cx.editor); - let text = doc.text(); - let saturating_add = |a: usize, b: usize| (a + b).min(text.len_lines()); - let selection = doc.selection(view.id).clone().transform(|range| { - let (start_line, end_line) = range.line_range(text.slice(..)); - let start = text.line_to_char(start_line); - let end = text.line_to_char(saturating_add(end_line, 1)); - let direction = range.direction(); - - // Extending to line bounds is counted as one step - if range.from() != start || range.to() != end { - count = count.saturating_sub(1) - } - let (anchor_line, head_line) = match (&extend, direction) { - (Extend::Above, Direction::Forward) => (start_line, end_line.saturating_sub(count)), - (Extend::Above, Direction::Backward) => (end_line, start_line.saturating_sub(count)), - (Extend::Below, Direction::Forward) => (start_line, saturating_add(end_line, count)), - (Extend::Below, Direction::Backward) => (end_line, saturating_add(start_line, count)), - }; - let (anchor, head) = match anchor_line.cmp(&head_line) { - Ordering::Less => ( - text.line_to_char(anchor_line), - text.line_to_char(saturating_add(head_line, 1)), - ), - Ordering::Equal => match extend { - Extend::Above => ( - text.line_to_char(saturating_add(anchor_line, 1)), - text.line_to_char(head_line), - ), - Extend::Below => ( - text.line_to_char(head_line), - text.line_to_char(saturating_add(anchor_line, 1)), - ), - }, - - Ordering::Greater => ( - text.line_to_char(saturating_add(anchor_line, 1)), - text.line_to_char(head_line), - ), - }; - Range::new(anchor, head) - }); - - doc.set_selection(view.id, selection); -} fn extend_to_line_bounds(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -2852,45 +2335,20 @@ enum Operation { Change, } -fn selection_is_linewise(selection: &Selection, text: &Rope) -> bool { - selection.ranges().iter().all(|range| { - let text = text.slice(..); - if range.slice(text).len_lines() < 2 { - return false; - } - // If the start of the selection is at the start of a line and the end at the end of a line. - let (start_line, end_line) = range.line_range(text); - let start = text.line_to_char(start_line); - let end = text.line_to_char((end_line + 1).min(text.len_lines())); - start == range.from() && end == range.to() - }) -} - -enum YankAction { - Yank, - NoYank, -} - -fn delete_selection_impl(cx: &mut Context, op: Operation, yank: YankAction) { +fn delete_selection_impl(cx: &mut Context, op: Operation) { let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); - let only_whole_lines = selection_is_linewise(selection, doc.text()); - if cx.register != Some('_') && matches!(yank, YankAction::Yank) { - // yank the selection + if cx.register != Some('_') { + // first yank the selection let text = doc.text().slice(..); let values: Vec<String> = selection.fragments(text).map(Cow::into_owned).collect(); - let reg_name = cx - .register - .unwrap_or_else(|| cx.editor.config.load().default_yank_register); - if let Err(err) = cx.editor.registers.write(reg_name, values) { - cx.editor.set_error(err.to_string()); - return; - } - } + let reg_name = cx.register.unwrap_or('"'); + cx.editor.registers.write(reg_name, values); + }; - // delete the selection + // then delete let transaction = Transaction::delete_by_selection(doc.text(), selection, |range| (range.from(), range.to())); doc.apply(&transaction, view.id); @@ -2901,11 +2359,7 @@ fn delete_selection_impl(cx: &mut Context, op: Operation, yank: YankAction) { exit_select_mode(cx); } Operation::Change => { - if only_whole_lines { - open(cx, Open::Above, CommentContinuation::Disabled); - } else { - enter_insert_mode(cx); - } + enter_insert_mode(cx); } } } @@ -2953,22 +2407,25 @@ fn delete_by_selection_insert_mode( ); } doc.apply(&transaction, view.id); + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } fn delete_selection(cx: &mut Context) { - delete_selection_impl(cx, Operation::Delete, YankAction::Yank); + delete_selection_impl(cx, Operation::Delete); } fn delete_selection_noyank(cx: &mut Context) { - delete_selection_impl(cx, Operation::Delete, YankAction::NoYank); + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Delete); } fn change_selection(cx: &mut Context) { - delete_selection_impl(cx, Operation::Change, YankAction::Yank); + delete_selection_impl(cx, Operation::Change); } fn change_selection_noyank(cx: &mut Context) { - delete_selection_impl(cx, Operation::Change, YankAction::NoYank); + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Change); } fn collapse_selection(cx: &mut Context) { @@ -3060,11 +2517,7 @@ fn append_mode(cx: &mut Context) { fn file_picker(cx: &mut Context) { let root = find_workspace().0; - if !root.exists() { - cx.editor.set_error("Workspace directory does not exist"); - return; - } - let picker = ui::file_picker(cx.editor, root); + let picker = ui::file_picker(root, &cx.editor.config()); cx.push_layer(Box::new(overlaid(picker))); } @@ -3081,82 +2534,51 @@ fn file_picker_in_current_buffer_directory(cx: &mut Context) { } }; - let picker = ui::file_picker(cx.editor, path); + let picker = ui::file_picker(path, &cx.editor.config()); cx.push_layer(Box::new(overlaid(picker))); } - fn file_picker_in_current_directory(cx: &mut Context) { - let cwd = helix_stdx::env::current_working_dir(); - if !cwd.exists() { - cx.editor - .set_error("Current working directory does not exist"); - return; - } - let picker = ui::file_picker(cx.editor, cwd); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./")); + let picker = ui::file_picker(cwd, &cx.editor.config()); cx.push_layer(Box::new(overlaid(picker))); } -fn file_explorer(cx: &mut Context) { - let root = find_workspace().0; - if !root.exists() { - cx.editor.set_error("Workspace directory does not exist"); - return; - } - - if let Ok(picker) = ui::file_explorer(root, cx.editor) { - cx.push_layer(Box::new(overlaid(picker))); - } +pub struct BufferMeta { + pub id: DocumentId, + path: Option<PathBuf>, + is_modified: bool, + is_current: bool, + focused_at: std::time::Instant, } -fn file_explorer_in_current_buffer_directory(cx: &mut Context) { - let doc_dir = doc!(cx.editor) - .path() - .and_then(|path| path.parent().map(|path| path.to_path_buf())); - - let path = match doc_dir { - Some(path) => path, - None => { - let cwd = helix_stdx::env::current_working_dir(); - if !cwd.exists() { - cx.editor.set_error( - "Current buffer has no parent and current working directory does not exist", - ); - return; - } - cx.editor.set_error( - "Current buffer has no parent, opening file explorer in current working directory", - ); - cwd - } - }; +pub type BufferPicker = Picker<BufferMeta>; - if let Ok(picker) = ui::file_explorer(path, cx.editor) { - cx.push_layer(Box::new(overlaid(picker))); - } -} +fn buffer_picker(cx: &mut Context) { + let current = view!(cx.editor).doc; -fn file_explorer_in_current_directory(cx: &mut Context) { - let cwd = helix_stdx::env::current_working_dir(); - if !cwd.exists() { - cx.editor - .set_error("Current working directory does not exist"); - return; - } + impl ui::menu::Item for BufferMeta { + type Data = (); - if let Ok(picker) = ui::file_explorer(cwd, cx.editor) { - cx.push_layer(Box::new(overlaid(picker))); - } -} + fn format(&self, _data: &Self::Data) -> Row { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; -fn buffer_picker(cx: &mut Context) { - let current = view!(cx.editor).doc; + let mut flags = String::new(); + if self.is_modified { + flags.push('+'); + } + if self.is_current { + flags.push('*'); + } - struct BufferMeta { - id: DocumentId, - path: Option<PathBuf>, - is_modified: bool, - is_current: bool, - focused_at: std::time::Instant, + Row::new([self.id.to_string(), flags, path.to_string()]) + } } let new_meta = |doc: &Document| BufferMeta { @@ -3171,49 +2593,25 @@ fn buffer_picker(cx: &mut Context) { .editor .documents .values() - .map(new_meta) + .map(|doc| new_meta(doc)) .collect::<Vec<BufferMeta>>(); // mru items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); - let columns = [ - PickerColumn::new("id", |meta: &BufferMeta, _| meta.id.to_string().into()), - PickerColumn::new("flags", |meta: &BufferMeta, _| { - let mut flags = String::new(); - if meta.is_modified { - flags.push('+'); - } - if meta.is_current { - flags.push('*'); - } - flags.into() - }), - PickerColumn::new("path", |meta: &BufferMeta, _| { - let path = meta - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - path.as_deref() - .and_then(Path::to_str) - .unwrap_or(SCRATCH_BUFFER_NAME) - .to_string() - .into() - }), - ]; - let initial_cursor = if items.len() <= 1 { 0 } else { 1 }; - let picker = Picker::new(columns, 2, items, (), |cx, meta, action| { + let picker = Picker::new(items, (), |cx, meta, action| { cx.editor.switch(meta.id, action); }) - .with_initial_cursor(initial_cursor) .with_preview(|editor, meta| { let doc = &editor.documents.get(&meta.id)?; - let lines = doc.selections().values().next().map(|selection| { - let cursor_line = selection.primary().cursor_line(doc.text().slice(..)); - (cursor_line, cursor_line) - }); - Some((meta.id.into(), lines)) - }); + let &view_id = doc.selections().keys().next()?; + let line = doc + .selection(view_id) + .primary() + .cursor_line(doc.text().slice(..)); + Some((meta.id.into(), Some((line, line)))) + }) + .with_id("buffer-picker"); cx.push_layer(Box::new(overlaid(picker))); } @@ -3226,6 +2624,33 @@ fn jumplist_picker(cx: &mut Context) { is_current: bool, } + impl ui::menu::Item for JumpMeta { + type Data = (); + + fn format(&self, _data: &Self::Data) -> Row { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; + + let mut flags = Vec::new(); + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + format!("{} {}{} {}", self.id, path, flag, self.text).into() + } + } + for (view, _) in cx.editor.tree.views_mut() { for doc_id in view.jumps.iter().map(|e| e.0).collect::<Vec<_>>().iter() { let doc = doc_mut!(cx.editor, doc_id); @@ -3252,52 +2677,23 @@ fn jumplist_picker(cx: &mut Context) { } }; - let columns = [ - ui::PickerColumn::new("id", |item: &JumpMeta, _| item.id.to_string().into()), - ui::PickerColumn::new("path", |item: &JumpMeta, _| { - let path = item - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - path.as_deref() - .and_then(Path::to_str) - .unwrap_or(SCRATCH_BUFFER_NAME) - .to_string() - .into() - }), - ui::PickerColumn::new("flags", |item: &JumpMeta, _| { - let mut flags = Vec::new(); - if item.is_current { - flags.push("*"); - } - - if flags.is_empty() { - "".into() - } else { - format!(" ({})", flags.join("")).into() - } - }), - ui::PickerColumn::new("contents", |item: &JumpMeta, _| item.text.as_str().into()), - ]; - let picker = Picker::new( - columns, - 1, // path - cx.editor.tree.views().flat_map(|(view, _)| { - view.jumps - .iter() - .rev() - .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) - }), + cx.editor + .tree + .views() + .flat_map(|(view, _)| { + view.jumps + .iter() + .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) + }) + .collect(), (), |cx, meta, action| { cx.editor.switch(meta.id, action); let config = cx.editor.config(); - let (view, doc) = (view_mut!(cx.editor), doc_mut!(cx.editor, &meta.id)); + let (view, doc) = current!(cx.editor); doc.set_selection(view.id, meta.selection.clone()); - if action.align_view(view, doc.id()) { - view.ensure_cursor_in_view_center(doc, config.scrolloff); - } + view.ensure_cursor_in_view_center(doc, config.scrolloff); }, ) .with_preview(|editor, meta| { @@ -3308,157 +2704,61 @@ fn jumplist_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -fn changed_file_picker(cx: &mut Context) { - pub struct FileChangeData { - cwd: PathBuf, - style_untracked: Style, - style_modified: Style, - style_conflict: Style, - style_deleted: Style, - style_renamed: Style, - } - - let cwd = helix_stdx::env::current_working_dir(); - if !cwd.exists() { - cx.editor - .set_error("Current working directory does not exist"); - return; - } +impl ui::menu::Item for MappableCommand { + type Data = ReverseKeymap; - let added = cx.editor.theme.get("diff.plus"); - let modified = cx.editor.theme.get("diff.delta"); - let conflict = cx.editor.theme.get("diff.delta.conflict"); - let deleted = cx.editor.theme.get("diff.minus"); - let renamed = cx.editor.theme.get("diff.delta.moved"); - - let columns = [ - PickerColumn::new("change", |change: &FileChange, data: &FileChangeData| { - match change { - FileChange::Untracked { .. } => Span::styled("+ untracked", data.style_untracked), - FileChange::Modified { .. } => Span::styled("~ modified", data.style_modified), - FileChange::Conflict { .. } => Span::styled("x conflict", data.style_conflict), - FileChange::Deleted { .. } => Span::styled("- deleted", data.style_deleted), - FileChange::Renamed { .. } => Span::styled("> renamed", data.style_renamed), - } - .into() - }), - PickerColumn::new("path", |change: &FileChange, data: &FileChangeData| { - let display_path = |path: &PathBuf| { - path.strip_prefix(&data.cwd) - .unwrap_or(path) - .display() - .to_string() - }; - match change { - FileChange::Untracked { path } => display_path(path), - FileChange::Modified { path } => display_path(path), - FileChange::Conflict { path } => display_path(path), - FileChange::Deleted { path } => display_path(path), - FileChange::Renamed { from_path, to_path } => { - format!("{} -> {}", display_path(from_path), display_path(to_path)) + fn format(&self, keymap: &Self::Data) -> Row { + let fmt_binding = |bindings: &Vec<Vec<KeyEvent>>| -> String { + bindings.iter().fold(String::new(), |mut acc, bind| { + if !acc.is_empty() { + acc.push(' '); } - } - .into() - }), - ]; - - let picker = Picker::new( - columns, - 1, // path - [], - FileChangeData { - cwd: cwd.clone(), - style_untracked: added, - style_modified: modified, - style_conflict: conflict, - style_deleted: deleted, - style_renamed: renamed, - }, - |cx, meta: &FileChange, action| { - let path_to_open = meta.path(); - if let Err(e) = cx.editor.open(path_to_open, action) { - let err = if let Some(err) = e.source() { - format!("{}", err) - } else { - format!("unable to open \"{}\"", path_to_open.display()) - }; - cx.editor.set_error(err); - } - }, - ) - .with_preview(|_editor, meta| Some((meta.path().into(), None))); - let injector = picker.injector(); + for key in bind { + acc.push_str(&key.key_sequence_format()); + } + acc + }) + }; - cx.editor - .diff_providers - .clone() - .for_each_changed_file(cwd, move |change| match change { - Ok(change) => injector.push(change).is_ok(), - Err(err) => { - status::report_blocking(err); - true - } - }); - cx.push_layer(Box::new(overlaid(picker))); + match self { + MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { + Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [:{}]", doc, name).into(), + }, + MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { + Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [{}]", doc, name).into(), + }, + MappableCommand::Component { .. } => unimplemented!(), + } + } } pub fn command_palette(cx: &mut Context) { let register = cx.register; let count = cx.count; - cx.callback.push(Box::new( + cx.callback = Some(Box::new( move |compositor: &mut Compositor, cx: &mut compositor::Context| { - let keymap = compositor.find::<ui::EditorView>().unwrap().keymaps.map() - [&cx.editor.mode] - .reverse_map(); - - let commands = MappableCommand::STATIC_COMMAND_LIST.iter().cloned().chain( - typed::TYPABLE_COMMAND_LIST - .iter() - .map(|cmd| MappableCommand::Typable { - name: cmd.name.to_owned(), - args: String::new(), - doc: cmd.doc.to_owned(), - }), - ); + let keymap = + cx.keymaps.map()[&crate::keymap::Domain::Mode(cx.editor.mode)].reverse_map(); - let columns = [ - ui::PickerColumn::new("name", |item, _| match item { - MappableCommand::Typable { name, .. } => format!(":{name}").into(), - MappableCommand::Static { name, .. } => (*name).into(), - MappableCommand::Macro { .. } => { - unreachable!("macros aren't included in the command palette") - } - }), - ui::PickerColumn::new( - "bindings", - |item: &MappableCommand, keymap: &crate::keymap::ReverseKeymap| { - keymap - .get(item.name()) - .map(|bindings| { - bindings.iter().fold(String::new(), |mut acc, bind| { - if !acc.is_empty() { - acc.push(' '); - } - for key in bind { - acc.push_str(&key.key_sequence_format()); - } - acc - }) - }) - .unwrap_or_default() - .into() - }, - ), - ui::PickerColumn::new("doc", |item: &MappableCommand, _| item.doc().into()), - ]; + let mut commands: Vec<MappableCommand> = MappableCommand::STATIC_COMMAND_LIST.into(); + commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { + MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: cmd.doc.to_owned(), + args: Vec::new(), + } + })); - let picker = Picker::new(columns, 0, commands, keymap, move |cx, command, _action| { + let picker = Picker::new(commands, keymap, move |cx, command, _action| { let mut ctx = Context { register, count, + keymaps: cx.keymaps, editor: cx.editor, - callback: Vec::new(), + callback: None, on_next_key_callback: None, jobs: cx.jobs, }; @@ -3486,7 +2786,7 @@ pub fn command_palette(cx: &mut Context) { fn last_picker(cx: &mut Context) { // TODO: last picker does not seem to work well with buffer_picker - cx.callback.push(Box::new(|compositor, cx| { + cx.callback = Some(Box::new(|compositor, cx| { if let Some(picker) = compositor.last_picker.take() { compositor.push(picker); } else { @@ -3519,12 +2819,12 @@ fn insert_with_indent(cx: &mut Context, cursor_fallback: IndentFallbackPos) { enter_insert_mode(cx); let (view, doc) = current!(cx.editor); - let loader = cx.editor.syn_loader.load(); let text = doc.text().slice(..); let contents = doc.text(); let selection = doc.selection(view.id); + let language_config = doc.language_config(); let syntax = doc.syntax(); let tab_width = doc.tab_width(); @@ -3540,9 +2840,8 @@ fn insert_with_indent(cx: &mut Context, cursor_fallback: IndentFallbackPos) { let line_end_index = cursor_line_start; let indent = indent::indent_for_newline( - &loader, + language_config, syntax, - &doc.config.load().indent_heuristic, &doc.indent_style, tab_width, text, @@ -3561,11 +2860,11 @@ fn insert_with_indent(cx: &mut Context, cursor_fallback: IndentFallbackPos) { } else { // move cursor to the fallback position let pos = match cursor_fallback { - IndentFallbackPos::LineStart => text - .line(cursor_line) - .first_non_whitespace_char() - .map(|ws_offset| ws_offset + cursor_line_start) - .unwrap_or(cursor_line_start), + IndentFallbackPos::LineStart => { + find_first_non_whitespace_char(text.line(cursor_line)) + .map(|ws_offset| ws_offset + cursor_line_start) + .unwrap_or(cursor_line_start) + } IndentFallbackPos::LineEnd => line_end_char_index(&text, cursor_line), }; @@ -3602,23 +2901,14 @@ async fn make_format_callback( let doc = doc_mut!(editor, &doc_id); let view = view_mut!(editor, view_id); - match format { - Ok(format) => { - if doc.version() == doc_version { - doc.apply(&format, view.id); - doc.append_changes_to_history(view); - doc.detect_indent_and_line_ending(); - view.ensure_cursor_in_view(doc, scrolloff); - } else { - log::info!("discarded formatting changes because the document changed"); - } - } - Err(err) => { - if write.is_none() { - editor.set_error(err.to_string()); - return; - } - log::info!("failed to format '{}': {err}", doc.display_name()); + if let Ok(format) = format { + if doc.version() == doc_version { + doc.apply(&format, view.id); + doc.append_changes_to_history(view); + doc.detect_indent_and_line_ending(); + view.ensure_cursor_in_view(doc, scrolloff); + } else { + log::info!("discarded formatting changes because the document changed"); } } @@ -3639,125 +2929,69 @@ pub enum Open { Above, } -#[derive(PartialEq)] -pub enum CommentContinuation { - Enabled, - Disabled, -} - -fn open(cx: &mut Context, open: Open, comment_continuation: CommentContinuation) { +fn open(cx: &mut Context, open: Open) { let count = cx.count(); enter_insert_mode(cx); - let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - let loader = cx.editor.syn_loader.load(); let text = doc.text().slice(..); let contents = doc.text(); let selection = doc.selection(view.id); - let mut offs = 0; let mut ranges = SmallVec::with_capacity(selection.len()); - - let continue_comment_tokens = - if comment_continuation == CommentContinuation::Enabled && config.continue_comments { - doc.language_config() - .and_then(|config| config.comment_tokens.as_ref()) - } else { - None - }; + let mut offs = 0; let mut transaction = Transaction::change_by_selection(contents, selection, |range| { - // the line number, where the cursor is currently - let curr_line_num = text.char_to_line(match open { + let cursor_line = text.char_to_line(match open { Open::Below => graphemes::prev_grapheme_boundary(text, range.to()), Open::Above => range.from(), }); - - // the next line number, where the cursor will be, after finishing the transaction - let next_new_line_num = match open { - Open::Below => curr_line_num + 1, - Open::Above => curr_line_num, + let new_line = match open { + // adjust position to the end of the line (next line - 1) + Open::Below => cursor_line + 1, + // adjust position to the end of the previous line (current line - 1) + Open::Above => cursor_line, }; - let above_next_new_line_num = next_new_line_num.saturating_sub(1); - - let continue_comment_token = continue_comment_tokens - .and_then(|tokens| comment::get_comment_token(text, tokens, curr_line_num)); - // Index to insert newlines after, as well as the char width // to use to compensate for those inserted newlines. - let (above_next_line_end_index, above_next_line_end_width) = if next_new_line_num == 0 { + let (line_end_index, line_end_offset_width) = if new_line == 0 { (0, 0) } else { ( - line_end_char_index(&text, above_next_new_line_num), + line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)), doc.line_ending.len_chars(), ) }; - let line = text.line(curr_line_num); - let indent = match line.first_non_whitespace_char() { - Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), - _ => indent::indent_for_newline( - &loader, - doc.syntax(), - &config.indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - above_next_new_line_num, - above_next_line_end_index, - curr_line_num, - ), - }; - + let indent = indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.indent_style, + doc.tab_width(), + text, + new_line.saturating_sub(1), + line_end_index, + cursor_line, + ); let indent_len = indent.len(); let mut text = String::with_capacity(1 + indent_len); - - if open == Open::Above && next_new_line_num == 0 { - text.push_str(&indent); - if let Some(token) = continue_comment_token { - text.push_str(token); - text.push(' '); - } - text.push_str(doc.line_ending.as_str()); - } else { - text.push_str(doc.line_ending.as_str()); - text.push_str(&indent); - - if let Some(token) = continue_comment_token { - text.push_str(token); - text.push(' '); - } - } - + text.push_str(doc.line_ending.as_str()); + text.push_str(&indent); let text = text.repeat(count); // calculate new selection ranges - let pos = offs + above_next_line_end_index + above_next_line_end_width; - let comment_len = continue_comment_token - .map(|token| token.len() + 1) // `+ 1` for the extra space added - .unwrap_or_default(); + let pos = offs + line_end_index + line_end_offset_width; for i in 0..count { - // pos -> beginning of reference line, - // + (i * (line_ending_len + indent_len + comment_len)) -> beginning of i'th line from pos (possibly including comment token) - // + indent_len + comment_len -> -> indent for i'th line - ranges.push(Range::point( - pos + (i * (doc.line_ending.len_chars() + indent_len + comment_len)) - + indent_len - + comment_len, - )); + // pos -> beginning of reference line, + // + (i * (1+indent_len)) -> beginning of i'th line from pos + // + indent_len -> -> indent for i'th line + ranges.push(Range::point(pos + (i * (1 + indent_len)) + indent_len)); } - // update the offset for the next range offs += text.chars().count(); - ( - above_next_line_end_index, - above_next_line_end_index, - Some(text.into()), - ) + (line_end_index, line_end_index, Some(text.into())) }); transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); @@ -3767,12 +3001,12 @@ fn open(cx: &mut Context, open: Open, comment_continuation: CommentContinuation) // o inserts a new line after each line with a selection fn open_below(cx: &mut Context) { - open(cx, Open::Below, CommentContinuation::Enabled) + open(cx, Open::Below) } // O inserts a new line before each line with a selection fn open_above(cx: &mut Context) { - open(cx, Open::Above, CommentContinuation::Enabled) + open(cx, Open::Above) } fn normal_mode(cx: &mut Context) { @@ -3780,30 +3014,21 @@ fn normal_mode(cx: &mut Context) { } // Store a jump on the jumplist. -fn push_jump(view: &mut View, doc: &mut Document) { - doc.append_changes_to_history(view); +fn push_jump(view: &mut View, doc: &Document) { let jump = (doc.id(), doc.selection(view.id).clone()); view.jumps.push(jump); } fn goto_line(cx: &mut Context) { - goto_line_impl(cx, Movement::Move); -} - -fn goto_line_impl(cx: &mut Context, movement: Movement) { if cx.count.is_some() { let (view, doc) = current!(cx.editor); push_jump(view, doc); - goto_line_without_jumplist(cx.editor, cx.count, movement); + goto_line_without_jumplist(cx.editor, cx.count); } } -fn goto_line_without_jumplist( - editor: &mut Editor, - count: Option<NonZeroUsize>, - movement: Movement, -) { +fn goto_line_without_jumplist(editor: &mut Editor, count: Option<NonZeroUsize>) { if let Some(count) = count { let (view, doc) = current!(editor); let text = doc.text().slice(..); @@ -3818,21 +3043,13 @@ fn goto_line_without_jumplist( let selection = doc .selection(view.id) .clone() - .transform(|range| range.put_cursor(text, pos, movement == Movement::Extend)); + .transform(|range| range.put_cursor(text, pos, editor.mode == Mode::Select)); doc.set_selection(view.id, selection); } } fn goto_last_line(cx: &mut Context) { - goto_last_line_impl(cx, Movement::Move) -} - -fn extend_to_last_line(cx: &mut Context) { - goto_last_line_impl(cx, Movement::Extend) -} - -fn goto_last_line_impl(cx: &mut Context, movement: Movement) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let line_idx = if text.line(text.len_lines() - 1).len_chars() == 0 { @@ -3845,31 +3062,8 @@ fn goto_last_line_impl(cx: &mut Context, movement: Movement) { let selection = doc .selection(view.id) .clone() - .transform(|range| range.put_cursor(text, pos, movement == Movement::Extend)); - - push_jump(view, doc); - doc.set_selection(view.id, selection); -} - -fn goto_column(cx: &mut Context) { - goto_column_impl(cx, Movement::Move); -} - -fn extend_to_column(cx: &mut Context) { - goto_column_impl(cx, Movement::Extend); -} + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); -fn goto_column_impl(cx: &mut Context, movement: Movement) { - let count = cx.count(); - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let selection = doc.selection(view.id).clone().transform(|range| { - let line = range.cursor_line(text); - let line_start = text.line_to_char(line); - let line_end = line_end_char_index(&text, line); - let pos = graphemes::nth_next_grapheme_boundary(text, line_start, count - 1).min(line_end); - range.put_cursor(text, pos, movement == Movement::Extend) - }); push_jump(view, doc); doc.set_selection(view.id, selection); } @@ -3892,7 +3086,6 @@ fn goto_last_modification(cx: &mut Context) { .selection(view.id) .clone() .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); - push_jump(view, doc); doc.set_selection(view.id, selection); } } @@ -3940,82 +3133,63 @@ fn exit_select_mode(cx: &mut Context) { fn goto_first_diag(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().first() { + let selection = match doc.shown_diagnostics().next() { Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; - push_jump(view, doc); doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); } fn goto_last_diag(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().last() { + let selection = match doc.shown_diagnostics().last() { Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; - push_jump(view, doc); doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); } fn goto_next_diag(cx: &mut Context) { - let motion = move |editor: &mut Editor| { - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); - let cursor_pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); - let diag = doc - .diagnostics() - .iter() - .find(|diag| diag.range.start > cursor_pos); + let diag = doc + .shown_diagnostics() + .find(|diag| diag.range.start > cursor_pos) + .or_else(|| doc.shown_diagnostics().next()); - let selection = match diag { - Some(diag) => Selection::single(diag.range.start, diag.range.end), - None => return, - }; - push_jump(view, doc); - doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); + let selection = match diag { + Some(diag) => Selection::single(diag.range.start, diag.range.end), + None => return, }; - - cx.editor.apply_motion(motion); + doc.set_selection(view.id, selection); } fn goto_prev_diag(cx: &mut Context) { - let motion = move |editor: &mut Editor| { - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); - let cursor_pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); - let diag = doc - .diagnostics() - .iter() - .rev() - .find(|diag| diag.range.start < cursor_pos); + let diag = doc + .shown_diagnostics() + .rev() + .find(|diag| diag.range.start < cursor_pos) + .or_else(|| doc.shown_diagnostics().last()); - let selection = match diag { - // NOTE: the selection is reversed because we're jumping to the - // previous diagnostic. - Some(diag) => Selection::single(diag.range.end, diag.range.start), - None => return, - }; - push_jump(view, doc); - doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); + let selection = match diag { + // NOTE: the selection is reversed because we're jumping to the + // previous diagnostic. + Some(diag) => Selection::single(diag.range.end, diag.range.start), + None => return, }; - cx.editor.apply_motion(motion) + doc.set_selection(view.id, selection); } fn goto_first_change(cx: &mut Context) { @@ -4041,7 +3215,6 @@ fn goto_first_change_impl(cx: &mut Context, reverse: bool) { }; if hunk != Hunk::NONE { let range = hunk_range(hunk, doc.text().slice(..)); - push_jump(view, doc); doc.set_selection(view.id, Selection::single(range.anchor, range.head)); } } @@ -4097,10 +3270,10 @@ fn goto_next_change_impl(cx: &mut Context, direction: Direction) { } }); - push_jump(view, doc); doc.set_selection(view.id, selection) }; - cx.editor.apply_motion(motion); + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } /// Returns the [Range] for a [Hunk] in the given text. @@ -4118,10 +3291,9 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range { } pub mod insert { - use crate::{events::PostInsertChar, key}; - use super::*; pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>; + pub type PostHook = fn(&mut Context, char); /// Exclude the cursor in range. fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { @@ -4135,6 +3307,88 @@ pub mod insert { } } + // It trigger completion when idle timer reaches deadline + // Only trigger completion if the word under cursor is longer than n characters + pub fn idle_completion(cx: &mut Context) { + let config = cx.editor.config(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + use helix_core::chars::char_is_word; + let mut iter = text.chars_at(cursor); + iter.reverse(); + for _ in 0..config.completion_trigger_len { + match iter.next() { + Some(c) if char_is_word(c) => {} + _ => return, + } + } + super::completion(cx); + } + + fn language_server_completion(cx: &mut Context, ch: char) { + let config = cx.editor.config(); + if !config.auto_completion { + return; + } + + use helix_lsp::lsp; + // if ch matches completion char, trigger completion + let doc = doc_mut!(cx.editor); + let trigger_completion = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .any(|ls| { + // TODO: what if trigger is multiple chars long + matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions { + trigger_characters: Some(triggers), + .. + }) if triggers.iter().any(|trigger| trigger.contains(ch))) + }); + + if trigger_completion { + cx.editor.clear_idle_timer(); + super::completion(cx); + } + } + + fn signature_help(cx: &mut Context, ch: char) { + use helix_lsp::lsp; + // if ch matches signature_help char, trigger + let doc = doc_mut!(cx.editor); + // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow + let Some(language_server) = doc + .language_servers_with_feature(LanguageServerFeature::SignatureHelp) + .next() + else { + return; + }; + + let capabilities = language_server.capabilities(); + + if let lsp::ServerCapabilities { + signature_help_provider: + Some(lsp::SignatureHelpOptions { + trigger_characters: Some(triggers), + // TODO: retrigger_characters + .. + }), + .. + } = capabilities + { + // TODO: what if trigger is multiple chars long + let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch)); + // lsp doesn't tell us when to close the signature help, so we request + // the help information again after common close triggers which should + // return None, which in turn closes the popup. + let close_triggers = &[')', ';', '.']; + + if is_trigger || close_triggers.contains(&ch) { + super::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } + } + } + // The default insert hook: simply insert the character #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> { @@ -4146,7 +3400,6 @@ pub mod insert { } use helix_core::auto_pairs; - use helix_view::editor::SmartTabConfig; pub fn insert_char(cx: &mut Context, c: char) { let (view, doc) = current_ref!(cx.editor); @@ -4164,48 +3417,20 @@ pub mod insert { doc.apply(&t, view.id); } - helix_event::dispatch(PostInsertChar { c, cx }); - } - - pub fn smart_tab(cx: &mut Context) { - let (view, doc) = current_ref!(cx.editor); - let view_id = view.id; - - if matches!( - cx.editor.config().smart_tab, - Some(SmartTabConfig { enable: true, .. }) - ) { - let cursors_after_whitespace = doc.selection(view_id).ranges().iter().all(|range| { - let cursor = range.cursor(doc.text().slice(..)); - let current_line_num = doc.text().char_to_line(cursor); - let current_line_start = doc.text().line_to_char(current_line_num); - let left = doc.text().slice(current_line_start..cursor); - left.chars().all(|c| c.is_whitespace()) - }); - - if !cursors_after_whitespace { - if doc.active_snippet.is_some() { - goto_next_tabstop(cx); - } else { - move_parent_node_end(cx); - } - return; - } + // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) + // this could also generically look at Transaction, but it's a bit annoying to look at + // Operation instead of Change. + for hook in &[language_server_completion, signature_help] { + hook(cx, c); } - - insert_tab(cx); } pub fn insert_tab(cx: &mut Context) { - insert_tab_impl(cx, 1) - } - - fn insert_tab_impl(cx: &mut Context, count: usize) { let (view, doc) = current!(cx.editor); // TODO: round out to nearest indentation level (for example a line with 3 spaces should // indent by one to reach 4 spaces). - let indent = Tendril::from(doc.indent_style.as_str().repeat(count)); + let indent = Tendril::from(doc.indent_style.as_str()); let transaction = Transaction::insert( doc.text(), &doc.selection(view.id).clone().cursors(doc.text().slice(..)), @@ -4214,75 +3439,18 @@ pub mod insert { doc.apply(&transaction, view.id); } - pub fn append_char_interactive(cx: &mut Context) { - // Save the current mode, so we can restore it later. - let mode = cx.editor.mode; - append_mode(cx); - insert_selection_interactive(cx, mode); - } - - pub fn insert_char_interactive(cx: &mut Context) { - let mode = cx.editor.mode; - insert_mode(cx); - insert_selection_interactive(cx, mode); - } - - fn insert_selection_interactive(cx: &mut Context, old_mode: Mode) { - let count = cx.count(); - - // need to wait for next key - cx.on_next_key(move |cx, event| { - match event { - KeyEvent { - code: KeyCode::Char(ch), - .. - } => { - for _ in 0..count { - insert::insert_char(cx, ch) - } - } - key!(Enter) => { - if count != 1 { - cx.editor - .set_error("inserting multiple newlines not yet supported"); - return; - } - insert_newline(cx) - } - key!(Tab) => insert_tab_impl(cx, count), - _ => (), - }; - // Restore the old mode. - cx.editor.mode = old_mode; - }); - } - pub fn insert_newline(cx: &mut Context) { - let config = cx.editor.config(); let (view, doc) = current_ref!(cx.editor); - let loader = cx.editor.syn_loader.load(); let text = doc.text().slice(..); - let line_ending = doc.line_ending.as_str(); let contents = doc.text(); - let selection = doc.selection(view.id); + let selection = doc.selection(view.id).clone(); let mut ranges = SmallVec::with_capacity(selection.len()); // TODO: this is annoying, but we need to do it to properly calculate pos after edits let mut global_offs = 0; - let mut new_text = String::new(); - let continue_comment_tokens = if config.continue_comments { - doc.language_config() - .and_then(|config| config.comment_tokens.as_ref()) - } else { - None - }; - - let mut last_pos = 0; - let mut transaction = Transaction::change_by_selection(contents, selection, |range| { - // Tracks the number of trailing whitespace characters deleted by this selection. - let mut chars_deleted = 0; + let mut transaction = Transaction::change_by_selection(contents, &selection, |range| { let pos = range.cursor(text); let prev = if pos == 0 { @@ -4293,32 +3461,32 @@ pub mod insert { let curr = contents.get_char(pos).unwrap_or(' '); let current_line = text.char_to_line(pos); - let line_start = text.line_to_char(current_line); + let line_is_only_whitespace = text + .line(current_line) + .chars() + .all(|char| char.is_ascii_whitespace()); - let continue_comment_token = continue_comment_tokens - .and_then(|tokens| comment::get_comment_token(text, tokens, current_line)); + let mut new_text = String::new(); - let (from, to, local_offs) = if let Some(idx) = - text.slice(line_start..pos).last_non_whitespace_char() - { - let first_trailing_whitespace_char = (line_start + idx + 1).clamp(last_pos, pos); - last_pos = pos; - let line = text.line(current_line); - - let indent = match line.first_non_whitespace_char() { - Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), - _ => indent::indent_for_newline( - &loader, - doc.syntax(), - &config.indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - current_line, - pos, - current_line, - ), - }; + // If the current line is all whitespace, insert a line ending at the beginning of + // the current line. This makes the current line empty and the new line contain the + // indentation of the old line. + let (from, to, local_offs) = if line_is_only_whitespace { + let line_start = text.line_to_char(current_line); + new_text.push_str(doc.line_ending.as_str()); + + (line_start, line_start, new_text.chars().count()) + } else { + let indent = indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.indent_style, + doc.tab_width(), + text, + current_line, + pos, + current_line, + ); // If we are between pairs (such as brackets), we want to // insert an additional line which is indented one level @@ -4326,66 +3494,38 @@ pub mod insert { let on_auto_pair = doc .auto_pairs(cx.editor) .and_then(|pairs| pairs.get(prev)) - .is_some_and(|pair| pair.open == prev && pair.close == curr); + .map_or(false, |pair| pair.open == prev && pair.close == curr); - let local_offs = if let Some(token) = continue_comment_token { - new_text.reserve_exact(line_ending.len() + indent.len() + token.len() + 1); - new_text.push_str(line_ending); - new_text.push_str(&indent); - new_text.push_str(token); - new_text.push(' '); - new_text.chars().count() - } else if on_auto_pair { - // line where the cursor will be + let local_offs = if on_auto_pair { let inner_indent = indent.clone() + doc.indent_style.as_str(); - new_text - .reserve_exact(line_ending.len() * 2 + indent.len() + inner_indent.len()); - new_text.push_str(line_ending); + new_text.reserve_exact(2 + indent.len() + inner_indent.len()); + new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&inner_indent); - - // line where the matching pair will be let local_offs = new_text.chars().count(); - new_text.push_str(line_ending); + new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); - local_offs } else { - new_text.reserve_exact(line_ending.len() + indent.len()); - new_text.push_str(line_ending); + new_text.reserve_exact(1 + indent.len()); + new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); - new_text.chars().count() }; - // Note that `first_trailing_whitespace_char` is at least `pos` so this unsigned - // subtraction cannot underflow. - chars_deleted = pos - first_trailing_whitespace_char; - - ( - first_trailing_whitespace_char, - pos, - local_offs as isize - chars_deleted as isize, - ) - } else { - // If the current line is all whitespace, insert a line ending at the beginning of - // the current line. This makes the current line empty and the new line contain the - // indentation of the old line. - new_text.push_str(line_ending); - - (line_start, line_start, new_text.chars().count() as isize) + (pos, pos, local_offs) }; - let new_range = if range.cursor(text) > range.anchor { + let new_range = if doc.restore_cursor { // when appending, extend the range by local_offs Range::new( - (range.anchor as isize + global_offs) as usize, - (range.head as isize + local_offs + global_offs) as usize, + range.anchor + global_offs, + range.head + local_offs + global_offs, ) } else { // when inserting, slide the range by local_offs Range::new( - (range.anchor as isize + local_offs + global_offs) as usize, - (range.head as isize + local_offs + global_offs) as usize, + range.anchor + local_offs + global_offs, + range.head + local_offs + global_offs, ) }; @@ -4393,11 +3533,9 @@ pub mod insert { // range.replace(|range| range.is_empty(), head); -> fn extend if cond true, new head pos // can be used with cx.mode to do replace or extend on most changes ranges.push(new_range); - global_offs += new_text.chars().count() as isize - chars_deleted as isize; - let tendril = Tendril::from(&new_text); - new_text.clear(); + global_offs += new_text.chars().count(); - (from, to, Some(tendril)) + (from, to, Some(new_text.into())) }); transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); @@ -4483,6 +3621,8 @@ pub mod insert { }); let (view, doc) = current!(cx.editor); doc.apply(&transaction, view.id); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_char_forward(cx: &mut Context) { @@ -4580,26 +3720,7 @@ fn commit_undo_checkpoint(cx: &mut Context) { // Yank / Paste fn yank(cx: &mut Context) { - yank_impl( - cx.editor, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), - ); - exit_select_mode(cx); -} - -fn yank_to_clipboard(cx: &mut Context) { - yank_impl(cx.editor, '+'); - exit_select_mode(cx); -} - -fn yank_to_primary_clipboard(cx: &mut Context) { - yank_impl(cx.editor, '*'); - exit_select_mode(cx); -} - -fn yank_impl(editor: &mut Editor, register: char) { - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let values: Vec<String> = doc @@ -4607,15 +3728,19 @@ fn yank_impl(editor: &mut Editor, register: char) { .fragments(text) .map(Cow::into_owned) .collect(); - let selections = values.len(); - match editor.registers.write(register, values) { - Ok(_) => editor.set_status(format!( - "yanked {selections} selection{} to register {register}", - if selections == 1 { "" } else { "s" } - )), - Err(err) => editor.set_error(err.to_string()), - } + let msg = format!( + "yanked {} selection(s) to register {}", + values.len(), + cx.register.unwrap_or('"') + ); + + cx.editor + .registers + .write(cx.register.unwrap_or('"'), values); + + cx.editor.set_status(msg); + exit_select_mode(cx); } fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { @@ -4623,7 +3748,6 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - let selections = selection.len(); let joined = selection .fragments(text) .fold(String::new(), |mut acc, fragment| { @@ -4634,57 +3758,104 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { acc }); - match editor.registers.write(register, vec![joined]) { - Ok(_) => editor.set_status(format!( - "joined and yanked {selections} selection{} to register {register}", - if selections == 1 { "" } else { "s" } - )), - Err(err) => editor.set_error(err.to_string()), - } + let msg = format!( + "joined and yanked {} selection(s) to register {}", + selection.len(), + register, + ); + + editor.registers.write(register, vec![joined]); + editor.set_status(msg); } fn yank_joined(cx: &mut Context) { - let separator = doc!(cx.editor).line_ending.as_str(); - yank_joined_impl( - cx.editor, - separator, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), - ); + let line_ending = doc!(cx.editor).line_ending; + let register = cx.register.unwrap_or('"'); + yank_joined_impl(cx.editor, line_ending.as_str(), register); exit_select_mode(cx); } -fn yank_joined_to_clipboard(cx: &mut Context) { - let line_ending = doc!(cx.editor).line_ending; - yank_joined_impl(cx.editor, line_ending.as_str(), '+'); - exit_select_mode(cx); +fn yank_joined_to_clipboard_impl( + editor: &mut Editor, + separator: &str, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + + let values: Vec<String> = doc + .selection(view.id) + .fragments(text) + .map(Cow::into_owned) + .collect(); + + let clipboard_text = match clipboard_type { + ClipboardType::Clipboard => "system clipboard", + ClipboardType::Selection => "primary clipboard", + }; + + let msg = format!( + "joined and yanked {} selection(s) to {}", + values.len(), + clipboard_text, + ); + + let joined = values.join(separator); + + editor + .clipboard_provider + .set_contents(joined, clipboard_type) + .context("Couldn't set system clipboard content")?; + + editor.set_status(msg); + + Ok(()) } -fn yank_joined_to_primary_clipboard(cx: &mut Context) { +fn yank_joined_to_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - yank_joined_impl(cx.editor, line_ending.as_str(), '*'); + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Clipboard); exit_select_mode(cx); } -fn yank_primary_selection_impl(editor: &mut Editor, register: char) { +fn yank_main_selection_to_clipboard_impl( + editor: &mut Editor, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { let (view, doc) = current!(editor); let text = doc.text().slice(..); - let selection = doc.selection(view.id).primary().fragment(text).to_string(); + let message_text = match clipboard_type { + ClipboardType::Clipboard => "yanked main selection to system clipboard", + ClipboardType::Selection => "yanked main selection to primary clipboard", + }; + + let value = doc.selection(view.id).primary().fragment(text); - match editor.registers.write(register, vec![selection]) { - Ok(_) => editor.set_status(format!("yanked primary selection to register {register}",)), - Err(err) => editor.set_error(err.to_string()), + if let Err(e) = editor + .clipboard_provider + .set_contents(value.into_owned(), clipboard_type) + { + bail!("Couldn't set system clipboard content: {}", e); } + + editor.set_status(message_text); + Ok(()) } fn yank_main_selection_to_clipboard(cx: &mut Context) { - yank_primary_selection_impl(cx.editor, '+'); - exit_select_mode(cx); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard); +} + +fn yank_joined_to_primary_clipboard(cx: &mut Context) { + let line_ending = doc!(cx.editor).line_ending; + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Selection); } fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { - yank_primary_selection_impl(cx.editor, '*'); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection); exit_select_mode(cx); } @@ -4695,8 +3866,6 @@ enum Paste { Cursor, } -static LINE_ENDING_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); - fn paste_impl( values: &[String], doc: &mut Document, @@ -4709,30 +3878,26 @@ fn paste_impl( return; } - if mode == Mode::Insert { - doc.append_changes_to_history(view); - } + let repeat = std::iter::repeat( + // `values` is asserted to have at least one entry above. + values + .last() + .map(|value| Tendril::from(value.repeat(count))) + .unwrap(), + ); // if any of values ends with a line ending, it's linewise paste let linewise = values .iter() .any(|value| get_line_ending_of_str(value).is_some()); - let map_value = |value| { - let value = LINE_ENDING_REGEX.replace_all(value, doc.line_ending.as_str()); - let mut out = Tendril::from(value.as_ref()); - for _ in 1..count { - out.push_str(&value); - } - out - }; - - let repeat = std::iter::repeat( - // `values` is asserted to have at least one entry above. - map_value(values.last().unwrap()), - ); - - let mut values = values.iter().map(|value| map_value(value)).chain(repeat); + // Only compiled once. + static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); + let mut values = values + .iter() + .map(|value| REGEX.replace_all(value, doc.line_ending.as_str())) + .map(|value| Tendril::from(value.as_ref().repeat(count))) + .chain(repeat); let text = doc.text(); let selection = doc.selection(view.id); @@ -4788,122 +3953,146 @@ pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { }; let (view, doc) = current!(cx.editor); paste_impl(&[contents], doc, view, paste, count, cx.editor.mode); - exit_select_mode(cx); +} + +fn paste_clipboard_impl( + editor: &mut Editor, + action: Paste, + clipboard_type: ClipboardType, + count: usize, +) -> anyhow::Result<()> { + let (view, doc) = current!(editor); + match editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + paste_impl(&[contents], doc, view, action, count, editor.mode); + Ok(()) + } + Err(e) => Err(e.context("Couldn't get system clipboard contents")), + } } fn paste_clipboard_after(cx: &mut Context) { - paste(cx.editor, '+', Paste::After, cx.count()); - exit_select_mode(cx); + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Clipboard, + cx.count(), + ); } fn paste_clipboard_before(cx: &mut Context) { - paste(cx.editor, '+', Paste::Before, cx.count()); - exit_select_mode(cx); + let _ = paste_clipboard_impl( + cx.editor, + Paste::Before, + ClipboardType::Clipboard, + cx.count(), + ); } fn paste_primary_clipboard_after(cx: &mut Context) { - paste(cx.editor, '*', Paste::After, cx.count()); - exit_select_mode(cx); + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Selection, + cx.count(), + ); } fn paste_primary_clipboard_before(cx: &mut Context) { - paste(cx.editor, '*', Paste::Before, cx.count()); - exit_select_mode(cx); -} - -fn replace_with_yanked(cx: &mut Context) { - replace_with_yanked_impl( + let _ = paste_clipboard_impl( cx.editor, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), + Paste::Before, + ClipboardType::Selection, cx.count(), ); - exit_select_mode(cx); } -fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) { - let Some(values) = editor - .registers - .read(register, editor) - .filter(|values| values.len() > 0) - else { - return; - }; - let scrolloff = editor.config().scrolloff; - let (view, doc) = current_ref!(editor); - - let map_value = |value: &Cow<str>| { - let value = LINE_ENDING_REGEX.replace_all(value, doc.line_ending.as_str()); - let mut out = Tendril::from(value.as_ref()); - for _ in 1..count { - out.push_str(&value); +fn replace_with_yanked(cx: &mut Context) { + let count = cx.count(); + let reg_name = cx.register.unwrap_or('"'); + let (view, doc) = current!(cx.editor); + let registers = &mut cx.editor.registers; + + if let Some(values) = registers.read(reg_name) { + if !values.is_empty() { + let repeat = std::iter::repeat( + values + .last() + .map(|value| Tendril::from(&value.repeat(count))) + .unwrap(), + ); + let mut values = values + .iter() + .map(|value| Tendril::from(&value.repeat(count))) + .chain(repeat); + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + if !range.is_empty() { + (range.from(), range.to(), Some(values.next().unwrap())) + } else { + (range.from(), range.to(), None) + } + }); + + doc.apply(&transaction, view.id); + exit_select_mode(cx); } - out - }; - let mut values_rev = values.rev().peekable(); - // `values` is asserted to have at least one entry above. - let last = values_rev.peek().unwrap(); - let repeat = std::iter::repeat(map_value(last)); - let mut values = values_rev - .rev() - .map(|value| map_value(&value)) - .chain(repeat); - let selection = doc.selection(view.id); - let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - if !range.is_empty() { - (range.from(), range.to(), Some(values.next().unwrap())) - } else { - (range.from(), range.to(), None) + } +} + +fn replace_selections_with_clipboard_impl( + cx: &mut Context, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + + match cx.editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + ( + range.from(), + range.to(), + Some(contents.repeat(count).as_str().into()), + ) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); } - }); - drop(values); + Err(e) => return Err(e.context("Couldn't get system clipboard contents")), + } - let (view, doc) = current!(editor); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view); - view.ensure_cursor_in_view(doc, scrolloff); + exit_select_mode(cx); + Ok(()) } fn replace_selections_with_clipboard(cx: &mut Context) { - replace_with_yanked_impl(cx.editor, '+', cx.count()); - exit_select_mode(cx); + let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard); } fn replace_selections_with_primary_clipboard(cx: &mut Context) { - replace_with_yanked_impl(cx.editor, '*', cx.count()); - exit_select_mode(cx); + let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Selection); } -fn paste(editor: &mut Editor, register: char, pos: Paste, count: usize) { - let Some(values) = editor.registers.read(register, editor) else { - return; - }; - let values: Vec<_> = values.map(|value| value.to_string()).collect(); +fn paste(cx: &mut Context, pos: Paste) { + let count = cx.count(); + let reg_name = cx.register.unwrap_or('"'); + let (view, doc) = current!(cx.editor); + let registers = &mut cx.editor.registers; - let (view, doc) = current!(editor); - paste_impl(&values, doc, view, pos, count, editor.mode); + if let Some(values) = registers.read(reg_name) { + paste_impl(values, doc, view, pos, count, cx.editor.mode); + } } fn paste_after(cx: &mut Context) { - paste( - cx.editor, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), - Paste::After, - cx.count(), - ); - exit_select_mode(cx); + paste(cx, Paste::After) } fn paste_before(cx: &mut Context) { - paste( - cx.editor, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), - Paste::Before, - cx.count(), - ); - exit_select_mode(cx); + paste(cx, Paste::Before) } fn get_lines(doc: &Document, view_id: ViewId) -> Vec<usize> { @@ -4942,7 +4131,6 @@ fn indent(cx: &mut Context) { }), ); doc.apply(&transaction, view.id); - exit_select_mode(cx); } fn unindent(cx: &mut Context) { @@ -4982,7 +4170,6 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); doc.apply(&transaction, view.id); - exit_select_mode(cx); } fn format_selections(cx: &mut Context) { @@ -5012,7 +4199,7 @@ fn format_selections(cx: &mut Context) { }) else { cx.editor - .set_error("No configured language server supports range formatting"); + .set_error("No configured language server does not support range formatting"); return; }; @@ -5032,19 +4219,12 @@ fn format_selections(cx: &mut Context) { .text_document_range_formatting( doc.identifier(), range, - lsp::FormattingOptions { - tab_size: doc.tab_width() as u32, - insert_spaces: matches!(doc.indent_style, IndentStyle::Spaces(_)), - ..Default::default() - }, + lsp::FormattingOptions::default(), None, ) .unwrap(); - let edits = tokio::task::block_in_place(|| helix_lsp::block_on(future)) - .ok() - .flatten() - .unwrap_or_default(); + let edits = tokio::task::block_in_place(|| helix_lsp::block_on(future)).unwrap_or_default(); let transaction = helix_lsp::util::generate_transaction_from_edits(doc.text(), edits, offset_encoding); @@ -5056,17 +4236,10 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { use movement::skip_while; let (view, doc) = current!(cx.editor); let text = doc.text(); - let slice = text.slice(..); - - let comment_tokens = doc - .language_config() - .and_then(|config| config.comment_tokens.as_deref()) - .unwrap_or(&[]); - // Sort by length to handle Rust's /// vs // - let mut comment_tokens: Vec<&str> = comment_tokens.iter().map(|x| x.as_str()).collect(); - comment_tokens.sort_unstable_by_key(|x| std::cmp::Reverse(x.len())); + let slice = doc.text().slice(..); let mut changes = Vec::new(); + let fragment = Tendril::from(" "); for selection in doc.selection(view.id) { let (start, mut end) = selection.line_range(slice); @@ -5077,39 +4250,14 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { changes.reserve(lines.len()); - let first_line_idx = slice.line_to_char(start); - let first_line_idx = skip_while(slice, first_line_idx, |ch| matches!(ch, ' ' | '\t')) - .unwrap_or(first_line_idx); - let first_line = slice.slice(first_line_idx..); - let mut current_comment_token = comment_tokens - .iter() - .find(|token| first_line.starts_with(token)); - for line in lines { let start = line_end_char_index(&slice, line); let mut end = text.line_to_char(line + 1); end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end); - let slice_from_end = slice.slice(end..); - if let Some(token) = comment_tokens - .iter() - .find(|token| slice_from_end.starts_with(token)) - { - if Some(token) == current_comment_token { - end += token.chars().count(); - end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end); - } else { - // update current token, but don't delete this one. - current_comment_token = Some(token); - } - } - let separator = if end == line_end_char_index(&slice, line + 1) { - // the joining line contains only space-characters => don't include a whitespace when joining - None - } else { - Some(Tendril::from(" ")) - }; - changes.push((start, end, separator)); + // need to skip from start, not end + let change = (start, end, Some(fragment.clone())); + changes.push(change); } } @@ -5121,31 +4269,23 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { changes.sort_unstable_by_key(|(from, _to, _text)| *from); changes.dedup(); + // TODO: joining multiple empty lines should be replaced by a single space. + // need to merge change ranges that touch + // select inserted spaces let transaction = if select_space { - let mut offset: usize = 0; let ranges: SmallVec<_> = changes .iter() - .filter_map(|change| { - if change.2.is_some() { - let range = Range::point(change.0 - offset); - offset += change.1 - change.0 - 1; // -1 adjusts for the replacement of the range by a space - Some(range) - } else { - offset += change.1 - change.0; - None - } + .scan(0, |offset, change| { + let range = Range::point(change.0 - *offset); + *offset += change.1 - change.0 - 1; // -1 because cursor is 0-sized + Some(range) }) .collect(); - let t = Transaction::change(text, changes.into_iter()); - if ranges.is_empty() { - t - } else { - let selection = Selection::new(ranges, 0); - t.with_selection(selection) - } + let selection = Selection::new(ranges, 0); + Transaction::change(doc.text(), changes.into_iter()).with_selection(selection) } else { - Transaction::change(text, changes.into_iter()) + Transaction::change(doc.text(), changes.into_iter()) }; doc.apply(&transaction, view.id); @@ -5159,8 +4299,8 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { if remove { "remove:" } else { "keep:" }.into(), Some(reg), ui::completers::none, - move |cx, regex, event| { - let (view, doc) = current!(cx.editor); + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -5170,8 +4310,6 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { selection::keep_or_remove_matches(text, doc.selection(view.id), ®ex, remove) { doc.set_selection(view.id, selection); - } else { - cx.editor.set_error("no selections remaining"); } }, ) @@ -5217,133 +4355,164 @@ fn remove_primary_selection(cx: &mut Context) { } pub fn completion(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let range = doc.selection(view.id).primary(); - let text = doc.text().slice(..); - let cursor = range.cursor(text); - - cx.editor - .handlers - .trigger_completions(cursor, doc.id(), view.id); -} + use helix_lsp::{lsp, util::pos_to_lsp_pos}; -// comments -type CommentTransactionFn = fn( - line_token: Option<&str>, - block_tokens: Option<&[BlockCommentToken]>, - doc: &Rope, - selection: &Selection, -) -> Transaction; - -fn toggle_comments_impl(cx: &mut Context, comment_transaction: CommentTransactionFn) { let (view, doc) = current!(cx.editor); - let line_token: Option<&str> = doc - .language_config() - .and_then(|lc| lc.comment_tokens.as_ref()) - .and_then(|tc| tc.first()) - .map(|tc| tc.as_str()); - let block_tokens: Option<&[BlockCommentToken]> = doc - .language_config() - .and_then(|lc| lc.block_comment_tokens.as_ref()) - .map(|tc| &tc[..]); - - let transaction = - comment_transaction(line_token, block_tokens, doc.text(), doc.selection(view.id)); - doc.apply(&transaction, view.id); - exit_select_mode(cx); -} - -/// commenting behavior: -/// 1. only line comment tokens -> line comment -/// 2. each line block commented -> uncomment all lines -/// 3. whole selection block commented -> uncomment selection -/// 4. all lines not commented and block tokens -> comment uncommented lines -/// 5. no comment tokens and not block commented -> line comment -fn toggle_comments(cx: &mut Context) { - toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { - let text = doc.slice(..); - - // only have line comment tokens - if line_token.is_some() && block_tokens.is_none() { - return comment::toggle_line_comments(doc, selection, line_token); - } - - let split_lines = comment::split_lines_of_selection(text, selection); - - let default_block_tokens = &[BlockCommentToken::default()]; - let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); - - let (line_commented, line_comment_changes) = - comment::find_block_comments(block_comment_tokens, text, &split_lines); + let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion + { + savepoint.clone() + } else { + doc.savepoint(view) + }; - // block commented by line would also be block commented so check this first - if line_commented { - return comment::create_block_comment_transaction( - doc, - &split_lines, - line_commented, - line_comment_changes, - ) - .0; - } + let text = savepoint.text.clone(); + let cursor = savepoint.cursor(); + + let mut seen_language_servers = HashSet::new(); + + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .filter(|ls| seen_language_servers.insert(ls.id())) + .map(|language_server| { + let language_server_id = language_server.id(); + let offset_encoding = language_server.offset_encoding(); + let pos = pos_to_lsp_pos(&text, cursor, offset_encoding); + let doc_id = doc.identifier(); + let completion_request = language_server.completion(doc_id, pos, None).unwrap(); + + async move { + let json = completion_request.await?; + let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?; + + let items = match response { + Some(lsp::CompletionResponse::Array(items)) => items, + // TODO: do something with is_incomplete + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: _is_incomplete, + items, + })) => items, + None => Vec::new(), + } + .into_iter() + .map(|item| CompletionItem { + item, + language_server_id, + resolved: false, + }) + .collect(); - let (block_commented, comment_changes) = - comment::find_block_comments(block_comment_tokens, text, selection); + anyhow::Ok(items) + } + }) + .collect(); - // check if selection has block comments - if block_commented { - return comment::create_block_comment_transaction( - doc, - selection, - block_commented, - comment_changes, - ) - .0; + // setup a channel that allows the request to be canceled + let (tx, rx) = oneshot::channel(); + // set completion_request so that this request can be canceled + // by setting completion_request, the old channel stored there is dropped + // and the associated request is automatically dropped + cx.editor.completion_request_handle = Some(tx); + let future = async move { + let items_future = async move { + let mut items = Vec::new(); + // TODO if one completion request errors, all other completion requests are discarded (even if they're valid) + while let Some(mut lsp_items) = futures.try_next().await? { + items.append(&mut lsp_items); + } + anyhow::Ok(items) + }; + tokio::select! { + biased; + _ = rx => { + Ok(Vec::new()) + } + res = items_future => { + res + } } + }; - // not commented and only have block comment tokens - if line_token.is_none() && block_tokens.is_some() { - return comment::create_block_comment_transaction( - doc, - &split_lines, - line_commented, - line_comment_changes, - ) - .0; - } + let trigger_offset = cursor; + + // TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply + // completion filtering. For example logger.te| should filter the initial suggestion list with "te". + + use helix_core::chars; + let mut iter = text.chars_at(cursor); + iter.reverse(); + let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count(); + let start_offset = cursor.saturating_sub(offset); + + let trigger_doc = doc.id(); + let trigger_view = view.id; + + // FIXME: The commands Context can only have a single callback + // which means it gets overwritten when executing keybindings + // with multiple commands or macros. This would mean that completion + // might be incorrectly applied when repeating the insertmode action + // + // TODO: to solve this either make cx.callback a Vec of callbacks or + // alternatively move `last_insert` to `helix_view::Editor` + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, _cx: &mut compositor::Context| { + let ui = compositor.find::<ui::EditorView>().unwrap(); + ui.last_insert.1.push(InsertEvent::RequestCompletion); + }, + )); - // not block commented at all and don't have any tokens - comment::toggle_line_comments(doc, selection, line_token) - }) -} + cx.jobs.callback(async move { + let items = future.await?; + let call = move |editor: &mut Editor, compositor: &mut Compositor| { + let (view, doc) = current_ref!(editor); + // check if the completion request is stale. + // + // Completions are completed asynchronously and therefore the user could + //switch document/view or leave insert mode. In all of thoise cases the + // completion should be discarded + if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc { + return; + } -fn toggle_line_comments(cx: &mut Context) { - toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { - if line_token.is_none() && block_tokens.is_some() { - let default_block_tokens = &[BlockCommentToken::default()]; - let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); - comment::toggle_block_comments( - doc, - &comment::split_lines_of_selection(doc.slice(..), selection), - block_comment_tokens, - ) - } else { - comment::toggle_line_comments(doc, selection, line_token) - } + if items.is_empty() { + // editor.set_error("No completion available"); + return; + } + let size = compositor.size(); + let ui = compositor.find::<ui::EditorView>().unwrap(); + let completion_area = ui.set_completion( + editor, + savepoint, + items, + start_offset, + trigger_offset, + size, + ); + let size = compositor.size(); + let signature_help_area = compositor + .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID) + .map(|signature_help| signature_help.area(size, editor)); + // Delete the signature help popup if they intersect. + if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) + { + compositor.remove(SignatureHelp::ID); + } + }; + Ok(Callback::EditorCompositor(Box::new(call))) }); } -fn toggle_block_comments(cx: &mut Context) { - toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { - if line_token.is_some() && block_tokens.is_none() { - comment::toggle_line_comments(doc, selection, line_token) - } else { - let default_block_tokens = &[BlockCommentToken::default()]; - let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); - comment::toggle_block_comments(doc, selection, block_comment_tokens) - } - }); +// comments +fn toggle_comments(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let token = doc + .language_config() + .and_then(|lc| lc.comment_token.as_ref()) + .map(|tc| tc.as_ref()); + let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); + + doc.apply(&transaction, view.id); + exit_select_mode(cx); } fn rotate_selections(cx: &mut Context, direction: Direction) { @@ -5365,22 +4534,6 @@ fn rotate_selections_backward(cx: &mut Context) { rotate_selections(cx, Direction::Backward) } -fn rotate_selections_first(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let mut selection = doc.selection(view.id).clone(); - selection.set_primary_index(0); - doc.set_selection(view.id, selection); -} - -fn rotate_selections_last(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let mut selection = doc.selection(view.id).clone(); - let len = selection.len(); - selection.set_primary_index(len - 1); - doc.set_selection(view.id, selection); -} - -#[derive(Debug)] enum ReorderStrategy { RotateForward, RotateBackward, @@ -5393,50 +4546,34 @@ fn reorder_selection_contents(cx: &mut Context, strategy: ReorderStrategy) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - - let mut ranges: Vec<_> = selection + let mut fragments: Vec<_> = selection .slices(text) .map(|fragment| fragment.chunks().collect()) .collect(); - let rotate_by = count.map_or(1, |count| count.get().min(ranges.len())); - - let primary_index = match strategy { - ReorderStrategy::RotateForward => { - ranges.rotate_right(rotate_by); - // Like `usize::wrapping_add`, but provide a custom range from `0` to `ranges.len()` - (selection.primary_index() + ranges.len() + rotate_by) % ranges.len() - } - ReorderStrategy::RotateBackward => { - ranges.rotate_left(rotate_by); - // Like `usize::wrapping_sub`, but provide a custom range from `0` to `ranges.len()` - (selection.primary_index() + ranges.len() - rotate_by) % ranges.len() - } - ReorderStrategy::Reverse => { - if rotate_by % 2 == 0 { - // nothing changed, if we reverse something an even - // amount of times, the output will be the same - return; - } - ranges.reverse(); - // -1 to turn 1-based len into 0-based index - (ranges.len() - 1) - selection.primary_index() - } - }; + let group = count + .map(|count| count.get()) + .unwrap_or(fragments.len()) // default to rotating everything as one group + .min(fragments.len()); + + for chunk in fragments.chunks_mut(group) { + // TODO: also modify main index + match strategy { + ReorderStrategy::RotateForward => chunk.rotate_right(1), + ReorderStrategy::RotateBackward => chunk.rotate_left(1), + ReorderStrategy::Reverse => chunk.reverse(), + }; + } let transaction = Transaction::change( doc.text(), selection .ranges() .iter() - .zip(ranges) + .zip(fragments) .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), ); - doc.set_selection( - view.id, - Selection::new(selection.ranges().into(), primary_index), - ); doc.apply(&transaction, view.id); } @@ -5471,7 +4608,8 @@ fn expand_selection(cx: &mut Context) { } } }; - cx.editor.apply_motion(motion); + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } fn shrink_selection(cx: &mut Context) { @@ -5495,101 +4633,35 @@ fn shrink_selection(cx: &mut Context) { doc.set_selection(view.id, selection); } }; - cx.editor.apply_motion(motion); + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } -fn select_sibling_impl<F>(cx: &mut Context, sibling_fn: F) +fn select_sibling_impl<F>(cx: &mut Context, sibling_fn: &'static F) where - F: Fn(&helix_core::Syntax, RopeSlice, Selection) -> Selection + 'static, + F: Fn(Node) -> Option<Node>, { - let motion = move |editor: &mut Editor| { + let motion = |editor: &mut Editor| { let (view, doc) = current!(editor); if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); let current_selection = doc.selection(view.id); - let selection = sibling_fn(syntax, text, current_selection.clone()); + let selection = + object::select_sibling(syntax, text, current_selection.clone(), sibling_fn); doc.set_selection(view.id, selection); } }; - cx.editor.apply_motion(motion); + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } fn select_next_sibling(cx: &mut Context) { - select_sibling_impl(cx, object::select_next_sibling) + select_sibling_impl(cx, &|node| Node::next_sibling(&node)) } fn select_prev_sibling(cx: &mut Context) { - select_sibling_impl(cx, object::select_prev_sibling) -} - -fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) { - let motion = move |editor: &mut Editor| { - let (view, doc) = current!(editor); - - if let Some(syntax) = doc.syntax() { - let text = doc.text().slice(..); - let current_selection = doc.selection(view.id); - - let selection = movement::move_parent_node_end( - syntax, - text, - current_selection.clone(), - dir, - movement, - ); - - doc.set_selection(view.id, selection); - } - }; - - cx.editor.apply_motion(motion); -} - -pub fn move_parent_node_end(cx: &mut Context) { - move_node_bound_impl(cx, Direction::Forward, Movement::Move) -} - -pub fn move_parent_node_start(cx: &mut Context) { - move_node_bound_impl(cx, Direction::Backward, Movement::Move) -} - -pub fn extend_parent_node_end(cx: &mut Context) { - move_node_bound_impl(cx, Direction::Forward, Movement::Extend) -} - -pub fn extend_parent_node_start(cx: &mut Context) { - move_node_bound_impl(cx, Direction::Backward, Movement::Extend) -} - -fn select_all_impl<F>(editor: &mut Editor, select_fn: F) -where - F: Fn(&Syntax, RopeSlice, Selection) -> Selection, -{ - let (view, doc) = current!(editor); - - if let Some(syntax) = doc.syntax() { - let text = doc.text().slice(..); - let current_selection = doc.selection(view.id); - let selection = select_fn(syntax, text, current_selection.clone()); - doc.set_selection(view.id, selection); - } -} - -fn select_all_siblings(cx: &mut Context) { - let motion = |editor: &mut Editor| { - select_all_impl(editor, object::select_all_siblings); - }; - - cx.editor.apply_motion(motion); -} - -fn select_all_children(cx: &mut Context) { - let motion = |editor: &mut Editor| { - select_all_impl(editor, object::select_all_children); - }; - - cx.editor.apply_motion(motion); + select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) } fn match_brackets(cx: &mut Context) { @@ -5631,8 +4703,6 @@ fn jump_forward(cx: &mut Context) { } doc.set_selection(view.id, selection); - // Document we switch to might not have been opened in the view before - doc.ensure_view_init(view.id); view.ensure_cursor_in_view_center(doc, config.scrolloff); }; } @@ -5653,8 +4723,6 @@ fn jump_backward(cx: &mut Context) { } doc.set_selection(view.id, selection); - // Document we switch to might not have been opened in the view before - doc.ensure_view_init(view.id); view.ensure_cursor_in_view_center(doc, config.scrolloff); }; } @@ -5709,27 +4777,25 @@ fn transpose_view(cx: &mut Context) { cx.editor.transpose_view() } -/// Open a new split in the given direction specified by the action. -/// -/// Maintain the current view (both the cursor's position and view in document). -fn split(editor: &mut Editor, action: Action) { - let (view, doc) = current!(editor); +// split helper, clear it later +fn split(cx: &mut Context, action: Action) { + let (view, doc) = current!(cx.editor); let id = doc.id(); let selection = doc.selection(view.id).clone(); - let offset = doc.view_offset(view.id); + let offset = view.offset; - editor.switch(id, action); + cx.editor.switch(id, action); // match the selection in the previous view - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); doc.set_selection(view.id, selection); // match the view scroll offset (switch doesn't handle this fully // since the selection is only matched after the split) - doc.set_view_offset(view.id, offset); + view.offset = offset; } fn hsplit(cx: &mut Context) { - split(cx.editor, Action::HorizontalSplit); + split(cx, Action::HorizontalSplit); } fn hsplit_new(cx: &mut Context) { @@ -5737,7 +4803,7 @@ fn hsplit_new(cx: &mut Context) { } fn vsplit(cx: &mut Context) { - split(cx.editor, Action::VerticalSplit); + split(cx, Action::VerticalSplit); } fn vsplit_new(cx: &mut Context) { @@ -5771,79 +4837,26 @@ fn wonly(cx: &mut Context) { } fn select_register(cx: &mut Context) { - cx.editor.autoinfo = Some(Info::from_registers( - "Select register", - &cx.editor.registers, - )); + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; if let Some(ch) = event.char() { + cx.editor.autoinfo = None; cx.editor.selected_register = Some(ch); } }) } fn insert_register(cx: &mut Context) { - cx.editor.autoinfo = Some(Info::from_registers( - "Insert register", - &cx.editor.registers, - )); + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; if let Some(ch) = event.char() { + cx.editor.autoinfo = None; cx.register = Some(ch); - paste( - cx.editor, - cx.register - .unwrap_or(cx.editor.config().default_yank_register), - Paste::Cursor, - cx.count(), - ); + paste(cx, Paste::Cursor); } }) } -fn copy_between_registers(cx: &mut Context) { - cx.editor.autoinfo = Some(Info::from_registers( - "Copy from register", - &cx.editor.registers, - )); - cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; - - let Some(source) = event.char() else { - return; - }; - - let Some(values) = cx.editor.registers.read(source, cx.editor) else { - cx.editor.set_error(format!("register {source} is empty")); - return; - }; - let values: Vec<_> = values.map(|value| value.to_string()).collect(); - - cx.editor.autoinfo = Some(Info::from_registers( - "Copy into register", - &cx.editor.registers, - )); - cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; - - let Some(dest) = event.char() else { - return; - }; - - let n_values = values.len(); - match cx.editor.registers.write(dest, values) { - Ok(_) => cx.editor.set_status(format!( - "yanked {n_values} value{} from register {source} to {dest}", - if n_values == 1 { "" } else { "s" } - )), - Err(err) => cx.editor.set_error(err.to_string()), - } - }); - }); -} - fn align_view_top(cx: &mut Context) { let (view, doc) = current!(cx.editor); align_view(doc, view, Align::Top); @@ -5868,43 +4881,41 @@ fn align_view_middle(cx: &mut Context) { return; } let doc_text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); let pos = doc.selection(view.id).primary().cursor(doc_text); - let pos = visual_offset_from_block( - doc_text, - doc.view_offset(view.id).anchor, - pos, - &text_fmt, - &view.text_annotations(doc, None), - ) - .0; + let pos = + visual_offset_from_block(doc_text, view.offset.anchor, pos, &text_fmt, &annotations).0; - let mut offset = doc.view_offset(view.id); - offset.horizontal_offset = pos + view.offset.horizontal_offset = pos .col .saturating_sub((view.inner_area(doc).width as usize) / 2); - doc.set_view_offset(view.id, offset); } fn scroll_up(cx: &mut Context) { - scroll(cx, cx.count(), Direction::Backward, false); + scroll(cx, cx.count(), Direction::Backward); } fn scroll_down(cx: &mut Context) { - scroll(cx, cx.count(), Direction::Forward, false); + scroll(cx, cx.count(), Direction::Forward); } fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direction) { let count = cx.count(); let motion = move |editor: &mut Editor| { let (view, doc) = current!(editor); - let loader = editor.syn_loader.load(); - if let Some(syntax) = doc.syntax() { + if let Some((lang_config, syntax)) = doc.language_config().zip(doc.syntax()) { let text = doc.text().slice(..); let root = syntax.tree().root_node(); let selection = doc.selection(view.id).clone().transform(|range| { let new_range = movement::goto_treesitter_object( - text, range, object, direction, &root, syntax, &loader, count, + text, + range, + object, + direction, + root, + lang_config, + count, ); if editor.mode == Mode::Select { @@ -5920,13 +4931,13 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct } }); - push_jump(view, doc); doc.set_selection(view.id, selection); } else { editor.set_status("Syntax-tree is not available in current buffer"); } }; - cx.editor.apply_motion(motion); + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } fn goto_next_function(cx: &mut Context) { @@ -5969,22 +4980,6 @@ fn goto_prev_test(cx: &mut Context) { goto_ts_object_impl(cx, "test", Direction::Backward) } -fn goto_next_xml_element(cx: &mut Context) { - goto_ts_object_impl(cx, "xml-element", Direction::Forward) -} - -fn goto_prev_xml_element(cx: &mut Context) { - goto_ts_object_impl(cx, "xml-element", Direction::Backward) -} - -fn goto_next_entry(cx: &mut Context) { - goto_ts_object_impl(cx, "entry", Direction::Forward) -} - -fn goto_prev_entry(cx: &mut Context) { - goto_ts_object_impl(cx, "entry", Direction::Backward) -} - fn select_textobject_around(cx: &mut Context) { select_textobject(cx, textobject::TextObject::Around); } @@ -6001,15 +4996,21 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { if let Some(ch) = event.char() { let textobject = move |editor: &mut Editor| { let (view, doc) = current!(editor); - let loader = editor.syn_loader.load(); let text = doc.text().slice(..); let textobject_treesitter = |obj_name: &str, range: Range| -> Range { - let Some(syntax) = doc.syntax() else { - return range; + let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) { + Some(t) => t, + None => return range, }; textobject::textobject_treesitter( - text, range, objtype, obj_name, syntax, &loader, count, + text, + range, + objtype, + obj_name, + syntax.tree().root_node(), + lang_config, + count, ) }; @@ -6043,32 +5044,22 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { 'a' => textobject_treesitter("parameter", range), 'c' => textobject_treesitter("comment", range), 'T' => textobject_treesitter("test", range), - 'e' => textobject_treesitter("entry", range), - 'x' => textobject_treesitter("xml-element", range), 'p' => textobject::textobject_paragraph(text, range, objtype, count), 'm' => textobject::textobject_pair_surround_closest( - doc.syntax(), - text, - range, - objtype, - count, + text, range, objtype, count, ), 'g' => textobject_change(range), // TODO: cancel new ranges if inconsistent surround matches across lines - ch if !ch.is_ascii_alphanumeric() => textobject::textobject_pair_surround( - doc.syntax(), - text, - range, - objtype, - ch, - count, - ), + ch if !ch.is_ascii_alphanumeric() => { + textobject::textobject_pair_surround(text, range, objtype, ch, count) + } _ => range, } }); doc.set_selection(view.id, selection); }; - cx.editor.apply_motion(textobject); + textobject(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(textobject))); } }); @@ -6086,33 +5077,20 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { ("a", "Argument/parameter (tree-sitter)"), ("c", "Comment (tree-sitter)"), ("T", "Test (tree-sitter)"), - ("e", "Data structure entry (tree-sitter)"), - ("m", "Closest surrounding pair (tree-sitter)"), - ("g", "Change"), - ("x", "(X)HTML element (tree-sitter)"), + ("m", "Closest surrounding pair"), (" ", "... or any character acting as a pair"), ]; cx.editor.autoinfo = Some(Info::new(title, &help_text)); } -static SURROUND_HELP_TEXT: [(&str, &str); 6] = [ - ("m", "Nearest matching pair"), - ("( or )", "Parentheses"), - ("{ or }", "Curly braces"), - ("< or >", "Angled brackets"), - ("[ or ]", "Square brackets"), - (" ", "... or any character"), -]; - fn surround_add(cx: &mut Context) { cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; let (view, doc) = current!(cx.editor); // surround_len is the number of new characters being added. let (open, close, surround_len) = match event.char() { Some(ch) => { - let (o, c) = match_brackets::get_pair(ch); + let (o, c) = surround::get_pair(ch); let mut open = Tendril::new(); open.push(o); let mut close = Tendril::new(); @@ -6148,18 +5126,12 @@ fn surround_add(cx: &mut Context) { .with_selection(Selection::new(ranges, selection.primary_index())); doc.apply(&transaction, view.id); exit_select_mode(cx); - }); - - cx.editor.autoinfo = Some(Info::new( - "Surround selections with", - &SURROUND_HELP_TEXT[1..], - )); + }) } fn surround_replace(cx: &mut Context) { let count = cx.count(); cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; let surround_ch = match event.char() { Some('m') => None, // m selects the closest surround pair Some(ch) => Some(ch), @@ -6169,68 +5141,38 @@ fn surround_replace(cx: &mut Context) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - let change_pos = - match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) { - Ok(c) => c, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; - } - }; - - let selection = selection.clone(); - let ranges: SmallVec<[Range; 1]> = change_pos.iter().map(|&p| Range::point(p)).collect(); - doc.set_selection( - view.id, - Selection::new(ranges, selection.primary_index() * 2), - ); + let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; let (view, doc) = current!(cx.editor); let to = match event.char() { Some(to) => to, - None => return doc.set_selection(view.id, selection), + None => return, }; - let (open, close) = match_brackets::get_pair(to); - - // the changeset has to be sorted to allow nested surrounds - let mut sorted_pos: Vec<(usize, char)> = Vec::new(); - for p in change_pos.chunks(2) { - sorted_pos.push((p[0], open)); - sorted_pos.push((p[1], close)); - } - sorted_pos.sort_unstable(); - + let (open, close) = surround::get_pair(to); let transaction = Transaction::change( doc.text(), - sorted_pos.iter().map(|&pos| { + change_pos.iter().enumerate().map(|(i, &pos)| { let mut t = Tendril::new(); - t.push(pos.1); - (pos.0, pos.0 + 1, Some(t)) + t.push(if i % 2 == 0 { open } else { close }); + (pos, pos + 1, Some(t)) }), ); - doc.set_selection(view.id, selection); doc.apply(&transaction, view.id); exit_select_mode(cx); }); - - cx.editor.autoinfo = Some(Info::new( - "Replace with a pair of", - &SURROUND_HELP_TEXT[1..], - )); - }); - - cx.editor.autoinfo = Some(Info::new( - "Replace surrounding pair of", - &SURROUND_HELP_TEXT, - )); + }) } fn surround_delete(cx: &mut Context) { let count = cx.count(); cx.on_next_key(move |cx, event| { - cx.editor.autoinfo = None; let surround_ch = match event.char() { Some('m') => None, // m selects the closest surround pair Some(ch) => Some(ch), @@ -6240,22 +5182,19 @@ fn surround_delete(cx: &mut Context) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - let mut change_pos = - match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) { - Ok(c) => c, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; - } - }; - change_pos.sort_unstable(); // the changeset has to be sorted to allow nested surrounds + let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); doc.apply(&transaction, view.id); exit_select_mode(cx); - }); - - cx.editor.autoinfo = Some(Info::new("Delete surrounding pair of", &SURROUND_HELP_TEXT)); + }) } #[derive(Eq, PartialEq)] @@ -6267,55 +5206,74 @@ enum ShellBehavior { } fn shell_pipe(cx: &mut Context) { - shell_prompt_for_behavior(cx, "pipe:".into(), ShellBehavior::Replace); + shell_prompt(cx, "pipe:".into(), ShellBehavior::Replace); } fn shell_pipe_to(cx: &mut Context) { - shell_prompt_for_behavior(cx, "pipe-to:".into(), ShellBehavior::Ignore); + shell_prompt(cx, "pipe-to:".into(), ShellBehavior::Ignore); } fn shell_insert_output(cx: &mut Context) { - shell_prompt_for_behavior(cx, "insert-output:".into(), ShellBehavior::Insert); + shell_prompt(cx, "insert-output:".into(), ShellBehavior::Insert); } fn shell_append_output(cx: &mut Context) { - shell_prompt_for_behavior(cx, "append-output:".into(), ShellBehavior::Append); + shell_prompt(cx, "append-output:".into(), ShellBehavior::Append); } fn shell_keep_pipe(cx: &mut Context) { - shell_prompt(cx, "keep-pipe:".into(), |cx, args| { - let shell = &cx.editor.config().shell; - let (view, doc) = current!(cx.editor); - let selection = doc.selection(view.id); + ui::prompt( + cx, + "keep-pipe:".into(), + Some('|'), + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + let shell = &cx.editor.config().shell; + if event != PromptEvent::Validate { + return; + } + if input.is_empty() { + return; + } + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); - let mut ranges = SmallVec::with_capacity(selection.len()); - let old_index = selection.primary_index(); - let mut index: Option<usize> = None; - let text = doc.text().slice(..); + let mut ranges = SmallVec::with_capacity(selection.len()); + let old_index = selection.primary_index(); + let mut index: Option<usize> = None; + let text = doc.text().slice(..); - for (i, range) in selection.ranges().iter().enumerate() { - let fragment = range.slice(text); - if let Err(err) = shell_impl(shell, args.join(" ").as_str(), Some(fragment.into())) { - log::debug!("Shell command failed: {}", err); - } else { - ranges.push(*range); - if i >= old_index && index.is_none() { - index = Some(ranges.len() - 1); + for (i, range) in selection.ranges().iter().enumerate() { + let fragment = range.slice(text); + let (_output, success) = match shell_impl(shell, input, Some(fragment.into())) { + Ok(result) => result, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + + // if the process exits successfully, keep the selection + if success { + ranges.push(*range); + if i >= old_index && index.is_none() { + index = Some(ranges.len() - 1); + } } } - } - if ranges.is_empty() { - cx.editor.set_error("No selections remaining"); - return; - } + if ranges.is_empty() { + cx.editor.set_error("No selections remaining"); + return; + } - let index = index.unwrap_or_else(|| ranges.len() - 1); - doc.set_selection(view.id, Selection::new(ranges, index)); - }); + let index = index.unwrap_or_else(|| ranges.len() - 1); + doc.set_selection(view.id, Selection::new(ranges, index)); + }, + ); } -fn shell_impl(shell: &[String], cmd: &str, input: Option<Rope>) -> anyhow::Result<Tendril> { +fn shell_impl(shell: &[String], cmd: &str, input: Option<Rope>) -> anyhow::Result<(Tendril, bool)> { tokio::task::block_in_place(|| helix_lsp::block_on(shell_impl_async(shell, cmd, input))) } @@ -6323,7 +5281,7 @@ async fn shell_impl_async( shell: &[String], cmd: &str, input: Option<Rope>, -) -> anyhow::Result<Tendril> { +) -> anyhow::Result<(Tendril, bool)> { use std::process::Stdio; use tokio::process::Command; ensure!(!shell.is_empty(), "No shell set"); @@ -6366,24 +5324,27 @@ async fn shell_impl_async( process.wait_with_output().await? }; - let output = if !output.status.success() { - if output.stderr.is_empty() { - match output.status.code() { - Some(exit_code) => bail!("Shell command failed: status {}", exit_code), - None => bail!("Shell command failed"), - } + if !output.status.success() { + if !output.stderr.is_empty() { + let err = String::from_utf8_lossy(&output.stderr).to_string(); + log::error!("Shell error: {}", err); + bail!("Shell error: {}", err); + } + match output.status.code() { + Some(exit_code) => bail!("Shell command failed: status {}", exit_code), + None => bail!("Shell command failed"), } - String::from_utf8_lossy(&output.stderr) - // Prioritize `stderr` output over `stdout` } else if !output.stderr.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::debug!("Command printed to stderr: {stderr}"); - stderr - } else { - String::from_utf8_lossy(&output.stdout) - }; + log::debug!( + "Command printed to stderr: {}", + String::from_utf8_lossy(&output.stderr).to_string() + ); + } - Ok(Tendril::from(output)) + let str = std::str::from_utf8(&output.stdout) + .map_err(|_| anyhow!("Process did not output valid UTF-8"))?; + let tendril = Tendril::from(str); + Ok((tendril, output.status.success())) } fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { @@ -6404,23 +5365,16 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { let mut shell_output: Option<Tendril> = None; let mut offset = 0isize; for range in selection.ranges() { - let output = if let Some(output) = shell_output.as_ref() { - output.clone() + let (output, success) = if let Some(output) = shell_output.as_ref() { + (output.clone(), true) } else { - let input = range.slice(text); - match shell_impl(shell, cmd, pipe.then(|| input.into())) { - Ok(mut output) => { - if !input.ends_with("\n") && output.ends_with('\n') { - output.pop(); - if output.ends_with('\r') { - output.pop(); - } - } - + let fragment = range.slice(text); + match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { + Ok(result) => { if !pipe { - shell_output = Some(output.clone()); + shell_output = Some(result.0.clone()); } - output + result } Err(err) => { cx.editor.set_error(err.to_string()); @@ -6429,6 +5383,11 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { } }; + if !success { + cx.editor.set_error("Command failed"); + return; + } + let output_len = output.chars().count(); let (from, to, deleted_len) = match behavior { @@ -6439,18 +5398,12 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { }; // These `usize`s cannot underflow because selection ranges cannot overlap. - let anchor = to - .checked_add_signed(offset) - .expect("Selection ranges cannot overlap") - .checked_sub(deleted_len) - .expect("Selection ranges cannot overlap"); + // Once the MSRV is 1.66.0 (mixed_integer_ops is stabilized), we can use checked + // arithmetic to assert this. + let anchor = (to as isize + offset - deleted_len as isize) as usize; let new_range = Range::new(anchor, anchor + output_len).with_direction(range.direction()); ranges.push(new_range); - offset = offset - .checked_add_unsigned(output_len) - .expect("Selection ranges cannot overlap") - .checked_sub_unsigned(deleted_len) - .expect("Selection ranges cannot overlap"); + offset = offset + output_len as isize - deleted_len as isize; changes.push((from, to, Some(output))); } @@ -6467,41 +5420,28 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { view.ensure_cursor_in_view(doc, config.scrolloff); } -fn shell_prompt<F>(cx: &mut Context, prompt: Cow<'static, str>, mut callback_fn: F) -where - F: FnMut(&mut compositor::Context, Args) + 'static, -{ +fn shell_prompt(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { ui::prompt( cx, prompt, Some('|'), - |editor, input| complete_command_args(editor, SHELL_SIGNATURE, &SHELL_COMPLETER, input, 0), - move |cx, input, event| { - if event != PromptEvent::Validate || input.is_empty() { + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { return; } - match Args::parse(input, SHELL_SIGNATURE, true, |token| { - expansion::expand(cx.editor, token).map_err(|err| err.into()) - }) { - Ok(args) => callback_fn(cx, args), - Err(err) => cx.editor.set_error(err.to_string()), + if input.is_empty() { + return; } + + shell(cx, input, &behavior); }, ); } -fn shell_prompt_for_behavior(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { - shell_prompt(cx, prompt, move |cx, args| { - shell(cx, args.join(" ").as_str(), &behavior) - }) -} - fn suspend(_cx: &mut Context) { #[cfg(not(windows))] - { - _cx.block_try_flush_writes().ok(); - signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); - } + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); } fn add_newline_above(cx: &mut Context) { @@ -6602,48 +5542,6 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { let transaction = Transaction::change(doc.text(), changes.into_iter()); let transaction = transaction.with_selection(new_selection); doc.apply(&transaction, view.id); - exit_select_mode(cx); - } -} - -fn goto_next_tabstop(cx: &mut Context) { - goto_next_tabstop_impl(cx, Direction::Forward) -} - -fn goto_prev_tabstop(cx: &mut Context) { - goto_next_tabstop_impl(cx, Direction::Backward) -} - -fn goto_next_tabstop_impl(cx: &mut Context, direction: Direction) { - let (view, doc) = current!(cx.editor); - let view_id = view.id; - let Some(mut snippet) = doc.active_snippet.take() else { - cx.editor.set_error("no snippet is currently active"); - return; - }; - let tabstop = match direction { - Direction::Forward => Some(snippet.next_tabstop(doc.selection(view_id))), - Direction::Backward => snippet - .prev_tabstop(doc.selection(view_id)) - .map(|selection| (selection, false)), - }; - let Some((selection, last_tabstop)) = tabstop else { - return; - }; - doc.set_selection(view_id, selection); - if !last_tabstop { - doc.active_snippet = Some(snippet) - } - if cx.editor.mode() == Mode::Insert { - cx.on_next_key_fallback(|cx, key| { - if let Some(c) = key.char() { - let (view, doc) = current!(cx.editor); - if let Some(snippet) = &doc.active_snippet { - doc.apply(&snippet.delete_placeholder(doc.text()), view.id); - } - insert_char(cx, c); - } - }) } } @@ -6662,12 +5560,9 @@ fn record_macro(cx: &mut Context) { } }) .collect::<String>(); - match cx.editor.registers.write(reg, vec![s]) { - Ok(_) => cx - .editor - .set_status(format!("Recorded to register [{}]", reg)), - Err(err) => cx.editor.set_error(err.to_string()), - } + cx.editor.registers.write(reg, vec![s]); + cx.editor + .set_status(format!("Recorded to register [{}]", reg)); } else { let reg = cx.register.take().unwrap_or('@'); cx.editor.macro_recording = Some((reg, Vec::new())); @@ -6687,14 +5582,8 @@ fn replay_macro(cx: &mut Context) { return; } - let keys: Vec<KeyEvent> = if let Some(keys) = cx - .editor - .registers - .read(reg, cx.editor) - .filter(|values| values.len() == 1) - .map(|mut values| values.next().unwrap()) - { - match helix_view::input::parse_macro(&keys) { + let keys: Vec<KeyEvent> = if let Some([keys_str]) = cx.editor.registers.read(reg) { + match helix_view::input::parse_macro(keys_str) { Ok(keys) => keys, Err(err) => { cx.editor.set_error(format!("Invalid macro: {}", err)); @@ -6711,7 +5600,7 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.push(reg); let count = cx.count(); - cx.callback.push(Box::new(move |compositor, cx| { + cx.callback = Some(Box::new(move |compositor, cx| { for _ in 0..count { for &key in keys.iter() { compositor.handle_event(&compositor::Event::Key(key), cx); @@ -6723,229 +5612,3 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.pop(); })); } - -fn goto_word(cx: &mut Context) { - jump_to_word(cx, Movement::Move) -} - -fn extend_to_word(cx: &mut Context) { - jump_to_word(cx, Movement::Extend) -} - -fn jump_to_label(cx: &mut Context, labels: Vec<Range>, behaviour: Movement) { - let doc = doc!(cx.editor); - let alphabet = &cx.editor.config().jump_label_alphabet; - if labels.is_empty() { - return; - } - let alphabet_char = |i| { - let mut res = Tendril::new(); - res.push(alphabet[i]); - res - }; - - // Add label for each jump candidate to the View as virtual text. - let text = doc.text().slice(..); - let mut overlays: Vec<_> = labels - .iter() - .enumerate() - .flat_map(|(i, range)| { - [ - Overlay::new(range.from(), alphabet_char(i / alphabet.len())), - Overlay::new( - graphemes::next_grapheme_boundary(text, range.from()), - alphabet_char(i % alphabet.len()), - ), - ] - }) - .collect(); - overlays.sort_unstable_by_key(|overlay| overlay.char_idx); - let (view, doc) = current!(cx.editor); - doc.set_jump_labels(view.id, overlays); - - // Accept two characters matching a visible label. Jump to the candidate - // for that label if it exists. - let primary_selection = doc.selection(view.id).primary(); - let view = view.id; - let doc = doc.id(); - cx.on_next_key(move |cx, event| { - let alphabet = &cx.editor.config().jump_label_alphabet; - let Some(i) = event - .char() - .filter(|_| event.modifiers.is_empty()) - .and_then(|ch| alphabet.iter().position(|&it| it == ch)) - else { - doc_mut!(cx.editor, &doc).remove_jump_labels(view); - return; - }; - let outer = i * alphabet.len(); - // Bail if the given character cannot be a jump label. - if outer > labels.len() { - doc_mut!(cx.editor, &doc).remove_jump_labels(view); - return; - } - cx.on_next_key(move |cx, event| { - doc_mut!(cx.editor, &doc).remove_jump_labels(view); - let alphabet = &cx.editor.config().jump_label_alphabet; - let Some(inner) = event - .char() - .filter(|_| event.modifiers.is_empty()) - .and_then(|ch| alphabet.iter().position(|&it| it == ch)) - else { - return; - }; - if let Some(mut range) = labels.get(outer + inner).copied() { - range = if behaviour == Movement::Extend { - let anchor = if range.anchor < range.head { - let from = primary_selection.from(); - if range.anchor < from { - range.anchor - } else { - from - } - } else { - let to = primary_selection.to(); - if range.anchor > to { - range.anchor - } else { - to - } - }; - Range::new(anchor, range.head) - } else { - range.with_direction(Direction::Forward) - }; - doc_mut!(cx.editor, &doc).set_selection(view, range.into()); - } - }); - }); -} - -fn jump_to_word(cx: &mut Context, behaviour: Movement) { - // Calculate the jump candidates: ranges for any visible words with two or - // more characters. - let alphabet = &cx.editor.config().jump_label_alphabet; - if alphabet.is_empty() { - return; - } - - let jump_label_limit = alphabet.len() * alphabet.len(); - let mut words = Vec::with_capacity(jump_label_limit); - let (view, doc) = current_ref!(cx.editor); - let text = doc.text().slice(..); - - // This is not necessarily exact if there is virtual text like soft wrap. - // It's ok though because the extra jump labels will not be rendered. - let start = text.line_to_char(text.char_to_line(doc.view_offset(view.id).anchor)); - let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1); - - let primary_selection = doc.selection(view.id).primary(); - let cursor = primary_selection.cursor(text); - let mut cursor_fwd = Range::point(cursor); - let mut cursor_rev = Range::point(cursor); - if text.get_char(cursor).is_some_and(|c| !c.is_whitespace()) { - let cursor_word_end = movement::move_next_word_end(text, cursor_fwd, 1); - // single grapheme words need a special case - if cursor_word_end.anchor == cursor { - cursor_fwd = cursor_word_end; - } - let cursor_word_start = movement::move_prev_word_start(text, cursor_rev, 1); - if cursor_word_start.anchor == next_grapheme_boundary(text, cursor) { - cursor_rev = cursor_word_start; - } - } - 'outer: loop { - let mut changed = false; - while cursor_fwd.head < end { - cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1); - // The cursor is on a word that is atleast two graphemes long and - // madeup of word characters. The latter condition is needed because - // move_next_word_end simply treats a sequence of characters from - // the same char class as a word so `=<` would also count as a word. - let add_label = text - .slice(..cursor_fwd.head) - .graphemes_rev() - .take(2) - .take_while(|g| g.chars().all(char_is_word)) - .count() - == 2; - if !add_label { - continue; - } - changed = true; - // skip any leading whitespace - cursor_fwd.anchor += text - .chars_at(cursor_fwd.anchor) - .take_while(|&c| !char_is_word(c)) - .count(); - words.push(cursor_fwd); - if words.len() == jump_label_limit { - break 'outer; - } - break; - } - while cursor_rev.head > start { - cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1); - // The cursor is on a word that is atleast two graphemes long and - // madeup of word characters. The latter condition is needed because - // move_prev_word_start simply treats a sequence of characters from - // the same char class as a word so `=<` would also count as a word. - let add_label = text - .slice(cursor_rev.head..) - .graphemes() - .take(2) - .take_while(|g| g.chars().all(char_is_word)) - .count() - == 2; - if !add_label { - continue; - } - changed = true; - cursor_rev.anchor -= text - .chars_at(cursor_rev.anchor) - .reversed() - .take_while(|&c| !char_is_word(c)) - .count(); - words.push(cursor_rev); - if words.len() == jump_label_limit { - break 'outer; - } - break; - } - if !changed { - break; - } - } - jump_to_label(cx, words, behaviour) -} - -fn lsp_or_syntax_symbol_picker(cx: &mut Context) { - let doc = doc!(cx.editor); - - if doc - .language_servers_with_feature(LanguageServerFeature::DocumentSymbols) - .next() - .is_some() - { - lsp::symbol_picker(cx); - } else if doc.syntax().is_some() { - syntax_symbol_picker(cx); - } else { - cx.editor - .set_error("No language server supporting document symbols or syntax info available"); - } -} - -fn lsp_or_syntax_workspace_symbol_picker(cx: &mut Context) { - let doc = doc!(cx.editor); - - if doc - .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) - .next() - .is_some() - { - lsp::workspace_symbol_picker(cx); - } else { - syntax_workspace_symbol_picker(cx); - } -} |