mirror of
https://github.com/helix-editor/helix.git
synced 2024-12-24 17:01:54 +04:00
52f5a4228a
* feat(commands): better handling of buffer-close Previously, when closing buffer, you would loose cursor position in other docs. Also, all splits where the buffer was open would be closed. This PR changes the behavior, if the view has also other buffer previously viewed it switches back to the last one instead of the view being closed. As a side effect, since the views are persisted, the cursor history is persisted as well. Fixes: https://github.com/helix-editor/helix/issues/1186 * Adjust buffer close behavior * Remove closed documents from jump history * Fix after rebase
4546 lines
146 KiB
Rust
4546 lines
146 KiB
Rust
pub(crate) mod dap;
|
|
pub(crate) mod lsp;
|
|
pub(crate) mod typed;
|
|
|
|
pub use dap::*;
|
|
pub use lsp::*;
|
|
pub use typed::*;
|
|
|
|
use helix_core::{
|
|
comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes,
|
|
history::UndoKind,
|
|
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, Direction},
|
|
object, pos_at_coords,
|
|
regex::{self, Regex, RegexBuilder},
|
|
search::{self, CharMatcher},
|
|
selection, shellwords, surround, textobject,
|
|
tree_sitter::Node,
|
|
unicode::width::UnicodeWidthChar,
|
|
LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril,
|
|
Transaction,
|
|
};
|
|
use helix_view::{
|
|
clipboard::ClipboardType,
|
|
document::{Mode, SCRATCH_BUFFER_NAME},
|
|
editor::{Action, Motion},
|
|
info::Info,
|
|
input::KeyEvent,
|
|
keyboard::KeyCode,
|
|
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},
|
|
ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent},
|
|
};
|
|
|
|
use crate::job::{self, Job, Jobs};
|
|
use futures_util::{FutureExt, StreamExt};
|
|
use std::{collections::HashMap, fmt, future::Future};
|
|
use std::{collections::HashSet, num::NonZeroUsize};
|
|
|
|
use std::{
|
|
borrow::Cow,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use once_cell::sync::Lazy;
|
|
use serde::de::{self, Deserialize, Deserializer};
|
|
|
|
use grep_regex::RegexMatcherBuilder;
|
|
use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
|
|
use ignore::{DirEntry, WalkBuilder, WalkState};
|
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
|
|
pub struct Context<'a> {
|
|
pub register: Option<char>,
|
|
pub count: Option<NonZeroUsize>,
|
|
pub editor: &'a mut Editor,
|
|
|
|
pub callback: Option<crate::compositor::Callback>,
|
|
pub on_next_key_callback: Option<Box<dyn FnOnce(&mut Context, KeyEvent)>>,
|
|
pub jobs: &'a mut Jobs,
|
|
}
|
|
|
|
impl<'a> Context<'a> {
|
|
/// Push a new component onto the compositor.
|
|
pub fn push_layer(&mut self, component: Box<dyn Component>) {
|
|
self.callback = Some(Box::new(|compositor: &mut Compositor, _| {
|
|
compositor.push(component)
|
|
}));
|
|
}
|
|
|
|
#[inline]
|
|
pub fn on_next_key(
|
|
&mut self,
|
|
on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static,
|
|
) {
|
|
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<serde_json::Value>> + 'static + Send,
|
|
callback: F,
|
|
) where
|
|
T: for<'de> serde::Deserialize<'de> + Send + 'static,
|
|
F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static,
|
|
{
|
|
let callback = Box::pin(async move {
|
|
let json = call.await?;
|
|
let response = serde_json::from_value(json)?;
|
|
let call: job::Callback =
|
|
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
|
|
#[inline]
|
|
pub fn count(&self) -> usize {
|
|
self.count.map_or(1, |v| v.get())
|
|
}
|
|
}
|
|
|
|
use helix_view::{align_view, Align};
|
|
|
|
/// 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: Vec<String>,
|
|
doc: String,
|
|
},
|
|
Static {
|
|
name: &'static str,
|
|
fun: fn(cx: &mut Context),
|
|
doc: &'static str,
|
|
},
|
|
}
|
|
|
|
macro_rules! static_commands {
|
|
( $($name:ident, $doc:literal,)* ) => {
|
|
$(
|
|
#[allow(non_upper_case_globals)]
|
|
pub const $name: Self = Self::Static {
|
|
name: stringify!($name),
|
|
fun: $name,
|
|
doc: $doc
|
|
};
|
|
)*
|
|
|
|
pub const STATIC_COMMAND_LIST: &'static [Self] = &[
|
|
$( Self::$name, )*
|
|
];
|
|
}
|
|
}
|
|
|
|
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) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) {
|
|
cx.editor.set_error(format!("{}", e));
|
|
}
|
|
}
|
|
}
|
|
Self::Static { fun, .. } => (fun)(cx),
|
|
}
|
|
}
|
|
|
|
pub fn name(&self) -> &str {
|
|
match &self {
|
|
Self::Typable { name, .. } => name,
|
|
Self::Static { name, .. } => name,
|
|
}
|
|
}
|
|
|
|
pub fn doc(&self) -> &str {
|
|
match &self {
|
|
Self::Typable { doc, .. } => doc,
|
|
Self::Static { doc, .. } => doc,
|
|
}
|
|
}
|
|
|
|
#[rustfmt::skip]
|
|
static_commands!(
|
|
no_op, "Do nothing",
|
|
move_char_left, "Move left",
|
|
move_char_right, "Move right",
|
|
move_line_up, "Move up",
|
|
move_line_down, "Move down",
|
|
extend_char_left, "Extend left",
|
|
extend_char_right, "Extend right",
|
|
extend_line_up, "Extend up",
|
|
extend_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 beginning of next word",
|
|
move_prev_word_start, "Move to beginning of previous word",
|
|
move_prev_word_end, "Move to end of previous word",
|
|
move_next_word_end, "Move to end of next word",
|
|
move_next_long_word_start, "Move to beginning of next long word",
|
|
move_prev_long_word_start, "Move to beginning of previous long word",
|
|
move_next_long_word_end, "Move to end of next long word",
|
|
extend_next_word_start, "Extend to beginning of next word",
|
|
extend_prev_word_start, "Extend to beginning of previous word",
|
|
extend_next_long_word_start, "Extend to beginning of next long word",
|
|
extend_prev_long_word_start, "Extend to beginning of previous long word",
|
|
extend_next_long_word_end, "Extend to end of next long word",
|
|
extend_next_word_end, "Extend to end of next word",
|
|
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",
|
|
extend_next_char, "Extend to next occurrence of char",
|
|
till_prev_char, "Move till previous occurrence of char",
|
|
find_prev_char, "Move to previous occurrence of char",
|
|
extend_till_prev_char, "Extend till previous occurrence of char",
|
|
extend_prev_char, "Extend to previous occurrence of char",
|
|
repeat_last_motion, "repeat last motion(extend_next_char, extend_till_char, find_next_char, find_till_char...)",
|
|
replace, "Replace with new char",
|
|
switch_case, "Switch (toggle) case",
|
|
switch_to_uppercase, "Switch to uppercase",
|
|
switch_to_lowercase, "Switch to lowercase",
|
|
page_up, "Move page up",
|
|
page_down, "Move page down",
|
|
half_page_up, "Move half page up",
|
|
half_page_down, "Move half page down",
|
|
select_all, "Select whole document",
|
|
select_regex, "Select all regex matches inside selections",
|
|
split_selection, "Split selection into subselections on regex matches",
|
|
split_selection_on_newline, "Split selection on newlines",
|
|
search, "Search for regex pattern",
|
|
rsearch, "Reverse search for regex pattern",
|
|
search_next, "Select next search match",
|
|
search_prev, "Select previous search match",
|
|
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",
|
|
global_search, "Global Search in workspace folder",
|
|
extend_line, "Select current line, if already selected, extend to next line",
|
|
extend_line_above, "Select current line, if already selected, extend to previous line",
|
|
extend_to_line_bounds, "Extend selection to line bounds (line-wise selection)",
|
|
delete_selection, "Delete selection",
|
|
delete_selection_noyank, "Delete selection, without yanking",
|
|
change_selection, "Change selection (delete and enter insert mode)",
|
|
change_selection_noyank, "Change selection (delete and enter insert mode, without yanking)",
|
|
collapse_selection, "Collapse selection onto a single cursor",
|
|
flip_selections, "Flip selection cursor and anchor",
|
|
ensure_selections_forward, "Ensure the selection is in forward direction",
|
|
insert_mode, "Insert before selection",
|
|
append_mode, "Insert after selection (append)",
|
|
command_mode, "Enter command mode",
|
|
file_picker, "Open file picker",
|
|
file_picker_in_current_directory, "Open file picker at current working directory",
|
|
code_action, "Perform code action",
|
|
buffer_picker, "Open buffer picker",
|
|
symbol_picker, "Open symbol picker",
|
|
workspace_symbol_picker, "Open workspace symbol picker",
|
|
last_picker, "Open last picker",
|
|
prepend_to_line, "Insert at start of line",
|
|
append_to_line, "Insert at end of line",
|
|
open_below, "Open new line below selection",
|
|
open_above, "Open new line above selection",
|
|
normal_mode, "Enter normal mode",
|
|
select_mode, "Enter selection extend mode",
|
|
exit_select_mode, "Exit selection mode",
|
|
goto_definition, "Goto definition",
|
|
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",
|
|
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",
|
|
goto_window_bottom, "Goto window bottom",
|
|
goto_last_accessed_file, "Goto last accessed file",
|
|
goto_last_modified_file, "Goto last modified file",
|
|
goto_last_modification, "Goto last modification",
|
|
goto_line, "Goto line",
|
|
goto_last_line, "Goto last line",
|
|
goto_first_diag, "Goto first diagnostic",
|
|
goto_last_diag, "Goto last diagnostic",
|
|
goto_next_diag, "Goto next diagnostic",
|
|
goto_prev_diag, "Goto previous diagnostic",
|
|
goto_line_start, "Goto line start",
|
|
goto_line_end, "Goto line end",
|
|
goto_next_buffer, "Goto next buffer",
|
|
goto_previous_buffer, "Goto previous buffer",
|
|
// TODO: different description ?
|
|
goto_line_end_newline, "Goto 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_line_end, "Extend to line end",
|
|
extend_to_line_end_newline, "Extend to line end",
|
|
signature_help, "Show signature help",
|
|
insert_tab, "Insert tab char",
|
|
insert_newline, "Insert newline char",
|
|
delete_char_backward, "Delete previous char",
|
|
delete_char_forward, "Delete next char",
|
|
delete_word_backward, "Delete previous word",
|
|
delete_word_forward, "Delete next word",
|
|
kill_to_line_start, "Delete content till the start of the line",
|
|
kill_to_line_end, "Delete content till the end of the line",
|
|
undo, "Undo change",
|
|
redo, "Redo change",
|
|
earlier, "Move backward in history",
|
|
later, "Move forward in history",
|
|
yank, "Yank selection",
|
|
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",
|
|
yank_main_selection_to_primary_clipboard, "Yank main selection to primary clipboard",
|
|
replace_with_yanked, "Replace with yanked text",
|
|
replace_selections_with_clipboard, "Replace selections by clipboard content",
|
|
replace_selections_with_primary_clipboard, "Replace selections by primary clipboard content",
|
|
paste_after, "Paste after selection",
|
|
paste_before, "Paste before selection",
|
|
paste_clipboard_after, "Paste clipboard after selections",
|
|
paste_clipboard_before, "Paste clipboard before selections",
|
|
paste_primary_clipboard_after, "Paste primary clipboard after selections",
|
|
paste_primary_clipboard_before, "Paste primary clipboard before selections",
|
|
indent, "Indent selection",
|
|
unindent, "Unindent selection",
|
|
format_selections, "Format selection",
|
|
join_selections, "Join lines inside selection",
|
|
keep_selections, "Keep selections matching regex",
|
|
remove_selections, "Remove selections matching regex",
|
|
align_selections, "Align selections in column",
|
|
keep_primary_selection, "Keep primary selection",
|
|
remove_primary_selection, "Remove primary selection",
|
|
completion, "Invoke completion popup",
|
|
hover, "Show docs for item under cursor",
|
|
toggle_comments, "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",
|
|
expand_selection, "Expand selection to parent syntax node",
|
|
shrink_selection, "Shrink selection to previously expanded syntax node",
|
|
select_next_sibling, "Select the next sibling in the syntax tree",
|
|
select_prev_sibling, "Select the previous sibling in the syntax tree",
|
|
jump_forward, "Jump forward on jumplist",
|
|
jump_backward, "Jump backward on jumplist",
|
|
save_selection, "Save the current selection to the jumplist",
|
|
jump_view_right, "Jump to the split to the right",
|
|
jump_view_left, "Jump to the split to the left",
|
|
jump_view_up, "Jump to the split above",
|
|
jump_view_down, "Jump to the split below",
|
|
rotate_view, "Goto next window",
|
|
hsplit, "Horizontal bottom split",
|
|
hsplit_new, "Horizontal bottom split scratch buffer",
|
|
vsplit, "Vertical right split",
|
|
vsplit_new, "Vertical right split scratch buffer",
|
|
wclose, "Close window",
|
|
wonly, "Current window only",
|
|
select_register, "Select register",
|
|
insert_register, "Insert register",
|
|
align_view_middle, "Align view middle",
|
|
align_view_top, "Align view top",
|
|
align_view_center, "Align view center",
|
|
align_view_bottom, "Align view bottom",
|
|
scroll_up, "Scroll view up",
|
|
scroll_down, "Scroll view down",
|
|
match_brackets, "Goto matching bracket",
|
|
surround_add, "Surround add",
|
|
surround_replace, "Surround replace",
|
|
surround_delete, "Surround delete",
|
|
select_textobject_around, "Select around object",
|
|
select_textobject_inner, "Select inside object",
|
|
goto_next_function, "Goto next function",
|
|
goto_prev_function, "Goto previous function",
|
|
goto_next_class, "Goto next class",
|
|
goto_prev_class, "Goto previous class",
|
|
goto_next_parameter, "Goto next parameter",
|
|
goto_prev_parameter, "Goto previous parameter",
|
|
goto_next_comment, "Goto next comment",
|
|
goto_prev_comment, "Goto previous comment",
|
|
goto_next_paragraph, "Goto next paragraph",
|
|
goto_prev_paragraph, "Goto previous paragraph",
|
|
dap_launch, "Launch debug target",
|
|
dap_toggle_breakpoint, "Toggle breakpoint",
|
|
dap_continue, "Continue program execution",
|
|
dap_pause, "Pause program execution",
|
|
dap_step_in, "Step in",
|
|
dap_step_out, "Step out",
|
|
dap_next, "Step to next",
|
|
dap_variables, "List variables",
|
|
dap_terminate, "End debug session",
|
|
dap_edit_condition, "Edit condition of the breakpoint on the current line",
|
|
dap_edit_log, "Edit log message of the breakpoint on the current line",
|
|
dap_switch_thread, "Switch current thread",
|
|
dap_switch_stack_frame, "Switch stack frame",
|
|
dap_enable_exceptions, "Enable exception breakpoints",
|
|
dap_disable_exceptions, "Disable exception breakpoints",
|
|
shell_pipe, "Pipe selections through shell command",
|
|
shell_pipe_to, "Pipe selections into shell command, ignoring command output",
|
|
shell_insert_output, "Insert output of shell command before each selection",
|
|
shell_append_output, "Append output of shell command after each selection",
|
|
shell_keep_pipe, "Filter selections with shell predicate",
|
|
suspend, "Suspend",
|
|
rename_symbol, "Rename symbol",
|
|
increment, "Increment",
|
|
decrement, "Decrement",
|
|
record_macro, "Record macro",
|
|
replay_macro, "Replay macro",
|
|
command_palette, "Open command palette",
|
|
);
|
|
}
|
|
|
|
impl fmt::Debug for MappableCommand {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_tuple("MappableCommand")
|
|
.field(&self.name())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for MappableCommand {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.name())
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for MappableCommand {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
if let Some(suffix) = s.strip_prefix(':') {
|
|
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| MappableCommand::Typable {
|
|
name: cmd.name.to_owned(),
|
|
doc: format!(":{} {:?}", cmd.name, args),
|
|
args,
|
|
})
|
|
.ok_or_else(|| anyhow!("No TypableCommand named '{}'", s))
|
|
} else {
|
|
MappableCommand::STATIC_COMMAND_LIST
|
|
.iter()
|
|
.find(|cmd| cmd.name() == s)
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("No command named '{}'", s))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for MappableCommand {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
s.parse().map_err(de::Error::custom)
|
|
}
|
|
}
|
|
|
|
impl PartialEq for MappableCommand {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
match (self, other) {
|
|
(
|
|
MappableCommand::Typable {
|
|
name: first_name, ..
|
|
},
|
|
MappableCommand::Typable {
|
|
name: second_name, ..
|
|
},
|
|
) => first_name == second_name,
|
|
(
|
|
MappableCommand::Static {
|
|
name: first_name, ..
|
|
},
|
|
MappableCommand::Static {
|
|
name: second_name, ..
|
|
},
|
|
) => first_name == second_name,
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn no_op(_cx: &mut Context) {}
|
|
|
|
fn move_impl<F>(cx: &mut Context, move_fn: F, dir: Direction, behaviour: Movement)
|
|
where
|
|
F: Fn(RopeSlice, Range, Direction, usize, Movement) -> Range,
|
|
{
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| move_fn(text, range, dir, count, behaviour));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
use helix_core::movement::{move_horizontally, move_vertically};
|
|
|
|
fn move_char_left(cx: &mut Context) {
|
|
move_impl(cx, move_horizontally, Direction::Backward, Movement::Move)
|
|
}
|
|
|
|
fn move_char_right(cx: &mut Context) {
|
|
move_impl(cx, move_horizontally, Direction::Forward, Movement::Move)
|
|
}
|
|
|
|
fn move_line_up(cx: &mut Context) {
|
|
move_impl(cx, move_vertically, Direction::Backward, Movement::Move)
|
|
}
|
|
|
|
fn move_line_down(cx: &mut Context) {
|
|
move_impl(cx, move_vertically, Direction::Forward, Movement::Move)
|
|
}
|
|
|
|
fn extend_char_left(cx: &mut Context) {
|
|
move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend)
|
|
}
|
|
|
|
fn extend_char_right(cx: &mut Context) {
|
|
move_impl(cx, move_horizontally, Direction::Forward, Movement::Extend)
|
|
}
|
|
|
|
fn extend_line_up(cx: &mut Context) {
|
|
move_impl(cx, move_vertically, Direction::Backward, Movement::Extend)
|
|
}
|
|
|
|
fn extend_line_down(cx: &mut Context) {
|
|
move_impl(cx, move_vertically, Direction::Forward, Movement::Extend)
|
|
}
|
|
|
|
fn goto_line_end_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);
|
|
let line_start = text.line_to_char(line);
|
|
|
|
let pos = graphemes::prev_grapheme_boundary(text, line_end_char_index(&text, line))
|
|
.max(line_start);
|
|
|
|
range.put_cursor(text, pos, movement == Movement::Extend)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn goto_line_end(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_end_impl(
|
|
view,
|
|
doc,
|
|
if doc.mode == Mode::Select {
|
|
Movement::Extend
|
|
} else {
|
|
Movement::Move
|
|
},
|
|
)
|
|
}
|
|
|
|
fn extend_to_line_end(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_end_impl(view, doc, Movement::Extend)
|
|
}
|
|
|
|
fn goto_line_end_newline_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);
|
|
let pos = line_end_char_index(&text, line);
|
|
|
|
range.put_cursor(text, pos, movement == Movement::Extend)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn goto_line_end_newline(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_end_newline_impl(
|
|
view,
|
|
doc,
|
|
if doc.mode == Mode::Select {
|
|
Movement::Extend
|
|
} else {
|
|
Movement::Move
|
|
},
|
|
)
|
|
}
|
|
|
|
fn extend_to_line_end_newline(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_end_newline_impl(view, doc, Movement::Extend)
|
|
}
|
|
|
|
fn goto_line_start_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);
|
|
|
|
// adjust to start of the line
|
|
let pos = text.line_to_char(line);
|
|
range.put_cursor(text, pos, movement == Movement::Extend)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn goto_line_start(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_start_impl(
|
|
view,
|
|
doc,
|
|
if doc.mode == Mode::Select {
|
|
Movement::Extend
|
|
} else {
|
|
Movement::Move
|
|
},
|
|
)
|
|
}
|
|
|
|
fn goto_next_buffer(cx: &mut Context) {
|
|
goto_buffer(cx.editor, Direction::Forward);
|
|
}
|
|
|
|
fn goto_previous_buffer(cx: &mut Context) {
|
|
goto_buffer(cx.editor, Direction::Backward);
|
|
}
|
|
|
|
fn goto_buffer(editor: &mut Editor, direction: Direction) {
|
|
let current = view!(editor).doc;
|
|
|
|
let id = match direction {
|
|
Direction::Forward => {
|
|
let iter = editor.documents.keys();
|
|
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();
|
|
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();
|
|
|
|
let id = *id;
|
|
|
|
editor.switch(id, Action::Replace);
|
|
}
|
|
|
|
fn extend_to_line_start(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
goto_line_start_impl(view, doc, Movement::Extend)
|
|
}
|
|
|
|
fn kill_to_line_start(cx: &mut Context) {
|
|
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
|
|
}
|
|
} else {
|
|
// select until start of line
|
|
first_char
|
|
};
|
|
Range::new(head, anchor)
|
|
});
|
|
delete_selection_insert_mode(doc, view, &selection);
|
|
}
|
|
|
|
fn kill_to_line_end(cx: &mut Context) {
|
|
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_end_pos = line_end_char_index(&text, line);
|
|
let pos = range.cursor(text);
|
|
|
|
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);
|
|
}
|
|
|
|
fn goto_first_nonwhitespace(cx: &mut Context) {
|
|
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);
|
|
|
|
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, doc.mode == Mode::Select)
|
|
} else {
|
|
range
|
|
}
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn trim_selections(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let ranges: SmallVec<[Range; 1]> = doc
|
|
.selection(view.id)
|
|
.iter()
|
|
.filter_map(|range| {
|
|
if range.is_empty() || range.fragment(text).chars().all(|ch| ch.is_whitespace()) {
|
|
return None;
|
|
}
|
|
let mut start = range.from();
|
|
let mut end = range.to();
|
|
start = movement::skip_while(text, start, |x| x.is_whitespace()).unwrap_or(start);
|
|
end = movement::backwards_skip_while(text, end, |x| x.is_whitespace()).unwrap_or(end);
|
|
if range.anchor < range.head {
|
|
Some(Range::new(start, end))
|
|
} else {
|
|
Some(Range::new(end, start))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !ranges.is_empty() {
|
|
let primary = doc.selection(view.id).primary();
|
|
let idx = ranges
|
|
.iter()
|
|
.position(|range| range.overlaps(&primary))
|
|
.unwrap_or(ranges.len() - 1);
|
|
doc.set_selection(view.id, Selection::new(ranges, idx));
|
|
} else {
|
|
collapse_selection(cx);
|
|
keep_primary_selection(cx);
|
|
};
|
|
}
|
|
|
|
// align text in selection
|
|
fn align_selections(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id);
|
|
|
|
let mut column_widths: Vec<Vec<_>> = Vec::new();
|
|
let mut last_line = text.len_lines() + 1;
|
|
let mut col = 0;
|
|
|
|
for range in selection {
|
|
let coords = coords_at_pos(text, range.head);
|
|
let anchor_coords = coords_at_pos(text, range.anchor);
|
|
|
|
if coords.row != anchor_coords.row {
|
|
cx.editor
|
|
.set_error("align cannot work with multi line selections");
|
|
return;
|
|
}
|
|
|
|
col = if coords.row == last_line { col + 1 } else { 0 };
|
|
|
|
if col >= column_widths.len() {
|
|
column_widths.push(Vec::new());
|
|
}
|
|
column_widths[col].push((range.from(), coords.col));
|
|
|
|
last_line = coords.row;
|
|
}
|
|
|
|
let mut changes = Vec::with_capacity(selection.len());
|
|
|
|
// Account for changes on each row
|
|
let len = column_widths.first().map(|cols| cols.len()).unwrap_or(0);
|
|
let mut offs = vec![0; len];
|
|
|
|
for col in column_widths {
|
|
let max_col = col
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(row, (_, cursor))| *cursor + offs[row])
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
for (row, (insert_pos, last_col)) in col.into_iter().enumerate() {
|
|
let ins_count = max_col - (last_col + offs[row]);
|
|
|
|
if ins_count == 0 {
|
|
continue;
|
|
}
|
|
|
|
offs[row] += ins_count;
|
|
|
|
changes.push((insert_pos, insert_pos, Some(" ".repeat(ins_count).into())));
|
|
}
|
|
}
|
|
|
|
// The changeset has to be sorted
|
|
changes.sort_unstable_by_key(|(from, _, _)| *from);
|
|
|
|
let transaction = Transaction::change(doc.text(), changes.into_iter());
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
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 height = view.inner_area().height as usize;
|
|
|
|
// respect user given count if any
|
|
// - 1 so we have at least one gap in the middle.
|
|
// a height of 6 with padding of 3 on each side will keep shifting the view back and forth
|
|
// as we type
|
|
let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2);
|
|
|
|
let last_line = view.last_line(doc);
|
|
|
|
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);
|
|
|
|
doc.set_selection(view.id, Selection::point(pos));
|
|
}
|
|
|
|
fn goto_window_top(cx: &mut Context) {
|
|
goto_window(cx, Align::Top)
|
|
}
|
|
|
|
fn goto_window_center(cx: &mut Context) {
|
|
goto_window(cx, Align::Center)
|
|
}
|
|
|
|
fn goto_window_bottom(cx: &mut Context) {
|
|
goto_window(cx, Align::Bottom)
|
|
}
|
|
|
|
fn move_word_impl<F>(cx: &mut Context, move_fn: F)
|
|
where
|
|
F: Fn(RopeSlice, Range, usize) -> Range,
|
|
{
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| move_fn(text, range, count));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn move_next_word_start(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_next_word_start)
|
|
}
|
|
|
|
fn move_prev_word_start(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_prev_word_start)
|
|
}
|
|
|
|
fn move_prev_word_end(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_prev_word_end)
|
|
}
|
|
|
|
fn move_next_word_end(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_next_word_end)
|
|
}
|
|
|
|
fn move_next_long_word_start(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_next_long_word_start)
|
|
}
|
|
|
|
fn move_prev_long_word_start(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_prev_long_word_start)
|
|
}
|
|
|
|
fn move_next_long_word_end(cx: &mut Context) {
|
|
move_word_impl(cx, movement::move_next_long_word_end)
|
|
}
|
|
|
|
fn goto_para_impl<F>(cx: &mut Context, move_fn: F)
|
|
where
|
|
F: Fn(RopeSlice, Range, usize, Movement) -> Range + 'static,
|
|
{
|
|
let count = cx.count();
|
|
let motion = move |editor: &mut Editor| {
|
|
let (view, doc) = current!(editor);
|
|
let text = doc.text().slice(..);
|
|
let behavior = if doc.mode == Mode::Select {
|
|
Movement::Extend
|
|
} else {
|
|
Movement::Move
|
|
};
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| move_fn(text, range, count, behavior));
|
|
doc.set_selection(view.id, selection);
|
|
};
|
|
motion(cx.editor);
|
|
cx.editor.last_motion = Some(Motion(Box::new(motion)));
|
|
}
|
|
|
|
fn goto_prev_paragraph(cx: &mut Context) {
|
|
goto_para_impl(cx, movement::move_prev_paragraph)
|
|
}
|
|
|
|
fn goto_next_paragraph(cx: &mut Context) {
|
|
goto_para_impl(cx, movement::move_next_paragraph)
|
|
}
|
|
|
|
fn goto_file_start(cx: &mut Context) {
|
|
if cx.count.is_some() {
|
|
goto_line(cx);
|
|
} else {
|
|
push_jump(cx.editor);
|
|
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, doc.mode == Mode::Select));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
}
|
|
|
|
fn goto_file_end(cx: &mut Context) {
|
|
push_jump(cx.editor);
|
|
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, doc.mode == Mode::Select));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn goto_file(cx: &mut Context) {
|
|
goto_file_impl(cx, Action::Replace);
|
|
}
|
|
|
|
fn goto_file_hsplit(cx: &mut Context) {
|
|
goto_file_impl(cx, Action::HorizontalSplit);
|
|
}
|
|
|
|
fn goto_file_vsplit(cx: &mut Context) {
|
|
goto_file_impl(cx, Action::VerticalSplit);
|
|
}
|
|
|
|
fn goto_file_impl(cx: &mut Context, action: Action) {
|
|
let (view, doc) = current_ref!(cx.editor);
|
|
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();
|
|
if selections.len() == 1 && primary.to() - primary.from() == 1 {
|
|
let current_word = movement::move_next_long_word_start(
|
|
text.slice(..),
|
|
movement::move_prev_long_word_start(text.slice(..), primary, 1),
|
|
1,
|
|
);
|
|
paths.clear();
|
|
paths.push(
|
|
text.slice(current_word.from()..current_word.to())
|
|
.to_string(),
|
|
);
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extend_word_impl<F>(cx: &mut Context, extend_fn: F)
|
|
where
|
|
F: Fn(RopeSlice, Range, usize) -> Range,
|
|
{
|
|
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 word = extend_fn(text, range, count);
|
|
let pos = word.cursor(text);
|
|
range.put_cursor(text, pos, true)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn extend_next_word_start(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_next_word_start)
|
|
}
|
|
|
|
fn extend_prev_word_start(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_prev_word_start)
|
|
}
|
|
|
|
fn extend_next_word_end(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_next_word_end)
|
|
}
|
|
|
|
fn extend_next_long_word_start(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_next_long_word_start)
|
|
}
|
|
|
|
fn extend_prev_long_word_start(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_prev_long_word_start)
|
|
}
|
|
|
|
fn extend_next_long_word_end(cx: &mut Context) {
|
|
extend_word_impl(cx, movement::move_next_long_word_end)
|
|
}
|
|
|
|
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();
|
|
|
|
// need to wait for next key
|
|
// TODO: should this be done by grapheme rather than char? For example,
|
|
// we can't properly handle the line-ending CRLF case here in terms of char.
|
|
cx.on_next_key(move |cx, event| {
|
|
let ch = match event {
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
..
|
|
} =>
|
|
// 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 {
|
|
code: KeyCode::Char(ch),
|
|
..
|
|
} => ch,
|
|
_ => return,
|
|
};
|
|
|
|
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);
|
|
})));
|
|
})
|
|
}
|
|
|
|
//
|
|
|
|
#[inline]
|
|
fn find_char_impl<F, M: CharMatcher + Clone + Copy>(
|
|
editor: &mut Editor,
|
|
search_fn: &F,
|
|
inclusive: bool,
|
|
extend: bool,
|
|
char_matcher: M,
|
|
count: usize,
|
|
) where
|
|
F: Fn(RopeSlice, M, usize, usize, bool) -> Option<usize> + 'static,
|
|
{
|
|
let (view, doc) = current!(editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
// TODO: use `Range::cursor()` here instead. However, that works in terms of
|
|
// graphemes, whereas this function doesn't yet. So we're doing the same logic
|
|
// here, but just in terms of chars instead.
|
|
let search_start_pos = if range.anchor < range.head {
|
|
range.head - 1
|
|
} else {
|
|
range.head
|
|
};
|
|
|
|
search_fn(text, char_matcher, search_start_pos, count, inclusive).map_or(range, |pos| {
|
|
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_next_char_impl(
|
|
text: RopeSlice,
|
|
ch: char,
|
|
pos: usize,
|
|
n: usize,
|
|
inclusive: bool,
|
|
) -> Option<usize> {
|
|
let pos = (pos + 1).min(text.len_chars());
|
|
if inclusive {
|
|
search::find_nth_next(text, ch, pos, n)
|
|
} else {
|
|
let n = match text.get_char(pos) {
|
|
Some(next_ch) if next_ch == ch => n + 1,
|
|
_ => n,
|
|
};
|
|
search::find_nth_next(text, ch, pos, n).map(|n| n.saturating_sub(1))
|
|
}
|
|
}
|
|
|
|
fn find_prev_char_impl(
|
|
text: RopeSlice,
|
|
ch: char,
|
|
pos: usize,
|
|
n: usize,
|
|
inclusive: bool,
|
|
) -> Option<usize> {
|
|
if inclusive {
|
|
search::find_nth_prev(text, ch, pos, n)
|
|
} else {
|
|
let n = match text.get_char(pos.saturating_sub(1)) {
|
|
Some(next_ch) if next_ch == ch => n + 1,
|
|
_ => n,
|
|
};
|
|
search::find_nth_prev(text, ch, pos, n).map(|n| (n + 1).min(text.len_chars()))
|
|
}
|
|
}
|
|
|
|
fn find_till_char(cx: &mut Context) {
|
|
will_find_char(cx, find_next_char_impl, false, false)
|
|
}
|
|
|
|
fn find_next_char(cx: &mut Context) {
|
|
will_find_char(cx, find_next_char_impl, true, false)
|
|
}
|
|
|
|
fn extend_till_char(cx: &mut Context) {
|
|
will_find_char(cx, find_next_char_impl, false, true)
|
|
}
|
|
|
|
fn extend_next_char(cx: &mut Context) {
|
|
will_find_char(cx, find_next_char_impl, true, true)
|
|
}
|
|
|
|
fn till_prev_char(cx: &mut Context) {
|
|
will_find_char(cx, find_prev_char_impl, false, false)
|
|
}
|
|
|
|
fn find_prev_char(cx: &mut Context) {
|
|
will_find_char(cx, find_prev_char_impl, true, false)
|
|
}
|
|
|
|
fn extend_till_prev_char(cx: &mut Context) {
|
|
will_find_char(cx, find_prev_char_impl, false, true)
|
|
}
|
|
|
|
fn extend_prev_char(cx: &mut Context) {
|
|
will_find_char(cx, find_prev_char_impl, true, true)
|
|
}
|
|
|
|
fn repeat_last_motion(cx: &mut Context) {
|
|
let last_motion = cx.editor.last_motion.take();
|
|
if let Some(m) = &last_motion {
|
|
m.run(cx.editor);
|
|
cx.editor.last_motion = last_motion;
|
|
}
|
|
}
|
|
|
|
fn replace(cx: &mut Context) {
|
|
let mut buf = [0u8; 4]; // To hold utf8 encoded char.
|
|
|
|
// need to wait for next key
|
|
cx.on_next_key(move |cx, event| {
|
|
let (view, doc) = current!(cx.editor);
|
|
let ch = match event {
|
|
KeyEvent {
|
|
code: KeyCode::Char(ch),
|
|
..
|
|
} => Some(&ch.encode_utf8(&mut buf[..])[..]),
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
..
|
|
} => Some(doc.line_ending.as_str()),
|
|
_ => None,
|
|
};
|
|
|
|
let selection = doc.selection(view.id);
|
|
|
|
if let Some(ch) = ch {
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
if !range.is_empty() {
|
|
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);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn switch_case_impl<F>(cx: &mut Context, change_fn: F)
|
|
where
|
|
F: Fn(Cow<str>) -> Tendril,
|
|
{
|
|
let (view, doc) = current!(cx.editor);
|
|
let selection = doc.selection(view.id);
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
let text: Tendril = change_fn(range.fragment(doc.text().slice(..)));
|
|
|
|
(range.from(), range.to(), Some(text))
|
|
});
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
fn switch_case(cx: &mut Context) {
|
|
switch_case_impl(cx, |string| {
|
|
string
|
|
.chars()
|
|
.flat_map(|ch| {
|
|
if ch.is_lowercase() {
|
|
ch.to_uppercase().collect()
|
|
} else if ch.is_uppercase() {
|
|
ch.to_lowercase().collect()
|
|
} else {
|
|
vec![ch]
|
|
}
|
|
})
|
|
.collect()
|
|
});
|
|
}
|
|
|
|
fn switch_to_uppercase(cx: &mut Context) {
|
|
switch_case_impl(cx, |string| string.to_uppercase().into());
|
|
}
|
|
|
|
fn switch_to_lowercase(cx: &mut Context) {
|
|
switch_case_impl(cx, |string| string.to_lowercase().into());
|
|
}
|
|
|
|
pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) {
|
|
use Direction::*;
|
|
let config = cx.editor.config();
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let range = doc.selection(view.id).primary();
|
|
let text = doc.text().slice(..);
|
|
|
|
let cursor = coords_at_pos(text, range.cursor(text));
|
|
let doc_last_line = doc.text().len_lines().saturating_sub(1);
|
|
|
|
let last_line = view.last_line(doc);
|
|
|
|
if direction == Backward && view.offset.row == 0
|
|
|| direction == Forward && last_line == doc_last_line
|
|
{
|
|
return;
|
|
}
|
|
|
|
let height = view.inner_area().height;
|
|
|
|
let scrolloff = config.scrolloff.min(height as usize / 2);
|
|
|
|
view.offset.row = match direction {
|
|
Forward => view.offset.row + offset,
|
|
Backward => view.offset.row.saturating_sub(offset),
|
|
}
|
|
.min(doc_last_line);
|
|
|
|
// recalculate last line
|
|
let last_line = view.last_line(doc);
|
|
|
|
// 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_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end
|
|
|
|
let anchor = if doc.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_area().height as usize;
|
|
scroll(cx, offset, Direction::Backward);
|
|
}
|
|
|
|
fn page_down(cx: &mut Context) {
|
|
let view = view!(cx.editor);
|
|
let offset = view.inner_area().height as usize;
|
|
scroll(cx, offset, Direction::Forward);
|
|
}
|
|
|
|
fn half_page_up(cx: &mut Context) {
|
|
let view = view!(cx.editor);
|
|
let offset = view.inner_area().height as usize / 2;
|
|
scroll(cx, offset, Direction::Backward);
|
|
}
|
|
|
|
fn half_page_down(cx: &mut Context) {
|
|
let view = view!(cx.editor);
|
|
let offset = view.inner_area().height as usize / 2;
|
|
scroll(cx, offset, Direction::Forward);
|
|
}
|
|
|
|
fn copy_selection_on_line(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 = SmallVec::with_capacity(selection.ranges().len() * (count + 1));
|
|
ranges.extend_from_slice(selection.ranges());
|
|
let mut primary_index = 0;
|
|
for range in selection.iter() {
|
|
let is_primary = *range == selection.primary();
|
|
|
|
// The range is always head exclusive
|
|
let head = if range.anchor < range.head {
|
|
range.head - 1
|
|
} else {
|
|
range.head
|
|
};
|
|
|
|
// TODO: this should use visual offsets / pos_at_screen_coords
|
|
let head_pos = coords_at_pos(text, head);
|
|
let anchor_pos = coords_at_pos(text, range.anchor);
|
|
|
|
let height = std::cmp::max(head_pos.row, anchor_pos.row)
|
|
- std::cmp::min(head_pos.row, anchor_pos.row)
|
|
+ 1;
|
|
|
|
if is_primary {
|
|
primary_index = ranges.len();
|
|
}
|
|
ranges.push(*range);
|
|
|
|
let mut sels = 0;
|
|
let mut i = 0;
|
|
while sels < count {
|
|
let offset = (i + 1) * height;
|
|
|
|
let anchor_row = match direction {
|
|
Direction::Forward => anchor_pos.row + offset,
|
|
Direction::Backward => anchor_pos.row.saturating_sub(offset),
|
|
};
|
|
|
|
let head_row = match direction {
|
|
Direction::Forward => head_pos.row + offset,
|
|
Direction::Backward => head_pos.row.saturating_sub(offset),
|
|
};
|
|
|
|
if anchor_row >= text.len_lines() || head_row >= text.len_lines() {
|
|
break;
|
|
}
|
|
|
|
let anchor = pos_at_coords(text, Position::new(anchor_row, anchor_pos.col), true);
|
|
let head = pos_at_coords(text, Position::new(head_row, head_pos.col), true);
|
|
|
|
// skip lines that are too short
|
|
if coords_at_pos(text, anchor).col == anchor_pos.col
|
|
&& coords_at_pos(text, head).col == head_pos.col
|
|
{
|
|
if is_primary {
|
|
primary_index = ranges.len();
|
|
}
|
|
// This is Range::new(anchor, head), but it will place the cursor on the correct column
|
|
ranges.push(Range::point(anchor).put_cursor(text, head, true));
|
|
sels += 1;
|
|
}
|
|
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
let selection = Selection::new(ranges, primary_index);
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn copy_selection_on_prev_line(cx: &mut Context) {
|
|
copy_selection_on_line(cx, Direction::Backward)
|
|
}
|
|
|
|
fn copy_selection_on_next_line(cx: &mut Context) {
|
|
copy_selection_on_line(cx, Direction::Forward)
|
|
}
|
|
|
|
fn select_all(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let end = doc.text().len_chars();
|
|
doc.set_selection(view.id, Selection::single(0, end))
|
|
}
|
|
|
|
fn select_regex(cx: &mut Context) {
|
|
let reg = cx.register.unwrap_or('/');
|
|
ui::regex_prompt(
|
|
cx,
|
|
"select:".into(),
|
|
Some(reg),
|
|
ui::completers::none,
|
|
move |view, doc, regex, event| {
|
|
if event != PromptEvent::Update {
|
|
return;
|
|
}
|
|
let text = doc.text().slice(..);
|
|
if let Some(selection) =
|
|
selection::select_on_matches(text, doc.selection(view.id), ®ex)
|
|
{
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn split_selection(cx: &mut Context) {
|
|
let reg = cx.register.unwrap_or('/');
|
|
ui::regex_prompt(
|
|
cx,
|
|
"split:".into(),
|
|
Some(reg),
|
|
ui::completers::none,
|
|
move |view, doc, regex, event| {
|
|
if event != PromptEvent::Update {
|
|
return;
|
|
}
|
|
let text = doc.text().slice(..);
|
|
let selection = selection::split_on_matches(text, doc.selection(view.id), ®ex);
|
|
doc.set_selection(view.id, selection);
|
|
},
|
|
);
|
|
}
|
|
|
|
fn split_selection_on_newline(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
// 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(
|
|
doc: &mut Document,
|
|
view: &mut View,
|
|
contents: &str,
|
|
regex: &Regex,
|
|
movement: Movement,
|
|
direction: Direction,
|
|
scrolloff: usize,
|
|
wrap_around: bool,
|
|
) {
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id);
|
|
|
|
// Get the right side of the primary block cursor for forward search, or the
|
|
// grapheme before the start of the selection for reverse search.
|
|
let start = match direction {
|
|
Direction::Forward => text.char_to_byte(graphemes::ensure_grapheme_boundary_next(
|
|
text,
|
|
selection.primary().to(),
|
|
)),
|
|
Direction::Backward => text.char_to_byte(graphemes::ensure_grapheme_boundary_prev(
|
|
text,
|
|
selection.primary().from(),
|
|
)),
|
|
};
|
|
|
|
// A regex::Match returns byte-positions in the str. In the case where we
|
|
// 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 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_at(contents, start),
|
|
Direction::Backward => regex.find_iter(&contents[..start]).last(),
|
|
};
|
|
|
|
if wrap_around && mat.is_none() {
|
|
mat = match direction {
|
|
Direction::Forward => regex.find(contents),
|
|
Direction::Backward => {
|
|
offset = start;
|
|
regex.find_iter(&contents[start..]).last()
|
|
}
|
|
}
|
|
// TODO: message on wraparound
|
|
}
|
|
|
|
if let Some(mat) = mat {
|
|
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
|
|
return;
|
|
}
|
|
|
|
// Determine range direction based on the primary range
|
|
let primary = selection.primary();
|
|
let range = if primary.head < primary.anchor {
|
|
Range::new(end, start)
|
|
} else {
|
|
Range::new(start, end)
|
|
};
|
|
|
|
let selection = match movement {
|
|
Movement::Extend => selection.clone().push(range),
|
|
Movement::Move => selection.clone().replace(selection.primary_index(), range),
|
|
};
|
|
|
|
doc.set_selection(view.id, selection);
|
|
// TODO: is_cursor_in_view does the same calculation as ensure_cursor_in_view
|
|
if view.is_cursor_in_view(doc, 0) {
|
|
view.ensure_cursor_in_view(doc, scrolloff);
|
|
} else {
|
|
align_view(doc, view, Align::Center)
|
|
}
|
|
};
|
|
}
|
|
|
|
fn search_completions(cx: &mut Context, reg: Option<char>) -> Vec<String> {
|
|
let mut items = reg
|
|
.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().cloned().collect()
|
|
}
|
|
|
|
fn search(cx: &mut Context) {
|
|
searcher(cx, Direction::Forward)
|
|
}
|
|
|
|
fn rsearch(cx: &mut Context) {
|
|
searcher(cx, Direction::Backward)
|
|
}
|
|
|
|
fn searcher(cx: &mut Context, direction: Direction) {
|
|
let reg = cx.register.unwrap_or('/');
|
|
let config = cx.editor.config();
|
|
let scrolloff = config.scrolloff;
|
|
let wrap_around = config.search.wrap_around;
|
|
|
|
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(
|
|
cx,
|
|
"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 |view, doc, regex, event| {
|
|
if event != PromptEvent::Update {
|
|
return;
|
|
}
|
|
search_impl(
|
|
doc,
|
|
view,
|
|
&contents,
|
|
®ex,
|
|
Movement::Move,
|
|
direction,
|
|
scrolloff,
|
|
wrap_around,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) {
|
|
let config = cx.editor.config();
|
|
let scrolloff = config.scrolloff;
|
|
let (view, doc) = current!(cx.editor);
|
|
let registers = &cx.editor.registers;
|
|
if let Some(query) = registers.read('/') {
|
|
let query = query.last().unwrap();
|
|
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)
|
|
} else {
|
|
false
|
|
};
|
|
let wrap_around = search_config.wrap_around;
|
|
if let Ok(regex) = RegexBuilder::new(query)
|
|
.case_insensitive(case_insensitive)
|
|
.multi_line(true)
|
|
.build()
|
|
{
|
|
search_impl(
|
|
doc,
|
|
view,
|
|
&contents,
|
|
®ex,
|
|
movement,
|
|
direction,
|
|
scrolloff,
|
|
wrap_around,
|
|
);
|
|
} else {
|
|
let error = format!("Invalid regex: {}", query);
|
|
cx.editor.set_error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn search_next(cx: &mut Context) {
|
|
search_next_or_prev_impl(cx, Movement::Move, Direction::Forward);
|
|
}
|
|
|
|
fn search_prev(cx: &mut Context) {
|
|
search_next_or_prev_impl(cx, Movement::Move, Direction::Backward);
|
|
}
|
|
fn extend_search_next(cx: &mut Context) {
|
|
search_next_or_prev_impl(cx, Movement::Extend, Direction::Forward);
|
|
}
|
|
|
|
fn extend_search_prev(cx: &mut Context) {
|
|
search_next_or_prev_impl(cx, Movement::Extend, Direction::Backward);
|
|
}
|
|
|
|
fn search_selection(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let contents = doc.text().slice(..);
|
|
let query = doc.selection(view.id).primary().fragment(contents);
|
|
let regex = regex::escape(&query);
|
|
cx.editor.registers.get_mut('/').push(regex);
|
|
let msg = format!("register '{}' set to '{}'", '/', query);
|
|
cx.editor.set_status(msg);
|
|
}
|
|
|
|
fn global_search(cx: &mut Context) {
|
|
let (all_matches_sx, all_matches_rx) =
|
|
tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>();
|
|
let config = cx.editor.config();
|
|
let smart_case = config.search.smart_case;
|
|
let file_picker_config = config.file_picker.clone();
|
|
|
|
let completions = search_completions(cx, None);
|
|
ui::regex_prompt(
|
|
cx,
|
|
"global-search:".into(),
|
|
None,
|
|
move |_editor: &Editor, input: &str| {
|
|
completions
|
|
.iter()
|
|
.filter(|comp| comp.starts_with(input))
|
|
.map(|comp| (0.., std::borrow::Cow::Owned(comp.clone())))
|
|
.collect()
|
|
},
|
|
move |_view, _doc, regex, event| {
|
|
if event != PromptEvent::Validate {
|
|
return;
|
|
}
|
|
|
|
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)
|
|
.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)
|
|
.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((line_num as usize - 1, entry.path().to_path_buf()))
|
|
.unwrap();
|
|
|
|
Ok(true)
|
|
}),
|
|
);
|
|
|
|
if let Err(err) = result {
|
|
log::error!(
|
|
"Global search error: {}, {}",
|
|
entry.path().display(),
|
|
err
|
|
);
|
|
}
|
|
WalkState::Continue
|
|
})
|
|
});
|
|
} else {
|
|
// Otherwise do nothing
|
|
// log::warn!("Global Search Invalid Pattern")
|
|
}
|
|
},
|
|
);
|
|
|
|
let current_path = doc_mut!(cx.editor).path().cloned();
|
|
|
|
let show_picker = async move {
|
|
let all_matches: Vec<(usize, PathBuf)> =
|
|
UnboundedReceiverStream::new(all_matches_rx).collect().await;
|
|
let call: job::Callback =
|
|
Box::new(move |editor: &mut Editor, compositor: &mut Compositor| {
|
|
if all_matches.is_empty() {
|
|
editor.set_status("No matches found");
|
|
return;
|
|
}
|
|
|
|
let picker = FilePicker::new(
|
|
all_matches,
|
|
move |(_line_num, path)| {
|
|
let relative_path = helix_core::path::get_relative_path(path)
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
if current_path.as_ref().map(|p| p == path).unwrap_or(false) {
|
|
format!("{} (*)", relative_path).into()
|
|
} else {
|
|
relative_path.into()
|
|
}
|
|
},
|
|
move |cx, (line_num, path), action| {
|
|
match cx.editor.open(path.into(), action) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
cx.editor.set_error(format!(
|
|
"Failed to open file '{}': {}",
|
|
path.display(),
|
|
e
|
|
));
|
|
return;
|
|
}
|
|
}
|
|
|
|
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()));
|
|
|
|
doc.set_selection(view.id, Selection::single(start, end));
|
|
align_view(doc, view, Align::Center);
|
|
},
|
|
|_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))),
|
|
);
|
|
compositor.push(Box::new(overlayed(picker)));
|
|
});
|
|
Ok(call)
|
|
};
|
|
cx.jobs.callback(show_picker);
|
|
}
|
|
|
|
enum Extend {
|
|
Above,
|
|
Below,
|
|
}
|
|
|
|
fn extend_line(cx: &mut Context) {
|
|
extend_line_impl(cx, Extend::Below);
|
|
}
|
|
|
|
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);
|
|
|
|
let text = doc.text();
|
|
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((end_line + count).min(text.len_lines()));
|
|
|
|
// extend to previous/next line if current line is selected
|
|
let (anchor, head) = if range.from() == start && range.to() == end {
|
|
match extend {
|
|
Extend::Above => (end, text.line_to_char(start_line.saturating_sub(1))),
|
|
Extend::Below => (
|
|
start,
|
|
text.line_to_char((end_line + count + 1).min(text.len_lines())),
|
|
),
|
|
}
|
|
} else {
|
|
(start, end)
|
|
};
|
|
|
|
Range::new(anchor, head)
|
|
});
|
|
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn extend_to_line_bounds(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
doc.set_selection(
|
|
view.id,
|
|
doc.selection(view.id).clone().transform(|range| {
|
|
let text = doc.text();
|
|
|
|
let (start_line, end_line) = range.line_range(text.slice(..));
|
|
let start = text.line_to_char(start_line);
|
|
let end = text.line_to_char((end_line + 1).min(text.len_lines()));
|
|
|
|
if range.anchor <= range.head {
|
|
Range::new(start, end)
|
|
} else {
|
|
Range::new(end, start)
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
enum Operation {
|
|
Delete,
|
|
Change,
|
|
}
|
|
|
|
fn delete_selection_impl(cx: &mut Context, op: Operation) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id);
|
|
|
|
if cx.register != Some('_') {
|
|
// first yank the selection
|
|
let values: Vec<String> = selection.fragments(text).map(Cow::into_owned).collect();
|
|
let reg_name = cx.register.unwrap_or('"');
|
|
let registers = &mut cx.editor.registers;
|
|
let reg = registers.get_mut(reg_name);
|
|
reg.write(values);
|
|
};
|
|
|
|
// then delete
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
(range.from(), range.to(), None)
|
|
});
|
|
doc.apply(&transaction, view.id);
|
|
|
|
match op {
|
|
Operation::Delete => {
|
|
// exit select mode, if currently in select mode
|
|
exit_select_mode(cx);
|
|
}
|
|
Operation::Change => {
|
|
enter_insert_mode(doc);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) {
|
|
let view_id = view.id;
|
|
|
|
// then delete
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
(range.from(), range.to(), None)
|
|
});
|
|
doc.apply(&transaction, view_id);
|
|
}
|
|
|
|
fn delete_selection(cx: &mut Context) {
|
|
delete_selection_impl(cx, Operation::Delete);
|
|
}
|
|
|
|
fn delete_selection_noyank(cx: &mut Context) {
|
|
cx.register = Some('_');
|
|
delete_selection_impl(cx, Operation::Delete);
|
|
}
|
|
|
|
fn change_selection(cx: &mut Context) {
|
|
delete_selection_impl(cx, Operation::Change);
|
|
}
|
|
|
|
fn change_selection_noyank(cx: &mut Context) {
|
|
cx.register = Some('_');
|
|
delete_selection_impl(cx, Operation::Change);
|
|
}
|
|
|
|
fn collapse_selection(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
let pos = range.cursor(text);
|
|
Range::new(pos, pos)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn flip_selections(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| range.flip());
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn ensure_selections_forward(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|r| match r.direction() {
|
|
Direction::Forward => r,
|
|
Direction::Backward => r.flip(),
|
|
});
|
|
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn enter_insert_mode(doc: &mut Document) {
|
|
doc.mode = Mode::Insert;
|
|
}
|
|
|
|
// inserts at the start of each selection
|
|
fn insert_mode(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
enter_insert_mode(doc);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| Range::new(range.to(), range.from()));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
// inserts at the end of each selection
|
|
fn append_mode(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
enter_insert_mode(doc);
|
|
doc.restore_cursor = true;
|
|
let text = doc.text().slice(..);
|
|
|
|
// Make sure there's room at the end of the document if the last
|
|
// selection butts up against it.
|
|
let end = text.len_chars();
|
|
let last_range = doc.selection(view.id).iter().last().unwrap();
|
|
if !last_range.is_empty() && last_range.head == end {
|
|
let transaction = Transaction::change(
|
|
doc.text(),
|
|
[(end, end, Some(doc.line_ending.as_str().into()))].into_iter(),
|
|
);
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
Range::new(
|
|
range.from(),
|
|
graphemes::next_grapheme_boundary(doc.text().slice(..), range.to()),
|
|
)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn file_picker(cx: &mut Context) {
|
|
// We don't specify language markers, root will be the root of the current git repo
|
|
let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./"));
|
|
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 = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./"));
|
|
let picker = ui::file_picker(cwd, &cx.editor.config());
|
|
cx.push_layer(Box::new(picker));
|
|
}
|
|
|
|
fn buffer_picker(cx: &mut Context) {
|
|
let current = view!(cx.editor).doc;
|
|
|
|
struct BufferMeta {
|
|
id: DocumentId,
|
|
path: Option<PathBuf>,
|
|
is_modified: bool,
|
|
is_current: bool,
|
|
}
|
|
|
|
impl BufferMeta {
|
|
fn format(&self) -> Cow<str> {
|
|
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(""))
|
|
};
|
|
Cow::Owned(format!("{} {}{}", self.id, path, flag))
|
|
}
|
|
}
|
|
|
|
let new_meta = |doc: &Document| BufferMeta {
|
|
id: doc.id(),
|
|
path: doc.path().cloned(),
|
|
is_modified: doc.is_modified(),
|
|
is_current: doc.id() == current,
|
|
};
|
|
|
|
let picker = FilePicker::new(
|
|
cx.editor
|
|
.documents
|
|
.iter()
|
|
.map(|(_, doc)| new_meta(doc))
|
|
.collect(),
|
|
BufferMeta::format,
|
|
|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.path.clone()?, Some((line, line))))
|
|
},
|
|
);
|
|
cx.push_layer(Box::new(overlayed(picker)));
|
|
}
|
|
|
|
pub fn command_palette(cx: &mut Context) {
|
|
cx.callback = Some(Box::new(
|
|
move |compositor: &mut Compositor, cx: &mut compositor::Context| {
|
|
let doc = doc_mut!(cx.editor);
|
|
let keymap =
|
|
compositor.find::<ui::EditorView>().unwrap().keymaps.map()[&doc.mode].reverse_map();
|
|
|
|
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(),
|
|
}
|
|
}));
|
|
|
|
// formats key bindings, multiple bindings are comma separated,
|
|
// individual key presses are joined with `+`
|
|
let fmt_binding = |bindings: &Vec<Vec<KeyEvent>>| -> String {
|
|
bindings
|
|
.iter()
|
|
.map(|bind| {
|
|
bind.iter()
|
|
.map(|key| key.to_string())
|
|
.collect::<Vec<String>>()
|
|
.join("+")
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join(", ")
|
|
};
|
|
|
|
let picker = Picker::new(
|
|
commands,
|
|
move |command| match command {
|
|
MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String)
|
|
{
|
|
Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
|
|
None => doc.into(),
|
|
},
|
|
MappableCommand::Static { doc, name, .. } => match keymap.get(*name) {
|
|
Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
|
|
None => (*doc).into(),
|
|
},
|
|
},
|
|
move |cx, command, _action| {
|
|
let mut ctx = Context {
|
|
register: None,
|
|
count: std::num::NonZeroUsize::new(1),
|
|
editor: cx.editor,
|
|
callback: None,
|
|
on_next_key_callback: None,
|
|
jobs: cx.jobs,
|
|
};
|
|
command.execute(&mut ctx);
|
|
},
|
|
);
|
|
compositor.push(Box::new(picker));
|
|
},
|
|
));
|
|
}
|
|
|
|
fn last_picker(cx: &mut Context) {
|
|
// TODO: last picker does not seem to work well with buffer_picker
|
|
cx.callback = Some(Box::new(|compositor: &mut Compositor, _| {
|
|
if let Some(picker) = compositor.last_picker.take() {
|
|
compositor.push(picker);
|
|
}
|
|
// XXX: figure out how to show error when no last picker lifetime
|
|
// cx.editor.set_error("no last picker")
|
|
}));
|
|
}
|
|
|
|
// I inserts at the first nonwhitespace character of each line with a selection
|
|
fn prepend_to_line(cx: &mut Context) {
|
|
goto_first_nonwhitespace(cx);
|
|
let doc = doc_mut!(cx.editor);
|
|
enter_insert_mode(doc);
|
|
}
|
|
|
|
// A inserts at the end of each line with a selection
|
|
fn append_to_line(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
enter_insert_mode(doc);
|
|
|
|
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)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
/// Sometimes when applying formatting changes we want to mark the buffer as unmodified, for
|
|
/// example because we just applied the same changes while saving.
|
|
enum Modified {
|
|
SetUnmodified,
|
|
LeaveModified,
|
|
}
|
|
|
|
// Creates an LspCallback that waits for formatting changes to be computed. When they're done,
|
|
// it applies them, but only if the doc hasn't changed.
|
|
//
|
|
// TODO: provide some way to cancel this, probably as part of a more general job cancellation
|
|
// scheme
|
|
async fn make_format_callback(
|
|
doc_id: DocumentId,
|
|
doc_version: i32,
|
|
modified: Modified,
|
|
format: impl Future<Output = helix_lsp::util::LspFormatting> + Send + 'static,
|
|
) -> anyhow::Result<job::Callback> {
|
|
let format = format.await;
|
|
let call: job::Callback = Box::new(move |editor, _compositor| {
|
|
let view_id = view!(editor).id;
|
|
if let Some(doc) = editor.document_mut(doc_id) {
|
|
if doc.version() == doc_version {
|
|
doc.apply(&Transaction::from(format), view_id);
|
|
doc.append_changes_to_history(view_id);
|
|
if let Modified::SetUnmodified = modified {
|
|
doc.reset_modified();
|
|
}
|
|
} else {
|
|
log::info!("discarded formatting changes because the document changed");
|
|
}
|
|
}
|
|
});
|
|
Ok(call)
|
|
}
|
|
|
|
enum Open {
|
|
Below,
|
|
Above,
|
|
}
|
|
|
|
fn open(cx: &mut Context, open: Open) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
enter_insert_mode(doc);
|
|
|
|
let text = doc.text().slice(..);
|
|
let contents = doc.text();
|
|
let selection = doc.selection(view.id);
|
|
|
|
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 = text.char_to_line(match open {
|
|
Open::Below => graphemes::prev_grapheme_boundary(text, range.to()),
|
|
Open::Above => range.from(),
|
|
});
|
|
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,
|
|
};
|
|
|
|
// Index to insert newlines after, as well as the char width
|
|
// to use to compensate for those inserted newlines.
|
|
let (line_end_index, line_end_offset_width) = if new_line == 0 {
|
|
(0, 0)
|
|
} else {
|
|
(
|
|
line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)),
|
|
doc.line_ending.len_chars(),
|
|
)
|
|
};
|
|
|
|
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);
|
|
text.push_str(doc.line_ending.as_str());
|
|
text.push_str(&indent);
|
|
let text = text.repeat(count);
|
|
|
|
// calculate new selection ranges
|
|
let pos = offs + line_end_index + line_end_offset_width;
|
|
for i in 0..count {
|
|
// 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));
|
|
}
|
|
|
|
offs += text.chars().count();
|
|
|
|
(line_end_index, line_end_index, Some(text.into()))
|
|
});
|
|
|
|
transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index()));
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
// o inserts a new line after each line with a selection
|
|
fn open_below(cx: &mut Context) {
|
|
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)
|
|
}
|
|
|
|
fn normal_mode(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
if doc.mode == Mode::Normal {
|
|
return;
|
|
}
|
|
|
|
doc.mode = Mode::Normal;
|
|
|
|
try_restore_indent(doc, view.id);
|
|
|
|
// if leaving append mode, move cursor back by 1
|
|
if doc.restore_cursor {
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
Range::new(
|
|
range.from(),
|
|
graphemes::prev_grapheme_boundary(text, range.to()),
|
|
)
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
|
|
doc.restore_cursor = false;
|
|
}
|
|
}
|
|
|
|
fn try_restore_indent(doc: &mut Document, view_id: ViewId) {
|
|
use helix_core::chars::char_is_whitespace;
|
|
use helix_core::Operation;
|
|
|
|
fn inserted_a_new_blank_line(changes: &[Operation], pos: usize, line_end_pos: usize) -> bool {
|
|
if let [Operation::Retain(move_pos), Operation::Insert(ref inserted_str), Operation::Retain(_)] =
|
|
changes
|
|
{
|
|
move_pos + inserted_str.len() == pos
|
|
&& inserted_str.starts_with('\n')
|
|
&& inserted_str.chars().skip(1).all(char_is_whitespace)
|
|
&& pos == line_end_pos // ensure no characters exists after current position
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
let doc_changes = doc.changes().changes();
|
|
let text = doc.text().slice(..);
|
|
let range = doc.selection(view_id).primary();
|
|
let pos = range.cursor(text);
|
|
let line_end_pos = line_end_char_index(&text, range.cursor_line(text));
|
|
|
|
if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) {
|
|
// Removes tailing whitespaces.
|
|
let transaction =
|
|
Transaction::change_by_selection(doc.text(), doc.selection(view_id), |range| {
|
|
let line_start_pos = text.line_to_char(range.cursor_line(text));
|
|
(line_start_pos, pos, None)
|
|
});
|
|
doc.apply(&transaction, view_id);
|
|
}
|
|
}
|
|
|
|
// Store a jump on the jumplist.
|
|
fn push_jump(editor: &mut Editor) {
|
|
let (view, doc) = current!(editor);
|
|
let jump = (doc.id(), doc.selection(view.id).clone());
|
|
view.jumps.push(jump);
|
|
}
|
|
|
|
fn goto_line(cx: &mut Context) {
|
|
goto_line_impl(cx.editor, cx.count)
|
|
}
|
|
|
|
fn goto_line_impl(editor: &mut Editor, count: Option<NonZeroUsize>) {
|
|
if let Some(count) = count {
|
|
push_jump(editor);
|
|
|
|
let (view, doc) = current!(editor);
|
|
let max_line = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 {
|
|
// If the last line is blank, don't jump to it.
|
|
doc.text().len_lines().saturating_sub(2)
|
|
} else {
|
|
doc.text().len_lines() - 1
|
|
};
|
|
let line_idx = std::cmp::min(count.get() - 1, max_line);
|
|
let text = doc.text().slice(..);
|
|
let pos = doc.text().line_to_char(line_idx);
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
}
|
|
|
|
fn goto_last_line(cx: &mut Context) {
|
|
push_jump(cx.editor);
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
let line_idx = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 {
|
|
// If the last line is blank, don't jump to it.
|
|
doc.text().len_lines().saturating_sub(2)
|
|
} else {
|
|
doc.text().len_lines() - 1
|
|
};
|
|
let text = doc.text().slice(..);
|
|
let pos = doc.text().line_to_char(line_idx);
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn goto_last_accessed_file(cx: &mut Context) {
|
|
let view = view_mut!(cx.editor);
|
|
if let Some(alt) = view.docs_access_history.pop() {
|
|
cx.editor.switch(alt, Action::Replace);
|
|
} else {
|
|
cx.editor.set_error("no last accessed buffer")
|
|
}
|
|
}
|
|
|
|
fn goto_last_modification(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let pos = doc.history.get_mut().last_edit_pos();
|
|
let text = doc.text().slice(..);
|
|
if let Some(pos) = pos {
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
}
|
|
|
|
fn goto_last_modified_file(cx: &mut Context) {
|
|
let view = view!(cx.editor);
|
|
let alternate_file = view
|
|
.last_modified_docs
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|&id| id != view.doc);
|
|
if let Some(alt) = alternate_file {
|
|
cx.editor.switch(alt, Action::Replace);
|
|
} else {
|
|
cx.editor.set_error("no last modified buffer")
|
|
}
|
|
}
|
|
|
|
fn select_mode(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
// Make sure end-of-document selections are also 1-width.
|
|
// (With the exception of being in an empty document, of course.)
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
if range.is_empty() && range.head == text.len_chars() {
|
|
Range::new(
|
|
graphemes::prev_grapheme_boundary(text, range.anchor),
|
|
range.head,
|
|
)
|
|
} else {
|
|
range
|
|
}
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
|
|
doc_mut!(cx.editor).mode = Mode::Select;
|
|
}
|
|
|
|
fn exit_select_mode(cx: &mut Context) {
|
|
let doc = doc_mut!(cx.editor);
|
|
if doc.mode == Mode::Select {
|
|
doc.mode = Mode::Normal;
|
|
}
|
|
}
|
|
|
|
fn goto_pos(editor: &mut Editor, pos: usize) {
|
|
push_jump(editor);
|
|
|
|
let (view, doc) = current!(editor);
|
|
|
|
doc.set_selection(view.id, Selection::point(pos));
|
|
align_view(doc, view, Align::Center);
|
|
}
|
|
|
|
fn goto_first_diag(cx: &mut Context) {
|
|
let doc = doc!(cx.editor);
|
|
let pos = match doc.diagnostics().first() {
|
|
Some(diag) => diag.range.start,
|
|
None => return,
|
|
};
|
|
goto_pos(cx.editor, pos);
|
|
}
|
|
|
|
fn goto_last_diag(cx: &mut Context) {
|
|
let doc = doc!(cx.editor);
|
|
let pos = match doc.diagnostics().last() {
|
|
Some(diag) => diag.range.start,
|
|
None => return,
|
|
};
|
|
goto_pos(cx.editor, pos);
|
|
}
|
|
|
|
fn goto_next_diag(cx: &mut Context) {
|
|
let editor = &mut cx.editor;
|
|
let (view, doc) = current!(editor);
|
|
|
|
let cursor_pos = doc
|
|
.selection(view.id)
|
|
.primary()
|
|
.cursor(doc.text().slice(..));
|
|
|
|
let diag = doc
|
|
.diagnostics()
|
|
.iter()
|
|
.find(|diag| diag.range.start > cursor_pos)
|
|
.or_else(|| doc.diagnostics().first());
|
|
|
|
let pos = match diag {
|
|
Some(diag) => diag.range.start,
|
|
None => return,
|
|
};
|
|
|
|
goto_pos(editor, pos);
|
|
}
|
|
|
|
fn goto_prev_diag(cx: &mut Context) {
|
|
let editor = &mut cx.editor;
|
|
let (view, doc) = current!(editor);
|
|
|
|
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)
|
|
.or_else(|| doc.diagnostics().last());
|
|
|
|
let pos = match diag {
|
|
Some(diag) => diag.range.start,
|
|
None => return,
|
|
};
|
|
|
|
goto_pos(editor, pos);
|
|
}
|
|
|
|
pub mod insert {
|
|
use super::*;
|
|
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
|
|
pub type PostHook = fn(&mut Context, char);
|
|
|
|
// 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) {
|
|
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);
|
|
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));
|
|
|
|
if is_trigger {
|
|
super::signature_help(cx);
|
|
}
|
|
}
|
|
|
|
// SignatureHelp {
|
|
// signatures: [
|
|
// SignatureInformation {
|
|
// label: "fn open(&mut self, path: PathBuf, action: Action) -> Result<DocumentId, Error>",
|
|
// documentation: None,
|
|
// parameters: Some(
|
|
// [ParameterInformation { label: Simple("path: PathBuf"), documentation: None },
|
|
// ParameterInformation { label: Simple("action: Action"), documentation: None }]
|
|
// ),
|
|
// active_parameter: Some(0)
|
|
// }
|
|
// ],
|
|
// active_signature: None, active_parameter: Some(0)
|
|
// }
|
|
}
|
|
|
|
// 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> {
|
|
let cursors = selection.clone().cursors(doc.slice(..));
|
|
let mut t = Tendril::new();
|
|
t.push(ch);
|
|
let transaction = Transaction::insert(doc, &cursors, t);
|
|
Some(transaction)
|
|
}
|
|
|
|
use helix_core::auto_pairs;
|
|
|
|
pub fn insert_char(cx: &mut Context, c: char) {
|
|
let (view, doc) = current_ref!(cx.editor);
|
|
let text = doc.text();
|
|
let selection = doc.selection(view.id);
|
|
let auto_pairs = doc.auto_pairs(cx.editor);
|
|
|
|
let transaction = auto_pairs
|
|
.as_ref()
|
|
.and_then(|ap| auto_pairs::hook(text, selection, c, ap))
|
|
.or_else(|| insert(text, selection, c));
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
if let Some(t) = transaction {
|
|
doc.apply(&t, view.id);
|
|
}
|
|
|
|
// 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] {
|
|
// for hook in &[signature_help] {
|
|
hook(cx, c);
|
|
}
|
|
}
|
|
|
|
pub fn insert_tab(cx: &mut Context) {
|
|
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_unit());
|
|
let transaction = Transaction::insert(
|
|
doc.text(),
|
|
&doc.selection(view.id).clone().cursors(doc.text().slice(..)),
|
|
indent,
|
|
);
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
pub fn insert_newline(cx: &mut Context) {
|
|
let (view, doc) = current_ref!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let contents = doc.text();
|
|
let selection = doc.selection(view.id).clone().cursors(text);
|
|
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 offs = 0;
|
|
|
|
let mut transaction = Transaction::change_by_selection(contents, &selection, |range| {
|
|
let pos = range.head;
|
|
|
|
let prev = if pos == 0 {
|
|
' '
|
|
} else {
|
|
contents.char(pos - 1)
|
|
};
|
|
let curr = contents.get_char(pos).unwrap_or(' ');
|
|
|
|
let current_line = text.char_to_line(pos);
|
|
let indent = indent::indent_for_newline(
|
|
doc.language_config(),
|
|
doc.syntax(),
|
|
&doc.indent_style,
|
|
doc.tab_width(),
|
|
text,
|
|
current_line,
|
|
pos,
|
|
current_line,
|
|
);
|
|
let mut text = String::new();
|
|
// If we are between pairs (such as brackets), we want to
|
|
// insert an additional line which is indented one level
|
|
// more and place the cursor there
|
|
let on_auto_pair = doc
|
|
.auto_pairs(cx.editor)
|
|
.and_then(|pairs| pairs.get(prev))
|
|
.and_then(|pair| if pair.close == curr { Some(pair) } else { None })
|
|
.is_some();
|
|
|
|
let new_head_pos = if on_auto_pair {
|
|
let inner_indent = indent.clone() + doc.indent_style.as_str();
|
|
text.reserve_exact(2 + indent.len() + inner_indent.len());
|
|
text.push_str(doc.line_ending.as_str());
|
|
text.push_str(&inner_indent);
|
|
let new_head_pos = pos + offs + text.chars().count();
|
|
text.push_str(doc.line_ending.as_str());
|
|
text.push_str(&indent);
|
|
new_head_pos
|
|
} else {
|
|
text.reserve_exact(1 + indent.len());
|
|
text.push_str(doc.line_ending.as_str());
|
|
text.push_str(&indent);
|
|
pos + offs + text.chars().count()
|
|
};
|
|
|
|
// TODO: range replace or extend
|
|
// 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(Range::new(new_head_pos, new_head_pos));
|
|
offs += text.chars().count();
|
|
|
|
(pos, pos, Some(text.into()))
|
|
});
|
|
|
|
transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index()));
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
pub fn delete_char_backward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let indent_unit = doc.indent_unit();
|
|
let tab_size = doc.tab_width();
|
|
|
|
let transaction =
|
|
Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
|
|
let pos = range.cursor(text);
|
|
let line_start_pos = text.line_to_char(range.cursor_line(text));
|
|
// considier to delete by indent level if all characters before `pos` are indent units.
|
|
let fragment = Cow::from(text.slice(line_start_pos..pos));
|
|
if !fragment.is_empty() && fragment.chars().all(|ch| ch.is_whitespace()) {
|
|
if text.get_char(pos.saturating_sub(1)) == Some('\t') {
|
|
// fast path, delete one char
|
|
(
|
|
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_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?).
|
|
ch.width().unwrap_or(1)
|
|
}
|
|
})
|
|
.sum();
|
|
let mut drop = width % unit_size; // round down to nearest unit
|
|
if drop == 0 {
|
|
drop = unit_size
|
|
}; // if it's already at a unit, consume a whole unit
|
|
let mut chars = fragment.chars().rev();
|
|
let mut start = pos;
|
|
for _ in 0..drop {
|
|
// delete up to `drop` spaces
|
|
match chars.next() {
|
|
Some(' ') => start -= 1,
|
|
_ => break,
|
|
}
|
|
}
|
|
(start, pos, None) // delete!
|
|
}
|
|
} else {
|
|
// delete char
|
|
(
|
|
graphemes::nth_prev_grapheme_boundary(text, pos, count),
|
|
pos,
|
|
None,
|
|
)
|
|
}
|
|
});
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
pub fn delete_char_forward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
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),
|
|
None,
|
|
)
|
|
});
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
pub fn delete_word_backward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| movement::move_prev_word_start(text, range, count));
|
|
delete_selection_insert_mode(doc, view, &selection);
|
|
}
|
|
|
|
pub fn delete_word_forward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let selection = doc
|
|
.selection(view.id)
|
|
.clone()
|
|
.transform(|range| movement::move_next_word_start(text, range, count));
|
|
delete_selection_insert_mode(doc, view, &selection);
|
|
}
|
|
}
|
|
|
|
// Undo / Redo
|
|
|
|
fn undo(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
for _ in 0..count {
|
|
if !doc.undo(view.id) {
|
|
cx.editor.set_status("Already at oldest change");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn redo(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
for _ in 0..count {
|
|
if !doc.redo(view.id) {
|
|
cx.editor.set_status("Already at newest change");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn earlier(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
for _ in 0..count {
|
|
// rather than doing in batch we do this so get error halfway
|
|
if !doc.earlier(view.id, UndoKind::Steps(1)) {
|
|
cx.editor.set_status("Already at oldest change");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn later(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
for _ in 0..count {
|
|
// rather than doing in batch we do this so get error halfway
|
|
if !doc.later(view.id, UndoKind::Steps(1)) {
|
|
cx.editor.set_status("Already at newest change");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Yank / Paste
|
|
|
|
fn yank(cx: &mut Context) {
|
|
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('"')
|
|
);
|
|
|
|
cx.editor
|
|
.registers
|
|
.write(cx.register.unwrap_or('"'), values);
|
|
|
|
cx.editor.set_status(msg);
|
|
exit_select_mode(cx);
|
|
}
|
|
|
|
fn yank_joined_to_clipboard_impl(
|
|
editor: &mut Editor,
|
|
separator: &str,
|
|
clipboard_type: ClipboardType,
|
|
) -> anyhow::Result<()> {
|
|
let (view, doc) = current!(editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let values: Vec<String> = doc
|
|
.selection(view.id)
|
|
.fragments(text)
|
|
.map(Cow::into_owned)
|
|
.collect();
|
|
|
|
let msg = format!(
|
|
"joined and yanked {} selection(s) to system clipboard",
|
|
values.len(),
|
|
);
|
|
|
|
let joined = values.join(separator);
|
|
|
|
editor
|
|
.clipboard_provider
|
|
.set_contents(joined, clipboard_type)
|
|
.context("Couldn't set system clipboard content")?;
|
|
|
|
editor.set_status(msg);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn yank_joined_to_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::Clipboard);
|
|
exit_select_mode(cx);
|
|
}
|
|
|
|
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 value = doc.selection(view.id).primary().fragment(text);
|
|
|
|
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("yanked main selection to system clipboard");
|
|
Ok(())
|
|
}
|
|
|
|
fn yank_main_selection_to_clipboard(cx: &mut Context) {
|
|
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) {
|
|
let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection);
|
|
exit_select_mode(cx);
|
|
}
|
|
|
|
#[derive(Copy, Clone)]
|
|
enum Paste {
|
|
Before,
|
|
After,
|
|
Cursor,
|
|
}
|
|
|
|
fn paste_impl(
|
|
values: &[String],
|
|
doc: &mut Document,
|
|
view: &View,
|
|
action: Paste,
|
|
count: usize,
|
|
) -> Option<Transaction> {
|
|
let repeat = std::iter::repeat(
|
|
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());
|
|
|
|
// Only compiled once.
|
|
#[allow(clippy::trivial_regex)]
|
|
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);
|
|
|
|
let transaction = Transaction::change_by_selection(text, selection, |range| {
|
|
let pos = match (action, linewise) {
|
|
// paste linewise before
|
|
(Paste::Before, true) => text.line_to_char(text.char_to_line(range.from())),
|
|
// paste linewise after
|
|
(Paste::After, true) => {
|
|
let line = range.line_range(text.slice(..)).1;
|
|
text.line_to_char((line + 1).min(text.len_lines()))
|
|
}
|
|
// paste insert
|
|
(Paste::Before, false) => range.from(),
|
|
// paste append
|
|
(Paste::After, false) => range.to(),
|
|
// paste at cursor
|
|
(Paste::Cursor, _) => range.cursor(text.slice(..)),
|
|
};
|
|
(pos, pos, values.next())
|
|
});
|
|
|
|
Some(transaction)
|
|
}
|
|
|
|
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)
|
|
.map(|contents| paste_impl(&[contents], doc, view, action, count))
|
|
{
|
|
Ok(Some(transaction)) => {
|
|
doc.apply(&transaction, view.id);
|
|
doc.append_changes_to_history(view.id);
|
|
Ok(())
|
|
}
|
|
Ok(None) => Ok(()),
|
|
Err(e) => Err(e.context("Couldn't get system clipboard contents")),
|
|
}
|
|
}
|
|
|
|
fn paste_clipboard_after(cx: &mut Context) {
|
|
let _ = paste_clipboard_impl(
|
|
cx.editor,
|
|
Paste::After,
|
|
ClipboardType::Clipboard,
|
|
cx.count(),
|
|
);
|
|
}
|
|
|
|
fn paste_clipboard_before(cx: &mut Context) {
|
|
let _ = paste_clipboard_impl(
|
|
cx.editor,
|
|
Paste::Before,
|
|
ClipboardType::Clipboard,
|
|
cx.count(),
|
|
);
|
|
}
|
|
|
|
fn paste_primary_clipboard_after(cx: &mut Context) {
|
|
let _ = paste_clipboard_impl(
|
|
cx.editor,
|
|
Paste::After,
|
|
ClipboardType::Selection,
|
|
cx.count(),
|
|
);
|
|
}
|
|
|
|
fn paste_primary_clipboard_before(cx: &mut Context) {
|
|
let _ = paste_clipboard_impl(
|
|
cx.editor,
|
|
Paste::Before,
|
|
ClipboardType::Selection,
|
|
cx.count(),
|
|
);
|
|
}
|
|
|
|
fn replace_with_yanked(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let reg_name = cx.register.unwrap_or('"');
|
|
let (view, doc) = current!(cx.editor);
|
|
let registers = &mut cx.editor.registers;
|
|
|
|
if let Some(values) = registers.read(reg_name) {
|
|
if !values.is_empty() {
|
|
let repeat = std::iter::repeat(
|
|
values
|
|
.last()
|
|
.map(|value| Tendril::from(&value.repeat(count)))
|
|
.unwrap(),
|
|
);
|
|
let mut values = values
|
|
.iter()
|
|
.map(|value| Tendril::from(&value.repeat(count)))
|
|
.chain(repeat);
|
|
let selection = doc.selection(view.id);
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
if !range.is_empty() {
|
|
(range.from(), range.to(), Some(values.next().unwrap()))
|
|
} else {
|
|
(range.from(), range.to(), None)
|
|
}
|
|
});
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn replace_selections_with_clipboard_impl(
|
|
editor: &mut Editor,
|
|
clipboard_type: ClipboardType,
|
|
count: usize,
|
|
) -> anyhow::Result<()> {
|
|
let (view, doc) = current!(editor);
|
|
|
|
match editor.clipboard_provider.get_contents(clipboard_type) {
|
|
Ok(contents) => {
|
|
let selection = doc.selection(view.id);
|
|
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
|
|
(
|
|
range.from(),
|
|
range.to(),
|
|
Some(contents.repeat(count).as_str().into()),
|
|
)
|
|
});
|
|
|
|
doc.apply(&transaction, view.id);
|
|
doc.append_changes_to_history(view.id);
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.context("Couldn't get system clipboard contents")),
|
|
}
|
|
}
|
|
|
|
fn replace_selections_with_clipboard(cx: &mut Context) {
|
|
let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Clipboard, cx.count());
|
|
}
|
|
|
|
fn replace_selections_with_primary_clipboard(cx: &mut Context) {
|
|
let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Selection, cx.count());
|
|
}
|
|
|
|
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;
|
|
|
|
if let Some(transaction) = registers
|
|
.read(reg_name)
|
|
.and_then(|values| paste_impl(values, doc, view, pos, count))
|
|
{
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
}
|
|
|
|
fn paste_after(cx: &mut Context) {
|
|
paste(cx, Paste::After)
|
|
}
|
|
|
|
fn paste_before(cx: &mut Context) {
|
|
paste(cx, Paste::Before)
|
|
}
|
|
|
|
fn get_lines(doc: &Document, view_id: ViewId) -> Vec<usize> {
|
|
let mut lines = Vec::new();
|
|
|
|
// Get all line numbers
|
|
for range in doc.selection(view_id) {
|
|
let (start, end) = range.line_range(doc.text().slice(..));
|
|
|
|
for line in start..=end {
|
|
lines.push(line)
|
|
}
|
|
}
|
|
lines.sort_unstable(); // sorting by usize so _unstable is preferred
|
|
lines.dedup();
|
|
lines
|
|
}
|
|
|
|
fn indent(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let lines = get_lines(doc, view.id);
|
|
|
|
// Indent by one level
|
|
let indent = Tendril::from(doc.indent_unit().repeat(count));
|
|
|
|
let transaction = Transaction::change(
|
|
doc.text(),
|
|
lines.into_iter().filter_map(|line| {
|
|
let is_blank = doc.text().line(line).chunks().all(|s| s.trim().is_empty());
|
|
if is_blank {
|
|
return None;
|
|
}
|
|
let pos = doc.text().line_to_char(line);
|
|
Some((pos, pos, Some(indent.clone())))
|
|
}),
|
|
);
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
fn unindent(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
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 * tab_width;
|
|
|
|
for line_idx in lines {
|
|
let line = doc.text().line(line_idx);
|
|
let mut width = 0;
|
|
let mut pos = 0;
|
|
|
|
for ch in line.chars() {
|
|
match ch {
|
|
' ' => width += 1,
|
|
'\t' => width = (width / tab_width + 1) * tab_width,
|
|
_ => break,
|
|
}
|
|
|
|
pos += 1;
|
|
|
|
if width >= indent_width {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// now delete from start to first non-blank
|
|
if pos > 0 {
|
|
let start = doc.text().line_to_char(line_idx);
|
|
changes.push((start, start + pos, None))
|
|
}
|
|
}
|
|
|
|
let transaction = Transaction::change(doc.text(), changes.into_iter());
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
fn format_selections(cx: &mut Context) {
|
|
use helix_lsp::{lsp, util::range_to_lsp_range};
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
// via lsp if available
|
|
// else via tree-sitter indentation calculations
|
|
|
|
let language_server = match doc.language_server() {
|
|
Some(language_server) => language_server,
|
|
None => return,
|
|
};
|
|
|
|
let ranges: Vec<lsp::Range> = doc
|
|
.selection(view.id)
|
|
.iter()
|
|
.map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding()))
|
|
.collect();
|
|
|
|
// TODO: all of the TODO's and commented code inside the loop,
|
|
// to make this actually work.
|
|
for _range in ranges {
|
|
let _language_server = match doc.language_server() {
|
|
Some(language_server) => language_server,
|
|
None => return,
|
|
};
|
|
// TODO: handle fails
|
|
// TODO: concurrent map
|
|
|
|
// TODO: need to block to get the formatting
|
|
|
|
// let edits = block_on(language_server.text_document_range_formatting(
|
|
// doc.identifier(),
|
|
// range,
|
|
// lsp::FormattingOptions::default(),
|
|
// ))
|
|
// .unwrap_or_default();
|
|
|
|
// let transaction = helix_lsp::util::generate_transaction_from_edits(
|
|
// doc.text(),
|
|
// edits,
|
|
// language_server.offset_encoding(),
|
|
// );
|
|
|
|
// doc.apply(&transaction, view.id);
|
|
}
|
|
}
|
|
|
|
fn join_selections(cx: &mut Context) {
|
|
use movement::skip_while;
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text();
|
|
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);
|
|
if start == end {
|
|
end = (end + 1).min(text.len_lines() - 1);
|
|
}
|
|
let lines = start..end;
|
|
|
|
changes.reserve(lines.len());
|
|
|
|
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);
|
|
|
|
// need to skip from start, not end
|
|
let change = (start, end, Some(fragment.clone()));
|
|
changes.push(change);
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
let transaction = Transaction::change(doc.text(), changes.into_iter());
|
|
// TODO: select inserted spaces
|
|
// .with_selection(selection);
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) {
|
|
// keep or remove selections matching regex
|
|
let reg = cx.register.unwrap_or('/');
|
|
ui::regex_prompt(
|
|
cx,
|
|
if remove { "remove:" } else { "keep:" }.into(),
|
|
Some(reg),
|
|
ui::completers::none,
|
|
move |view, doc, regex, event| {
|
|
if event != PromptEvent::Update {
|
|
return;
|
|
}
|
|
let text = doc.text().slice(..);
|
|
|
|
if let Some(selection) =
|
|
selection::keep_or_remove_matches(text, doc.selection(view.id), ®ex, remove)
|
|
{
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn keep_selections(cx: &mut Context) {
|
|
keep_or_remove_selections_impl(cx, false)
|
|
}
|
|
|
|
fn remove_selections(cx: &mut Context) {
|
|
keep_or_remove_selections_impl(cx, true)
|
|
}
|
|
|
|
fn keep_primary_selection(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
// TODO: handle count
|
|
|
|
let range = doc.selection(view.id).primary();
|
|
doc.set_selection(view.id, Selection::single(range.anchor, range.head));
|
|
}
|
|
|
|
fn remove_primary_selection(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
// TODO: handle count
|
|
|
|
let selection = doc.selection(view.id);
|
|
if selection.len() == 1 {
|
|
cx.editor.set_error("no selections remaining");
|
|
return;
|
|
}
|
|
let index = selection.primary_index();
|
|
let selection = selection.clone().remove(index);
|
|
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
pub fn completion(cx: &mut Context) {
|
|
use helix_lsp::{lsp, util::pos_to_lsp_pos};
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
let language_server = match doc.language_server() {
|
|
Some(language_server) => language_server,
|
|
None => return,
|
|
};
|
|
|
|
let offset_encoding = language_server.offset_encoding();
|
|
let text = doc.text().slice(..);
|
|
let cursor = doc.selection(view.id).primary().cursor(text);
|
|
|
|
let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding);
|
|
|
|
let future = language_server.completion(doc.identifier(), pos, None);
|
|
|
|
let trigger_offset = cursor;
|
|
|
|
// TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply
|
|
// completion filtering. For example logger.te| should filter the initial suggestion list with "te".
|
|
|
|
use helix_core::chars;
|
|
let mut iter = text.chars_at(cursor);
|
|
iter.reverse();
|
|
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
|
|
let start_offset = cursor.saturating_sub(offset);
|
|
let prefix = text.slice(start_offset..cursor).to_string();
|
|
|
|
cx.callback(
|
|
future,
|
|
move |editor, compositor, response: Option<lsp::CompletionResponse>| {
|
|
let doc = doc!(editor);
|
|
if doc.mode() != Mode::Insert {
|
|
// we're not in insert mode anymore
|
|
return;
|
|
}
|
|
|
|
let mut 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(),
|
|
};
|
|
|
|
if !prefix.is_empty() {
|
|
items = items
|
|
.into_iter()
|
|
.filter(|item| {
|
|
item.filter_text
|
|
.as_ref()
|
|
.unwrap_or(&item.label)
|
|
.starts_with(&prefix)
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// comments
|
|
fn toggle_comments(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let token = doc
|
|
.language_config()
|
|
.and_then(|lc| lc.comment_token.as_ref())
|
|
.map(|tc| tc.as_ref());
|
|
let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token);
|
|
|
|
doc.apply(&transaction, view.id);
|
|
exit_select_mode(cx);
|
|
}
|
|
|
|
fn rotate_selections(cx: &mut Context, direction: Direction) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let mut selection = doc.selection(view.id).clone();
|
|
let index = selection.primary_index();
|
|
let len = selection.len();
|
|
selection.set_primary_index(match direction {
|
|
Direction::Forward => (index + count) % len,
|
|
Direction::Backward => (index + (len.saturating_sub(count) % len)) % len,
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
fn rotate_selections_forward(cx: &mut Context) {
|
|
rotate_selections(cx, Direction::Forward)
|
|
}
|
|
fn rotate_selections_backward(cx: &mut Context) {
|
|
rotate_selections(cx, Direction::Backward)
|
|
}
|
|
|
|
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 fragments: Vec<_> = selection
|
|
.fragments(text)
|
|
.map(|fragment| Tendril::from(fragment.as_ref()))
|
|
.collect();
|
|
|
|
let group = count
|
|
.map(|count| count.get())
|
|
.unwrap_or(fragments.len()) // default to rotating everything as one group
|
|
.min(fragments.len());
|
|
|
|
for chunk in fragments.chunks_mut(group) {
|
|
// TODO: also modify main index
|
|
match direction {
|
|
Direction::Forward => chunk.rotate_right(1),
|
|
Direction::Backward => chunk.rotate_left(1),
|
|
};
|
|
}
|
|
|
|
let transaction = Transaction::change(
|
|
doc.text(),
|
|
selection
|
|
.ranges()
|
|
.iter()
|
|
.zip(fragments)
|
|
.map(|(range, fragment)| (range.from(), range.to(), Some(fragment))),
|
|
);
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
fn rotate_selection_contents_forward(cx: &mut Context) {
|
|
rotate_selection_contents(cx, Direction::Forward)
|
|
}
|
|
fn rotate_selection_contents_backward(cx: &mut Context) {
|
|
rotate_selection_contents(cx, Direction::Backward)
|
|
}
|
|
|
|
// tree sitter node selection
|
|
|
|
fn expand_selection(cx: &mut Context) {
|
|
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);
|
|
|
|
// save current selection so it can be restored using shrink_selection
|
|
view.object_selections.push(current_selection.clone());
|
|
|
|
let selection = object::expand_selection(syntax, text, current_selection.clone());
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
};
|
|
motion(cx.editor);
|
|
cx.editor.last_motion = Some(Motion(Box::new(motion)));
|
|
}
|
|
|
|
fn shrink_selection(cx: &mut Context) {
|
|
let motion = |editor: &mut Editor| {
|
|
let (view, doc) = current!(editor);
|
|
let current_selection = doc.selection(view.id);
|
|
// 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 {
|
|
// clear existing selection as they can't be shrinked to anyway
|
|
view.object_selections.clear();
|
|
}
|
|
}
|
|
// if not previous selection, shrink to first child
|
|
if let Some(syntax) = doc.syntax() {
|
|
let text = doc.text().slice(..);
|
|
let selection = object::shrink_selection(syntax, text, current_selection.clone());
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
};
|
|
motion(cx.editor);
|
|
cx.editor.last_motion = Some(Motion(Box::new(motion)));
|
|
}
|
|
|
|
fn select_sibling_impl<F>(cx: &mut Context, sibling_fn: &'static F)
|
|
where
|
|
F: Fn(Node) -> Option<Node>,
|
|
{
|
|
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 =
|
|
object::select_sibling(syntax, text, current_selection.clone(), sibling_fn);
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
};
|
|
motion(cx.editor);
|
|
cx.editor.last_motion = Some(Motion(Box::new(motion)));
|
|
}
|
|
|
|
fn select_next_sibling(cx: &mut Context) {
|
|
select_sibling_impl(cx, &|node| Node::next_sibling(&node))
|
|
}
|
|
|
|
fn select_prev_sibling(cx: &mut Context) {
|
|
select_sibling_impl(cx, &|node| Node::prev_sibling(&node))
|
|
}
|
|
|
|
fn match_brackets(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
if let Some(syntax) = doc.syntax() {
|
|
let text = doc.text().slice(..);
|
|
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, doc.mode == Mode::Select)
|
|
} else {
|
|
range
|
|
}
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
}
|
|
|
|
//
|
|
|
|
fn jump_forward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let view = view_mut!(cx.editor);
|
|
|
|
if let Some((id, selection)) = view.jumps.forward(count) {
|
|
view.doc = *id;
|
|
let selection = selection.clone();
|
|
let (view, doc) = current!(cx.editor); // refetch doc
|
|
doc.set_selection(view.id, selection);
|
|
|
|
align_view(doc, view, Align::Center);
|
|
};
|
|
}
|
|
|
|
fn jump_backward(cx: &mut Context) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
|
|
if let Some((id, selection)) = view.jumps.backward(view.id, doc, count) {
|
|
view.doc = *id;
|
|
let selection = selection.clone();
|
|
let (view, doc) = current!(cx.editor); // refetch doc
|
|
doc.set_selection(view.id, selection);
|
|
|
|
align_view(doc, view, Align::Center);
|
|
};
|
|
}
|
|
|
|
fn save_selection(cx: &mut Context) {
|
|
push_jump(cx.editor);
|
|
cx.editor.set_status("Selection saved to jumplist");
|
|
}
|
|
|
|
fn rotate_view(cx: &mut Context) {
|
|
cx.editor.focus_next()
|
|
}
|
|
|
|
fn jump_view_right(cx: &mut Context) {
|
|
cx.editor.focus_right()
|
|
}
|
|
|
|
fn jump_view_left(cx: &mut Context) {
|
|
cx.editor.focus_left()
|
|
}
|
|
|
|
fn jump_view_up(cx: &mut Context) {
|
|
cx.editor.focus_up()
|
|
}
|
|
|
|
fn jump_view_down(cx: &mut Context) {
|
|
cx.editor.focus_down()
|
|
}
|
|
|
|
// 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 = view.offset;
|
|
|
|
cx.editor.switch(id, action);
|
|
|
|
// match the selection in the previous view
|
|
let (view, doc) = current!(cx.editor);
|
|
view.offset = offset;
|
|
doc.set_selection(view.id, selection);
|
|
}
|
|
|
|
fn hsplit(cx: &mut Context) {
|
|
split(cx, Action::HorizontalSplit);
|
|
}
|
|
|
|
fn hsplit_new(cx: &mut Context) {
|
|
cx.editor.new_file(Action::HorizontalSplit);
|
|
}
|
|
|
|
fn vsplit(cx: &mut Context) {
|
|
split(cx, Action::VerticalSplit);
|
|
}
|
|
|
|
fn vsplit_new(cx: &mut Context) {
|
|
cx.editor.new_file(Action::VerticalSplit);
|
|
}
|
|
|
|
fn wclose(cx: &mut Context) {
|
|
if cx.editor.tree.views().count() == 1 {
|
|
if let Err(err) = typed::buffers_remaining_impl(cx.editor) {
|
|
cx.editor.set_error(err.to_string());
|
|
return;
|
|
}
|
|
}
|
|
let view_id = view!(cx.editor).id;
|
|
// close current split
|
|
cx.editor.close(view_id);
|
|
}
|
|
|
|
fn wonly(cx: &mut Context) {
|
|
let views = cx
|
|
.editor
|
|
.tree
|
|
.views()
|
|
.map(|(v, focus)| (v.id, focus))
|
|
.collect::<Vec<_>>();
|
|
for (view_id, focus) in views {
|
|
if !focus {
|
|
cx.editor.close(view_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn select_register(cx: &mut Context) {
|
|
cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers));
|
|
cx.on_next_key(move |cx, event| {
|
|
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(&cx.editor.registers));
|
|
cx.on_next_key(move |cx, event| {
|
|
if let Some(ch) = event.char() {
|
|
cx.editor.autoinfo = None;
|
|
cx.register = Some(ch);
|
|
paste(cx, Paste::Cursor);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn align_view_top(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
align_view(doc, view, Align::Top);
|
|
}
|
|
|
|
fn align_view_center(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
align_view(doc, view, Align::Center);
|
|
}
|
|
|
|
fn align_view_bottom(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
align_view(doc, view, Align::Bottom);
|
|
}
|
|
|
|
fn align_view_middle(cx: &mut Context) {
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let pos = doc.selection(view.id).primary().cursor(text);
|
|
let pos = coords_at_pos(text, pos);
|
|
|
|
view.offset.col = pos
|
|
.col
|
|
.saturating_sub((view.inner_area().width as usize) / 2);
|
|
}
|
|
|
|
fn scroll_up(cx: &mut Context) {
|
|
scroll(cx, cx.count(), Direction::Backward);
|
|
}
|
|
|
|
fn scroll_down(cx: &mut Context) {
|
|
scroll(cx, cx.count(), Direction::Forward);
|
|
}
|
|
|
|
fn goto_ts_object_impl(cx: &mut Context, object: &str, direction: Direction) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let range = doc.selection(view.id).primary();
|
|
|
|
let new_range = match doc.language_config().zip(doc.syntax()) {
|
|
Some((lang_config, syntax)) => movement::goto_treesitter_object(
|
|
text,
|
|
range,
|
|
object,
|
|
direction,
|
|
syntax.tree().root_node(),
|
|
lang_config,
|
|
count,
|
|
),
|
|
None => range,
|
|
};
|
|
|
|
doc.set_selection(view.id, Selection::single(new_range.anchor, new_range.head));
|
|
}
|
|
|
|
fn goto_next_function(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "function", Direction::Forward)
|
|
}
|
|
|
|
fn goto_prev_function(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "function", Direction::Backward)
|
|
}
|
|
|
|
fn goto_next_class(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "class", Direction::Forward)
|
|
}
|
|
|
|
fn goto_prev_class(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "class", Direction::Backward)
|
|
}
|
|
|
|
fn goto_next_parameter(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "parameter", Direction::Forward)
|
|
}
|
|
|
|
fn goto_prev_parameter(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "parameter", Direction::Backward)
|
|
}
|
|
|
|
fn goto_next_comment(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "comment", Direction::Forward)
|
|
}
|
|
|
|
fn goto_prev_comment(cx: &mut Context) {
|
|
goto_ts_object_impl(cx, "comment", Direction::Backward)
|
|
}
|
|
|
|
fn select_textobject_around(cx: &mut Context) {
|
|
select_textobject(cx, textobject::TextObject::Around);
|
|
}
|
|
|
|
fn select_textobject_inner(cx: &mut Context) {
|
|
select_textobject(cx, textobject::TextObject::Inside);
|
|
}
|
|
|
|
fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
|
|
let count = cx.count();
|
|
|
|
cx.on_next_key(move |cx, event| {
|
|
cx.editor.autoinfo = None;
|
|
cx.editor.pseudo_pending = None;
|
|
if let Some(ch) = event.char() {
|
|
let textobject = move |editor: &mut Editor| {
|
|
let (view, doc) = current!(editor);
|
|
let text = doc.text().slice(..);
|
|
|
|
let textobject_treesitter = |obj_name: &str, range: Range| -> 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.tree().root_node(),
|
|
lang_config,
|
|
count,
|
|
)
|
|
};
|
|
|
|
let selection = doc.selection(view.id).clone().transform(|range| {
|
|
match ch {
|
|
'w' => textobject::textobject_word(text, range, objtype, count, false),
|
|
'W' => textobject::textobject_word(text, range, objtype, count, true),
|
|
'c' => textobject_treesitter("class", range),
|
|
'f' => textobject_treesitter("function", range),
|
|
'a' => textobject_treesitter("parameter", range),
|
|
'o' => textobject_treesitter("comment", range),
|
|
'p' => textobject::textobject_paragraph(text, range, objtype, count),
|
|
'm' => {
|
|
let ch = text.char(range.cursor(text));
|
|
if !ch.is_ascii_alphanumeric() {
|
|
textobject::textobject_surround(text, range, objtype, ch, count)
|
|
} else {
|
|
range
|
|
}
|
|
}
|
|
// TODO: cancel new ranges if inconsistent surround matches across lines
|
|
ch if !ch.is_ascii_alphanumeric() => {
|
|
textobject::textobject_surround(text, range, objtype, ch, count)
|
|
}
|
|
_ => range,
|
|
}
|
|
});
|
|
doc.set_selection(view.id, selection);
|
|
};
|
|
textobject(cx.editor);
|
|
cx.editor.last_motion = Some(Motion(Box::new(textobject)));
|
|
}
|
|
});
|
|
|
|
if let Some((title, abbrev)) = match objtype {
|
|
textobject::TextObject::Inside => Some(("Match inside", "mi")),
|
|
textobject::TextObject::Around => Some(("Match around", "ma")),
|
|
_ => return,
|
|
} {
|
|
let help_text = [
|
|
("w", "Word"),
|
|
("W", "WORD"),
|
|
("p", "Paragraph"),
|
|
("c", "Class (tree-sitter)"),
|
|
("f", "Function (tree-sitter)"),
|
|
("a", "Argument/parameter (tree-sitter)"),
|
|
("o", "Comment (tree-sitter)"),
|
|
("m", "Matching delimiter under cursor"),
|
|
(" ", "... or any character acting as a pair"),
|
|
];
|
|
|
|
cx.editor.autoinfo = Some(Info::new(
|
|
title,
|
|
help_text
|
|
.into_iter()
|
|
.map(|(col1, col2)| (col1.to_string(), col2.to_string()))
|
|
.collect(),
|
|
));
|
|
cx.editor.pseudo_pending = Some(abbrev.to_string());
|
|
};
|
|
}
|
|
|
|
fn surround_add(cx: &mut Context) {
|
|
cx.on_next_key(move |cx, event| {
|
|
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);
|
|
|
|
let mut changes = Vec::with_capacity(selection.len() * 2);
|
|
for range in selection.iter() {
|
|
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)));
|
|
}
|
|
|
|
let transaction = Transaction::change(doc.text(), changes.into_iter());
|
|
doc.apply(&transaction, view.id);
|
|
})
|
|
}
|
|
|
|
fn surround_replace(cx: &mut Context) {
|
|
let count = cx.count();
|
|
cx.on_next_key(move |cx, event| {
|
|
let from = match event.char() {
|
|
Some(from) => from,
|
|
None => return,
|
|
};
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id);
|
|
|
|
let change_pos = match surround::get_surround_pos(text, selection, from, count) {
|
|
Ok(c) => c,
|
|
Err(err) => {
|
|
cx.editor.set_error(err.to_string());
|
|
return;
|
|
}
|
|
};
|
|
|
|
cx.on_next_key(move |cx, event| {
|
|
let (view, doc) = current!(cx.editor);
|
|
let to = match event.char() {
|
|
Some(to) => to,
|
|
None => return,
|
|
};
|
|
let (open, close) = surround::get_pair(to);
|
|
let transaction = Transaction::change(
|
|
doc.text(),
|
|
change_pos.iter().enumerate().map(|(i, &pos)| {
|
|
let mut t = Tendril::new();
|
|
t.push(if i % 2 == 0 { open } else { close });
|
|
(pos, pos + 1, Some(t))
|
|
}),
|
|
);
|
|
doc.apply(&transaction, view.id);
|
|
});
|
|
})
|
|
}
|
|
|
|
fn surround_delete(cx: &mut Context) {
|
|
let count = cx.count();
|
|
cx.on_next_key(move |cx, event| {
|
|
let ch = match event.char() {
|
|
Some(ch) => ch,
|
|
None => return,
|
|
};
|
|
let (view, doc) = current!(cx.editor);
|
|
let text = doc.text().slice(..);
|
|
let selection = doc.selection(view.id);
|
|
|
|
let change_pos = match surround::get_surround_pos(text, selection, 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);
|
|
})
|
|
}
|
|
|
|
#[derive(Eq, PartialEq)]
|
|
enum ShellBehavior {
|
|
Replace,
|
|
Ignore,
|
|
Insert,
|
|
Append,
|
|
}
|
|
|
|
fn shell_pipe(cx: &mut Context) {
|
|
shell_prompt(cx, "pipe:".into(), ShellBehavior::Replace);
|
|
}
|
|
|
|
fn shell_pipe_to(cx: &mut Context) {
|
|
shell_prompt(cx, "pipe-to:".into(), ShellBehavior::Ignore);
|
|
}
|
|
|
|
fn shell_insert_output(cx: &mut Context) {
|
|
shell_prompt(cx, "insert-output:".into(), ShellBehavior::Insert);
|
|
}
|
|
|
|
fn shell_append_output(cx: &mut Context) {
|
|
shell_prompt(cx, "append-output:".into(), ShellBehavior::Append);
|
|
}
|
|
|
|
fn shell_keep_pipe(cx: &mut Context) {
|
|
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(..);
|
|
|
|
for (i, range) in selection.ranges().iter().enumerate() {
|
|
let fragment = range.fragment(text);
|
|
let (_output, success) = match shell_impl(shell, input, Some(fragment.as_bytes())) {
|
|
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;
|
|
}
|
|
|
|
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<&[u8]>,
|
|
) -> anyhow::Result<(Tendril, bool)> {
|
|
use std::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
ensure!(!shell.is_empty(), "No shell set");
|
|
|
|
let mut process = match Command::new(&shell[0])
|
|
.args(&shell[1..])
|
|
.arg(cmd)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
{
|
|
Ok(process) => process,
|
|
Err(e) => {
|
|
log::error!("Failed to start shell: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
if let Some(input) = input {
|
|
let mut stdin = process.stdin.take().unwrap();
|
|
stdin.write_all(input)?;
|
|
}
|
|
let output = process.wait_with_output()?;
|
|
|
|
if !output.stderr.is_empty() {
|
|
log::error!("Shell error: {}", String::from_utf8_lossy(&output.stderr));
|
|
}
|
|
|
|
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) {
|
|
let pipe = match behavior {
|
|
ShellBehavior::Replace | ShellBehavior::Ignore => true,
|
|
ShellBehavior::Insert | ShellBehavior::Append => false,
|
|
};
|
|
|
|
let config = cx.editor.config();
|
|
let shell = &config.shell;
|
|
let (view, doc) = current!(cx.editor);
|
|
let selection = doc.selection(view.id);
|
|
|
|
let mut changes = Vec::with_capacity(selection.len());
|
|
let text = doc.text().slice(..);
|
|
|
|
for range in selection.ranges() {
|
|
let fragment = range.fragment(text);
|
|
let (output, success) = match shell_impl(shell, cmd, pipe.then(|| fragment.as_bytes())) {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
cx.editor.set_error(err.to_string());
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !success {
|
|
cx.editor.set_error("Command failed");
|
|
return;
|
|
}
|
|
|
|
let (from, to) = match behavior {
|
|
ShellBehavior::Replace => (range.from(), range.to()),
|
|
ShellBehavior::Insert => (range.from(), range.from()),
|
|
ShellBehavior::Append => (range.to(), range.to()),
|
|
_ => (range.from(), range.from()),
|
|
};
|
|
changes.push((from, to, Some(output)));
|
|
}
|
|
|
|
if behavior != &ShellBehavior::Ignore {
|
|
let transaction = Transaction::change(doc.text(), changes.into_iter());
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
// after replace cursor may be out of bounds, do this to
|
|
// make sure cursor is in view and update scroll as well
|
|
view.ensure_cursor_in_view(doc, config.scrolloff);
|
|
}
|
|
|
|
fn shell_prompt(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) {
|
|
ui::prompt(
|
|
cx,
|
|
prompt,
|
|
Some('|'),
|
|
ui::completers::none,
|
|
move |cx, input: &str, event: PromptEvent| {
|
|
if event != PromptEvent::Validate {
|
|
return;
|
|
}
|
|
if input.is_empty() {
|
|
return;
|
|
}
|
|
|
|
shell(cx, input, &behavior);
|
|
},
|
|
);
|
|
}
|
|
|
|
fn suspend(_cx: &mut Context) {
|
|
#[cfg(not(windows))]
|
|
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap();
|
|
}
|
|
|
|
fn add_newline_above(cx: &mut Context) {
|
|
add_newline_impl(cx, Open::Above);
|
|
}
|
|
|
|
fn add_newline_below(cx: &mut Context) {
|
|
add_newline_impl(cx, Open::Below)
|
|
}
|
|
|
|
fn add_newline_impl(cx: &mut Context, open: Open) {
|
|
let count = cx.count();
|
|
let (view, doc) = current!(cx.editor);
|
|
let selection = doc.selection(view.id);
|
|
let text = doc.text();
|
|
let slice = text.slice(..);
|
|
|
|
let changes = selection.into_iter().map(|range| {
|
|
let (start, end) = range.line_range(slice);
|
|
let line = match open {
|
|
Open::Above => start,
|
|
Open::Below => end + 1,
|
|
};
|
|
let pos = text.line_to_char(line);
|
|
(
|
|
pos,
|
|
pos,
|
|
Some(doc.line_ending.as_str().repeat(count).into()),
|
|
)
|
|
});
|
|
|
|
let transaction = Transaction::change(text, changes);
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
|
|
/// Increment object under cursor by count.
|
|
fn increment(cx: &mut Context) {
|
|
increment_impl(cx, cx.count() as i64);
|
|
}
|
|
|
|
/// Decrement object under cursor by count.
|
|
fn decrement(cx: &mut Context) {
|
|
increment_impl(cx, -(cx.count() as i64));
|
|
}
|
|
|
|
/// 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, amount: i64) {
|
|
// 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,
|
|
);
|
|
|
|
let (view, doc) = current!(cx.editor);
|
|
let selection = doc.selection(view.id);
|
|
let text = doc.text().slice(..);
|
|
|
|
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;
|
|
};
|
|
|
|
let (range, new_text) = incrementor.increment(amount);
|
|
|
|
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 = changes.into_iter().enumerate().filter_map(|(i, change)| {
|
|
if overlapping_indexes.contains(&i) {
|
|
None
|
|
} else {
|
|
Some(change)
|
|
}
|
|
});
|
|
|
|
if changes.clone().count() > 0 {
|
|
let transaction = Transaction::change(doc.text(), changes);
|
|
let transaction = transaction.with_selection(selection.clone());
|
|
|
|
doc.apply(&transaction, view.id);
|
|
}
|
|
}
|
|
|
|
fn record_macro(cx: &mut Context) {
|
|
if let Some((reg, mut keys)) = cx.editor.macro_recording.take() {
|
|
// Remove the keypress which ends the recording
|
|
keys.pop();
|
|
let s = keys
|
|
.into_iter()
|
|
.map(|key| {
|
|
let s = key.to_string();
|
|
if s.chars().count() == 1 {
|
|
s
|
|
} else {
|
|
format!("<{}>", s)
|
|
}
|
|
})
|
|
.collect::<String>();
|
|
cx.editor.registers.get_mut(reg).write(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()));
|
|
cx.editor
|
|
.set_status(format!("Recording to register [{}]", reg));
|
|
}
|
|
}
|
|
|
|
fn replay_macro(cx: &mut Context) {
|
|
let reg = cx.register.unwrap_or('@');
|
|
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));
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
cx.editor.set_error(format!("Register [{}] empty", reg));
|
|
return;
|
|
};
|
|
|
|
let count = cx.count();
|
|
cx.callback = Some(Box::new(move |compositor, cx| {
|
|
for _ in 0..count {
|
|
for &key in keys.iter() {
|
|
compositor.handle_event(crossterm::event::Event::Key(key.into()), cx);
|
|
}
|
|
}
|
|
}));
|
|
}
|