Skip to content

Commit 5bedb5a

Browse files
authored
Merge pull request #5467 from Textualize/log-selection
Log selection
2 parents f2b4764 + bbd4ec6 commit 5bedb5a

File tree

8 files changed

+261
-190
lines changed

8 files changed

+261
-190
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
3030
- Added Widget.allow_select method for programmatic control of text selection https://github.com/Textualize/textual/pull/5409
3131
- Added App.ALLOW_SELECT for a global switch to disable text selection https://github.com/Textualize/textual/pull/5409
3232
- Added `DOMNode.query_ancestor` https://github.com/Textualize/textual/pull/5409
33+
- Added selection to Log widget https://github.com/Textualize/textual/pull/5467
3334

3435
### Fixed
3536

src/textual/screen.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242
from textual._spatial_map import SpatialMap
4343
from textual._types import CallbackType
44+
from textual.actions import SkipAction
4445
from textual.await_complete import AwaitComplete
4546
from textual.binding import ActiveBinding, Binding, BindingsMap
4647
from textual.css.match import match
@@ -342,7 +343,7 @@ async def _watch_selections(
342343
selections: dict[Widget, Selection],
343344
):
344345
for widget in old_selections.keys() | selections.keys():
345-
widget.refresh()
346+
widget.selection_updated(selections.get(widget, None))
346347

347348
def refresh_bindings(self) -> None:
348349
"""Call to request a refresh of bindings."""
@@ -868,8 +869,10 @@ def get_selected_text(self) -> str | None:
868869
def action_copy_text(self) -> None:
869870
"""Copy selected text to clipboard."""
870871
selection = self.get_selected_text()
871-
if selection is not None:
872-
self.app.copy_to_clipboard(selection)
872+
if selection is None:
873+
# No text selected
874+
raise SkipAction()
875+
self.app.copy_to_clipboard(selection)
873876

874877
def action_maximize(self) -> None:
875878
"""Action to maximize the currently focused widget."""

src/textual/strip.py

+21
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,24 @@ def text_align(self, width: int, align: AlignHorizontal) -> Strip:
672672
line_pad(self._segments, width - self.cell_length, 0, Style.null()),
673673
width,
674674
)
675+
676+
def apply_offsets(self, x: int, y: int) -> Strip:
677+
"""Apply offsets used in text selection.
678+
679+
Args:
680+
x: Offset on X axis (column).
681+
y: Offset on Y axis (row).
682+
683+
Returns:
684+
New strip.
685+
"""
686+
segments = self._segments
687+
strip_segments: list[Segment] = []
688+
for segment in segments:
689+
text, style, _ = segment
690+
offset_style = Style.from_meta({"offset": (x, y)})
691+
strip_segments.append(
692+
Segment(text, style + offset_style if style else offset_style)
693+
)
694+
x += len(segment.text)
695+
return Strip(strip_segments, self._cell_length)

src/textual/widget.py

+8
Original file line numberDiff line numberDiff line change
@@ -3832,6 +3832,14 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
38323832
return None
38333833
return selection.extract(text), "\n"
38343834

3835+
def selection_updated(self, selection: Selection | None) -> None:
3836+
"""Called when the selection is updated.
3837+
3838+
Args:
3839+
selection: Selection information or `None` if no selection.
3840+
"""
3841+
self.refresh()
3842+
38353843
def _render_content(self) -> None:
38363844
"""Render all lines."""
38373845
width, height = self.size

src/textual/widgets/_log.py

+45-7
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from rich.cells import cell_len
77
from rich.highlighter import Highlighter, ReprHighlighter
8-
from rich.segment import Segment
98
from rich.style import Style
109
from rich.text import Text
1110

@@ -15,6 +14,7 @@
1514
from textual.geometry import Size
1615
from textual.reactive import var
1716
from textual.scroll_view import ScrollView
17+
from textual.selection import Selection
1818
from textual.strip import Strip
1919

2020
if TYPE_CHECKING:
@@ -26,6 +26,7 @@
2626
class Log(ScrollView, can_focus=True):
2727
"""A widget to log text."""
2828

29+
ALLOW_SELECT = True
2930
DEFAULT_CSS = """
3031
Log {
3132
background: $surface;
@@ -75,6 +76,11 @@ def __init__(
7576
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
7677
self.highlighter: Highlighter = ReprHighlighter()
7778
"""The Rich Highlighter object to use, if `highlight=True`"""
79+
self._clear_y = 0
80+
81+
@property
82+
def allow_select(self) -> bool:
83+
return True
7884

7985
@property
8086
def lines(self) -> Sequence[str]:
@@ -251,8 +257,25 @@ def clear(self) -> Self:
251257
self._render_line_cache.clear()
252258
self._updates += 1
253259
self.virtual_size = Size(0, 0)
260+
self._clear_y = 0
254261
return self
255262

263+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
264+
"""Get the text under the selection.
265+
266+
Args:
267+
selection: Selection information.
268+
269+
Returns:
270+
Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted.
271+
"""
272+
text = "\n".join(self._lines)
273+
return selection.extract(text), "\n"
274+
275+
def selection_updated(self, selection: Selection | None) -> None:
276+
self._render_line_cache.clear()
277+
self.refresh()
278+
256279
def render_line(self, y: int) -> Strip:
257280
"""Render a line of content.
258281
@@ -284,6 +307,7 @@ def _render_line(self, y: int, scroll_x: int, width: int) -> Strip:
284307
line = self._render_line_strip(y, rich_style)
285308
assert line._cell_length is not None
286309
line = line.crop_extend(scroll_x, scroll_x + width, rich_style)
310+
line = line.apply_offsets(scroll_x, y)
287311
return line
288312

289313
def _render_line_strip(self, y: int, rich_style: Style) -> Strip:
@@ -296,18 +320,32 @@ def _render_line_strip(self, y: int, rich_style: Style) -> Strip:
296320
Returns:
297321
An uncropped Strip.
298322
"""
299-
if y in self._render_line_cache:
323+
selection = self.selection
324+
if y in self._render_line_cache and self.selection is None:
300325
return self._render_line_cache[y]
301326

302327
_line = self._process_line(self._lines[y])
303328

329+
line_text = Text(_line, no_wrap=True)
330+
line_text.stylize(rich_style)
331+
304332
if self.highlight:
305-
line_text = self.highlighter(Text(_line, style=rich_style, no_wrap=True))
306-
line = Strip(line_text.render(self.app.console), cell_len(_line))
307-
else:
308-
line = Strip([Segment(_line, rich_style)], cell_len(_line))
333+
line_text = self.highlighter(line_text)
334+
if selection is not None:
335+
if (select_span := selection.get_span(y - self._clear_y)) is not None:
336+
start, end = select_span
337+
if end == -1:
338+
end = len(line_text)
339+
340+
selection_style = self.screen.get_component_rich_style(
341+
"screen--selection"
342+
)
343+
line_text.stylize(selection_style, start, end)
344+
345+
line = Strip(line_text.render(self.app.console), cell_len(_line))
309346

310-
self._render_line_cache[y] = line
347+
if selection is not None:
348+
self._render_line_cache[y] = line
311349
return line
312350

313351
def refresh_lines(self, y_start: int, line_count: int = 1) -> None:

0 commit comments

Comments
 (0)