diff --git a/CHANGELOG.md b/CHANGELOG.md index a225adf3b4..d87a5fcfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [3.7.2] - Unreleased +## Unreleased ### Fixed - Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception. +### Changed + +- Breaking change: `Widget.anchor` now has different semantics. It should be applied to a container and anchors to the bottom of the scroll position. https://github.com/Textualize/textual/pull/5950 + +### Added + +- Added `Markdown.append` https://github.com/Textualize/textual/pull/5950 +- Added `Widget.release_anchor` https://github.com/Textualize/textual/pull/5950 + ## [3.7.1] - 2025-07-09 ### Fixed diff --git a/examples/mother.py b/examples/mother.py index 4fd0c3eda5..87bcaaa457 100644 --- a/examples/mother.py +++ b/examples/mother.py @@ -74,6 +74,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: """You might want to change the model if you don't have access to it.""" self.model = llm.get_model("gpt-4o") + self.query_one("#chat-view").anchor() @on(Input.Submitted) async def on_input(self, event: Input.Submitted) -> None: @@ -82,8 +83,6 @@ async def on_input(self, event: Input.Submitted) -> None: event.input.clear() await chat_view.mount(Prompt(event.value)) await chat_view.mount(response := Response()) - response.anchor() - self.send_prompt(event.value, response) @work(thread=True) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 245abf6ccb..6941bf96c0 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -36,12 +36,12 @@ from textual.geometry import NULL_SPACING, Offset, Region, Size, Spacing from textual.map_geometry import MapGeometry from textual.strip import Strip, StripRenderable +from textual.widget import Widget if TYPE_CHECKING: from typing_extensions import TypeAlias from textual.screen import Screen - from textual.widget import Widget class ReflowResult(NamedTuple): @@ -605,6 +605,18 @@ def add_widget( # Get the region that will be updated sub_clip = clip.intersection(child_region) + if widget._anchored and not widget._anchor_released: + scroll_y = widget.scroll_y + new_scroll_y = ( + arrange_result.spatial_map.total_region.bottom + - ( + widget.container_size.height + - widget.scrollbar_size_horizontal + ) + ) + widget.set_reactive(Widget.scroll_y, new_scroll_y) + widget.watch_scroll_y(scroll_y, new_scroll_y) + if visible_only: placements = arrange_result.get_visible_placements( sub_clip - child_region.offset + widget.scroll_offset diff --git a/src/textual/screen.py b/src/textual/screen.py index 73369c6a46..51eb4e838d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1663,12 +1663,15 @@ def _watch__select_end( if end_region.y <= start_region.bottom or self._box_select: select_regions.append(Region.union(start_region, end_region)) else: - container_region = Region.from_union( - [ - start_widget.select_container.content_region, - end_widget.select_container.content_region, - ] - ) + try: + container_region = Region.from_union( + [ + start_widget.select_container.content_region, + end_widget.select_container.content_region, + ] + ) + except NoMatches: + return start_region = Region.from_corners( start_region.x, diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index e28d9db941..b1cce697b6 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -357,12 +357,14 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: def _on_mouse_capture(self, event: events.MouseCapture) -> None: if isinstance(self._parent, Widget): - self._parent._user_scroll_interrupt = True + self._parent.release_anchor() self.grabbed = event.mouse_position self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None + if self.vertical and isinstance(self.parent, Widget): + self.parent._check_anchor() event.stop() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 0997ce217d..4c92931e56 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -491,9 +491,11 @@ 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 + self._anchored: bool = False + """Has this widget been anchored?""" + self._anchor_released: bool = False + """Has the anchor been released?""" + """Flag to enable animation when scrolling anchored widgets.""" self._cover_widget: Widget | None = None """Widget to render over this widget (used by loading indicator).""" @@ -510,8 +512,6 @@ def __init__( """Used to cache :odd pseudoclass state.""" self._last_scroll_time = monotonic() """Time of last scroll.""" - self._user_scroll_interrupt: bool = False - """Has the user interrupted a scroll to end?""" self._extrema = Extrema() """Optional minimum and maximum values for width and height.""" @@ -612,8 +612,12 @@ def opacity(self) -> float: @property def is_anchored(self) -> bool: - """Is this widget anchored?""" - return isinstance(self._parent, Widget) and self._parent._anchored is self + """Is this widget anchored? + + See [anchor()][textual.widget.Widget.anchor] for an explanation of anchoring. + + """ + return self._anchored @property def is_mouse_over(self) -> bool: @@ -698,34 +702,37 @@ def _uncover(self) -> None: self._cover_widget = None self.refresh(layout=True) - 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. + def anchor(self, anchor: bool = True) -> None: + """Anchor a scrollable widget. - !!! note - - Anchored widgets will be un-anchored if the users scrolls the container. + An anchored widget will stay scrolled the bottom when new content is added, until + the user moves the scroll position. Args: - animate: `True` if the scroll should animate, or `False` if it shouldn't. + anchor: Anchor the widget if `True`, clear the anchor if `False`. + """ - if self._parent is not None and isinstance(self._parent, Widget): - self._parent._anchored = self - self._parent._anchor_animate = animate - self.check_idle() + self._anchored = anchor + if anchor: + self.scroll_end() + + def release_anchor(self) -> None: + """Release the [anchor][textual.widget.Widget]. - def clear_anchor(self) -> None: - """Stop anchoring this widget (a no-op if this widget is not anchored).""" + If a widget is anchored, releasing the anchor will allow the user to scroll as normal. + + """ + self.scroll_target_y = self.scroll_y + self._anchor_released = True + + def _check_anchor(self) -> None: + """Check if the scroll position is near enough to the bottom to restore anchor.""" if ( - self._parent is not None - and isinstance(self._parent, Widget) - and self._parent._anchored is self + self._anchored + and self._anchor_released + and self.scroll_y >= self.max_scroll_y ): - self._parent._anchored = None - - def _clear_anchor(self) -> None: - """Clear an anchored child.""" - self._anchored = None + self._anchor_released = False def _check_disabled(self) -> bool: """Check if the widget is disabled either explicitly by setting `disabled`, @@ -1737,6 +1744,8 @@ def watch_scroll_x(self, old_value: float, new_value: float) -> None: def watch_scroll_y(self, old_value: float, new_value: float) -> None: self.vertical_scrollbar.position = new_value + if self._anchored and self._anchor_released: + self._check_anchor() if round(old_value) != round(new_value): self._refresh_scroll() @@ -2519,9 +2528,6 @@ def _animate_on_complete() -> None: if on_complete is not None: self.call_next(on_complete) - if y is not None and maybe_scroll_y and y >= self.max_scroll_y: - self._user_scroll_interrupt = False - if animate: # TODO: configure animation speed if duration is None and speed is None: @@ -2792,16 +2798,11 @@ def scroll_end( """ - if self._user_scroll_interrupt and not force: - # Do not scroll to end if a user action has interrupted scrolling - return - if speed is None and duration is None: duration = 1.0 async def scroll_end_on_complete() -> None: """It's possible new content was added before we reached the end.""" - self.scroll_y = self.max_scroll_y if on_complete is not None: self.call_next(on_complete) @@ -2825,6 +2826,9 @@ def _lazily_scroll_end() -> None: level=level, ) + if self._anchored and self._anchor_released: + self._anchor_released = False + if immediate: _lazily_scroll_end() else: @@ -4269,9 +4273,6 @@ async def _on_idle(self, event: events.Idle) -> None: """ self._check_refresh() - if self.is_anchored: - self.scroll_visible(animate=self._anchor_animate, immediate=True) - def _check_refresh(self) -> None: """Check if a refresh was requested.""" if self._parent is not None and not self._closing: @@ -4493,54 +4494,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() + self.release_anchor() if self._scroll_right_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_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() + self.release_anchor() if self._scroll_left_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_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.release_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.release_anchor() self.scroll_page_up() event.stop() def _on_scroll_down(self, event: ScrollDown) -> None: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_down() event.stop() def _on_scroll_left(self, event: ScrollLeft) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_left() event.stop() def _on_scroll_right(self, event: ScrollRight) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_right() event.stop() @@ -4568,67 +4569,60 @@ def _on_unmount(self) -> None: def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_home(x_axis=self.scroll_y == 0) def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() - self._clear_anchor() - self._user_scroll_interrupt = False self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) def action_scroll_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_left() def action_scroll_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_right() def action_scroll_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_up() def action_scroll_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_down() def action_page_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_page_down() def action_page_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_page_up() def action_page_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_page_left() def action_page_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_page_right() def notify( diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index ecc0bc24ac..45b1c4ca15 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -254,14 +254,14 @@ async def bindings_changed(self, screen: Screen) -> None: def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_right_for_pointer(animate=True): event.stop() event.prevent_default() def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_left_for_pointer(animate=True): event.stop() event.prevent_default() diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index fdc32c0144..a491e17458 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -20,6 +20,7 @@ from textual.app import ComposeResult from textual.await_complete import AwaitComplete from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.css.query import NoMatches from textual.events import Mount from textual.message import Message from textual.reactive import reactive, var @@ -664,7 +665,10 @@ def _retheme(self) -> None: if self.app.current_theme.dark else self._markdown.code_light_theme ) - self.get_child_by_type(Static).update(self._block()) + try: + self.get_child_by_type(Static).update(self._block()) + except NoMatches: + pass def compose(self) -> ComposeResult: yield Static( @@ -823,6 +827,11 @@ def control(self) -> Markdown: """ return self.markdown + @property + def source(self) -> str: + """The markdown source.""" + return self._markdown or "" + async def _on_mount(self, _: Mount) -> None: if self._markdown is not None: await self.update(self._markdown) @@ -915,6 +924,91 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None: """ return None + def _parse_markdown( + self, tokens: Iterable[Token], table_of_contents: TableOfContentsType + ) -> Iterable[MarkdownBlock]: + """Create a stream of MarkdownBlock widgets from markdown. + + Args: + tokens: List of tokens. + table_of_contents: List to store table of contents. + + Yields: + Widgets for mounting. + """ + + stack: list[MarkdownBlock] = [] + stack_append = stack.append + block_id: int = 0 + + for token in tokens: + token_type = token.type + if token_type == "heading_open": + block_id += 1 + stack_append(HEADINGS[token.tag](self, id=f"block{block_id}")) + elif token_type == "hr": + yield MarkdownHorizontalRule(self) + elif token_type == "paragraph_open": + stack_append(MarkdownParagraph(self)) + elif token_type == "blockquote_open": + stack_append(MarkdownBlockQuote(self)) + elif token_type == "bullet_list_open": + stack_append(MarkdownBulletList(self)) + elif token_type == "ordered_list_open": + stack_append(MarkdownOrderedList(self)) + elif token_type == "list_item_open": + if token.info: + stack_append(MarkdownOrderedListItem(self, token.info)) + else: + item_count = sum( + 1 + for block in stack + if isinstance(block, MarkdownUnorderedListItem) + ) + stack_append( + MarkdownUnorderedListItem( + self, + self.BULLETS[item_count % len(self.BULLETS)], + ) + ) + elif token_type == "table_open": + stack_append(MarkdownTable(self)) + elif token_type == "tbody_open": + stack_append(MarkdownTBody(self)) + elif token_type == "thead_open": + stack_append(MarkdownTHead(self)) + elif token_type == "tr_open": + stack_append(MarkdownTR(self)) + elif token_type == "th_open": + stack_append(MarkdownTH(self)) + elif token_type == "td_open": + stack_append(MarkdownTD(self)) + elif token_type.endswith("_close"): + block = stack.pop() + if token.type == "heading_close": + heading = block._text.plain + level = int(token.tag[1:]) + table_of_contents.append((level, heading, block.id)) + if stack: + stack[-1]._blocks.append(block) + else: + yield block + elif token_type == "inline": + stack[-1].build_from_token(token) + elif token_type in ("fence", "code_block"): + fence = MarkdownFence(self, token.content.rstrip(), token.info) + if stack: + stack[-1]._blocks.append(fence) + else: + yield fence + else: + external = self.unhandled_token(token) + if external is not None: + if stack: + stack[-1]._blocks.append(external) + else: + yield external + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. @@ -930,92 +1024,11 @@ def update(self, markdown: str) -> AwaitComplete: else self._parser_factory() ) - table_of_contents = [] - - def parse_markdown(tokens) -> Iterable[MarkdownBlock]: - """Create a stream of MarkdownBlock widgets from markdown. - - Args: - tokens: List of tokens - - Yields: - Widgets for mounting. - """ - - stack: list[MarkdownBlock] = [] - stack_append = stack.append - block_id: int = 0 - - for token in tokens: - token_type = token.type - if token_type == "heading_open": - block_id += 1 - stack_append(HEADINGS[token.tag](self, id=f"block{block_id}")) - elif token_type == "hr": - yield MarkdownHorizontalRule(self) - elif token_type == "paragraph_open": - stack_append(MarkdownParagraph(self)) - elif token_type == "blockquote_open": - stack_append(MarkdownBlockQuote(self)) - elif token_type == "bullet_list_open": - stack_append(MarkdownBulletList(self)) - elif token_type == "ordered_list_open": - stack_append(MarkdownOrderedList(self)) - elif token_type == "list_item_open": - if token.info: - stack_append(MarkdownOrderedListItem(self, token.info)) - else: - item_count = sum( - 1 - for block in stack - if isinstance(block, MarkdownUnorderedListItem) - ) - stack_append( - MarkdownUnorderedListItem( - self, - self.BULLETS[item_count % len(self.BULLETS)], - ) - ) - elif token_type == "table_open": - stack_append(MarkdownTable(self)) - elif token_type == "tbody_open": - stack_append(MarkdownTBody(self)) - elif token_type == "thead_open": - stack_append(MarkdownTHead(self)) - elif token_type == "tr_open": - stack_append(MarkdownTR(self)) - elif token_type == "th_open": - stack_append(MarkdownTH(self)) - elif token_type == "td_open": - stack_append(MarkdownTD(self)) - elif token_type.endswith("_close"): - block = stack.pop() - if token.type == "heading_close": - heading = block._text.plain - level = int(token.tag[1:]) - table_of_contents.append((level, heading, block.id)) - if stack: - stack[-1]._blocks.append(block) - else: - yield block - elif token_type == "inline": - stack[-1].build_from_token(token) - elif token_type in ("fence", "code_block"): - fence = MarkdownFence(self, token.content.rstrip(), token.info) - if stack: - stack[-1]._blocks.append(fence) - else: - yield fence - else: - external = self.unhandled_token(token) - if external is not None: - if stack: - stack[-1]._blocks.append(external) - else: - yield external - + table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") + self._markdown = markdown + async def await_update() -> None: """Update in batches.""" BATCH_SIZE = 200 @@ -1044,7 +1057,7 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: await self.mount_all(batch) removed = True - for block in parse_markdown(tokens): + for block in self._parse_markdown(tokens, table_of_contents): batch.append(block) if len(batch) == BATCH_SIZE: await mount_batch(batch) @@ -1064,6 +1077,53 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: return AwaitComplete(await_update()) + def append(self, markdown: str) -> AwaitComplete: + """Append to markdown. + + Args: + markdown: A fragment of markdown to be appended. + + Returns: + An optionally awaitable object. Await this to ensure that the markdown has been append by the next line. + """ + parser = ( + MarkdownIt("gfm-like") + if self._parser_factory is None + else self._parser_factory() + ) + + table_of_contents: TableOfContentsType = [] + + self._markdown = updated_markdown = self.source + markdown + existing_blocks = list(self.children) + + async def await_append() -> None: + """Append new markdown widgets.""" + + tokens = await asyncio.get_running_loop().run_in_executor( + None, parser.parse, updated_markdown + ) + new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + + last_index = len(existing_blocks) - 1 + + async with self.lock: + with self.app.batch_update(): + for block in existing_blocks[last_index:]: + await block.remove() + append_blocks = new_blocks[last_index:] + if append_blocks: + await self.mount_all(append_blocks) + + self._table_of_contents = table_of_contents + self.post_message( + Markdown.TableOfContentsUpdated( + self, self._table_of_contents + ).set_sender(self) + ) + + return AwaitComplete(await_append()) + class MarkdownTableOfContents(Widget, can_focus_children=True): """Displays a table of contents for a markdown document.""" diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 47b04bd211..c2d8c7facd 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -212,7 +212,6 @@ def write( ) return self - is_vertical_scroll_end = self.is_vertical_scroll_end renderable = self._make_renderable(content) auto_scroll = self.auto_scroll if scroll_end is None else scroll_end diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg new file mode 100644 index 0000000000..8c29c0af03 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MDApp + + + + + + + + + + + +Title + + 1. List item 1 + 2. List item 2 + +There can be only one + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index e1a99d61f4..54de5c0722 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -39,6 +39,7 @@ ListItem, ListView, Log, + Markdown, OptionList, Placeholder, ProgressBar, @@ -4360,7 +4361,7 @@ class TextApp(App): def compose(self) -> ComposeResult: yield TextArea("Hello, World! " * 100) - snap_compare( + assert snap_compare( TextApp(), press=( "right", @@ -4372,3 +4373,28 @@ def compose(self) -> ComposeResult: "shift+right", ), ) + + +def test_markdown_append(snap_compare): + """Test Markdown.append method. + + You should see a view of markdown, ending with a quote that says "There can be only one" + + """ + + MD = [ + "# Title\n", + "\n", + "1. List item 1\n" "2. List item 2\n" "\n" "> There can be only one\n", + ] + + class MDApp(App): + def compose(self) -> ComposeResult: + yield Markdown() + + async def on_mount(self) -> None: + markdown = self.query_one(Markdown) + for line in MD: + await markdown.append(line) + + assert snap_compare(MDApp())