Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [5.0.1] - 2025-07-25

### Fixed

- Fixed appending to Markdown widgets that were constructed with an existing document https://github.com/Textualize/textual/pull/5990

## [5.0.0] - 2025-07-25

### Added
Expand Down Expand Up @@ -3012,6 +3018,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[5.0.1]: https://github.com/Textualize/textual/compare/v5.0.0...v5.0.1
[5.0.0]: https://github.com/Textualize/textual/compare/v4.1.0...v5.0.0
[4.0.0]: https://github.com/Textualize/textual/compare/v3.7.1...v4.0.0
[3.7.1]: https://github.com/Textualize/textual/compare/v3.7.0...v3.7.1
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "5.0.0"
version = "5.0.1"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"
Expand Down
2 changes: 1 addition & 1 deletion src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ def on_mount(self) -> None:

def bindings_changed(screen: Screen) -> None:
"""Update bindings after a short delay to avoid flicker."""
self.set_timer(1 / 20, lambda: self.bindings_changed(screen))
self.call_after_refresh(self.bindings_changed, screen)

self.screen.bindings_updated_signal.subscribe(self, bindings_changed)

Expand Down
88 changes: 57 additions & 31 deletions src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ def close_tag() -> None:
class MarkdownHeader(MarkdownBlock):
"""Base class for a Markdown header."""

LEVEL = 0

DEFAULT_CSS = """
MarkdownHeader {
color: $text;
Expand All @@ -366,6 +368,8 @@ class MarkdownHeader(MarkdownBlock):
class MarkdownH1(MarkdownHeader):
"""An H1 Markdown header."""

LEVEL = 1

DEFAULT_CSS = """
MarkdownH1 {
content-align: center middle;
Expand All @@ -379,6 +383,8 @@ class MarkdownH1(MarkdownHeader):
class MarkdownH2(MarkdownHeader):
"""An H2 Markdown header."""

LEVEL = 2

DEFAULT_CSS = """
MarkdownH2 {
color: $markdown-h2-color;
Expand All @@ -391,6 +397,8 @@ class MarkdownH2(MarkdownHeader):
class MarkdownH3(MarkdownHeader):
"""An H3 Markdown header."""

LEVEL = 3

DEFAULT_CSS = """
MarkdownH3 {
color: $markdown-h3-color;
Expand All @@ -405,6 +413,8 @@ class MarkdownH3(MarkdownHeader):
class MarkdownH4(MarkdownHeader):
"""An H4 Markdown header."""

LEVEL = 4

DEFAULT_CSS = """
MarkdownH4 {
color: $markdown-h4-color;
Expand All @@ -418,6 +428,8 @@ class MarkdownH4(MarkdownHeader):
class MarkdownH5(MarkdownHeader):
"""An H5 Markdown header."""

LEVEL = 5

DEFAULT_CSS = """
MarkdownH5 {
color: $markdown-h5-color;
Expand All @@ -431,6 +443,8 @@ class MarkdownH5(MarkdownHeader):
class MarkdownH6(MarkdownHeader):
"""An H6 Markdown header."""

LEVEL = 6

DEFAULT_CSS = """
MarkdownH6 {
color: $markdown-h6-color;
Expand Down Expand Up @@ -574,7 +588,7 @@ def compose(self) -> ComposeResult:
bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1)
yield Horizontal(bullet, Vertical(*block._blocks))

# self._blocks.clear()
self._blocks.clear()


class MarkdownTableCellContents(Static):
Expand Down Expand Up @@ -974,11 +988,21 @@ def __init__(
self._initial_markdown: str | None = markdown
self._markdown = ""
self._parser_factory = parser_factory
self._table_of_contents: TableOfContentsType = []
self._table_of_contents: TableOfContentsType | None = None
self._open_links = open_links
self._last_parsed_line = 0
self._theme = ""

@property
def table_of_contents(self) -> TableOfContentsType:
"""The document's table of contents."""
if self._table_of_contents is None:
self._table_of_contents = [
(header.LEVEL, header._content.plain, header.id)
for header in self.query_children(MarkdownHeader)
]
return self._table_of_contents

class TableOfContentsUpdated(Message):
"""The table of contents was updated."""

Expand Down Expand Up @@ -1182,16 +1206,11 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None:
"""
return None

def _parse_markdown(
self,
tokens: Iterable[Token],
table_of_contents: TableOfContentsType,
) -> Iterable[MarkdownBlock]:
def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]:
"""Create a stream of MarkdownBlock widgets from markdown.

Args:
tokens: List of tokens.
table_of_contents: List to store table of contents.

Yields:
Widgets for mounting.
Expand Down Expand Up @@ -1251,18 +1270,17 @@ def _parse_markdown(
elif token_type.endswith("_close"):
block = stack.pop()
if token.type == "heading_close":
heading = block._content.plain
level = int(token.tag[1:])
block.id = f"{slug(heading)}-{len(table_of_contents) + 1}"
table_of_contents.append((level, heading, block.id))
block.id = f"heading-{slug(block._content.plain)}-{id(block)}"
if stack:
stack[-1]._blocks.append(block)
else:
yield block
elif token_type == "inline":
stack[-1].build_from_token(token)
elif token_type in ("fence", "code_block"):
fence = get_block_class(token_type)(self, token, token.content.rstrip())
fence_class = get_block_class(token_type)
assert issubclass(fence_class, MarkdownFence)
fence = fence_class(self, token, token.content.rstrip())
if stack:
stack[-1]._blocks.append(fence)
else:
Expand All @@ -1276,13 +1294,21 @@ def _parse_markdown(
yield external

def _build_from_source(self, markdown: str) -> list[MarkdownBlock]:
"""Build blocks from markdown source.

Args:
markdown: A Markdown document, or partial document.

Returns:
A list of MarkdownBlock instances.
"""
parser = (
MarkdownIt("gfm-like")
if self._parser_factory is None
else self._parser_factory()
)
tokens = parser.parse(markdown)
return list(self._parse_markdown(tokens, []))
return list(self._parse_markdown(tokens))

def update(self, markdown: str) -> AwaitComplete:
"""Update the document with new Markdown.
Expand All @@ -1300,9 +1326,9 @@ def update(self, markdown: str) -> AwaitComplete:
else self._parser_factory()
)

table_of_contents: TableOfContentsType = []
markdown_block = self.query("MarkdownBlock")
self._markdown = markdown
self._table_of_contents = None

async def await_update() -> None:
"""Update in batches."""
Expand Down Expand Up @@ -1333,7 +1359,7 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None:
await self.mount_all(batch)
removed = True

for block in self._parse_markdown(tokens, table_of_contents):
for block in self._parse_markdown(tokens):
batch.append(block)
if len(batch) == BATCH_SIZE:
await mount_batch(batch)
Expand All @@ -1343,13 +1369,13 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None:
if not removed:
await markdown_block.remove()

if table_of_contents != self._table_of_contents:
self._table_of_contents = table_of_contents
self.post_message(
Markdown.TableOfContentsUpdated(
self, self._table_of_contents
).set_sender(self)
)
lines = markdown.splitlines()
self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0)
self.post_message(
Markdown.TableOfContentsUpdated(
self, self.table_of_contents
).set_sender(self)
)

return AwaitComplete(await_update())

Expand All @@ -1368,7 +1394,6 @@ def append(self, markdown: str) -> AwaitComplete:
else self._parser_factory()
)

table_of_contents: TableOfContentsType = []
self._markdown = self.source + markdown
updated_source = "".join(
self._markdown.splitlines(keepends=True)[self._last_parsed_line :]
Expand All @@ -1387,7 +1412,7 @@ async def await_append() -> None:
self._last_parsed_line += token.map[0]
break

new_blocks = list(self._parse_markdown(tokens, table_of_contents))
new_blocks = list(self._parse_markdown(tokens))
for block in new_blocks:
start, end = block.source_range
block.source_range = (
Expand All @@ -1409,12 +1434,13 @@ async def await_append() -> None:
if new_blocks:
await self.mount_all(new_blocks)

self._table_of_contents = table_of_contents
self.post_message(
Markdown.TableOfContentsUpdated(
self, self._table_of_contents
).set_sender(self)
)
if any(isinstance(block, MarkdownHeader) for block in new_blocks):
self._table_of_contents = None
self.post_message(
Markdown.TableOfContentsUpdated(
self, self.table_of_contents
).set_sender(self)
)

return AwaitComplete(await_append())

Expand Down
1 change: 1 addition & 0 deletions tests/footer/test_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def action_app_binding(self) -> None:
async with app.run_test() as pilot:
await pilot.pause()
assert app_binding_count == 0
await app.wait_for_refresh()
await pilot.click("Footer", offset=(1, 0))
assert app_binding_count == 1
await pilot.click("Footer")
Expand Down
Loading
Loading