Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [8.2.0] - 2026-03-27

### Added

- Auto-scrolling on select https://github.com/Textualize/textual/pull/6440
- Selecting over containers https://github.com/Textualize/textual/pull/6440
- Added `App.ENABLE_SELECT_AUTO_SCROLL`, `App.SELECT_AUTO_SCROLL_LINES`, `App.SELECT_AUTO_SCROLL_SPEED` to tweak auto scrolling behavior https://github.com/Textualize/textual/pull/6440

## [8.1.1] - 2026-03-10

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
30 changes: 30 additions & 0 deletions src/textual/_auto_scroll.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from textual.geometry import Region


def get_auto_scroll_regions(
widget_region: Region, auto_scroll_lines: int
) -> tuple[Region, Region]:
"""Get non-overlapping regions which should auto scroll when selecting.

Args:
widget_region: The region occupied by the widget.
auto_scroll_lines: Number of lines in auto scroll regions.

Returns:
A pair of regions. The first for the region to scroll up, the second for the region to scroll down.
"""
x, y, width, height = widget_region

# Divide the region in to two, non overlapping regions
top_half, bottom_half = widget_region.split_horizontal(height // 2)

# Get a region at the top with the desired dimensions
up_region = Region(x, y, width, auto_scroll_lines)
# Ensure it is no larger than the top half
up_region = top_half.intersection(up_region)

# Repeat for the bottom half
down_region = Region(x, y + height - auto_scroll_lines, width, auto_scroll_lines)
down_region = bottom_half.intersection(down_region)

return up_region, down_region
15 changes: 15 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,15 @@ class MyApp(App[None]):
PAUSE_GC_ON_SCROLL: ClassVar[bool] = False
"""Pause Python GC (Garbage Collection) when scrolling, for potentially smoother scrolling with many widgets (experimental)."""

ENABLE_SELECT_AUTO_SCROLL: ClassVar[bool] = True
"""Enable automatic scrolling if selecting and the mouse is at the top or bottom of the widget?"""

SELECT_AUTO_SCROLL_LINES: ClassVar[int] = 3
"""Number of lines in auto-scrolling regions at the top and bottom of a widget."""

SELECT_AUTO_SCROLL_SPEED: ClassVar[float] = 60.0
"""Maximum speed of select auto-scroll in lines per second."""

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
Expand Down Expand Up @@ -631,7 +640,12 @@ def __init__(
self._action_targets = {"app", "screen", "focused"}
self._animator = Animator(self)
self._animate = self._animator.bind(self)

self.mouse_position = Offset(0, 0)
"""The current screen-space mouse position."""

self.mouse_position_high_resolution: tuple[float, float] = (0.0, 0.0)
"""A high resolution (floating point) mouse position. If supported by the terminal, this may be more granular than `mouse_position`"""

self._mouse_down_widget: Widget | None = None
"""The widget that was most recently mouse downed (used to create click events)."""
Expand Down Expand Up @@ -4019,6 +4033,7 @@ async def on_event(self, event: events.Event) -> None:
if isinstance(event, events.MouseEvent):
# Record current mouse position on App
self.mouse_position = Offset(event.x, event.y)
self.mouse_position_high_resolution = (event.screen_x, event.screen_y)
if isinstance(event, events.MouseDown):
try:
self._mouse_down_widget, _ = self.get_widget_at(
Expand Down
153 changes: 152 additions & 1 deletion src/textual/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
TYPE_CHECKING,
Any,
Collection,
Iterable,
Literal,
NamedTuple,
Tuple,
Expand All @@ -25,6 +26,7 @@
if TYPE_CHECKING:
from typing_extensions import TypeAlias

import rich.repr

SpacingDimensions: TypeAlias = Union[
int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]
Expand Down Expand Up @@ -1316,6 +1318,155 @@ def grow_maximum(self, other: Spacing) -> Spacing:
)


class Shape:
"""An arbitrary shape defined by a sequence of regions.

This class currently exists to filter widgets within a shape defined when the user is slecting text.

"""

__slots__ = [
"_regions",
"_bounds",
]

def __init__(self, regions: Iterable[Region]):
"""

Args:
regions: Regions which will define the shape.
"""
self._regions = tuple(regions)
self._bounds = Region.from_union(self._regions) if regions else NULL_REGION

def __bool__(self) -> bool:
return bool(self._bounds)

def __hash__(self) -> int:
return hash(self._regions)

def __rich_repr__(self) -> rich.repr.Result:
yield self._regions

@property
def regions(self) -> tuple[Region, ...]:
"""The regions in the shape."""
return self._regions

@property
def bounds(self) -> Region:
"""A region that encloses the shape."""
return self._bounds

@property
def area(self) -> int:
"""Cells covered by the shape."""
# TODO: Currently doesn't handle overlapping regions
return sum(region.area for region in self._regions)

@classmethod
def selection_bounds(cls, container: Region, start: Offset, end: Offset) -> Shape:
"""Get a shape that would be constructed by a user selecting text between two points.

The shape would look something like this:

```
XXXXXXXXXX <- top
XXXXXXXXXXXXXX
XXXXXXXXXXXXXX <- middle
XXXXXXXXXXXXXX
XXXXXXXXX <- bottom
```

Args:
container: The container region for the selection.
start: The start offset.
end: The end offset.

Returns:
A new shape covering the selection bounds.
"""
if start.transpose > end.transpose:
end, start = start, end
start_x, start_y = start
end_x, end_y = end

def get_regions() -> Iterable[Region]:
"""Get regions to cover selection bounds.

Yields:
Regions to cover bounds.
"""
# Special case where start and end offsets are on the edges, and the shape
# becomes a single region
if start_x == 0 and end_x == container.width:
yield Region(
0,
start_y,
container.width,
end_y - start_y,
)

# Simple case: all on one line
elif start.y == end.y:
yield Region(
start_x,
start_y,
end_x - start_x,
1,
)

# Shape is on two or more lines
else:
# top
yield Region(
start_x,
start_y,
container.width - start_x,
1,
)
# middle
if end.y - start.y > 2:
# We need a middle region between the top and the bottom
yield Region(
0,
start_y + 1,
container.width,
end_y - start_y - 1,
)
# bottom
yield Region(
container.x,
end_y,
end_x,
1,
)

return Shape(get_regions())

def overlaps(self, region: Region) -> bool:
"""Does a region overlap this shape?

Args:
region: A Region to check.

Returns:
`True` if any part of the shape overlaps the region, `False` if there is no overlap.
"""
return any(shape_region.overlaps(region) for shape_region in self._regions)

def contains_point(self, offset: Offset) -> bool:
"""Check if the given offset is within the shape.

Args:
offset: An offset.

Returns:
`True` if the given offset is anywhere within the shape, otherwise `False`.
"""
return any(region.contains_point(offset) for region in self._regions)


if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1":
try:
from textual_speedups import Offset, Region, Size, Spacing
Expand All @@ -1324,7 +1475,7 @@ def grow_maximum(self, other: Spacing) -> Spacing:


NULL_OFFSET: Final = Offset(0, 0)
"""An [offset][textual.geometry.Offset] constant for (0, 0)."""
"""An [Offset][textual.geometry.Offset] constant for (0, 0)."""

NULL_REGION: Final = Region(0, 0, 0, 0)
"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero)."""
Expand Down
Loading
Loading