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