diff --git a/book/src/keymap.md b/book/src/keymap.md index 153f3b6483f1..5dc467f28498 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -291,7 +291,7 @@ This layer is a kludge of mappings, mostly pickers. | `w` | Enter [window mode](#window-mode) | N/A | | `p` | Paste system clipboard after selections | `paste_clipboard_after` | | `P` | Paste system clipboard before selections | `paste_clipboard_before` | -| `y` | Join and yank selections to clipboard | `yank_joined_to_clipboard` | +| `y` | Yank selections to clipboard | `yank_to_clipboard` | | `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` | | `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | | `/` | Global search in workspace folder | `global_search` | diff --git a/book/src/usage.md b/book/src/usage.md index 3c48e30652e3..e392e5e67158 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -37,19 +37,35 @@ If a register is selected before invoking a change or delete command, the select - `"hc` - Store the selection in register `h` and then change it (delete and enter insert mode). - `"md` - Store the selection in register `m` and delete it. -### Special registers +### Default registers + +Commands that use registers, like yank (`y`), use a default register if none is specified. +These registers are used as defaults: | Register character | Contains | | --- | --- | | `/` | Last search | | `:` | Last executed command | | `"` | Last yanked text | -| `_` | Black hole | +| `@` | Last recorded macro | -The system clipboard is not directly supported by a special register. Instead, special commands and keybindings are provided. Refer to the -[key map](keymap.md#space-mode) for more details. +### Special registers -The black hole register is a no-op register, meaning that no data will be read or written to it. +Some registers have special behavior when read from and written to. + +| Register character | When read | When written | +| --- | --- | --- | +| `_` | No values are returned | All values are discarded | +| `#` | Selection indices (first selection is `1`, second is `2`, etc.) | This register is not writable | +| `.` | Contents of the current selections | This register is not writable | +| `%` | Name of the current file | This register is not writable | +| `*` | Reads from the system clipboard | Joins and yanks to the system clipboard | +| `+` | Reads from the primary clipboard | Joins and yanks to the primary clipboard | + +When yanking multiple selections to the clipboard registers, the selections +are joined with newlines. Pasting from these registers will paste multiple +selections if the clipboard was last yanked to by the Helix session. Otherwise +the clipboard contents are pasted as one selection. ## Surround diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index e1b5a1a12daa..9a512eae8158 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -18,7 +18,6 @@ pub mod movement; pub mod object; pub mod path; mod position; -pub mod register; pub mod search; pub mod selection; pub mod shellwords; diff --git a/helix-core/src/register.rs b/helix-core/src/register.rs deleted file mode 100644 index df68a75943e6..000000000000 --- a/helix-core/src/register.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::collections::HashMap; - -#[derive(Debug)] -pub struct Register { - name: char, - values: Vec, -} - -impl Register { - pub const fn new(name: char) -> Self { - Self { - name, - values: Vec::new(), - } - } - - pub fn new_with_values(name: char, values: Vec) -> Self { - Self { name, values } - } - - pub const fn name(&self) -> char { - self.name - } - - pub fn read(&self) -> &[String] { - &self.values - } - - pub fn write(&mut self, values: Vec) { - self.values = values; - } - - pub fn push(&mut self, value: String) { - self.values.push(value); - } -} - -/// Currently just wraps a `HashMap` of `Register`s -#[derive(Debug, Default)] -pub struct Registers { - inner: HashMap, -} - -impl Registers { - pub fn get(&self, name: char) -> Option<&Register> { - self.inner.get(&name) - } - - pub fn read(&self, name: char) -> Option<&[String]> { - self.get(name).map(|reg| reg.read()) - } - - pub fn write(&mut self, name: char, values: Vec) { - if name != '_' { - self.inner - .insert(name, Register::new_with_values(name, values)); - } - } - - pub fn push(&mut self, name: char, value: String) { - if name != '_' { - if let Some(r) = self.inner.get_mut(&name) { - r.push(value); - } else { - self.write(name, vec![value]); - } - } - } - - pub fn first(&self, name: char) -> Option<&String> { - self.read(name).and_then(|entries| entries.first()) - } - - pub fn last(&self, name: char) -> Option<&String> { - self.read(name).and_then(|entries| entries.last()) - } - - pub fn inner(&self) -> &HashMap { - &self.inner - } - - pub fn clear(&mut self) { - self.inner.clear(); - } - - pub fn remove(&mut self, name: char) -> Option { - self.inner.remove(&name) - } -} diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 9104c2099a42..c44685eeae84 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -630,11 +630,19 @@ impl Selection { self.transform(|range| Range::point(range.cursor(text))) } - pub fn fragments<'a>(&'a self, text: RopeSlice<'a>) -> impl Iterator> + 'a { + pub fn fragments<'a>( + &'a self, + text: RopeSlice<'a>, + ) -> impl DoubleEndedIterator> + ExactSizeIterator> + 'a + { self.ranges.iter().map(move |range| range.fragment(text)) } - pub fn slices<'a>(&'a self, text: RopeSlice<'a>) -> impl Iterator + 'a { + pub fn slices<'a>( + &'a self, + text: RopeSlice<'a>, + ) -> impl DoubleEndedIterator> + ExactSizeIterator> + 'a + { self.ranges.iter().map(move |range| range.slice(text)) } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 58c172968bb6..bf60ad71a468 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -32,7 +32,6 @@ use helix_core::{ RopeReader, RopeSlice, Selection, SmallVec, Tendril, Transaction, }; use helix_view::{ - clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, CompleteAction}, info::Info, @@ -376,6 +375,8 @@ impl MappableCommand { later, "Move forward in history", commit_undo_checkpoint, "Commit changes to new checkpoint", yank, "Yank selection", + yank_to_clipboard, "Yank selections to clipboard", + yank_to_primary_clipboard, "Yank selections to primary clipboard", yank_joined, "Join and yank selections", yank_joined_to_clipboard, "Join and yank selections to clipboard", yank_main_selection_to_clipboard, "Yank main selection to clipboard", @@ -1847,11 +1848,11 @@ fn search_impl( fn search_completions(cx: &mut Context, reg: Option) -> Vec { let mut items = reg - .and_then(|reg| cx.editor.registers.get(reg)) - .map_or(Vec::new(), |reg| reg.read().iter().take(200).collect()); + .and_then(|reg| cx.editor.registers.read(reg, cx.editor)) + .map_or(Vec::new(), |reg| reg.take(200).collect()); items.sort_unstable(); items.dedup(); - items.into_iter().cloned().collect() + items.into_iter().map(|value| value.to_string()).collect() } fn search(cx: &mut Context) { @@ -1908,11 +1909,11 @@ fn searcher(cx: &mut Context, direction: Direction) { fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) { let count = cx.count(); + let register = cx.register.unwrap_or('/'); let config = cx.editor.config(); let scrolloff = config.scrolloff; - let (_, doc) = current!(cx.editor); - let registers = &cx.editor.registers; - if let Some(query) = registers.read('/').and_then(|query| query.last()) { + if let Some(query) = cx.editor.registers.first(register, cx.editor) { + let doc = doc!(cx.editor); let contents = doc.text().slice(..).to_string(); let search_config = &config.search; let case_insensitive = if search_config.smart_case { @@ -1921,7 +1922,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir false }; let wrap_around = search_config.wrap_around; - if let Ok(regex) = RegexBuilder::new(query) + if let Ok(regex) = RegexBuilder::new(&query) .case_insensitive(case_insensitive) .multi_line(true) .build() @@ -1961,6 +1962,7 @@ fn extend_search_prev(cx: &mut Context) { } fn search_selection(cx: &mut Context) { + let register = cx.register.unwrap_or('/'); let (view, doc) = current!(cx.editor); let contents = doc.text().slice(..); @@ -1973,13 +1975,16 @@ fn search_selection(cx: &mut Context) { .collect::>() .join("|"); - let msg = format!("register '{}' set to '{}'", '/', ®ex); - cx.editor.registers.push('/', regex); - cx.editor.set_status(msg); + let msg = format!("register '{}' set to '{}'", register, ®ex); + match cx.editor.registers.push(register, regex) { + Ok(_) => cx.editor.set_status(msg), + Err(err) => cx.editor.set_error(err.to_string()), + } } fn make_search_word_bounded(cx: &mut Context) { - let regex = match cx.editor.registers.last('/') { + let register = cx.register.unwrap_or('/'); + let regex = match cx.editor.registers.first(register, cx.editor) { Some(regex) => regex, None => return, }; @@ -1997,14 +2002,16 @@ fn make_search_word_bounded(cx: &mut Context) { if !start_anchored { new_regex.push_str("\\b"); } - new_regex.push_str(regex); + new_regex.push_str(®ex); if !end_anchored { new_regex.push_str("\\b"); } - let msg = format!("register '{}' set to '{}'", '/', &new_regex); - cx.editor.registers.push('/', new_regex); - cx.editor.set_status(msg); + let msg = format!("register '{}' set to '{}'", register, &new_regex); + match cx.editor.registers.push(register, new_regex) { + Ok(_) => cx.editor.set_status(msg), + Err(err) => cx.editor.set_error(err.to_string()), + } } fn global_search(cx: &mut Context) { @@ -2367,7 +2374,10 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { let text = doc.text().slice(..); let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); let reg_name = cx.register.unwrap_or('"'); - cx.editor.registers.write(reg_name, values); + if let Err(err) = cx.editor.registers.write(reg_name, values) { + cx.editor.set_error(err.to_string()); + return; + } }; // then delete @@ -3750,7 +3760,22 @@ fn commit_undo_checkpoint(cx: &mut Context) { // Yank / Paste fn yank(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + yank_impl(cx.editor, cx.register.unwrap_or('"')); + exit_select_mode(cx); +} + +fn yank_to_clipboard(cx: &mut Context) { + yank_impl(cx.editor, '*'); + exit_select_mode(cx); +} + +fn yank_to_primary_clipboard(cx: &mut Context) { + yank_impl(cx.editor, '+'); + exit_select_mode(cx); +} + +fn yank_impl(editor: &mut Editor, register: char) { + let (view, doc) = current!(editor); let text = doc.text().slice(..); let values: Vec = doc @@ -3758,19 +3783,15 @@ fn yank(cx: &mut Context) { .fragments(text) .map(Cow::into_owned) .collect(); + let selections = values.len(); - 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); + match editor.registers.write(register, values) { + Ok(_) => editor.set_status(format!( + "yanked {selections} selection{} to register {register}", + if selections == 1 { "" } else { "s" } + )), + Err(err) => editor.set_error(err.to_string()), + } } fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { @@ -3778,6 +3799,7 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { let text = doc.text().slice(..); let selection = doc.selection(view.id); + let selections = selection.len(); let joined = selection .fragments(text) .fold(String::new(), |mut acc, fragment| { @@ -3788,104 +3810,52 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { acc }); - let msg = format!( - "joined and yanked {} selection(s) to register {}", - selection.len(), - register, - ); - - editor.registers.write(register, vec![joined]); - editor.set_status(msg); + match editor.registers.write(register, vec![joined]) { + Ok(_) => editor.set_status(format!( + "joined and yanked {selections} selection{} to register {register}", + if selections == 1 { "" } else { "s" } + )), + Err(err) => editor.set_error(err.to_string()), + } } fn yank_joined(cx: &mut Context) { - let line_ending = doc!(cx.editor).line_ending; - let register = cx.register.unwrap_or('"'); - yank_joined_impl(cx.editor, line_ending.as_str(), register); + let separator = doc!(cx.editor).line_ending.as_str(); + yank_joined_impl(cx.editor, separator, cx.register.unwrap_or('"')); 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 = doc - .selection(view.id) - .fragments(text) - .map(Cow::into_owned) - .collect(); - - let clipboard_text = match clipboard_type { - ClipboardType::Clipboard => "system clipboard", - ClipboardType::Selection => "primary clipboard", - }; - - let msg = format!( - "joined and yanked {} selection(s) to {}", - values.len(), - clipboard_text, - ); - - let joined = values.join(separator); - - editor - .clipboard_provider - .set_contents(joined, clipboard_type) - .context("Couldn't set system clipboard content")?; - - editor.set_status(msg); - - Ok(()) +fn yank_joined_to_clipboard(cx: &mut Context) { + let line_ending = doc!(cx.editor).line_ending; + yank_joined_impl(cx.editor, line_ending.as_str(), '*'); + exit_select_mode(cx); } -fn yank_joined_to_clipboard(cx: &mut Context) { +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::Clipboard); + yank_joined_impl(cx.editor, line_ending.as_str(), '+'); exit_select_mode(cx); } -fn yank_main_selection_to_clipboard_impl( - editor: &mut Editor, - clipboard_type: ClipboardType, -) -> anyhow::Result<()> { +fn yank_primary_selection_impl(editor: &mut Editor, register: char) { let (view, doc) = current!(editor); let text = doc.text().slice(..); - let message_text = match clipboard_type { - ClipboardType::Clipboard => "yanked main selection to system clipboard", - ClipboardType::Selection => "yanked main selection to primary clipboard", - }; - - let value = doc.selection(view.id).primary().fragment(text); + let selection = doc.selection(view.id).primary().fragment(text).to_string(); - if let Err(e) = editor - .clipboard_provider - .set_contents(value.into_owned(), clipboard_type) - { - bail!("Couldn't set system clipboard content: {}", e); + match editor.registers.write(register, vec![selection]) { + Ok(_) => editor.set_status(format!("yanked primary selection to register {register}",)), + Err(err) => editor.set_error(err.to_string()), } - - editor.set_status(message_text); - 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); + yank_primary_selection_impl(cx.editor, '*'); + exit_select_mode(cx); } fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection); + yank_primary_selection_impl(cx.editor, '+'); exit_select_mode(cx); } @@ -3985,144 +3955,88 @@ pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { paste_impl(&[contents], doc, view, paste, count, cx.editor.mode); } -fn paste_clipboard_impl( - editor: &mut Editor, - action: Paste, - clipboard_type: ClipboardType, - count: usize, -) -> anyhow::Result<()> { - let (view, doc) = current!(editor); - match editor.clipboard_provider.get_contents(clipboard_type) { - Ok(contents) => { - paste_impl(&[contents], doc, view, action, count, editor.mode); - Ok(()) - } - Err(e) => Err(e.context("Couldn't get system clipboard contents")), - } -} - fn paste_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl( - cx.editor, - Paste::After, - ClipboardType::Clipboard, - cx.count(), - ); + paste(cx.editor, '*', Paste::After, cx.count()); } fn paste_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl( - cx.editor, - Paste::Before, - ClipboardType::Clipboard, - cx.count(), - ); + paste(cx.editor, '*', Paste::Before, cx.count()); } fn paste_primary_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl( - cx.editor, - Paste::After, - ClipboardType::Selection, - cx.count(), - ); + paste(cx.editor, '+', Paste::After, cx.count()); } fn paste_primary_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl( - cx.editor, - Paste::Before, - ClipboardType::Selection, - cx.count(), - ); + paste(cx.editor, '+', Paste::Before, 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); - exit_select_mode(cx); - } - } + replace_with_yanked_impl(cx.editor, cx.register.unwrap_or('"'), cx.count()); + exit_select_mode(cx); } -fn replace_selections_with_clipboard_impl( - cx: &mut Context, - clipboard_type: ClipboardType, -) -> anyhow::Result<()> { - let count = cx.count(); - let (view, doc) = current!(cx.editor); - - match cx.editor.clipboard_provider.get_contents(clipboard_type) { - Ok(contents) => { - let selection = doc.selection(view.id); - let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - ( - range.from(), - range.to(), - Some(contents.repeat(count).as_str().into()), - ) - }); +fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) { + let Some(values) = editor.registers + .read(register, editor) + .filter(|values| values.len() > 0) else { return }; + let values: Vec<_> = values.map(|value| value.to_string()).collect(); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view); + let (view, doc) = current!(editor); + 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) } - Err(e) => return Err(e.context("Couldn't get system clipboard contents")), - } + }); - exit_select_mode(cx); - Ok(()) + doc.apply(&transaction, view.id); } fn replace_selections_with_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard); + replace_with_yanked_impl(cx.editor, '*', cx.count()); } fn replace_selections_with_primary_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Selection); + replace_with_yanked_impl(cx.editor, '+', 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; +fn paste(editor: &mut Editor, register: char, pos: Paste, count: usize) { + let Some(values) = editor.registers.read(register, editor) else { return }; + let values: Vec<_> = values.map(|value| value.to_string()).collect(); - if let Some(values) = registers.read(reg_name) { - paste_impl(values, doc, view, pos, count, cx.editor.mode); - } + let (view, doc) = current!(editor); + paste_impl(&values, doc, view, pos, count, editor.mode); } fn paste_after(cx: &mut Context) { - paste(cx, Paste::After) + paste( + cx.editor, + cx.register.unwrap_or('"'), + Paste::After, + cx.count(), + ); } fn paste_before(cx: &mut Context) { - paste(cx, Paste::Before) + paste( + cx.editor, + cx.register.unwrap_or('"'), + Paste::Before, + cx.count(), + ); } fn get_lines(doc: &Document, view_id: ViewId) -> Vec { @@ -4879,7 +4793,12 @@ fn insert_register(cx: &mut Context) { if let Some(ch) = event.char() { cx.editor.autoinfo = None; cx.register = Some(ch); - paste(cx, Paste::Cursor); + paste( + cx.editor, + cx.register.unwrap_or('"'), + Paste::Cursor, + cx.count(), + ); } }) } @@ -5593,9 +5512,12 @@ fn record_macro(cx: &mut Context) { } }) .collect::(); - cx.editor.registers.write(reg, vec![s]); - cx.editor - .set_status(format!("Recorded to register [{}]", reg)); + match cx.editor.registers.write(reg, vec![s]) { + Ok(_) => cx + .editor + .set_status(format!("Recorded to register [{}]", reg)), + Err(err) => cx.editor.set_error(err.to_string()), + } } else { let reg = cx.register.take().unwrap_or('@'); cx.editor.macro_recording = Some((reg, Vec::new())); @@ -5615,8 +5537,14 @@ fn replay_macro(cx: &mut Context) { return; } - let keys: Vec = if let Some([keys_str]) = cx.editor.registers.read(reg) { - match helix_view::input::parse_macro(keys_str) { + let keys: Vec = if let Some(keys) = cx + .editor + .registers + .read(reg, cx.editor) + .filter(|values| values.len() == 1) + .map(|mut values| values.next().unwrap()) + { + match helix_view::input::parse_macro(&keys) { Ok(keys) => keys, Err(err) => { cx.editor.set_error(format!("Invalid macro: {}", err)); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 175f8bc61217..67640f79764f 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -904,7 +904,8 @@ fn yank_main_selection_to_clipboard( return Ok(()); } - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) + yank_primary_selection_impl(cx.editor, '*'); + Ok(()) } fn yank_joined( @@ -938,7 +939,8 @@ fn yank_joined_to_clipboard( let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); - yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) + yank_joined_impl(cx.editor, separator, '*'); + Ok(()) } fn yank_main_selection_to_primary_clipboard( @@ -950,7 +952,8 @@ fn yank_main_selection_to_primary_clipboard( return Ok(()); } - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) + yank_primary_selection_impl(cx.editor, '+'); + Ok(()) } fn yank_joined_to_primary_clipboard( @@ -965,7 +968,8 @@ fn yank_joined_to_primary_clipboard( let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); - yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) + yank_joined_impl(cx.editor, separator, '+'); + Ok(()) } fn paste_clipboard_after( @@ -977,7 +981,8 @@ fn paste_clipboard_after( return Ok(()); } - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) + paste(cx.editor, '*', Paste::After, 1); + Ok(()) } fn paste_clipboard_before( @@ -989,7 +994,8 @@ fn paste_clipboard_before( return Ok(()); } - paste_clipboard_impl(cx.editor, Paste::Before, ClipboardType::Clipboard, 1) + paste(cx.editor, '*', Paste::Before, 1); + Ok(()) } fn paste_primary_clipboard_after( @@ -1001,7 +1007,8 @@ fn paste_primary_clipboard_after( return Ok(()); } - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) + paste(cx.editor, '+', Paste::After, 1); + Ok(()) } fn paste_primary_clipboard_before( @@ -1013,30 +1020,8 @@ fn paste_primary_clipboard_before( return Ok(()); } - paste_clipboard_impl(cx.editor, Paste::Before, ClipboardType::Selection, 1) -} - -fn replace_selections_with_clipboard_impl( - cx: &mut compositor::Context, - clipboard_type: ClipboardType, -) -> anyhow::Result<()> { - let scrolloff = cx.editor.config().scrolloff; - let (view, doc) = current!(cx.editor); - - match cx.editor.clipboard_provider.get_contents(clipboard_type) { - Ok(contents) => { - let selection = doc.selection(view.id); - let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - (range.from(), range.to(), Some(contents.as_str().into())) - }); - - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view); - view.ensure_cursor_in_view(doc, scrolloff); - Ok(()) - } - Err(e) => Err(e.context("Couldn't get system clipboard contents")), - } + paste(cx.editor, '+', Paste::Before, 1); + Ok(()) } fn replace_selections_with_clipboard( @@ -1048,7 +1033,8 @@ fn replace_selections_with_clipboard( return Ok(()); } - replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) + replace_with_yanked_impl(cx.editor, '*', 1); + Ok(()) } fn replace_selections_with_primary_clipboard( @@ -1060,7 +1046,8 @@ fn replace_selections_with_primary_clipboard( return Ok(()); } - replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) + replace_with_yanked_impl(cx.editor, '+', 1); + Ok(()) } fn show_clipboard_provider( @@ -1073,7 +1060,7 @@ fn show_clipboard_provider( } cx.editor - .set_status(cx.editor.clipboard_provider.name().to_string()); + .set_status(cx.editor.registers.clipboard_provider_name().to_string()); Ok(()) } @@ -2285,13 +2272,12 @@ fn clear_register( format!("Invalid register {}", args[0]) ); let register = args[0].chars().next().unwrap_or_default(); - match cx.editor.registers.remove(register) { - Some(_) => cx - .editor - .set_status(format!("Register {} cleared", register)), - None => cx - .editor - .set_error(format!("Register {} not found", register)), + if cx.editor.registers.remove(register) { + cx.editor + .set_status(format!("Register {} cleared", register)); + } else { + cx.editor + .set_error(format!("Register {} not found", register)); } Ok(()) } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index c84c616c6c0b..379833525b33 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -265,7 +265,7 @@ pub fn default() -> HashMap { "C-v" | "v" => vsplit_new, }, }, - "y" => yank_joined_to_clipboard, + "y" => yank_to_clipboard, "Y" => yank_main_selection_to_clipboard, "p" => paste_clipboard_after, "P" => paste_clipboard_before, diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 1352f4937753..702a6e6714ad 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -306,8 +306,8 @@ impl Prompt { direction: CompletionDirection, ) { (self.callback_fn)(cx, &self.line, PromptEvent::Abort); - let values = match cx.editor.registers.read(register) { - Some(values) if !values.is_empty() => values, + let mut values = match cx.editor.registers.read(register, cx.editor) { + Some(values) if values.len() > 0 => values.rev(), _ => return, }; @@ -315,13 +315,16 @@ impl Prompt { let index = match direction { CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1), - CompletionDirection::Backward => { - self.history_pos.unwrap_or(values.len()).saturating_sub(1) - } + CompletionDirection::Backward => self + .history_pos + .unwrap_or_else(|| values.len()) + .saturating_sub(1), } .min(end); - self.line = values[index].clone(); + self.line = values.nth(index).unwrap().to_string(); + // Appease the borrow checker. + drop(values); self.history_pos = Some(index); @@ -470,7 +473,7 @@ impl Prompt { // Show the most recently entered value as a suggestion. if let Some(suggestion) = self .history_register - .and_then(|reg| cx.editor.registers.last(reg)) + .and_then(|reg| cx.editor.registers.first(reg, cx.editor)) { surface.set_string(line_area.x, line_area.y, suggestion, suggestion_color); } @@ -567,25 +570,29 @@ impl Component for Prompt { } else { let last_item = self .history_register - .and_then(|reg| cx.editor.registers.last(reg).cloned()) - .map(|entry| entry.into()) - .unwrap_or_else(|| Cow::from("")); + .and_then(|reg| cx.editor.registers.first(reg, cx.editor)) + .map(|entry| entry.to_string()) + .unwrap_or_else(|| String::from("")); // handle executing with last command in history if nothing entered - let input: Cow = if self.line.is_empty() { - last_item + let input = if self.line.is_empty() { + &last_item } else { if last_item != self.line { // store in history if let Some(register) = self.history_register { - cx.editor.registers.push(register, self.line.clone()); + if let Err(err) = + cx.editor.registers.push(register, self.line.clone()) + { + cx.editor.set_error(err.to_string()); + } }; } - self.line.as_str().into() + &self.line }; - (self.callback_fn)(cx, &input, PromptEvent::Validate); + (self.callback_fn)(cx, input, PromptEvent::Validate); return close_fn; } @@ -617,25 +624,16 @@ impl Component for Prompt { self.completion = cx .editor .registers - .inner() - .iter() - .map(|(ch, reg)| { - let content = reg - .read() - .get(0) - .and_then(|s| s.lines().next().to_owned()) - .unwrap_or_default(); - (0.., format!("{} {}", ch, &content).into()) - }) + .iter_preview() + .map(|(ch, preview)| (0.., format!("{} {}", ch, &preview).into())) .collect(); self.next_char_handler = Some(Box::new(|prompt, c, context| { prompt.insert_str( - context + &context .editor .registers - .read(c) - .and_then(|r| r.first()) - .map_or("", |r| r.as_str()), + .first(c, context.editor) + .unwrap_or_default(), context.editor, ); })); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 20469ae94eff..4ea1c49f536e 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,10 +1,10 @@ use crate::{ align_view, - clipboard::{get_clipboard_provider, ClipboardProvider}, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, graphics::{CursorKind, Rect}, info::Info, input::KeyEvent, + register::Registers, theme::{self, Theme}, tree::{self, Tree}, view::ViewPosition, @@ -40,7 +40,6 @@ use tokio::{ use anyhow::{anyhow, bail, Error}; pub use helix_core::diagnostic::Severity; -pub use helix_core::register::Registers; use helix_core::{ auto_pairs::AutoPairs, syntax::{self, AutoPairConfig, SoftWrap}, @@ -875,8 +874,6 @@ pub struct Editor { pub debugger_events: SelectAll>, pub breakpoints: HashMap>, - pub clipboard_provider: Box, - pub syn_loader: Arc, pub theme_loader: Arc, /// last_theme is used for theme previews. We store the current theme here, @@ -1023,7 +1020,6 @@ impl Editor { last_theme: None, last_selection: None, registers: Registers::default(), - clipboard_provider: get_clipboard_provider(), status_msg: None, autoinfo: None, idle_timer: Box::pin(sleep(conf.idle_timeout)), diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs index 1503e855e362..ca783fef54a2 100644 --- a/helix-view/src/info.rs +++ b/helix-view/src/info.rs @@ -1,4 +1,5 @@ -use helix_core::{register::Registers, unicode::width::UnicodeWidthStr}; +use crate::register::Registers; +use helix_core::unicode::width::UnicodeWidthStr; use std::fmt::Write; #[derive(Debug)] @@ -56,16 +57,8 @@ impl Info { pub fn from_registers(registers: &Registers) -> Self { let body: Vec<_> = registers - .inner() - .iter() - .map(|(ch, reg)| { - let content = reg - .read() - .get(0) - .and_then(|s| s.lines().next()) - .unwrap_or_default(); - (ch.to_string(), content) - }) + .iter_preview() + .map(|(ch, preview)| (ch.to_string(), preview)) .collect(); let mut infobox = Self::new("Registers", &body); diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index c3f67345b361..6a68e7d6f9bd 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -15,6 +15,7 @@ pub mod base64; pub mod info; pub mod input; pub mod keyboard; +pub mod register; pub mod theme; pub mod tree; pub mod view; diff --git a/helix-view/src/register.rs b/helix-view/src/register.rs new file mode 100644 index 000000000000..3d1849a6fb66 --- /dev/null +++ b/helix-view/src/register.rs @@ -0,0 +1,331 @@ +use std::{borrow::Cow, collections::HashMap, iter}; + +use anyhow::Result; +use helix_core::NATIVE_LINE_ENDING; + +use crate::{ + clipboard::{get_clipboard_provider, ClipboardProvider, ClipboardType}, + document::SCRATCH_BUFFER_NAME, + Editor, +}; + +/// A key-value store for saving sets of values. +/// +/// Each register corresponds to a `char`. Most chars can be used to store any set of +/// values but a few chars are "special registers". Special registers have unique +/// behaviors when read or written to: +/// +/// * Black hole (`_`): all values read and written are discarded +/// * Selection indices (`#`): index number of each selection starting at 1 +/// * Selection contents (`.`) +/// * Document path (`%`): filename of the current buffer +/// * System clipboard (`*`) +/// * Primary clipboard (`+`) +#[derive(Debug)] +pub struct Registers { + /// The mapping of register to values. + /// Values are stored in reverse order when inserted with `Registers::write`. + /// The order is reversed again in `Registers::read`. This allows us to + /// efficiently prepend new values in `Registers::push`. + inner: HashMap>, + clipboard_provider: Box, +} + +impl Default for Registers { + fn default() -> Self { + Self { + inner: Default::default(), + clipboard_provider: get_clipboard_provider(), + } + } +} + +impl Registers { + pub fn read<'a>(&'a self, name: char, editor: &'a Editor) -> Option> { + match name { + '_' => Some(RegisterValues::new(iter::empty())), + '#' => { + let (view, doc) = current_ref!(editor); + let selections = doc.selection(view.id).len(); + // ExactSizeIterator is implemented for Range but + // not RangeInclusive. + Some(RegisterValues::new( + (0..selections).map(|i| (i + 1).to_string().into()), + )) + } + '.' => { + let (view, doc) = current_ref!(editor); + let text = doc.text().slice(..); + Some(RegisterValues::new(doc.selection(view.id).fragments(text))) + } + '%' => { + let doc = doc!(editor); + + let path = doc + .path() + .as_ref() + .map(|p| p.to_string_lossy()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); + + Some(RegisterValues::new(iter::once(path))) + } + '*' | '+' => Some(read_from_clipboard( + self.clipboard_provider.as_ref(), + self.inner.get(&name), + match name { + '*' => ClipboardType::Clipboard, + '+' => ClipboardType::Selection, + _ => unreachable!(), + }, + )), + _ => self + .inner + .get(&name) + .map(|values| RegisterValues::new(values.iter().map(Cow::from).rev())), + } + } + + pub fn write(&mut self, name: char, mut values: Vec) -> Result<()> { + match name { + '_' => Ok(()), + '#' | '.' | '%' => Err(anyhow::anyhow!("Register {name} does not support writing")), + '*' | '+' => { + self.clipboard_provider.set_contents( + values.join(NATIVE_LINE_ENDING.as_str()), + match name { + '*' => ClipboardType::Clipboard, + '+' => ClipboardType::Selection, + _ => unreachable!(), + }, + )?; + values.reverse(); + self.inner.insert(name, values); + Ok(()) + } + _ => { + values.reverse(); + self.inner.insert(name, values); + Ok(()) + } + } + } + + pub fn push(&mut self, name: char, mut value: String) -> Result<()> { + match name { + '_' => Ok(()), + '#' | '.' | '%' => Err(anyhow::anyhow!("Register {name} does not support pushing")), + '*' | '+' => { + let clipboard_type = match name { + '*' => ClipboardType::Clipboard, + '+' => ClipboardType::Selection, + _ => unreachable!(), + }; + let contents = self.clipboard_provider.get_contents(clipboard_type)?; + let saved_values = self.inner.entry(name).or_insert_with(Vec::new); + + if !contents_are_saved(saved_values, &contents) { + anyhow::bail!("Failed to push to register {name}: clipboard does not match register contents"); + } + + saved_values.push(value.clone()); + if !contents.is_empty() { + value.push_str(NATIVE_LINE_ENDING.as_str()); + } + value.push_str(&contents); + self.clipboard_provider + .set_contents(value, clipboard_type)?; + + Ok(()) + } + _ => { + self.inner.entry(name).or_insert_with(Vec::new).push(value); + Ok(()) + } + } + } + + pub fn first<'a>(&'a self, name: char, editor: &'a Editor) -> Option> { + self.read(name, editor).and_then(|mut values| values.next()) + } + + pub fn last<'a>(&'a self, name: char, editor: &'a Editor) -> Option> { + self.read(name, editor).and_then(|values| values.last()) + } + + pub fn iter_preview(&self) -> impl Iterator { + self.inner + .iter() + .filter(|(name, _)| !matches!(name, '*' | '+')) + .map(|(name, values)| { + let preview = values + .last() + .and_then(|s| s.lines().next()) + .unwrap_or(""); + + (*name, preview) + }) + .chain( + [ + ('_', ""), + ('#', ""), + ('.', ""), + ('%', ""), + ('*', ""), + ('+', ""), + ] + .iter() + .copied(), + ) + } + + pub fn clear(&mut self) { + self.clear_clipboard(ClipboardType::Clipboard); + self.clear_clipboard(ClipboardType::Selection); + self.inner.clear() + } + + pub fn remove(&mut self, name: char) -> bool { + match name { + '*' | '+' => { + self.clear_clipboard(match name { + '*' => ClipboardType::Clipboard, + '+' => ClipboardType::Selection, + _ => unreachable!(), + }); + self.inner.remove(&name); + + true + } + '_' | '#' | '.' | '%' => false, + _ => self.inner.remove(&name).is_some(), + } + } + + fn clear_clipboard(&mut self, clipboard_type: ClipboardType) { + if let Err(err) = self + .clipboard_provider + .set_contents("".into(), clipboard_type) + { + log::error!( + "Failed to clear {} clipboard: {err}", + match clipboard_type { + ClipboardType::Clipboard => "system", + ClipboardType::Selection => "primary", + } + ) + } + } + + pub fn clipboard_provider_name(&self) -> Cow { + self.clipboard_provider.name() + } +} + +fn read_from_clipboard<'a>( + provider: &dyn ClipboardProvider, + saved_values: Option<&'a Vec>, + clipboard_type: ClipboardType, +) -> RegisterValues<'a> { + match provider.get_contents(clipboard_type) { + Ok(contents) => { + // If we're pasting the same values that we just yanked, re-use + // the saved values. This allows pasting multiple selections + // even when yanked to a clipboard. + let Some(values) = saved_values else { return RegisterValues::new(iter::once(contents.into())) }; + + if contents_are_saved(values, &contents) { + RegisterValues::new(values.iter().map(Cow::from).rev()) + } else { + RegisterValues::new(iter::once(contents.into())) + } + } + Err(err) => { + log::error!( + "Failed to read {} clipboard: {err}", + match clipboard_type { + ClipboardType::Clipboard => "system", + ClipboardType::Selection => "primary", + } + ); + + RegisterValues::new(iter::empty()) + } + } +} + +fn contents_are_saved(saved_values: &[String], mut contents: &str) -> bool { + let line_ending = NATIVE_LINE_ENDING.as_str(); + let mut values = saved_values.iter().rev(); + + match values.next() { + Some(first) if contents.starts_with(first) => { + contents = &contents[first.len()..]; + } + None if contents.is_empty() => return true, + _ => return false, + } + + for value in values { + if contents.starts_with(line_ending) && contents[line_ending.len()..].starts_with(value) { + contents = &contents[line_ending.len() + value.len()..]; + } else { + return false; + } + } + + true +} + +// This is a wrapper of an iterator that is both double ended and exact size, +// and can return either owned or borrowed values. Regular registers can +// return borrowed values while some special registers need to return owned +// values. +pub struct RegisterValues<'a> { + iter: Box> + 'a>, +} + +impl<'a> RegisterValues<'a> { + fn new( + iter: impl DoubleEndedIterator> + + ExactSizeIterator> + + 'a, + ) -> Self { + Self { + iter: Box::new(iter), + } + } +} + +impl<'a> Iterator for RegisterValues<'a> { + type Item = Cow<'a, str>; + + fn next(&mut self) -> Option { + self.iter.next() + } + + fn size_hint(&self) -> (usize, Option) { + self.iter.size_hint() + } +} + +impl<'a> DoubleEndedIterator for RegisterValues<'a> { + fn next_back(&mut self) -> Option { + self.iter.next_back() + } +} + +impl<'a> ExactSizeIterator for RegisterValues<'a> { + fn len(&self) -> usize { + self.iter.len() + } +} + +// Each RegisterValues iterator is both double ended and exact size. We can't +// type RegisterValues as `Box` +// because only one non-auto trait is allowed in trait objects. So we need to +// create a new trait that covers both. `RegisterValues` wraps that type so that +// trait only needs to live in this module and not be imported for all register +// callsites. +trait DoubleEndedExactSizeIterator: DoubleEndedIterator + ExactSizeIterator {} + +impl DoubleEndedExactSizeIterator for I {}