diff --git a/src/textual/content.py b/src/textual/content.py index a7eb3bc7cc..59031b00da 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -27,6 +27,8 @@ from textual._loop import loop_last from textual.color import Color from textual.css.types import TextAlign +from textual.geometry import Offset +from textual.selection import Selection from textual.strip import Strip from textual.visual import Style, Visual @@ -270,7 +272,9 @@ def render_strips( ), no_wrap=False, tab_size=8, + selection=widget.selection, ) + if height is not None: lines = lines[:height] @@ -948,13 +952,30 @@ def wrap( overflow: OverflowMethod = "fold", no_wrap: bool = False, tab_size: int = 8, + selection: Selection | None = None, ) -> list[Content]: lines: list[Content] = [] + selection = Selection(Offset(10, 0), Offset(20, 1)) + + if selection is not None: + get_span = selection.get_span + else: + + def get_span(y: int) -> tuple[int, int] | None: + return None + for line_no, line in enumerate(self.split(allow_blank=True)): if "\t" in line._text: line = line.expand_tabs(tab_size) line = line.stylize(Style.from_meta({"offset": (line_no, 0)})) + + if (span := get_span(line_no)) is not None: + start, end = span + if end == -1: + end = len(line.plain) + line = line.stylize(Style(reverse=True), start, end) + if no_wrap: new_lines = [line] else: @@ -964,6 +985,7 @@ def wrap( new_lines = _align_lines(new_lines, width, align=align, overflow=overflow) new_lines = [line.truncate(width, overflow=overflow) for line in new_lines] lines.extend(new_lines) + return lines def highlight_regex( diff --git a/src/textual/screen.py b/src/textual/screen.py index 3f1c423639..da3f0d941e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -53,9 +53,10 @@ from textual.reactive import Reactive, var from textual.renderables.background_screen import BackgroundScreen from textual.renderables.blank import Blank +from textual.selection import Selection from textual.signal import Signal from textual.timer import Timer -from textual.widget import Selection, Widget +from textual.widget import Widget from textual.widgets import Tooltip from textual.widgets._toast import ToastRack diff --git a/src/textual/selection.py b/src/textual/selection.py new file mode 100644 index 0000000000..4b6b8da576 --- /dev/null +++ b/src/textual/selection.py @@ -0,0 +1,49 @@ +from typing import NamedTuple + +from textual.geometry import Offset + + +class Selection(NamedTuple): + """A selected range of lines.""" + + start: Offset | None + """Offset or None for `start`.""" + end: Offset | None + """Offset or None for `end`.""" + + def get_span(self, y: int) -> tuple[int, int] | None: + """Get the selected span in a given line. + + Args: + y: Offset of the line. + + Returns: + A tuple of x start and end offset, or None for no selection. + """ + start, end = self + if start is None and end is None: + # Selection covers everything + return 0, -1 + + if start is not None and end is not None: + if y < start.y or y > end.y: + # Outside + return None + if y == start.y == end.y: + # Same line + return start.x, end.x + if y == end.y: + # Last line + return 0, end.x + # Remaining lines + return start.x, -1 + + if start is None and end is not None: + if y == end.y: + return 0, end.x + return 0, -1 + + if end is None and start is not None: + if y == start.y: + return start.x, -1 + return 0, -1 diff --git a/src/textual/visual.py b/src/textual/visual.py index c58431f2cc..b3e6a9f00c 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -110,6 +110,7 @@ class Style: dim: bool | None = None italic: bool | None = None underline: bool | None = None + reverse: bool | None = None strike: bool | None = None link: str | None = None _meta: bytes | None = None @@ -122,6 +123,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "dim", self.dim, None yield "italic", self.italic, None yield "underline", self.underline, None + yield "reverse", self.reverse, None yield "strike", self.strike, None if self._meta is not None: yield "meta", self.meta @@ -137,6 +139,7 @@ def __add__(self, other: object) -> Style: self.dim if other.dim is None else other.dim, self.italic if other.italic is None else other.italic, self.underline if other.underline is None else other.underline, + self.reverse if other.reverse is None else other.reverse, self.strike if other.strike is None else other.strike, self.link if other.link is None else other.link, self._meta if other._meta is None else other._meta, @@ -163,6 +166,7 @@ def from_rich_style( dim=rich_style.dim, italic=rich_style.italic, underline=rich_style.underline, + reverse=rich_style.reverse, strike=rich_style.strike, ) @@ -186,6 +190,7 @@ def from_styles(cls, styles: StylesBase) -> Style: dim=text_style.italic, italic=text_style.italic, underline=text_style.underline, + reverse=text_style.reverse, strike=text_style.strike, auto_color=styles.auto_color, ) @@ -208,6 +213,7 @@ def rich_style(self) -> RichStyle: dim=self.dim, italic=self.italic, underline=self.underline, + reverse=self.reverse, strike=self.strike, link=self.link, meta=self.meta, @@ -219,6 +225,7 @@ def without_color(self) -> Style: bold=self.bold, dim=self.dim, italic=self.italic, + reverse=self.reverse, strike=self.strike, link=self.link, _meta=self._meta, diff --git a/src/textual/widget.py b/src/textual/widget.py index a69a81437b..7c8c8af6ef 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -84,6 +84,7 @@ from textual.reactive import Reactive from textual.renderables.blank import Blank from textual.rlock import RLock +from textual.selection import Selection from textual.strip import Strip from textual.visual import Style as VisualStyle from textual.visual import Visual, visualize @@ -109,29 +110,10 @@ } -_NULL_STYLE = Style() _MOUSE_EVENTS_DISALLOW_IF_DISABLED = (events.MouseEvent, events.Enter, events.Leave) _MOUSE_EVENTS_ALLOW_IF_DISABLED = (events.MouseScrollDown, events.MouseScrollUp) -class Selection(NamedTuple): - """A selected range of lines.""" - - start: Offset | None - end: Offset | None - - def get_selection(self, line_no: int) -> tuple[int, int] | None: - start, end = self - if start is None and end is None: - # Selection covers everything - return 0, -1 - if start is not None and end is not None: - if line_no < start.y or line_no >= end.y: - return None - # TODO - return None - - @rich.repr.auto class AwaitMount: """An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all]. @@ -654,6 +636,10 @@ def _render_widget(self) -> Widget: # Will return the "cover widget" if one is set, otherwise self. return self._cover_widget if self._cover_widget is not None else self + @property + def selection(self) -> Selection | None: + return self.screen.selections.get(self, None) + def _cover(self, widget: Widget) -> None: """Set a widget used to replace the visuals of this widget (used for loading indicator).