diff --git a/src/textual/_text_selection.py b/src/textual/_text_selection.py new file mode 100644 index 0000000000..26e182684d --- /dev/null +++ b/src/textual/_text_selection.py @@ -0,0 +1,180 @@ +"""Text selection utilities. + +This module provides helpers for selecting words and paragraphs from a widget's +text content. The default word segmentation uses the pluggable word-segmentation +module (`textual._word_segmentation`). Widgets can override the selection methods +on `Widget` for more sophisticated behavior. +""" + +from __future__ import annotations + +import re + +from textual._word_segmentation import get_word_offsets +from textual.geometry import Offset +from textual.selection import Selection + + +def _split_lines(text: str) -> list[tuple[str, int]]: + """Split text into lines, preserving the exact width of each newline. + + This is like ``str.splitlines()`` but also returns the width of the line + ending (``\r\n`` is 2, ``\n`` or ``\r`` is 1, end-of-text is 0). + + Args: + text: The text to split. + + Returns: + A list of ``(line_text, newline_width)`` pairs. + """ + if not text: + return [] + + lines: list[tuple[str, int]] = [] + start = 0 + for match in re.finditer(r"\r\n|\r|\n", text): + line_end = match.start() + newline_width = len(match.group()) + lines.append((text[start:line_end], newline_width)) + start = match.end() + + # Only add a final line if there is content after the last newline. + if start < len(text): + lines.append((text[start:], 0)) + + return lines + + +def _flat_offset_to_offset(text: str, flat_offset: int) -> Offset: + """Convert a flat character offset into a line/character Offset. + + Args: + text: The text being selected. + flat_offset: A character offset in the text. + + Returns: + An Offset where x is the character offset on the line and y is the line index. + """ + if flat_offset <= 0: + return Offset(0, 0) + + lines = _split_lines(text) + position = 0 + for y, (line, newline_width) in enumerate(lines): + line_end = position + len(line) + if flat_offset <= line_end: + return Offset(flat_offset - position, y) + + next_position = line_end + newline_width + if flat_offset < next_position: + # The offset falls inside a newline; clamp to the end of the line. + return Offset(len(line), y) + + position = next_position + + # Past the end of the text; clamp to the last position. + if lines: + last_line, _ = lines[-1] + return Offset(len(last_line), len(lines) - 1) + return Offset(0, 0) + + +def get_word_selection(text: str, offset: Offset) -> Selection | None: + """Get a Selection for the word at the given offset. + + Args: + text: The text to search. + offset: A line/character offset within the text. + + Returns: + A Selection for the word, or None if no word was found. + """ + lines = _split_lines(text) + flat_offset = sum( + len(line) + newline_width for line, newline_width in lines[: offset.y] + ) + offset.x + word_offsets = get_word_offsets(text, flat_offset) + if word_offsets is None: + return None + + start, end = word_offsets + return Selection.from_offsets( + _flat_offset_to_offset(text, start), + _flat_offset_to_offset(text, end), + ) + + +def get_paragraph_offsets(text: str, flat_offset: int) -> tuple[int, int] | None: + """Return the start and end offsets of the paragraph containing the given offset. + + Paragraphs are separated by blank lines. + + Args: + text: The text to search. + flat_offset: A character offset within the text. + + Returns: + A tuple of (start, end) offsets, or None if no paragraph was found. + """ + if not text or flat_offset < 0 or flat_offset > len(text): + return None + + lines_info = _split_lines(text) + if not lines_info: + return None + + lines = [line for line, _ in lines_info] + + # Find the line containing the offset. + line_index = 0 + position = 0 + for i, (line, newline_width) in enumerate(lines_info): + line_end = position + len(line) + if position <= flat_offset <= line_end: + line_index = i + break + position = line_end + newline_width + + # Expand to paragraph boundaries (blank lines). + start_line = line_index + while start_line > 0 and lines[start_line - 1] != "": + start_line -= 1 + + end_line = line_index + while end_line < len(lines) - 1 and lines[end_line + 1] != "": + end_line += 1 + + start = sum( + len(line) + newline_width + for line, newline_width in lines_info[:start_line] + ) + end = sum( + len(line) + newline_width + for line, newline_width in lines_info[:end_line] + ) + len(lines_info[end_line][0]) + return start, end + + +def get_paragraph_selection(text: str, offset: Offset) -> Selection | None: + """Get a Selection for the paragraph at the given offset. + + Args: + text: The text to search. + offset: A line/character offset within the text. + + Returns: + A Selection for the paragraph, or None if no paragraph was found. + """ + lines_info = _split_lines(text) + flat_offset = sum( + len(line) + newline_width for line, newline_width in lines_info[: offset.y] + ) + offset.x + paragraph_offsets = get_paragraph_offsets(text, flat_offset) + if paragraph_offsets is None: + return None + + start, end = paragraph_offsets + return Selection.from_offsets( + _flat_offset_to_offset(text, start), + _flat_offset_to_offset(text, end), + ) diff --git a/src/textual/_word_segmentation.py b/src/textual/_word_segmentation.py new file mode 100644 index 0000000000..a88c54ef45 --- /dev/null +++ b/src/textual/_word_segmentation.py @@ -0,0 +1,59 @@ +"""Word segmentation helpers. + +This module provides an optional-dependency structure for word segmentation. +If the third-party `regex` package is installed, it is used for more accurate +Unicode-aware word matching (including marks and a broader range of word +characters). Otherwise the implementation falls back to Python's standard +``re`` module. + +In the future this can be extended to use a full UAX #29 word-boundary +implementation (e.g. ``regex`` with ``\b{wb}`` or ``uniseg``). +""" + +from __future__ import annotations + +import re + +try: + import regex as _regex_module # type: ignore[import-untyped] +except ImportError: # pragma: no cover + _regex_module = None + +if _regex_module is not None: + # Use Unicode character properties when the regex package is available. + # This includes letters (L), numbers (N), and marks (M) so that combining + # characters stay attached to their base characters. + _WORD_OR_URL_RE = _regex_module.compile( + r"(?:https?|ftp)://\S+|[\p{L}\p{N}\p{M}'’-]+", + _regex_module.UNICODE, + ) +else: + _WORD_OR_URL_RE = re.compile( + r"(?:https?|ftp)://\S+|[\w'’-]+", + re.UNICODE, + ) + + +def get_word_offsets(text: str, flat_offset: int) -> tuple[int, int] | None: + """Return the start and end offsets of the word containing the given offset. + + Args: + text: The text to search. + flat_offset: A character offset within the text. + + Returns: + A tuple of (start, end) offsets, or ``None`` if no word was found at the offset. + """ + if not text or flat_offset < 0 or flat_offset > len(text): + return None + + # Clamp an offset that is exactly at the end of the text to the last character. + if flat_offset == len(text): + flat_offset = max(0, len(text) - 1) + + for match in _WORD_OR_URL_RE.finditer(text): + start, end = match.span() + if start <= flat_offset < end: + return start, end + + return None diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ef5da97a..2f19790da0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -56,6 +56,7 @@ from textual._easing import DEFAULT_SCROLL_EASING from textual._extrema import Extrema from textual._styles_cache import StylesCache +from textual._text_selection import get_paragraph_selection, get_word_selection from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_remove import AwaitRemove @@ -4224,10 +4225,8 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None: Returns: Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted. """ - visual = self._render() - if isinstance(visual, (Text, Content)): - text = str(visual) - else: + text = self.get_selectable_text() + if text is None: return None return selection.extract(text), "\n" @@ -4633,6 +4632,84 @@ def text_select_all(self) -> None: """Select the entire widget.""" self.screen._select_all_in_widget(self) + def get_selectable_text(self) -> str | None: + """Get the text content of the widget, if it is text-based. + + Returns: + The widget's text content, or `None` if it cannot be represented as text. + """ + visual = self._render() + if isinstance(visual, (Text, Content)): + return str(visual) + return None + + def get_word_selection(self, offset: Offset) -> Selection | None: + """Get a Selection for the word at the given offset. + + Args: + offset: A line/character offset within the widget's text content. + + Returns: + A Selection for the word, or `None` if no word was found. + """ + text = self.get_selectable_text() + if text is None: + return None + return get_word_selection(text, offset) + + def get_paragraph_selection(self, offset: Offset) -> Selection | None: + """Get a Selection for the paragraph at the given offset. + + The default implementation selects the paragraph within the widget's text. + Widgets may override this method to define what constitutes a paragraph. + + Args: + offset: A line/character offset within the widget's text content. + + Returns: + A Selection for the paragraph, or `None` if no paragraph was found. + """ + text = self.get_selectable_text() + if text is None: + return None + return get_paragraph_selection(text, offset) + + def _set_selection(self, selection: Selection | None) -> None: + """Set the selection for this widget, clearing any stale selection state. + + Args: + selection: The selection to apply, or ``None`` to select the entire widget. + """ + if selection is None: + self.text_select_all() + else: + self.screen.selections = {self: selection} + # The selection system tracks drag state via _select_state; clear it so + # the new selection does not leave stale state behind. + self.screen._select_state = None + + def text_select_word(self, offset: Offset | None = None) -> None: + """Select the word at the given offset. + + Args: + offset: The offset of the word to select, or `None` to select the entire widget. + """ + if offset is None: + self.text_select_all() + return + self._set_selection(self.get_word_selection(offset)) + + def text_select_paragraph(self, offset: Offset | None = None) -> None: + """Select the paragraph at the given offset. + + Args: + offset: The offset of the paragraph to select, or `None` to select the entire widget. + """ + if offset is None: + self.text_select_all() + return + self._set_selection(self.get_paragraph_selection(offset)) + def begin_capture_print(self, stdout: bool = True, stderr: bool = True) -> None: """Capture text from print statements (or writes to stdout / stderr). @@ -4696,9 +4773,27 @@ 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() + if event.screen_x is None or event.screen_y is None: + self.text_select_all() + else: + widget, offset = self.screen.get_widget_and_offset_at( + event.screen_x, event.screen_y + ) + if widget is self and offset is not None: + self.text_select_word(offset) + else: + self.text_select_all() elif event.chain == 3 and self.parent is not None: - self.select_container.text_select_all() + if event.screen_x is None or event.screen_y is None: + self.select_container.text_select_all() + else: + widget, offset = self.screen.get_widget_and_offset_at( + event.screen_x, event.screen_y + ) + if widget is self and offset is not None: + self.text_select_paragraph(offset) + else: + self.select_container.text_select_all() await self.broker_event("click", event) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_selection_disabled_when_allow_select_is_false[True-True-True].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_selection_disabled_when_allow_select_is_false[True-True-True].svg index 6c9c28b062..e6b7dc2c8b 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_selection_disabled_when_allow_select_is_false[True-True-True].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_selection_disabled_when_allow_select_is_false[True-True-True].svg @@ -119,9 +119,9 @@ - + - Double-clicking me SHOULD select the text + Double-clicking me SHOULD select the text diff --git a/tests/test_selection.py b/tests/test_selection.py index c8c93b3243..75fdd55757 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -1,7 +1,7 @@ import pytest from textual.app import App, ComposeResult -from textual.containers import VerticalScroll +from textual.containers import Vertical, VerticalScroll from textual.geometry import Offset from textual.selection import Selection from textual.widgets import Static @@ -225,3 +225,109 @@ async def test_select_out_of_scrollable_container_on_gap(): assert ( f"item-{i:02d}" in selected_text ), f"item-{i:02d} missing from {selected_text!r}" + + +async def test_double_click_selects_word(): + """Double-clicking a word should select just that word.""" + + class WordSelectApp(App): + def compose(self) -> ComposeResult: + yield Static("hello world foo bar") + + app = WordSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + static = app.query_one(Static) + await pilot.click(static, offset=(8, 0), times=2) + await pilot.pause() + assert app.screen.get_selected_text() == "world" + + +async def test_double_click_selects_url(): + """Double-clicking inside a URL should select the whole URL.""" + + class UrlSelectApp(App): + def compose(self) -> ComposeResult: + yield Static("Visit https://example.com/path for info.") + + app = UrlSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + static = app.query_one(Static) + await pilot.click(static, offset=(18, 0), times=2) + await pilot.pause() + assert app.screen.get_selected_text() == "https://example.com/path" + + +async def test_triple_click_selects_paragraph(): + """Triple-clicking should select the paragraph at the click location.""" + + class ParagraphSelectApp(App): + def compose(self) -> ComposeResult: + yield Static("first paragraph\n\nsecond paragraph\nmore text") + + app = ParagraphSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + static = app.query_one(Static) + await pilot.click(static, offset=(2, 3), times=3) + await pilot.pause() + assert app.screen.get_selected_text() == "second paragraph\nmore text" + + +async def test_double_click_clears_select_state(): + """Double-clicking a word should leave the selection state consistent.""" + + class WordSelectApp(App): + def compose(self) -> ComposeResult: + yield Static("hello world foo bar") + + app = WordSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + static = app.query_one(Static) + await pilot.click(static, offset=(8, 0), times=2) + await pilot.pause() + assert app.screen.get_selected_text() == "world" + assert app.screen._select_state is None + + +async def test_triple_click_fallback_selects_container(): + """Triple-clicking a non-text widget should select its container.""" + + from textual.renderables.blank import Blank + + class NonTextWidget(Static): + def render(self) -> Blank: + return Blank() + + class ContainerSelectApp(App): + def compose(self) -> ComposeResult: + with VerticalScroll(id="container"): + yield NonTextWidget() + + app = ContainerSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + widget = app.query_one(NonTextWidget) + container = widget.select_container + await pilot.click(widget, times=3) + await pilot.pause() + assert app.screen.selections.get(container) is not None + + +async def test_get_selectable_text_shared_with_get_selection(): + """get_selectable_text should return the text that get_selection extracts from.""" + + class SelectableTextApp(App): + def compose(self) -> ComposeResult: + yield Static("hello world") + + app = SelectableTextApp() + async with app.run_test() as pilot: + await pilot.pause() + static = app.query_one(Static) + text = static.get_selectable_text() + selection = static.get_selection(Selection.from_offsets(Offset(6, 0), Offset(11, 0))) + assert text == "hello world" + assert selection == ("world", "\n") diff --git a/tests/test_text_selection.py b/tests/test_text_selection.py new file mode 100644 index 0000000000..37dce2dc2a --- /dev/null +++ b/tests/test_text_selection.py @@ -0,0 +1,180 @@ +"""Tests for the text selection helpers.""" + +import re +from contextlib import contextmanager + +import pytest + +from textual._text_selection import ( + _flat_offset_to_offset, + _split_lines, + get_paragraph_offsets, + get_paragraph_selection, + get_word_selection, +) +from textual import _word_segmentation +from textual._word_segmentation import get_word_offsets +from textual.geometry import Offset +from textual.selection import Selection + + +@contextmanager +def _re_fallback(): + """Temporarily force the word-segmentation module to use the standard re fallback.""" + original_regex_module = _word_segmentation._regex_module + original_word_re = _word_segmentation._WORD_OR_URL_RE + _word_segmentation._regex_module = None + _word_segmentation._WORD_OR_URL_RE = re.compile( + r"(?:https?|ftp)://\S+|[\w'’-]+", + re.UNICODE, + ) + try: + yield + finally: + _word_segmentation._regex_module = original_regex_module + _word_segmentation._WORD_OR_URL_RE = original_word_re + + +@pytest.mark.parametrize( + "text,offset,expected", + [ + ("hello world", 0, (0, 5)), + ("hello world", 3, (0, 5)), + ("hello world", 6, (6, 11)), + ("hello world", 9, (6, 11)), + ("hello world", 10, (6, 11)), + ("hello world", 11, (6, 11)), + ("hello world", 15, None), + ("", 0, None), + ], +) +def test_get_word_offsets(text, offset, expected): + assert get_word_offsets(text, offset) == expected + + +def test_get_word_offsets_url(): + text = "Visit https://example.com/path for info." + start = text.index("https") + for offset in range(start, len(text)): + if text[offset].isspace(): + break + assert get_word_offsets(text, offset) == ( + start, + start + len("https://example.com/path"), + ) + + +@pytest.mark.skipif( + _word_segmentation._regex_module is None, + reason="regex package not installed", +) +def test_get_word_offsets_unicode_combining_mark(): + """When regex is available, combining marks stay attached to the word.""" + # "café" spelled with a combining acute accent (e + U+0301). + text = "cafe\u0301" + assert get_word_offsets(text, 2) == (0, 5) + + +def test_get_word_offsets_re_fallback(): + """The re fallback still handles basic words correctly.""" + with _re_fallback(): + assert get_word_offsets("hello world", 0) == (0, 5) + assert get_word_offsets("hello world", 6) == (6, 11) + + +def test_get_word_offsets_url_re_fallback(): + """The re fallback still selects URLs.""" + with _re_fallback(): + text = "Visit https://example.com/path for info." + start = text.index("https") + for offset in range(start, len(text)): + if text[offset].isspace(): + break + assert get_word_offsets(text, offset) == ( + start, + start + len("https://example.com/path"), + ) + + +def test_get_word_selection(): + text = "hello world\nfoo bar" + selection = get_word_selection(text, Offset(8, 0)) # on "world" + assert selection == Selection.from_offsets(Offset(6, 0), Offset(11, 0)) + + +def test_get_word_selection_multiline(): + text = "hello world\nfoo bar" + selection = get_word_selection(text, Offset(2, 1)) # on "foo" + assert selection == Selection.from_offsets(Offset(0, 1), Offset(3, 1)) + + +def test_get_word_selection_crlf(): + text = "hello world\r\nfoo bar" + selection = get_word_selection(text, Offset(2, 1)) # on "foo" + assert selection == Selection.from_offsets(Offset(0, 1), Offset(3, 1)) + + +def test_get_word_selection_inside_crlf_newline(): + """Clicking inside a CRLF newline should still select the preceding word.""" + text = "hello world\r\nfoo bar" + # Offset(10, 0) is the last character of "world" on the first line. + selection = get_word_selection(text, Offset(10, 0)) + assert selection == Selection.from_offsets(Offset(6, 0), Offset(11, 0)) + + +def test_flat_offset_to_offset(): + assert _flat_offset_to_offset("hello\nworld", 0) == Offset(0, 0) + assert _flat_offset_to_offset("hello\nworld", 6) == Offset(0, 1) + assert _flat_offset_to_offset("hello\nworld", 8) == Offset(2, 1) + assert _flat_offset_to_offset("hello\nworld", 20) == Offset(5, 1) + + +def test_flat_offset_to_offset_crlf(): + assert _flat_offset_to_offset("hello\r\nworld", 0) == Offset(0, 0) + assert _flat_offset_to_offset("hello\r\nworld", 5) == Offset(5, 0) + assert _flat_offset_to_offset("hello\r\nworld", 7) == Offset(0, 1) + assert _flat_offset_to_offset("hello\r\nworld", 9) == Offset(2, 1) + assert _flat_offset_to_offset("hello\r\nworld", 20) == Offset(5, 1) + + +def test_split_lines(): + assert _split_lines("hello\nworld") == [("hello", 1), ("world", 0)] + assert _split_lines("hello\r\nworld") == [("hello", 2), ("world", 0)] + assert _split_lines("hello\nworld\n") == [("hello", 1), ("world", 1)] + assert _split_lines("\n\n") == [("", 1), ("", 1)] + assert _split_lines("") == [] + + +@pytest.mark.parametrize( + "text,offset,expected", + [ + ("para1\n\npara2\n\npara3", 2, (0, 5)), + ("para1\n\npara2\n\npara3", 8, (7, 12)), + ("para1\n\npara2\n\npara3", 14, (14, 19)), + ("line1\nline2\n\npara2", 1, (0, 11)), + ("line1\nline2\n\npara2", 8, (0, 11)), + ("single", 3, (0, 6)), + ("", 0, None), + ], +) +def test_get_paragraph_offsets(text, offset, expected): + assert get_paragraph_offsets(text, offset) == expected + + +def test_get_paragraph_offsets_crlf(): + text = "para1\r\n\r\npara2\r\n\r\npara3" + assert get_paragraph_offsets(text, 2) == (0, 5) + assert get_paragraph_offsets(text, 10) == (9, 14) + assert get_paragraph_offsets(text, 18) == (18, 23) + + +def test_get_paragraph_selection(): + text = "para1\n\npara2\n\npara3" + selection = get_paragraph_selection(text, Offset(2, 2)) + assert selection == Selection.from_offsets(Offset(0, 2), Offset(5, 2)) + + +def test_get_paragraph_selection_crlf(): + text = "para1\r\n\r\npara2\r\n\r\npara3" + selection = get_paragraph_selection(text, Offset(2, 2)) + assert selection == Selection.from_offsets(Offset(0, 2), Offset(5, 2))