Merge pull request #3244 from davep/markdown-anchor-jump

Fix Markdown anchor load crash, and support going to an heading on document load
This commit is contained in:
Dave Pearson
2023-09-12 12:38:47 +01:00
committed by GitHub
4 changed files with 126 additions and 4 deletions

View File

@@ -23,12 +23,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
- Fixed a crash when removing an option from an `OptionList` while the mouse is hovering over the last option https://github.com/Textualize/textual/issues/3270 - Fixed a crash when removing an option from an `OptionList` while the mouse is hovering over the last option https://github.com/Textualize/textual/issues/3270
- Fixed a crash in `MarkdownViewer` when clicking on a link that contains an anchor https://github.com/Textualize/textual/issues/3094
### Changed ### Changed
- Widget.notify and App.notify are now thread-safe https://github.com/Textualize/textual/pull/3275 - Widget.notify and App.notify are now thread-safe https://github.com/Textualize/textual/pull/3275
- Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275 - Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275
- App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275 - App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275
- `Markdown.load` will now attempt to scroll to a related heading if an anchor is provided https://github.com/Textualize/textual/pull/3244
## [0.36.0] - 2023-09-05 ## [0.36.0] - 2023-09-05

View File

@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from sys import argv
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.reactive import var from textual.reactive import var
@@ -44,4 +45,6 @@ class MarkdownApp(App):
if __name__ == "__main__": if __name__ == "__main__":
app = MarkdownApp() app = MarkdownApp()
if len(argv) > 1 and Path(argv[1]).exists():
app.path = Path(argv[1])
app.run() app.run()

View File

@@ -12,6 +12,7 @@ from rich.table import Table
from rich.text import Text from rich.text import Text
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
from .._slug import TrackedSlugs
from ..app import ComposeResult from ..app import ComposeResult
from ..containers import Horizontal, Vertical, VerticalScroll from ..containers import Horizontal, Vertical, VerticalScroll
from ..events import Mount from ..events import Mount
@@ -50,6 +51,10 @@ class Navigator:
Returns: Returns:
New location. New location.
""" """
location, anchor = Markdown.sanitize_location(str(path))
if location == Path(".") and anchor:
current_file, _ = Markdown.sanitize_location(str(self.location))
path = f"{current_file}#{anchor}"
new_path = self.location.parent / Path(path) new_path = self.location.parent / Path(path)
self.stack = self.stack[: self.index + 1] self.stack = self.stack[: self.index + 1]
new_path = new_path.absolute() new_path = new_path.absolute()
@@ -564,6 +569,7 @@ class Markdown(Widget):
super().__init__(name=name, id=id, classes=classes) super().__init__(name=name, id=id, classes=classes)
self._markdown = markdown self._markdown = markdown
self._parser_factory = parser_factory self._parser_factory = parser_factory
self._table_of_contents: TableOfContentsType | None = None
class TableOfContentsUpdated(Message): class TableOfContentsUpdated(Message):
"""The table of contents was updated.""" """The table of contents was updated."""
@@ -628,6 +634,42 @@ class Markdown(Widget):
if self._markdown is not None: if self._markdown is not None:
self.update(self._markdown) self.update(self._markdown)
@staticmethod
def sanitize_location(location: str) -> tuple[Path, str]:
"""Given a location, break out the path and any anchor.
Args:
location: The location to sanitize.
Returns:
A tuple of the path to the location cleaned of any anchor, plus
the anchor (or an empty string if none was found).
"""
location, _, anchor = location.partition("#")
return Path(location), anchor
def goto_anchor(self, anchor: str) -> None:
"""Try and find the given anchor in the current document.
Args:
anchor: The anchor to try and find.
Note:
The anchor is found by looking at all of the headings in the
document and finding the first one whose slug matches the
anchor.
Note that the slugging method used is similar to that found on
GitHub.
"""
if not self._table_of_contents or not isinstance(self.parent, Widget):
return
unique = TrackedSlugs()
for _, title, header_id in self._table_of_contents:
if unique.slug(title) == anchor:
self.parent.scroll_to_widget(self.query_one(f"#{header_id}"), top=True)
return
async def load(self, path: Path) -> None: async def load(self, path: Path) -> None:
"""Load a new Markdown document. """Load a new Markdown document.
@@ -641,7 +683,10 @@ class Markdown(Widget):
The exceptions that can be raised by this method are all of The exceptions that can be raised by this method are all of
those that can be raised by calling [`Path.read_text`][pathlib.Path.read_text]. those that can be raised by calling [`Path.read_text`][pathlib.Path.read_text].
""" """
path, anchor = self.sanitize_location(str(path))
await self.update(path.read_text(encoding="utf-8")) await self.update(path.read_text(encoding="utf-8"))
if anchor:
self.goto_anchor(anchor)
def unhandled_token(self, token: Token) -> MarkdownBlock | None: def unhandled_token(self, token: Token) -> MarkdownBlock | None:
"""Process an unhandled token. """Process an unhandled token.
@@ -672,7 +717,7 @@ class Markdown(Widget):
) )
block_id: int = 0 block_id: int = 0
table_of_contents: TableOfContentsType = [] self._table_of_contents = []
for token in parser.parse(markdown): for token in parser.parse(markdown):
if token.type == "heading_open": if token.type == "heading_open":
@@ -721,7 +766,7 @@ class Markdown(Widget):
if token.type == "heading_close": if token.type == "heading_close":
heading = block._text.plain heading = block._text.plain
level = int(token.tag[1:]) level = int(token.tag[1:])
table_of_contents.append((level, heading, block.id)) self._table_of_contents.append((level, heading, block.id))
if stack: if stack:
stack[-1]._blocks.append(block) stack[-1]._blocks.append(block)
else: else:
@@ -801,7 +846,9 @@ class Markdown(Widget):
if external is not None: if external is not None:
(stack[-1]._blocks if stack else output).append(external) (stack[-1]._blocks if stack else output).append(external)
self.post_message(Markdown.TableOfContentsUpdated(self, table_of_contents)) self.post_message(
Markdown.TableOfContentsUpdated(self, self._table_of_contents)
)
with self.app.batch_update(): with self.app.batch_update():
self.query("MarkdownBlock").remove() self.query("MarkdownBlock").remove()
return self.mount_all(output) return self.mount_all(output)
@@ -952,7 +999,13 @@ class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True):
async def go(self, location: str | PurePath) -> None: async def go(self, location: str | PurePath) -> None:
"""Navigate to a new document path.""" """Navigate to a new document path."""
await self.document.load(self.navigator.go(location)) path, anchor = self.document.sanitize_location(str(location))
if path == Path(".") and anchor:
# We've been asked to go to an anchor but with no file specified.
self.document.goto_anchor(anchor)
else:
# We've been asked to go to a file, optionally with an anchor.
await self.document.load(self.navigator.go(location))
async def back(self) -> None: async def back(self) -> None:
"""Go back one level in the history.""" """Go back one level in the history."""

View File

@@ -0,0 +1,64 @@
from pathlib import Path
import pytest
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.widgets import Markdown, MarkdownViewer
TEST_MARKDOWN = """\
* [First]({{file}}#first)
* [Second](#second)
# First
The first.
# Second
The second.
"""
class MarkdownFileViewerApp(App[None]):
def __init__(self, markdown_file: Path) -> None:
super().__init__()
self.markdown_file = markdown_file
markdown_file.write_text(TEST_MARKDOWN.replace("{{file}}", markdown_file.name))
def compose(self) -> ComposeResult:
yield MarkdownViewer()
async def on_mount(self) -> None:
self.query_one(MarkdownViewer).show_table_of_contents = False
await self.query_one(MarkdownViewer).go(self.markdown_file)
@pytest.mark.parametrize("link", [0, 1])
async def test_markdown_file_viewer_anchor_link(tmp_path, link: int) -> None:
"""Test https://github.com/Textualize/textual/issues/3094"""
async with MarkdownFileViewerApp(Path(tmp_path) / "test.md").run_test() as pilot:
# There's not really anything to test *for* here, but the lack of an
# exception is the win (before the fix this is testing it would have
# been FileNotFoundError).
await pilot.click(Markdown, Offset(2, link))
class MarkdownStringViewerApp(App[None]):
def compose(self) -> ComposeResult:
yield MarkdownViewer(TEST_MARKDOWN.replace("{{file}}", ""))
async def on_mount(self) -> None:
self.query_one(MarkdownViewer).show_table_of_contents = False
@pytest.mark.parametrize("link", [0, 1])
async def test_markdown_string_viewer_anchor_link(link: int) -> None:
"""Test https://github.com/Textualize/textual/issues/3094
Also https://github.com/Textualize/textual/pull/3244#issuecomment-1710278718."""
async with MarkdownStringViewerApp().run_test() as pilot:
# There's not really anything to test *for* here, but the lack of an
# exception is the win (before the fix this is testing it would have
# been FileNotFoundError).
await pilot.click(Markdown, Offset(2, link))