diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fc6e7f18..65a7bb27e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- Fixed `TextArea` scrollbar position not updated after paste https://github.com/Textualize/textual/issues/4852 - Fixed `TextArea` cursor display on wrapped lines https://github.com/Textualize/textual/pull/6196 - Fixed `remove_children` not refreshing layout https://github.com/Textualize/textual/pull/6206 - Fixed flicker with :hover pseudo class https://github.com/Textualize/textual/pull/6214 diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 5ef4dc9066..227d617947 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1850,6 +1850,7 @@ async def _on_paste(self, event: events.Paste) -> None: return if result := self._replace_via_keyboard(event.text, *self.selection): self.move_cursor(result.end_location) + self.scroll_cursor_visible() self.focus() def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: @@ -2530,6 +2531,7 @@ def action_paste(self) -> None: clipboard = self.app.clipboard if result := self._replace_via_keyboard(clipboard, *self.selection): self.move_cursor(result.end_location) + self.scroll_cursor_visible() def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" diff --git a/tests/text_area/test_textarea_cut_copy_paste.py b/tests/text_area/test_textarea_cut_copy_paste.py index 3922eb41e2..b2a0f37154 100644 --- a/tests/text_area/test_textarea_cut_copy_paste.py +++ b/tests/text_area/test_textarea_cut_copy_paste.py @@ -50,3 +50,116 @@ async def test_paste(): assert text_area.text == "Hello, World" await pilot.press("ctrl+v") assert text_area.text == "Hello, WorldWorld" + + +async def test_paste_scrollbar_position_updated(): + """Regression test for issue #4852: scrollbar position should be updated after paste. + + When pasting text that extends beyond the visible area, the scrollbar should + automatically scroll to show the cursor position after the pasted text. + """ + app = TextAreaApp() + async with app.run_test(size=(40, 10)) as pilot: + text_area = app.query_one(TextArea) + await pilot.click(text_area) + + # Create long text that will require scrolling + long_text = "\n".join(f"Line {i}" for i in range(50)) + app.copy_to_clipboard(long_text) + + # Get initial scroll position + initial_scroll_x, initial_scroll_y = text_area.scroll_offset + + # Paste the text + await pilot.press("ctrl+v") + + # Wait for the paste to complete and scrollbar to update + await pilot.pause() + + # Get scroll position after paste + final_scroll_x, final_scroll_y = text_area.scroll_offset + + # The scrollbar should have scrolled down to show the cursor + # (which should be at the end of the pasted text) + assert final_scroll_y > initial_scroll_y, ( + f"Scrollbar should have scrolled down after paste. " + f"Initial: {initial_scroll_y}, Final: {final_scroll_y}" + ) + + # Verify the text was pasted + assert text_area.text == long_text + + +async def test_paste_event_scrollbar_position_updated(): + """Regression test for issue #4852: scrollbar position should be updated after paste event. + + Tests the _on_paste method specifically (paste via event, not action). + """ + from textual.events import Paste + + app = TextAreaApp() + async with app.run_test(size=(40, 10)) as pilot: + text_area = app.query_one(TextArea) + await pilot.click(text_area) + + # Create long text that will require scrolling + long_text = "\n".join(f"Line {i}" for i in range(50)) + + # Get initial scroll position + initial_scroll_x, initial_scroll_y = text_area.scroll_offset + + # Post a paste event + app.post_message(Paste(long_text)) + await pilot.pause() + + # Get scroll position after paste + final_scroll_x, final_scroll_y = text_area.scroll_offset + + # The scrollbar should have scrolled down to show the cursor + assert final_scroll_y > initial_scroll_y, ( + f"Scrollbar should have scrolled down after paste event. " + f"Initial: {initial_scroll_y}, Final: {final_scroll_y}" + ) + + # Verify the text was pasted + assert text_area.text == long_text + + +async def test_paste_scrollbar_with_wide_text(): + """Regression test for issue #4852: scrollbar position updated with wide text. + + Based on comment from TomJGooding: vertical scrollbar updates correctly + when horizontal scrollbar is also triggered. This test verifies both scrollbars work. + """ + app = TextAreaApp() + async with app.run_test(size=(40, 10)) as pilot: + text_area = app.query_one(TextArea) + await pilot.click(text_area) + + # Create text that requires both horizontal and vertical scrolling + # Wide lines + many lines + wide_text = "\n".join(f"Line {i}: {'x' * 100}" for i in range(50)) + app.copy_to_clipboard(wide_text) + + # Get initial scroll position + initial_scroll_x, initial_scroll_y = text_area.scroll_offset + + # Paste the text + await pilot.press("ctrl+v") + await pilot.pause() + + # Get scroll position after paste + final_scroll_x, final_scroll_y = text_area.scroll_offset + + # Both scrollbars should have updated + # Vertical scrollbar should have scrolled down + assert final_scroll_y > initial_scroll_y, ( + f"Vertical scrollbar should have scrolled down after paste. " + f"Initial: {initial_scroll_y}, Final: {final_scroll_y}" + ) + + # Horizontal scrollbar may or may not scroll depending on cursor position + # But the important thing is that vertical scrollbar updated correctly + + # Verify the text was pasted + assert text_area.text == wide_text