From e87edd79cbb0312ff031c7b1c3d44b6875b4ca28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?=
<5621605+rodrigogiraoserrao@users.noreply.github.com>
Date: Tue, 11 Apr 2023 13:29:56 +0100
Subject: [PATCH 1/5] Add test for scroll_to_center method.
---
.../snapshot_apps/scroll_to_center.py | 40 +++++++++++++++++++
tests/snapshot_tests/test_snapshots.py | 4 ++
2 files changed, 44 insertions(+)
create mode 100644 tests/snapshot_tests/snapshot_apps/scroll_to_center.py
diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to_center.py b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py
new file mode 100644
index 0000000000..fdd616cb6d
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py
@@ -0,0 +1,40 @@
+from textual.app import App, ComposeResult
+from textual.containers import HorizontalScroll, VerticalScroll
+from textual.widgets import Label
+
+
+class MyApp(App[None]):
+ CSS = """
+ VerticalScroll, HorizontalScroll {
+ border: round $primary;
+ }
+ #vertical {
+ height: 21;
+ }
+ HorizontalScroll {
+ height: auto;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ with VerticalScroll():
+ yield Label(("SPAM\n" * 25)[:-1])
+ with VerticalScroll():
+ yield Label(("SPAM\n" * 53)[:-1])
+ with VerticalScroll(id="vertical"):
+ yield Label(("SPAM\n" * 78)[:-1])
+ with HorizontalScroll():
+ yield Label(("v\n" * 17)[:-1])
+ yield Label("@" * 302)
+ yield Label("[red]>>bullseye<<[/red]", id="bullseye")
+ yield Label("@" * 99)
+ yield Label(("SPAM\n" * 49)[:-1])
+ yield Label(("SPAM\n" * 51)[:-1])
+ yield Label(("SPAM\n" * 59)[:-1])
+
+ def key_s(self) -> None:
+ self.query_one("#bullseye").scroll_to_center()
+
+
+if __name__ == "__main__":
+ MyApp().run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 14226a191f..6e87678859 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -406,3 +406,7 @@ def test_fr_margins(snap_compare):
def test_scroll_visible(snap_compare):
# https://github.com/Textualize/textual/issues/2181
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_visible.py", press=["t"])
+
+
+def test_scroll_to_center(snap_compare):
+ assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to_center.py", press=["s"])
From 454254fab4e43a57af192a27b4468ac0e59850f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?=
<5621605+rodrigogiraoserrao@users.noreply.github.com>
Date: Tue, 11 Apr 2023 13:32:09 +0100
Subject: [PATCH 2/5] Implement scroll_to_center method.
---
CHANGELOG.md | 7 ++
src/textual/widget.py | 98 ++++++++++++++++++++++++++
tests/snapshot_tests/test_snapshots.py | 6 ++
3 files changed, 111 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c17bbe06c3..2124f2dd3c 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
+
+- `Widget.scroll_to_center` now scrolls the widget to the center of the screen https://github.com/Textualize/textual/pull/2255
+
## [0.19.1] - 2023-04-10
### Fixed
diff --git a/src/textual/widget.py b/src/textual/widget.py
index fa8d566abe..a628bf94b3 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -2408,6 +2408,104 @@ def scroll_visible(
force=force,
)
+ async def _scroll_to_center_of(
+ self,
+ widget: Widget,
+ animate: bool = True,
+ *,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> None:
+ """Scroll a widget to the center of this container.
+
+ Args:
+ widget: The widget to center.
+ animate: Whether to animate the scroll.
+ speed: Speed of scroll if animate is `True`; or `None` to use `duration`.
+ duration: Duration of animation, if `animate` is `True` and `speed` is `None`.
+ easing: An easing method for the scrolling animation.
+ force: Force scrolling even when prohibited by overflow styling.
+ """
+
+ central_point = Offset(
+ widget.virtual_region.x + (1 + widget.virtual_region.width) // 2,
+ widget.virtual_region.y + (1 + widget.virtual_region.height) // 2,
+ )
+
+ container = widget.parent
+ while isinstance(container, Widget) and widget is not self:
+ container_virtual_region = container.virtual_region
+ # The region we want to scroll to must be centered around the central point.
+ # We make it as big as possible because `scroll_to_region` scrolls as little
+ # as possible.
+ target_region = Region(
+ central_point.x - container_virtual_region.width // 2,
+ central_point.y - container_virtual_region.height // 2,
+ container_virtual_region.width,
+ container_virtual_region.height,
+ )
+ scroll = container.scroll_to_region(
+ target_region,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
+ # We scroll `widget` within `container` with the central point written in
+ # the frame of reference of `container`. However, we need to update it so
+ # that we are ready to scroll `container` within _its_ container.
+ # To do this, notice that
+ # (central_point.y - container.scroll_offset.y - scroll.y) is the number
+ # of rows of `widget` that are visible within `container`.
+ # We add that to `container_virtual_region.y` to find the total vertical
+ # offset of the central point with respect to the container of `container`.
+ # A similar calculation is made for the horizontal update.
+ central_point = Offset(
+ container_virtual_region.x
+ + central_point.x
+ - container.scroll_offset.x
+ - scroll.x,
+ container_virtual_region.y
+ + central_point.y
+ - container.scroll_offset.y
+ - scroll.y,
+ )
+ widget = container
+ container = widget.parent
+
+ def scroll_to_center(
+ self,
+ animate: bool = True,
+ *,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> None:
+ """Scroll this widget to the center of the screen.
+
+ Args:
+ animate: Whether to animate the scroll.
+ speed: Speed of scroll if animate is `True`; or `None` to use `duration`.
+ duration: Duration of animation, if `animate` is `True` and `speed` is `None`.
+ easing: An easing method for the scrolling animation.
+ force: Force scrolling even when prohibited by overflow styling.
+ """
+
+ self.call_after_refresh(
+ self.screen._scroll_to_center_of,
+ widget=self,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
def __init_subclass__(
cls,
can_focus: bool | None = None,
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 6e87678859..edc151e28d 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -409,4 +409,10 @@ def test_scroll_visible(snap_compare):
def test_scroll_to_center(snap_compare):
+ # READ THIS IF THIS TEST FAILS:
+ # While https://github.com/Textualize/textual/issues/2254 is open, the snapshot
+ # this is being compared against is INCORRECT.
+ # The correct output for this snapshot test would show a couple of containers
+ # scrolled so that the red string >>bullseye<< is centered on the screen.
+ # When this snapshot "breaks" because #2254 is fixed, this snapshot can be updated.
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to_center.py", press=["s"])
From 2588cfdb0d8e9e6bfe84ab9298cc05e89970a25e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?=
<5621605+rodrigogiraoserrao@users.noreply.github.com>
Date: Tue, 11 Apr 2023 13:39:24 +0100
Subject: [PATCH 3/5] Update snapshot test.
---
.../__snapshots__/test_snapshots.ambr | 480 ++++++------------
1 file changed, 158 insertions(+), 322 deletions(-)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index e6c1164852..9e4ea19539 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -17278,328 +17278,6 @@
'''
# ---
-# name: test_modal_dialog_bindings
- '''
-
-
- '''
-# ---
-# name: test_modal_dialog_bindings_input
- '''
-
-
- '''
-# ---
# name: test_multiple_css
'''