Repository: Textualize/textual

Issue:
Implement scroll_to_center method
We have methods to scroll widgets in to view, but these tend to do as little scrolling as possible.

We may have a need to scroll something to the center. This would benefit the Tabs control.


Discussion:
Do you mind clarifying what "center" we are talking about?
With what I'm imagining in my head, I can't figure out how it would benefit the `Tabs` control.
It would scroll the widgets parent so as to position the widget in the center of its container.

When move the tab selection to the right edge, you can't see what tabs are remaining. By moving the section to the center, you can see tabs in both directions.

Resolving patch:
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -2408,6 +2408,100 @@ 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 = (
+                container_virtual_region.offset
+                + central_point
+                - container.scroll_offset
+                - scroll
+            )
+            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,


Test patch:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -19840,6 +19840,164 @@
   
   '''
 # ---
+# name: test_scroll_to_center
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1405474793-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1405474793-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1405474793-r1 { fill: #004578 }
+  .terminal-1405474793-r2 { fill: #c5c8c6 }
+  .terminal-1405474793-r3 { fill: #e1e1e1 }
+  .terminal-1405474793-r4 { fill: #23568b }
+  .terminal-1405474793-r5 { fill: #14191f }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1405474793-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1405474793-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1405474793-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1405474793-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MyApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1405474793-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="1.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="25.9" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="902.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="50.3" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="902.8" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="74.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="74.7" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="902.8" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="99.1" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="902.8" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="123.5" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="902.8" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="147.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="147.9" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="902.8" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="172.3" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="927.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="196.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="196.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="221.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="221.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="245.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="245.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="939.4" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="269.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="269.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="939.4" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="294.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="294.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="939.4" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="318.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="318.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="939.4" y="318.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="343.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="343.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="367.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="367.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="391.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="391.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="416.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="416.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="440.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="440.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="465.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="465.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="465.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="489.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="489.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="489.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="513.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="513.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="513.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="538.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="538.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="939.4" y="538.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="562.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1405474793-matrix">
+      <text class="terminal-1405474793-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-1405474793-line-0)">╭</text><text class="terminal-1405474793-r1" x="12.2" y="20" textLength="951.6" clip-path="url(#terminal-1405474793-line-0)">──────────────────────────────────────────────────────────────────────────────</text><text class="terminal-1405474793-r1" x="963.8" y="20" textLength="12.2" clip-path="url(#terminal-1405474793-line-0)">╮</text><text class="terminal-1405474793-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1405474793-line-0)">
+  </text><text class="terminal-1405474793-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-1)">│</text><text class="terminal-1405474793-r1" x="12.2" y="44.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-1)">│</text><text class="terminal-1405474793-r3" x="24.4" y="44.4" textLength="48.8" clip-path="url(#terminal-1405474793-line-1)">SPAM</text><text class="terminal-1405474793-r1" x="927.2" y="44.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-1)">│</text><text class="terminal-1405474793-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-1)">│</text><text class="terminal-1405474793-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-1)">
+  </text><text class="terminal-1405474793-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-2)">│</text><text class="terminal-1405474793-r1" x="12.2" y="68.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-2)">│</text><text class="terminal-1405474793-r3" x="24.4" y="68.8" textLength="48.8" clip-path="url(#terminal-1405474793-line-2)">SPAM</text><text class="terminal-1405474793-r1" x="927.2" y="68.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-2)">│</text><text class="terminal-1405474793-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-2)">│</text><text class="terminal-1405474793-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-2)">
+  </text><text class="terminal-1405474793-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-3)">│</text><text class="terminal-1405474793-r1" x="12.2" y="93.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-3)">│</text><text class="terminal-1405474793-r3" x="24.4" y="93.2" textLength="48.8" clip-path="url(#terminal-1405474793-line-3)">SPAM</text><text class="terminal-1405474793-r4" x="902.8" y="93.2" textLength="24.4" clip-path="url(#terminal-1405474793-line-3)">▂▂</text><text class="terminal-1405474793-r1" x="927.2" y="93.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-3)">│</text><text class="terminal-1405474793-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-3)">│</text><text class="terminal-1405474793-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-3)">
+  </text><text class="terminal-1405474793-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-4)">│</text><text class="terminal-1405474793-r1" x="12.2" y="117.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-4)">│</text><text class="terminal-1405474793-r3" x="24.4" y="117.6" textLength="48.8" clip-path="url(#terminal-1405474793-line-4)">SPAM</text><text class="terminal-1405474793-r1" x="927.2" y="117.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-4)">│</text><text class="terminal-1405474793-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-4)">│</text><text class="terminal-1405474793-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-4)">
+  </text><text class="terminal-1405474793-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-1405474793-line-5)">│</text><text class="terminal-1405474793-r1" x="12.2" y="142" textLength="12.2" clip-path="url(#terminal-1405474793-line-5)">│</text><text class="terminal-1405474793-r3" x="24.4" y="142" textLength="48.8" clip-path="url(#terminal-1405474793-line-5)">SPAM</text><text class="terminal-1405474793-r1" x="927.2" y="142" textLength="12.2" clip-path="url(#terminal-1405474793-line-5)">│</text><text class="terminal-1405474793-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-1405474793-line-5)">│</text><text class="terminal-1405474793-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1405474793-line-5)">
+  </text><text class="terminal-1405474793-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-6)">│</text><text class="terminal-1405474793-r1" x="12.2" y="166.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-6)">│</text><text class="terminal-1405474793-r3" x="24.4" y="166.4" textLength="48.8" clip-path="url(#terminal-1405474793-line-6)">SPAM</text><text class="terminal-1405474793-r1" x="927.2" y="166.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-6)">│</text><text class="terminal-1405474793-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-6)">│</text><text class="terminal-1405474793-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-6)">
+  </text><text class="terminal-1405474793-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-7)">│</text><text class="terminal-1405474793-r1" x="12.2" y="190.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-7)">╰</text><text class="terminal-1405474793-r1" x="24.4" y="190.8" textLength="902.8" clip-path="url(#terminal-1405474793-line-7)">──────────────────────────────────────────────────────────────────────────</text><text class="terminal-1405474793-r1" x="927.2" y="190.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-7)">╯</text><text class="terminal-1405474793-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-7)">│</text><text class="terminal-1405474793-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-7)">
+  </text><text class="terminal-1405474793-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-8)">│</text><text class="terminal-1405474793-r3" x="12.2" y="215.2" textLength="48.8" clip-path="url(#terminal-1405474793-line-8)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-8)">│</text><text class="terminal-1405474793-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-8)">
+  </text><text class="terminal-1405474793-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-9)">│</text><text class="terminal-1405474793-r3" x="12.2" y="239.6" textLength="48.8" clip-path="url(#terminal-1405474793-line-9)">SPAM</text><text class="terminal-1405474793-r4" x="939.4" y="239.6" textLength="24.4" clip-path="url(#terminal-1405474793-line-9)">▆▆</text><text class="terminal-1405474793-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-9)">│</text><text class="terminal-1405474793-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-9)">
+  </text><text class="terminal-1405474793-r1" x="0" y="264" textLength="12.2" clip-path="url(#terminal-1405474793-line-10)">│</text><text class="terminal-1405474793-r3" x="12.2" y="264" textLength="48.8" clip-path="url(#terminal-1405474793-line-10)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="264" textLength="12.2" clip-path="url(#terminal-1405474793-line-10)">│</text><text class="terminal-1405474793-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1405474793-line-10)">
+  </text><text class="terminal-1405474793-r1" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-11)">│</text><text class="terminal-1405474793-r3" x="12.2" y="288.4" textLength="48.8" clip-path="url(#terminal-1405474793-line-11)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="288.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-11)">│</text><text class="terminal-1405474793-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-11)">
+  </text><text class="terminal-1405474793-r1" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-12)">│</text><text class="terminal-1405474793-r3" x="12.2" y="312.8" textLength="48.8" clip-path="url(#terminal-1405474793-line-12)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="312.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-12)">│</text><text class="terminal-1405474793-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-12)">
+  </text><text class="terminal-1405474793-r1" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-13)">│</text><text class="terminal-1405474793-r3" x="12.2" y="337.2" textLength="48.8" clip-path="url(#terminal-1405474793-line-13)">SPAM</text><text class="terminal-1405474793-r5" x="939.4" y="337.2" textLength="24.4" clip-path="url(#terminal-1405474793-line-13)">▁▁</text><text class="terminal-1405474793-r1" x="963.8" y="337.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-13)">│</text><text class="terminal-1405474793-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-13)">
+  </text><text class="terminal-1405474793-r1" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-14)">│</text><text class="terminal-1405474793-r3" x="12.2" y="361.6" textLength="48.8" clip-path="url(#terminal-1405474793-line-14)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="361.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-14)">│</text><text class="terminal-1405474793-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-14)">
+  </text><text class="terminal-1405474793-r1" x="0" y="386" textLength="12.2" clip-path="url(#terminal-1405474793-line-15)">│</text><text class="terminal-1405474793-r3" x="12.2" y="386" textLength="48.8" clip-path="url(#terminal-1405474793-line-15)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="386" textLength="12.2" clip-path="url(#terminal-1405474793-line-15)">│</text><text class="terminal-1405474793-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1405474793-line-15)">
+  </text><text class="terminal-1405474793-r1" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-16)">│</text><text class="terminal-1405474793-r3" x="12.2" y="410.4" textLength="48.8" clip-path="url(#terminal-1405474793-line-16)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="410.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-16)">│</text><text class="terminal-1405474793-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-16)">
+  </text><text class="terminal-1405474793-r1" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-17)">│</text><text class="terminal-1405474793-r3" x="12.2" y="434.8" textLength="48.8" clip-path="url(#terminal-1405474793-line-17)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="434.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-17)">│</text><text class="terminal-1405474793-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-17)">
+  </text><text class="terminal-1405474793-r1" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-18)">│</text><text class="terminal-1405474793-r3" x="12.2" y="459.2" textLength="48.8" clip-path="url(#terminal-1405474793-line-18)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="459.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-18)">│</text><text class="terminal-1405474793-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-18)">
+  </text><text class="terminal-1405474793-r1" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-19)">│</text><text class="terminal-1405474793-r3" x="12.2" y="483.6" textLength="48.8" clip-path="url(#terminal-1405474793-line-19)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="483.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-19)">│</text><text class="terminal-1405474793-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1405474793-line-19)">
+  </text><text class="terminal-1405474793-r1" x="0" y="508" textLength="12.2" clip-path="url(#terminal-1405474793-line-20)">│</text><text class="terminal-1405474793-r3" x="12.2" y="508" textLength="48.8" clip-path="url(#terminal-1405474793-line-20)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="508" textLength="12.2" clip-path="url(#terminal-1405474793-line-20)">│</text><text class="terminal-1405474793-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1405474793-line-20)">
+  </text><text class="terminal-1405474793-r1" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-21)">│</text><text class="terminal-1405474793-r3" x="12.2" y="532.4" textLength="48.8" clip-path="url(#terminal-1405474793-line-21)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="532.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-21)">│</text><text class="terminal-1405474793-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1405474793-line-21)">
+  </text><text class="terminal-1405474793-r1" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-22)">│</text><text class="terminal-1405474793-r3" x="12.2" y="556.8" textLength="48.8" clip-path="url(#terminal-1405474793-line-22)">SPAM</text><text class="terminal-1405474793-r1" x="963.8" y="556.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-22)">│</text><text class="terminal-1405474793-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1405474793-line-22)">
+  </text><text class="terminal-1405474793-r1" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-23)">╰</text><text class="terminal-1405474793-r1" x="12.2" y="581.2" textLength="951.6" clip-path="url(#terminal-1405474793-line-23)">──────────────────────────────────────────────────────────────────────────────</text><text class="terminal-1405474793-r1" x="963.8" y="581.2" textLength="12.2" clip-path="url(#terminal-1405474793-line-23)">╯</text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_scroll_visible
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
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
--- /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
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -406,3 +406,13 @@ 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):
+    # 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"])