Skip to content

Commit

Permalink
click and drag to select
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan committed Dec 19, 2024
1 parent 82d0025 commit 5364fc7
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 42 deletions.
21 changes: 11 additions & 10 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,9 +874,9 @@ def get_style_at(self, x: int, y: int) -> Style:

return Style.null()

def get_style_and_offset_at(
def get_widget_and_offset_at(
self, x: int, y: int
) -> tuple[Style, tuple[int, int] | None]:
) -> tuple[Widget | None, Offset | None]:
"""Get the Style at the given cell, the offset within the content.
Args:
Expand All @@ -889,9 +889,9 @@ def get_style_and_offset_at(
try:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return Style.null(), None
return None, None
if widget not in self.visible_widgets:
return Style.null(), None
return None, None

x -= region.x
y -= region.y
Expand All @@ -900,7 +900,7 @@ def get_style_and_offset_at(
lines = widget.render_lines(Region(0, y, region.width, 1))

if not lines:
return Style.null(), None
return None, None
end = 0
start = 0
for segment in lines[0]:
Expand All @@ -910,13 +910,14 @@ def get_style_and_offset_at(
if style and style._meta is not None:
meta = style.meta
if "offset" in meta:
line, column = style.meta["offset"]
offset = (line, column + (x - start))
return style, offset
print(segment)
offset_x, offset_y = style.meta["offset"]
offset = Offset(offset_x + (x - start), offset_y)
return widget, offset

return (style or Style.null()), None
return None, None
start = end
return Style.null(), None
return None, None

def find_widget(self, widget: Widget) -> MapGeometry:
"""Get information regarding the relative position of a widget in the Compositor.
Expand Down
75 changes: 55 additions & 20 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import re
from operator import itemgetter
from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, Sequence
from typing import TYPE_CHECKING, Callable, Iterable, Literal, NamedTuple, Sequence

import rich.repr
from rich._wrap import divide_line
Expand All @@ -27,7 +27,6 @@
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
Expand Down Expand Up @@ -146,6 +145,8 @@ def __init__(
align: TextAlign = "left",
no_wrap: bool = False,
ellipsis: bool = False,
x: int = 0,
y: int = 0,
) -> None:
"""
Expand All @@ -163,6 +164,11 @@ def __init__(
self._align = align
self._no_wrap = no_wrap
self._ellipsis = ellipsis
self._x = x
self._y = y

def _update_y(self, y: int):
self._y = y

@classmethod
def from_rich_text(
Expand Down Expand Up @@ -250,6 +256,12 @@ def styled(
)
return new_content

@property
def offset(self) -> tuple[int, int] | None:
if self._x is not None and self._y is not None:
return (self._x, self._y)
return None

def get_optimal_width(self, container_width: int) -> int:
lines = self.without_spans.split("\n")
return max(line.cell_length for line in lines)
Expand Down Expand Up @@ -296,6 +308,7 @@ def __hash__(self) -> int:
def __rich_repr__(self) -> rich.repr.Result:
yield self._text
yield "spans", self._spans, []
yield "offset", self.offset, None

@property
def cell_length(self) -> int:
Expand Down Expand Up @@ -516,7 +529,7 @@ def truncate(
text = f"{self.plain}{' ' * spaces}"
length = len(self.plain)
spans = self._trim_spans(text, self._spans)
return Content(text, spans)
return Content(text, spans, x=self._x, y=self._y)

def pad_left(self, count: int, character: str = " ") -> Content:
"""Pad the left with a given character.
Expand Down Expand Up @@ -628,7 +641,7 @@ def right_crop(self, amount: int = 1) -> Content:
]
text = self.plain[:-amount]
length = None if self._cell_length is None else self._cell_length - amount
return Content(text, spans, length)
return Content(text, spans, length, x=self._x, y=self._y)

def stylize(
self, style: Style | str, start: int = 0, end: int | None = None
Expand All @@ -655,6 +668,8 @@ def stylize(
return Content(
self.plain,
[*self._spans, Span(start, length if length < end else end, style)],
x=self._x,
y=self._y,
)

def stylize_before(
Expand Down Expand Up @@ -685,18 +700,20 @@ def stylize_before(
return Content(
self.plain,
[Span(start, length if length < end else end, style), *self._spans],
x=self._x,
y=self._y,
)

def render(
self,
base_style: Style,
end: str = "\n",
parse_style: Callable[[str], Style] | None = None,
) -> Iterable[tuple[str, Style]]:
) -> Iterable[tuple[str, Style, tuple[int, int] | None]]:
if not self._spans:
yield self._text, base_style
yield (self._text, base_style, self.offset)
if end:
yield end, base_style
yield end, base_style, None
return

if parse_style is None:
Expand Down Expand Up @@ -750,25 +767,31 @@ def get_current_style() -> Style:
style_cache[cache_key] = current_style
return current_style

x = self._x or 0
y = self._y or 0
for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
if leaving:
stack_pop(style_id)
else:
stack_append(style_id)
if next_offset > offset:
yield text[offset:next_offset], get_current_style()
yield text[offset:next_offset], get_current_style(), (x + offset, y)
if end:
yield end, base_style
yield end, base_style, None

def render_segments(self, base_style: Style, end: str = "") -> list[Segment]:
_Segment = Segment
render = list(self.render(base_style, end))

segments = [
_Segment(text, style.rich_style)
for text, style in self.render(base_style, end)
_Segment(text, style.get_rich_style(offset))
for text, style, offset in render
]
return segments

def divide(self, offsets: Sequence[int]) -> list[Content]:
def divide(
self, offsets: Sequence[int], axis: Literal["x", "y"] | None = "y"
) -> list[Content]:
if not offsets:
return [self]

Expand All @@ -777,7 +800,17 @@ def divide(self, offsets: Sequence[int]) -> list[Content]:
divide_offsets = [0, *offsets, text_length]
line_ranges = list(zip(divide_offsets, divide_offsets[1:]))

new_lines = [Content(text[start:end]) for start, end in line_ranges]
if axis == "x":
new_lines = [
Content(text[start:end], x=self._x + start, y=self._y)
for start, end in line_ranges
]
elif axis == "y":
new_lines = [
Content(text[start:end], x=self._x, y=self._y + line_no)
for line_no, (start, end) in enumerate(line_ranges)
]

if not self._spans:
return new_lines

Expand Down Expand Up @@ -831,6 +864,7 @@ def split(
*,
include_separator: bool = False,
allow_blank: bool = False,
axis: Literal["x", "y"] = "y",
) -> list[Content]:
"""Split rich text in to lines, preserving styles.
Expand All @@ -850,7 +884,8 @@ def split(

if include_separator:
lines = self.divide(
[match.end() for match in re.finditer(re.escape(separator), text)]
[match.end() for match in re.finditer(re.escape(separator), text)],
axis=axis,
)
else:

Expand All @@ -860,9 +895,11 @@ def flatten_spans() -> Iterable[int]:

lines = [
line
for line in self.divide(list(flatten_spans()))
for line in self.divide(list(flatten_spans()), axis=axis)
if line.plain != separator
]
for y, line in enumerate(lines):
line._update_y(self._y + y)

if not allow_blank and text.endswith(separator):
lines.pop()
Expand Down Expand Up @@ -956,19 +993,16 @@ def wrap(
) -> 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)):
for line_no, line in enumerate(self.split(allow_blank=True, axis="y")):
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
Expand All @@ -980,7 +1014,8 @@ def get_span(y: int) -> tuple[int, int] | None:
new_lines = [line]
else:
offsets = divide_line(line._text, width, fold=overflow == "fold")
new_lines = line.divide(offsets)
new_lines = line.divide(offsets, axis="x")

new_lines = [line.rstrip_end(width) for line in new_lines]
new_lines = _align_lines(new_lines, width, align=align, overflow=overflow)
new_lines = [line.truncate(width, overflow=overflow) for line in new_lines]
Expand Down
46 changes: 35 additions & 11 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ class Screen(Generic[ScreenResultType], Widget):

selections: var[dict[Widget, Selection]] = var(dict)

_select_start: Reactive[tuple[Widget, Offset] | None] = Reactive(None)
_select_end: Reactive[tuple[Widget, Offset] | None] = Reactive(None)

BINDINGS = [
Binding("tab", "app.focus_next", "Focus Next", show=False),
Binding("shift+tab", "app.focus_previous", "Focus Previous", show=False),
Expand Down Expand Up @@ -593,19 +596,19 @@ def get_style_at(self, x: int, y: int) -> Style:
"""
return self._compositor.get_style_at(x, y)

def get_style_and_offset_at(
def get_widget_and_offset_at(
self, x: int, y: int
) -> tuple[Style, tuple[int, int] | None]:
"""Get the style under a given coordinate, and an offset within the original content.
) -> tuple[Widget | None, Offset | None]:
"""Get the widget under a given coordinate, and an offset within the original content.
Args:
x: X Coordinate.
y: Y Coordinate.
Returns:
Tuple of Rich Style and Offset.
Tuple of Widget and Offset, both of which may be None.
"""
return self._compositor.get_style_and_offset_at(x, y)
return self._compositor.get_widget_and_offset_at(x, y)

def find_widget(self, widget: Widget) -> MapGeometry:
"""Get the screen region of a Widget.
Expand Down Expand Up @@ -1414,11 +1417,27 @@ def _forward_event(self, event: events.Event) -> None:
if isinstance(event, (events.Enter, events.Leave)):
self.post_message(event)

if isinstance(event, events.MouseDown):
select_widget, select_offset = self.get_widget_and_offset_at(
event.x, event.y
)
if select_widget is not None and select_offset is not None:
self._select_start = (select_widget, select_offset)

elif isinstance(event, events.MouseUp):
pass

elif isinstance(event, events.MouseMove):
event.style = self.get_style_at(event.screen_x, event.screen_y)
event.style = self.get_style_at(event.x, event.y)
self._handle_mouse_move(event)

elif isinstance(event, events.MouseEvent):
select_widget, select_offset = self.get_widget_and_offset_at(
event.x, event.y
)
if select_widget is not None and select_offset is not None:
self._select_end = (select_widget, select_offset)

if isinstance(event, events.MouseEvent):
try:
if self.app.mouse_captured:
widget = self.app.mouse_captured
Expand All @@ -1432,10 +1451,7 @@ def _forward_event(self, event: events.Event) -> None:
focusable_widget = self.get_focusable_widget_at(event.x, event.y)
if focusable_widget:
self.set_focus(focusable_widget, scroll_visible=False)
event.style, offset = self.get_style_and_offset_at(
event.screen_x, event.screen_y
)
print(offset)
event.style = self.get_style_at(event.screen_x, event.screen_y)
if widget.loading:
return
if widget is self:
Expand All @@ -1447,6 +1463,14 @@ def _forward_event(self, event: events.Event) -> None:
else:
self.post_message(event)

def watch__select_end(self, select_end: tuple[Widget, Offset] | None) -> None:
if select_end is None or self._select_start is None:
return

start_widget, start_offset = self._select_start
end_widget, end_offset = select_end
self.selections = {start_widget: Selection(start_offset, end_offset)}

def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete:
"""Dismiss the screen, optionally with a result.
Expand Down
4 changes: 3 additions & 1 deletion src/textual/selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ def get_span(self, y: int) -> tuple[int, int] | None:
if y == end.y:
# Last line
return 0, end.x
if y == start.y:
return start.x, -1
# Remaining lines
return start.x, -1
return 0, -1

if start is None and end is not None:
if y == end.y:
Expand Down
Loading

0 comments on commit 5364fc7

Please sign in to comment.