diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2d6279b4..4849a09bf 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/Makefile b/Makefile
index de1bea195..4b4000e7b 100644
--- a/Makefile
+++ b/Makefile
@@ -76,3 +76,7 @@ setup:
.PHONY: update
update:
poetry update
+
+.PHONY: install-pre-commit
+install-pre-commit:
+ $(run) pre-commit install
diff --git a/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md b/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md
index 076cb96b5..5e91cd825 100644
--- a/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md
+++ b/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md
@@ -300,6 +300,12 @@ So, thanks to this bit of code in my `Activity` widget...
self.save_activity_list()
```
+!!! warning
+
+ The code above used `emit_no_wait`. Since this blog post was first
+ published that method has been removed from Textual. You should use
+ [`post_message_no_wait` or `post_message`](/guide/events/#sending-messages) instead now.
+
### Pain points
On top of the issues of getting to know terminal-based-CSS that I mentioned
diff --git a/docs/guide/app.md b/docs/guide/app.md
index eaa874ed3..891ea72a5 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -157,10 +157,14 @@ The chapter on [Textual CSS](CSS.md) describes how to use CSS in detail. For now
The following example enables loading of CSS by adding a `CSS_PATH` class variable:
-```python title="question02.py" hl_lines="6"
+```python title="question02.py" hl_lines="6 9"
--8<-- "docs/examples/app/question02.py"
```
+!!! note
+
+ We also added an `id` to the `Label`, because we want to style it in the CSS.
+
If the path is relative (as it is above) then it is taken as relative to where the app is defined. Hence this example references `"question01.css"` in the same directory as the Python code. Here is that CSS file:
```sass title="question02.css"
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 021604a6c..25b89faf8 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -1422,7 +1422,7 @@ class Widget(DOMNode):
self._repaint_regions.clear()
return regions
- def scroll_to(
+ def _scroll_to(
self,
x: float | None = None,
y: float | None = None,
@@ -1497,6 +1497,43 @@ class Widget(DOMNode):
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,
@@ -1507,7 +1544,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll relative to current position.
Args:
@@ -1518,11 +1555,8 @@ class Widget(DOMNode):
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.
-
- 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,
@@ -1540,7 +1574,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll to home position.
Args:
@@ -1549,13 +1583,10 @@ class Widget(DOMNode):
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.
-
- 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,
@@ -1573,7 +1604,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll to the end of the container.
Args:
@@ -1582,21 +1613,29 @@ class Widget(DOMNode):
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.
-
- 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,
- animate=animate,
- speed=speed,
- duration=duration,
- easing=easing,
- force=force,
- )
+
+ # 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,
@@ -1606,9 +1645,37 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> 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,
+ easing=easing,
+ force=force,
+ )
+
+ def _scroll_left_for_pointer(
+ self,
+ *,
+ animate: bool = True,
+ speed: float | None = None,
+ duration: float | None = None,
+ easing: EasingFunction | str | None = None,
+ force: bool = False,
+ ) -> bool:
+ """Scroll left 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.
@@ -1620,8 +1687,11 @@ class Widget(DOMNode):
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 +1708,36 @@ class Widget(DOMNode):
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 +1750,11 @@ class Widget(DOMNode):
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 +1771,37 @@ class Widget(DOMNode):
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 +1813,11 @@ class Widget(DOMNode):
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 +1834,37 @@ class Widget(DOMNode):
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 +1876,12 @@ class Widget(DOMNode):
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 +1897,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page up.
Args:
@@ -1744,12 +1907,8 @@ class Widget(DOMNode):
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 +1925,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page down.
Args:
@@ -1776,12 +1935,8 @@ class Widget(DOMNode):
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 +1953,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page left.
Args:
@@ -1808,14 +1963,10 @@ class Widget(DOMNode):
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 +1983,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
- ) -> bool:
+ ) -> None:
"""Scroll one page right.
Args:
@@ -1842,14 +1993,10 @@ class Widget(DOMNode):
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 +2725,21 @@ class Widget(DOMNode):
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 db198f68b..6fd15c510 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