diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2d6279b4f..4849a09bf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `RadioButton` https://github.com/Textualize/textual/pull/1872
- Added `RadioSet` https://github.com/Textualize/textual/pull/1872
+### Changed
+
+- Widget scrolling methods (such as `Widget.scroll_home` and `Widget.scroll_end`) now perform the scroll after the next refresh https://github.com/Textualize/textual/issues/1774
+
+### Fixed
+
+- Scrolling with cursor keys now moves just one cell https://github.com/Textualize/textual/issues/1897
+
### Fixed
- Fix exceptions in watch methods being hidden on startup https://github.com/Textualize/textual/issues/1886
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 47e1b431d4..6c230e20ee 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -1417,7 +1417,7 @@ def _exchange_repaint_regions(self) -> Collection[Region]:
self._repaint_regions.clear()
return regions
- def scroll_to(
+ def _scroll_to(
self,
x: float | None = None,
y: float | None = None,
@@ -1493,6 +1493,43 @@ def scroll_to(
return scrolled_x or scrolled_y
+ def scroll_to(
+ self,
+ x: float | None = None,
+ y: float | None = None,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> None:
+ """Scroll to a given (absolute) coordinate, optionally animating.
+
+ Args:
+ x: X coordinate (column) to scroll to, or None for no change. Defaults to None.
+ y: Y coordinate (row) to scroll to, or None for no change. Defaults to None.
+ animate: Animate to new scroll position. Defaults to True.
+ 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. Defaults to "None",
+ which will result in Textual choosing the default scrolling easing function.
+ force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
+
+ Note:
+ The call to scroll is made after the next refresh.
+ """
+ self.call_after_refresh(
+ self._scroll_to,
+ x,
+ y,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
def scroll_relative(
self,
x: float | None = None,
@@ -1503,7 +1540,7 @@ def scroll_relative(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll relative to current position.
Args:
@@ -1515,11 +1552,8 @@ def scroll_relative(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if the scroll position changed, otherwise False.
"""
- return self.scroll_to(
+ 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,
@@ -1537,7 +1571,7 @@ def scroll_home(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll to home position.
Args:
@@ -1547,13 +1581,10 @@ def scroll_home(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
"""
if speed is None and duration is None:
duration = 1.0
- return self.scroll_to(
+ self.scroll_to(
0,
0,
animate=animate,
@@ -1571,7 +1602,7 @@ def scroll_end(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll to the end of the container.
Args:
@@ -1581,16 +1612,51 @@ def scroll_end(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
-
"""
if speed is None and duration is None:
duration = 1.0
- return self.scroll_to(
- 0,
- self.max_scroll_y,
+
+ # In most cases we'd call self.scroll_to and let it handle the call
+ # to do things after a refresh, but here we need the refresh to
+ # happen first so that we can get the new self.max_scroll_y (that
+ # is, we need the layout to work out and then figure out how big
+ # things are). Because of this we'll create a closure over the call
+ # here and make our own call to call_after_refresh.
+ def _lazily_scroll_end() -> None:
+ """Scroll to the end of the widget."""
+ self._scroll_to(
+ 0,
+ self.max_scroll_y,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
+ self.call_after_refresh(_lazily_scroll_end)
+
+ def scroll_left(
+ self,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> None:
+ """Scroll one cell left.
+
+ Args:
+ animate: Animate scroll. Defaults to True.
+ 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. Defaults to "None",
+ which will result in Textual choosing the configured default scrolling easing function.
+ force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
+ """
+ self.scroll_to(
+ x=self.scroll_target_x - 1,
animate=animate,
speed=speed,
duration=duration,
@@ -1598,7 +1664,7 @@ def scroll_end(
force=force,
)
- def scroll_left(
+ def _scroll_left_for_pointer(
self,
*,
animate: bool = True,
@@ -1607,7 +1673,7 @@ def scroll_left(
easing: EasingFunction | str | None = None,
force: bool = False,
) -> bool:
- """Scroll one cell left.
+ """Scroll left one position, taking scroll sensitivity into account.
Args:
animate: Animate scroll. Defaults to True.
@@ -1620,8 +1686,11 @@ def scroll_left(
Returns:
True if any scrolling was done.
+ Note:
+ How much is scrolled is controlled by
+ [App.scroll_sensitivity_x][textual.app.App.scroll_sensitivity_x].
"""
- return self.scroll_to(
+ return self._scroll_to(
x=self.scroll_target_x - self.app.scroll_sensitivity_x,
animate=animate,
speed=speed,
@@ -1638,8 +1707,36 @@ def scroll_right(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
+ ) -> None:
+ """Scroll one cell right.
+
+ Args:
+ animate: Animate scroll. Defaults to True.
+ 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. Defaults to "None",
+ which will result in Textual choosing the configured default scrolling easing function.
+ force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
+ """
+ self.scroll_to(
+ x=self.scroll_target_x + 1,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
+ def _scroll_right_for_pointer(
+ self,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
) -> bool:
- """Scroll on cell right.
+ """Scroll right one position, taking scroll sensitivity into account.
Args:
animate: Animate scroll. Defaults to True.
@@ -1652,8 +1749,11 @@ def scroll_right(
Returns:
True if any scrolling was done.
+ Note:
+ How much is scrolled is controlled by
+ [App.scroll_sensitivity_x][textual.app.App.scroll_sensitivity_x].
"""
- return self.scroll_to(
+ return self._scroll_to(
x=self.scroll_target_x + self.app.scroll_sensitivity_x,
animate=animate,
speed=speed,
@@ -1670,9 +1770,37 @@ def scroll_down(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one line down.
+ Args:
+ animate: Animate scroll. Defaults to True.
+ 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. Defaults to "None",
+ which will result in Textual choosing the configured default scrolling easing function.
+ force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
+ """
+ self.scroll_to(
+ y=self.scroll_target_y + 1,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
+ def _scroll_down_for_pointer(
+ self,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> bool:
+ """Scroll down one position, taking scroll sensitivity into account.
+
Args:
animate: Animate scroll. Defaults to True.
speed: Speed of scroll if animate is True. Or None to use duration.
@@ -1684,8 +1812,11 @@ def scroll_down(
Returns:
True if any scrolling was done.
+ Note:
+ How much is scrolled is controlled by
+ [App.scroll_sensitivity_y][textual.app.App.scroll_sensitivity_y].
"""
- return self.scroll_to(
+ return self._scroll_to(
y=self.scroll_target_y + self.app.scroll_sensitivity_y,
animate=animate,
speed=speed,
@@ -1702,9 +1833,37 @@ def scroll_up(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one line up.
+ Args:
+ animate: Animate scroll. Defaults to True.
+ 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. Defaults to "None",
+ which will result in Textual choosing the configured default scrolling easing function.
+ force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
+ """
+ self.scroll_to(
+ y=self.scroll_target_y - 1,
+ animate=animate,
+ speed=speed,
+ duration=duration,
+ easing=easing,
+ force=force,
+ )
+
+ def _scroll_up_for_pointer(
+ self,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> bool:
+ """Scroll up one position, taking scroll sensitivity into account.
+
Args:
animate: Animate scroll. Defaults to True.
speed: Speed of scroll if animate is True. Or None to use duration.
@@ -1716,9 +1875,12 @@ def scroll_up(
Returns:
True if any scrolling was done.
+ Note:
+ How much is scrolled is controlled by
+ [App.scroll_sensitivity_y][textual.app.App.scroll_sensitivity_y].
"""
- return self.scroll_to(
- y=self.scroll_target_y - +self.app.scroll_sensitivity_y,
+ return self._scroll_to(
+ y=self.scroll_target_y - self.app.scroll_sensitivity_y,
animate=animate,
speed=speed,
duration=duration,
@@ -1734,7 +1896,7 @@ def scroll_page_up(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page up.
Args:
@@ -1744,12 +1906,8 @@ def scroll_page_up(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
-
"""
- return self.scroll_to(
+ self.scroll_to(
y=self.scroll_y - self.container_size.height,
animate=animate,
speed=speed,
@@ -1766,7 +1924,7 @@ def scroll_page_down(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page down.
Args:
@@ -1776,12 +1934,8 @@ def scroll_page_down(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
-
"""
- return self.scroll_to(
+ self.scroll_to(
y=self.scroll_y + self.container_size.height,
animate=animate,
speed=speed,
@@ -1798,7 +1952,7 @@ def scroll_page_left(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page left.
Args:
@@ -1808,14 +1962,10 @@ def scroll_page_left(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
-
"""
if speed is None and duration is None:
duration = 0.3
- return self.scroll_to(
+ self.scroll_to(
x=self.scroll_x - self.container_size.width,
animate=animate,
speed=speed,
@@ -1832,7 +1982,7 @@ def scroll_page_right(
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page right.
Args:
@@ -1842,14 +1992,10 @@ def scroll_page_right(
easing: An easing method for the scrolling animation. Defaults to "None",
which will result in Textual choosing the configured default scrolling easing function.
force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
-
- Returns:
- True if any scrolling was done.
-
"""
if speed is None and duration is None:
duration = 0.3
- return self.scroll_to(
+ self.scroll_to(
x=self.scroll_x + self.container_size.width,
animate=animate,
speed=speed,
@@ -2578,21 +2724,21 @@ def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
if event.ctrl or event.shift:
if self.allow_horizontal_scroll:
- if self.scroll_right(animate=False):
+ if self._scroll_right_for_pointer(animate=False):
event.stop()
else:
if self.allow_vertical_scroll:
- if self.scroll_down(animate=False):
+ if self._scroll_down_for_pointer(animate=False):
event.stop()
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
if event.ctrl or event.shift:
if self.allow_horizontal_scroll:
- if self.scroll_left(animate=False):
+ if self._scroll_left_for_pointer(animate=False):
event.stop()
else:
if self.allow_vertical_scroll:
- if self.scroll_up(animate=False):
+ if self._scroll_up_for_pointer(animate=False):
event.stop()
def _on_scroll_to(self, message: ScrollTo) -> None:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index db198f68b1..6fd15c5108 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -10970,169 +10970,169 @@
font-weight: 700;
}
- .terminal-3394521078-matrix {
+ .terminal-832370059-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3394521078-title {
+ .terminal-832370059-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3394521078-r1 { fill: #c5c8c6 }
- .terminal-3394521078-r2 { fill: #e3e3e3 }
- .terminal-3394521078-r3 { fill: #e1e1e1 }
- .terminal-3394521078-r4 { fill: #23568b }
- .terminal-3394521078-r5 { fill: #004578 }
- .terminal-3394521078-r6 { fill: #e2e2e2 }
- .terminal-3394521078-r7 { fill: #262626 }
- .terminal-3394521078-r8 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; }
- .terminal-3394521078-r9 { fill: #14191f }
- .terminal-3394521078-r10 { fill: #e2e2e2;font-weight: bold }
- .terminal-3394521078-r11 { fill: #7ae998 }
- .terminal-3394521078-r12 { fill: #4ebf71;font-weight: bold }
- .terminal-3394521078-r13 { fill: #008139 }
- .terminal-3394521078-r14 { fill: #dde8f3;font-weight: bold }
- .terminal-3394521078-r15 { fill: #ddedf9 }
+ .terminal-832370059-r1 { fill: #c5c8c6 }
+ .terminal-832370059-r2 { fill: #e3e3e3 }
+ .terminal-832370059-r3 { fill: #e1e1e1 }
+ .terminal-832370059-r4 { fill: #23568b }
+ .terminal-832370059-r5 { fill: #e2e2e2 }
+ .terminal-832370059-r6 { fill: #004578 }
+ .terminal-832370059-r7 { fill: #14191f }
+ .terminal-832370059-r8 { fill: #262626 }
+ .terminal-832370059-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; }
+ .terminal-832370059-r10 { fill: #e2e2e2;font-weight: bold }
+ .terminal-832370059-r11 { fill: #7ae998 }
+ .terminal-832370059-r12 { fill: #4ebf71;font-weight: bold }
+ .terminal-832370059-r13 { fill: #008139 }
+ .terminal-832370059-r14 { fill: #dde8f3;font-weight: bold }
+ .terminal-832370059-r15 { fill: #ddedf9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- Textual Demo
+ Textual Demo
-
-
-
- ⭘Textual Demo
- ▁▁
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- TOP▎▋
- ▎▋
- ▎Textual Demo▋
- ▎▋▇▇
- Widgets▎Welcome! Textual is a framework for creating sophisticated▋
- ▎applications with the terminal.▋
- ▎▋
- ▎▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▋
- Rich content▎Start▋
- ▎▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▋
- ▎▋
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- CSS
-
-
-
-
-
-
-
-
-
- Widgets
-
-
- Textual widgets are powerful interactive components.
- CTRL+C Quit CTRL+B Sidebar CTRL+T Toggle Dark mode CTRL+S Screenshot F1 Notes
+
+
+
+ ⭘Textual Demo
+ ▅▅
+
+ TOP
+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃
+ ▎▋
+ Widgets▎▋
+ ▎Textual Demo▋
+ ▎▋
+ ▎Welcome! Textual is a framework for creating sophisticated▋
+ Rich content▎applications with the terminal.▋
+ ▎▋
+ ▎▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▋
+ ▎Start▋
+ CSS▎▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▋
+ ▎▋
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+
+
+
+
+
+
+
+
+
+
+ Widgets
+ CTRL+C Quit CTRL+B Sidebar CTRL+T Toggle Dark mode CTRL+S Screenshot F1 Notes