From 038cdb23d85c3083cffbcb2ebc8c203d4907ff93 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 20 Jun 2023 09:13:41 +0100 Subject: [PATCH] `Markdown` improvements (#2803) * Initial set of Markdown widget unit tests Noting too crazy or clever to start with, initially something to just test the basics and to ensure that the resulting Textual node list is what we'd expect. Really just the start of a testing framework for Markdown. * Allow handling of an unknown token This allow for a couple of things: 1. First and foremost this will let me test for unhandled tokens in testing. 2. This will also let applications support other token types. * Update the Markdown testing to get upset about unknown token types * Treat a code_block markdown token the same as a fence I believe this should be a fine way to solve this. I don't see anything that means that a `code_block` is in any way different than a fenced block that has no syntax specified. See #2781. * Add a test for a code_block within Markdown * Allow for inline fenced code and code blocks See #2676 Co-authored-by: TomJGooding <101601846+TomJGooding@users.noreply.github.com> * Update the ChangeLog * Improve the external Markdown elements are added to the document * Improve the testing of Markdown Also add a test for the list inline code block * Remove the unnecessary pause * Stop list items in Markdown being added to the focus chain See #2380 * Remove hint to pyright/pylance/pylint that it's okay to ignore the arg --------- Co-authored-by: TomJGooding <101601846+TomJGooding@users.noreply.github.com> --- CHANGELOG.md | 11 ++++ src/textual/widgets/_markdown.py | 32 ++++++++--- src/textual/widgets/markdown.py | 4 +- tests/test_markdown.py | 91 ++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 tests/test_markdown.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0d40657c..ea4be1f2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed indented code blocks not showing up in `Markdown` https://github.com/Textualize/textual/issues/2781 +- Fixed inline code blocks in lists showing out of order in `Markdown` https://github.com/Textualize/textual/issues/2676 +- Fixed list items in a `Markdown` being added to the focus chain https://github.com/Textualize/textual/issues/2380 + +### Added + +- Added a method of allowing third party code to handle unhandled tokens in `Markdown` https://github.com/Textualize/textual/pull/2803 +- Added `MarkdownBlock` as an exported symbol in `textual.widgets.markdown` https://github.com/Textualize/textual/pull/2803 + ### Changed - Tooltips are now inherited, so will work with compound widgets diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 738edd6759..b457366eed 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -4,6 +4,7 @@ from typing import Callable, Iterable from markdown_it import MarkdownIt +from markdown_it.token import Token from rich import box from rich.style import Style from rich.syntax import Syntax @@ -12,7 +13,7 @@ from typing_extensions import TypeAlias from ..app import ComposeResult -from ..containers import Horizontal, VerticalScroll +from ..containers import Horizontal, Vertical, VerticalScroll from ..events import Mount from ..message import Message from ..reactive import reactive, var @@ -269,7 +270,7 @@ class MarkdownBulletList(MarkdownList): width: 1fr; } - MarkdownBulletList VerticalScroll { + MarkdownBulletList Vertical { height: auto; width: 1fr; } @@ -280,7 +281,7 @@ def compose(self) -> ComposeResult: if isinstance(block, MarkdownListItem): bullet = MarkdownBullet() bullet.symbol = block.bullet - yield Horizontal(bullet, VerticalScroll(*block._blocks)) + yield Horizontal(bullet, Vertical(*block._blocks)) self._blocks.clear() @@ -298,7 +299,7 @@ class MarkdownOrderedList(MarkdownList): width: 1fr; } - MarkdownOrderedList VerticalScroll { + MarkdownOrderedList Vertical { height: auto; width: 1fr; } @@ -321,7 +322,7 @@ def compose(self) -> ComposeResult: if isinstance(block, MarkdownListItem): bullet = MarkdownBullet() bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1) - yield Horizontal(bullet, VerticalScroll(*block._blocks)) + yield Horizontal(bullet, Vertical(*block._blocks)) self._blocks.clear() @@ -449,7 +450,7 @@ class MarkdownListItem(MarkdownBlock): height: auto; } - MarkdownListItem > VerticalScroll { + MarkdownListItem > Vertical { width: 1fr; height: auto; } @@ -644,6 +645,17 @@ async def load(self, path: Path) -> bool: self.update(markdown) return True + def unhandled_token(self, token: Token) -> MarkdownBlock | None: + """Process an unhandled token. + + Args: + token: The token to handle. + + Returns: + Either a widget to be added to the output, or `None`. + """ + return None + def update(self, markdown: str) -> None: """Update the document with new Markdown. @@ -777,14 +789,18 @@ def update(self, markdown: str) -> None: style_stack.pop() stack[-1].set_content(content) - elif token.type == "fence": - output.append( + elif token.type in ("fence", "code_block"): + (stack[-1]._blocks if stack else output).append( MarkdownFence( self, token.content.rstrip(), token.info, ) ) + else: + external = self.unhandled_token(token) + if external is not None: + (stack[-1]._blocks if stack else output).append(external) self.post_message(Markdown.TableOfContentsUpdated(self, table_of_contents)) with self.app.batch_update(): diff --git a/src/textual/widgets/markdown.py b/src/textual/widgets/markdown.py index df891cb1fb..b9b1fec2fd 100644 --- a/src/textual/widgets/markdown.py +++ b/src/textual/widgets/markdown.py @@ -1,3 +1,3 @@ -from ._markdown import Markdown, MarkdownTableOfContents +from ._markdown import Markdown, MarkdownBlock, MarkdownTableOfContents -__all__ = ["MarkdownTableOfContents", "Markdown"] +__all__ = ["MarkdownTableOfContents", "Markdown", "MarkdownBlock"] diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000000..53cb6768c7 --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,91 @@ +"""Unit tests for the Markdown widget.""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from markdown_it.token import Token + +import textual.widgets._markdown as MD +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Markdown +from textual.widgets.markdown import MarkdownBlock + + +class UnhandledToken(MarkdownBlock): + def __init__(self, markdown: Markdown, token: Token) -> None: + super().__init__(markdown) + self._token = token + + def __repr___(self) -> str: + return self._token.type + + +class FussyMarkdown(Markdown): + def unhandled_token(self, token: Token) -> MarkdownBlock | None: + return UnhandledToken(self, token) + + +class MarkdownApp(App[None]): + def __init__(self, markdown: str) -> None: + super().__init__() + self._markdown = markdown + + def compose(self) -> ComposeResult: + yield FussyMarkdown(self._markdown) + + +@pytest.mark.parametrize( + ["document", "expected_nodes"], + [ + # Basic markup. + ("", []), + ("# Hello", [MD.MarkdownH1]), + ("## Hello", [MD.MarkdownH2]), + ("### Hello", [MD.MarkdownH3]), + ("#### Hello", [MD.MarkdownH4]), + ("##### Hello", [MD.MarkdownH5]), + ("###### Hello", [MD.MarkdownH6]), + ("---", [MD.MarkdownHorizontalRule]), + ("Hello", [MD.MarkdownParagraph]), + ("Hello\nWorld", [MD.MarkdownParagraph]), + ("> Hello", [MD.MarkdownBlockQuote, MD.MarkdownParagraph]), + ("- One\n-Two", [MD.MarkdownBulletList, MD.MarkdownParagraph]), + ( + "1. One\n2. Two", + [MD.MarkdownOrderedList, MD.MarkdownParagraph, MD.MarkdownParagraph], + ), + (" 1", [MD.MarkdownFence]), + ("```\n1\n```", [MD.MarkdownFence]), + ("```python\n1\n```", [MD.MarkdownFence]), + ("""| One | Two |\n| :- | :- |\n| 1 | 2 |""", [MD.MarkdownTable]), + # Test for https://github.com/Textualize/textual/issues/2676 + ( + "- One\n```\nTwo\n```\n- Three\n", + [ + MD.MarkdownBulletList, + MD.MarkdownParagraph, + MD.MarkdownFence, + MD.MarkdownBulletList, + MD.MarkdownParagraph, + ], + ), + ], +) +async def test_markdown_nodes( + document: str, expected_nodes: list[Widget | list[Widget]] +) -> None: + """A Markdown document should parse into the expected Textual node list.""" + + def markdown_nodes(root: Widget) -> Iterator[MarkdownBlock]: + for node in root.children: + if isinstance(node, MarkdownBlock): + yield node + yield from markdown_nodes(node) + + async with MarkdownApp(document).run_test() as pilot: + assert [ + node.__class__ for node in markdown_nodes(pilot.app.query_one(Markdown)) + ] == expected_nodes