Repository: Textualize/textual
Pull request: Allow by-code scrolling even when by-input scrolling would be denied
See #1201.

See
https://github.com/Textualize/textual/issues/1201#issuecomment-1380123660 though.


Problem statement:
Programatic scrolling should work without scrollbars (overflow hidden).
Currently if there are no scrollbars, and you call `Widget.scroll_to`, the scrolling us suppressed. This is so that moving the scroll wheel doesn't scroll when the CSS has disabled scrolling.

However, some times you may to scroll a container programatically. Suggest we need to differentiate scrolling initiated from mouse / keyboard input and programatic scrolling.

Hints from issue discussion:
Quick test code to visually confirm the issue (using `scroll_to_widget`, which I imagine is part of this issue too?):

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Button
from textual.binding    import Binding

class ScrollToTester( App[ None ] ):

    CSS = """
    Vertical {
        overflow-y: hidden;
    }

    Button {
        margin: 1;
        width: 100%;
    }
    """

    BINDINGS = [
        Binding( "j", "jump", "Jump to a button off the screen" ),
    ]

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            *[ Button( str( n ), id=f"btn-{n}" ) for n in range( 200 ) ]
        )
        yield Footer()

    def action_jump( self ) -> None:
        btn = self.query_one( "#btn-150", Button )
        self.query_one( Vertical ).scroll_to_widget( btn )
        btn.focus()

if __name__ == "__main__":
    ScrollToTester().run()
```

Pressing <kbd>j</kbd> should bring button 150 into view.
Note to self: diving into the code, in the above situation, `scroll_to` gets called *and* returns `True`; so as far as it's concerned it did scroll. Whatever needs changing, it looks like it's down in the animation code, most likely.
Tracing further, a quick fix to this is this:

```diff
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 7808f1a0..67c140b7 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -811,8 +811,8 @@ class Widget(DOMNode):
     def watch_scroll_y(self, old_value: float, new_value: float) -> None:
         if self.show_vertical_scrollbar:
             self.vertical_scrollbar.position = round(new_value)
-            if round(old_value) != round(new_value):
-                self._refresh_scroll()
+        if round(old_value) != round(new_value):
+            self._refresh_scroll()

     def validate_scroll_x(self, value: float) -> float:
         return clamp(value, 0, self.max_scroll_x)
```

I'm not sure at the moment if this is *the* way to do it, but this has the effect I'm after here and would seem to narrow it down to `watch_scroll_y` (likely also need to consider the same for `watch_scroll_x` too).
Testing/checking more, this does seem to have exactly the effect we want, but confirming it with @willmcgugan he's said it would possibly be beneficial to have a method of optionally blocking by-code scroll as well (the above change would still block input-based scrolling, but always allow by-code scrolling).

Need to find a bit of time to dive into this further to understand the use-case for that.

Meanwhile I'll draft a PR with the above changes as a placeholder for what we want.
Following on a little more, it's been noted that `Widget` has `allow_horizontal_scroll` and `allow_vertical_scroll` properties, which `watch_scroll_x` and `watch_scroll_y` are bypassing. It would seem that they should make use of them.
Further to the above: in further discussion, it's been suggested that *all* of the `scroll_`-prefixed method that have the side-effect of making a scroll happen should have a `force` keyword parameter, which will be used to override the relevant `allow_` property. Following this through, to help try and grok this, let's look at what would happen if `scroll_visible` where called.

- [`scroll_visible`](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1838-L1867) gets called on a given widget.
- `scroll_visible` books a later call to [`scroll_to_widget`, scrolling to the `parent` of the given widget](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1859-L1867).
- [`scroll_to_widget` walks the parent chain of the given widget, using `scroll_to_region` to ask each container in turn to scroll to make the widget visible](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1726-L1781).
- [`scroll_to_region` starts to work out what horizontal and vertical scrolling is required](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1783-L1836), and then [calls `scroll_relative` to make the scroll happen](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1828-L1835).
- [`scroll_relative` is a fairly thin wrapper around `scroll_to`](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1399-L1430).
- [`scroll_to`](https://github.com/Textualize/textual/blob/b6bf7595b8c14b1802371f8710618b0ee5e97437/src/textual/widget.py#L1331-L1397) is where the scrolling is actually kicked off.

I still need to check other paths but I imagine that all roads lead to `scroll_to`. The next thing to figure out is how we can know what direction we're scrolling in at this point. Both `x` and `y` can be `None` to indicate "no change" but at this point I'm not confidant that that's all there is to this.

Commit message: Merge branch 'main' into issue/1201/scroll-to-with-code

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 402b8064cc..be893a83ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524
 - `events.Paste` now bubbles https://github.com/Textualize/textual/issues/1434
 - Clock color in the `Header` widget now matches the header color https://github.com/Textualize/textual/issues/1459
+- Programmatic calls to scroll now optionally scroll even if overflow styling says otherwise (introduces a new `force` parameter to all the `scroll_*` methods) https://github.com/Textualize/textual/issues/1201
 
 ### Fixed
 
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 7808f1a087..beec210193 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -803,16 +803,16 @@ def watch_hover_style(
             self.highlight_link_id = hover_style.link_id
 
     def watch_scroll_x(self, old_value: float, new_value: float) -> None:
-        if self.show_horizontal_scrollbar:
+        if self.allow_horizontal_scroll:
             self.horizontal_scrollbar.position = round(new_value)
-            if round(old_value) != round(new_value):
-                self._refresh_scroll()
+        if round(old_value) != round(new_value):
+            self._refresh_scroll()
 
     def watch_scroll_y(self, old_value: float, new_value: float) -> None:
-        if self.show_vertical_scrollbar:
+        if self.allow_vertical_scroll:
             self.vertical_scrollbar.position = round(new_value)
-            if round(old_value) != round(new_value):
-                self._refresh_scroll()
+        if round(old_value) != round(new_value):
+            self._refresh_scroll()
 
     def validate_scroll_x(self, value: float) -> float:
         return clamp(value, 0, self.max_scroll_x)
@@ -1337,6 +1337,7 @@ def scroll_to(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll to a given (absolute) coordinate, optionally animating.
 
@@ -1348,10 +1349,13 @@ def scroll_to(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if the scroll position changed, otherwise False.
         """
+        maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force)
+        maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force)
         scrolled_x = scrolled_y = False
         if animate:
             # TODO: configure animation speed
@@ -1361,7 +1365,7 @@ def scroll_to(
             if easing is None:
                 easing = DEFAULT_SCROLL_EASING
 
-            if x is not None:
+            if maybe_scroll_x:
                 self.scroll_target_x = x
                 if x != self.scroll_x:
                     self.animate(
@@ -1372,7 +1376,7 @@ def scroll_to(
                         easing=easing,
                     )
                     scrolled_x = True
-            if y is not None:
+            if maybe_scroll_y:
                 self.scroll_target_y = y
                 if y != self.scroll_y:
                     self.animate(
@@ -1385,11 +1389,11 @@ def scroll_to(
                     scrolled_y = True
 
         else:
-            if x is not None:
+            if maybe_scroll_x:
                 scroll_x = self.scroll_x
                 self.scroll_target_x = self.scroll_x = x
                 scrolled_x = scroll_x != self.scroll_x
-            if y is not None:
+            if maybe_scroll_y:
                 scroll_y = self.scroll_y
                 self.scroll_target_y = self.scroll_y = y
                 scrolled_y = scroll_y != self.scroll_y
@@ -1405,6 +1409,7 @@ def scroll_relative(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll relative to current position.
 
@@ -1416,6 +1421,7 @@ def scroll_relative(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if the scroll position changed, otherwise False.
@@ -1427,6 +1433,7 @@ def scroll_relative(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_home(
@@ -1436,6 +1443,7 @@ def scroll_home(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll to home position.
 
@@ -1445,6 +1453,7 @@ def scroll_home(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1452,7 +1461,13 @@ def scroll_home(
         if speed is None and duration is None:
             duration = 1.0
         return self.scroll_to(
-            0, 0, animate=animate, speed=speed, duration=duration, easing=easing
+            0,
+            0,
+            animate=animate,
+            speed=speed,
+            duration=duration,
+            easing=easing,
+            force=force,
         )
 
     def scroll_end(
@@ -1462,6 +1477,7 @@ def scroll_end(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll to the end of the container.
 
@@ -1471,6 +1487,7 @@ def scroll_end(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1485,6 +1502,7 @@ def scroll_end(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_left(
@@ -1494,6 +1512,7 @@ def scroll_left(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one cell left.
 
@@ -1503,6 +1522,7 @@ def scroll_left(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1514,6 +1534,7 @@ def scroll_left(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_right(
@@ -1523,6 +1544,7 @@ def scroll_right(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll on cell right.
 
@@ -1532,6 +1554,7 @@ def scroll_right(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1543,6 +1566,7 @@ def scroll_right(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_down(
@@ -1552,6 +1576,7 @@ def scroll_down(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one line down.
 
@@ -1561,6 +1586,7 @@ def scroll_down(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1572,6 +1598,7 @@ def scroll_down(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_up(
@@ -1581,6 +1608,7 @@ def scroll_up(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one line up.
 
@@ -1590,6 +1618,7 @@ def scroll_up(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1601,6 +1630,7 @@ def scroll_up(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_page_up(
@@ -1610,6 +1640,7 @@ def scroll_page_up(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one page up.
 
@@ -1619,6 +1650,7 @@ def scroll_page_up(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1630,6 +1662,7 @@ def scroll_page_up(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_page_down(
@@ -1639,6 +1672,7 @@ def scroll_page_down(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one page down.
 
@@ -1648,6 +1682,7 @@ def scroll_page_down(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1659,6 +1694,7 @@ def scroll_page_down(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_page_left(
@@ -1668,6 +1704,7 @@ def scroll_page_left(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one page left.
 
@@ -1677,6 +1714,7 @@ def scroll_page_left(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1690,6 +1728,7 @@ def scroll_page_left(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_page_right(
@@ -1699,6 +1738,7 @@ def scroll_page_right(
         speed: float | None = None,
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> bool:
         """Scroll one page right.
 
@@ -1708,6 +1748,7 @@ def scroll_page_right(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling was done.
@@ -1721,6 +1762,7 @@ def scroll_page_right(
             speed=speed,
             duration=duration,
             easing=easing,
+            force=force,
         )
 
     def scroll_to_widget(
@@ -1732,6 +1774,7 @@ def scroll_to_widget(
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
         top: bool = False,
+        force: bool = False,
     ) -> bool:
         """Scroll scrolling to bring a widget in to view.
 
@@ -1743,6 +1786,7 @@ def scroll_to_widget(
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
             top (bool, optional): Scroll widget to top of container. Defaults to False.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             bool: True if any scrolling has occurred in any descendant, otherwise False.
@@ -1762,6 +1806,7 @@ def scroll_to_widget(
                 duration=duration,
                 top=top,
                 easing=easing,
+                force=force,
             )
             if scroll_offset:
                 scrolled = True
@@ -1790,6 +1835,7 @@ def scroll_to_region(
         duration: float | None = None,
         easing: EasingFunction | str | None = None,
         top: bool = False,
+        force: bool = False,
     ) -> Offset:
         """Scrolls a given region in to view, if required.
 
@@ -1805,6 +1851,7 @@ def scroll_to_region(
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
             top (bool, optional): Scroll region to top of container. Defaults to False.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
 
         Returns:
             Offset: The distance that was scrolled.
@@ -1832,6 +1879,7 @@ def scroll_to_region(
                 speed=speed,
                 duration=duration,
                 easing=easing,
+                force=force,
             )
         return delta
 
@@ -1843,6 +1891,7 @@ def scroll_visible(
         duration: float | None = None,
         top: bool = False,
         easing: EasingFunction | str | None = None,
+        force: bool = False,
     ) -> None:
         """Scroll the container to make this widget visible.
 
@@ -1853,6 +1902,7 @@ def scroll_visible(
             top (bool, optional): Scroll to top of container. Defaults to False.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
         """
         parent = self.parent
         if isinstance(parent, Widget):
@@ -1864,6 +1914,7 @@ def scroll_visible(
                 duration=duration,
                 top=top,
                 easing=easing,
+                force=force,
             )
 
     def __init_subclass__(


Code review comments:
diff_hunk: @@ -1853,6 +1902,7 @@ def scroll_visible(
             top (bool, optional): Scroll to top of container. Defaults to False.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1648,6 +1682,7 @@ def scroll_page_down(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1619,6 +1650,7 @@ def scroll_page_up(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1590,6 +1618,7 @@ def scroll_up(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1561,6 +1586,7 @@ def scroll_down(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1532,6 +1554,7 @@ def scroll_right(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1503,6 +1522,7 @@ def scroll_left(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1677,6 +1714,7 @@ def scroll_page_left(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -803,16 +803,16 @@ def watch_hover_style(
             self.highlight_link_id = hover_style.link_id
 
     def watch_scroll_x(self, old_value: float, new_value: float) -> None:
-        if self.show_horizontal_scrollbar:
+        if self.allow_horizontal_scroll:
             self.horizontal_scrollbar.position = round(new_value)
path: src/textual/widget.py
text: @user1:
I was reading your comment in the original issue and I'm not convinced `allow_horizontal_scroll` should be used here. Note that `scroll_x` was changed, which is why we are in `watch_scroll_x`, so now that `scroll_x` was changed, we need to update the horizontal scrollbar if it is visible.

To me, scrollbar being visible != scrolling enabled, so we really want to check if we are _showing_ the scrollbar.
What do you think?

@author:
I think we'll need @user2 to decide on that as he asked for this change. 

@user2:
Hasn't the logic already been done in the various scroll methods, with regards to allowing scrolling?

By the time we get here, I don't think we need to apply the logic again.

This does mean that you can circumvent scroll logic by setting the `scroll_x`  attribute, but I think I'm fine with that.

@author:
Makes sense to me, I only made that change as it was something you explicitly asked for. I'll nuke that then.

@author:
Done.

diff_hunk: @@ -1471,6 +1487,7 @@ def scroll_end(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1743,6 +1786,7 @@ def scroll_to_widget(
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
             top (bool, optional): Scroll widget to top of container. Defaults to False.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1708,6 +1748,7 @@ def scroll_page_right(
             duration (float | None, optional): Duration of animation, if animate is True and speed is None.
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```

diff_hunk: @@ -1805,6 +1851,7 @@ def scroll_to_region(
             easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
                 which will result in Textual choosing the configured default scrolling easing function.
             top (bool, optional): Scroll region to top of container. Defaults to False.
+            force (bool,optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
path: src/textual/widget.py
text: @user1:
```suggestion
            force (bool, optional): Force scrolling even when prohibited by overflow styling. Defaults to `False`.
```