Skip to content

Commit 038cdb2

Browse files
davepTomJGooding
andauthored
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 <[email protected]> * 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 <[email protected]>
1 parent bb9cc62 commit 038cdb2

File tree

4 files changed

+128
-10
lines changed

4 files changed

+128
-10
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Fixed indented code blocks not showing up in `Markdown` https://github.com/Textualize/textual/issues/2781
13+
- Fixed inline code blocks in lists showing out of order in `Markdown` https://github.com/Textualize/textual/issues/2676
14+
- Fixed list items in a `Markdown` being added to the focus chain https://github.com/Textualize/textual/issues/2380
15+
16+
### Added
17+
18+
- Added a method of allowing third party code to handle unhandled tokens in `Markdown` https://github.com/Textualize/textual/pull/2803
19+
- Added `MarkdownBlock` as an exported symbol in `textual.widgets.markdown` https://github.com/Textualize/textual/pull/2803
20+
1021
### Changed
1122

1223
- Tooltips are now inherited, so will work with compound widgets

src/textual/widgets/_markdown.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Callable, Iterable
55

66
from markdown_it import MarkdownIt
7+
from markdown_it.token import Token
78
from rich import box
89
from rich.style import Style
910
from rich.syntax import Syntax
@@ -12,7 +13,7 @@
1213
from typing_extensions import TypeAlias
1314

1415
from ..app import ComposeResult
15-
from ..containers import Horizontal, VerticalScroll
16+
from ..containers import Horizontal, Vertical, VerticalScroll
1617
from ..events import Mount
1718
from ..message import Message
1819
from ..reactive import reactive, var
@@ -269,7 +270,7 @@ class MarkdownBulletList(MarkdownList):
269270
width: 1fr;
270271
}
271272
272-
MarkdownBulletList VerticalScroll {
273+
MarkdownBulletList Vertical {
273274
height: auto;
274275
width: 1fr;
275276
}
@@ -280,7 +281,7 @@ def compose(self) -> ComposeResult:
280281
if isinstance(block, MarkdownListItem):
281282
bullet = MarkdownBullet()
282283
bullet.symbol = block.bullet
283-
yield Horizontal(bullet, VerticalScroll(*block._blocks))
284+
yield Horizontal(bullet, Vertical(*block._blocks))
284285
self._blocks.clear()
285286

286287

@@ -298,7 +299,7 @@ class MarkdownOrderedList(MarkdownList):
298299
width: 1fr;
299300
}
300301
301-
MarkdownOrderedList VerticalScroll {
302+
MarkdownOrderedList Vertical {
302303
height: auto;
303304
width: 1fr;
304305
}
@@ -321,7 +322,7 @@ def compose(self) -> ComposeResult:
321322
if isinstance(block, MarkdownListItem):
322323
bullet = MarkdownBullet()
323324
bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1)
324-
yield Horizontal(bullet, VerticalScroll(*block._blocks))
325+
yield Horizontal(bullet, Vertical(*block._blocks))
325326

326327
self._blocks.clear()
327328

@@ -449,7 +450,7 @@ class MarkdownListItem(MarkdownBlock):
449450
height: auto;
450451
}
451452
452-
MarkdownListItem > VerticalScroll {
453+
MarkdownListItem > Vertical {
453454
width: 1fr;
454455
height: auto;
455456
}
@@ -644,6 +645,17 @@ async def load(self, path: Path) -> bool:
644645
self.update(markdown)
645646
return True
646647

648+
def unhandled_token(self, token: Token) -> MarkdownBlock | None:
649+
"""Process an unhandled token.
650+
651+
Args:
652+
token: The token to handle.
653+
654+
Returns:
655+
Either a widget to be added to the output, or `None`.
656+
"""
657+
return None
658+
647659
def update(self, markdown: str) -> None:
648660
"""Update the document with new Markdown.
649661
@@ -777,14 +789,18 @@ def update(self, markdown: str) -> None:
777789
style_stack.pop()
778790

779791
stack[-1].set_content(content)
780-
elif token.type == "fence":
781-
output.append(
792+
elif token.type in ("fence", "code_block"):
793+
(stack[-1]._blocks if stack else output).append(
782794
MarkdownFence(
783795
self,
784796
token.content.rstrip(),
785797
token.info,
786798
)
787799
)
800+
else:
801+
external = self.unhandled_token(token)
802+
if external is not None:
803+
(stack[-1]._blocks if stack else output).append(external)
788804

789805
self.post_message(Markdown.TableOfContentsUpdated(self, table_of_contents))
790806
with self.app.batch_update():

src/textual/widgets/markdown.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from ._markdown import Markdown, MarkdownTableOfContents
1+
from ._markdown import Markdown, MarkdownBlock, MarkdownTableOfContents
22

3-
__all__ = ["MarkdownTableOfContents", "Markdown"]
3+
__all__ = ["MarkdownTableOfContents", "Markdown", "MarkdownBlock"]

tests/test_markdown.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Unit tests for the Markdown widget."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Iterator
6+
7+
import pytest
8+
from markdown_it.token import Token
9+
10+
import textual.widgets._markdown as MD
11+
from textual.app import App, ComposeResult
12+
from textual.widget import Widget
13+
from textual.widgets import Markdown
14+
from textual.widgets.markdown import MarkdownBlock
15+
16+
17+
class UnhandledToken(MarkdownBlock):
18+
def __init__(self, markdown: Markdown, token: Token) -> None:
19+
super().__init__(markdown)
20+
self._token = token
21+
22+
def __repr___(self) -> str:
23+
return self._token.type
24+
25+
26+
class FussyMarkdown(Markdown):
27+
def unhandled_token(self, token: Token) -> MarkdownBlock | None:
28+
return UnhandledToken(self, token)
29+
30+
31+
class MarkdownApp(App[None]):
32+
def __init__(self, markdown: str) -> None:
33+
super().__init__()
34+
self._markdown = markdown
35+
36+
def compose(self) -> ComposeResult:
37+
yield FussyMarkdown(self._markdown)
38+
39+
40+
@pytest.mark.parametrize(
41+
["document", "expected_nodes"],
42+
[
43+
# Basic markup.
44+
("", []),
45+
("# Hello", [MD.MarkdownH1]),
46+
("## Hello", [MD.MarkdownH2]),
47+
("### Hello", [MD.MarkdownH3]),
48+
("#### Hello", [MD.MarkdownH4]),
49+
("##### Hello", [MD.MarkdownH5]),
50+
("###### Hello", [MD.MarkdownH6]),
51+
("---", [MD.MarkdownHorizontalRule]),
52+
("Hello", [MD.MarkdownParagraph]),
53+
("Hello\nWorld", [MD.MarkdownParagraph]),
54+
("> Hello", [MD.MarkdownBlockQuote, MD.MarkdownParagraph]),
55+
("- One\n-Two", [MD.MarkdownBulletList, MD.MarkdownParagraph]),
56+
(
57+
"1. One\n2. Two",
58+
[MD.MarkdownOrderedList, MD.MarkdownParagraph, MD.MarkdownParagraph],
59+
),
60+
(" 1", [MD.MarkdownFence]),
61+
("```\n1\n```", [MD.MarkdownFence]),
62+
("```python\n1\n```", [MD.MarkdownFence]),
63+
("""| One | Two |\n| :- | :- |\n| 1 | 2 |""", [MD.MarkdownTable]),
64+
# Test for https://github.com/Textualize/textual/issues/2676
65+
(
66+
"- One\n```\nTwo\n```\n- Three\n",
67+
[
68+
MD.MarkdownBulletList,
69+
MD.MarkdownParagraph,
70+
MD.MarkdownFence,
71+
MD.MarkdownBulletList,
72+
MD.MarkdownParagraph,
73+
],
74+
),
75+
],
76+
)
77+
async def test_markdown_nodes(
78+
document: str, expected_nodes: list[Widget | list[Widget]]
79+
) -> None:
80+
"""A Markdown document should parse into the expected Textual node list."""
81+
82+
def markdown_nodes(root: Widget) -> Iterator[MarkdownBlock]:
83+
for node in root.children:
84+
if isinstance(node, MarkdownBlock):
85+
yield node
86+
yield from markdown_nodes(node)
87+
88+
async with MarkdownApp(document).run_test() as pilot:
89+
assert [
90+
node.__class__ for node in markdown_nodes(pilot.app.query_one(Markdown))
91+
] == expected_nodes

0 commit comments

Comments
 (0)