From a9701931847d7d705c35e5865a1f0b8aadb2cb81 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 May 2022 15:59:59 +0100 Subject: [PATCH] Added scroll_to_widget --- sandbox/uber.css | 2 +- src/textual/_compositor.py | 20 ++++---- src/textual/css/styles.py | 7 ++- src/textual/events.py | 4 +- src/textual/geometry.py | 15 ++++++ src/textual/screen.py | 10 ++-- src/textual/widget.py | 102 ++++++++++++++++++++++++++++++------- 7 files changed, 123 insertions(+), 37 deletions(-) diff --git a/sandbox/uber.css b/sandbox/uber.css index 14848e9c2..8c1df2124 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 5e4652a59..b770ca4b2 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -52,17 +52,17 @@ class ReflowResult(NamedTuple): resized: set[Widget] # Widgets that have been resized -class RenderRegion(NamedTuple): +class RegionGeometry(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]" +RenderRegionMap: TypeAlias = "dict[Widget, RegionGeometry]" @rich.repr.auto @@ -251,7 +251,7 @@ class Compositor: for chrome_widget, chrome_region in widget._arrange_scrollbars( container_size ): - map[chrome_widget] = RenderRegion( + map[chrome_widget] = RegionGeometry( chrome_region + container_region.origin + layout_offset, order, clip, @@ -260,7 +260,7 @@ class Compositor: ) # Add the container widget, which will render a background - map[widget] = RenderRegion( + map[widget] = RegionGeometry( region + layout_offset, order, clip, @@ -270,7 +270,7 @@ class Compositor: else: # Add the widget to the map - map[widget] = RenderRegion( + map[widget] = RegionGeometry( region + layout_offset, order, clip, region.size, container_size ) @@ -340,8 +340,8 @@ class Compositor: 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) -> RegionGeometry: + """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. @@ -350,11 +350,11 @@ class Compositor: NoWidget: If the Widget is not contained in this Layout. Returns: - Region: The Region of the Widget. + RenderRegion: Widget 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 2a7471cd0..1386c6d14 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -241,7 +241,12 @@ class StylesBase(ABC): 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: + spacing = self.padding + self.border.spacing + self.margin return spacing @abstractmethod diff --git a/src/textual/events.py b/src/textual/events.py index 20bf7033a..aa2437078 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 324707178..2eb242bf5 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -257,6 +257,21 @@ class Region(NamedTuple): """Get the start point of the region.""" return Offset(self.x, self.y) + @property + def bottom_left(self) -> Offset: + x, y, _width, height = self + return Offset(x, y + height) + + @property + def top_right(self) -> Offset: + x, y, width, _height = self + return Offset(x + width, y) + + @property + def bottom_right(self) -> Offset: + x, y, width, height = self + return Offset(x + width, y + height) + @property def size(self) -> Size: """Get the size of the region.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 75607ab44..cfd796d59 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -8,7 +8,7 @@ from rich.style import Style from . import events, messages, errors from .geometry import Offset, Region -from ._compositor import Compositor +from ._compositor import Compositor, RegionGeometry from .reactive import Reactive from .widget import Widget @@ -76,7 +76,7 @@ class Screen(Widget): """ return self._compositor.get_style_at(x, y) - def get_widget_region(self, widget: Widget) -> Region: + def find_widget(self, widget: Widget) -> RegionGeometry: """Get the screen region of a Widget. Args: @@ -85,7 +85,7 @@ class Screen(Widget): 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 @@ class Screen(Widget): 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 @@ class Screen(Widget): 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 9ce3b74f4..0ad84e9f4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -300,13 +300,12 @@ class Widget(DOMNode): """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. + x (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. """ - scrolled_x = False - scrolled_y = False + scrolled_x = scrolled_y = False if animate: # TODO: configure animation speed @@ -327,54 +326,106 @@ class Widget(DOMNode): 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 + # self.scroll_target_x = self.scroll_x = x + # if x != self.scroll_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) + # self.scroll_target_y = self.scroll_y = y + # if y != self.scroll_y: + # scrolled_y = 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, + ) -> bool: + """Scroll relative to current position. + + Args: + x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None. + x (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. + """ + 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, + ) + + 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: + 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: + return False + + 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=True) + def __init_subclass__( cls, can_focus: bool = True, can_focus_children: bool = True ) -> None: @@ -515,6 +566,18 @@ class Widget(DOMNode): 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: + x, y = self.styles.content_gutter.top_left + return Offset(x, y) + @property def virtual_size(self) -> Size: return self._virtual_size @@ -522,7 +585,7 @@ class Widget(DOMNode): @property def region(self) -> Region: try: - return self.screen._compositor.get_widget_region(self) + return self.screen._compositor.find_widget(self) except errors.NoWidget: return Region() @@ -759,6 +822,9 @@ class Widget(DOMNode): def on_descendant_focus(self, event: events.DescendantFocus) -> None: self.descendant_has_focus = True + if self.is_container: + self.log(event.sender) + self.scroll_to_widget(event.sender, animate=True) def on_descendant_blur(self, event: events.DescendantBlur) -> None: self.descendant_has_focus = False