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