diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7e8cbb01..586f5051da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +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/). +## [5.0.1] - 2025-07-25 + +### Fixed + +- Fixed appending to Markdown widgets that were constructed with an existing document https://github.com/Textualize/textual/pull/5990 + ## [5.0.0] - 2025-07-25 ### Added @@ -3012,6 +3018,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 +[5.0.1]: https://github.com/Textualize/textual/compare/v5.0.0...v5.0.1 [5.0.0]: https://github.com/Textualize/textual/compare/v4.1.0...v5.0.0 [4.0.0]: https://github.com/Textualize/textual/compare/v3.7.1...v4.0.0 [3.7.1]: https://github.com/Textualize/textual/compare/v3.7.0...v3.7.1 diff --git a/pyproject.toml b/pyproject.toml index 5993ed1706..31cdbb8e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "5.0.0" +version = "5.0.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index f55568bea3..b643ea8fda 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -271,7 +271,7 @@ def on_mount(self) -> None: def bindings_changed(screen: Screen) -> None: """Update bindings after a short delay to avoid flicker.""" - self.set_timer(1 / 20, lambda: self.bindings_changed(screen)) + self.call_after_refresh(self.bindings_changed, screen) self.screen.bindings_updated_signal.subscribe(self, bindings_changed) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index c671312a13..d8889be375 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -354,6 +354,8 @@ def close_tag() -> None: class MarkdownHeader(MarkdownBlock): """Base class for a Markdown header.""" + LEVEL = 0 + DEFAULT_CSS = """ MarkdownHeader { color: $text; @@ -366,6 +368,8 @@ class MarkdownHeader(MarkdownBlock): class MarkdownH1(MarkdownHeader): """An H1 Markdown header.""" + LEVEL = 1 + DEFAULT_CSS = """ MarkdownH1 { content-align: center middle; @@ -379,6 +383,8 @@ class MarkdownH1(MarkdownHeader): class MarkdownH2(MarkdownHeader): """An H2 Markdown header.""" + LEVEL = 2 + DEFAULT_CSS = """ MarkdownH2 { color: $markdown-h2-color; @@ -391,6 +397,8 @@ class MarkdownH2(MarkdownHeader): class MarkdownH3(MarkdownHeader): """An H3 Markdown header.""" + LEVEL = 3 + DEFAULT_CSS = """ MarkdownH3 { color: $markdown-h3-color; @@ -405,6 +413,8 @@ class MarkdownH3(MarkdownHeader): class MarkdownH4(MarkdownHeader): """An H4 Markdown header.""" + LEVEL = 4 + DEFAULT_CSS = """ MarkdownH4 { color: $markdown-h4-color; @@ -418,6 +428,8 @@ class MarkdownH4(MarkdownHeader): class MarkdownH5(MarkdownHeader): """An H5 Markdown header.""" + LEVEL = 5 + DEFAULT_CSS = """ MarkdownH5 { color: $markdown-h5-color; @@ -431,6 +443,8 @@ class MarkdownH5(MarkdownHeader): class MarkdownH6(MarkdownHeader): """An H6 Markdown header.""" + LEVEL = 6 + DEFAULT_CSS = """ MarkdownH6 { color: $markdown-h6-color; @@ -574,7 +588,7 @@ def compose(self) -> ComposeResult: bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1) yield Horizontal(bullet, Vertical(*block._blocks)) - # self._blocks.clear() + self._blocks.clear() class MarkdownTableCellContents(Static): @@ -974,11 +988,21 @@ def __init__( self._initial_markdown: str | None = markdown self._markdown = "" self._parser_factory = parser_factory - self._table_of_contents: TableOfContentsType = [] + self._table_of_contents: TableOfContentsType | None = None self._open_links = open_links self._last_parsed_line = 0 self._theme = "" + @property + def table_of_contents(self) -> TableOfContentsType: + """The document's table of contents.""" + if self._table_of_contents is None: + self._table_of_contents = [ + (header.LEVEL, header._content.plain, header.id) + for header in self.query_children(MarkdownHeader) + ] + return self._table_of_contents + class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -1182,16 +1206,11 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None: """ return None - def _parse_markdown( - self, - tokens: Iterable[Token], - table_of_contents: TableOfContentsType, - ) -> Iterable[MarkdownBlock]: + def _parse_markdown(self, tokens: Iterable[Token]) -> 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. @@ -1251,10 +1270,7 @@ def _parse_markdown( elif token_type.endswith("_close"): block = stack.pop() if token.type == "heading_close": - heading = block._content.plain - level = int(token.tag[1:]) - block.id = f"{slug(heading)}-{len(table_of_contents) + 1}" - table_of_contents.append((level, heading, block.id)) + block.id = f"heading-{slug(block._content.plain)}-{id(block)}" if stack: stack[-1]._blocks.append(block) else: @@ -1262,7 +1278,9 @@ def _parse_markdown( elif token_type == "inline": stack[-1].build_from_token(token) elif token_type in ("fence", "code_block"): - fence = get_block_class(token_type)(self, token, token.content.rstrip()) + fence_class = get_block_class(token_type) + assert issubclass(fence_class, MarkdownFence) + fence = fence_class(self, token, token.content.rstrip()) if stack: stack[-1]._blocks.append(fence) else: @@ -1276,13 +1294,21 @@ def _parse_markdown( yield external def _build_from_source(self, markdown: str) -> list[MarkdownBlock]: + """Build blocks from markdown source. + + Args: + markdown: A Markdown document, or partial document. + + Returns: + A list of MarkdownBlock instances. + """ parser = ( MarkdownIt("gfm-like") if self._parser_factory is None else self._parser_factory() ) tokens = parser.parse(markdown) - return list(self._parse_markdown(tokens, [])) + return list(self._parse_markdown(tokens)) def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. @@ -1300,9 +1326,9 @@ def update(self, markdown: str) -> AwaitComplete: else self._parser_factory() ) - table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") self._markdown = markdown + self._table_of_contents = None async def await_update() -> None: """Update in batches.""" @@ -1333,7 +1359,7 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: await self.mount_all(batch) removed = True - for block in self._parse_markdown(tokens, table_of_contents): + for block in self._parse_markdown(tokens): batch.append(block) if len(batch) == BATCH_SIZE: await mount_batch(batch) @@ -1343,13 +1369,13 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: if not removed: await markdown_block.remove() - if table_of_contents != self._table_of_contents: - self._table_of_contents = table_of_contents - self.post_message( - Markdown.TableOfContentsUpdated( - self, self._table_of_contents - ).set_sender(self) - ) + lines = markdown.splitlines() + self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0) + self.post_message( + Markdown.TableOfContentsUpdated( + self, self.table_of_contents + ).set_sender(self) + ) return AwaitComplete(await_update()) @@ -1368,7 +1394,6 @@ def append(self, markdown: str) -> AwaitComplete: else self._parser_factory() ) - table_of_contents: TableOfContentsType = [] self._markdown = self.source + markdown updated_source = "".join( self._markdown.splitlines(keepends=True)[self._last_parsed_line :] @@ -1387,7 +1412,7 @@ async def await_append() -> None: self._last_parsed_line += token.map[0] break - new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + new_blocks = list(self._parse_markdown(tokens)) for block in new_blocks: start, end = block.source_range block.source_range = ( @@ -1409,12 +1434,13 @@ async def await_append() -> None: if new_blocks: await self.mount_all(new_blocks) - self._table_of_contents = table_of_contents - self.post_message( - Markdown.TableOfContentsUpdated( - self, self._table_of_contents - ).set_sender(self) - ) + if any(isinstance(block, MarkdownHeader) for block in new_blocks): + self._table_of_contents = None + self.post_message( + Markdown.TableOfContentsUpdated( + self, self.table_of_contents + ).set_sender(self) + ) return AwaitComplete(await_append()) diff --git a/tests/footer/test_footer.py b/tests/footer/test_footer.py index 2016451c04..8bd339cf57 100644 --- a/tests/footer/test_footer.py +++ b/tests/footer/test_footer.py @@ -52,6 +52,7 @@ def action_app_binding(self) -> None: async with app.run_test() as pilot: await pilot.pause() assert app_binding_count == 0 + await app.wait_for_refresh() await pilot.click("Footer", offset=(1, 0)) assert app_binding_count == 1 await pilot.click("Footer") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_append_with_initial_document.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_append_with_initial_document.svg new file mode 100644 index 0000000000..d61466da52 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_append_with_initial_document.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + +Header + +HelloWorld + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6bde2bdd17..40cb75a816 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4423,19 +4423,40 @@ async def on_mount(self) -> None: assert snap_compare(MDApp(), press=["1", "wait:500"]) +def test_append_with_initial_document(snap_compare): + """Test appending to an an existing document works. + + You should a header, followed by Hello World on the next line. + "Hello" will be in bold, and "World" in italics. + """ + + TEXT = """\ +### Header +**Hello**""" + + class MyApp(App): + def compose(self): + yield Markdown(TEXT) + + async def on_mount(self) -> None: + await self.query_one(Markdown).append(" *World*") + + assert snap_compare(MyApp()) + + def test_text_area_css_theme_updates_background(snap_compare): """Regression test for https://github.com/Textualize/textual/issues/5964""" class TextAreaThemeApp(App): def compose(self) -> ComposeResult: - text_area_control = TextArea( + text_area_control = TextArea( "This TextArea theme is always `css`.", theme="css", id="text-area-control", ) text_area_control.cursor_blink = False - text_area_variable = TextArea( + text_area_variable = TextArea( "This TextArea theme changes from `github_light` to `css`.\n" "The colors should match the TextArea above.", theme="github_light",