Skip to content

Commit e3bae00

Browse files
authored
Merge pull request #5990 from Textualize/append-markdown-fix
fix for table of contents when using markdown.append
2 parents c36de60 + 1e35bc3 commit e3bae00

File tree

7 files changed

+243
-35
lines changed

7 files changed

+243
-35
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [5.0.1] - 2025-07-25
9+
10+
### Fixed
11+
12+
- Fixed appending to Markdown widgets that were constructed with an existing document https://github.com/Textualize/textual/pull/5990
13+
814
## [5.0.0] - 2025-07-25
915

1016
### Added
@@ -3015,6 +3021,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
30153021
- New handler system for messages that doesn't require inheritance
30163022
- Improved traceback handling
30173023

3024+
[5.0.1]: https://github.com/Textualize/textual/compare/v5.0.0...v5.0.1
30183025
[5.0.0]: https://github.com/Textualize/textual/compare/v4.1.0...v5.0.0
30193026
[4.0.0]: https://github.com/Textualize/textual/compare/v3.7.1...v4.0.0
30203027
[3.7.1]: https://github.com/Textualize/textual/compare/v3.7.0...v3.7.1

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "5.0.0"
3+
version = "5.0.1"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/widgets/_footer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ def on_mount(self) -> None:
271271

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

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

src/textual/widgets/_markdown.py

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ def close_tag() -> None:
354354
class MarkdownHeader(MarkdownBlock):
355355
"""Base class for a Markdown header."""
356356

357+
LEVEL = 0
358+
357359
DEFAULT_CSS = """
358360
MarkdownHeader {
359361
color: $text;
@@ -366,6 +368,8 @@ class MarkdownHeader(MarkdownBlock):
366368
class MarkdownH1(MarkdownHeader):
367369
"""An H1 Markdown header."""
368370

371+
LEVEL = 1
372+
369373
DEFAULT_CSS = """
370374
MarkdownH1 {
371375
content-align: center middle;
@@ -379,6 +383,8 @@ class MarkdownH1(MarkdownHeader):
379383
class MarkdownH2(MarkdownHeader):
380384
"""An H2 Markdown header."""
381385

386+
LEVEL = 2
387+
382388
DEFAULT_CSS = """
383389
MarkdownH2 {
384390
color: $markdown-h2-color;
@@ -391,6 +397,8 @@ class MarkdownH2(MarkdownHeader):
391397
class MarkdownH3(MarkdownHeader):
392398
"""An H3 Markdown header."""
393399

400+
LEVEL = 3
401+
394402
DEFAULT_CSS = """
395403
MarkdownH3 {
396404
color: $markdown-h3-color;
@@ -405,6 +413,8 @@ class MarkdownH3(MarkdownHeader):
405413
class MarkdownH4(MarkdownHeader):
406414
"""An H4 Markdown header."""
407415

416+
LEVEL = 4
417+
408418
DEFAULT_CSS = """
409419
MarkdownH4 {
410420
color: $markdown-h4-color;
@@ -418,6 +428,8 @@ class MarkdownH4(MarkdownHeader):
418428
class MarkdownH5(MarkdownHeader):
419429
"""An H5 Markdown header."""
420430

431+
LEVEL = 5
432+
421433
DEFAULT_CSS = """
422434
MarkdownH5 {
423435
color: $markdown-h5-color;
@@ -431,6 +443,8 @@ class MarkdownH5(MarkdownHeader):
431443
class MarkdownH6(MarkdownHeader):
432444
"""An H6 Markdown header."""
433445

446+
LEVEL = 6
447+
434448
DEFAULT_CSS = """
435449
MarkdownH6 {
436450
color: $markdown-h6-color;
@@ -574,7 +588,7 @@ def compose(self) -> ComposeResult:
574588
bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1)
575589
yield Horizontal(bullet, Vertical(*block._blocks))
576590

577-
# self._blocks.clear()
591+
self._blocks.clear()
578592

579593

580594
class MarkdownTableCellContents(Static):
@@ -974,11 +988,21 @@ def __init__(
974988
self._initial_markdown: str | None = markdown
975989
self._markdown = ""
976990
self._parser_factory = parser_factory
977-
self._table_of_contents: TableOfContentsType = []
991+
self._table_of_contents: TableOfContentsType | None = None
978992
self._open_links = open_links
979993
self._last_parsed_line = 0
980994
self._theme = ""
981995

996+
@property
997+
def table_of_contents(self) -> TableOfContentsType:
998+
"""The document's table of contents."""
999+
if self._table_of_contents is None:
1000+
self._table_of_contents = [
1001+
(header.LEVEL, header._content.plain, header.id)
1002+
for header in self.query_children(MarkdownHeader)
1003+
]
1004+
return self._table_of_contents
1005+
9821006
class TableOfContentsUpdated(Message):
9831007
"""The table of contents was updated."""
9841008

@@ -1182,16 +1206,11 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None:
11821206
"""
11831207
return None
11841208

1185-
def _parse_markdown(
1186-
self,
1187-
tokens: Iterable[Token],
1188-
table_of_contents: TableOfContentsType,
1189-
) -> Iterable[MarkdownBlock]:
1209+
def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]:
11901210
"""Create a stream of MarkdownBlock widgets from markdown.
11911211
11921212
Args:
11931213
tokens: List of tokens.
1194-
table_of_contents: List to store table of contents.
11951214
11961215
Yields:
11971216
Widgets for mounting.
@@ -1251,18 +1270,17 @@ def _parse_markdown(
12511270
elif token_type.endswith("_close"):
12521271
block = stack.pop()
12531272
if token.type == "heading_close":
1254-
heading = block._content.plain
1255-
level = int(token.tag[1:])
1256-
block.id = f"{slug(heading)}-{len(table_of_contents) + 1}"
1257-
table_of_contents.append((level, heading, block.id))
1273+
block.id = f"heading-{slug(block._content.plain)}-{id(block)}"
12581274
if stack:
12591275
stack[-1]._blocks.append(block)
12601276
else:
12611277
yield block
12621278
elif token_type == "inline":
12631279
stack[-1].build_from_token(token)
12641280
elif token_type in ("fence", "code_block"):
1265-
fence = get_block_class(token_type)(self, token, token.content.rstrip())
1281+
fence_class = get_block_class(token_type)
1282+
assert issubclass(fence_class, MarkdownFence)
1283+
fence = fence_class(self, token, token.content.rstrip())
12661284
if stack:
12671285
stack[-1]._blocks.append(fence)
12681286
else:
@@ -1276,13 +1294,21 @@ def _parse_markdown(
12761294
yield external
12771295

12781296
def _build_from_source(self, markdown: str) -> list[MarkdownBlock]:
1297+
"""Build blocks from markdown source.
1298+
1299+
Args:
1300+
markdown: A Markdown document, or partial document.
1301+
1302+
Returns:
1303+
A list of MarkdownBlock instances.
1304+
"""
12791305
parser = (
12801306
MarkdownIt("gfm-like")
12811307
if self._parser_factory is None
12821308
else self._parser_factory()
12831309
)
12841310
tokens = parser.parse(markdown)
1285-
return list(self._parse_markdown(tokens, []))
1311+
return list(self._parse_markdown(tokens))
12861312

12871313
def update(self, markdown: str) -> AwaitComplete:
12881314
"""Update the document with new Markdown.
@@ -1300,9 +1326,9 @@ def update(self, markdown: str) -> AwaitComplete:
13001326
else self._parser_factory()
13011327
)
13021328

1303-
table_of_contents: TableOfContentsType = []
13041329
markdown_block = self.query("MarkdownBlock")
13051330
self._markdown = markdown
1331+
self._table_of_contents = None
13061332

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

1336-
for block in self._parse_markdown(tokens, table_of_contents):
1362+
for block in self._parse_markdown(tokens):
13371363
batch.append(block)
13381364
if len(batch) == BATCH_SIZE:
13391365
await mount_batch(batch)
@@ -1343,13 +1369,13 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None:
13431369
if not removed:
13441370
await markdown_block.remove()
13451371

1346-
if table_of_contents != self._table_of_contents:
1347-
self._table_of_contents = table_of_contents
1348-
self.post_message(
1349-
Markdown.TableOfContentsUpdated(
1350-
self, self._table_of_contents
1351-
).set_sender(self)
1352-
)
1372+
lines = markdown.splitlines()
1373+
self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0)
1374+
self.post_message(
1375+
Markdown.TableOfContentsUpdated(
1376+
self, self.table_of_contents
1377+
).set_sender(self)
1378+
)
13531379

13541380
return AwaitComplete(await_update())
13551381

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

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

1390-
new_blocks = list(self._parse_markdown(tokens, table_of_contents))
1415+
new_blocks = list(self._parse_markdown(tokens))
13911416
for block in new_blocks:
13921417
start, end = block.source_range
13931418
block.source_range = (
@@ -1409,12 +1434,13 @@ async def await_append() -> None:
14091434
if new_blocks:
14101435
await self.mount_all(new_blocks)
14111436

1412-
self._table_of_contents = table_of_contents
1413-
self.post_message(
1414-
Markdown.TableOfContentsUpdated(
1415-
self, self._table_of_contents
1416-
).set_sender(self)
1417-
)
1437+
if any(isinstance(block, MarkdownHeader) for block in new_blocks):
1438+
self._table_of_contents = None
1439+
self.post_message(
1440+
Markdown.TableOfContentsUpdated(
1441+
self, self.table_of_contents
1442+
).set_sender(self)
1443+
)
14181444

14191445
return AwaitComplete(await_append())
14201446

tests/footer/test_footer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def action_app_binding(self) -> None:
5252
async with app.run_test() as pilot:
5353
await pilot.pause()
5454
assert app_binding_count == 0
55+
await app.wait_for_refresh()
5556
await pilot.click("Footer", offset=(1, 0))
5657
assert app_binding_count == 1
5758
await pilot.click("Footer")

0 commit comments

Comments
 (0)