on master help sign in
Add system clipboard yank and paste commands

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 https://github.com/helix-editor/helix/issues/76

commita2b8cfca34433b1d4595143652e10b58685d0dc0 authorBenoît CORTIER <benoit.cortier@fried-world.eu> dateMon 14 Jun 2021, 17:37 (5 years ago) parentd59c9f3
6 files changed+351−2
Cargo.lock+17 helix-term/src/commands.rs+132−1 helix-view/Cargo.toml+3 helix-view/src/clipboard.rs+193 helix-view/src/editor.rs+5−1 helix-view/src/lib.rs+1
Cargo.lock+17
@@ -140,6 +140,12 @@ dependencies = [
140140 "winapi",
141141]
142142
143+[[package]]
144+name = "either"
145+version = "1.6.1"
146+source = "registry+https://github.com/rust-lang/crates.io-index"
147+checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
148+
143149[[package]]
144150name = "etcetera"
145151version = "0.3.2"
@@ -360,6 +366,7 @@ dependencies = [
360366 "tokio",
361367 "toml",
362368 "url",
369+ "which",
363370]
364371
365372[[package]]
@@ -1063,6 +1070,16 @@ version = "0.10.2+wasi-snapshot-preview1"
10631070source = "registry+https://github.com/rust-lang/crates.io-index"
10641071checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
10651072
1073+[[package]]
1074+name = "which"
1075+version = "4.1.0"
1076+source = "registry+https://github.com/rust-lang/crates.io-index"
1077+checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe"
1078+dependencies = [
1079+ "either",
1080+ "libc",
1081+]
1082+
10661083[[package]]
10671084name = "winapi"
10681085version = "0.3.9"
helix-term/src/commands.rs+132−1
@@ -1280,6 +1280,96 @@ mod cmd {
12801280 editor.set_theme_from_name(theme);
12811281 }
12821282
1283+ fn yank_main_selection_to_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) {
1284+ let (view, doc) = current!(editor);
1285+
1286+ // TODO: currently the main selection is the first one. This needs to be revisited later.
1287+ let range = doc
1288+ .selection(view.id)
1289+ .ranges()
1290+ .first()
1291+ .expect("at least one selection");
1292+
1293+ let value = range.fragment(doc.text().slice(..));
1294+
1295+ if let Err(e) = editor.clipboard_provider.set_contents(value.into_owned()) {
1296+ log::error!("Couldn't set system clipboard content: {:?}", e);
1297+ }
1298+
1299+ editor.set_status("yanked main selection to system clipboard".to_owned());
1300+ }
1301+
1302+ fn yank_joined_to_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) {
1303+ let (view, doc) = current!(editor);
1304+
1305+ let values: Vec<String> = doc
1306+ .selection(view.id)
1307+ .fragments(doc.text().slice(..))
1308+ .map(Cow::into_owned)
1309+ .collect();
1310+
1311+ let msg = format!(
1312+ "joined and yanked {} selection(s) to system clipboard",
1313+ values.len(),
1314+ );
1315+
1316+ let joined = values.join("\n");
1317+
1318+ if let Err(e) = editor.clipboard_provider.set_contents(joined) {
1319+ log::error!("Couldn't set system clipboard content: {:?}", e);
1320+ }
1321+
1322+ editor.set_status(msg);
1323+ }
1324+
1325+ fn paste_clipboard_impl(editor: &mut Editor, action: Paste) {
1326+ let (view, doc) = current!(editor);
1327+
1328+ match editor
1329+ .clipboard_provider
1330+ .get_contents()
1331+ .map(|contents| paste_impl(&[contents], doc, view, action))
1332+ {
1333+ Ok(Some(transaction)) => {
1334+ doc.apply(&transaction, view.id);
1335+ doc.append_changes_to_history(view.id);
1336+ }
1337+ Ok(None) => {}
1338+ Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e),
1339+ }
1340+ }
1341+
1342+ fn paste_clipboard_after(editor: &mut Editor, _: &[&str], _: PromptEvent) {
1343+ paste_clipboard_impl(editor, Paste::After);
1344+ }
1345+
1346+ fn paste_clipboard_before(editor: &mut Editor, args: &[&str], event: PromptEvent) {
1347+ paste_clipboard_impl(editor, Paste::After);
1348+ }
1349+
1350+ fn replace_selections_with_clipboard(editor: &mut Editor, args: &[&str], event: PromptEvent) {
1351+ let (view, doc) = current!(editor);
1352+
1353+ match editor.clipboard_provider.get_contents() {
1354+ Ok(contents) => {
1355+ let transaction =
1356+ Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
1357+ let max_to = doc.text().len_chars().saturating_sub(1);
1358+ let to = std::cmp::min(max_to, range.to() + 1);
1359+ (range.from(), to, Some(contents.as_str().into()))
1360+ });
1361+
1362+ doc.apply(&transaction, view.id);
1363+ doc.append_changes_to_history(view.id);
1364+ }
1365+ Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e),
1366+ }
1367+ }
1368+
1369+ fn show_clipboard_provider(editor: &mut Editor, _: &[&str], _: PromptEvent) {
1370+ editor.set_status(editor.clipboard_provider.name().into());
1371+ }
1372+
12831373 pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
12841374 TypableCommand {
12851375 name: "quit",
@@ -1400,7 +1490,48 @@ mod cmd {
14001490 fun: theme,
14011491 completer: Some(completers::theme),
14021492 },
1403−
1493+ TypableCommand {
1494+ name: "clipboard-yank",
1495+ alias: None,
1496+ doc: "Yank main selection into system clipboard.",
1497+ fun: yank_main_selection_to_clipboard,
1498+ completer: None,
1499+ },
1500+ TypableCommand {
1501+ name: "clipboard-yank-join",
1502+ alias: None,
1503+ doc: "Yank joined selections into system clipboard.",
1504+ fun: yank_joined_to_clipboard,
1505+ completer: None,
1506+ },
1507+ TypableCommand {
1508+ name: "clipboard-paste-after",
1509+ alias: None,
1510+ doc: "Paste system clipboard after selections.",
1511+ fun: paste_clipboard_after,
1512+ completer: None,
1513+ },
1514+ TypableCommand {
1515+ name: "clipboard-paste-before",
1516+ alias: None,
1517+ doc: "Paste system clipboard before selections.",
1518+ fun: paste_clipboard_before,
1519+ completer: None,
1520+ },
1521+ TypableCommand {
1522+ name: "clipboard-paste-replace",
1523+ alias: None,
1524+ doc: "Replace selections with content of system clipboard.",
1525+ fun: replace_selections_with_clipboard,
1526+ completer: None,
1527+ },
1528+ TypableCommand {
1529+ name: "show-clipboard-provider",
1530+ alias: None,
1531+ doc: "Show clipboard provider name in status bar.",
1532+ fun: show_clipboard_provider,
1533+ completer: None,
1534+ },
14041535 ];
14051536
14061537 pub static COMMANDS: Lazy<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| {
helix-view/Cargo.toml+3
@@ -34,3 +34,6 @@ slotmap = "1"
3434serde = { version = "1.0", features = ["derive"] }
3535toml = "0.5"
3636log = "~0.4"
37+
38+which = "4.1"
39+
helix-view/src/clipboard.rsnew file+193
@@ -0,0 +1,193 @@
1+// Implementation reference: https://github.com/neovim/neovim/blob/f2906a4669a2eef6d7bf86a29648793d63c98949/runtime/autoload/provider/clipboard.vim#L68-L152
2+
3+use anyhow::Result;
4+use std::borrow::Cow;
5+
6+pub trait ClipboardProvider: std::fmt::Debug {
7+ fn name(&self) -> Cow<str>;
8+ fn get_contents(&self) -> Result<String>;
9+ fn set_contents(&self, contents: String) -> Result<()>;
10+}
11+
12+macro_rules! command_provider {
13+ (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{
14+ Box::new(provider::CommandProvider {
15+ get_cmd: provider::CommandConfig {
16+ prg: $get_prg,
17+ args: &[ $( $get_arg ),* ],
18+ },
19+ set_cmd: provider::CommandConfig {
20+ prg: $set_prg,
21+ args: &[ $( $set_arg ),* ],
22+ },
23+ })
24+ }};
25+}
26+
27+pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
28+ // TODO: support for user-defined provider, probably when we have plugin support by setting a
29+ // variable?
30+
31+ if exists("pbcopy") && exists("pbpaste") {
32+ command_provider! {
33+ paste => "pbpaste";
34+ copy => "pbcopy";
35+ }
36+ } else if env_var_is_set("WAYLAND_DISPLAY") && exists("wl-copy") && exists("wl-paste") {
37+ command_provider! {
38+ paste => "wl-paste", "--no-newline";
39+ copy => "wl-copy", "--foreground", "--type", "text/plain";
40+ }
41+ } else if env_var_is_set("DISPLAY") && exists("xclip") {
42+ command_provider! {
43+ paste => "xclip", "-o", "-selection", "clipboard";
44+ copy => "xclip", "-i", "-selection", "clipboard";
45+ }
46+ } else if env_var_is_set("DISPLAY") && exists("xsel") && is_exit_success("xsel", &["-o", "-b"])
47+ {
48+ // FIXME: check performance of is_exit_success
49+ command_provider! {
50+ paste => "xsel", "-o", "-b";
51+ copy => "xsel", "--nodetach", "-i", "-b";
52+ }
53+ } else if exists("lemonade") {
54+ command_provider! {
55+ paste => "lemonade", "paste";
56+ copy => "lemonade", "copy";
57+ }
58+ } else if exists("doitclient") {
59+ command_provider! {
60+ paste => "doitclient", "wclip", "-r";
61+ copy => "doitclient", "wclip";
62+ }
63+ } else if exists("win32yank.exe") {
64+ // FIXME: does it work within WSL?
65+ command_provider! {
66+ paste => "win32yank.exe", "-o", "--lf";
67+ copy => "win32yank.exe", "-i", "--crlf";
68+ }
69+ } else if exists("termux-clipboard-set") && exists("termux-clipboard-get") {
70+ command_provider! {
71+ paste => "termux-clipboard-get";
72+ copy => "termux-clipboard-set";
73+ }
74+ } else if env_var_is_set("TMUX") && exists("tmux") {
75+ command_provider! {
76+ paste => "tmux", "save-buffer", "-";
77+ copy => "tmux", "load-buffer", "-";
78+ }
79+ } else {
80+ Box::new(provider::NopProvider)
81+ }
82+}
83+
84+fn exists(executable_name: &str) -> bool {
85+ which::which(executable_name).is_ok()
86+}
87+
88+fn env_var_is_set(env_var_name: &str) -> bool {
89+ std::env::var_os(env_var_name).is_some()
90+}
91+
92+fn is_exit_success(program: &str, args: &[&str]) -> bool {
93+ std::process::Command::new(program)
94+ .args(args)
95+ .output()
96+ .ok()
97+ .and_then(|out| out.status.success().then(|| ())) // TODO: use then_some when stabilized
98+ .is_some()
99+}
100+
101+mod provider {
102+ use super::ClipboardProvider;
103+ use anyhow::{bail, Context as _, Result};
104+ use std::borrow::Cow;
105+
106+ #[derive(Debug)]
107+ pub struct NopProvider;
108+
109+ impl ClipboardProvider for NopProvider {
110+ fn name(&self) -> Cow<str> {
111+ Cow::Borrowed("none")
112+ }
113+
114+ fn get_contents(&self) -> Result<String> {
115+ Ok(String::new())
116+ }
117+
118+ fn set_contents(&self, _: String) -> Result<()> {
119+ Ok(())
120+ }
121+ }
122+
123+ #[derive(Debug)]
124+ pub struct CommandConfig {
125+ pub prg: &'static str,
126+ pub args: &'static [&'static str],
127+ }
128+
129+ impl CommandConfig {
130+ fn execute(&self, input: Option<&str>, pipe_output: bool) -> Result<Option<String>> {
131+ use std::io::Write;
132+ use std::process::{Command, Stdio};
133+
134+ let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null);
135+ let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null);
136+
137+ let mut child = Command::new(self.prg)
138+ .args(self.args)
139+ .stdin(stdin)
140+ .stdout(stdout)
141+ .stderr(Stdio::null())
142+ .spawn()?;
143+
144+ if let Some(input) = input {
145+ let mut stdin = child.stdin.take().context("stdin is missing")?;
146+ stdin
147+ .write_all(input.as_bytes())
148+ .context("couldn't write in stdin")?;
149+ }
150+
151+ // TODO: add timer?
152+ let output = child.wait_with_output()?;
153+
154+ if !output.status.success() {
155+ bail!("clipboard provider {} failed", self.prg);
156+ }
157+
158+ if pipe_output {
159+ Ok(Some(String::from_utf8(output.stdout)?))
160+ } else {
161+ Ok(None)
162+ }
163+ }
164+ }
165+
166+ #[derive(Debug)]
167+ pub struct CommandProvider {
168+ pub get_cmd: CommandConfig,
169+ pub set_cmd: CommandConfig,
170+ }
171+
172+ impl ClipboardProvider for CommandProvider {
173+ fn name(&self) -> Cow<str> {
174+ if self.get_cmd.prg != self.set_cmd.prg {
175+ Cow::Owned(format!("{}+{}", self.get_cmd.prg, self.set_cmd.prg))
176+ } else {
177+ Cow::Borrowed(self.get_cmd.prg)
178+ }
179+ }
180+
181+ fn get_contents(&self) -> Result<String> {
182+ let output = self
183+ .get_cmd
184+ .execute(None, true)?
185+ .context("output is missing")?;
186+ Ok(output)
187+ }
188+
189+ fn set_contents(&self, value: String) -> Result<()> {
190+ self.set_cmd.execute(Some(&value), false).map(|_| ())
191+ }
192+ }
193+}
helix-view/src/editor.rs+5−1
@@ -1,3 +1,4 @@
1+use crate::clipboard::{get_clipboard_provider, ClipboardProvider};
12use crate::{
23 theme::{self, Theme},
34 tree::Tree,
@@ -14,9 +15,10 @@ use slotmap::SlotMap;
1415
1516use anyhow::Error;
1617
18+use helix_core::Position;
19+
1720pub use helix_core::diagnostic::Severity;
1821pub use helix_core::register::Registers;
19−use helix_core::Position;
2022
2123#[derive(Debug)]
2224pub struct Editor {
@@ -27,6 +29,7 @@ pub struct Editor {
2729 pub registers: Registers,
2830 pub theme: Theme,
2931 pub language_servers: helix_lsp::Registry,
32+ pub clipboard_provider: Box<dyn ClipboardProvider>,
3033
3134 pub syn_loader: Arc<syntax::Loader>,
3235 pub theme_loader: Arc<theme::Loader>,
@@ -62,6 +65,7 @@ impl Editor {
6265 syn_loader: config_loader,
6366 theme_loader: themes,
6467 registers: Registers::default(),
68+ clipboard_provider: get_clipboard_provider(),
6569 status_msg: None,
6670 }
6771 }
helix-view/src/lib.rs+1
@@ -1,6 +1,7 @@
11#[macro_use]
22pub mod macros;
33
4+pub mod clipboard;
45pub mod document;
56pub mod editor;
67pub mod register_selection;