diff --git a/CHANGELOG.md b/CHANGELOG.md index c1666869d1..88eb868de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 standard cut/copy/paste (ctrl+x, ctrl+c, ctrl+v) bindings to Input / TextArea https://github.com/Textualize/textual/pull/5352 & https://github.com/Textualize/textual/pull/5374 - 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 - The command palette will now select the top item automatically https://github.com/Textualize/textual/pull/5361 +- `ctrl+shift+k` now deletes the current line in `TextArea`, and `ctrl+x` will cut +the selection if there is one, otherwise it will cut the current line https://github.com/Textualize/textual/pull/5374 - Implemented a better matching algorithm for the command palette https://github.com/Textualize/textual/pull/5365 ### Fixed diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 475f6efb7b..b011d8d8ae 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -226,7 +226,6 @@ class TextArea(ScrollView): Binding( "ctrl+f", "delete_word_right", "Delete right to start of word", 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), @@ -239,9 +238,14 @@ class TextArea(ScrollView): "Delete to line end", show=False, ), + Binding( + "ctrl+shift+k", + "delete_line", + "Delete line", + show=False, + ), Binding("ctrl+z", "undo", "Undo", show=False), Binding("ctrl+y", "redo", "Redo", show=False), - Binding("ctrl+c", "copy_selection", "Copy selected text", show=False), ] """ | Key(s) | Description | @@ -268,13 +272,16 @@ 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+shift+x | Delete the current line. | + | ctrl+shift+k | 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. | | f7 | Select all text in the document. | | ctrl+z | Undo. | | ctrl+y | Redo. | + | ctrl+x | Cut selection or line if no selection. | + | ctrl+c | Copy selection to clipboard. | + | ctrl+v | Paste from clipboard. | """ language: Reactive[str | None] = reactive(None, always_update=True, init=False) @@ -2185,6 +2192,10 @@ def action_delete_right(self) -> None: def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" + self._delete_cursor_line() + + def _delete_cursor_line(self) -> EditResult | None: + """Deletes the line (including the line terminator) that the cursor is on.""" start, end = self.selection start, end = sorted((start, end)) start_row, _start_column = start @@ -2201,6 +2212,7 @@ def action_delete_line(self) -> None: deletion = self._delete_via_keyboard(from_location, to_location) if deletion is not None: self.move_cursor_relative(columns=end_column, record_width=False) + return deletion def action_cut(self) -> None: """Cut text (remove and copy to clipboard).""" @@ -2208,18 +2220,18 @@ def action_cut(self) -> None: return start, end = self.selection if start == end: - return - copy_text = self.get_text_range(start, end) - self.app.copy_to_clipboard(copy_text) - self._delete_via_keyboard(start, end) + edit_result = self._delete_cursor_line() + else: + edit_result = self._delete_via_keyboard(start, end) + + if edit_result is not None: + self.app.copy_to_clipboard(edit_result.replaced_text) 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) + selected_text = self.selected_text + if selected_text: + self.app.copy_to_clipboard(selected_text) def action_paste(self) -> None: """Paste from local clipboard.""" @@ -2310,10 +2322,6 @@ def action_delete_word_right(self) -> None: self._delete_via_keyboard(end, to_location) - def action_copy_selection(self) -> None: - """Copy the current selection to the clipboard.""" - self.app.copy_to_clipboard(self.selected_text) - @lru_cache(maxsize=128) def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py index 4724b74ea8..f6b3f013f4 100644 --- a/tests/text_area/test_edit_via_bindings.py +++ b/tests/text_area/test_edit_via_bindings.py @@ -167,6 +167,57 @@ async def test_delete_right_end_of_line(): assert text_area.text == "helloworld!" +@pytest.mark.parametrize( + "selection,expected_result,expected_clipboard,cursor_end_location", + [ + (Selection.cursor((0, 0)), "", "0123456789", (0, 0)), + (Selection.cursor((0, 4)), "", "0123456789", (0, 0)), + (Selection.cursor((0, 10)), "", "0123456789", (0, 0)), + (Selection((0, 2), (0, 4)), "01456789", "23", (0, 2)), + (Selection((0, 4), (0, 2)), "01456789", "23", (0, 2)), + ], +) +async def test_cut(selection, expected_result, expected_clipboard, cursor_end_location): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+x") + + assert text_area.selection == Selection.cursor(cursor_end_location) + assert text_area.text == expected_result + assert app.clipboard == expected_clipboard + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), "345\n678\n9\n"), + (Selection.cursor((0, 2)), "345\n678\n9\n"), + (Selection.cursor((3, 1)), "012\n345\n678\n"), + (Selection.cursor((4, 0)), "012\n345\n678\n9\n"), + # Selections + (Selection((1, 1), (1, 2)), "012\n35\n678\n9\n"), + (Selection((1, 2), (2, 1)), "012\n3478\n9\n"), + ], +) +async def test_cut_multiline_document(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("012\n345\n678\n9\n") + text_area.selection = selection + + await pilot.press("ctrl+x") + + cursor_row, cursor_column = text_area.cursor_location + assert text_area.selection == Selection.cursor((cursor_row, cursor_column)) + assert text_area.text == expected_result + + @pytest.mark.parametrize( "selection,expected_result", [ @@ -184,7 +235,7 @@ async def test_delete_line(selection, expected_result): text_area.load_text("0123456789") text_area.selection = selection - await pilot.press("ctrl+shift+x") + await pilot.press("ctrl+shift+k") assert text_area.selection == Selection.cursor((0, 0)) assert text_area.text == expected_result @@ -219,7 +270,7 @@ async def test_delete_line_multiline_document(selection, expected_result): text_area.load_text("012\n345\n678\n9\n") text_area.selection = selection - await pilot.press("ctrl+shift+x") + await pilot.press("ctrl+shift+k") cursor_row, cursor_column = text_area.cursor_location assert text_area.selection == Selection.cursor((cursor_row, cursor_column))