mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Added scroll_to_widget
This commit is contained in:
@@ -14,7 +14,7 @@ App.-show-focus *:focus {
|
||||
}
|
||||
|
||||
.list-item {
|
||||
height: 10;
|
||||
height: 6;
|
||||
color: #12a0;
|
||||
background: #ffffff00;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user