Skip to content

Commit ebdd6ef

Browse files
authored
Merge pull request #5352 from Textualize/clipboard
Clipboard keys
2 parents 146eab0 + 8a09562 commit ebdd6ef

25 files changed

+828
-628
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Added `App.clipboard` https://github.com/Textualize/textual/pull/5352
13+
- Added standard cut/copy/paste (ctrl+x, ctrl+c, ctrl+v) bindings to Input / TextArea https://github.com/Textualize/textual/pull/5352
14+
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352
15+
16+
### Changed
17+
18+
- Change default quit key to `ctrl+q` https://github.com/Textualize/textual/pull/5352
19+
- Changed delete line binding on TextArea to use `ctrl+shift+x` https://github.com/Textualize/textual/pull/5352
20+
821
## [0.89.1] - 2024-11-05
922

1023
### Fixed

docs/blog/posts/inline-mode.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ You can see this in action if you run the [calculator example](https://github.co
1616

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

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

docs/guide/app.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ If we run this app with `python simple02.py` you will see a blank terminal, some
3232

3333
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*).
3434

35-
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.
35+
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.
3636

3737
!!! tip
3838

docs/guide/input.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,11 @@ The tuple of three strings may be enough for simple bindings, but you can also r
172172

173173
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.
174174

175-
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:
175+
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:
176176

177177
```python
178178
BINDINGS = [
179-
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
180-
Binding("tab", "focus_next", "Focus Next", show=False),
181-
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
179+
Binding("ctrl+q", "quit", "Quit", show=False, priority=True)
182180
]
183181
```
184182

docs/tutorial.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Hit the ++d++ key to toggle between light and dark themes.
122122
```{.textual path="docs/examples/tutorial/stopwatch01.py" press="d" title="stopwatch01.py"}
123123
```
124124

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

127127
### A closer look at the App class
128128

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

160-
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.
160+
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.
161161

162162
## Designing a UI with widgets
163163

src/textual/app.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,15 @@ class MyApp(App[None]):
438438
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""
439439

440440
BINDINGS: ClassVar[list[BindingType]] = [
441-
Binding("ctrl+c", "quit", "Quit", show=False, priority=True)
441+
Binding(
442+
"ctrl+q",
443+
"quit",
444+
"Quit",
445+
tooltip="Quit the app and return to the command prompt.",
446+
show=False,
447+
priority=True,
448+
),
449+
Binding("ctrl+c", "help_quit", show=False, system=True),
442450
]
443451
"""The default key bindings."""
444452

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

778+
self._clipboard: str = ""
779+
"""Contents of local clipboard."""
780+
770781
if self.ENABLE_COMMAND_PALETTE:
771782
for _key, binding in self._bindings:
772783
if binding.action in {"command_palette", "app.command_palette"}:
@@ -866,6 +877,15 @@ def children(self) -> Sequence["Widget"]:
866877
except StopIteration:
867878
return ()
868879

880+
@property
881+
def clipboard(self) -> str:
882+
"""The value of the local clipboard.
883+
884+
Note, that this only contains text copied in the app, and not
885+
text copied from elsewhere in the OS.
886+
"""
887+
return self._clipboard
888+
869889
@contextmanager
870890
def batch_update(self) -> Generator[None, None, None]:
871891
"""A context manager to suspend all repaints until the end of the batch."""
@@ -1497,6 +1517,7 @@ def copy_to_clipboard(self, text: str) -> None:
14971517
Args:
14981518
text: Text you wish to copy to the clipboard.
14991519
"""
1520+
self._clipboard = text
15001521
if self._driver is None:
15011522
return
15021523

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

3629+
def action_help_quit(self) -> None:
3630+
"""Bound to ctrl+C to alert the user that it no longer quits."""
3631+
# Doing this because users will reflexively hit ctrl+C to exit
3632+
# Ctrl+C is now bound to copy if an input / textarea is focused.
3633+
# This makes is possible, even likely, that a user may do it accidentally -- which would be maddening.
3634+
# Rather than do nothing, we can make an educated guess the user was trying
3635+
# to quit, and inform them how you really quit.
3636+
for key, active_binding in self.active_bindings.items():
3637+
if active_binding.binding.action in ("quit", "app.quit"):
3638+
self.notify(
3639+
f"Press [b]{key}[/b] to quit the app", title="Do you want to quit?"
3640+
)
3641+
return
3642+
36083643
def set_keymap(self, keymap: Keymap) -> None:
36093644
"""Set the keymap, a mapping of binding IDs to key strings.
36103645

src/textual/binding.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ class Binding:
8181
If specified in the App's keymap then Textual will use this ID to lookup the binding,
8282
and substitute the `key` property of the Binding with the key specified in the keymap.
8383
"""
84+
system: bool = False
85+
"""Make this binding a system binding, which removes it from the key panel."""
8486

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

153156

src/textual/widgets/_input.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ class Input(ScrollView):
115115
"ctrl+f", "delete_right_word", "Delete right to start of word", show=False
116116
),
117117
Binding("ctrl+k", "delete_right_all", "Delete all to the right", show=False),
118-
Binding("ctrl+c", "copy_selection", "Copy selected text", show=False),
118+
Binding("ctrl+x", "cut", "Cut selected text", show=False),
119+
Binding("ctrl+c", "copy", "Copy selected text", show=False),
120+
Binding("ctrl+v", "paste", "Paste text from the clipboard", show=False),
119121
]
120122
"""
121123
| Key(s) | Description |
@@ -995,6 +997,17 @@ async def action_submit(self) -> None:
995997
)
996998
self.post_message(self.Submitted(self, self.value, validation_result))
997999

998-
def action_copy_selection(self) -> None:
1000+
def action_cut(self) -> None:
1001+
"""Cut the current selection (copy to clipboard and remove from input)."""
1002+
self.app.copy_to_clipboard(self.selected_text)
1003+
self.delete_selection()
1004+
1005+
def action_copy(self) -> None:
9991006
"""Copy the current selection to the clipboard."""
10001007
self.app.copy_to_clipboard(self.selected_text)
1008+
1009+
def action_paste(self) -> None:
1010+
"""Paste from the local clipboard."""
1011+
clipboard = self.app._clipboard
1012+
start, end = self.selection
1013+
self.replace(clipboard, start, end)

src/textual/widgets/_key_panel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ def render_bindings_table(self) -> Table:
7171
action_to_bindings: defaultdict[str, list[tuple[Binding, bool, str]]]
7272
action_to_bindings = defaultdict(list)
7373
for _, binding, enabled, tooltip in table_bindings:
74-
action_to_bindings[binding.action].append((binding, enabled, tooltip))
74+
if not binding.system:
75+
action_to_bindings[binding.action].append(
76+
(binding, enabled, tooltip)
77+
)
7578

7679
description_style = self.get_component_rich_style(
7780
"bindings-table--description"

src/textual/widgets/_text_area.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,10 @@ class TextArea(ScrollView):
226226
Binding(
227227
"ctrl+f", "delete_word_right", "Delete right to start of word", show=False
228228
),
229-
Binding("ctrl+x", "delete_line", "Delete line", show=False),
229+
Binding("ctrl+shift+x", "delete_line", "Delete line", show=False),
230+
Binding("ctrl+x", "cut", "Cut", show=False),
231+
Binding("ctrl+c", "copy", "Copy", show=False),
232+
Binding("ctrl+v", "paste", "Paste", show=False),
230233
Binding(
231234
"ctrl+u", "delete_to_start_of_line", "Delete to line start", show=False
232235
),
@@ -265,7 +268,7 @@ class TextArea(ScrollView):
265268
| ctrl+w | Delete from cursor to start of the word. |
266269
| delete,ctrl+d | Delete character to the right of cursor. |
267270
| ctrl+f | Delete from cursor to end of the word. |
268-
| ctrl+x | Delete the current line. |
271+
| ctrl+shift+x | Delete the current line. |
269272
| ctrl+u | Delete from cursor to the start of the line. |
270273
| ctrl+k | Delete from cursor to the end of the line. |
271274
| f6 | Select the current line. |
@@ -2199,6 +2202,33 @@ def action_delete_line(self) -> None:
21992202
if deletion is not None:
22002203
self.move_cursor_relative(columns=end_column, record_width=False)
22012204

2205+
def action_cut(self) -> None:
2206+
"""Cut text (remove and copy to clipboard)."""
2207+
if self.read_only:
2208+
return
2209+
start, end = self.selection
2210+
if start == end:
2211+
return
2212+
copy_text = self.get_text_range(start, end)
2213+
self.app.copy_to_clipboard(copy_text)
2214+
self._delete_via_keyboard(start, end)
2215+
2216+
def action_copy(self) -> None:
2217+
"""Copy selection to clipboard."""
2218+
start, end = self.selection
2219+
if start == end:
2220+
return
2221+
copy_text = self.get_text_range(start, end)
2222+
self.app.copy_to_clipboard(copy_text)
2223+
2224+
def action_paste(self) -> None:
2225+
"""Paste from local clipboard."""
2226+
if self.read_only:
2227+
return
2228+
clipboard = self.app.clipboard
2229+
if result := self._replace_via_keyboard(clipboard, *self.selection):
2230+
self.move_cursor(result.end_location)
2231+
22022232
def action_delete_to_start_of_line(self) -> None:
22032233
"""Deletes from the cursor location to the start of the line."""
22042234
from_location = self.selection.end

0 commit comments

Comments
 (0)