Skip to content

Commit

Permalink
Add system clipboard yank and paste commands
Browse files Browse the repository at this point in the history
This commit adds six new commands to interact with system clipboard:
- clipboard-yank
- clipboard-yank-join
- clipboard-paste-after
- clipboard-paste-before
- clipboard-paste-replace
- show-clipboard-provider

System clipboard provider is detected by checking a few environment
variables and executables. Currently only built-in detection is
supported.

`clipboard-yank` will only yank the "main" selection, which is currently the first
one. This will need to be revisited later.

Closes #76
  • Loading branch information
CBenoit committed Jun 18, 2021
1 parent 1c1474c commit dfe4472
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 2 deletions.
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 132 additions & 1 deletion helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,96 @@ mod cmd {
quit_all_impl(editor, args, event, true)
}

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<String> = 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",
Expand Down Expand Up @@ -1382,7 +1472,48 @@ mod cmd {
fun: force_quit_all,
completer: None,
},

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<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| {
Expand Down
3 changes: 3 additions & 0 deletions helix-view/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ slotmap = "1"
serde = { version = "1.0", features = ["derive"] }
toml = "0.5"
log = "~0.4"

which = "4.1"

193 changes: 193 additions & 0 deletions helix-view/src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -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<str>;
fn get_contents(&self) -> Result<String>;
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<dyn ClipboardProvider> {
// 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<str> {
Cow::Borrowed("none")
}

fn get_contents(&self) -> Result<String> {
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<Option<String>> {
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<str> {
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<String> {
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(|_| ())
}
}
}
Loading

0 comments on commit dfe4472

Please sign in to comment.