Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/textual/_text_selection.py
Original file line number Diff line number Diff line change
@@ -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),
)
59 changes: 59 additions & 0 deletions src/textual/_word_segmentation.py
Original file line number Diff line number Diff line change
@@ -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
107 changes: 101 additions & 6 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading