Skip to content

Commit 05a7128

Browse files
committed
copy to clipboard
1 parent 273cc04 commit 05a7128

File tree

4 files changed

+77
-0
lines changed

4 files changed

+77
-0
lines changed

src/textual/content.py

+3
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def __init__(
112112
self._no_wrap = no_wrap
113113
self._ellipsis = ellipsis
114114

115+
def __str__(self) -> str:
116+
return self._text
117+
115118
@classmethod
116119
def from_rich_text(
117120
cls,

src/textual/screen.py

+25
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ class Screen(Generic[ScreenResultType], Widget):
237237
BINDINGS = [
238238
Binding("tab", "app.focus_next", "Focus Next", show=False),
239239
Binding("shift+tab", "app.focus_previous", "Focus Previous", show=False),
240+
Binding("ctrl+c", "screen.copy_text", "Copy selected text", show=False),
240241
]
241242

242243
def __init__(
@@ -833,6 +834,30 @@ def minimize(self) -> None:
833834
self.scroll_to_widget, self.focused, animate=False, center=True
834835
)
835836

837+
def get_selected_text(self) -> str | None:
838+
"""Get text under selection.
839+
840+
Returns:
841+
Selected text, or `None` if no text was selected.
842+
"""
843+
if not self.selections:
844+
return None
845+
846+
widget_text: list[str] = []
847+
for widget, selection in self.selections.items():
848+
selected_text_in_widget = widget.get_selection(selection)
849+
if selected_text_in_widget is not None:
850+
widget_text.append(selected_text_in_widget)
851+
852+
selected_text = "\n".join(widget_text)
853+
return selected_text
854+
855+
def action_copy_text(self) -> None:
856+
"""Copy selected text to clipboard."""
857+
selection = self.get_selected_text()
858+
if selection is not None:
859+
self.app.copy_to_clipboard(selection)
860+
836861
def action_maximize(self) -> None:
837862
"""Action to maximize the currently focused widget."""
838863
if self.focused is not None:

src/textual/selection.py

+32
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,38 @@ def from_offsets(cls, offset1: Offset, offset2: Offset) -> Selection:
2727
offsets = sorted([offset1, offset2], key=(lambda offset: (offset.y, offset.x)))
2828
return cls(*offsets)
2929

30+
def extract(self, text: str) -> str:
31+
"""Extract selection from text.
32+
33+
Args:
34+
text: Raw text pulled from widget.
35+
36+
Returns:
37+
Extracted text.
38+
"""
39+
lines = text.splitlines()
40+
if self.start is None:
41+
start_line = 0
42+
start_offset = 0
43+
else:
44+
start_line, start_offset = self.start.transpose
45+
46+
if self.end is None:
47+
end_line = len(lines) - 1
48+
end_offset = len(lines[end_line])
49+
else:
50+
end_line, end_offset = self.end.transpose
51+
52+
if start_line == end_line:
53+
return lines[start_line][start_offset:end_offset]
54+
55+
selection: list[str] = []
56+
first_line, *mid_lines, last_line = lines[start_line:end_line]
57+
selection.append(first_line[start_offset:])
58+
selection.extend(mid_lines)
59+
selection.append(last_line[: end_offset + 1])
60+
return "\n".join(selection)
61+
3062
def get_span(self, y: int) -> tuple[int, int] | None:
3163
"""Get the selected span in a given line.
3264

src/textual/widget.py

+17
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from textual.box_model import BoxModel
6262
from textual.cache import FIFOCache
6363
from textual.color import Color
64+
from textual.content import Content
6465
from textual.css.match import match
6566
from textual.css.parse import parse_selectors
6667
from textual.css.query import NoMatches, WrongType
@@ -3810,6 +3811,22 @@ def visual_style(self) -> VisualStyle:
38103811
strike=style.strike,
38113812
)
38123813

3814+
def get_selection(self, selection: Selection) -> str | None:
3815+
"""Get the text under the selection.
3816+
3817+
Args:
3818+
selection: Selection information.
3819+
3820+
Returns:
3821+
Extracted text, or `None` if no text could be extracted.
3822+
"""
3823+
visual = self._render()
3824+
if isinstance(visual, (Text, Content)):
3825+
text = str(visual)
3826+
else:
3827+
return None
3828+
return selection.extract(text)
3829+
38133830
def _render_content(self) -> None:
38143831
"""Render all lines."""
38153832
width, height = self.size

0 commit comments

Comments
 (0)