Skip to content

Commit

Permalink
Markdown improvements (#2803)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
davep and TomJGooding authored Jun 20, 2023
1 parent bb9cc62 commit 038cdb2
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 10 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -269,7 +270,7 @@ class MarkdownBulletList(MarkdownList):
width: 1fr;
}
MarkdownBulletList VerticalScroll {
MarkdownBulletList Vertical {
height: auto;
width: 1fr;
}
Expand All @@ -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()


Expand All @@ -298,7 +299,7 @@ class MarkdownOrderedList(MarkdownList):
width: 1fr;
}
MarkdownOrderedList VerticalScroll {
MarkdownOrderedList Vertical {
height: auto;
width: 1fr;
}
Expand All @@ -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()

Expand Down Expand Up @@ -449,7 +450,7 @@ class MarkdownListItem(MarkdownBlock):
height: auto;
}
MarkdownListItem > VerticalScroll {
MarkdownListItem > Vertical {
width: 1fr;
height: auto;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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():
Expand Down
4 changes: 2 additions & 2 deletions src/textual/widgets/markdown.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from ._markdown import Markdown, MarkdownTableOfContents
from ._markdown import Markdown, MarkdownBlock, MarkdownTableOfContents

__all__ = ["MarkdownTableOfContents", "Markdown"]
__all__ = ["MarkdownTableOfContents", "Markdown", "MarkdownBlock"]
91 changes: 91 additions & 0 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 038cdb2

Please sign in to comment.