From 56b7e68fcd2d77291f855ed6359860b8e3864774 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Jul 2025 17:35:35 +0100 Subject: [PATCH 01/10] WIP Markdown streaming --- src/textual/widgets/_markdown.py | 228 +++++++++++++++++++------------ 1 file changed, 143 insertions(+), 85 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index fdc32c0144..9d0c89ae76 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -325,6 +325,7 @@ class MarkdownH6(MarkdownHeader): class MarkdownHorizontalRule(MarkdownBlock): """A horizontal rule.""" + _final = True DEFAULT_CSS = """ MarkdownHorizontalRule { border-bottom: heavy $secondary; @@ -823,6 +824,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 +921,93 @@ 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 + + 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: + block.add_class("-final") + 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: + fence.add_class("-final") + yield fence + else: + external = self.unhandled_token(token) + if external is not None: + if stack: + stack[-1]._blocks.append(external) + else: + external.add_class("-final") + yield external + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. @@ -930,92 +1023,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 +1056,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 +1076,52 @@ 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. + """ + parser = ( + MarkdownIt("gfm-like") + if self._parser_factory is None + else self._parser_factory() + ) + + table_of_contents: TableOfContentsType = [] + + async def await_append() -> None: + self._markdown = updated_markdown = self.source + markdown + existing_blocks = list(self.children) + + 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_final_index = 0 + for index, block in enumerate(reversed(existing_blocks)): + if block.has_class("-final"): + last_final_index = len(existing_blocks) - index - 1 + break + + async with self.lock: + with self.app.batch_update(): + for block in existing_blocks[last_final_index:]: + await block.remove() + append_blocks = new_blocks[last_final_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.""" From 20c23fecaddc1b46590025dc3cf36c270225b0cc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Jul 2025 20:57:22 +0100 Subject: [PATCH 02/10] wip, smooth update --- src/textual/_compositor.py | 9 +++++ src/textual/widget.py | 1 + src/textual/widgets/_markdown.py | 60 +++++++++++++++++--------------- src/textual/widgets/_rich_log.py | 1 - 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 245abf6ccb..ed9ac617ef 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -605,6 +605,15 @@ def add_widget( # Get the region that will be updated sub_clip = clip.intersection(child_region) + if widget._anchored: + + scroll_y = widget.scroll_y + widget.scroll_y = ( + arrange_result.spatial_map.total_region.bottom + - region.height + ) + # widget.watch_scroll_y(scroll_y, widget.scroll_y) + if visible_only: placements = arrange_result.get_visible_placements( sub_clip - child_region.offset + widget.scroll_offset diff --git a/src/textual/widget.py b/src/textual/widget.py index 0997ce217d..b91f8b0dea 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1747,6 +1747,7 @@ def validate_scroll_target_x(self, value: float) -> float: return round(clamp(value, 0, self.max_scroll_x)) def validate_scroll_y(self, value: float) -> float: + return value return clamp(value, 0, self.max_scroll_y) def validate_scroll_target_y(self, value: float) -> float: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 9d0c89ae76..fad89f7cdb 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -18,8 +18,8 @@ from textual._slug import TrackedSlugs 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 @@ -665,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( @@ -764,6 +767,7 @@ def __init__( self._parser_factory = parser_factory self._table_of_contents: TableOfContentsType | None = None self._open_links = open_links + self._pending_markdown = "" class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -988,7 +992,6 @@ def _parse_markdown( if stack: stack[-1]._blocks.append(block) else: - block.add_class("-final") yield block elif token_type == "inline": stack[-1].build_from_token(token) @@ -997,7 +1000,6 @@ def _parse_markdown( if stack: stack[-1]._blocks.append(fence) else: - fence.add_class("-final") yield fence else: external = self.unhandled_token(token) @@ -1005,10 +1007,15 @@ def _parse_markdown( if stack: stack[-1]._blocks.append(external) else: - external.add_class("-final") yield external - def update(self, markdown: str) -> AwaitComplete: + async def _on_idle(self) -> None: + if self._pending_markdown: + pending_markdown = self._pending_markdown + self._pending_markdown = "" + await self._append(pending_markdown) + + def update(self, markdown: str) -> None: """Update the document with new Markdown. Args: @@ -1074,9 +1081,11 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: ).set_sender(self) ) - return AwaitComplete(await_update()) + self.call_next(await_update) + + # return AwaitComplete(await_update()) - def append(self, markdown: str) -> AwaitComplete: + async def append(self, markdown: str) -> None: """Append to markdown. Args: @@ -1090,28 +1099,23 @@ def append(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] - async def await_append() -> None: - self._markdown = updated_markdown = self.source + markdown - existing_blocks = list(self.children) + self._markdown = updated_markdown = self.source + markdown + existing_blocks = list(self.children) - tokens = await asyncio.get_running_loop().run_in_executor( - None, parser.parse, updated_markdown - ) - new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + 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_final_index = 0 - for index, block in enumerate(reversed(existing_blocks)): - if block.has_class("-final"): - last_final_index = len(existing_blocks) - index - 1 - break + last_index = len(existing_blocks) - 1 - async with self.lock: - with self.app.batch_update(): - for block in existing_blocks[last_final_index:]: - await block.remove() - append_blocks = new_blocks[last_final_index:] - if append_blocks: - await self.mount_all(append_blocks) + 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( @@ -1120,8 +1124,6 @@ async def await_append() -> None: ).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 From 69d8042159cb010b81ce72a1fc982af032618b21 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 16:46:16 +0100 Subject: [PATCH 03/10] Anchor improvements --- CHANGELOG.md | 12 ++- src/textual/_compositor.py | 16 ++-- src/textual/scrollbar.py | 4 +- src/textual/widget.py | 125 +++++++++++++++---------------- src/textual/widgets/_footer.py | 4 +- src/textual/widgets/_markdown.py | 15 +--- 6 files changed, 90 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a225adf3b4..2a8ce6ac87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,22 @@ 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. + +### Added + +- Added `Markdown.append` +- Added `Widget.release_anchor` + ## [3.7.1] - 2025-07-09 ### Fixed diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ed9ac617ef..aec5c02240 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,14 +605,18 @@ def add_widget( # Get the region that will be updated sub_clip = clip.intersection(child_region) - if widget._anchored: - + if widget._anchored and not widget._anchor_released: scroll_y = widget.scroll_y - widget.scroll_y = ( + new_scroll_y = ( arrange_result.spatial_map.total_region.bottom - - region.height + - ( + widget.container_size.height + - widget.scrollbar_size_horizontal + ) ) - # widget.watch_scroll_y(scroll_y, widget.scroll_y) + widget.set_reactive(Widget.scroll_y, new_scroll_y) + # widget._reactive_scroll_y = new_scroll_y + widget.watch_scroll_y(scroll_y, new_scroll_y) if visible_only: placements = arrange_result.get_visible_placements( 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 b91f8b0dea..1e54393eca 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() @@ -1747,7 +1756,7 @@ def validate_scroll_target_x(self, value: float) -> float: return round(clamp(value, 0, self.max_scroll_x)) def validate_scroll_y(self, value: float) -> float: - return value + # return value return clamp(value, 0, self.max_scroll_y) def validate_scroll_target_y(self, value: float) -> float: @@ -2520,8 +2529,8 @@ 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 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 @@ -2793,16 +2802,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) @@ -2826,6 +2830,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: @@ -4270,9 +4277,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: @@ -4494,54 +4498,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() @@ -4569,67 +4573,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 fad89f7cdb..e8c2f57e92 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -18,6 +18,7 @@ from textual._slug import TrackedSlugs 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 @@ -325,7 +326,6 @@ class MarkdownH6(MarkdownHeader): class MarkdownHorizontalRule(MarkdownBlock): """A horizontal rule.""" - _final = True DEFAULT_CSS = """ MarkdownHorizontalRule { border-bottom: heavy $secondary; @@ -767,7 +767,6 @@ def __init__( self._parser_factory = parser_factory self._table_of_contents: TableOfContentsType | None = None self._open_links = open_links - self._pending_markdown = "" class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -1009,13 +1008,7 @@ def _parse_markdown( else: yield external - async def _on_idle(self) -> None: - if self._pending_markdown: - pending_markdown = self._pending_markdown - self._pending_markdown = "" - await self._append(pending_markdown) - - def update(self, markdown: str) -> None: + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. Args: @@ -1081,9 +1074,7 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: ).set_sender(self) ) - self.call_next(await_update) - - # return AwaitComplete(await_update()) + return AwaitComplete(await_update()) async def append(self, markdown: str) -> None: """Append to markdown. From 3519827e0f83b721726d8d71a811b3b8f96f8dbd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 16:50:34 +0100 Subject: [PATCH 04/10] make append an optional awaitable --- src/textual/widgets/_markdown.py | 46 +++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index e8c2f57e92..5566d3660c 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1076,11 +1076,14 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: return AwaitComplete(await_update()) - async def append(self, markdown: str) -> None: + 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") @@ -1093,27 +1096,32 @@ async def append(self, markdown: str) -> None: self._markdown = updated_markdown = self.source + markdown existing_blocks = list(self.children) - tokens = await asyncio.get_running_loop().run_in_executor( - None, parser.parse, updated_markdown - ) - new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + async def await_append() -> None: + """Append new markdown widgets.""" - last_index = len(existing_blocks) - 1 + tokens = await asyncio.get_running_loop().run_in_executor( + None, parser.parse, updated_markdown + ) + new_blocks = list(self._parse_markdown(tokens, table_of_contents)) - 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) + last_index = len(existing_blocks) - 1 - self._table_of_contents = table_of_contents - self.post_message( - Markdown.TableOfContentsUpdated( - self, self._table_of_contents - ).set_sender(self) - ) + 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): From 4057ae171398731b00de36483860b17e8c1bde84 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 16:52:34 +0100 Subject: [PATCH 05/10] fix mother --- examples/mother.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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) From a96cc250238cbf0824e1fbd26c4a53eb5c24a917 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 17:09:45 +0100 Subject: [PATCH 06/10] tests --- src/textual/widgets/_markdown.py | 3 +- .../test_snapshots/test_markdown_append.svg | 153 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 28 +++- 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 5566d3660c..a491e17458 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -930,7 +930,8 @@ def _parse_markdown( """Create a stream of MarkdownBlock widgets from markdown. Args: - tokens: List of tokens + tokens: List of tokens. + table_of_contents: List to store table of contents. Yields: Widgets for mounting. 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()) From 9ebb1792d7b92663d3a268fcf3a49c24e0e2873a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 17:12:06 +0100 Subject: [PATCH 07/10] changelog --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8ce6ac87..67dee27e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception. - +- Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception. https://github.com/Textualize/textual/pull/5950 ### 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. +- 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` -- Added `Widget.release_anchor` +- 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 From a75bd77d84368556f8dda14599e8a21aecc01b96 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 17:13:43 +0100 Subject: [PATCH 08/10] fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67dee27e87..d87a5fcfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception. https://github.com/Textualize/textual/pull/5950 +- Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception. ### Changed From 0afd264e03d0755d33430cd68851204140a8563f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Jul 2025 17:15:21 +0100 Subject: [PATCH 09/10] remove comments --- src/textual/_compositor.py | 1 - src/textual/widget.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index aec5c02240..6941bf96c0 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -615,7 +615,6 @@ def add_widget( ) ) widget.set_reactive(Widget.scroll_y, new_scroll_y) - # widget._reactive_scroll_y = new_scroll_y widget.watch_scroll_y(scroll_y, new_scroll_y) if visible_only: diff --git a/src/textual/widget.py b/src/textual/widget.py index 1e54393eca..c42130c529 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2529,9 +2529,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: From 03b94706399f110ff93fa396d4afbc79c3738638 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Jul 2025 09:36:06 +0100 Subject: [PATCH 10/10] fix error with selection when widget removed --- src/textual/screen.py | 15 +++++++++------ src/textual/widget.py | 1 - 2 files changed, 9 insertions(+), 7 deletions(-) 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/widget.py b/src/textual/widget.py index c42130c529..4c92931e56 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1756,7 +1756,6 @@ def validate_scroll_target_x(self, value: float) -> float: return round(clamp(value, 0, self.max_scroll_x)) def validate_scroll_y(self, value: float) -> float: - # return value return clamp(value, 0, self.max_scroll_y) def validate_scroll_target_y(self, value: float) -> float: