diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdf692da8..7653c3b1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.62.0] - Unrelease +## [0.62.0] - 2024-05-20 ### Added - Added `start` and `end` properties to Markdown Navigator +- Added `Widget.anchor`, `Widget.clear_anchor`, and `Widget.is_anchored` https://github.com/Textualize/textual/pull/4530 ## [0.61.1] - 2024-05-19 @@ -1978,6 +1979,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.62.0]: https://github.com/Textualize/textual/compare/v0.61.1...v0.62.0 [0.61.1]: https://github.com/Textualize/textual/compare/v0.61.0...v0.61.1 [0.61.0]: https://github.com/Textualize/textual/compare/v0.60.1...v0.61.0 [0.60.1]: https://github.com/Textualize/textual/compare/v0.60.0...v0.60.1 diff --git a/examples/markdown.py b/examples/markdown.py index 736e88d2f1..2cf43e4399 100644 --- a/examples/markdown.py +++ b/examples/markdown.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from sys import argv diff --git a/pyproject.toml b/pyproject.toml index e1f10a2b06..25cb9d25f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.61.1" +version = "0.62.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/widget.py b/src/textual/widget.py index 48cbb67cf4..e1ae3d1ae0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -399,6 +399,10 @@ def __init__( might result in a race condition. This can be fixed by adding `async with widget.lock:` around the method calls. """ + self._anchored: Widget | None = None + """An anchored child widget, or `None` if no child is anchored.""" + self._anchor_animate: bool = False + """Flag to enable animation when scrolling anchored widgets.""" virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" @@ -515,6 +519,40 @@ def opacity(self) -> float: break return opacity + @property + def is_anchored(self) -> bool: + """Is this widget anchored?""" + return self._parent is not None and self._parent is self + + def anchor(self, *, animate: bool = False) -> None: + """Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]), + but also keeps it in view if the widget's size changes, or the size of its container changes. + + !!! note + + Anchored widgets will be un-anchored if the users scrolls the container. + + Args: + animate: `True` if the scroll should animate, or `False` if it shouldn't. + """ + if self._parent is not None and isinstance(self._parent, Widget): + self._parent._anchored = self + self._parent._anchor_animate = animate + self.check_idle() + + def clear_anchor(self) -> None: + """Stop anchoring this widget (a no-op if this widget is not anchored).""" + if ( + self._parent is not None + and isinstance(self._parent, Widget) + and self._parent._anchored is self + ): + self._parent._anchored = None + + def _clear_anchor(self) -> None: + """Clear an anchored child.""" + self._anchored = None + def _check_disabled(self) -> bool: """Check if the widget is disabled either explicitly by setting `disabled`, or implicitly by setting `loading`. @@ -3178,6 +3216,7 @@ def _size_updated( Returns: True if anything changed, or False if nothing changed. """ + if ( self._size != size or self.virtual_size != virtual_size @@ -3502,6 +3541,11 @@ async def _on_idle(self, event: events.Idle) -> None: """ self._check_refresh() + if self.is_anchored: + self.scroll_visible(animate=self._anchor_animate) + if self._anchored: + self._anchored.scroll_visible(animate=self._anchor_animate) + def _check_refresh(self) -> None: """Check if a refresh was requested.""" if self._parent is not None and not self._closing: @@ -3702,45 +3746,54 @@ def _on_blur(self, event: events.Blur) -> None: def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if event.ctrl or event.shift: if self.allow_horizontal_scroll: + self._clear_anchor() if self._scroll_right_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: + self._clear_anchor() 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: + self._clear_anchor() if self._scroll_left_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: + self._clear_anchor() if self._scroll_up_for_pointer(animate=False): event.stop() def _on_scroll_to(self, message: ScrollTo) -> None: if self._allow_scroll: + self._clear_anchor() self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1) message.stop() def _on_scroll_up(self, event: ScrollUp) -> None: if self.allow_vertical_scroll: + self._clear_anchor() self.scroll_page_up() event.stop() def _on_scroll_down(self, event: ScrollDown) -> None: if self.allow_vertical_scroll: + self._clear_anchor() self.scroll_page_down() event.stop() def _on_scroll_left(self, event: ScrollLeft) -> None: if self.allow_horizontal_scroll: + self._clear_anchor() self.scroll_page_left() event.stop() def _on_scroll_right(self, event: ScrollRight) -> None: if self.allow_horizontal_scroll: + self._clear_anchor() self.scroll_page_right() event.stop() @@ -3767,41 +3820,49 @@ def _on_unmount(self) -> None: def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() + self._clear_anchor() self.scroll_home() def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() + self._clear_anchor() self.scroll_end() def action_scroll_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() + self._clear_anchor() self.scroll_left() def action_scroll_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() + self._clear_anchor() self.scroll_right() def action_scroll_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() + self._clear_anchor() self.scroll_up() def action_scroll_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() + self._clear_anchor() self.scroll_down() def action_page_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() + self._clear_anchor() self.scroll_page_down() def action_page_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() + self._clear_anchor() self.scroll_page_up() def notify( diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index bcf20010d0..e4c28166e9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -19874,145 +19874,147 @@ font-weight: 700; } - .terminal-3470819201-matrix { + .terminal-2491303797-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3470819201-title { + .terminal-2491303797-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3470819201-r1 { fill: #c5c8c6 } - .terminal-3470819201-r2 { fill: #24292f } - .terminal-3470819201-r3 { fill: #e1e1e1 } - .terminal-3470819201-r4 { fill: #121212 } - .terminal-3470819201-r5 { fill: #1e1e1e } - .terminal-3470819201-r6 { fill: #e2e3e3 } - .terminal-3470819201-r7 { fill: #96989b } - .terminal-3470819201-r8 { fill: #0053aa } - .terminal-3470819201-r9 { fill: #008139 } - .terminal-3470819201-r10 { fill: #dde8f3;font-weight: bold } - .terminal-3470819201-r11 { fill: #939393;font-weight: bold } - .terminal-3470819201-r12 { fill: #e2e3e3;font-weight: bold } - .terminal-3470819201-r13 { fill: #e1e1e1;text-decoration: underline; } - .terminal-3470819201-r14 { fill: #14191f } - .terminal-3470819201-r15 { fill: #ddedf9 } + .terminal-2491303797-r1 { fill: #c5c8c6 } + .terminal-2491303797-r2 { fill: #24292f } + .terminal-2491303797-r3 { fill: #e1e1e1 } + .terminal-2491303797-r4 { fill: #121212 } + .terminal-2491303797-r5 { fill: #1e1e1e } + .terminal-2491303797-r6 { fill: #e2e3e3 } + .terminal-2491303797-r7 { fill: #96989b } + .terminal-2491303797-r8 { fill: #0053aa } + .terminal-2491303797-r9 { fill: #008139 } + .terminal-2491303797-r10 { fill: #dde8f3;font-weight: bold } + .terminal-2491303797-r11 { fill: #939393;font-weight: bold } + .terminal-2491303797-r12 { fill: #e2e3e3;font-weight: bold } + .terminal-2491303797-r13 { fill: #e1e1e1;text-decoration: underline; } + .terminal-2491303797-r14 { fill: #14191f } + .terminal-2491303797-r15 { fill: #ddedf9 } + .terminal-2491303797-r16 { fill: #84acd5;font-weight: bold } + .terminal-2491303797-r17 { fill: #85beea } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownApp + MarkdownApp - + - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼ Ⅰ Textual Markdown Browser - └── Ⅱ Do You Want to Know More?Textual Markdown Browser - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Welcome fellow adventurer! If you  - ran markdown.py from the terminal  - you are viewing demo.md with  - Textual's built in Markdown  - widget. - - The widget supports much of the  - Markdown spec. There is also an  - optional Table of Contents sidebar - which you will see to your left. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - -   Do You Want to Know More?    - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - See example.md for more examples ▆▆ - of what this can do. -  T  TOC  B  Back  F  Forward  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼ Ⅰ Textual Markdown Browser + └── Ⅱ Do You Want to Know More?Textual Markdown Browser + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Welcome fellow adventurer! If you  + ran markdown.py from the terminal  + you are viewing demo.md with  + Textual's built in Markdown  + widget. + + The widget supports much of the  + Markdown spec. There is also an  + optional Table of Contents sidebar + which you will see to your left. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +   Do You Want to Know More?    + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + See example.md for more examples ▆▆ + of what this can do. +  T  TOC  B  Back  F  Forward