diff --git a/Cargo.lock b/Cargo.lock index 896f7bc19..f360117bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "etcetera" version = "0.3.2" @@ -360,6 +366,7 @@ dependencies = [ "tokio", "toml", "url", + "which", ] [[package]] @@ -1063,6 +1070,16 @@ version = "0.10.2+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +[[package]] +name = "which" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +dependencies = [ + "either", + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3223b14f8..5cfee75d0 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1280,6 +1280,96 @@ fn theme(editor: &mut Editor, args: &[&str], event: PromptEvent) { editor.set_theme_from_name(theme); } + fn yank_main_selection_to_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) { + let (view, doc) = current!(editor); + + // TODO: currently the main selection is the first one. This needs to be revisited later. + let range = doc + .selection(view.id) + .ranges() + .first() + .expect("at least one selection"); + + let value = range.fragment(doc.text().slice(..)); + + if let Err(e) = editor.clipboard_provider.set_contents(value.into_owned()) { + log::error!("Couldn't set system clipboard content: {:?}", e); + } + + editor.set_status("yanked main selection to system clipboard".to_owned()); + } + + fn yank_joined_to_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) { + let (view, doc) = current!(editor); + + let values: Vec = doc + .selection(view.id) + .fragments(doc.text().slice(..)) + .map(Cow::into_owned) + .collect(); + + let msg = format!( + "joined and yanked {} selection(s) to system clipboard", + values.len(), + ); + + let joined = values.join("\n"); + + if let Err(e) = editor.clipboard_provider.set_contents(joined) { + log::error!("Couldn't set system clipboard content: {:?}", e); + } + + editor.set_status(msg); + } + + fn paste_clipboard_impl(editor: &mut Editor, action: Paste) { + let (view, doc) = current!(editor); + + match editor + .clipboard_provider + .get_contents() + .map(|contents| paste_impl(&[contents], doc, view, action)) + { + Ok(Some(transaction)) => { + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + Ok(None) => {} + Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e), + } + } + + fn paste_clipboard_after(editor: &mut Editor, _: &[&str], _: PromptEvent) { + paste_clipboard_impl(editor, Paste::After); + } + + fn paste_clipboard_before(editor: &mut Editor, args: &[&str], event: PromptEvent) { + paste_clipboard_impl(editor, Paste::After); + } + + fn replace_selections_with_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) { + let (view, doc) = current!(editor); + + match editor.clipboard_provider.get_contents() { + Ok(contents) => { + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let max_to = doc.text().len_chars().saturating_sub(1); + let to = std::cmp::min(max_to, range.to() + 1); + (range.from(), to, Some(contents.as_str().into())) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e), + } + } + + fn show_clipboard_provider(editor: &mut Editor, _: &[&str], _: PromptEvent) { + editor.set_status(editor.clipboard_provider.name().into()); + } + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -1400,7 +1490,48 @@ fn theme(editor: &mut Editor, args: &[&str], event: PromptEvent) { fun: theme, completer: Some(completers::theme), }, - + TypableCommand { + name: "clipboard-yank", + alias: None, + doc: "Yank main selection into system clipboard.", + fun: yank_main_selection_to_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-yank-join", + alias: None, + doc: "Yank joined selections into system clipboard.", + fun: yank_joined_to_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-after", + alias: None, + doc: "Paste system clipboard after selections.", + fun: paste_clipboard_after, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-before", + alias: None, + doc: "Paste system clipboard before selections.", + fun: paste_clipboard_before, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-replace", + alias: None, + doc: "Replace selections with content of system clipboard.", + fun: replace_selections_with_clipboard, + completer: None, + }, + TypableCommand { + name: "show-clipboard-provider", + alias: None, + doc: "Show clipboard provider name in status bar.", + fun: show_clipboard_provider, + completer: None, + }, ]; pub static COMMANDS: Lazy> = Lazy::new(|| { diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 7f18e9a2a..8d93d2d9e 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -34,3 +34,6 @@ slotmap = "1" serde = { version = "1.0", features = ["derive"] } toml = "0.5" log = "~0.4" + +which = "4.1" + diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs new file mode 100644 index 000000000..dcc443405 --- /dev/null +++ b/helix-view/src/clipboard.rs @@ -0,0 +1,193 @@ +// Implementation reference: https://github.com/neovim/neovim/blob/f2906a4669a2eef6d7bf86a29648793d63c98949/runtime/autoload/provider/clipboard.vim#L68-L152 + +use anyhow::Result; +use std::borrow::Cow; + +pub trait ClipboardProvider: std::fmt::Debug { + fn name(&self) -> Cow; + fn get_contents(&self) -> Result; + fn set_contents(&self, contents: String) -> Result<()>; +} + +macro_rules! command_provider { + (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{ + Box::new(provider::CommandProvider { + get_cmd: provider::CommandConfig { + prg: $get_prg, + args: &[ $( $get_arg ),* ], + }, + set_cmd: provider::CommandConfig { + prg: $set_prg, + args: &[ $( $set_arg ),* ], + }, + }) + }}; +} + +pub fn get_clipboard_provider() -> Box { + // TODO: support for user-defined provider, probably when we have plugin support by setting a + // variable? + + if exists("pbcopy") && exists("pbpaste") { + command_provider! { + paste => "pbpaste"; + copy => "pbcopy"; + } + } else if env_var_is_set("WAYLAND_DISPLAY") && exists("wl-copy") && exists("wl-paste") { + command_provider! { + paste => "wl-paste", "--no-newline"; + copy => "wl-copy", "--foreground", "--type", "text/plain"; + } + } else if env_var_is_set("DISPLAY") && exists("xclip") { + command_provider! { + paste => "xclip", "-o", "-selection", "clipboard"; + copy => "xclip", "-i", "-selection", "clipboard"; + } + } else if env_var_is_set("DISPLAY") && exists("xsel") && is_exit_success("xsel", &["-o", "-b"]) + { + // FIXME: check performance of is_exit_success + command_provider! { + paste => "xsel", "-o", "-b"; + copy => "xsel", "--nodetach", "-i", "-b"; + } + } else if exists("lemonade") { + command_provider! { + paste => "lemonade", "paste"; + copy => "lemonade", "copy"; + } + } else if exists("doitclient") { + command_provider! { + paste => "doitclient", "wclip", "-r"; + copy => "doitclient", "wclip"; + } + } else if exists("win32yank.exe") { + // FIXME: does it work within WSL? + command_provider! { + paste => "win32yank.exe", "-o", "--lf"; + copy => "win32yank.exe", "-i", "--crlf"; + } + } else if exists("termux-clipboard-set") && exists("termux-clipboard-get") { + command_provider! { + paste => "termux-clipboard-get"; + copy => "termux-clipboard-set"; + } + } else if env_var_is_set("TMUX") && exists("tmux") { + command_provider! { + paste => "tmux", "save-buffer", "-"; + copy => "tmux", "load-buffer", "-"; + } + } else { + Box::new(provider::NopProvider) + } +} + +fn exists(executable_name: &str) -> bool { + which::which(executable_name).is_ok() +} + +fn env_var_is_set(env_var_name: &str) -> bool { + std::env::var_os(env_var_name).is_some() +} + +fn is_exit_success(program: &str, args: &[&str]) -> bool { + std::process::Command::new(program) + .args(args) + .output() + .ok() + .and_then(|out| out.status.success().then(|| ())) // TODO: use then_some when stabilized + .is_some() +} + +mod provider { + use super::ClipboardProvider; + use anyhow::{bail, Context as _, Result}; + use std::borrow::Cow; + + #[derive(Debug)] + pub struct NopProvider; + + impl ClipboardProvider for NopProvider { + fn name(&self) -> Cow { + Cow::Borrowed("none") + } + + fn get_contents(&self) -> Result { + Ok(String::new()) + } + + fn set_contents(&self, _: String) -> Result<()> { + Ok(()) + } + } + + #[derive(Debug)] + pub struct CommandConfig { + pub prg: &'static str, + pub args: &'static [&'static str], + } + + impl CommandConfig { + fn execute(&self, input: Option<&str>, pipe_output: bool) -> Result> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null); + let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null); + + let mut child = Command::new(self.prg) + .args(self.args) + .stdin(stdin) + .stdout(stdout) + .stderr(Stdio::null()) + .spawn()?; + + if let Some(input) = input { + let mut stdin = child.stdin.take().context("stdin is missing")?; + stdin + .write_all(input.as_bytes()) + .context("couldn't write in stdin")?; + } + + // TODO: add timer? + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("clipboard provider {} failed", self.prg); + } + + if pipe_output { + Ok(Some(String::from_utf8(output.stdout)?)) + } else { + Ok(None) + } + } + } + + #[derive(Debug)] + pub struct CommandProvider { + pub get_cmd: CommandConfig, + pub set_cmd: CommandConfig, + } + + impl ClipboardProvider for CommandProvider { + fn name(&self) -> Cow { + if self.get_cmd.prg != self.set_cmd.prg { + Cow::Owned(format!("{}+{}", self.get_cmd.prg, self.set_cmd.prg)) + } else { + Cow::Borrowed(self.get_cmd.prg) + } + } + + fn get_contents(&self) -> Result { + let output = self + .get_cmd + .execute(None, true)? + .context("output is missing")?; + Ok(output) + } + + fn set_contents(&self, value: String) -> Result<()> { + self.set_cmd.execute(Some(&value), false).map(|_| ()) + } + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 35a547adc..5d18030a0 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,3 +1,4 @@ +use crate::clipboard::{get_clipboard_provider, ClipboardProvider}; use crate::{ theme::{self, Theme}, tree::Tree, @@ -14,9 +15,10 @@ use anyhow::Error; +use helix_core::Position; + pub use helix_core::diagnostic::Severity; pub use helix_core::register::Registers; -use helix_core::Position; #[derive(Debug)] pub struct Editor { @@ -27,6 +29,7 @@ pub struct Editor { pub registers: Registers, pub theme: Theme, pub language_servers: helix_lsp::Registry, + pub clipboard_provider: Box, pub syn_loader: Arc, pub theme_loader: Arc, @@ -62,6 +65,7 @@ pub fn new( syn_loader: config_loader, theme_loader: themes, registers: Registers::default(), + clipboard_provider: get_clipboard_provider(), status_msg: None, } } diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 206134515..17f415fcb 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] pub mod macros; +pub mod clipboard; pub mod document; pub mod editor; pub mod register_selection;