From 08663e58697aa9cb13650c464f0e74407f415b30 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 18 Mar 2026 16:13:17 +0800 Subject: [PATCH 01/25] scroll selectable containers --- .gitignore | 1 + src/textual/screen.py | 85 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 97b3813f5c..9ccb9f880b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tools/*.txt playground/ .mypy_cache/ .screenshot_cache/ +uv.lock # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/src/textual/screen.py b/src/textual/screen.py index c3e4fbe4e9..08ed4be7dd 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -32,6 +32,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 @@ -328,6 +329,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 +1684,64 @@ def _translate_mouse_move_event( style=event.style, ) + def _check_auto_scroll( + self, select_widget: Widget, mouse_coordinate: Offset + ) -> None: + for ancestor in select_widget.ancestors: + if not isinstance(ancestor, Widget): + break + if ancestor.allow_vertical_scroll: + ancestor_region = ancestor.region + up_region, down_region = get_auto_scroll_regions(ancestor_region) + + if self._auto_select_scroll_timer is not None: + self._auto_select_scroll_timer.stop() + self._auto_select_scroll_timer = None + + if mouse_coordinate in up_region and ancestor.scroll_y > 0: + self._auto_select_scroll_timer = self.set_interval( + 1 / 60, partial(self._auto_scroll_y, ancestor, -0.5) + ) + elif ( + mouse_coordinate in down_region + and ancestor.scroll_y < ancestor.max_scroll_y + ): + self._auto_select_scroll_timer = self.set_interval( + 1 / 60, partial(self._auto_scroll_y, ancestor, +0.5) + ) + + async def _auto_scroll_y(self, widget: Widget, direction: float) -> None: + widget.scroll_y += direction + self._update_select(self.app.mouse_position) + + def _update_select(self, screen_offset: Offset) -> None: + 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 @@ -1721,13 +1783,24 @@ def _forward_event(self, event: events.Event) -> None: select_offset, ) + if select_widget is not None: + self._check_auto_scroll(select_widget, event.screen_offset) + 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 +1860,11 @@ def _forward_event(self, event: events.Event) -> None: def _key_escape(self) -> None: self.clear_selection() + def _watch__selecting(self, selecting: bool) -> None: + if self._auto_select_scroll_timer is not None: + self._auto_select_scroll_timer.stop() + self._auto_select_scroll_timer = None + def _watch__select_end( self, select_end: tuple[Widget, Offset, Offset] | None ) -> None: @@ -1805,8 +1883,9 @@ def _watch__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) + start_widget: Selection.from_offsets(start_offset, end_offset + (1, 0)) } + self.log(self.selections) return select_start, select_end = sorted( @@ -1816,6 +1895,7 @@ def _watch__select_end( start_widget, _screen_start, start_offset = select_start end_widget, _screen_end, end_offset = select_end + end_offset += (1, 0) select_regions: list[Region] = [] start_region = start_widget.content_region @@ -1888,6 +1968,7 @@ def _watch__select_end( }, end_widget: Selection(None, end_offset), } + self.log(self.selections) def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: """Dismiss the screen, optionally with a result. From 39224a837a86e1ec95af31a04f363cac91ca60a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 18 Mar 2026 16:13:46 +0800 Subject: [PATCH 02/25] auto scroll --- src/textual/_auto_scroll.py | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/textual/_auto_scroll.py diff --git a/src/textual/_auto_scroll.py b/src/textual/_auto_scroll.py new file mode 100644 index 0000000000..0888bf5a97 --- /dev/null +++ b/src/textual/_auto_scroll.py @@ -0,0 +1,39 @@ +from textual.geometry import Region + +AUTO_SCROLL_LINES = 2 + + +def get_auto_scroll_regions(widget_region: Region) -> tuple[Region, Region]: + """Get regions which should auto scroll when selecting. + + Args: + widget_region: The region occupied by the widget. + + 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 + + top_half, bottom_half = widget_region.grow( + (AUTO_SCROLL_LINES, 0, AUTO_SCROLL_LINES, 0) + ).split_horizontal(AUTO_SCROLL_LINES + height // 2) + + up_region = top_half.intersection( + Region( + x, + y - AUTO_SCROLL_LINES, + width, + AUTO_SCROLL_LINES * 2, + ) + ) + + down_region = bottom_half.intersection( + Region( + x, + y + height - AUTO_SCROLL_LINES, + width, + AUTO_SCROLL_LINES * 2, + ) + ) + + return up_region, down_region From 13057a7edb98b4cd5d07cbc370f07a725e1a832c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Mar 2026 20:53:14 +0800 Subject: [PATCH 03/25] auto scroll --- src/textual/_auto_scroll.py | 37 ++---- src/textual/_spatial_map.py | 5 +- src/textual/app.py | 9 ++ src/textual/screen.py | 211 +++++++++++++++++++++++++++---- src/textual/widget.py | 37 +++++- src/textual/widgets/_markdown.py | 6 +- 6 files changed, 245 insertions(+), 60 deletions(-) diff --git a/src/textual/_auto_scroll.py b/src/textual/_auto_scroll.py index 0888bf5a97..ed0b9f8edd 100644 --- a/src/textual/_auto_scroll.py +++ b/src/textual/_auto_scroll.py @@ -1,39 +1,26 @@ from textual.geometry import Region -AUTO_SCROLL_LINES = 2 - -def get_auto_scroll_regions(widget_region: Region) -> tuple[Region, Region]: +def get_auto_scroll_regions( + widget_region: Region, auto_scroll_lines: int +) -> tuple[Region, Region]: """Get 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. + 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 - top_half, bottom_half = widget_region.grow( - (AUTO_SCROLL_LINES, 0, AUTO_SCROLL_LINES, 0) - ).split_horizontal(AUTO_SCROLL_LINES + height // 2) - - up_region = top_half.intersection( - Region( - x, - y - AUTO_SCROLL_LINES, - width, - AUTO_SCROLL_LINES * 2, - ) - ) - - down_region = bottom_half.intersection( - Region( - x, - y + height - AUTO_SCROLL_LINES, - width, - AUTO_SCROLL_LINES * 2, - ) - ) + top_half, bottom_half = widget_region.split_horizontal(height // 2) + + up_region = Region(x, y, width, auto_scroll_lines) + up_region = top_half.intersection(up_region) + + 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/_spatial_map.py b/src/textual/_spatial_map.py index 8f6175321a..6bb1b6a20e 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -2,7 +2,7 @@ from collections import defaultdict from itertools import product -from typing import Generic, Iterable, TypeVar +from typing import TYPE_CHECKING, Generic, Iterable, TypeVar from typing_extensions import TypeAlias @@ -11,6 +11,9 @@ ValueType = TypeVar("ValueType") GridCoordinate: TypeAlias = "tuple[int, int]" +if TYPE_CHECKING: + pass + class SpatialMap(Generic[ValueType]): """A spatial map allows for data to be associated with rectangular regions diff --git a/src/textual/app.py b/src/textual/app.py index a0974af08e..28daa96f73 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] = 45.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, diff --git a/src/textual/screen.py b/src/textual/screen.py index 08ed4be7dd..8a72039534 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -20,6 +20,7 @@ Generic, Iterable, Iterator, + Literal, NamedTuple, Optional, TypeVar, @@ -1684,35 +1685,78 @@ 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. + """ + print("AUTO SCROLL", widget, direction) + + 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: + widget.scroll_y += direction + widget.scroll_target_y = widget.scroll_y + self._update_select(self.app.mouse_position) + + self._stop_auto_scroll() + + lines_to_scroll = ( + direction * (self.app.SELECT_AUTO_SCROLL_SPEED / constants.MAX_FPS) * speed + ) + _auto_scroll_y(widget, lines_to_scroll) + self._auto_select_scroll_timer = self.set_interval( + 1 / constants.MAX_FPS, + partial(_auto_scroll_y, widget, lines_to_scroll), + ) + + 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 + print("STOP AUTO SCROLL") + def _check_auto_scroll( self, select_widget: Widget, mouse_coordinate: Offset ) -> None: + + if not self.app.ENABLE_SELECT_AUTO_SCROLL: + # Disabled by app + return + for ancestor in select_widget.ancestors: if not isinstance(ancestor, Widget): break if ancestor.allow_vertical_scroll: - ancestor_region = ancestor.region - up_region, down_region = get_auto_scroll_regions(ancestor_region) - - if self._auto_select_scroll_timer is not None: - self._auto_select_scroll_timer.stop() - self._auto_select_scroll_timer = None - + 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_coordinate in up_region and ancestor.scroll_y > 0: - self._auto_select_scroll_timer = self.set_interval( - 1 / 60, partial(self._auto_scroll_y, ancestor, -0.5) - ) + speed = (up_region.y - mouse_coordinate.y + 1) / scroll_lines + self._start_auto_scroll(ancestor, -1, speed) + return elif ( mouse_coordinate in down_region and ancestor.scroll_y < ancestor.max_scroll_y ): - self._auto_select_scroll_timer = self.set_interval( - 1 / 60, partial(self._auto_scroll_y, ancestor, +0.5) - ) - - async def _auto_scroll_y(self, widget: Widget, direction: float) -> None: - widget.scroll_y += direction - self._update_select(self.app.mouse_position) + speed = (mouse_coordinate.y - down_region.y + 1) / scroll_lines + self._start_auto_scroll(ancestor, +1, speed) + return + self._stop_auto_scroll() def _update_select(self, screen_offset: Offset) -> None: select_widget, select_offset = self.get_widget_and_offset_at( @@ -1785,6 +1829,8 @@ def _forward_event(self, event: events.Event) -> None: if select_widget is not None: self._check_auto_scroll(select_widget, event.screen_offset) + else: + self._stop_auto_scroll() elif isinstance(event, events.MouseEvent): if isinstance(event, events.MouseUp): @@ -1801,6 +1847,7 @@ def _forward_event(self, event: events.Event) -> None: "-textual-system" ): self.clear_selection() + self._mouse_down_offset = None self._selecting = False self.post_message(events.TextSelected()) @@ -1861,9 +1908,39 @@ def _key_escape(self) -> None: self.clear_selection() def _watch__selecting(self, selecting: bool) -> None: - if self._auto_select_scroll_timer is not None: - self._auto_select_scroll_timer.stop() - self._auto_select_scroll_timer = None + if not selecting: + self._stop_auto_scroll() + + @classmethod + def _collect_select_widgets( + cls, selection_region: Region, widgets: Iterable[Widget] + ) -> list[Widget]: + """Collect widgets within a selection region. + + Args: + selection_region: A screenspace bounding box to include widgets. + widgets: Widgets to consider. + + Returns: + Widgets within selection region. + """ + results: list[Widget] = [] + + def _recurse_node(node: Widget) -> None: + if not node.is_container: + if selection_region.overlaps(node.region): + results.append(node) + return + if node.region in selection_region: + results.extend(node.query("*")) + else: + for child in node.displayed_and_visible_children: + _recurse_node(child) + + for node in widgets: + _recurse_node(node) + + return results def _watch__select_end( self, select_end: tuple[Widget, Offset, Offset] | None @@ -1877,17 +1954,94 @@ def _watch__select_end( # 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 self.selections = { - start_widget: Selection.from_offsets(start_offset, end_offset + (1, 0)) + start_widget: Selection.from_offsets( + start_offset, + end_offset + (1, 0), + ) } - self.log(self.selections) return + # start_widget, end_widget = sorted( + # [start_widget, end_widget], + # key=lambda widget: widget.region.offset.transpose, + # ) + + mouse_position = self.app.mouse_position + selection_start_offset = start_widget.region.offset + start_offset + selection_end_offset = mouse_position + + selection_start_offset, selection_end_offset = sorted( + [selection_start_offset, selection_end_offset], + key=lambda offset: offset.transpose, + ) + + print(selection_start_offset, selection_end_offset) + # select_region = Region.from_corners( + # *selection_start_offset, *selection_end_offset + # ) + + select_container = start_widget.select_container + select_region = Region( + 0, + selection_start_offset.y, + select_container.region.width, + selection_end_offset.y - selection_start_offset.y, + ) + + parent_select_widgets = select_container.filter_children_overlapping_region( + select_region + ) + + select_widgets = set( + self._collect_select_widgets(select_region, parent_select_widgets) + ) + select_widgets -= {self, start_widget, end_widget} + + select_all = SELECT_ALL + self.selections = { + start_widget: Selection(start_offset, None), + **{ + widget: select_all + for widget in sorted( + select_widgets, + key=lambda widget: widget.content_region.offset.transpose, + ) + }, + end_widget: Selection(None, end_offset), + } + + return + + screen_start = start_widget.region.offset + print("START", screen_start) + + select_container = start_widget.select_container + + # select_container.region.intersection( + # Region.from_corners(select_container) + # ) + + screen_start, screen_end = sorted( + [screen_start, screen_end], + key=lambda offset: offset.transpose, + ) + print(start_offset, end_offset) + select_region = Region.from_corners(*start_offset, *end_offset) + + print(select_region) + # select_widgets:list[Widget] = [] + # for widget in self.filter_children_overlapping_region(select_container.region): + + # select_container = start_widget.select_container + + # for widget in select_container: + + return select_start, select_end = sorted( [select_start, select_end], key=lambda selection: (selection[0].region.offset.transpose), @@ -1937,12 +2091,13 @@ def _watch__select_end( ) select_regions.append(mid_region) + scroll_container = self._get_scroll_container(start_widget) + print("scroll_container", scroll_container) + widgets = list(scroll_container.query("*")) + spatial_map: SpatialMap[Widget] = SpatialMap() spatial_map.insert( - [ - (widget.region, NULL_OFFSET, False, False, widget) - for widget in self._compositor.visible_widgets.keys() - ] + [(widget.region, NULL_OFFSET, False, False, widget) for widget in widgets] ) highlighted_widgets: set[Widget] = set() diff --git a/src/textual/widget.py b/src/textual/widget.py index be2e9ad893..3401bfdc6d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2419,6 +2419,36 @@ def is_on_screen(self) -> bool: return False return True + def filter_children_overlapping_region(self, region: Region) -> list[Widget]: + """Filter all children that overlap a given region (in screen space). + + Args: + region: A region in screen space. + + Returns: + A list of widgets. + """ + return [ + widget + for widget in self.displayed_and_visible_children + if region.overlaps(widget.region) + ] + + def filter_children_within_region(self, region: Region) -> list[Widget]: + """Filter children that are contained within a given region. + + Args: + region: a region in screen space. + + Returns: + A list of the children contained within the region. + """ + return [ + widget + for widget in self.displayed_and_visible_children + if widget.region in region + ] + def _resolve_extrema( self, container: Size, @@ -2638,11 +2668,12 @@ def select_container(self) -> Widget: Returns: A widget which contains this widget. """ - container: Widget = self for widget in self.ancestors: - if isinstance(widget, Widget) and widget.is_scrollable: + if not isinstance(widget, Widget): + break + if widget.allow_vertical_scroll: return widget - return container + return self.screen def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 2b4f74a0ff..ea0ef99eaa 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -382,7 +382,7 @@ class MarkdownH1(MarkdownHeader): MarkdownH1 { content-align: center middle; color: $markdown-h1-color; - background: $markdown-h1-background; + # background: $markdown-h1-background; text-style: $markdown-h1-text-style; } """ @@ -396,7 +396,7 @@ class MarkdownH2(MarkdownHeader): DEFAULT_CSS = """ MarkdownH2 { color: $markdown-h2-color; - background: $markdown-h2-background; + # background: $markdown-h2-background; text-style: $markdown-h2-text-style; } """ @@ -410,7 +410,7 @@ class MarkdownH3(MarkdownHeader): DEFAULT_CSS = """ MarkdownH3 { color: $markdown-h3-color; - background: $markdown-h3-background; + # background: $markdown-h3-background; text-style: $markdown-h3-text-style; margin: 1 0; width: auto; From ec2a8c3139c1c6cc8849b1308da2d953ce68aef0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 14:00:48 +0800 Subject: [PATCH 04/25] clip region --- src/textual/_auto_scroll.py | 6 +++- src/textual/screen.py | 69 ++++++++++++++++++++++++------------- src/textual/widget.py | 31 +++++++++++++++-- 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/src/textual/_auto_scroll.py b/src/textual/_auto_scroll.py index ed0b9f8edd..8d71f91fa9 100644 --- a/src/textual/_auto_scroll.py +++ b/src/textual/_auto_scroll.py @@ -4,7 +4,7 @@ def get_auto_scroll_regions( widget_region: Region, auto_scroll_lines: int ) -> tuple[Region, Region]: - """Get regions which should auto scroll when selecting. + """Get non-overlapping regions which should auto scroll when selecting. Args: widget_region: The region occupied by the widget. @@ -15,11 +15,15 @@ def get_auto_scroll_regions( """ 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) diff --git a/src/textual/screen.py b/src/textual/screen.py index 8a72039534..437ef50ac8 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1686,7 +1686,10 @@ def _translate_mouse_move_event( ) def _start_auto_scroll( - self, widget: Widget, direction: Literal[+1, -1], speed: float = 1.0 + self, + widget: Widget, + direction: Literal[+1, -1], + speed: float = 1.0, ) -> None: """Start (or update) auto scrolling. @@ -1695,7 +1698,8 @@ def _start_auto_scroll( direction: Direction: `+1` for up, `-1` for down. speed: The scroll speed as a factor of the maximum. """ - print("AUTO SCROLL", widget, direction) + print("start auto scroll", widget, direction, speed) + assert speed > 0 def _auto_scroll_y(widget: Widget, direction: float) -> None: """Scroll a container a single line in the given direction. @@ -1725,37 +1729,46 @@ def _stop_auto_scroll(self) -> None: if self._auto_select_scroll_timer is not None: self._auto_select_scroll_timer.stop() self._auto_select_scroll_timer = None - print("STOP AUTO SCROLL") def _check_auto_scroll( self, select_widget: Widget, mouse_coordinate: Offset ) -> None: + """Check auto-scrolling when selecting. + + Args: + select_widget: The widget under thje mouise pointer. + mouse_coordinate: The screen-space mouse pointer. + """ if not self.app.ENABLE_SELECT_AUTO_SCROLL: # Disabled by app return - for ancestor in select_widget.ancestors: + # 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 ancestor.allow_vertical_scroll: - 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_coordinate in up_region and ancestor.scroll_y > 0: - speed = (up_region.y - mouse_coordinate.y + 1) / scroll_lines + if not ancestor.allow_vertical_scroll: + 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_coordinate in up_region: + if ancestor.scroll_y > 0: + speed = ( + (scroll_lines - (mouse_coordinate.y - up_region.y)) + ) / scroll_lines self._start_auto_scroll(ancestor, -1, speed) - return - elif ( - mouse_coordinate in down_region - and ancestor.scroll_y < ancestor.max_scroll_y - ): + return + elif mouse_coordinate in down_region: + if ancestor.scroll_y < ancestor.max_scroll_y: speed = (mouse_coordinate.y - down_region.y + 1) / scroll_lines self._start_auto_scroll(ancestor, +1, speed) - return + return self._stop_auto_scroll() def _update_select(self, screen_offset: Offset) -> None: @@ -1798,7 +1811,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 @@ -1830,6 +1844,7 @@ def _forward_event(self, event: events.Event) -> None: if select_widget is not None: self._check_auto_scroll(select_widget, event.screen_offset) else: + print("select widget is None") self._stop_auto_scroll() elif isinstance(event, events.MouseEvent): @@ -1975,12 +1990,17 @@ def _watch__select_end( selection_start_offset = start_widget.region.offset + start_offset selection_end_offset = mouse_position - selection_start_offset, selection_end_offset = sorted( - [selection_start_offset, selection_end_offset], - key=lambda offset: offset.transpose, + (start_widget, selection_start_offset), (end_widget, selection_end_offset) = ( + sorted( + [ + (start_widget, selection_start_offset), + (end_widget, selection_end_offset), + ], + key=lambda widget_offset: widget_offset[1].transpose, + ) ) - print(selection_start_offset, selection_end_offset) + # print(selection_start_offset, selection_end_offset) # select_region = Region.from_corners( # *selection_start_offset, *selection_end_offset # ) @@ -2003,6 +2023,7 @@ def _watch__select_end( select_widgets -= {self, start_widget, end_widget} select_all = SELECT_ALL + self.selections = { start_widget: Selection(start_offset, None), **{ diff --git a/src/textual/widget.py b/src/textual/widget.py index 3401bfdc6d..de1648043a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2257,6 +2257,19 @@ def region(self) -> Region: except (NoScreen, errors.NoWidget): return NULL_REGION + @property + def clip_region(self) -> Region: + """The widget region intersection with its clip region (as you would expect from a scrollable widget) + + Returns: + A a screen-space region. + """ + try: + map_geometry = self.screen.find_widget(self) + except (NoScreen, errors.NoWidget): + return NULL_REGION + return map_geometry.visible_region + @property def dock_gutter(self) -> Spacing: """Space allocated to docks in the parent. @@ -2668,10 +2681,22 @@ def select_container(self) -> Widget: Returns: A widget which contains this widget. """ + container: Widget = self for widget in self.ancestors: - if not isinstance(widget, Widget): - break - if widget.allow_vertical_scroll: + if isinstance(widget, Widget) and widget.is_scrollable: + return widget + return container + + @property + def select_scroll_container(self) -> Widget: + """The widget's container used when selecting text.. + + Returns: + A widget which contains this widget. + """ + + for widget in self.select_container.ancestors_with_self: + if isinstance(widget, Widget) and widget.allow_vertical_scroll: return widget return self.screen From 0339b5ac11bd88cf18184b2b1d2d623bcb69be5d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 15:28:02 +0800 Subject: [PATCH 05/25] added selection shape --- src/textual/geometry.py | 92 +++++++++++++++++++++++++++++++++++++++++ src/textual/screen.py | 53 ++++++++++++++++++++---- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e86c73aeaa..72d1c469de 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -25,6 +25,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 +1317,97 @@ def grow_maximum(self, other: Spacing) -> Spacing: ) +class Shape: + """An arbitrary shape.""" + + __slots__ = ["_regions", "_bounds"] + + def __init__(self, regions: list[Region]): + self._regions = regions.copy() + self._bounds = Region.from_union(regions) if self._bounds 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 + + @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 + XXXXXXXXXXXXXX + XXXXXXXXXXXXXX + XXXXXXXXX + ``` + + Args: + container: The container region for the selection. + start: The start offset. + end: The end offset. + + Returns: + A new shape. + """ + if start.transpose > end.transpose: + end, start = start, end + start_x, start_y = start + end_x, end_y = end + + if start_x == 0 and end_x == container.width: + # Special case where start and end offsets are on the edges, and the shape + # becomes a single region + return Shape([Region(0, start_y, container.width, end_y - start_y)]) + + if start.y == end.y: + # Simple case: all on one line + return Shape([Region(start_x, start_y, end_x - start_x, 1)]) + + regions = [ + Region(start_x, start_y, container.width - start_x, 1), # top + Region(container.x, end_y, container.width - container.x, 1), # bottom + ] + if end.y - start.y > 1: + # We need a middle region betweem the top and the bottom + regions.append(Region(0, start_y + 1, container.width, end_y - start_y - 1)) + return Shape(regions) + + def overlaps_region(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. + """ + if not self._bounds: + return False + return self._bounds.overlaps(region) and 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 a point is anywhere within the shape, otherwise `False`. + """ + return self._bounds.contains_point(offset) and 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 diff --git a/src/textual/screen.py b/src/textual/screen.py index 437ef50ac8..ae6394429f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1698,8 +1698,7 @@ def _start_auto_scroll( direction: Direction: `+1` for up, `-1` for down. speed: The scroll speed as a factor of the maximum. """ - print("start auto scroll", widget, direction, speed) - assert speed > 0 + 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. @@ -1709,19 +1708,27 @@ def _auto_scroll_y(widget: Widget, direction: float) -> None: 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 ) - _auto_scroll_y(widget, lines_to_scroll) + # 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, - partial(_auto_scroll_y, widget, lines_to_scroll), + 1 / constants.MAX_FPS, scroll_callback ) def _stop_auto_scroll(self) -> None: @@ -1735,8 +1742,10 @@ def _check_auto_scroll( ) -> 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 thje mouise pointer. + select_widget: The widget under the mouise pointer. mouse_coordinate: The screen-space mouse pointer. """ @@ -1750,6 +1759,7 @@ def _check_auto_scroll( 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 @@ -1758,20 +1768,33 @@ def _check_auto_scroll( auto_scroll_lines=scroll_lines, ) if mouse_coordinate 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_coordinate.y - up_region.y)) ) / scroll_lines self._start_auto_scroll(ancestor, -1, speed) - return + return elif mouse_coordinate 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_coordinate.y - down_region.y + 1) / scroll_lines self._start_auto_scroll(ancestor, +1, speed) - return + 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 ) @@ -1981,11 +2004,25 @@ def _watch__select_end( } return + select_start, select_end = sorted( + [self._select_start, select_end], + key=lambda select: select[1].transpose, + ) + + print("START", select_start) + print("END ", select_end) + print("--") + # start_widget, end_widget = sorted( # [start_widget, end_widget], # key=lambda widget: widget.region.offset.transpose, # ) + self.log(self._select_start) + self.log(self._select_end) + self.log("---") + return + mouse_position = self.app.mouse_position selection_start_offset = start_widget.region.offset + start_offset selection_end_offset = mouse_position From 71d2db385776f46c21170aceefe8db94d5f3e188 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 15:44:02 +0800 Subject: [PATCH 06/25] shapes and tests --- src/textual/geometry.py | 48 +++++++++++++++++++++++++-------------- tests/test_auto_scroll.py | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 tests/test_auto_scroll.py diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 72d1c469de..9f63880c3a 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -12,8 +12,10 @@ TYPE_CHECKING, Any, Collection, + Iterable, Literal, NamedTuple, + Sequence, Tuple, TypeVar, Union, @@ -1322,9 +1324,9 @@ class Shape: __slots__ = ["_regions", "_bounds"] - def __init__(self, regions: list[Region]): - self._regions = regions.copy() - self._bounds = Region.from_union(regions) if self._bounds else NULL_REGION + def __init__(self, regions: Sequence[Region]): + self._regions = regions + self._bounds = Region.from_union(regions) if regions else NULL_REGION def __bool__(self) -> bool: return bool(self._bounds) @@ -1342,10 +1344,11 @@ def selection_bounds(cls, container: Region, start: Offset, end: Offset) -> Shap The shape would look something like this: ``` - XXXXXXXXXX + XXXXXXXXXX <- top XXXXXXXXXXXXXX + XXXXXXXXXXXXXX <- middle XXXXXXXXXXXXXX - XXXXXXXXX + XXXXXXXXX <- bottom ``` Args: @@ -1361,22 +1364,33 @@ def selection_bounds(cls, container: Region, start: Offset, end: Offset) -> Shap start_x, start_y = start end_x, end_y = end - if start_x == 0 and end_x == container.width: + 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 - return Shape([Region(0, start_y, container.width, end_y - start_y)]) + if start_x == 0 and end_x == container.width: + yield Region(0, start_y, container.width, end_y - start_y) - if start.y == end.y: # Simple case: all on one line - return Shape([Region(start_x, start_y, end_x - start_x, 1)]) - - regions = [ - Region(start_x, start_y, container.width - start_x, 1), # top - Region(container.x, end_y, container.width - container.x, 1), # bottom - ] - if end.y - start.y > 1: - # We need a middle region betweem the top and the bottom - regions.append(Region(0, start_y + 1, container.width, end_y - start_y - 1)) + 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 > 1: + # 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, container.width - container.x, 1) + + regions = list(get_regions()) return Shape(regions) def overlaps_region(self, region: Region) -> bool: 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 From 9855f2bb0905bd4098247610222e65a938a35c27 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 15:58:53 +0800 Subject: [PATCH 07/25] get shape refinements --- src/textual/geometry.py | 67 ++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 9f63880c3a..b67efb946e 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -15,7 +15,6 @@ Iterable, Literal, NamedTuple, - Sequence, Tuple, TypeVar, Union, @@ -1320,13 +1319,25 @@ def grow_maximum(self, other: Spacing) -> Spacing: class Shape: - """An arbitrary shape.""" + """An arbitrary shape defined by a sequence of regions. - __slots__ = ["_regions", "_bounds"] + This class currently exists to filter widgets within a shape defined when the user is slecting text. - def __init__(self, regions: Sequence[Region]): - self._regions = regions - self._bounds = Region.from_union(regions) if regions else NULL_REGION + """ + + __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) @@ -1357,7 +1368,7 @@ def selection_bounds(cls, container: Region, start: Offset, end: Offset) -> Shap end: The end offset. Returns: - A new shape. + A new shape covering the selection bounds. """ if start.transpose > end.transpose: end, start = start, end @@ -1373,25 +1384,49 @@ def get_regions() -> Iterable[Region]: # 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) + 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) + 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) + yield Region( + start_x, + start_y, + container.width - start_x, + 1, + ) # middle if end.y - start.y > 1: # We need a middle region between the top and the bottom - yield Region(0, start_y + 1, container.width, end_y - start_y - 1) + yield Region( + 0, + start_y + 1, + container.width, + end_y - start_y - 1, + ) # bottom - yield Region(container.x, end_y, container.width - container.x, 1) + yield Region( + container.x, + end_y, + container.width - container.x, + 1, + ) - regions = list(get_regions()) - return Shape(regions) + return Shape(get_regions()) def overlaps_region(self, region: Region) -> bool: """Does a region overlap this shape? @@ -1402,8 +1437,6 @@ def overlaps_region(self, region: Region) -> bool: Returns: `True` if any part of the shape overlaps the region, `False` if there is no overlap. """ - if not self._bounds: - return False return self._bounds.overlaps(region) and any( shape_region.overlaps(region) for shape_region in self._regions ) @@ -1415,7 +1448,7 @@ def contains_point(self, offset: Offset) -> bool: offset: An offset. Returns: - `True` if a point is anywhere within the shape, otherwise `False`. + `True` if the given offset is anywhere within the shape, otherwise `False`. """ return self._bounds.contains_point(offset) and any( region.contains_point(offset) for region in self._regions From a369fc0666582c8b0ecd1080b3594916d0f7ec99 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 17:04:41 +0800 Subject: [PATCH 08/25] shape tests --- src/textual/geometry.py | 11 ++- tests/test_geometry.py | 150 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b67efb946e..1813c5765e 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1348,6 +1348,11 @@ def __hash__(self) -> int: def __rich_repr__(self) -> rich.repr.Result: yield self._regions + @property + def bounds(self) -> Region: + """A region that encloses the shape.""" + return self._bounds + @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. @@ -1422,7 +1427,7 @@ def get_regions() -> Iterable[Region]: yield Region( container.x, end_y, - container.width - container.x, + end_x, 1, ) @@ -1437,6 +1442,8 @@ def overlaps_region(self, region: Region) -> bool: Returns: `True` if any part of the shape overlaps the region, `False` if there is no overlap. """ + if not self._regions: + return False return self._bounds.overlaps(region) and any( shape_region.overlaps(region) for shape_region in self._regions ) @@ -1450,6 +1457,8 @@ def contains_point(self, offset: Offset) -> bool: Returns: `True` if the given offset is anywhere within the shape, otherwise `False`. """ + if not self._regions: + return False return self._bounds.contains_point(offset) and any( region.contains_point(offset) for region in self._regions ) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index f290429815..83407b1376 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,151 @@ 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§""" + 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_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 From 0425b59b01a9e70b0a084c8ad3fd5f9d615ebdd5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 22 Mar 2026 17:09:39 +0800 Subject: [PATCH 09/25] more shape tests --- src/textual/geometry.py | 5 +++++ tests/test_geometry.py | 26 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 1813c5765e..b4904f933f 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1348,6 +1348,11 @@ def __hash__(self) -> int: 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.""" diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 83407b1376..e91fa99b5e 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -639,7 +639,9 @@ def test_shape_contains_point_simple( def build_grid_snapshot(shape: Shape) -> str: - """Build a string with a 2D grid of results from §contains_point§""" + """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 @@ -695,6 +697,28 @@ def test_selection_bounds_contains_point(): 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( From ab27208e012a6918e7ff8c9be1a771f02798a7e3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 23 Mar 2026 15:34:32 +0800 Subject: [PATCH 10/25] select range --- src/textual/geometry.py | 20 +++++++++----------- src/textual/screen.py | 36 +++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b4904f933f..44f6bfb8a7 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1358,6 +1358,12 @@ 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. @@ -1438,7 +1444,7 @@ def get_regions() -> Iterable[Region]: return Shape(get_regions()) - def overlaps_region(self, region: Region) -> bool: + def overlaps(self, region: Region) -> bool: """Does a region overlap this shape? Args: @@ -1447,11 +1453,7 @@ def overlaps_region(self, region: Region) -> bool: Returns: `True` if any part of the shape overlaps the region, `False` if there is no overlap. """ - if not self._regions: - return False - return self._bounds.overlaps(region) and any( - shape_region.overlaps(region) for shape_region in self._regions - ) + 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. @@ -1462,11 +1464,7 @@ def contains_point(self, offset: Offset) -> bool: Returns: `True` if the given offset is anywhere within the shape, otherwise `False`. """ - if not self._regions: - return False - return self._bounds.contains_point(offset) and any( - region.contains_point(offset) for region in self._regions - ) + return any(region.contains_point(offset) for region in self._regions) if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1": diff --git a/src/textual/screen.py b/src/textual/screen.py index ae6394429f..5a63422a66 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -53,7 +53,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 NULL_OFFSET, Offset, Region, Shape, Size from textual.keys import key_to_character from textual.layout import DockArrangeResult from textual.reactive import Reactive, var @@ -1992,7 +1992,8 @@ def _watch__select_end( # Nothing to select return - start_widget, _screen_start, start_offset = self._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 @@ -2004,23 +2005,36 @@ def _watch__select_end( } return - select_start, select_end = sorted( - [self._select_start, select_end], - key=lambda select: select[1].transpose, + print(select_end) + # 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) + + bounds_start, bounds_end = sorted( + [select_start[1] + select_start[2], self.app.mouse_position], + key=lambda bounds: bounds.transpose, + ) + + selection_bounds = Shape.selection_bounds( + self.size.region, + bounds_start, + bounds_end, ) - print("START", select_start) - print("END ", select_end) - print("--") + select_all = SELECT_ALL + + selections: dict[Widget, Selection] = {} + for widget, map_geometry in self._compositor.full_map.items(): + if selection_bounds.overlaps(map_geometry.visible_region): + selections[widget] = select_all + + self.selections = selections # start_widget, end_widget = sorted( # [start_widget, end_widget], # key=lambda widget: widget.region.offset.transpose, # ) - self.log(self._select_start) - self.log(self._select_end) - self.log("---") return mouse_position = self.app.mouse_position From d8cb2d424cbc8d1eb8b4b1f3e1195a2689af5150 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 24 Mar 2026 12:58:04 +0800 Subject: [PATCH 11/25] common ancestors algo --- src/textual/app.py | 2 +- src/textual/geometry.py | 29 +++++++++++++++- src/textual/screen.py | 75 ++++++++++++++++++++++++++--------------- src/textual/walk.py | 49 +++++++++++++++++++++++++++ src/textual/widget.py | 22 ++++++++++++ 5 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 28daa96f73..88217c728a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -517,7 +517,7 @@ class MyApp(App[None]): 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 + SELECT_AUTO_SCROLL_LINES: ClassVar[int] = 2 """Number of lines in auto-scrolling regions at the top and bottom of a widget.""" SELECT_AUTO_SCROLL_SPEED: ClassVar[float] = 45.0 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 44f6bfb8a7..b24da7a9b7 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1466,6 +1466,33 @@ def contains_point(self, offset: Offset) -> bool: """ return any(region.contains_point(offset) for region in self._regions) + def intersection(self, clip: Region) -> Shape: + """Return a shape that is the intersection of this shape with a region. + + Args: + clip: A region. + + Returns: + A new shape. + """ + regions = [ + clipped_region + for region in self.regions + if (clipped_region := clip.intersection(region)) + ] + return Shape(regions) + + def contains_region(self, region: Region) -> bool: + """Check if the given region fits within this shape. + + Args: + region: A region. + + Returns: + `True` if the region fits within the shape without clipping, otherwise `False`. + """ + return self.intersection(region).area == self.area + if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1": try: @@ -1475,7 +1502,7 @@ def contains_point(self, offset: Offset) -> bool: 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 5a63422a66..e36d5e0aca 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -62,6 +62,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 @@ -1951,33 +1952,44 @@ def _watch__selecting(self, selecting: bool) -> None: @classmethod def _collect_select_widgets( - cls, selection_region: Region, widgets: Iterable[Widget] + cls, + selection_bounds: Shape, + container: Widget, + start_widget: Widget, + end_widget: Widget, ) -> list[Widget]: - """Collect widgets within a selection region. + """Get widgets between two widgets (inclusive). Args: - selection_region: A screenspace bounding box to include widgets. - widgets: Widgets to consider. + container: A parent widgets. + start_widget: First widget. + end_widget: Second widget. Returns: - Widgets within selection region. + Widgets between start and end, in select sort order. """ - results: list[Widget] = [] - def _recurse_node(node: Widget) -> None: - if not node.is_container: - if selection_region.overlaps(node.region): - results.append(node) - return - if node.region in selection_region: - results.extend(node.query("*")) - else: - for child in node.displayed_and_visible_children: - _recurse_node(child) + widgets = list( + walk_selectable_widgets( + container, + selection_bounds, + {start_widget, end_widget}, + ) + ) - for node in widgets: - _recurse_node(node) + index1: int | None = None + index2: int | None = None + try: + index1 = widgets.index(start_widget) + except ValueError: + pass + try: + index2 = widgets.index(end_widget) + 1 + except ValueError: + pass + + results = widgets[index1:index2] return results def _watch__select_end( @@ -1988,6 +2000,7 @@ 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 @@ -2005,7 +2018,6 @@ def _watch__select_end( } return - print(select_end) # 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) @@ -2016,19 +2028,28 @@ def _watch__select_end( ) selection_bounds = Shape.selection_bounds( - self.size.region, - bounds_start, - bounds_end, + self.size.region, bounds_start, bounds_end + ) + + if start_widget.region.offset.transpose > end_widget.region.offset.transpose: + start_widget, end_widget = end_widget, start_widget + + container_widget = Widget.get_common_ancestor(start_widget, end_widget) + + select_widgets = self._collect_select_widgets( + selection_bounds, container_widget, start_widget, end_widget ) select_all = SELECT_ALL - selections: dict[Widget, Selection] = {} - for widget, map_geometry in self._compositor.full_map.items(): - if selection_bounds.overlaps(map_geometry.visible_region): - selections[widget] = select_all + self.selections = {widget: SELECT_ALL for widget in select_widgets} + + # selections: dict[Widget, Selection] = {} + # for widget, map_geometry in self._compositor.full_map.items(): + # if selection_bounds.overlaps(map_geometry.region): + # selections[widget] = select_all - self.selections = selections + # self.selections = selections # start_widget, end_widget = sorted( # [start_widget, end_widget], diff --git a/src/textual/walk.py b/src/textual/walk.py index 43b8d4b6e4..b1e7166b86 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -11,8 +11,11 @@ from collections import deque 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 +170,49 @@ 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 + + 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=lambda node: node.region.offset.transpose, + ) + 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() + else: + yield node + children = get_children(node) + if children: + push(iter(children)) diff --git a/src/textual/widget.py b/src/textual/widget.py index de1648043a..cf035e0949 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? From 2d73ddb3be21d19e49c708b761d7ef09c763bb53 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:02:38 +0700 Subject: [PATCH 12/25] scroll mechanics --- src/textual/app.py | 10 +- src/textual/screen.py | 240 ++++++++---------------------------------- src/textual/walk.py | 4 +- src/textual/widget.py | 14 +++ 4 files changed, 66 insertions(+), 202 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 88217c728a..df9db50376 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -517,10 +517,10 @@ class MyApp(App[None]): 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] = 2 + 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] = 45.0 + 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]]] = { @@ -640,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).""" @@ -4028,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/screen.py b/src/textual/screen.py index e36d5e0aca..c2e802043c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -42,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 @@ -53,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, Shape, 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 @@ -1739,7 +1738,7 @@ def _stop_auto_scroll(self) -> None: self._auto_select_scroll_timer = None def _check_auto_scroll( - self, select_widget: Widget, mouse_coordinate: Offset + self, select_widget: Widget, mouse_coordinate: tuple[float, float] ) -> None: """Check auto-scrolling when selecting. @@ -1754,6 +1753,9 @@ def _check_auto_scroll( # Disabled by app 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: @@ -1768,23 +1770,23 @@ def _check_auto_scroll( ancestor_region, auto_scroll_lines=scroll_lines, ) - if mouse_coordinate in up_region: + 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_coordinate.y - up_region.y)) - ) / scroll_lines - self._start_auto_scroll(ancestor, -1, speed) - return - elif mouse_coordinate in down_region: + 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_coordinate.y - down_region.y + 1) / scroll_lines - self._start_auto_scroll(ancestor, +1, speed) - return + 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() @@ -1866,7 +1868,9 @@ def _forward_event(self, event: events.Event) -> None: ) if select_widget is not None: - self._check_auto_scroll(select_widget, event.screen_offset) + self._check_auto_scroll( + select_widget, (event.pointer_screen_x, event.pointer_screen_y) + ) else: print("select widget is None") self._stop_auto_scroll() @@ -1978,14 +1982,14 @@ def _collect_select_widgets( ) index1: int | None = None - index2: int | None = None try: - index1 = widgets.index(start_widget) + index1 = widgets.index(start_widget) + 1 except ValueError: pass + index2: int | None = None try: - index2 = widgets.index(end_widget) + 1 + index2 = widgets.index(end_widget) except ValueError: pass @@ -2006,8 +2010,8 @@ def _watch__select_end( return 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 self.selections = { @@ -2022,201 +2026,41 @@ def _watch__select_end( # We need to adjust to the new screen-space position select_start = (start_widget, start_widget.region.offset, start_offset) - bounds_start, bounds_end = sorted( - [select_start[1] + select_start[2], self.app.mouse_position], - key=lambda bounds: bounds.transpose, - ) + if select_start[0]._selection_order > select_end[0]._selection_order: + select_start, select_end = select_end, select_start - selection_bounds = Shape.selection_bounds( - self.size.region, bounds_start, bounds_end - ) + start_widget, screen_start, start_offset = select_start + end_widget, screen_end, end_offset = select_end - if start_widget.region.offset.transpose > end_widget.region.offset.transpose: + if (screen_start + start_offset).transpose > ( + screen_end + end_offset + ).transpose: start_widget, end_widget = end_widget, start_widget container_widget = Widget.get_common_ancestor(start_widget, end_widget) - select_widgets = self._collect_select_widgets( - selection_bounds, container_widget, start_widget, end_widget - ) - - select_all = SELECT_ALL - - self.selections = {widget: SELECT_ALL for widget in select_widgets} - - # selections: dict[Widget, Selection] = {} - # for widget, map_geometry in self._compositor.full_map.items(): - # if selection_bounds.overlaps(map_geometry.region): - # selections[widget] = select_all - - # self.selections = selections - - # start_widget, end_widget = sorted( - # [start_widget, end_widget], - # key=lambda widget: widget.region.offset.transpose, - # ) - - return - - mouse_position = self.app.mouse_position - selection_start_offset = start_widget.region.offset + start_offset - selection_end_offset = mouse_position - - (start_widget, selection_start_offset), (end_widget, selection_end_offset) = ( - sorted( - [ - (start_widget, selection_start_offset), - (end_widget, selection_end_offset), - ], - key=lambda widget_offset: widget_offset[1].transpose, - ) - ) - - # print(selection_start_offset, selection_end_offset) - # select_region = Region.from_corners( - # *selection_start_offset, *selection_end_offset - # ) - - select_container = start_widget.select_container - select_region = Region( - 0, - selection_start_offset.y, - select_container.region.width, - selection_end_offset.y - selection_start_offset.y, - ) - - parent_select_widgets = select_container.filter_children_overlapping_region( - select_region - ) - - select_widgets = set( - self._collect_select_widgets(select_region, parent_select_widgets) - ) - select_widgets -= {self, start_widget, end_widget} - - select_all = SELECT_ALL - - self.selections = { - start_widget: Selection(start_offset, None), - **{ - widget: select_all - for widget in sorted( - select_widgets, - key=lambda widget: widget.content_region.offset.transpose, - ) - }, - end_widget: Selection(None, end_offset), - } - - return - - screen_start = start_widget.region.offset - print("START", screen_start) + bounds_start = select_start[1] + select_start[2] + bounds_end = self.app.mouse_position + if bounds_end.transpose < bounds_start.transpose: + bounds_end, bounds_start = bounds_start, bounds_end - select_container = start_widget.select_container - - # select_container.region.intersection( - # Region.from_corners(select_container) - # ) - - screen_start, screen_end = sorted( - [screen_start, screen_end], - key=lambda offset: offset.transpose, - ) - print(start_offset, end_offset) - select_region = Region.from_corners(*start_offset, *end_offset) - - print(select_region) - # select_widgets:list[Widget] = [] - # for widget in self.filter_children_overlapping_region(select_container.region): - - # select_container = start_widget.select_container - - # for widget in select_container: - - return - select_start, select_end = sorted( - [select_start, select_end], - key=lambda selection: (selection[0].region.offset.transpose), + selection_bounds = Shape.selection_bounds( + container_widget.region, bounds_start, bounds_end ) - start_widget, _screen_start, start_offset = select_start - end_widget, _screen_end, end_offset = select_end - end_offset += (1, 0) - - 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 - - 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) - - scroll_container = self._get_scroll_container(start_widget) - print("scroll_container", scroll_container) - widgets = list(scroll_container.query("*")) - - spatial_map: SpatialMap[Widget] = SpatialMap() - spatial_map.insert( - [(widget.region, NULL_OFFSET, False, False, widget) for widget in widgets] + select_widgets = self._collect_select_widgets( + selection_bounds, + container_widget, + start_widget, + end_widget, ) - 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} - 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)), } - self.log(self.selections) def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: """Dismiss the screen, optionally with a result. diff --git a/src/textual/walk.py b/src/textual/walk.py index b1e7166b86..3def3edddd 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -9,6 +9,7 @@ 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 @@ -199,8 +200,7 @@ def get_children(node: DOMNode) -> list[Widget]: A list of child widgets. """ children = sorted( - node.displayed_and_visible_children, - key=lambda node: node.region.offset.transpose, + node.displayed_and_visible_children, key=attrgetter("_selection_order") ) if node in bounded: children = [child for child in children if bounds.overlaps(child.region)] diff --git a/src/textual/widget.py b/src/textual/widget.py index cf035e0949..50f94448ec 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2267,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. @@ -4253,6 +4262,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. From ecfd5b936d72a9a5fd26b11fd29b6c93565341f6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:19:32 +0700 Subject: [PATCH 13/25] tidy --- src/textual/screen.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index c2e802043c..64c37c1da5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1738,7 +1738,9 @@ def _stop_auto_scroll(self) -> None: self._auto_select_scroll_timer = None def _check_auto_scroll( - self, select_widget: Widget, mouse_coordinate: tuple[float, float] + self, + select_widget: Widget, + mouse_coordinate: tuple[float, float], ) -> None: """Check auto-scrolling when selecting. @@ -1962,7 +1964,7 @@ def _collect_select_widgets( start_widget: Widget, end_widget: Widget, ) -> list[Widget]: - """Get widgets between two widgets (inclusive). + """Get widgets between two widgets in select order. Args: container: A parent widgets. @@ -2014,6 +2016,8 @@ def _watch__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, @@ -2025,7 +2029,7 @@ def _watch__select_end( # 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 @@ -2037,17 +2041,17 @@ def _watch__select_end( ).transpose: start_widget, end_widget = end_widget, start_widget + # Get a widget which contains both widgets container_widget = Widget.get_common_ancestor(start_widget, end_widget) - bounds_start = select_start[1] + select_start[2] - bounds_end = self.app.mouse_position - if bounds_end.transpose < bounds_start.transpose: - bounds_end, bounds_start = bounds_start, bounds_end - + # Get a selection bounds shape selection_bounds = Shape.selection_bounds( - container_widget.region, bounds_start, bounds_end + container_widget.region, + select_start[1] + select_start[2], + self.app.mouse_position, ) + # Get widgets bounded by the selection bounds select_widgets = self._collect_select_widgets( selection_bounds, container_widget, @@ -2055,6 +2059,7 @@ def _watch__select_end( end_widget, ) + # Build the selection select_all = SELECT_ALL self.selections = { start_widget: Selection(start_offset, None), From 1ce34f58cd1b74632690829b4bddf41b8bad1728 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:25:47 +0700 Subject: [PATCH 14/25] test fixes --- CHANGELOG.md | 7 ++ .../test_arbitrary_selection.svg | 4 +- .../test_arbitrary_selection_double_cell.svg | 110 +++++++++--------- tests/test_selection.py | 2 +- 4 files changed, 65 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f34ee2b40d..f347eefec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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/). +## Unreleased + +### Added + +- Auto-scrolling on select +- Selecting over containers + ## [8.1.1] - 2026-03-10 ### Fixed 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_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 From e84ad9eaa16d9ab6d7cfe94d620f74ab808e2d3a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:36:17 +0700 Subject: [PATCH 15/25] changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f347eefec6..d00f26ff91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Auto-scrolling on select -- Selecting over containers +- 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 From fe84f6cb80419cb1f05ebdd40005d3b13fec8498 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:37:17 +0700 Subject: [PATCH 16/25] bump --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00f26ff91..9dff90fea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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/). -## Unreleased +## [8.2.0] - 2026-03-27 ### Added 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/" From d0f014dc2e75e17fee4911f01c4885f73a4c68b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:46:41 +0700 Subject: [PATCH 17/25] remove debug --- src/textual/screen.py | 11 +++++++++-- src/textual/widgets/_markdown.py | 6 +++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 64c37c1da5..e4d796d8bd 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1741,6 +1741,7 @@ def _check_auto_scroll( self, select_widget: Widget, mouse_coordinate: tuple[float, float], + delta_y: float, ) -> None: """Check auto-scrolling when selecting. @@ -1749,12 +1750,17 @@ def _check_auto_scroll( 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)) @@ -1871,10 +1877,11 @@ def _forward_event(self, event: events.Event) -> None: if select_widget is not None: self._check_auto_scroll( - select_widget, (event.pointer_screen_x, event.pointer_screen_y) + select_widget, + (event.pointer_screen_x, event.pointer_screen_y), + event.delta_y, ) else: - print("select widget is None") self._stop_auto_scroll() elif isinstance(event, events.MouseEvent): diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index ea0ef99eaa..2b4f74a0ff 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -382,7 +382,7 @@ class MarkdownH1(MarkdownHeader): MarkdownH1 { content-align: center middle; color: $markdown-h1-color; - # background: $markdown-h1-background; + background: $markdown-h1-background; text-style: $markdown-h1-text-style; } """ @@ -396,7 +396,7 @@ class MarkdownH2(MarkdownHeader): DEFAULT_CSS = """ MarkdownH2 { color: $markdown-h2-color; - # background: $markdown-h2-background; + background: $markdown-h2-background; text-style: $markdown-h2-text-style; } """ @@ -410,7 +410,7 @@ class MarkdownH3(MarkdownHeader): DEFAULT_CSS = """ MarkdownH3 { color: $markdown-h3-color; - # background: $markdown-h3-background; + background: $markdown-h3-background; text-style: $markdown-h3-text-style; margin: 1 0; width: auto; From 829e587a38f309d8c8fc5e9a8a1e2bce27a66208 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 12:47:22 +0700 Subject: [PATCH 18/25] remove superfluous code --- src/textual/_spatial_map.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 6bb1b6a20e..8f6175321a 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -2,7 +2,7 @@ from collections import defaultdict from itertools import product -from typing import TYPE_CHECKING, Generic, Iterable, TypeVar +from typing import Generic, Iterable, TypeVar from typing_extensions import TypeAlias @@ -11,9 +11,6 @@ ValueType = TypeVar("ValueType") GridCoordinate: TypeAlias = "tuple[int, int]" -if TYPE_CHECKING: - pass - class SpatialMap(Generic[ValueType]): """A spatial map allows for data to be associated with rectangular regions From 1fda977eb5af4e89d5877fe4f0b3d8c83b68ca0e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:11:33 +0700 Subject: [PATCH 19/25] respect allow_select --- src/textual/walk.py | 5 ++--- src/textual/widget.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/walk.py b/src/textual/walk.py index 3def3edddd..2f9f10990d 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -211,8 +211,7 @@ def get_children(node: DOMNode) -> list[Widget]: while stack: if (node := next(stack[-1], None)) is None: pop() - else: + elif node.allow_select: yield node - children = get_children(node) - if children: + if children := get_children(node): push(iter(children)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 50f94448ec..7ee9d0a488 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2878,7 +2878,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. From edbea204e6d3aca655d6f70d1be2921d7fa3aba4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:20:24 +0700 Subject: [PATCH 20/25] get_common_ancestor --- tests/test_widget.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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" From 7c5158b78eadf0ac954c28baaada5b4abd636530 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:21:52 +0700 Subject: [PATCH 21/25] unused methods --- src/textual/widget.py | 43 ------------------------------------------- 1 file changed, 43 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7ee9d0a488..b1d61e9675 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2288,19 +2288,6 @@ def region(self) -> Region: except (NoScreen, errors.NoWidget): return NULL_REGION - @property - def clip_region(self) -> Region: - """The widget region intersection with its clip region (as you would expect from a scrollable widget) - - Returns: - A a screen-space region. - """ - try: - map_geometry = self.screen.find_widget(self) - except (NoScreen, errors.NoWidget): - return NULL_REGION - return map_geometry.visible_region - @property def dock_gutter(self) -> Spacing: """Space allocated to docks in the parent. @@ -2463,36 +2450,6 @@ def is_on_screen(self) -> bool: return False return True - def filter_children_overlapping_region(self, region: Region) -> list[Widget]: - """Filter all children that overlap a given region (in screen space). - - Args: - region: A region in screen space. - - Returns: - A list of widgets. - """ - return [ - widget - for widget in self.displayed_and_visible_children - if region.overlaps(widget.region) - ] - - def filter_children_within_region(self, region: Region) -> list[Widget]: - """Filter children that are contained within a given region. - - Args: - region: a region in screen space. - - Returns: - A list of the children contained within the region. - """ - return [ - widget - for widget in self.displayed_and_visible_children - if widget.region in region - ] - def _resolve_extrema( self, container: Size, From 049bd8292f511f967de77338992447309925e4b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:22:30 +0700 Subject: [PATCH 22/25] old code --- src/textual/widget.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index b1d61e9675..b7b08c4c89 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2675,19 +2675,6 @@ def select_container(self) -> Widget: return widget return container - @property - def select_scroll_container(self) -> Widget: - """The widget's container used when selecting text.. - - Returns: - A widget which contains this widget. - """ - - for widget in self.select_container.ancestors_with_self: - if isinstance(widget, Widget) and widget.allow_vertical_scroll: - return widget - return self.screen - def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). From 7688e58cc1b19632bec137ee491d8e3d0a5d37b4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:23:42 +0700 Subject: [PATCH 23/25] don't ignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9ccb9f880b..97b3813f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ tools/*.txt playground/ .mypy_cache/ .screenshot_cache/ -uv.lock # Byte-compiled / optimized / DLL files __pycache__/ From a211e1e6a317246c042fa05bdcb79646ab930aa2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:40:35 +0700 Subject: [PATCH 24/25] test fixes --- src/textual/geometry.py | 29 +--------------- tests/test_geometry.py | 77 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 3 ++ 3 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 uv.lock diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b24da7a9b7..7577d2cb43 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1426,7 +1426,7 @@ def get_regions() -> Iterable[Region]: 1, ) # middle - if end.y - start.y > 1: + if end.y - start.y > 2: # We need a middle region between the top and the bottom yield Region( 0, @@ -1466,33 +1466,6 @@ def contains_point(self, offset: Offset) -> bool: """ return any(region.contains_point(offset) for region in self._regions) - def intersection(self, clip: Region) -> Shape: - """Return a shape that is the intersection of this shape with a region. - - Args: - clip: A region. - - Returns: - A new shape. - """ - regions = [ - clipped_region - for region in self.regions - if (clipped_region := clip.intersection(region)) - ] - return Shape(regions) - - def contains_region(self, region: Region) -> bool: - """Check if the given region fits within this shape. - - Args: - region: A region. - - Returns: - `True` if the region fits within the shape without clipping, otherwise `False`. - """ - return self.intersection(region).area == self.area - if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1": try: diff --git a/tests/test_geometry.py b/tests/test_geometry.py index e91fa99b5e..c028454b85 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -752,3 +752,80 @@ def test_selection_bounds_contains_point_two_lines(): 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/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" From c59ba265a983219babdca83198aa5690cc777395 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Mar 2026 15:46:53 +0700 Subject: [PATCH 25/25] simplify --- src/textual/walk.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/walk.py b/src/textual/walk.py index 2f9f10990d..6a04f80df2 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -190,6 +190,8 @@ def walk_selectable_widgets( 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. @@ -200,7 +202,8 @@ def get_children(node: DOMNode) -> list[Widget]: A list of child widgets. """ children = sorted( - node.displayed_and_visible_children, key=attrgetter("_selection_order") + node.displayed_and_visible_children, + key=get_selection_order, ) if node in bounded: children = [child for child in children if bounds.overlaps(child.region)]