diff --git a/sandbox/uber.css b/sandbox/uber.css index 14848e9c2a..8c1df2124d 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -14,7 +14,7 @@ App.-show-focus *:focus { } .list-item { - height: 10; + height: 6; color: #12a0; background: #ffffff00; } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1b1b0ed0bf..c84794ccf3 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -50,17 +50,17 @@ class ReflowResult(NamedTuple): resized: set[Widget] # Widgets that have been resized -class RenderRegion(NamedTuple): +class MapGeometry(NamedTuple): """Defines the absolute location of a Widget.""" region: Region # The region occupied by the widget order: tuple[int, ...] # A tuple of ints defining the painting order clip: Region # A region to clip the widget by (if a Widget is within a container) virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container - container_size: Size # The container size (area no occupied by scrollbars) + container_size: Size # The container size (area not occupied by scrollbars) -RenderRegionMap: TypeAlias = "dict[Widget, RenderRegion]" +CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" @rich.repr.auto @@ -97,7 +97,7 @@ class Compositor: def __init__(self) -> None: # A mapping of Widget on to its "render location" (absolute position / depth) - self.map: RenderRegionMap = {} + self.map: CompositorMap = {} # All widgets considered in the arrangement # Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons @@ -167,7 +167,7 @@ def reflow(self, parent: Widget, size: Size) -> ReflowResult: resized=resized_widgets, ) - def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, set[Widget]]: + def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: """Arrange a widgets children based on its layout attribute. Args: @@ -180,7 +180,7 @@ def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, set[Widget]]: ORIGIN = Offset(0, 0) size = root.size - map: RenderRegionMap = {} + map: CompositorMap = {} widgets: set[Widget] = set() get_order = attrgetter("order") @@ -249,7 +249,7 @@ def add_widget( for chrome_widget, chrome_region in widget._arrange_scrollbars( container_size ): - map[chrome_widget] = RenderRegion( + map[chrome_widget] = MapGeometry( chrome_region + container_region.origin + layout_offset, order, clip, @@ -258,7 +258,7 @@ def add_widget( ) # Add the container widget, which will render a background - map[widget] = RenderRegion( + map[widget] = MapGeometry( region + layout_offset, order, clip, @@ -268,7 +268,7 @@ def add_widget( else: # Add the widget to the map - map[widget] = RenderRegion( + map[widget] = MapGeometry( region + layout_offset, order, clip, region.size, container_size ) @@ -338,8 +338,8 @@ def get_style_at(self, x: int, y: int) -> Style: return segment.style or Style.null() return Style.null() - def get_widget_region(self, widget: Widget) -> Region: - """Get the Region of a Widget contained in this Layout. + def find_widget(self, widget: Widget) -> MapGeometry: + """Get information regarding the relative position of a widget in the Compositor. Args: widget (Widget): The Widget in this layout you wish to know the Region of. @@ -348,11 +348,11 @@ def get_widget_region(self, widget: Widget) -> Region: NoWidget: If the Widget is not contained in this Layout. Returns: - Region: The Region of the Widget. + MapGeometry: Widget's composition information. """ try: - region, *_ = self.map[widget] + region = self.map[widget] except KeyError: raise errors.NoWidget("Widget is not in layout") else: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 2a7471cd08..681ed797d4 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -241,7 +241,13 @@ def gutter(self) -> Spacing: Returns: Spacing: Space around widget. """ - spacing = Spacing() + self.padding + self.border.spacing + spacing = self.padding + self.border.spacing + return spacing + + @property + def content_gutter(self) -> Spacing: + """The spacing that surrounds the content area of the widget.""" + spacing = self.padding + self.border.spacing + self.margin return spacing @abstractmethod diff --git a/src/textual/events.py b/src/textual/events.py index 20bf7033ab..aa24370786 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -395,9 +395,9 @@ class Blur(Event, bubble=False): pass -class DescendantFocus(Event, bubble=True): +class DescendantFocus(Event, verbosity=2, bubble=True): pass -class DescendantBlur(Event, bubble=True): +class DescendantBlur(Event, verbosity=2, bubble=True): pass diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3247071786..cc1d809426 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -257,6 +257,24 @@ def origin(self) -> Offset: """Get the start point of the region.""" return Offset(self.x, self.y) + @property + def bottom_left(self) -> Offset: + """Bottom left offset of the region.""" + x, y, _width, height = self + return Offset(x, y + height) + + @property + def top_right(self) -> Offset: + """Top right offset of the region.""" + x, y, width, _height = self + return Offset(x + width, y) + + @property + def bottom_right(self) -> Offset: + """Bottom right of the region.""" + x, y, width, height = self + return Offset(x + width, y + height) + @property def size(self) -> Size: """Get the size of the region.""" @@ -274,17 +292,17 @@ def corners(self) -> tuple[int, int, int, int]: @property def x_range(self) -> range: - """A range object for X coordinates""" + """A range object for X coordinates.""" return range(self.x, self.x + self.width) @property def y_range(self) -> range: - """A range object for Y coordinates""" + """A range object for Y coordinates.""" return range(self.y, self.y + self.height) @property def reset_origin(self) -> Region: - """An region of the same size at the origin.""" + """An region of the same size at (0, 0).""" _, _, width, height = self return Region(0, 0, width, height) diff --git a/src/textual/screen.py b/src/textual/screen.py index a575018dac..cac9e296f3 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -8,7 +8,7 @@ from . import events, messages, errors from .geometry import Offset, Region -from ._compositor import Compositor +from ._compositor import Compositor, MapGeometry from .reactive import Reactive from .widget import Widget @@ -76,7 +76,7 @@ def get_style_at(self, x: int, y: int) -> Style: """ return self._compositor.get_style_at(x, y) - def get_widget_region(self, widget: Widget) -> Region: + def find_widget(self, widget: Widget) -> MapGeometry: """Get the screen region of a Widget. Args: @@ -85,7 +85,7 @@ def get_widget_region(self, widget: Widget) -> Region: Returns: Region: Region relative to screen. """ - return self._compositor.get_widget_region(widget) + return self._compositor.find_widget(widget) def on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) @@ -156,7 +156,7 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: try: if self.app.mouse_captured: widget = self.app.mouse_captured - region = self.get_widget_region(widget) + region = self.find_widget(widget).region else: widget, region = self.get_widget_at(event.x, event.y) except errors.NoWidget: @@ -195,7 +195,7 @@ async def forward_event(self, event: events.Event) -> None: try: if self.app.mouse_captured: widget = self.app.mouse_captured - region = self.get_widget_region(widget) + region = self.find_widget(widget).region else: widget, region = self.get_widget_at(event.x, event.y) except errors.NoWidget: diff --git a/src/textual/widget.py b/src/textual/widget.py index e46d9bb765..3be4e77c17 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -298,17 +298,21 @@ def scroll_to( y: float | None = None, *, animate: bool = True, + speed: float | None = None, + duration: float | None = None, ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. Args: - scroll_x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None. - scroll_y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None. + x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None. + y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None. animate (bool, optional): Animate to new scroll position. Defaults to False. + + Returns: + bool: True if the scroll position changed, otherwise False. """ - scrolled_x = False - scrolled_y = False + scrolled_x = scrolled_y = False if animate: # TODO: configure animation speed @@ -316,67 +320,142 @@ def scroll_to( self.scroll_target_x = x if x != self.scroll_x: self.animate( - "scroll_x", self.scroll_target_x, speed=80, easing="out_cubic" + "scroll_x", + self.scroll_target_x, + speed=speed, + duration=duration, + easing="out_cubic", ) scrolled_x = True if y is not None: self.scroll_target_y = y if y != self.scroll_y: self.animate( - "scroll_y", self.scroll_target_y, speed=80, easing="out_cubic" + "scroll_y", + self.scroll_target_y, + speed=speed, + duration=duration, + easing="out_cubic", ) scrolled_y = True else: if x is not None: - self.scroll_target_x = self.scroll_x = x if x != self.scroll_x: + self.scroll_target_x = self.scroll_x = x scrolled_x = True if y is not None: - self.scroll_target_y = self.scroll_y = y if y != self.scroll_y: + self.scroll_target_y = self.scroll_y = y scrolled_y = True - self.refresh(repaint=False, layout=True) + if scrolled_x or scrolled_y: + self.refresh(repaint=False, layout=True) + return scrolled_x or scrolled_y - def scroll_home(self, animate: bool = True) -> bool: + def scroll_relative( + self, + x: float | None = None, + y: float | None = None, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll relative to current position. + + Args: + x (int | None, optional): X distance (columns) to scroll, or ``None`` for no change. Defaults to None. + y (int | None, optional): Y distance (rows) to scroll, or ``None`` for no change. Defaults to None. + animate (bool, optional): Animate to new scroll position. Defaults to False. + + Returns: + bool: True if the scroll position changed, otherwise False. + """ + return self.scroll_to( + None if x is None else (self.scroll_x + x), + None if y is None else (self.scroll_y + y), + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_home(self, *, animate: bool = True) -> bool: return self.scroll_to(0, 0, animate=animate) - def scroll_end(self, animate: bool = True) -> bool: + def scroll_end(self, *, animate: bool = True) -> bool: return self.scroll_to(0, self.max_scroll_y, animate=animate) - def scroll_left(self, animate: bool = True) -> bool: + def scroll_left(self, *, animate: bool = True) -> bool: return self.scroll_to(x=self.scroll_target_x - 1, animate=animate) - def scroll_right(self, animate: bool = True) -> bool: + def scroll_right(self, *, animate: bool = True) -> bool: return self.scroll_to(x=self.scroll_target_x + 1, animate=animate) - def scroll_up(self, animate: bool = True) -> bool: + def scroll_up(self, *, animate: bool = True) -> bool: return self.scroll_to(y=self.scroll_target_y + 1, animate=animate) - def scroll_down(self, animate: bool = True) -> bool: + def scroll_down(self, *, animate: bool = True) -> bool: return self.scroll_to(y=self.scroll_target_y - 1, animate=animate) - def scroll_page_up(self, animate: bool = True) -> bool: + def scroll_page_up(self, *, animate: bool = True) -> bool: return self.scroll_to( y=self.scroll_target_y - self.container_size.height, animate=animate ) - def scroll_page_down(self, animate: bool = True) -> bool: + def scroll_page_down(self, *, animate: bool = True) -> bool: return self.scroll_to( y=self.scroll_target_y + self.container_size.height, animate=animate ) - def scroll_page_left(self, animate: bool = True) -> bool: + def scroll_page_left(self, *, animate: bool = True) -> bool: return self.scroll_to( x=self.scroll_target_x - self.container_size.width, animate=animate ) - def scroll_page_right(self, animate: bool = True) -> bool: + def scroll_page_right(self, *, animate: bool = True) -> bool: return self.scroll_to( x=self.scroll_target_x + self.container_size.width, animate=animate ) + def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool: + """Scroll so that a child widget is in the visible area. + + Args: + widget (Widget): A Widget in the children. + animate (bool, optional): True to animate, or False to jump. Defaults to True. + + Returns: + bool: True if the scroll position changed, otherwise False. + """ + screen = self.screen + try: + widget_geometry = screen.find_widget(widget) + container_geometry = screen.find_widget(self) + except errors.NoWidget: + return False + + widget_region = widget.content_region + widget_geometry.region.origin + container_region = self.content_region + container_geometry.region.origin + + if widget_region in container_region: + # Widget is visible, nothing to do + return False + + # We can either scroll so the widget is at the top of the container, or so that + # it is at the bottom. We want to pick which has the shortest distance + top_delta = widget_region.origin - container_region.origin + bottom_delta = widget_region.origin - ( + container_region.origin + + Offset(0, container_region.height - widget_region.height) + ) + + delta_x = min(top_delta.x, bottom_delta.x, key=abs) + delta_y = min(top_delta.y, bottom_delta.y, key=abs) + return self.scroll_relative( + delta_x or None, delta_y or None, animate=animate, duration=0.2 + ) + def __init_subclass__( cls, can_focus: bool = True, can_focus_children: bool = True ) -> None: @@ -517,14 +596,28 @@ def size(self) -> Size: def container_size(self) -> Size: return self._container_size + @property + def content_region(self) -> Region: + """A region relative to the Widget origin that contains the content.""" + x, y = self.styles.content_gutter.top_left + width, height = self._container_size + return Region(x, y, width, height) + + @property + def content_offset(self) -> Offset: + """An offset from the Widget origin where the content begins.""" + x, y = self.styles.content_gutter.top_left + return Offset(x, y) + @property def virtual_size(self) -> Size: return self._virtual_size @property def region(self) -> Region: + """The region occupied by this widget, relative to the Screen.""" try: - return self.screen._compositor.get_widget_region(self) + return self.screen.find_widget(self).region except errors.NoWidget: return Region() @@ -766,6 +859,8 @@ def on_blur(self, event: events.Blur) -> None: def on_descendant_focus(self, event: events.DescendantFocus) -> None: self.descendant_has_focus = True + if self.is_container and isinstance(event.sender, Widget): + self.scroll_to_widget(event.sender, animate=True) def on_descendant_blur(self, event: events.DescendantBlur) -> None: self.descendant_has_focus = False diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 72d5de4626..26b7a330b5 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -132,6 +132,18 @@ def test_region_origin(): assert Region(1, 2, 3, 4).origin == Offset(1, 2) +def test_region_bottom_left(): + assert Region(1, 2, 3, 4).bottom_left == Offset(1, 6) + + +def test_region_top_right(): + assert Region(1, 2, 3, 4).top_right == Offset(4, 2) + + +def test_region_bottom_right(): + assert Region(1, 2, 3, 4).bottom_right == Offset(4, 6) + + def test_region_add(): assert Region(1, 2, 3, 4) + (10, 20) == Region(11, 22, 3, 4) with pytest.raises(TypeError): diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 141bb0f387..f33a5f1bda 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -155,7 +155,7 @@ def compose(self) -> ComposeResult: # root widget checks: root_widget = cast(Widget, app.get_child("root")) assert root_widget.size == expected_screen_size - root_widget_region = app.screen.get_widget_region(root_widget) + root_widget_region = app.screen.find_widget(root_widget).region assert root_widget_region == ( 0, 0,