diff --git a/CHANGELOG.md b/CHANGELOG.md index f34ee2b40d..9dff90fea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [8.2.0] - 2026-03-27 + +### Added + +- Auto-scrolling on select https://github.com/Textualize/textual/pull/6440 +- Selecting over containers https://github.com/Textualize/textual/pull/6440 +- Added `App.ENABLE_SELECT_AUTO_SCROLL`, `App.SELECT_AUTO_SCROLL_LINES`, `App.SELECT_AUTO_SCROLL_SPEED` to tweak auto scrolling behavior https://github.com/Textualize/textual/pull/6440 + ## [8.1.1] - 2026-03-10 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index a1ca675c94..7c6477dedd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "8.1.1" +version = "8.2.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/_auto_scroll.py b/src/textual/_auto_scroll.py new file mode 100644 index 0000000000..8d71f91fa9 --- /dev/null +++ b/src/textual/_auto_scroll.py @@ -0,0 +1,30 @@ +from textual.geometry import Region + + +def get_auto_scroll_regions( + widget_region: Region, auto_scroll_lines: int +) -> tuple[Region, Region]: + """Get non-overlapping regions which should auto scroll when selecting. + + Args: + widget_region: The region occupied by the widget. + auto_scroll_lines: Number of lines in auto scroll regions. + + Returns: + A pair of regions. The first for the region to scroll up, the second for the region to scroll down. + """ + x, y, width, height = widget_region + + # Divide the region in to two, non overlapping regions + top_half, bottom_half = widget_region.split_horizontal(height // 2) + + # Get a region at the top with the desired dimensions + up_region = Region(x, y, width, auto_scroll_lines) + # Ensure it is no larger than the top half + up_region = top_half.intersection(up_region) + + # Repeat for the bottom half + down_region = Region(x, y + height - auto_scroll_lines, width, auto_scroll_lines) + down_region = bottom_half.intersection(down_region) + + return up_region, down_region diff --git a/src/textual/app.py b/src/textual/app.py index a0974af08e..df9db50376 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -514,6 +514,15 @@ class MyApp(App[None]): PAUSE_GC_ON_SCROLL: ClassVar[bool] = False """Pause Python GC (Garbage Collection) when scrolling, for potentially smoother scrolling with many widgets (experimental).""" + ENABLE_SELECT_AUTO_SCROLL: ClassVar[bool] = True + """Enable automatic scrolling if selecting and the mouse is at the top or bottom of the widget?""" + + SELECT_AUTO_SCROLL_LINES: ClassVar[int] = 3 + """Number of lines in auto-scrolling regions at the top and bottom of a widget.""" + + SELECT_AUTO_SCROLL_SPEED: ClassVar[float] = 60.0 + """Maximum speed of select auto-scroll in lines per second.""" + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = { "focus": lambda app: app.app_focus, "blur": lambda app: not app.app_focus, @@ -631,7 +640,12 @@ def __init__( self._action_targets = {"app", "screen", "focused"} self._animator = Animator(self) self._animate = self._animator.bind(self) + self.mouse_position = Offset(0, 0) + """The current screen-space mouse position.""" + + self.mouse_position_high_resolution: tuple[float, float] = (0.0, 0.0) + """A high resolution (floating point) mouse position. If supported by the terminal, this may be more granular than `mouse_position`""" self._mouse_down_widget: Widget | None = None """The widget that was most recently mouse downed (used to create click events).""" @@ -4019,6 +4033,7 @@ async def on_event(self, event: events.Event) -> None: if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) + self.mouse_position_high_resolution = (event.screen_x, event.screen_y) if isinstance(event, events.MouseDown): try: self._mouse_down_widget, _ = self.get_widget_at( diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e86c73aeaa..7577d2cb43 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -12,6 +12,7 @@ TYPE_CHECKING, Any, Collection, + Iterable, Literal, NamedTuple, Tuple, @@ -25,6 +26,7 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias +import rich.repr SpacingDimensions: TypeAlias = Union[ int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int] @@ -1316,6 +1318,155 @@ def grow_maximum(self, other: Spacing) -> Spacing: ) +class Shape: + """An arbitrary shape defined by a sequence of regions. + + This class currently exists to filter widgets within a shape defined when the user is slecting text. + + """ + + __slots__ = [ + "_regions", + "_bounds", + ] + + def __init__(self, regions: Iterable[Region]): + """ + + Args: + regions: Regions which will define the shape. + """ + self._regions = tuple(regions) + self._bounds = Region.from_union(self._regions) if regions else NULL_REGION + + def __bool__(self) -> bool: + return bool(self._bounds) + + def __hash__(self) -> int: + return hash(self._regions) + + def __rich_repr__(self) -> rich.repr.Result: + yield self._regions + + @property + def regions(self) -> tuple[Region, ...]: + """The regions in the shape.""" + return self._regions + + @property + def bounds(self) -> Region: + """A region that encloses the shape.""" + return self._bounds + + @property + def area(self) -> int: + """Cells covered by the shape.""" + # TODO: Currently doesn't handle overlapping regions + return sum(region.area for region in self._regions) + + @classmethod + def selection_bounds(cls, container: Region, start: Offset, end: Offset) -> Shape: + """Get a shape that would be constructed by a user selecting text between two points. + + The shape would look something like this: + + ``` + XXXXXXXXXX <- top + XXXXXXXXXXXXXX + XXXXXXXXXXXXXX <- middle + XXXXXXXXXXXXXX + XXXXXXXXX <- bottom + ``` + + Args: + container: The container region for the selection. + start: The start offset. + end: The end offset. + + Returns: + A new shape covering the selection bounds. + """ + if start.transpose > end.transpose: + end, start = start, end + start_x, start_y = start + end_x, end_y = end + + def get_regions() -> Iterable[Region]: + """Get regions to cover selection bounds. + + Yields: + Regions to cover bounds. + """ + # Special case where start and end offsets are on the edges, and the shape + # becomes a single region + if start_x == 0 and end_x == container.width: + yield Region( + 0, + start_y, + container.width, + end_y - start_y, + ) + + # Simple case: all on one line + elif start.y == end.y: + yield Region( + start_x, + start_y, + end_x - start_x, + 1, + ) + + # Shape is on two or more lines + else: + # top + yield Region( + start_x, + start_y, + container.width - start_x, + 1, + ) + # middle + if end.y - start.y > 2: + # We need a middle region between the top and the bottom + yield Region( + 0, + start_y + 1, + container.width, + end_y - start_y - 1, + ) + # bottom + yield Region( + container.x, + end_y, + end_x, + 1, + ) + + return Shape(get_regions()) + + def overlaps(self, region: Region) -> bool: + """Does a region overlap this shape? + + Args: + region: A Region to check. + + Returns: + `True` if any part of the shape overlaps the region, `False` if there is no overlap. + """ + return any(shape_region.overlaps(region) for shape_region in self._regions) + + def contains_point(self, offset: Offset) -> bool: + """Check if the given offset is within the shape. + + Args: + offset: An offset. + + Returns: + `True` if the given offset is anywhere within the shape, otherwise `False`. + """ + return any(region.contains_point(offset) for region in self._regions) + + if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1": try: from textual_speedups import Offset, Region, Size, Spacing @@ -1324,7 +1475,7 @@ def grow_maximum(self, other: Spacing) -> Spacing: NULL_OFFSET: Final = Offset(0, 0) -"""An [offset][textual.geometry.Offset] constant for (0, 0).""" +"""An [Offset][textual.geometry.Offset] constant for (0, 0).""" NULL_REGION: Final = Region(0, 0, 0, 0) """A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero).""" diff --git a/src/textual/screen.py b/src/textual/screen.py index c3e4fbe4e9..e4d796d8bd 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -20,6 +20,7 @@ Generic, Iterable, Iterator, + Literal, NamedTuple, Optional, TypeVar, @@ -32,6 +33,7 @@ from textual import constants, errors, events, messages from textual._arrange import arrange +from textual._auto_scroll import get_auto_scroll_regions from textual._callback import invoke from textual._compositor import Compositor, MapGeometry from textual._context import active_message_pump, visible_screen_stack @@ -40,7 +42,6 @@ _css_path_type_as_list, _make_path_object_relative, ) -from textual._spatial_map import SpatialMap from textual._types import CallbackType from textual.actions import SkipAction from textual.await_complete import AwaitComplete @@ -51,7 +52,7 @@ from textual.css.styles import PointerShape from textual.dom import DOMNode from textual.errors import NoWidget -from textual.geometry import NULL_OFFSET, Offset, Region, Size +from textual.geometry import Offset, Region, Shape, Size from textual.keys import key_to_character from textual.layout import DockArrangeResult from textual.reactive import Reactive, var @@ -60,6 +61,7 @@ from textual.selection import SELECT_ALL, Selection from textual.signal import Signal from textual.timer import Timer +from textual.walk import walk_selectable_widgets from textual.widget import Widget from textual.widgets import Tooltip from textual.widgets._toast import ToastRack @@ -328,6 +330,9 @@ def __init__( self._layout_widgets: dict[DOMNode, set[Widget]] = {} """Widgets whose layout may have changed.""" + self._auto_select_scroll_timer: Timer | None = None + """A timer to auto scroll a container.""" + @property def is_modal(self) -> bool: """Is the screen modal?""" @@ -1680,6 +1685,154 @@ def _translate_mouse_move_event( style=event.style, ) + def _start_auto_scroll( + self, + widget: Widget, + direction: Literal[+1, -1], + speed: float = 1.0, + ) -> None: + """Start (or update) auto scrolling. + + Args: + widget: Container widget to scroll. + direction: Direction: `+1` for up, `-1` for down. + speed: The scroll speed as a factor of the maximum. + """ + assert speed > 0, "Speed should be positive and non-zero" + + def _auto_scroll_y(widget: Widget, direction: float) -> None: + """Scroll a container a single line in the given direction. + + Args: + widget: Container widgets to scroll. + direction: Lines to scroll. + """ + if self._select_start is not None: + # Update scroll position + widget.scroll_y += direction + widget.scroll_target_y = widget.scroll_y + # Update selection highlights which may have changed due to the scroll + self._update_select(self.app.mouse_position) + + # Replace current timer + self._stop_auto_scroll() + + # Lines to scroll per frame (may be fractional) + lines_to_scroll = ( + direction * (self.app.SELECT_AUTO_SCROLL_SPEED / constants.MAX_FPS) * speed + ) + # Callable to perform scroll + scroll_callback = partial(_auto_scroll_y, widget, lines_to_scroll) + # Perform initial scroll + scroll_callback() + # Start a timer to perform future scrolling + # This is so the user doesn't have to move the mouse to keep scrolling + self._auto_select_scroll_timer = self.set_interval( + 1 / constants.MAX_FPS, scroll_callback + ) + + def _stop_auto_scroll(self) -> None: + """Stop any auto scrolling.""" + if self._auto_select_scroll_timer is not None: + self._auto_select_scroll_timer.stop() + self._auto_select_scroll_timer = None + + def _check_auto_scroll( + self, + select_widget: Widget, + mouse_coordinate: tuple[float, float], + delta_y: float, + ) -> None: + """Check auto-scrolling when selecting. + + This will start, update, or stop a timer used to move the scroll position. + + Args: + select_widget: The widget under the mouise pointer. + mouse_coordinate: The screen-space mouse pointer. + delta_y: Change in mouse y since previous mouse move. + """ + + if not self.app.ENABLE_SELECT_AUTO_SCROLL: + # Disabled by app + return + + if self._auto_select_scroll_timer is None and abs(delta_y) < 1: + # Mouse has moved horizontally, not vertically, so we assume the user doesn't want to scroll + return + + mouse_x, mouse_y = mouse_coordinate + mouse_offset = Offset(int(mouse_x), int(mouse_y)) + + # We want to find any scrollable regions further up the DOM, + # and apply auto scrolling if we are in a region at the top or bottom + for ancestor in select_widget.ancestors_with_self: + if not isinstance(ancestor, Widget): + break + if not ancestor.allow_vertical_scroll: + # Can't scroll, so check the next ancestor + continue + ancestor_region = ancestor.content_region + scroll_lines = self.app.SELECT_AUTO_SCROLL_LINES + up_region, down_region = get_auto_scroll_regions( + ancestor_region, + auto_scroll_lines=scroll_lines, + ) + if mouse_offset in up_region: + # Mouse is in the up region + if ancestor.scroll_y > 0: + # And there is room to scroll + # Speed increases the closer we are to the edge + speed = (scroll_lines - (mouse_y - up_region.y)) / scroll_lines + if speed: + self._start_auto_scroll(ancestor, -1, speed) + return + elif mouse_offset in down_region: + # Mouse is in the down region + if ancestor.scroll_y < ancestor.max_scroll_y: + # And there is room to scroll + speed = (mouse_y - down_region.y) / scroll_lines + if speed: + self._start_auto_scroll(ancestor, +1, speed) + return + # Nothing to auto scroll, so stop the timer + self._stop_auto_scroll() + + def _update_select(self, screen_offset: Offset) -> None: + """Update select for a screen-space offset (typically the mouse position). + + This updates the `_select_end` reactrive, which will trigger the watch method `watch__select_end`. + + Args: + screen_offset: Screen-space position (i.e. mouse position). + """ + select_widget, select_offset = self.get_widget_and_offset_at( + screen_offset.x, screen_offset.y + ) + if ( + self._select_end is not None + and select_offset is None + and screen_offset.y > self._select_end[1].y + ): + end_widget = self._select_end[0] + select_offset = end_widget.content_region.bottom_right_inclusive + self._select_end = ( + end_widget, + screen_offset, + select_offset, + ) + + elif ( + select_widget is not None + and select_widget.allow_select + and select_offset is not None + ): + self._select_end = ( + select_widget, + screen_offset, + select_offset, + ) + def _forward_event(self, event: events.Event) -> None: if event.is_forwarded: return @@ -1692,7 +1845,8 @@ def _forward_event(self, event: events.Event) -> None: event.style = self.get_style_at(event.screen_x, event.screen_y) self._handle_mouse_move(event) - if self._selecting: + if self._selecting and self._select_start is not None: + self._box_select = event.shift select_widget, select_offset = self.get_widget_and_offset_at( event.x, event.y @@ -1721,13 +1875,31 @@ def _forward_event(self, event: events.Event) -> None: select_offset, ) + if select_widget is not None: + self._check_auto_scroll( + select_widget, + (event.pointer_screen_x, event.pointer_screen_y), + event.delta_y, + ) + else: + self._stop_auto_scroll() + elif isinstance(event, events.MouseEvent): if isinstance(event, events.MouseUp): if ( self._mouse_down_offset is not None and self._mouse_down_offset == event.screen_offset ): - self.clear_selection() + # A click elsewhere should clear the selection + select_widget, select_offset = self.get_widget_and_offset_at( + event.x, event.y + ) + # Exclude scrollbars, so the user may navigate without clearing the selection + if select_widget is None or not select_widget.has_class( + "-textual-system" + ): + self.clear_selection() + self._mouse_down_offset = None self._selecting = False self.post_message(events.TextSelected()) @@ -1787,6 +1959,52 @@ def _forward_event(self, event: events.Event) -> None: def _key_escape(self) -> None: self.clear_selection() + def _watch__selecting(self, selecting: bool) -> None: + if not selecting: + self._stop_auto_scroll() + + @classmethod + def _collect_select_widgets( + cls, + selection_bounds: Shape, + container: Widget, + start_widget: Widget, + end_widget: Widget, + ) -> list[Widget]: + """Get widgets between two widgets in select order. + + Args: + container: A parent widgets. + start_widget: First widget. + end_widget: Second widget. + + Returns: + Widgets between start and end, in select sort order. + """ + + widgets = list( + walk_selectable_widgets( + container, + selection_bounds, + {start_widget, end_widget}, + ) + ) + + index1: int | None = None + try: + index1 = widgets.index(start_widget) + 1 + except ValueError: + pass + + index2: int | None = None + try: + index2 = widgets.index(end_widget) + except ValueError: + pass + + results = widgets[index1:index2] + return results + def _watch__select_end( self, select_end: tuple[Widget, Offset, Offset] | None ) -> None: @@ -1795,98 +2013,65 @@ def _watch__select_end( Args: select_end: The end selection. """ + if select_end is None or self._select_start is None: # Nothing to select return - select_start = self._select_start - start_widget, screen_start, start_offset = select_start + start_widget, screen_start, start_offset = self._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 + if end_offset.transpose < start_offset.transpose: + start_offset, end_offset = end_offset, start_offset self.selections = { - start_widget: Selection.from_offsets(start_offset, end_offset) + start_widget: Selection.from_offsets( + start_offset, + end_offset + (1, 0), + ) } return - select_start, select_end = sorted( - [select_start, select_end], - key=lambda selection: (selection[0].region.offset.transpose), - ) + # The start selection may have been scrolled since it was saved + # We need to adjust to the new screen-space position + select_start = (start_widget, start_widget.region.offset, start_offset) + # Ensure select_start is < select_end in selection order + if select_start[0]._selection_order > select_end[0]._selection_order: + select_start, select_end = select_end, select_start - start_widget, _screen_start, start_offset = select_start - end_widget, _screen_end, end_offset = select_end + start_widget, screen_start, start_offset = select_start + end_widget, screen_end, end_offset = select_end - select_regions: list[Region] = [] - start_region = start_widget.content_region - end_region = end_widget.content_region - if end_region.y <= start_region.bottom or self._box_select: - select_regions.append(Region.union(start_region, end_region)) - else: - try: - container_region = Region.from_union( - [ - start_widget.select_container.content_region, - end_widget.select_container.content_region, - ] - ) - except NoMatches: - return + if (screen_start + start_offset).transpose > ( + screen_end + end_offset + ).transpose: + start_widget, end_widget = end_widget, start_widget - start_region = Region.from_corners( - start_region.x, - start_region.y, - container_region.right, - start_region.bottom, - ) - end_region = Region.from_corners( - container_region.x, - end_region.y, - end_region.right, - end_region.bottom, - ) - select_regions.append(start_region) - select_regions.append(end_region) - mid_height = end_region.y - start_region.bottom - if mid_height > 0: - mid_region = Region.from_corners( - container_region.x, - start_region.bottom, - container_region.right, - start_region.bottom + mid_height, - ) - select_regions.append(mid_region) + # Get a widget which contains both widgets + container_widget = Widget.get_common_ancestor(start_widget, end_widget) - spatial_map: SpatialMap[Widget] = SpatialMap() - spatial_map.insert( - [ - (widget.region, NULL_OFFSET, False, False, widget) - for widget in self._compositor.visible_widgets.keys() - ] + # Get a selection bounds shape + selection_bounds = Shape.selection_bounds( + container_widget.region, + select_start[1] + select_start[2], + self.app.mouse_position, ) - 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.content_region) - ] - highlighted_widgets.update(covered_widgets) - highlighted_widgets -= {self, start_widget, end_widget} + # Get widgets bounded by the selection bounds + select_widgets = self._collect_select_widgets( + selection_bounds, + container_widget, + start_widget, + end_widget, + ) + # Build the selection select_all = SELECT_ALL self.selections = { start_widget: Selection(start_offset, None), - **{ - widget: select_all - for widget in sorted( - highlighted_widgets, - key=lambda widget: widget.content_region.offset.transpose, - ) - }, - end_widget: Selection(None, end_offset), + **{widget: select_all for widget in select_widgets}, + end_widget: Selection(None, end_offset + (1, 0)), } def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: diff --git a/src/textual/walk.py b/src/textual/walk.py index 43b8d4b6e4..6a04f80df2 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -9,10 +9,14 @@ from __future__ import annotations from collections import deque +from operator import attrgetter from typing import TYPE_CHECKING, Iterable, Iterator, TypeVar, overload +from textual.geometry import Shape + if TYPE_CHECKING: from textual.dom import DOMNode + from textual.widget import Widget WalkType = TypeVar("WalkType", bound=DOMNode) @@ -167,3 +171,50 @@ def walk_breadth_search_id( return found_node queue.extend(node._nodes) return None + + +def walk_selectable_widgets( + root: DOMNode, bounds: Shape, bounded: set[DOMNode] +) -> Iterable[Widget]: + """Walk the tree depth first in select order (top to bottom, then left to right). + + Args: + root: The root note (starting point). + bounds: A Shape object that defines the selection bounds. + bounded: Container widgets that require a bounds check. + + Returns: + An iterable of DOMNodes. + """ + stack: list[Iterator[Widget]] = [iter(root.children)] + pop = stack.pop + push = stack.append + + get_selection_order = attrgetter("_selection_order") + + def get_children(node: DOMNode) -> list[Widget]: + """Get children, sorted in selection order, and potentially filtered by selection bounds. + + Args: + node: A root node. + + Returns: + A list of child widgets. + """ + children = sorted( + node.displayed_and_visible_children, + key=get_selection_order, + ) + if node in bounded: + children = [child for child in children if bounds.overlaps(child.region)] + return children + + children = get_children(root) + + while stack: + if (node := next(stack[-1], None)) is None: + pop() + elif node.allow_select: + yield node + if children := get_children(node): + push(iter(children)) diff --git a/src/textual/widget.py b/src/textual/widget.py index be2e9ad893..b7b08c4c89 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -689,6 +689,28 @@ def text_selection(self) -> Selection | None: """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) + @classmethod + def get_common_ancestor(cls, widget1: Widget, widget2: Widget) -> Widget: + """Get a common ancestors to both widgets. + + Raises: + ValueError: If there is not common ancestor (will not occur if both widgets are attached to the same DOM). + + Args: + widget1: A Widget. + widget2: A second widgets. + + Returns: + A common ancestor widgets. + """ + ancestors1 = widget1.ancestors + ancestors2 = set(widget2.ancestors) + for node in ancestors1: + if node in ancestors2: + assert isinstance(node, Widget) + return node + raise ValueError("No common ancestor found") + def focus_on_click(self) -> bool: """Automatically focus the widget on click? @@ -2245,6 +2267,15 @@ def content_size(self) -> Size: """ return self.region.shrink(self.styles.gutter).size + @property + def _selection_order(self) -> tuple[int, int]: + """A tuple of integers used to sort widgets in selection order.""" + try: + x, y, _width, _height = self.screen.find_widget(self).region + except (NoScreen, errors.NoWidget): + return (0, 0) + return y, x + @property def region(self) -> Region: """The region occupied by this widget, relative to the Screen. @@ -2791,7 +2822,7 @@ def allow_select(self) -> bool: Returns: `True` if the widget supports text selection, otherwise `False`. """ - return self.ALLOW_SELECT and not self.is_container + return self.ALLOW_SELECT def pre_layout(self, layout: Layout) -> None: """This method id called prior to a layout operation. @@ -4175,6 +4206,11 @@ def visual_style(self) -> VisualStyle: def get_selection(self, selection: Selection) -> tuple[str, str] | None: """Get the text under the selection. + !!! note + Implement this method if are building custom widget. If you just want to get the currently + selected text, then see [`Screen.get_selected_text`](textual.screen.Screen.get_selected_text) + + Args: selection: Selection information. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg index 7ea9688b59..5436a4b1fd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg @@ -198,7 +198,7 @@ - + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃┃┃┃ @@ -242,7 +242,7 @@ I will face my fear.┃┃I will face my fear.┃┃I will face my fear. I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me. Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner -eye to see its path.┃┃eye to see its path.┃┃eye to see its path. +eye to see its path.┃┃eye to see its path.┃┃eye to see its path. Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing. Only I will remain.┃┃Only I will remain.┃┃Only I will remain. ┃┃┃┃ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection_double_cell.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection_double_cell.svg index 96d2a861f1..9dbdb59ef9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection_double_cell.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection_double_cell.svg @@ -19,131 +19,131 @@ font-weight: 700; } - .terminal-3474746330-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3474746330-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3474746330-r1 { fill: #e0e0e0 } -.terminal-3474746330-r2 { fill: #c5c8c6 } + .terminal-r1 { fill: #e0e0e0 } +.terminal-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LApp + LApp - - - - 😃Hello World! - - - - - - - - - - - - - - - - - - - - - - + + + + 😃Hello World! + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_auto_scroll.py b/tests/test_auto_scroll.py new file mode 100644 index 0000000000..67f63cbe40 --- /dev/null +++ b/tests/test_auto_scroll.py @@ -0,0 +1,43 @@ +import pytest + +from textual._auto_scroll import get_auto_scroll_regions +from textual.geometry import Region + + +@pytest.mark.parametrize( + "region, lines, expected", + [ + # Simple case + ( + Region(0, 0, 100, 20), + 1, + (Region(0, 0, 100, 1), Region(0, 19, 100, 1)), + ), + # Simple case, more lines + ( + Region(0, 0, 100, 20), + 3, + (Region(0, 0, 100, 3), Region(0, 17, 100, 3)), + ), + # Potentially overlapping case + ( + Region(0, 0, 100, 5), + 3, + (Region(0, 0, 100, 2), Region(0, 2, 100, 3)), + ), + # Gross overlapping case, scroll zones should still occupy half + ( + Region(0, 0, 100, 5), + 5, + (Region(0, 0, 100, 2), Region(0, 2, 100, 3)), + ), + ], +) +def test_auto_scroll_regions( + region: Region, lines: int, expected: tuple[Region, Region] +): + """Test calculation of auto scrolling select zones.""" + print(region, lines) + result = get_auto_scroll_regions(region, lines) + print(result) + assert result == expected diff --git a/tests/test_geometry.py b/tests/test_geometry.py index f290429815..c028454b85 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -2,7 +2,7 @@ import pytest -from textual.geometry import Offset, Region, Size, Spacing, clamp +from textual.geometry import Offset, Region, Shape, Size, Spacing, clamp def test_dimensions_region(): @@ -580,3 +580,252 @@ def test_constrain( expected: Region, ) -> None: assert region.constrain(constrain_x, constrain_y, margin, container) == expected + + +def test_shape_null(): + """Test a null shape (shape with no regions).""" + null_shape = Shape([]) + assert isinstance(repr(null_shape), str) + assert not null_shape + assert not null_shape.contains_point(Offset()) + + +@pytest.mark.parametrize( + "shape,point,expected", + [ + ( + Shape([Region(10, 10, 5, 5)]), + Offset(0, 0), + False, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(10, 0), + False, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(10, 1), + False, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(10, 10), + True, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(14, 10), + True, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(14, 14), + True, + ), + ( + Shape([Region(10, 10, 5, 5)]), + Offset(15, 10), + False, + ), + ], +) +def test_shape_contains_point_simple( + shape: Shape, point: Offset, expected: bool +) -> None: + """Test shape.contains_point""" + assert isinstance(repr(shape), str) + assert shape.contains_point(point) == expected + + +def build_grid_snapshot(shape: Shape) -> str: + """Build a string with a 2D grid of results from contains_point.""" + + # Add a padding of 2 cells around the output, for clarity. + width = shape.bounds.right + 2 + height = shape.bounds.bottom + 2 + + map: list[list[str]] = [] + for y in range(height): + map.append([".X"[shape.contains_point(Offset(x, y))] for x in range(width)]) + + return "\n".join("".join(line) for line in map) + + +def test_shape_contains_point(): + """Test contains_point with multi region shape""" + shape = Shape( + [ + Region(2, 2, 4, 4), + Region(3, 5, 5, 3), + ] + ) + expected = """\ +.......... +.......... +..XXXX.... +..XXXX.... +..XXXX.... +..XXXXXX.. +...XXXXX.. +...XXXXX.. +.......... +..........""" + result = build_grid_snapshot(shape) + print(repr(result)) + assert result == expected + + +def test_selection_bounds_contains_point(): + """Test selection bounds shape and contains_point""" + + shape = Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(4, 1), + Offset(6, 4), + ) + expected = """\ +............ +....XXXXXX.. +XXXXXXXXXX.. +XXXXXXXXXX.. +XXXXXX...... +............ +............""" + result = build_grid_snapshot(shape) + print(result) + assert result == expected + + +def test_selection_bounds_contains_point_simple_case(): + """Test selection bounds shape and contains_point""" + + shape = Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(0, 1), + Offset(10, 4), + ) + # Should result in a single region + assert len(shape.regions) == 1 + expected = """\ +............ +XXXXXXXXXX.. +XXXXXXXXXX.. +XXXXXXXXXX.. +............ +............""" + result = build_grid_snapshot(shape) + print(result) + assert result == expected + + +def test_selection_bounds_contains_point_single_line(): + """Test selection bonds shape and single point where the offsets are on the same line""" + shape = Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(2, 1), + Offset(6, 1), + ) + expected = """\ +........ +..XXXX.. +........ +........""" + result = build_grid_snapshot(shape) + print(result) + assert result == expected + + +def test_selection_bounds_contains_point_two_lines(): + """Test selection bonds shape and single point where the offsets are over two lines""" + shape = Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(2, 1), + Offset(6, 2), + ) + expected = """\ +............ +..XXXXXXXX.. +XXXXXX...... +............ +............""" + result = build_grid_snapshot(shape) + print(result) + assert result == expected + + +@pytest.mark.parametrize( + "bounds,area", + [ + # Simple case, selection defines a box + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(0, 0), + Offset(10, 2), + ), + 20, + ), + # Start of selection is inse7 + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(1, 0), + Offset(10, 2), + ), + 19, + ), + # End of selection is inset + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(1, 0), + Offset(9, 2), + ), + 18, + ), + ], +) +def test_shape_area(bounds, area): + assert bounds.area == area + + +@pytest.mark.parametrize( + "bounds,region,expected", + [ + # Simple case + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(0, 0), + Offset(0, 6), + ), + Region(0, 0, 1, 1), + True, + ), + # Outside first line + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(2, 0), + Offset(0, 6), + ), + Region(0, 0, 1, 1), + False, + ), + # Point inside first line + ( + Shape.selection_bounds( + Region(0, 0, 10, 8), + Offset(2, 0), + Offset(0, 6), + ), + Region(2, 0, 1, 1), + True, + ), + ], +) +def test_selection_bounds_overlaps(bounds: Shape, region: Region, expected: bool): + overlaps = bounds.overlaps(region) + print(overlaps) + assert overlaps is expected diff --git a/tests/test_selection.py b/tests/test_selection.py index 83fca7653d..446fb4ecfd 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -40,6 +40,6 @@ def compose(self) -> ComposeResult: await pilot.pause() assert await pilot.mouse_up(offset=(7, 1)) selected_text = app.screen.get_selected_text() - expected = "❤️👍Select😊🙏😍\nme🔥💯😭" + expected = "❤️👍Select😊🙏😍\nme🔥💯😭😂" assert selected_text == expected diff --git a/tests/test_widget.py b/tests/test_widget.py index 01b54b4888..ee32789367 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -5,7 +5,7 @@ from textual import events from textual._node_list import DuplicateIds from textual.app import App, ComposeResult -from textual.containers import Container +from textual.containers import Container, Vertical from textual.content import Content from textual.css.errors import StyleValueError from textual.css.query import NoMatches @@ -693,3 +693,26 @@ def compose(self) -> ComposeResult: async with app.run_test() as pilot: await pilot.pause() await pilot.click("Log", (10, 0)) + + +async def test_get_common_ancestor(): + """Test the Widget.get_common_ancestor classmethod""" + + class AncestorApp(App): + + def compose(self) -> ComposeResult: + with Vertical(id="v1"): + with Vertical(id="v2"): + yield Label(id="label1") + with Vertical(id="v3"): + with Vertical(id="v4"): + yield Label(id="label2") + yield Label(id="label3") + + app = AncestorApp() + async with app.run_test(): + label1 = app.query_one("#label1") + label2 = app.query_one("#label2") + label3 = app.query_one("#label3") + assert Widget.get_common_ancestor(label2, label3).id == "v4" + assert Widget.get_common_ancestor(label2, label1).id == "v1" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..9431a635b1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.11"