Repository: xonsh/xonsh

Issue:
Deleting a word copies that word to the clipboard (or crashes trying)
## xonfig

<details>

```
$ xonfig
+------------------+----------------------+
| xonsh            | 0.9.27               |
| Git SHA          | 71fe9014             |
| Commit Date      | Jan 29 08:58:58 2021 |
| Python           | 3.9.2                |
| PLY              | 3.11                 |
| have readline    | True                 |
| prompt toolkit   | 3.0.18               |
| shell type       | prompt_toolkit       |
| pygments         | None                 |
| on posix         | True                 |
| on linux         | False                |
| on darwin        | True                 |
| on windows       | False                |
| on cygwin        | False                |
| on msys2         | False                |
| is superuser     | False                |
| default encoding | utf-8                |
| xonsh encoding   | utf-8                |
| encoding errors  | surrogateescape      |
| on jupyter       | False                |
| jupyter kernel   | None                 |
| xontrib 1        | vox                  |
| xontrib 2        | voxapi               |
+------------------+----------------------+
```

</details>

## Expected Behavior
Deleting a word shouldn't mutate the clipboard.

## Current Behavior
Hitting Option+Delete deletes the last word, but also copies it to the clipboard. When a pyperclip clipboard isn't available, causes a traceback. See prompt-toolkit/python-prompt-toolkit#1412 for more details on the crash.


## Steps to Reproduce
Install latest `xonsh[ptk]` on macOS. Type a word `foo` into the shell. Hit Option+Delete (I'm unsure the equivalent key binding for other platforms). The word is now gone. Now hit Cmd+V and `foo` appears on the terminal. Whatever was on the clipboard before is now lost.

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**



Discussion:
Looks like #3989 may have introduced this behavior.
@jnoortheen please take a look.
@jaraco please post the error when pyperclip is not installed. I think we have the following options 
1. make pyperclip integration configurable via a variable
2. disable copy on deleting word and other such kill-ring feature implemented by ptk via a variable. 
> @jaraco please post the error when pyperclip is not installed.

Sorry I wasn't clear. Pyperclip is installed - there's just no viable clipboard.

I see now the issue may be that the reason pyperclip is installed is because I used `pipx inject xonsh jaraco.clipboard`. I do that because my .xonshrc imports `jaraco.clipboard`.

I think a suitable workaround to suppress the crash is to uninstall `jaraco.clipboard` in environments that don't have a clipboard. Ideally, however, the clipboard support would be robust to having `pyperclip` installed but non-viable.

That still leaves the issue of environments where the clipboard is viable but the mutating of the clipboard is undesirable.



>     2\. disable copy on deleting word and other such kill-ring feature implemented by ptk via a variable.

I contend this behavior should be opt-in and not opt-out. Though I guess it's arguable that the presence of `pyperclip` is something of an opt-in. In my case, I'm installing pyperclip not to opt in to this functionality, but for other purposes.
We can make the Clipboard class passed to ptk as configurable to solve this. One can also implement their own interface and pass in this way. @jaraco are you interested in opening a PR?
Yes, perhaps. Do you have any more information about this "kill-ring feature"?
They are activated by the delete sequence shortcuts here https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/key_binding/bindings/emacs.py
Hi!
The 'copy-on-delete' thing is REALLY annoying and I agree it should be opt-in.
The code responsible is actually in https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/key_binding/bindings/named_commands.py in the functions `kill_line`, `kill_word`, `unix_word_rubout` and `unix_line_discard`.
All of them run `event.app.clipboard.set_text(deleted)`. So as I see it we have two options:

1. Create a PR in `prompt_toolkit` to make this optional somehow
2. Meanwhile, we can implement a Clipboard that actively checks if we're in any of these functions, and if so ignores the `set_text` invocation. This is super hacky but is probably a good solution until we introduce the change inside prompt_toolkit
Hey @jaraco do you intend to make a PR for this issue?
If not I can happily do so :)
Hey Daniel. I'd probably eventually get to it, but I'm more than happy for someone else to work on it. Go for it! Let me know if I can help.

Resolving patch:
diff --git a/xonsh/environ.py b/xonsh/environ.py
--- a/xonsh/environ.py
+++ b/xonsh/environ.py
@@ -1508,6 +1508,11 @@ class PTKSetting(PromptSetting):  # sub-classing -> sub-group
         "Whether Xonsh will auto-insert matching parentheses, brackets, and "
         "quotes. Only available under the prompt-toolkit shell.",
     )
+    XONSH_COPY_ON_DELETE = Var.with_default(
+        False,
+        "Whether to copy words/lines to clipboard on deletion (must be set in .xonshrc file)."
+        "Only available under the prompt-toolkit shell.",
+    )
 
 
 class AsyncPromptSetting(PTKSetting):
diff --git a/xonsh/ptk_shell/shell.py b/xonsh/ptk_shell/shell.py
--- a/xonsh/ptk_shell/shell.py
+++ b/xonsh/ptk_shell/shell.py
@@ -4,6 +4,7 @@
 import re
 import sys
 import builtins
+from functools import wraps
 from types import MethodType
 
 from xonsh.events import events
@@ -27,6 +28,7 @@
     load_emacs_shift_selection_bindings,
 )
 from prompt_toolkit.key_binding.key_bindings import merge_key_bindings
+from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
 from prompt_toolkit.history import ThreadedHistory
 from prompt_toolkit.shortcuts import print_formatted_text as ptk_print
 from prompt_toolkit.shortcuts import CompleteStyle
@@ -36,6 +38,7 @@
 from prompt_toolkit.styles.pygments import pygments_token_to_classname
 
 try:
+    from prompt_toolkit.clipboard import DummyClipboard
     from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
 
     HAVE_SYS_CLIPBOARD = True
@@ -136,6 +139,47 @@ def _style_from_pygments_cls(pygments_cls):
     return _style_from_pygments_dict(pygments_cls.styles)
 
 
+def disable_copy_on_deletion():
+    dummy_clipboard = DummyClipboard()
+    ignored_actions = [
+        "kill-line",
+        "kill-word",
+        "unix-word-rubout",
+        "unix-line-discard",
+        "backward-kill-word",
+    ]
+
+    def handle_binding(name):
+        try:
+            binding = get_by_name(name)
+        except KeyError:
+            print_warning(f"Failed to disable clipboard for ptk action {name!r}")
+            return
+
+        if getattr(binding, "xonsh_disabled_clipboard", False):
+            # binding's clipboard has already been disabled
+            return
+
+        setattr(binding, "xonsh_disabled_clipboard", True)
+        original_handler = binding.handler
+
+        # this needs to be defined inside a function so that ``binding`` will be the correct one
+        @wraps(original_handler)
+        def wrapped_handler(event):
+            app = event.app
+            prev = app.clipboard
+            app.clipboard = dummy_clipboard
+            try:
+                return original_handler(event)
+            finally:
+                app.clipboard = prev
+
+        binding.handler = wrapped_handler
+
+    for _name in ignored_actions:
+        handle_binding(_name)
+
+
 class PromptToolkitShell(BaseShell):
     """The xonsh shell for prompt_toolkit v2 and later."""
 
@@ -154,6 +198,8 @@ def __init__(self, **kwargs):
         self.history = ThreadedHistory(PromptToolkitHistory())
 
         ptk_args = {"history": self.history}
+        if not builtins.__xonsh__.env.get("XONSH_COPY_ON_DELETE", False):
+            disable_copy_on_deletion()
         if HAVE_SYS_CLIPBOARD:
             ptk_args["clipboard"] = PyperclipClipboard()
         self.prompter = PromptSession(**ptk_args)


Test patch: