Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add anchor method #4530

Merged
merged 7 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/markdown.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pathlib import Path
from sys import argv

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
61 changes: 61 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand All @@ -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(
Expand Down
138 changes: 70 additions & 68 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Large diffs are not rendered by default.

Loading