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 | 4928 |
1 files changed, 1590 insertions, 3338 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 430d4430..1310417e 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,84 +1,62 @@ 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 tui::text::Spans; pub use typed::*; use helix_core::{ - char_idx_at_visual_offset, - chars::char_is_word, - command_line::{self, Args}, - comment, - doc_formatter::TextFormat, - encoding, find_workspace, - graphemes::{self, next_grapheme_boundary}, + comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, - increment, - indent::{self, IndentStyle}, - line_ending::{get_line_ending_of_str, line_end_char_index}, + increment::date_time::DateTimeIncrementor, + increment::{number::NumberIncrementor, 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}, + movement::{self, Direction}, + object, pos_at_coords, pos_at_visual_coords, + regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, - selection, surround, - syntax::config::{BlockCommentToken, LanguageServerFeature}, - text_annotations::{Overlay, TextAnnotations}, - textobject, + selection, shellwords, surround, textobject, + tree_sitter::Node, unicode::width::UnicodeWidthChar, - visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeReader, RopeSlice, - Selection, SmallVec, Syntax, Tendril, Transaction, + visual_coords_at_pos, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, + SmallVec, Tendril, Transaction, }; use helix_view::{ + apply_transaction, + clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, - editor::Action, - expansion, + editor::{Action, 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::{ + args, compositor::{self, Component, Compositor}, - filter_picker_entry, job::Callback, - ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent}, + keymap::ReverseKeymap, + ui::{self, overlay::overlayed, FilePicker, 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::StreamExt; +use std::{collections::HashMap, fmt, future::Future}; +use std::{collections::HashSet, num::NonZeroUsize}; use std::{ borrow::Cow, @@ -87,44 +65,28 @@ 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}; - -pub type OnKeyCallback = Box<dyn FnOnce(&mut Context, KeyEvent)>; -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -pub enum OnKeyCallbackKind { - PseudoPending, - Fallback, -} +use tokio_stream::wrappers::UnboundedReceiverStream; pub struct Context<'a> { 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<Box<dyn FnOnce(&mut Context, KeyEvent)>>, 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) - })); - } - - /// 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(|compositor: &mut Compositor, _| { + compositor.push(component) + })); } #[inline] @@ -132,31 +94,29 @@ 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)); + let callback = Box::pin(async move { + 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) + }, + )); + Ok(call) + }); + self.jobs.callback(callback); } /// Returns 1 if no explicit count was provided @@ -164,56 +124,18 @@ 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, - callback: F, -) -> std::pin::Pin<Box<impl Future<Output = Result<Callback, anyhow::Error>>>> -where - T: Send + 'static, - F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, -{ - Box::pin(async move { - let response = call.await?; - let call: job::Callback = Callback::EditorCompositor(Box::new( - move |editor: &mut Editor, compositor: &mut Compositor| { - callback(editor, compositor, response) - }, - )); - Ok(call) - }) } 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,10 +143,6 @@ pub enum MappableCommand { fun: fn(cx: &mut Context), doc: &'static str, }, - Macro { - name: String, - keys: Vec<KeyEvent>, - }, } macro_rules! static_commands { @@ -248,39 +166,19 @@ 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 { 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(); - })); - } } } @@ -288,7 +186,6 @@ impl MappableCommand { match &self { Self::Typable { name, .. } => name, Self::Static { name, .. } => name, - Self::Macro { name, .. } => name, } } @@ -296,7 +193,6 @@ impl MappableCommand { match &self { Self::Typable { doc, .. } => doc, Self::Static { doc, .. } => doc, - Self::Macro { name, .. } => name, } } @@ -307,14 +203,10 @@ impl MappableCommand { move_char_right, "Move right", move_line_up, "Move up", move_line_down, "Move down", - move_visual_line_up, "Move up", - move_visual_line_down, "Move down", extend_char_left, "Extend left", extend_char_right, "Extend right", extend_line_up, "Extend up", extend_line_down, "Extend down", - extend_visual_line_up, "Extend up", - extend_visual_line_down, "Extend down", copy_selection_on_next_line, "Copy selection on next line", copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to start of next word", @@ -324,13 +216,6 @@ impl MappableCommand { move_next_long_word_start, "Move to start of next long word", 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", @@ -338,13 +223,6 @@ impl MappableCommand { extend_next_long_word_start, "Extend to start of next long word", 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,16 +240,10 @@ 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", split_selection_on_newline, "Split selection on newlines", - merge_selections, "Merge selections", - merge_consecutive_selections, "Merge consecutive selections", search, "Search for regex pattern", rsearch, "Reverse search for regex pattern", search_next, "Select next search match", @@ -379,14 +251,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 +269,13 @@ 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_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", @@ -427,18 +287,15 @@ impl MappableCommand { select_mode, "Enter selection extend mode", exit_select_mode, "Exit selection mode", goto_definition, "Goto definition", - goto_declaration, "Goto declaration", add_newline_above, "Add newline above", add_newline_below, "Add newline below", goto_type_definition, "Goto type definition", 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 +305,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,23 +315,17 @@ 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", goto_first_nonwhitespace, "Goto first non-blank in line", trim_selections, "Trim whitespace from selections", extend_to_line_start, "Extend to line start", - extend_to_first_nonwhitespace, "Extend to first non-blank in line", 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,9 +338,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", yank_joined_to_primary_clipboard, "Join and yank selections to primary clipboard", @@ -517,19 +364,14 @@ 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", rotate_selection_contents_backward, "Rotate selections contents backward", - 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", @@ -543,7 +385,6 @@ impl MappableCommand { swap_view_down, "Swap with split below", transpose_view, "Transpose splits", rotate_view, "Goto next window", - rotate_view_reverse, "Goto previous window", hsplit, "Horizontal bottom split", hsplit_new, "Horizontal bottom split scratch buffer", vsplit, "Vertical right split", @@ -552,7 +393,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,14 +415,9 @@ 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", - dap_restart, "Restart debugging session", dap_toggle_breakpoint, "Toggle breakpoint", dap_continue, "Continue program execution", dap_pause, "Pause program execution", @@ -609,32 +444,14 @@ 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", ); } impl fmt::Debug for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MappableCommand::Static { name, .. } => { - f.debug_tuple("MappableCommand").field(name).finish() - } - MappableCommand::Typable { name, args, .. } => f - .debug_tuple("MappableCommand") - .field(name) - .field(args) - .finish(), - MappableCommand::Macro { name, keys, .. } => f - .debug_tuple("MappableCommand") - .field(name) - .field(keys) - .finish(), - } + f.debug_tuple("MappableCommand") + .field(&self.name()) + .finish() } } @@ -649,28 +466,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(' ').into_iter().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() @@ -696,16 +506,12 @@ impl PartialEq for MappableCommand { match (self, other) { ( MappableCommand::Typable { - name: first_name, - args: first_args, - .. + name: first_name, .. }, MappableCommand::Typable { - name: second_name, - args: second_args, - .. + name: second_name, .. }, - ) => first_name == second_name && first_args == second_args, + ) => first_name == second_name, ( MappableCommand::Static { name: first_name, .. @@ -721,28 +527,18 @@ impl PartialEq for MappableCommand { fn no_op(_cx: &mut Context) {} -type MoveFn = - fn(RopeSlice, Range, Direction, usize, Movement, &TextFormat, &mut TextAnnotations) -> Range; - -fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movement) { +fn move_impl<F>(cx: &mut Context, move_fn: F, dir: Direction, behaviour: Movement) +where + F: Fn(RopeSlice, Range, Direction, usize, Movement, usize) -> Range, +{ let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - let text_fmt = doc.text_format(view.inner_area(doc).width, None); - let mut annotations = view.text_annotations(doc, None); - let selection = doc.selection(view.id).clone().transform(|range| { - move_fn( - text, - range, - dir, - count, - behaviour, - &text_fmt, - &mut annotations, - ) - }); - drop(annotations); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| move_fn(text, range, dir, count, behaviour, doc.tab_width())); doc.set_selection(view.id, selection); } @@ -764,24 +560,6 @@ fn move_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Move) } -fn move_visual_line_up(cx: &mut Context) { - move_impl( - cx, - move_vertically_visual, - Direction::Backward, - Movement::Move, - ) -} - -fn move_visual_line_down(cx: &mut Context) { - move_impl( - cx, - move_vertically_visual, - Direction::Forward, - Movement::Move, - ) -} - fn extend_char_left(cx: &mut Context) { move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend) } @@ -798,24 +576,6 @@ fn extend_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Extend) } -fn extend_visual_line_up(cx: &mut Context) { - move_impl( - cx, - move_vertically_visual, - Direction::Backward, - Movement::Extend, - ) -} - -fn extend_visual_line_down(cx: &mut Context) { - move_impl( - cx, - move_vertically_visual, - Direction::Forward, - Movement::Extend, - ) -} - fn goto_line_end_impl(view: &mut View, doc: &mut Document, movement: Movement) { let text = doc.text().slice(..); @@ -906,29 +666,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(); @@ -944,80 +703,66 @@ fn extend_to_line_start(cx: &mut Context) { } fn kill_to_line_start(cx: &mut Context) { - delete_by_selection_insert_mode( - cx, - move |text, range| { - let line = range.cursor_line(text); - let first_char = text.line_to_char(line); - let anchor = range.cursor(text); - 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() { - if first_char + pos < anchor { - // select until first non-blank in line if cursor is after it - first_char + pos - } else { - // select until start of line - first_char - } + 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 first_char = text.line_to_char(line); + let anchor = range.cursor(text); + let head = if anchor == first_char && line != 0 { + // select until previous line + line_end_char_index(&text, line - 1) + } 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 } else { // select until start of line first_char - }; - (head, anchor) - }, - Direction::Backward, - ); + } + } else { + // select until start of line + first_char + }; + Range::new(head, anchor) + }); + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } fn kill_to_line_end(cx: &mut Context) { - delete_by_selection_insert_mode( - cx, - |text, range| { - let line = range.cursor_line(text); - let line_end_pos = line_end_char_index(&text, line); - let pos = range.cursor(text); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); - // if the cursor is on the newline char delete that - if pos == line_end_pos { - (pos, text.line_to_char(line + 1)) - } else { - (pos, line_end_pos) - } - }, - Direction::Forward, - ); -} + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let line_end_pos = line_end_char_index(&text, line); + let pos = range.cursor(text); -fn goto_first_nonwhitespace(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + let mut new_range = range.put_cursor(text, line_end_pos, true); + // don't want to remove the line separator itself if the cursor doesn't reach the end of line. + if pos != line_end_pos { + new_range.head = line_end_pos; + } + new_range + }); + delete_selection_insert_mode(doc, view, &selection); - goto_first_nonwhitespace_impl( - view, - doc, - if cx.editor.mode == Mode::Select { - Movement::Extend - } else { - Movement::Move - }, - ) + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } -fn extend_to_first_nonwhitespace(cx: &mut Context) { +fn goto_first_nonwhitespace(cx: &mut Context) { let (view, doc) = current!(cx.editor); - goto_first_nonwhitespace_impl(view, doc, Movement::Extend) -} - -fn goto_first_nonwhitespace_impl(view: &mut View, doc: &mut Document, movement: Movement) { let text = doc.text().slice(..); 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) + range.put_cursor(text, pos, cx.editor.mode == Mode::Select) } else { range } @@ -1058,10 +803,7 @@ fn trim_selections(cx: &mut Context) { } // align text in selection -#[allow(deprecated)] fn align_selections(cx: &mut Context) { - use helix_core::visual_coords_at_pos; - let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -1122,15 +864,13 @@ fn align_selections(cx: &mut Context) { changes.sort_unstable_by_key(|(from, _, _)| *from); let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); - exit_select_mode(cx); + apply_transaction(&transaction, doc, view); } 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(); @@ -1140,23 +880,17 @@ fn goto_window(cx: &mut Context, align: Align) { // as we type let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); - 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::Bottom => { - 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)); + let last_line = view.last_line(doc); - let pos = view - .pos_at_visual_coords(doc, visual_line as u16, 0, false) - .expect("visual_line was constrained to the view area"); + let line = match align { + Align::Top => view.offset.row + scrolloff + count, + Align::Center => view.offset.row + ((last_line - view.offset.row) / 2), + Align::Bottom => last_line.saturating_sub(scrolloff + count), + } + .max(view.offset.row + scrolloff) + .min(last_line.saturating_sub(scrolloff)); + let pos = doc.text().line_to_char(line); let text = doc.text().slice(..); let selection = doc .selection(view.id) @@ -1216,30 +950,10 @@ fn move_prev_long_word_start(cx: &mut Context) { move_word_impl(cx, movement::move_prev_long_word_start) } -fn move_prev_long_word_end(cx: &mut Context) { - move_word_impl(cx, movement::move_prev_long_word_end) -} - 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 +974,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 +987,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 +1028,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)); } } @@ -1462,90 +1107,14 @@ fn extend_prev_long_word_start(cx: &mut Context) { extend_word_impl(cx, movement::move_prev_long_word_start) } -fn extend_prev_long_word_end(cx: &mut Context) { - extend_word_impl(cx, movement::move_prev_long_word_end) -} - 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 +1127,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 +1146,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, true, ch, 1); + }))); }) } @@ -1663,39 +1229,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,20 +1297,26 @@ 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) } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); exit_select_mode(cx); } }) @@ -1755,52 +1334,20 @@ where (range.from(), range.to(), Some(text)) }); - doc.apply(&transaction, view.id); - exit_select_mode(cx); -} - -enum CaseSwitcher { - Upper(ToUppercase), - Lower(ToLowercase), - Keep(Option<char>), + apply_transaction(&transaction, doc, view); } -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,173 +1366,88 @@ 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(..); - let cursor = range.cursor(text); - let height = view.inner_height(); - - let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); - let offset = match direction { - Forward => offset as isize, - Backward => -(offset as isize), - }; - - 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( - doc_text, - view_offset.anchor, - view_offset.vertical_offset as isize + offset, - 0, - &text_fmt, - // &annotations, - &view.text_annotations(&*doc, None), - ); - doc.set_view_offset(view.id, view_offset); + let cursor = visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + let doc_last_line = doc.text().len_lines().saturating_sub(1); - let doc_text = doc.text().slice(..); - let mut annotations = view.text_annotations(&*doc, None); + let last_line = view.last_line(doc); - 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); + if direction == Backward && view.offset.row == 0 + || direction == Forward && last_line == doc_last_line + { return; } - let view_offset = doc.view_offset(view.id); - - let mut head; - match direction { - Forward => { - let off; - (head, off) = char_idx_at_visual_offset( - doc_text, - view_offset.anchor, - (view_offset.vertical_offset + scrolloff) as isize, - 0, - &text_fmt, - &annotations, - ); - head += (off != 0) as usize; - if head <= cursor { - return; - } - } - Backward => { - head = char_idx_at_visual_offset( - doc_text, - view_offset.anchor, - (view_offset.vertical_offset + height - scrolloff - 1) as isize, - 0, - &text_fmt, - &annotations, - ) - .0; - if head >= cursor { - return; - } - } + let height = view.inner_height(); + + let scrolloff = config.scrolloff.min(height / 2); + + view.offset.row = match direction { + Forward => view.offset.row + offset, + Backward => view.offset.row.saturating_sub(offset), } + .min(doc_last_line); - let anchor = if cx.editor.mode == Mode::Select { - range.anchor - } else { - head - }; + // recalculate last line + let last_line = view.last_line(doc); - // replace primary selection with an empty selection at cursor pos - let prim_sel = Range::new(anchor, head); - 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); + // clamp into viewport + let line = cursor + .row + .max(view.offset.row + scrolloff) + .min(last_line.saturating_sub(scrolloff)); + + // If cursor needs moving, replace primary selection + if line != cursor.row { + let head = pos_at_visual_coords(text, Position::new(line, cursor.col), doc.tab_width()); // this func will properly truncate to line end + + let anchor = if cx.editor.mode == Mode::Select { + range.anchor + } else { + head + }; + + // replace primary selection with an empty selection at cursor pos + let prim_sel = Range::new(anchor, head); + let mut sel = doc.selection(view.id).clone(); + let idx = sel.primary_index(); + sel = sel.replace(idx, prim_sel); + 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)] -// currently uses the deprecated `visual_coords_at_pos`/`pos_at_visual_coords` functions -// as this function ignores softwrapping (and virtual text) and instead only cares -// about "text visual position" -// -// TODO: implement a variant of that uses visual lines and respects virtual text fn copy_selection_on_line(cx: &mut Context, direction: Direction) { - use helix_core::{pos_at_visual_coords, visual_coords_at_pos}; - let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -2052,10 +1514,6 @@ fn copy_selection_on_line(cx: &mut Context, direction: Direction) { sels += 1; } - if anchor_row == 0 && head_row == 0 { - break; - } - i += 1; } } @@ -2086,8 +1544,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 +1554,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 +1566,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,26 +1581,19 @@ 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)); - doc.set_selection(view.id, selection); -} - -fn merge_selections(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let selection = doc.selection(view.id).clone().merge_ranges(); - doc.set_selection(view.id, selection); -} - -fn merge_consecutive_selections(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let selection = doc.selection(view.id).clone().merge_consecutive_ranges(); + // 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); } #[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 +1621,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 +1654,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 +1678,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 +1698,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 +1716,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 +1739,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 +1752,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 +1792,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 +1828,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 +1855,165 @@ 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 label(&self, current_path: &Self::Data) -> Spans { + 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 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 smart_case = config.search.smart_case; + let file_picker_config = config.file_picker.clone(); - 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"); + 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) + // We always want to ignore the .git directory, otherwise if + // `ignore` is turned off above, we end up with a lot of noise + // in our picker. + .filter_entry(|entry| entry.file_name() != ".git") + .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 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 current_path = doc_mut!(cx.editor).path().cloned(); + + 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())); + let picker = FilePicker::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; + } + } - 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 line_num = *line_num; + let (view, doc) = current!(cx.editor); + let text = doc.text(); + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); - cx.push_layer(Box::new(overlaid(picker))); + doc.set_selection(view.id, Selection::single(start, end)); + align_view(doc, view, Align::Center); + }, + |_editor, FileResult { path, line_num }| { + Some((path.clone().into(), Some((*line_num, *line_num)))) + }, + ); + compositor.push(Box::new(overlayed(picker))); + }, + )); + Ok(call) + }; + cx.jobs.callback(show_picker); } enum Extend { @@ -2701,6 +2037,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); @@ -2709,10 +2046,16 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { 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 start = text.line_to_char(match extend { + Extend::Above => start_line.saturating_sub(count - 1), + Extend::Below => start_line, + }); let end = text.line_to_char( - (end_line + 1) // newline of end_line - .min(text.len_lines()), + match extend { + Extend::Above => end_line + 1, // the start of next line + Extend::Below => end_line + count, + } + .min(text.len_lines()), ); // extend to previous/next line if current line is selected @@ -2726,11 +2069,8 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { } } else { match extend { - Extend::Above => (end, text.line_to_char(start_line.saturating_sub(count - 1))), - Extend::Below => ( - start, - text.line_to_char((end_line + count).min(text.len_lines())), - ), + Extend::Above => (end, start), + Extend::Below => (start, end), } }; @@ -2739,59 +2079,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,48 +2139,24 @@ 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 - let transaction = - Transaction::delete_by_selection(doc.text(), selection, |range| (range.from(), range.to())); - doc.apply(&transaction, view.id); + // then delete + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), None) + }); + apply_transaction(&transaction, doc, view); match op { Operation::Delete => { @@ -2901,74 +2164,35 @@ 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); } } } #[inline] -fn delete_by_selection_insert_mode( - cx: &mut Context, - mut f: impl FnMut(RopeSlice, &Range) -> Deletion, - direction: Direction, -) { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let mut selection = SmallVec::new(); - let mut insert_newline = false; - let text_len = text.len_chars(); - let mut transaction = - Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| { - let (start, end) = f(text, range); - if direction == Direction::Forward { - let mut range = *range; - if range.head > range.anchor { - insert_newline |= end == text_len; - // move the cursor to the right so that the selection - // doesn't shrink when deleting forward (so the text appears to - // move to left) - // += 1 is enough here as the range is normalized to grapheme boundaries - // later anyway - range.head += 1; - } - selection.push(range); - } - (start, end) - }); - - // in case we delete the last character and the cursor would be moved to the EOF char - // insert a newline, just like when entering append mode - if insert_newline { - transaction = transaction.insert_at_eof(doc.line_ending.as_str().into()); - } - - if direction == Direction::Forward { - doc.set_selection( - view.id, - Selection::new(selection, doc.selection(view.id).primary_index()), - ); - } - doc.apply(&transaction, view.id); +fn delete_selection_insert_mode(doc: &mut Document, view: &mut View, selection: &Selection) { + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), None) + }); + apply_transaction(&transaction, doc, view); } 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) { @@ -3046,7 +2270,7 @@ fn append_mode(cx: &mut Context) { doc.text(), [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } let selection = doc.selection(view.id).clone().transform(|range| { @@ -3059,93 +2283,17 @@ 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); - cx.push_layer(Box::new(overlaid(picker))); -} - -fn file_picker_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 => { - cx.editor.set_error("current buffer has no path or parent"); - return; - } - }; - - let picker = ui::file_picker(cx.editor, path); - cx.push_layer(Box::new(overlaid(picker))); + // We don't specify language markers, root will be the root of the current + // git repo or the current dir if we're not in a repo + let root = find_root(None, &[]); + let picker = ui::file_picker(root, &cx.editor.config()); + cx.push_layer(Box::new(overlayed(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); - 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))); - } -} - -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 - } - }; - - if let Ok(picker) = ui::file_explorer(path, cx.editor) { - cx.push_layer(Box::new(overlaid(picker))); - } -} - -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; - } - - if let Ok(picker) = ui::file_explorer(cwd, cx.editor) { - cx.push_layer(Box::new(overlaid(picker))); - } + 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(overlayed(picker))); } fn buffer_picker(cx: &mut Context) { @@ -3156,7 +2304,36 @@ fn buffer_picker(cx: &mut Context) { path: Option<PathBuf>, is_modified: bool, is_current: bool, - focused_at: std::time::Instant, + } + + impl ui::menu::Item for BufferMeta { + type Data = (); + + fn label(&self, _data: &Self::Data) -> Spans { + 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_modified { + flags.push("+"); + } + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + format!("{} {}{}", self.id, path, flag).into() + } } let new_meta = |doc: &Document| BufferMeta { @@ -3164,57 +2341,29 @@ fn buffer_picker(cx: &mut Context) { path: doc.path().cloned(), is_modified: doc.is_modified(), is_current: doc.id() == current, - focused_at: doc.focused_at, }; - let mut items = cx - .editor - .documents - .values() - .map(new_meta) - .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| { - 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)) - }); - cx.push_layer(Box::new(overlaid(picker))); + let picker = FilePicker::new( + cx.editor + .documents + .values() + .map(|doc| new_meta(doc)) + .collect(), + (), + |cx, meta, action| { + cx.editor.switch(meta.id, action); + }, + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + 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)))) + }, + ); + cx.push_layer(Box::new(overlayed(picker))); } fn jumplist_picker(cx: &mut Context) { @@ -3226,10 +2375,30 @@ fn jumplist_picker(cx: &mut Context) { is_current: bool, } - 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); - view.sync_changes(doc); + impl ui::menu::Item for JumpMeta { + type Data = (); + + fn label(&self, _data: &Self::Data) -> Spans { + 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() } } @@ -3252,241 +2421,97 @@ 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())) - }), + let picker = FilePicker::new( + 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| { - let doc = &editor.documents.get(&meta.id)?; - let line = meta.selection.primary().cursor_line(doc.text().slice(..)); - Some((meta.id.into(), Some((line, line)))) - }); - cx.push_layer(Box::new(overlaid(picker))); + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + let line = meta.selection.primary().cursor_line(doc.text().slice(..)); + Some((meta.path.clone()?.into(), Some((line, line)))) + }, + ); + cx.push_layer(Box::new(overlayed(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 label(&self, keymap: &Self::Data) -> Spans { + 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(), + }, + } + } } 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 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, + register: None, + count: std::num::NonZeroUsize::new(1), editor: cx.editor, - callback: Vec::new(), + callback: None, on_next_key_callback: None, jobs: cx.jobs, }; - let focus = view!(ctx.editor).id; - command.execute(&mut ctx); - - if ctx.editor.tree.contains(focus) { - let config = ctx.editor.config(); - let mode = ctx.editor.mode(); - let view = view_mut!(ctx.editor, focus); - let doc = doc_mut!(ctx.editor, &view.doc); - - view.ensure_cursor_in_view(doc, config.scrolloff); - - if mode != Mode::Insert { - doc.append_changes_to_history(view); - } - } }); - compositor.push(Box::new(overlaid(picker))); + compositor.push(Box::new(overlayed(picker))); }, )); } 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 { @@ -3495,88 +2520,24 @@ fn last_picker(cx: &mut Context) { })); } -/// Fallback position to use for [`insert_with_indent`]. -enum IndentFallbackPos { - LineStart, - LineEnd, -} - -// `I` inserts at the first nonwhitespace character of each line with a selection. -// If the line is empty, automatically indent. +// I inserts at the first nonwhitespace character of each line with a selection fn insert_at_line_start(cx: &mut Context) { - insert_with_indent(cx, IndentFallbackPos::LineStart); + goto_first_nonwhitespace(cx); + enter_insert_mode(cx); } -// `A` inserts at the end of each line with a selection. -// If the line is empty, automatically indent. +// A inserts at the end of each line with a selection fn insert_at_line_end(cx: &mut Context) { - insert_with_indent(cx, IndentFallbackPos::LineEnd); -} - -// Enter insert mode and auto-indent the current line if it is empty. -// If the line is not empty, move the cursor to the specified fallback position. -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 syntax = doc.syntax(); - let tab_width = doc.tab_width(); - - let mut ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; - - let mut transaction = Transaction::change_by_selection(contents, selection, |range| { - let cursor_line = range.cursor_line(text); - let cursor_line_start = text.line_to_char(cursor_line); - - if line_end_char_index(&text, cursor_line) == cursor_line_start { - // line is empty => auto indent - let line_end_index = cursor_line_start; - - let indent = indent::indent_for_newline( - &loader, - syntax, - &doc.config.load().indent_heuristic, - &doc.indent_style, - tab_width, - text, - cursor_line, - line_end_index, - cursor_line, - ); - - // calculate new selection ranges - let pos = offs + cursor_line_start; - let indent_width = indent.chars().count(); - ranges.push(Range::point(pos + indent_width)); - offs += indent_width; - - (line_end_index, line_end_index, Some(indent.into())) - } 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::LineEnd => line_end_char_index(&text, cursor_line), - }; - - ranges.push(range.put_cursor(text, pos + offs, cx.editor.mode == Mode::Select)); - - (cursor_line_start, cursor_line_start, None) - } + let selection = doc.selection(view.id).clone().transform(|range| { + let text = doc.text().slice(..); + let line = range.cursor_line(text); + let pos = line_end_char_index(&text, line); + Range::new(pos, pos) }); - - transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + doc.set_selection(view.id, selection); } // Creates an LspCallback that waits for formatting changes to be computed. When they're done, @@ -3602,23 +2563,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 { + apply_transaction(&format, doc, view); + 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,140 +2591,84 @@ 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())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } // 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 +2676,16 @@ 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_impl(cx.editor, cx.count) } -fn goto_line_without_jumplist( - editor: &mut Editor, - count: Option<NonZeroUsize>, - movement: Movement, -) { +fn goto_line_impl(editor: &mut Editor, count: Option<NonZeroUsize>) { if let Some(count) = count { let (view, doc) = current!(editor); let text = doc.text().slice(..); @@ -3818,21 +2700,14 @@ 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)); + push_jump(view, doc); 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 +2720,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 +2744,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); } } @@ -3938,84 +2789,77 @@ fn exit_select_mode(cx: &mut Context) { } } +fn goto_pos(editor: &mut Editor, pos: usize) { + let (view, doc) = current!(editor); + + push_jump(view, doc); + doc.set_selection(view.id, Selection::point(pos)); + align_view(doc, view, Align::Center); +} + fn goto_first_diag(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().first() { - Some(diag) => Selection::single(diag.range.start, diag.range.end), + let doc = doc!(cx.editor); + let pos = match doc.diagnostics().first() { + Some(diag) => diag.range.start, None => return, }; - push_jump(view, doc); - doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); + goto_pos(cx.editor, pos); } fn goto_last_diag(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().last() { - Some(diag) => Selection::single(diag.range.start, diag.range.end), + let doc = doc!(cx.editor); + let pos = match doc.diagnostics().last() { + Some(diag) => diag.range.start, None => return, }; - push_jump(view, doc); - doc.set_selection(view.id, selection); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); + goto_pos(cx.editor, pos); } fn goto_next_diag(cx: &mut Context) { - let motion = move |editor: &mut Editor| { - let (view, doc) = current!(editor); + let editor = &mut cx.editor; + let (view, doc) = current!(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 + .diagnostics() + .iter() + .find(|diag| diag.range.start > cursor_pos) + .or_else(|| doc.diagnostics().first()); - 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 pos = match diag { + Some(diag) => diag.range.start, + None => return, }; - cx.editor.apply_motion(motion); + goto_pos(editor, pos); } fn goto_prev_diag(cx: &mut Context) { - let motion = move |editor: &mut Editor| { - let (view, doc) = current!(editor); + let editor = &mut cx.editor; + let (view, doc) = current!(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 + .diagnostics() + .iter() + .rev() + .find(|diag| diag.range.start < cursor_pos) + .or_else(|| doc.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 pos = match diag { + Some(diag) => diag.range.start, + None => return, }; - cx.editor.apply_motion(motion) + + goto_pos(editor, pos); } fn goto_first_change(cx: &mut Context) { @@ -4028,21 +2872,20 @@ fn goto_last_change(cx: &mut Context) { fn goto_first_change_impl(cx: &mut Context, reverse: bool) { let editor = &mut cx.editor; - let (view, doc) = current!(editor); + let (_, doc) = current!(editor); if let Some(handle) = doc.diff_handle() { let hunk = { - let diff = handle.load(); + let hunks = handle.hunks(); let idx = if reverse { - diff.len().saturating_sub(1) + hunks.len().saturating_sub(1) } else { 0 }; - diff.nth_hunk(idx) + hunks.nth_hunk(idx) }; 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)); + let pos = doc.text().line_to_char(hunk.after.start as usize); + goto_pos(editor, pos) } } } @@ -4070,20 +2913,30 @@ fn goto_next_change_impl(cx: &mut Context, direction: Direction) { let selection = doc.selection(view.id).clone().transform(|range| { let cursor_line = range.cursor_line(doc_text) as u32; - let diff = diff_handle.load(); + let hunks = diff_handle.hunks(); let hunk_idx = match direction { - Direction::Forward => diff + Direction::Forward => hunks .next_hunk(cursor_line) - .map(|idx| (idx + count).min(diff.len() - 1)), - Direction::Backward => diff + .map(|idx| (idx + count).min(hunks.len() - 1)), + Direction::Backward => hunks .prev_hunk(cursor_line) .map(|idx| idx.saturating_sub(count)), }; - let Some(hunk_idx) = hunk_idx else { + // TODO refactor with let..else once MSRV reaches 1.65 + let hunk_idx = if let Some(hunk_idx) = hunk_idx { + hunk_idx + } else { return range; }; - let hunk = diff.nth_hunk(hunk_idx); - let new_range = hunk_range(hunk, doc_text); + let hunk = hunks.nth_hunk(hunk_idx); + + let hunk_start = doc_text.line_to_char(hunk.after.start as usize); + let hunk_end = if hunk.after.is_empty() { + hunk_start + 1 + } else { + doc_text.line_to_char(hunk.after.end as usize) + }; + let new_range = Range::new(hunk_start, hunk_end); if editor.mode == Mode::Select { let head = if new_range.head < range.anchor { new_range.anchor @@ -4097,31 +2950,16 @@ 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); -} - -/// Returns the [Range] for a [Hunk] in the given text. -/// Additions and modifications cover the added and modified ranges. -/// Deletions are represented as the point at the start of the deletion hunk. -fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range { - let anchor = text.line_to_char(hunk.after.start as usize); - let head = if hunk.after.is_empty() { - anchor + 1 - } else { - text.line_to_char(hunk.after.end as usize) - }; - - Range::new(anchor, head) + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } 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 +2973,92 @@ 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 language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + let capabilities = language_server.capabilities(); + + if let Some(lsp::CompletionOptions { + trigger_characters: Some(triggers), + .. + }) = &capabilities.completion_provider + { + // TODO: what if trigger is multiple chars long + if triggers.iter().any(|trigger| trigger.contains(ch)) { + 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); + // The language_server!() macro is not used here since it will + // print an "LSP not active for current buffer" message on + // every keypress. + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => 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 +3070,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); @@ -4161,128 +3084,43 @@ pub mod insert { let (view, doc) = current!(cx.editor); if let Some(t) = transaction { - doc.apply(&t, view.id); + apply_transaction(&t, doc, view); } - 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(..)), indent, ); - 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; - }); + apply_transaction(&transaction, doc, view); } 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 +3131,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 +3164,39 @@ 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); + .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) + .is_some(); - 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,32 +3204,30 @@ 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())); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn delete_char_backward(cx: &mut Context) { let count = cx.count(); let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); - let tab_width = doc.tab_width(); - let indent_width = doc.indent_width(); + let indent_unit = doc.indent_style.as_str(); + let tab_size = doc.tab_width(); let auto_pairs = doc.auto_pairs(cx.editor); let transaction = - Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| { + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let pos = range.cursor(text); if pos == 0 { - return (pos, pos); + return (pos, pos, None); } let line_start_pos = text.line_to_char(range.cursor_line(text)); // consider to delete by indent level if all characters before `pos` are indent units. @@ -4426,13 +3235,24 @@ pub mod insert { if !fragment.is_empty() && fragment.chars().all(|ch| ch == ' ' || ch == '\t') { if text.get_char(pos.saturating_sub(1)) == Some('\t') { // fast path, delete one char - (graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos) + ( + graphemes::nth_prev_grapheme_boundary(text, pos, 1), + pos, + None, + ) } else { + let unit_len = indent_unit.chars().count(); + // NOTE: indent_unit always contains 'only spaces' or 'only tab' according to `IndentStyle` definition. + let unit_size = if indent_unit.starts_with('\t') { + tab_size * unit_len + } else { + unit_len + }; let width: usize = fragment .chars() .map(|ch| { if ch == '\t' { - tab_width + tab_size } else { // it can be none if it still meet control characters other than '\t' // here just set the width to 1 (or some value better?). @@ -4440,9 +3260,9 @@ pub mod insert { } }) .sum(); - let mut drop = width % indent_width; // round down to nearest unit + let mut drop = width % unit_size; // round down to nearest unit if drop == 0 { - drop = indent_width + drop = unit_size }; // if it's already at a unit, consume a whole unit let mut chars = fragment.chars().rev(); let mut start = pos; @@ -4453,7 +3273,7 @@ pub mod insert { _ => break, } } - (start, pos) // delete! + (start, pos, None) // delete! } } else { match ( @@ -4471,56 +3291,73 @@ pub mod insert { ( graphemes::nth_prev_grapheme_boundary(text, pos, count), graphemes::nth_next_grapheme_boundary(text, pos, count), + None, ) } _ => // delete 1 char { - (graphemes::nth_prev_grapheme_boundary(text, pos, count), pos) + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + pos, + None, + ) } } } }); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_char_forward(cx: &mut Context) { let count = cx.count(); - delete_by_selection_insert_mode( - cx, - |text, range| { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let pos = range.cursor(text); - (pos, graphemes::nth_next_grapheme_boundary(text, pos, count)) - }, - Direction::Forward, - ) + ( + pos, + graphemes::nth_next_grapheme_boundary(text, pos, count), + None, + ) + }); + apply_transaction(&transaction, doc, view); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_word_backward(cx: &mut Context) { let count = cx.count(); - delete_by_selection_insert_mode( - cx, - |text, range| { - let anchor = movement::move_prev_word_start(text, *range, count).from(); - let next = Range::new(anchor, range.cursor(text)); - let range = exclude_cursor(text, next, *range); - (range.from(), range.to()) - }, - Direction::Backward, - ); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let anchor = movement::move_prev_word_start(text, range, count).from(); + let next = Range::new(anchor, range.cursor(text)); + exclude_cursor(text, next, range) + }); + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_word_forward(cx: &mut Context) { let count = cx.count(); - delete_by_selection_insert_mode( - cx, - |text, range| { - let head = movement::move_next_word_end(text, *range, count).to(); - (range.cursor(text), head) - }, - Direction::Forward, - ); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let head = movement::move_next_word_end(text, range, count).to(); + Range::new(range.cursor(text), head) + }); + + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } } @@ -4580,25 +3417,34 @@ 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), + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let values: Vec<String> = doc + .selection(view.id) + .fragments(text) + .map(Cow::into_owned) + .collect(); + + let msg = format!( + "yanked {} selection(s) to register {}", + values.len(), + cx.register.unwrap_or('"') ); - exit_select_mode(cx); -} -fn yank_to_clipboard(cx: &mut Context) { - yank_impl(cx.editor, '+'); - exit_select_mode(cx); -} + cx.editor + .registers + .write(cx.register.unwrap_or('"'), values); -fn yank_to_primary_clipboard(cx: &mut Context) { - yank_impl(cx.editor, '*'); + cx.editor.set_status(msg); exit_select_mode(cx); } -fn yank_impl(editor: &mut Editor, register: char) { +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(..); @@ -4607,84 +3453,74 @@ 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 clipboard_text = match clipboard_type { + ClipboardType::Clipboard => "system clipboard", + ClipboardType::Selection => "primary clipboard", + }; -fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { - let (view, doc) = current!(editor); - let text = doc.text().slice(..); + let msg = format!( + "joined and yanked {} selection(s) to {}", + values.len(), + clipboard_text, + ); - let selection = doc.selection(view.id); - let selections = selection.len(); - let joined = selection - .fragments(text) - .fold(String::new(), |mut acc, fragment| { - if !acc.is_empty() { - acc.push_str(separator); - } - acc.push_str(&fragment); - acc - }); + let joined = values.join(separator); - 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()), - } -} + editor + .clipboard_provider + .set_contents(joined, clipboard_type) + .context("Couldn't set system clipboard content")?; -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), - ); - exit_select_mode(cx); -} + editor.set_status(msg); -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); + 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 +3531,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 +3543,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); @@ -4776,8 +3606,7 @@ fn paste_impl( transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); } - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view); + apply_transaction(&transaction, doc, view); } pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { @@ -4788,122 +3617,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) + } + }); + + apply_transaction(&transaction, doc, view); + 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()), + ) + }); + + apply_transaction(&transaction, doc, view); + 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> { @@ -4941,8 +3794,7 @@ fn indent(cx: &mut Context) { Some((pos, pos, Some(indent.clone()))) }), ); - doc.apply(&transaction, view.id); - exit_select_mode(cx); + apply_transaction(&transaction, doc, view); } fn unindent(cx: &mut Context) { @@ -4951,7 +3803,7 @@ fn unindent(cx: &mut Context) { let lines = get_lines(doc, view.id); let mut changes = Vec::with_capacity(lines.len()); let tab_width = doc.tab_width(); - let indent_width = count * doc.indent_width(); + let indent_width = count * tab_width; for line_idx in lines { let line = doc.text().line(line_idx); @@ -4981,92 +3833,72 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); - exit_select_mode(cx); + apply_transaction(&transaction, doc, view); } fn format_selections(cx: &mut Context) { use helix_lsp::{lsp, util::range_to_lsp_range}; let (view, doc) = current!(cx.editor); - let view_id = view.id; // via lsp if available // TODO: else via tree-sitter indentation calculations - if doc.selection(view_id).len() != 1 { - cx.editor - .set_error("format_selections only supports a single selection for now"); - return; - } - - // TODO extra LanguageServerFeature::FormatSelections? - // maybe such that LanguageServerFeature::Format contains it as well - let Some(language_server) = doc - .language_servers_with_feature(LanguageServerFeature::Format) - .find(|ls| { - matches!( - ls.capabilities().document_range_formatting_provider, - Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) - ) - }) - else { - cx.editor - .set_error("No configured language server supports range formatting"); - return; + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, }; - let offset_encoding = language_server.offset_encoding(); let ranges: Vec<lsp::Range> = doc - .selection(view_id) + .selection(view.id) .iter() - .map(|range| range_to_lsp_range(doc.text(), *range, offset_encoding)) + .map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding())) .collect(); + if ranges.len() != 1 { + cx.editor + .set_error("format_selections only supports a single selection for now"); + return; + } + // TODO: handle fails // TODO: concurrent map over all ranges let range = ranges[0]; - let future = language_server - .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() - }, - None, - ) - .unwrap(); + let request = match language_server.text_document_range_formatting( + doc.identifier(), + range, + lsp::FormattingOptions::default(), + None, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support range formatting"); + return; + } + }; - 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(request)).unwrap_or_default(); - let transaction = - helix_lsp::util::generate_transaction_from_edits(doc.text(), edits, offset_encoding); + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + edits, + language_server.offset_encoding(), + ); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } 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,78 +3909,40 @@ 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); } } - // nothing to do, bail out early to avoid crashes later - if changes.is_empty() { - return; - } - 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); + apply_transaction(&transaction, doc, view); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -5159,8 +3953,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 +3964,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 +4009,84 @@ 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); + use helix_lsp::{lsp, util::pos_to_lsp_pos}; - cx.editor - .handlers - .trigger_completions(cursor, doc.id(), view.id); -} - -// 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(..); + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; - // only have line comment tokens - if line_token.is_some() && block_tokens.is_none() { - return comment::toggle_line_comments(doc, selection, line_token); - } + let offset_encoding = language_server.offset_encoding(); + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); - let split_lines = comment::split_lines_of_selection(text, selection); + let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding); - let default_block_tokens = &[BlockCommentToken::default()]; - let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); + let future = match language_server.completion(doc.identifier(), pos, None) { + Some(future) => future, + None => return, + }; - let (line_commented, line_comment_changes) = - comment::find_block_comments(block_comment_tokens, text, &split_lines); + let trigger_offset = cursor; - // 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; - } + // 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". - let (block_commented, comment_changes) = - comment::find_block_comments(block_comment_tokens, text, selection); + 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); - // check if selection has block comments - if block_commented { - return comment::create_block_comment_transaction( - doc, - selection, - block_commented, - comment_changes, - ) - .0; - } + cx.callback( + future, + move |editor, compositor, response: Option<lsp::CompletionResponse>| { + if editor.mode != Mode::Insert { + // we're not in insert mode anymore + return; + } - // 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 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(), + }; - // not block commented at all and don't have any tokens - 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(); + ui.set_completion( + editor, + items, + offset_encoding, + start_offset, + trigger_offset, + size, + ); + }, + ); } -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) - } - }); -} +// 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); -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) - } - }); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); } fn rotate_selections(cx: &mut Context, direction: Direction) { @@ -5365,89 +4108,47 @@ 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, - Reverse, -} - -fn reorder_selection_contents(cx: &mut Context, strategy: ReorderStrategy) { +fn rotate_selection_contents(cx: &mut Context, direction: Direction) { let count = cx.count; let (view, doc) = current!(cx.editor); 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 group = count + .map(|count| count.get()) + .unwrap_or(fragments.len()) // default to rotating everything as one group + .min(fragments.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() - } - }; + for chunk in fragments.chunks_mut(group) { + // TODO: also modify main index + match direction { + Direction::Forward => chunk.rotate_right(1), + Direction::Backward => chunk.rotate_left(1), + }; + } 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); + apply_transaction(&transaction, doc, view); } fn rotate_selection_contents_forward(cx: &mut Context) { - reorder_selection_contents(cx, ReorderStrategy::RotateForward) + rotate_selection_contents(cx, Direction::Forward) } fn rotate_selection_contents_backward(cx: &mut Context) { - reorder_selection_contents(cx, ReorderStrategy::RotateBackward) -} -fn reverse_selection_contents(cx: &mut Context) { - reorder_selection_contents(cx, ReorderStrategy::Reverse) + rotate_selection_contents(cx, Direction::Backward) } // tree sitter node selection @@ -5471,7 +4172,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) { @@ -5481,6 +4183,7 @@ fn shrink_selection(cx: &mut Context) { // try to restore previous selection if let Some(prev_selection) = view.object_selections.pop() { if current_selection.contains(&prev_selection) { + // allow shrinking the selection only if current selection contains the previous object selection doc.set_selection(view.id, prev_selection); return; } else { @@ -5495,124 +4198,55 @@ 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) + select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) } -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); +fn match_brackets(cx: &mut Context) { + let (view, doc) = current!(cx.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()); + let selection = doc.selection(view.id).clone().transform(|range| { + if let Some(pos) = + match_brackets::find_matching_bracket_fuzzy(syntax, doc.text(), range.cursor(text)) + { + range.put_cursor(text, pos, cx.editor.mode == Mode::Select) + } else { + range + } + }); 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); -} - -fn match_brackets(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let is_select = cx.editor.mode == Mode::Select; - let text = doc.text(); - let text_slice = text.slice(..); - - let selection = doc.selection(view.id).clone().transform(|range| { - let pos = range.cursor(text_slice); - if let Some(matched_pos) = doc.syntax().map_or_else( - || match_brackets::find_matching_bracket_plaintext(text.slice(..), pos), - |syntax| match_brackets::find_matching_bracket_fuzzy(syntax, text.slice(..), pos), - ) { - range.put_cursor(text_slice, matched_pos, is_select) - } else { - range - } - }); - - doc.set_selection(view.id, selection); -} - // fn jump_forward(cx: &mut Context) { @@ -5631,8 +4265,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 +4285,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); }; } @@ -5669,10 +4299,6 @@ fn rotate_view(cx: &mut Context) { cx.editor.focus_next() } -fn rotate_view_reverse(cx: &mut Context) { - cx.editor.focus_prev() -} - fn jump_view_right(cx: &mut Context) { cx.editor.focus_direction(tree::Direction::Right) } @@ -5709,27 +4335,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 +4361,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 +4395,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); @@ -5861,50 +4432,40 @@ fn align_view_bottom(cx: &mut Context) { fn align_view_middle(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let inner_width = view.inner_width(doc); - let text_fmt = doc.text_format(inner_width, None); - // there is no horizontal position when softwrap is enabled - if text_fmt.soft_wrap { - return; - } - let doc_text = doc.text().slice(..); - 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 text = doc.text().slice(..); + let pos = doc.selection(view.id).primary().cursor(text); + let pos = coords_at_pos(text, pos); - let mut offset = doc.view_offset(view.id); - offset.horizontal_offset = pos + view.offset.col = 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 +4481,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 +4530,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 +4546,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, ) }; @@ -6020,14 +4571,14 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { let textobject_change = |range: Range| -> Range { let diff_handle = doc.diff_handle().unwrap(); - let diff = diff_handle.load(); + let hunks = diff_handle.hunks(); let line = range.cursor_line(text); - let hunk_idx = if let Some(hunk_idx) = diff.hunk_at(line as u32, false) { + let hunk_idx = if let Some(hunk_idx) = hunks.hunk_at(line as u32, false) { hunk_idx } else { return range; }; - let hunk = diff.nth_hunk(hunk_idx).after; + let hunk = hunks.nth_hunk(hunk_idx).after; let start = text.line_to_char(hunk.start as usize); let end = text.line_to_char(hunk.end as usize); @@ -6043,32 +4594,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,80 +4627,57 @@ 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 to cursor"), (" ", "... 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 mut open = Tendril::new(); - open.push(o); - let mut close = Tendril::new(); - close.push(c); - (open, close, 2) - } - None if event.code == KeyCode::Enter => ( - doc.line_ending.as_str().into(), - doc.line_ending.as_str().into(), - 2 * doc.line_ending.len_chars(), - ), + let ch = match event.char() { + Some(ch) => ch, None => return, }; - + let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); + let (open, close) = surround::get_pair(ch); + // The number of chars in get_pair + let surround_len = 2; + let mut changes = Vec::with_capacity(selection.len() * 2); let mut ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; for range in selection.iter() { - changes.push((range.from(), range.from(), Some(open.clone()))); - changes.push((range.to(), range.to(), Some(close.clone()))); - + let mut o = Tendril::new(); + o.push(open); + let mut c = Tendril::new(); + c.push(close); + changes.push((range.from(), range.from(), Some(o))); + changes.push((range.to(), range.to(), Some(c))); + + // Add 2 characters to the range to select them ranges.push( Range::new(offs + range.from(), offs + range.to() + surround_len) .with_direction(range.direction()), ); + // Add 2 characters to the offset for the next ranges offs += surround_len; } let transaction = Transaction::change(doc.text(), changes.into_iter()) .with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); 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 +4687,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); + apply_transaction(&transaction, doc, view); 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 +4728,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); + apply_transaction(&transaction, doc, view); exit_select_mode(cx); - }); - - cx.editor.autoinfo = Some(Info::new("Delete surrounding pair of", &SURROUND_HELP_TEXT)); + }) } #[derive(Eq, PartialEq)] @@ -6267,55 +4752,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 +4827,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"); @@ -6351,10 +4855,9 @@ async fn shell_impl_async( let output = if let Some(mut stdin) = process.stdin.take() { let input_task = tokio::spawn(async move { if let Some(input) = input { - helix_view::document::to_writer(&mut stdin, (encoding::UTF_8, false), &input) - .await?; + helix_view::document::to_writer(&mut stdin, encoding::UTF_8, &input).await?; } - anyhow::Ok(()) + Ok::<_, anyhow::Error>(()) }); let (output, _) = tokio::join! { process.wait_with_output(), @@ -6366,24 +4869,24 @@ 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); } - String::from_utf8_lossy(&output.stderr) - // Prioritize `stderr` output over `stdout` + bail!("Shell command failed"); } 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 +4907,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 +4925,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 +4940,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))); } @@ -6458,7 +4953,7 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { if behavior != &ShellBehavior::Ignore { let transaction = Transaction::change(doc.text(), changes.into_iter()) .with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view); } @@ -6467,41 +4962,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) { @@ -6534,32 +5016,64 @@ fn add_newline_impl(cx: &mut Context, open: Open) { }); let transaction = Transaction::change(text, changes); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } enum IncrementDirection { Increase, Decrease, } - -/// Increment objects within selections by count. +/// Increment object under cursor by count. fn increment(cx: &mut Context) { increment_impl(cx, IncrementDirection::Increase); } -/// Decrement objects within selections by count. +/// Decrement object under cursor by count. fn decrement(cx: &mut Context) { increment_impl(cx, IncrementDirection::Decrease); } -/// Increment objects within selections by `amount`. -/// A negative `amount` will decrement objects within selections. +/// This function differs from find_next_char_impl in that it stops searching at the newline, but also +/// starts searching at the current character, instead of the next. +/// It does not want to start at the next character because this function is used for incrementing +/// number and we don't want to move forward if we're already on a digit. +fn find_next_char_until_newline<M: CharMatcher>( + text: RopeSlice, + char_matcher: M, + pos: usize, + _count: usize, + _inclusive: bool, +) -> Option<usize> { + // Since we send the current line to find_nth_next instead of the whole text, we need to adjust + // the position we send to this function so that it's relative to that line and its returned + // position since it's expected this function returns a global position. + let line_index = text.char_to_line(pos); + let pos_delta = text.line_to_char(line_index); + let pos = pos - pos_delta; + search::find_nth_next(text.line(line_index), char_matcher, pos, 1).map(|pos| pos + pos_delta) +} + +/// Decrement object under cursor by `amount`. fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { + // TODO: when incrementing or decrementing a number that gets a new digit or lose one, the + // selection is updated improperly. + find_char_impl( + cx.editor, + &find_next_char_until_newline, + true, + true, + char::is_ascii_digit, + 1, + ); + + // Increase by 1 if `IncrementDirection` is `Increase` + // Decrease by 1 if `IncrementDirection` is `Decrease` let sign = match increment_direction { IncrementDirection::Increase => 1, IncrementDirection::Decrease => -1, }; let mut amount = sign * cx.count() as i64; + // If the register is `#` then increase or decrease the `amount` by 1 per element let increase_by = if cx.register == Some('#') { sign } else { 0 }; @@ -6567,83 +5081,56 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { let selection = doc.selection(view.id); let text = doc.text().slice(..); - let mut new_selection_ranges = SmallVec::new(); - let mut cumulative_length_diff: i128 = 0; - let mut changes = vec![]; + let changes: Vec<_> = selection + .ranges() + .iter() + .filter_map(|range| { + let incrementor: Box<dyn Increment> = + if let Some(incrementor) = DateTimeIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else if let Some(incrementor) = NumberIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else { + return None; + }; - for range in selection { - let selected_text: Cow<str> = range.fragment(text); - let new_from = ((range.from() as i128) + cumulative_length_diff) as usize; - let incremented = [increment::integer, increment::date_time] - .iter() - .find_map(|incrementor| incrementor(selected_text.as_ref(), amount)); + let (range, new_text) = incrementor.increment(amount); - amount += increase_by; + amount += increase_by; - match incremented { - None => { - let new_range = Range::new( - new_from, - (range.to() as i128 + cumulative_length_diff) as usize, - ); - new_selection_ranges.push(new_range); - } - Some(new_text) => { - let new_range = Range::new(new_from, new_from + new_text.len()); - cumulative_length_diff += new_text.len() as i128 - selected_text.len() as i128; - new_selection_ranges.push(new_range); - changes.push((range.from(), range.to(), Some(new_text.into()))); - } + Some((range.from(), range.to(), Some(new_text))) + }) + .collect(); + + // Overlapping changes in a transaction will panic, so we need to find and remove them. + // For example, if there are cursors on each of the year, month, and day of `2021-11-29`, + // incrementing will give overlapping changes, with each change incrementing a different part of + // the date. Since these conflict with each other we remove these changes from the transaction + // so nothing happens. + let mut overlapping_indexes = HashSet::new(); + for (i, changes) in changes.windows(2).enumerate() { + if changes[0].1 > changes[1].0 { + overlapping_indexes.insert(i); + overlapping_indexes.insert(i + 1); } } + let changes: Vec<_> = changes + .into_iter() + .enumerate() + .filter_map(|(i, change)| { + if overlapping_indexes.contains(&i) { + None + } else { + Some(change) + } + }) + .collect(); if !changes.is_empty() { - let new_selection = Selection::new(new_selection_ranges, selection.primary_index()); 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) -} + let transaction = transaction.with_selection(selection.clone()); -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); - } - }) + apply_transaction(&transaction, doc, view); } } @@ -6662,12 +5149,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 +5171,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 +5189,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 +5201,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); - } -} |