Fixes and improvements relating to scrolling

The changes here roll two issues into one change. With this commit:

- Scrolling up/down/etc using the keyboard now moves just one cell, rather
  than moving the number of cells specified by the scroll sensitivity that's
  intended for pointing devices. #1897
- Where appropriate the scrolling is done lazily; that is it is done after
  the next refresh, helping to ensure that the scroll will take into account
  any updates in the same parent call. #1774
This commit is contained in:
Dave Pearson
2023-02-28 14:59:46 +00:00
parent 80ae6ce4d9
commit 37208b199c
3 changed files with 285 additions and 143 deletions

View File

@@ -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

View File

@@ -1417,7 +1417,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,
@@ -1493,6 +1493,34 @@ 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,
@@ -1503,7 +1531,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
) -> bool:
) -> None:
"""Scroll relative to current position.
Args:
@@ -1515,11 +1543,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 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 +1562,7 @@ class Widget(DOMNode):
duration: float | None = None,
easing: EasingFunction | str | None = None,
force: bool = False,
) -> bool:
) -> None:
"""Scroll to home position.
Args:
@@ -1547,13 +1572,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 = 1.0
return self.scroll_to(
self.scroll_to(
0,
0,
animate=animate,
@@ -1571,7 +1593,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:
@@ -1581,22 +1603,26 @@ 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 = 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():
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 +1632,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 +1674,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 +1695,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 +1737,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 +1758,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 +1800,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 +1821,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 +1863,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 +1884,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 +1894,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 +1912,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 +1922,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 +1940,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 +1950,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 +1970,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 +1980,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 +2712,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:

File diff suppressed because one or more lines are too long