Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clipboard keys #5352

Merged
merged 15 commits into from
Dec 6, 2024
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added `App.clipboard` https://github.com/Textualize/textual/pull/5352
- Added standard cut/copy/paste (ctrl+x, ctrl+c, ctrl+v) bindings to Input / TextArea https://github.com/Textualize/textual/pull/5352
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352

### Changed

- Change default quit key to `ctrl+q` https://github.com/Textualize/textual/pull/5352
- Changed delete line binding on TextArea to use `ctrl+shift+x` https://github.com/Textualize/textual/pull/5352

## [0.89.1] - 2024-11-05

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/blog/posts/inline-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ You can see this in action if you run the [calculator example](https://github.co

The application appears directly under the prompt, rather than occupying the full height of the screen—which is more typical of TUI applications.
You can interact with this calculator using keys *or* the mouse.
When you press ++ctrl+c++ the calculator disappears and returns you to the prompt.
When you press ++ctrl+q++ the calculator disappears and returns you to the prompt.

Here's another app that creates an inline code editor:

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ If we run this app with `python simple02.py` you will see a blank terminal, some

When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*).

If you hit ++ctrl+c++ Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored.
If you hit ++ctrl+q++ Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored.

!!! tip

Expand Down
6 changes: 2 additions & 4 deletions docs/guide/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,11 @@ The tuple of three strings may be enough for simple bindings, but you can also r

Individual bindings may be marked as a *priority*, which means they will be checked prior to the bindings of the focused widget. This feature is often used to create hot-keys on the app or screen. Such bindings can not be disabled by binding the same key on a widget.

You can create priority key bindings by setting `priority=True` on the Binding object. Textual uses this feature to add a default binding for ++ctrl+c++ so there is always a way to exit the app. Here's the bindings from the App base class. Note the first binding is set as a priority:
You can create priority key bindings by setting `priority=True` on the Binding object. Textual uses this feature to add a default binding for ++ctrl+q++ so there is always a way to exit the app. Here's the `BINDINGS` from the App base class. Note the quit binding is set as a priority:

```python
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
Binding("tab", "focus_next", "Focus Next", show=False),
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
Binding("ctrl+q", "quit", "Quit", show=False, priority=True)
]
```

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Hit the ++d++ key to toggle between light and dark themes.
```{.textual path="docs/examples/tutorial/stopwatch01.py" press="d" title="stopwatch01.py"}
```

Hit ++ctrl+c++ to exit the app and return to the command prompt.
Hit ++ctrl+q++ to exit the app and return to the command prompt.

### A closer look at the App class

Expand Down Expand Up @@ -157,7 +157,7 @@ Here's what the above app defines:
--8<-- "docs/examples/tutorial/stopwatch01.py"
```

The final three lines create an instance of the app and calls the [run()][textual.app.App.run] method which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+c++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project.
The final three lines create an instance of the app and calls the [run()][textual.app.App.run] method which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+q++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project.

## Designing a UI with widgets

Expand Down
37 changes: 36 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,15 @@ class MyApp(App[None]):
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""

BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True)
Binding(
"ctrl+q",
"quit",
"Quit",
tooltip="Quit the app and return to the command prompt.",
show=False,
priority=True,
),
Binding("ctrl+c", "help_quit", show=False, system=True),
]
"""The default key bindings."""

Expand Down Expand Up @@ -767,6 +775,9 @@ def __init__(
self._css_update_count: int = 0
"""Incremented when CSS is invalidated."""

self._clipboard: str = ""
"""Contents of local clipboard."""

if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
if binding.action in {"command_palette", "app.command_palette"}:
Expand Down Expand Up @@ -866,6 +877,15 @@ def children(self) -> Sequence["Widget"]:
except StopIteration:
return ()

@property
def clipboard(self) -> str:
"""The value of the local clipboard.

Note, that this only contains text copied in the app, and not
text copied from elsewhere in the OS.
"""
return self._clipboard

@contextmanager
def batch_update(self) -> Generator[None, None, None]:
"""A context manager to suspend all repaints until the end of the batch."""
Expand Down Expand Up @@ -1497,6 +1517,7 @@ def copy_to_clipboard(self, text: str) -> None:
Args:
text: Text you wish to copy to the clipboard.
"""
self._clipboard = text
if self._driver is None:
return

Expand Down Expand Up @@ -3605,6 +3626,20 @@ async def _check_bindings(self, key: str, priority: bool = False) -> bool:
return True
return False

def action_help_quit(self) -> None:
"""Bound to ctrl+C to alert the user that it no longer quits."""
# Doing this because users will reflexively hit ctrl+C to exit
# Ctrl+C is now bound to copy if an input / textarea is focused.
# This makes is possible, even likely, that a user may do it accidentally -- which would be maddening.
# Rather than do nothing, we can make an educated guess the user was trying
# to quit, and inform them how you really quit.
for key, active_binding in self.active_bindings.items():
if active_binding.binding.action in ("quit", "app.quit"):
self.notify(
f"Press [b]{key}[/b] to quit the app", title="Do you want to quit?"
)
return

def set_keymap(self, keymap: Keymap) -> None:
"""Set the keymap, a mapping of binding IDs to key strings.

Expand Down
3 changes: 3 additions & 0 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ class Binding:
If specified in the App's keymap then Textual will use this ID to lookup the binding,
and substitute the `key` property of the Binding with the key specified in the keymap.
"""
system: bool = False
"""Make this binding a system binding, which removes it from the key panel."""

def parse_key(self) -> tuple[list[str], str]:
"""Parse a key in to a list of modifiers, and the actual key.
Expand Down Expand Up @@ -148,6 +150,7 @@ def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
priority=binding.priority,
tooltip=binding.tooltip,
id=binding.id,
system=binding.system,
)


Expand Down
17 changes: 15 additions & 2 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ class Input(ScrollView):
"ctrl+f", "delete_right_word", "Delete right to start of word", show=False
),
Binding("ctrl+k", "delete_right_all", "Delete all to the right", show=False),
Binding("ctrl+c", "copy_selection", "Copy selected text", show=False),
Binding("ctrl+x", "cut", "Cut selected text", show=False),
Binding("ctrl+c", "copy", "Copy selected text", show=False),
Binding("ctrl+v", "paste", "Paste text from the clipboard", show=False),
]
"""
| Key(s) | Description |
Expand Down Expand Up @@ -995,6 +997,17 @@ async def action_submit(self) -> None:
)
self.post_message(self.Submitted(self, self.value, validation_result))

def action_copy_selection(self) -> None:
def action_cut(self) -> None:
"""Cut the current selection (copy to clipboard and remove from input)."""
self.app.copy_to_clipboard(self.selected_text)
self.delete_selection()

def action_copy(self) -> None:
"""Copy the current selection to the clipboard."""
self.app.copy_to_clipboard(self.selected_text)

def action_paste(self) -> None:
"""Paste from the local clipboard."""
clipboard = self.app._clipboard
start, end = sorted(self.selection)
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
self.replace(clipboard, start, end)
5 changes: 4 additions & 1 deletion src/textual/widgets/_key_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ def render_bindings_table(self) -> Table:
action_to_bindings: defaultdict[str, list[tuple[Binding, bool, str]]]
action_to_bindings = defaultdict(list)
for _, binding, enabled, tooltip in table_bindings:
action_to_bindings[binding.action].append((binding, enabled, tooltip))
if not binding.system:
action_to_bindings[binding.action].append(
(binding, enabled, tooltip)
)

description_style = self.get_component_rich_style(
"bindings-table--description"
Expand Down
34 changes: 32 additions & 2 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ class TextArea(ScrollView):
Binding(
"ctrl+f", "delete_word_right", "Delete right to start of word", show=False
),
Binding("ctrl+x", "delete_line", "Delete line", show=False),
Binding("ctrl+shift+x", "delete_line", "Delete line", show=False),
Binding("ctrl+x", "cut", "Cut", show=False),
Binding("ctrl+c", "copy", "Copy", show=False),
Binding("ctrl+v", "paste", "Paste", show=False),
Binding(
"ctrl+u", "delete_to_start_of_line", "Delete to line start", show=False
),
Expand Down Expand Up @@ -265,7 +268,7 @@ class TextArea(ScrollView):
| ctrl+w | Delete from cursor to start of the word. |
| delete,ctrl+d | Delete character to the right of cursor. |
| ctrl+f | Delete from cursor to end of the word. |
| ctrl+x | Delete the current line. |
| ctrl+shift+x | Delete the current line. |
| ctrl+u | Delete from cursor to the start of the line. |
| ctrl+k | Delete from cursor to the end of the line. |
| f6 | Select the current line. |
Expand Down Expand Up @@ -2199,6 +2202,33 @@ def action_delete_line(self) -> None:
if deletion is not None:
self.move_cursor_relative(columns=end_column, record_width=False)

def action_cut(self) -> None:
"""Cut text (remove and copy to clipboard)."""
if self.read_only:
return
start, end = self.selection
if start == end:
return
copy_text = self.get_text_range(start, end)
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
self.app.copy_to_clipboard(copy_text)
self._delete_via_keyboard(start, end)

def action_copy(self) -> None:
"""Copy selection to clipboard."""
start, end = self.selection
if start == end:
return
copy_text = self.get_text_range(start, end)
self.app.copy_to_clipboard(copy_text)

def action_paste(self) -> None:
"""Paste from local clipboard."""
if self.read_only:
return
clipboard = self.app.clipboard
if result := self._replace_via_keyboard(clipboard, *self.selection):
self.move_cursor(result.end_location)

def action_delete_to_start_of_line(self) -> None:
"""Deletes from the cursor location to the start of the line."""
from_location = self.selection.end
Expand Down
52 changes: 52 additions & 0 deletions tests/input/test_cut_copy_paste.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from textual.app import App, ComposeResult
from textual.widgets import Input


class InputApp(App):
def compose(self) -> ComposeResult:
yield Input()


async def test_cut():
"""Check that cut removes text and places it in the clipboard."""
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
await pilot.click(input)
await pilot.press(*"Hello, World")
await pilot.press("left", "shift+left", "shift+left")
await pilot.press("ctrl+x")
assert input.value == "Hello, Wod"
assert app.clipboard == "rl"


async def test_copy():
"""Check that copy places text in the clipboard."""
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
await pilot.click(input)
await pilot.press(*"Hello, World")
await pilot.press("left", "shift+left", "shift+left")
await pilot.press("ctrl+c")
assert input.value == "Hello, World"
assert app.clipboard == "rl"


async def test_paste():
"""Check that paste copies text from the local clipboard."""
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
await pilot.click(input)
await pilot.press(*"Hello, World")
await pilot.press(
"shift+left", "shift+left", "shift+left", "shift+left", "shift+left"
)
await pilot.press("ctrl+c")
assert input.value == "Hello, World"
assert app.clipboard == "World"
await pilot.press("ctrl+v")
assert input.value == "Hello, World"
await pilot.press("ctrl+v")
assert input.value == "Hello, WorldWorld"
Loading
Loading