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 - ▇▇ - WidgetsWelcome! Textual is a framework for creating sophisticated - applications with the terminal. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Rich contentStart - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 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 contentapplications with the terminal. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Start + CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + + + +                           Widgets                            +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes