Skip to content

Commit

Permalink
click to select
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan committed Dec 22, 2024
1 parent cbe63ca commit 117e390
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 23 deletions.
1 change: 0 additions & 1 deletion src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,6 @@ def get_widget_and_offset_at(
if style and style._meta is not None:
meta = style.meta
if "offset" in meta:
print(segment)
offset_x, offset_y = style.meta["offset"]
offset = Offset(offset_x + (x - start), offset_y)
return widget, offset
Expand Down
14 changes: 12 additions & 2 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ def render_strips(
if not width:
return []

selection = widget.selection
if selection is not None:
selection_style = Style.from_rich_style(
widget.screen.get_component_rich_style("screen--selection")
)

else:
selection_style = None
lines = self.wrap(
width,
align=self._align,
Expand All @@ -285,6 +293,7 @@ def render_strips(
no_wrap=False,
tab_size=8,
selection=widget.selection,
selection_style=selection_style,
)

if height is not None:
Expand Down Expand Up @@ -990,6 +999,7 @@ def wrap(
no_wrap: bool = False,
tab_size: int = 8,
selection: Selection | None = None,
selection_style: Style | None = None,
) -> list[Content]:
lines: list[Content] = []

Expand All @@ -1004,11 +1014,11 @@ def get_span(y: int) -> tuple[int, int] | None:
if "\t" in line._text:
line = line.expand_tabs(tab_size)

if (span := get_span(line_no)) is not None:
if selection_style is not None and (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)
line = line.stylize(selection_style, start, end)

if no_wrap:
new_lines = [line]
Expand Down
6 changes: 6 additions & 0 deletions src/textual/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ def clamped(self) -> Offset:
x, y = self
return Offset(0 if x < 0 else x, 0 if y < 0 else y)

@property
def transpose(self) -> tuple[int, int]:
"""A tuple of x and y, in reverse order, i.e. (Y, X)."""
x, y = self
return y, x

def __bool__(self) -> bool:
return self != (0, 0)

Expand Down
148 changes: 129 additions & 19 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
_css_path_type_as_list,
_make_path_object_relative,
)
from textual._spatial_map import SpatialMap
from textual._types import CallbackType
from textual.await_complete import AwaitComplete
from textual.binding import ActiveBinding, Binding, BindingsMap
Expand All @@ -47,7 +48,7 @@
from textual.css.query import NoMatches, QueryType
from textual.dom import DOMNode
from textual.errors import NoWidget
from textual.geometry import Offset, Region, Size
from textual.geometry import NULL_OFFSET, Offset, Region, Size
from textual.keys import key_to_character
from textual.layout import DockArrangeResult
from textual.reactive import Reactive, var
Expand Down Expand Up @@ -146,6 +147,8 @@ class Screen(Generic[ScreenResultType], Widget):
This CSS applies to the whole app.
"""

COMPONENT_CLASSES = {"screen--selection"}

DEFAULT_CSS = """
Screen {
layout: vertical;
Expand All @@ -170,6 +173,9 @@ class Screen(Generic[ScreenResultType], Widget):
}
}
}
.screen--selection {
background: $primary 50%;
}
}
"""

Expand Down Expand Up @@ -215,9 +221,10 @@ class Screen(Generic[ScreenResultType], Widget):
"""The currently maximized widget, or `None` for no maximized widget."""

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

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

BINDINGS = [
Binding("tab", "app.focus_next", "Focus Next", show=False),
Expand Down Expand Up @@ -317,14 +324,12 @@ def _watch_focused(self):
def _watch_stack_updates(self):
self.refresh_bindings()

def _watch_selections(
async def _watch_selections(
self,
old_selections: dict[Widget, Selection],
selections: dict[Widget, Selection],
):
for widget in old_selections:
widget.refresh()
for widget in selections:
for widget in old_selections.keys() | selections.keys():
widget.refresh()

def refresh_bindings(self) -> None:
Expand Down Expand Up @@ -624,6 +629,17 @@ def find_widget(self, widget: Widget) -> MapGeometry:
"""
return self._compositor.find_widget(widget)

def clear_selection(self) -> None:
"""Clear any selected text."""
self.selections = {}

def _select_all_in_widget(self, widget: Widget) -> None:
select_all = Selection(None, None)
self.selections = {
widget: select_all,
**{child: select_all for child in widget.query("*")},
}

@property
def focus_chain(self) -> list[Widget]:
"""A list of widgets that may receive focus, in focus order."""
Expand Down Expand Up @@ -1421,21 +1437,25 @@ def _forward_event(self, event: events.Event) -> None:
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)
if select_widget is None or select_widget.ALLOW_SELECT:
self.selections = {}
self._selecting = True
if select_widget is not None and select_offset is not None:
self._select_start = (select_widget, event.offset, select_offset)

elif isinstance(event, events.MouseUp):
pass
self._selecting = False

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

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 self._selecting:
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, event.offset, select_offset)

if isinstance(event, events.MouseEvent):
try:
Expand Down Expand Up @@ -1463,14 +1483,104 @@ 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:
def _key_escape(self) -> None:
self.clear_selection()

def watch__select_end(
self, select_end: tuple[Widget, Offset, 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
select_start = self._select_start

start_widget, screen_start, start_offset = select_start
end_widget, screen_end, end_offset = select_end
if start_widget is end_widget:
# Simplest case, selection starts and ends on the same widget
self.selections = {
start_widget: Selection.from_offsets(start_offset, end_offset)
}
return

select_start, select_end = sorted(
[select_start, select_end], key=lambda selection: selection[1].transpose
)
start_widget, screen_start, start_offset = select_start
end_widget, screen_end, end_offset = select_end

select_regions: list[Region] = []
if screen_start.y == screen_end.y:
select_regions.append(
Region.from_corners(
screen_start.x, screen_start.y, screen_end.x, screen_start.y
)
)
else:
select_regions.append(Region.union(start_widget.region, end_widget.region))

# select_regions.append(start_widget.region)
# select_regions.append(end_widget.region)
# x1, y1, x2, y2 = start_widget.region
# top_region = Region.from_corners(x1, y1, container_region.right, y2)

# x1, y1, x2, y2 = end_widget.region
# bottom_region = Region.from_corners(container_region.x, y1, x2, y2)

# if top_region.overlaps(bottom_region):
# select_regions.append(top_region.union(bottom_region))
# else:
# select_regions.append(top_region)
# select_regions.append(bottom_region)

# # Top line
# select_regions.append(
# Region.from_corners(
# screen_start.x,
# screen_start.y,
# screen_end.x,
# screen_start.y,
# )
# )
# # bottom line
# select_regions.append(
# Region.from_corners(
# container_region.x, screen_end.y, screen_end.x, screen_end.y
# )
# )
# if abs(screen_start.y - screen_end.y) > 1:
# select_regions.append(
# Region.from_corners(
# container_region.x,
# screen_start.y,
# screen_end.x,
# screen_end.y,
# )
# )

spatial_map: SpatialMap[Widget] = SpatialMap()
spatial_map.insert(
[
(widget.region, NULL_OFFSET, False, False, widget)
for widget in self._compositor.visible_widgets.keys()
]
)

highlighted_widgets: set[Widget] = set()
for region in select_regions:
covered_widgets = spatial_map.get_values_in_region(region)
covered_widgets = [
widget for widget in covered_widgets if region.overlaps(widget.region)
]
highlighted_widgets.update(covered_widgets)
highlighted_widgets.discard(start_widget)
highlighted_widgets.discard(end_widget)
highlighted_widgets.discard(self)

self.selections = {
start_widget: Selection.from_offsets(start_offset, end_offset)
start_widget: Selection(start_offset, None),
**{widget: Selection(None, None) for widget in highlighted_widgets},
end_widget: Selection(None, end_offset),
}

def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete:
Expand Down
3 changes: 3 additions & 0 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ class MyScrollBarRender(ScrollBarRender): ...

DEFAULT_CLASSES = "-textual-system"

# Nothing to select in scrollbars
ALLOW_SELECT = False

def __init__(
self, vertical: bool = True, name: str | None = None, *, thickness: int = 1
) -> None:
Expand Down
7 changes: 6 additions & 1 deletion src/textual/selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def get_span(self, y: int) -> tuple[int, int] | None:
if y < start.y or y > end.y:
# Outside
return None
if y == start.y == end.y:
if y == start.y and start.y == end.y:
# Same line
return start.x, end.x
if y == end.y:
Expand All @@ -59,9 +59,14 @@ def get_span(self, y: int) -> tuple[int, int] | None:
if start is None and end is not None:
if y == end.y:
return 0, end.x
if y > end.y:
return None
return 0, -1

if end is None and start is not None:
if y == start.y:
return start.x, -1
if y > start.y:
return 0, -1
return None
return 0, -1
20 changes: 20 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@ class Widget(DOMNode):
"""

ALLOW_SELECT: ClassVar[bool] = True
"""Does this widget support automatic text selection?"""

can_focus: bool = False
"""Widget may receive focus."""
can_focus_children: bool = True
Expand Down Expand Up @@ -2305,6 +2308,14 @@ def link_style_hover(self) -> Style:
)
return style

@property
def scrollable_container(self) -> Widget:
container: Widget = self
for widget in self.ancestors:
if isinstance(widget, Widget) and widget.is_scrollable:
container = widget
return container

def _set_dirty(self, *regions: Region) -> None:
"""Set the Widget as 'dirty' (requiring re-paint).
Expand Down Expand Up @@ -4156,6 +4167,9 @@ def release_mouse(self) -> None:
"""
self.app.capture_mouse(None)

def select_all(self) -> None:
self.screen._select_all_in_widget(self)

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 @@ -4213,6 +4227,12 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None:
await self.broker_event("mouse.up", event)

async def _on_click(self, event: events.Click) -> None:
if event.widget is self:
if event.chain == 2:
self.select_all()
elif event.chain == 3 and self.parent is not None:
self.scrollable_container.select_all()

await self.broker_event("click", event)

async def _on_key(self, event: events.Key) -> None:
Expand Down

0 comments on commit 117e390

Please sign in to comment.