From 4d141b7ad3e5c4b352e9d32aed82dd96f149cd7b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 17:04:18 +0100 Subject: [PATCH 1/8] fix for table of contents when using markdown.append --- src/textual/widgets/_markdown.py | 76 +++++++++++++++++++------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index c671312a13..af7082cb09 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -216,6 +216,7 @@ def __init__( self._token: Token = token self._blocks: list[MarkdownBlock] = [] self._inline_token: Token | None = None + self._bock_id = 1 self.source_range: tuple[int, int] = source_range or ( (token.map[0], token.map[1]) if token.map is not None else (0, 0) ) @@ -354,6 +355,8 @@ def close_tag() -> None: class MarkdownHeader(MarkdownBlock): """Base class for a Markdown header.""" + LEVEL = 0 + DEFAULT_CSS = """ MarkdownHeader { color: $text; @@ -366,6 +369,8 @@ class MarkdownHeader(MarkdownBlock): class MarkdownH1(MarkdownHeader): """An H1 Markdown header.""" + LEVEL = 1 + DEFAULT_CSS = """ MarkdownH1 { content-align: center middle; @@ -379,6 +384,8 @@ class MarkdownH1(MarkdownHeader): class MarkdownH2(MarkdownHeader): """An H2 Markdown header.""" + LEVEL = 2 + DEFAULT_CSS = """ MarkdownH2 { color: $markdown-h2-color; @@ -391,6 +398,8 @@ class MarkdownH2(MarkdownHeader): class MarkdownH3(MarkdownHeader): """An H3 Markdown header.""" + LEVEL = 3 + DEFAULT_CSS = """ MarkdownH3 { color: $markdown-h3-color; @@ -405,6 +414,8 @@ class MarkdownH3(MarkdownHeader): class MarkdownH4(MarkdownHeader): """An H4 Markdown header.""" + LEVEL = 4 + DEFAULT_CSS = """ MarkdownH4 { color: $markdown-h4-color; @@ -418,6 +429,8 @@ class MarkdownH4(MarkdownHeader): class MarkdownH5(MarkdownHeader): """An H5 Markdown header.""" + LEVEL = 5 + DEFAULT_CSS = """ MarkdownH5 { color: $markdown-h5-color; @@ -431,6 +444,8 @@ class MarkdownH5(MarkdownHeader): class MarkdownH6(MarkdownHeader): """An H6 Markdown header.""" + LEVEL = 6 + DEFAULT_CSS = """ MarkdownH6 { color: $markdown-h6-color; @@ -574,7 +589,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 +989,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 +1207,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 +1271,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: @@ -1282,7 +1299,7 @@ def _build_from_source(self, markdown: str) -> list[MarkdownBlock]: 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 +1317,10 @@ def update(self, markdown: str) -> AwaitComplete: else self._parser_factory() ) - table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") self._markdown = markdown + self._last_parsed_line = len(markdown.splitlines()) - 1 + self._table_of_contents = None async def await_update() -> None: """Update in batches.""" @@ -1333,7 +1351,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 +1361,11 @@ 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) - ) + self.post_message( + Markdown.TableOfContentsUpdated( + self, self.table_of_contents + ).set_sender(self) + ) return AwaitComplete(await_update()) @@ -1368,7 +1384,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 +1402,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 +1424,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()) From 39d95f69ee6339c54c49ff4f121203f911bcf06e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 17:15:52 +0100 Subject: [PATCH 2/8] fix last parsed line --- src/textual/widgets/_markdown.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index af7082cb09..5e7783126c 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1319,7 +1319,6 @@ def update(self, markdown: str) -> AwaitComplete: markdown_block = self.query("MarkdownBlock") self._markdown = markdown - self._last_parsed_line = len(markdown.splitlines()) - 1 self._table_of_contents = None async def await_update() -> None: @@ -1361,6 +1360,8 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: if not removed: await markdown_block.remove() + 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 From 5041686a11169a968b55c9d60cc4a125b23543b6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 17:36:21 +0100 Subject: [PATCH 3/8] reduce test flakeyness --- src/textual/widgets/_footer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From b7063c67781e6f4ad9dbecc8a94873f2449d13fe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 17:41:59 +0100 Subject: [PATCH 4/8] remove superfluous line --- src/textual/widgets/_markdown.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 5e7783126c..7b4261aef8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -216,7 +216,6 @@ def __init__( self._token: Token = token self._blocks: list[MarkdownBlock] = [] self._inline_token: Token | None = None - self._bock_id = 1 self.source_range: tuple[int, int] = source_range or ( (token.map[0], token.map[1]) if token.map is not None else (0, 0) ) From 35c5bbcee3a8fa7d51f040e5cfcdfc034486ac21 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 17:48:05 +0100 Subject: [PATCH 5/8] docstring and typing --- src/textual/widgets/_markdown.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 7b4261aef8..d8889be375 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1278,7 +1278,9 @@ def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]: 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: @@ -1292,6 +1294,14 @@ def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]: 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 From 57dec3b8022a2d5e432e3739b982ed4b9c49187e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 20:15:40 +0100 Subject: [PATCH 6/8] snapshot tests --- .../test_append_with_initial_document.svg | 153 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 25 ++- 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_append_with_initial_document.svg 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", From a8de8107e384f6a7b206ded8b33a0ea92b39abb2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 20:17:37 +0100 Subject: [PATCH 7/8] bump --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/" From 1e35bc3ce598d421612375a98643dfa8c2c70e41 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 25 Jul 2025 20:34:48 +0100 Subject: [PATCH 8/8] test fix --- tests/footer/test_footer.py | 1 + 1 file changed, 1 insertion(+) 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")