Skip to content

Commit 300074d

Browse files
authored
Fix a TextArea crash (#4126)
* Fix crash with backwards selection where content is replaced with fewer lines of text * Ensure correct cursor positioning after paste * Improving tests * Update CHANGELOG * Add missing docstrings
1 parent e27c41c commit 300074d

File tree

3 files changed

+76
-13
lines changed

3 files changed

+76
-13
lines changed

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Fixed a crash in the TextArea when performing a backward replace https://github.com/Textualize/textual/pull/4126
13+
- Fixed selection not updating correctly when pasting while there's a non-zero selection https://github.com/Textualize/textual/pull/4126
14+
- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110
15+
1016
### Added
1117

1218
- Added DOMQuery.set https://github.com/Textualize/textual/pull/4075
@@ -15,10 +21,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1521
- Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075
1622
- Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075
1723

18-
### Fixed
19-
20-
- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110
21-
2224
## [0.48.2] - 2024-02-02
2325

2426
### Fixed

src/textual/widgets/_text_area.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,6 @@ def render_line(self, widget_y: int) -> Strip:
958958
# Get the line from the Document.
959959
line_string = document.get_line(line_index)
960960
line = Text(line_string, end="")
961-
962961
line_character_count = len(line)
963962
line.tab_size = self.indent_width
964963
line.set_length(line_character_count + 1) # space at end for cursor
@@ -1193,8 +1192,8 @@ def edit(self, edit: Edit) -> EditResult:
11931192
self.wrapped_document.wrap(self.wrap_width, self.indent_width)
11941193
else:
11951194
self.wrapped_document.wrap_range(
1196-
edit.from_location,
1197-
edit.to_location,
1195+
edit.top,
1196+
edit.bottom,
11981197
result.end_location,
11991198
)
12001199

@@ -1341,7 +1340,8 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None:
13411340

13421341
async def _on_paste(self, event: events.Paste) -> None:
13431342
"""When a paste occurs, insert the text from the paste event into the document."""
1344-
self.replace(event.text, *self.selection)
1343+
result = self.replace(event.text, *self.selection)
1344+
self.move_cursor(result.end_location)
13451345

13461346
def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int:
13471347
"""Return the column that the cell width corresponds to on the given row.
@@ -1814,8 +1814,7 @@ def delete(
18141814
Returns:
18151815
An `EditResult` containing information about the edit.
18161816
"""
1817-
top, bottom = sorted((start, end))
1818-
return self.edit(Edit("", top, bottom, maintain_selection_offset))
1817+
return self.edit(Edit("", start, end, maintain_selection_offset))
18191818

18201819
def replace(
18211820
self,
@@ -1989,14 +1988,13 @@ def do(self, text_area: TextArea) -> EditResult:
19891988
# position in the document even if an insert happens before
19901989
# their cursor position.
19911990

1992-
edit_top, edit_bottom = sorted((edit_from, edit_to))
1993-
edit_bottom_row, edit_bottom_column = edit_bottom
1991+
edit_bottom_row, edit_bottom_column = self.bottom
19941992

19951993
selection_start, selection_end = text_area.selection
19961994
selection_start_row, selection_start_column = selection_start
19971995
selection_end_row, selection_end_column = selection_end
19981996

1999-
replace_result = text_area.document.replace_range(edit_from, edit_to, text)
1997+
replace_result = text_area.document.replace_range(self.top, self.bottom, text)
20001998

20011999
new_edit_to_row, new_edit_to_column = replace_result.end_location
20022000

@@ -2051,6 +2049,16 @@ def after(self, text_area: TextArea) -> None:
20512049
text_area.selection = self._updated_selection
20522050
text_area.record_cursor_width()
20532051

2052+
@property
2053+
def top(self) -> Location:
2054+
"""The Location impacted by this edit that is nearest the start of the document."""
2055+
return min([self.from_location, self.to_location])
2056+
2057+
@property
2058+
def bottom(self) -> Location:
2059+
"""The Location impacted by this edit that is nearest the end of the document."""
2060+
return max([self.from_location, self.to_location])
2061+
20542062

20552063
@runtime_checkable
20562064
class Undoable(Protocol):

tests/text_area/test_edit_via_bindings.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
77
Note that more extensive testing for editing is done at the Document level.
88
"""
9+
910
import pytest
1011

1112
from textual.app import App, ComposeResult
13+
from textual.events import Paste
1214
from textual.widgets import TextArea
1315
from textual.widgets.text_area import Selection
1416

@@ -416,3 +418,54 @@ async def test_delete_word_right_at_end_of_line():
416418

417419
assert text_area.text == "0123456789"
418420
assert text_area.selection == Selection.cursor((0, 5))
421+
422+
423+
@pytest.mark.parametrize(
424+
"selection",
425+
[
426+
Selection(start=(1, 0), end=(3, 0)),
427+
Selection(start=(3, 0), end=(1, 0)),
428+
],
429+
)
430+
async def test_replace_lines_with_fewer_lines(selection):
431+
app = TextAreaApp()
432+
async with app.run_test() as pilot:
433+
text_area = app.query_one(TextArea)
434+
text_area.text = SIMPLE_TEXT
435+
text_area.selection = selection
436+
437+
await pilot.press("a")
438+
439+
expected_text = """\
440+
ABCDE
441+
aPQRST
442+
UVWXY
443+
Z"""
444+
assert text_area.text == expected_text
445+
assert text_area.selection == Selection.cursor((1, 1))
446+
447+
448+
@pytest.mark.parametrize(
449+
"selection",
450+
[
451+
Selection(start=(1, 0), end=(3, 0)),
452+
Selection(start=(3, 0), end=(1, 0)),
453+
],
454+
)
455+
async def test_paste(selection):
456+
app = TextAreaApp()
457+
async with app.run_test() as pilot:
458+
text_area = app.query_one(TextArea)
459+
text_area.text = SIMPLE_TEXT
460+
text_area.selection = selection
461+
462+
app.post_message(Paste("a"))
463+
await pilot.pause()
464+
465+
expected_text = """\
466+
ABCDE
467+
aPQRST
468+
UVWXY
469+
Z"""
470+
assert text_area.text == expected_text
471+
assert text_area.selection == Selection.cursor((1, 1))

0 commit comments

Comments
 (0)