diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e84af5e2..ca0ad9520c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [6.1.0] - 2025-08-01 +### Fixed + +- Fixed `Markdown` and `MarkdownViewer` widgets both handling `Markdown.LinkClicked` messages https://github.com/Textualize/textual/pull/6093 + ### Added - Added `Button.flat` boolean to enable flat button style https://github.com/Textualize/textual/pull/6094 diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 397b265b50..0bc4c283de 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -6,7 +6,7 @@ from functools import partial from pathlib import Path, PurePath from typing import Callable, Iterable, Optional -from urllib.parse import unquote +from urllib.parse import unquote, urlparse from markdown_it import MarkdownIt from markdown_it.token import Token @@ -1128,8 +1128,9 @@ async def stream_markdown(self) -> None: return updater def on_markdown_link_clicked(self, event: LinkClicked) -> None: - if self._open_links: + if self._open_links and is_http_url(event.href): self.app.open_url(event.href) + event.stop() @staticmethod def sanitize_location(location: str) -> tuple[Path, str]: @@ -1628,8 +1629,9 @@ async def forward(self) -> None: self.post_message(self.NavigatorUpdated()) async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None: - message.stop() - await self.go(message.href) + if not is_http_url(message.href): + message.stop() + await self.go(message.href) def watch_show_table_of_contents(self, show_table_of_contents: bool) -> None: self.set_class(show_table_of_contents, "-show-table-of-contents") @@ -1657,3 +1659,9 @@ def _on_markdown_table_of_contents_selected( block = self.query_one(block_selector, MarkdownBlock) self.scroll_to_widget(block, top=True) message.stop() + + +def is_http_url(url: str) -> bool: + """Check if a URL is an HTTP or HTTPS URL.""" + parsed = urlparse(url) + return parsed.scheme in ("http", "https") diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index 3c83d271d2..85075b1ae7 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -12,6 +12,7 @@ TEST_MARKDOWN = """\ * [First]({{file}}#first) * [Second](#second) +* [GitHub](https://github.com/textualize/textual/) # First @@ -24,6 +25,7 @@ class MarkdownFileViewerApp(App[None]): + def __init__(self, markdown_file: Path) -> None: super().__init__() self.markdown_file = markdown_file @@ -37,14 +39,14 @@ async def on_mount(self) -> None: await self.query_one(MarkdownViewer).go(self.markdown_file) -@pytest.mark.parametrize("link", [0, 1]) +@pytest.mark.parametrize("link", [0, 1, 2]) 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)) + await pilot.click(Markdown, Offset(4, link)) class MarkdownStringViewerApp(App[None]): @@ -59,7 +61,7 @@ async def on_mount(self) -> None: self.query_one(MarkdownViewer).show_table_of_contents = False -@pytest.mark.parametrize("link", [0, 1]) +@pytest.mark.parametrize("link", [0, 1, 2]) async def test_markdown_string_viewer_anchor_link(link: int) -> None: """Test https://github.com/Textualize/textual/issues/3094 @@ -70,7 +72,7 @@ async def test_markdown_string_viewer_anchor_link(link: int) -> None: # 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)) + await pilot.click(Markdown, Offset(4, link)) @pytest.mark.parametrize("text", ["Hey [[/test]]", "[i]Hey there[/i]"])