diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a82ccec25..5183dc77da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [8.3.0] - Unreleased + +### Added + +- Added `App.SELECTION_MODE` with `SelectionMode.LEGACY` (default, preserves existing behavior) and `SelectionMode.STANDARD` (word/line selection) + - Standard: double-click selects a word, triple-click selects a line (standard terminal behavior), and dragging after a double/triple click extends the selection by word/line +- Added `Widget.word_at_offset()` and `Widget.line_at_offset()` — overridable methods for custom word/line boundary detection + + ## [8.2.1] - 2026-03-29 ### Fixed diff --git a/src/textual/app.py b/src/textual/app.py index df9db50376..9d4f2baae3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -117,6 +117,7 @@ from textual.notifications import Notification, Notifications, Notify, SeverityLevel from textual.reactive import Reactive from textual.renderables.blank import Blank +from textual.selection import SelectionMode from textual.screen import ( ActiveBinding, Screen, @@ -521,6 +522,16 @@ class MyApp(App[None]): """Number of lines in auto-scrolling regions at the top and bottom of a widget.""" SELECT_AUTO_SCROLL_SPEED: ClassVar[float] = 60.0 + + SELECTION_MODE: ClassVar[SelectionMode] = SelectionMode.LEGACY + """Controls double-click and triple-click text selection behavior. + + - `SelectionMode.LEGACY` (default): Double-click selects the entire widget and + triple-click selects the container (Textual 8.2 and earlier behavior). + - `SelectionMode.STANDARD`: Double-click selects a word and triple-click selects + a line, matching standard terminal behavior. Dragging after double-click extends + word-by-word, and dragging after triple-click extends line-by-line. + """ """Maximum speed of select auto-scroll in lines per second.""" _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = { diff --git a/src/textual/screen.py b/src/textual/screen.py index 405b066d46..5b0a2e7bfc 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -58,7 +58,7 @@ from textual.reactive import Reactive, var from textual.renderables.background_screen import BackgroundScreen from textual.renderables.blank import Blank -from textual.selection import SELECT_ALL, Selection +from textual.selection import SELECT_ALL, Selection, SelectionMode from textual.signal import Signal from textual.timer import Timer from textual.walk import walk_selectable_widgets @@ -251,6 +251,24 @@ class Screen(Generic[ScreenResultType], Widget): """Map of widgets and selected ranges.""" _selecting = var(False) + _select_granularity: str = "char" + """Selection granularity: 'char', 'word', or 'line'.""" + _select_anchor_start: Offset | None = None + """Start offset of the initially selected word/line (anchor).""" + _select_anchor_end: Offset | None = None + """End offset of the initially selected word/line (anchor).""" + _select_anchor_widget: Widget | None = None + """The widget where the initial word/line was selected.""" + _last_mouse_down_time: float = 0.0 + """Timestamp of the last mouse down event, for click chain detection.""" + _last_mouse_down_position: Offset | None = None + """Screen offset of the last mouse down, for click chain detection.""" + _mouse_down_chain: int = 0 + """Current click chain count (1=single, 2=double, 3=triple). + + Note: This duplicates App._chained_clicks, but is needed because App + computes the chain on MouseUp (to produce Click events), while Screen + needs it on MouseDown (to set selection granularity and anchor).""" """Indicates mouse selection is in progress.""" _box_select = var(False) @@ -757,6 +775,14 @@ def clear_selection(self) -> None: self.selections = {} self._select_start = None self._select_end = None + # Don't reset _select_granularity here — it's set during MouseDown + # processing based on click chain detection. External clear_selection + # calls (e.g. from TextArea._watch_selection when focus changes) must + # not interfere with the click chain state, which spans multiple + # MouseDown/MouseUp cycles. + self._select_anchor_start = None + self._select_anchor_end = None + self._select_anchor_widget = None def _select_all_in_widget(self, widget: Widget) -> None: """Select a widget and all its children. @@ -1893,8 +1919,11 @@ def _forward_event(self, event: events.Event) -> None: if ( self._mouse_down_offset is not None and self._mouse_down_offset == event.screen_offset + and self._select_granularity == "char" ): - # A click elsewhere should clear the selection + # A click elsewhere should clear the selection, but not + # for word/line granularity where mouse-up at the same + # position as mouse-down is expected (double/triple click). select_widget, select_offset = self.get_widget_and_offset_at( event.x, event.y ) @@ -1914,6 +1943,25 @@ def _forward_event(self, event: events.Event) -> None: select_widget, select_offset = self.get_widget_and_offset_at( event.screen_x, event.screen_y ) + + # Detect click chain (double/triple click) based on timing + # and proximity to the previous mouse-down. + same_offset = ( + self._last_mouse_down_position is not None + and self._last_mouse_down_position == event.screen_offset + ) + within_time = ( + self._last_mouse_down_time > 0 + and (event.time - self._last_mouse_down_time) + <= self.app.CLICK_CHAIN_TIME_THRESHOLD + ) + if same_offset and within_time: + self._mouse_down_chain = min(self._mouse_down_chain + 1, 3) + else: + self._mouse_down_chain = 1 + self._last_mouse_down_time = event.time + self._last_mouse_down_position = event.screen_offset + if ( select_widget is not None and select_widget.allow_select @@ -1921,13 +1969,83 @@ def _forward_event(self, event: events.Event) -> None: and self.app.ALLOW_SELECT ): self._selecting = True + + # Set selection granularity based on click chain + if self.app.SELECTION_MODE == SelectionMode.STANDARD: + if self._mouse_down_chain == 2: + self._select_granularity = "word" + elif self._mouse_down_chain >= 3: + self._select_granularity = "line" + else: + self._select_granularity = "char" + else: + self._select_granularity = "char" + if select_widget is not None and select_offset is not None: self.text_selection_started_signal.publish(self) - self._select_start = ( - select_widget, - event.screen_offset, - select_offset, - ) + + # For word/line granularity, compute the anchor boundaries + if self._select_granularity == "word": + word_bounds = select_widget.word_at_offset(select_offset) + if word_bounds is not None: + anchor_start, anchor_end = word_bounds + self._select_anchor_start = anchor_start + self._select_anchor_end = anchor_end + self._select_anchor_widget = select_widget + self._select_start = ( + select_widget, + event.screen_offset, + anchor_start, + ) + # Set _select_end so the initial word is + # selected immediately (triggers _watch__select_end) + self._select_end = ( + select_widget, + event.screen_offset, + anchor_end, + ) + else: + self._select_granularity = "char" + self._select_start = ( + select_widget, + event.screen_offset, + select_offset, + ) + elif self._select_granularity == "line": + line_bounds = select_widget.line_at_offset(select_offset) + if line_bounds is not None: + anchor_start, anchor_end = line_bounds + self._select_anchor_start = anchor_start + self._select_anchor_end = anchor_end + self._select_anchor_widget = select_widget + self._select_start = ( + select_widget, + event.screen_offset, + anchor_start, + ) + # Set _select_end so the initial line is + # selected immediately (triggers _watch__select_end) + self._select_end = ( + select_widget, + event.screen_offset, + anchor_end, + ) + else: + self._select_granularity = "char" + self._select_start = ( + select_widget, + event.screen_offset, + select_offset, + ) + else: + self._select_anchor_start = None + self._select_anchor_end = None + self._select_anchor_widget = None + self._select_start = ( + select_widget, + event.screen_offset, + select_offset, + ) else: self._selecting = False @@ -2009,6 +2127,32 @@ def _collect_select_widgets( results = widgets[index1:index2] return results + def _snap_offset_to_granularity( + self, + widget: Widget, + offset: Offset, + prefer_end: bool = False, + ) -> Offset: + """Snap an offset to word or line boundaries based on current granularity. + + Args: + widget: The widget containing the offset. + offset: The raw character offset. + prefer_end: If True, return the end of the word/line; otherwise the start. + + Returns: + The snapped offset. + """ + if self._select_granularity == "word": + bounds = widget.word_at_offset(offset) + if bounds is not None: + return bounds[1] if prefer_end else bounds[0] + elif self._select_granularity == "line": + bounds = widget.line_at_offset(offset) + if bounds is not None: + return bounds[1] if prefer_end else bounds[0] + return offset + def _watch__select_end( self, select_end: tuple[Widget, Offset, Offset] | None ) -> None: @@ -2029,6 +2173,36 @@ def _watch__select_end( # Widgets may have been removed since selection started return + if start_widget is end_widget and self._select_granularity != "char": + # Word/line selection within the same widget. + # Determine drag direction relative to anchor, then snap. + anchor_start = self._select_anchor_start + anchor_end = self._select_anchor_end + if anchor_start is not None and anchor_end is not None: + snapped_end_start = self._snap_offset_to_granularity(end_widget, end_offset, prefer_end=False) + snapped_end_end = self._snap_offset_to_granularity(end_widget, end_offset, prefer_end=True) + + # Determine if drag is forward or backward from anchor + if end_offset.transpose >= anchor_start.transpose: + # Dragging forward: start at anchor start, end at snapped end + sel_start = anchor_start + sel_end = snapped_end_end + else: + # Dragging backward: start at snapped start, end at anchor end + sel_start = snapped_end_start + sel_end = anchor_end + + # word_at_offset / line_at_offset return exclusive end boundaries, + # so we do NOT add (1, 0) here (unlike char-level selection where + # end_offset points to the last selected character). + self.selections = { + start_widget: Selection.from_offsets( + sel_start, + sel_end, + ) + } + return + if start_widget is end_widget: # Simplest case, selection starts and ends on the same widget if end_offset.transpose < start_offset.transpose: @@ -2076,13 +2250,45 @@ def _watch__select_end( end_widget, ) - # Build the selection + # Build the selection, snapping to word/line boundaries if needed select_all = SELECT_ALL - self.selections = { - start_widget: Selection(start_offset, None), - **{widget: select_all for widget in select_widgets}, - end_widget: Selection(None, end_offset + (1, 0)), - } + if self._select_granularity != "char": + # For word/line granularity in cross-widget selection: + # - The anchor widget's anchor boundary is always fully included + # - The end widget's offset is snapped to the nearest word/line boundary + anchor_widget = self._select_anchor_widget + anchor_start = self._select_anchor_start + anchor_end = self._select_anchor_end + + if anchor_widget is start_widget and anchor_start is not None: + # Dragging forward from anchor + snapped_end = self._snap_offset_to_granularity(end_widget, end_offset, prefer_end=True) + self.selections = { + start_widget: Selection(anchor_start, None), + **{widget: select_all for widget in select_widgets}, + end_widget: Selection(None, snapped_end), + } + elif anchor_widget is end_widget and anchor_end is not None: + # Dragging backward from anchor + snapped_start = self._snap_offset_to_granularity(start_widget, start_offset, prefer_end=False) + self.selections = { + start_widget: Selection(snapped_start, None), + **{widget: select_all for widget in select_widgets}, + end_widget: Selection(None, anchor_end), + } + else: + # Fallback + self.selections = { + start_widget: Selection(start_offset, None), + **{widget: select_all for widget in select_widgets}, + end_widget: Selection(None, end_offset + (1, 0)), + } + else: + self.selections = { + start_widget: Selection(start_offset, None), + **{widget: select_all for widget in select_widgets}, + end_widget: Selection(None, end_offset + (1, 0)), + } def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: """Dismiss the screen, optionally with a result. diff --git a/src/textual/selection.py b/src/textual/selection.py index 0466fbec5a..4b607a2c04 100644 --- a/src/textual/selection.py +++ b/src/textual/selection.py @@ -1,10 +1,27 @@ from __future__ import annotations +from enum import Enum from typing import NamedTuple from textual.geometry import Offset +class SelectionMode(Enum): + """Controls how double-click and triple-click text selection behaves. + + Attributes: + LEGACY: Double-click selects the entire widget, triple-click selects the + container. This was the default behavior in Textual 8.2 and earlier. + STANDARD: Double-click selects a word, triple-click selects a line. + Dragging after double-click extends selection word-by-word, and + dragging after triple-click extends line-by-line. This matches + standard terminal and browser text selection behavior. + """ + + LEGACY = "legacy" + STANDARD = "standard" + + class Selection(NamedTuple): """A selected range of lines.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index ab52d03662..7a069123ae 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -87,7 +87,7 @@ from textual.reactive import Reactive from textual.renderables.blank import Blank from textual.rlock import RLock -from textual.selection import Selection +from textual.selection import Selection, SelectionMode from textual.strip import Strip from textual.style import Style as VisualStyle from textual.visual import Visual, VisualType, visualize @@ -4631,6 +4631,73 @@ def text_select_all(self) -> None: """Select the entire widget.""" self.screen._select_all_in_widget(self) + def word_at_offset(self, offset: Offset) -> tuple[Offset, Offset] | None: + """Get the word boundaries at the given cell offset within the widget. + + The default implementation uses the rendered content to find word boundaries + using whitespace and common punctuation as delimiters. + + Override this method to provide custom word boundary detection (e.g. for + markdown blocks or code editors that need language-aware word selection). + + Args: + offset: The cell offset within the widget content. + + Returns: + A tuple of (start, end) offsets defining the word, or `None` if no word + could be found at the given offset. + """ + visual = self._render() + if visual is None: + return None + text = str(visual) + lines = text.splitlines() + if not lines or offset.y < 0 or offset.y >= len(lines): + return None + line = lines[offset.y] + if offset.x < 0 or offset.x >= len(line): + return None + # Walk backwards to find word start + word_boundary_chars = " \t\n\r.,;:!?'\"()[]{}/<>@#$%^&*-+=~`|\\\u200b" + # If the character at the offset is itself a boundary, no word here + if line[offset.x] in word_boundary_chars: + return None + start_x = offset.x + while start_x > 0 and line[start_x - 1] not in word_boundary_chars: + start_x -= 1 + # Walk forwards to find word end + end_x = offset.x + while end_x < len(line) and line[end_x] not in word_boundary_chars: + end_x += 1 + if start_x == end_x: + return None + return (Offset(start_x, offset.y), Offset(end_x, offset.y)) + + def line_at_offset(self, offset: Offset) -> tuple[Offset, Offset] | None: + """Get the line boundaries at the given cell offset within the widget. + + The default implementation returns the full visual line at the given y offset. + + Override this method to provide custom line boundary detection (e.g. for + widgets that wrap long logical lines across multiple visual lines). + + Args: + offset: The cell offset within the widget content. + + Returns: + A tuple of (start, end) offsets defining the line, or `None` if no line + could be found at the given offset. + """ + visual = self._render() + if visual is None: + return None + text = str(visual) + lines = text.splitlines() + if not lines or offset.y < 0 or offset.y >= len(lines): + return None + line = lines[offset.y] + return (Offset(0, offset.y), Offset(len(line), offset.y)) + def begin_capture_print(self, stdout: bool = True, stderr: bool = True) -> None: """Capture text from print statements (or writes to stdout / stderr). @@ -4693,10 +4760,11 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: async def _on_click(self, event: events.Click) -> None: if event.widget is self: if self.allow_select and self.screen.allow_select and self.app.ALLOW_SELECT: - if event.chain == 2: - self.text_select_all() - elif event.chain == 3 and self.parent is not None: - self.select_container.text_select_all() + if self.app.SELECTION_MODE == SelectionMode.LEGACY: + if event.chain == 2: + self.text_select_all() + elif event.chain == 3 and self.parent is not None: + self.select_container.text_select_all() await self.broker_event("click", event) diff --git a/tests/test_word_line_selection.py b/tests/test_word_line_selection.py new file mode 100644 index 0000000000..31fd36e109 --- /dev/null +++ b/tests/test_word_line_selection.py @@ -0,0 +1,264 @@ +"""Tests for word and line selection granularity (SelectionMode.STANDARD).""" + +import pytest + +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.selection import SelectionMode +from textual.widgets import Static + + +class WordLineSelectApp(App): + """App with SelectionMode.STANDARD for word/line selection.""" + + SELECTION_MODE = SelectionMode.STANDARD + + def compose(self) -> ComposeResult: + yield Static("Hello World\nfoo bar baz\nline three here", id="text") + + +class LegacySelectApp(App): + """App with SelectionMode.LEGACY (default, select-all behavior).""" + + def compose(self) -> ComposeResult: + yield Static("Hello World\nfoo bar baz", id="text") + + +async def test_selection_mode_default_is_legacy(): + """SELECTION_MODE defaults to SelectionMode.LEGACY.""" + app = LegacySelectApp() + assert app.SELECTION_MODE == SelectionMode.LEGACY + + +async def test_double_click_does_not_select_all_in_standard_mode(): + """With SelectionMode.STANDARD, double-click should NOT select all text.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + # Double-click on a word + await pilot.click(offset=(2, 0), times=2) + await pilot.pause() + # Widget._on_click should NOT call text_select_all + # The screen's selection should be set by the granularity system instead + selected = app.screen.get_selected_text() + # Should NOT have selected all text in the widget + if selected is not None: + assert selected != "Hello World\nfoo bar baz\nline three here" + + +async def test_word_at_offset_basic(): + """Test Widget.word_at_offset returns correct word boundaries.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # "Hello" starts at x=0, ends at x=5 + result = widget.word_at_offset(Offset(2, 0)) + assert result is not None + start, end = result + assert start == Offset(0, 0) + assert end == Offset(5, 0) + + +async def test_word_at_offset_second_word(): + """Test word_at_offset for a word not at start of line.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # "World" starts at x=6, ends at x=11 + result = widget.word_at_offset(Offset(8, 0)) + assert result is not None + start, end = result + assert start == Offset(6, 0) + assert end == Offset(11, 0) + + +async def test_word_at_offset_on_space_returns_none(): + """Test word_at_offset returns None when clicking on a space.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # Space between "Hello" and "World" at x=5 + result = widget.word_at_offset(Offset(5, 0)) + assert result is None + + +async def test_word_at_offset_second_line(): + """Test word_at_offset works on non-first lines.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # "bar" on line 1 starts at x=4, ends at x=7 + result = widget.word_at_offset(Offset(5, 1)) + assert result is not None + start, end = result + assert start == Offset(4, 1) + assert end == Offset(7, 1) + + +async def test_line_at_offset_basic(): + """Test Widget.line_at_offset returns correct line boundaries.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # First line: "Hello World" (11 chars) + result = widget.line_at_offset(Offset(3, 0)) + assert result is not None + start, end = result + assert start == Offset(0, 0) + assert end == Offset(11, 0) + + +async def test_line_at_offset_second_line(): + """Test line_at_offset on second line.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + # Second line: "foo bar baz" (11 chars) + result = widget.line_at_offset(Offset(5, 1)) + assert result is not None + start, end = result + assert start == Offset(0, 1) + assert end == Offset(11, 1) + + +async def test_word_at_offset_out_of_bounds(): + """Test word_at_offset returns None for out-of-bounds offsets.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + assert widget.word_at_offset(Offset(0, 99)) is None + assert widget.word_at_offset(Offset(99, 0)) is None + assert widget.word_at_offset(Offset(-1, 0)) is None + + +async def test_line_at_offset_out_of_bounds(): + """Test line_at_offset returns None for out-of-bounds offsets.""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one("#text") + assert widget.line_at_offset(Offset(0, 99)) is None + assert widget.line_at_offset(Offset(0, -1)) is None + + +async def test_double_click_selects_word(): + """Double-click selects a word (standard terminal behavior).""" + app = WordLineSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + # First click + assert await pilot.mouse_down(offset=(8, 0)) + await pilot.pause() + assert await pilot.mouse_up(offset=(8, 0)) + await pilot.pause() + # Second click (double-click) — triggers word selection + assert await pilot.mouse_down(offset=(8, 0)) + await pilot.pause() + assert await pilot.mouse_up(offset=(8, 0)) + await pilot.pause() + selected = app.screen.get_selected_text() + assert selected == "World" + + +async def test_legacy_double_click_selects_all(): + """With SELECTION_MODE=False, double-click selects entire widget (legacy behavior).""" + app = LegacySelectApp() + async with app.run_test() as pilot: + await pilot.pause() + # pilot.click with times=2 triggers Widget._on_click with chain=2 + await pilot.click(offset=(2, 0), times=2) + await pilot.pause() + selected = app.screen.get_selected_text() + # Default behavior: should select all text in the widget + assert selected == "Hello World\nfoo bar baz" + + +async def test_triple_click_selects_line(): + """Triple-click selects the full visual line.""" + app = WordLineSelectApp() + async with app.run_test(size=(80, 5)) as pilot: + await pilot.pause() + w = app.query_one("#text") + x = w.region.x + 2 + y = w.region.y + 1 # Second line: "foo bar baz" + # Triple click + await pilot.mouse_down(offset=(x, y)) + await pilot.mouse_up(offset=(x, y)) + await pilot.mouse_down(offset=(x, y)) + await pilot.mouse_up(offset=(x, y)) + await pilot.mouse_down(offset=(x, y)) + await pilot.mouse_up(offset=(x, y)) + await pilot.pause() + selected = app.screen.get_selected_text() + assert selected == "foo bar baz" + + +class TwoWidgetApp(App): + """App with a TextArea and a Static to test clear_selection interaction.""" + + SELECTION_MODE = SelectionMode.STANDARD + + def compose(self) -> ComposeResult: + from textual.widgets import TextArea + + yield TextArea("input text", id="input") + yield Static("Hello World\nfoo bar baz", id="text") + + +async def test_clear_selection_preserves_granularity_during_click_chain(): + """When another widget calls clear_selection during a double-click, + the click chain granularity must not be reset. + + This was a real bug: TextArea._watch_selection calls app.clear_selection() + when focus changes, which wiped the granularity set by the MouseDown handler. + """ + app = TwoWidgetApp() + async with app.run_test(size=(80, 10)) as pilot: + await pilot.pause() + text_widget = app.query_one("#text") + x = text_widget.region.x + 2 + y = text_widget.region.y + # First click (may trigger TextArea focus change → clear_selection) + await pilot.mouse_down(offset=(x, y)) + await pilot.mouse_up(offset=(x, y)) + # Second click (double-click → should select word despite clear_selection) + await pilot.mouse_down(offset=(x, y)) + # Granularity should survive any clear_selection calls + assert app.screen._select_granularity == "word", ( + f"Expected granularity 'word' but got '{app.screen._select_granularity}'" + ) + await pilot.mouse_up(offset=(x, y)) + await pilot.pause() + selected = app.screen.get_selected_text() + assert selected == "Hello" + + +async def test_double_click_drag_selects_word_by_word(): + """Double-click and drag should extend selection word-by-word.""" + app = WordLineSelectApp() + async with app.run_test(size=(80, 5)) as pilot: + await pilot.pause() + w = app.query_one("#text") + # Double-click on "Hello" (x=2) + x_start = w.region.x + 2 + y = w.region.y + await pilot.mouse_down(offset=(x_start, y)) + await pilot.mouse_up(offset=(x_start, y)) + await pilot.mouse_down(offset=(x_start, y)) + # Now drag to "World" (x=8) + x_end = w.region.x + 8 + await pilot.hover(offset=(x_end, y)) + await pilot.pause() + selected = app.screen.get_selected_text() + # Should have selected both words, not just characters + assert selected is not None + assert "Hello" in selected + assert "World" in selected + await pilot.mouse_up(offset=(x_end, y))