Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow UI to update while markdown.update is in progress #4589

Merged
merged 3 commits into from
Jun 3, 2024
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
212 changes: 132 additions & 80 deletions src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import re
from functools import partial
from pathlib import Path, PurePath
from typing import Callable, Iterable, Optional

Expand Down Expand Up @@ -259,8 +261,7 @@ class MarkdownH2(MarkdownHeader):
MarkdownH2 {
text-style: underline;
color: $success;
&:light {color: $primary;}

&:light {color: $primary;}
}
"""

Expand All @@ -270,12 +271,11 @@ class MarkdownH3(MarkdownHeader):

DEFAULT_CSS = """
MarkdownH3 {

text-style: bold;
color: $success;
&:light {color: $primary;}
margin: 1 0;
width: auto;
&:light {color: $primary;}
}
"""

Expand Down Expand Up @@ -669,6 +669,7 @@ class Markdown(Widget):
margin: 0 2 1 2;
layout: vertical;
color: $text;
overflow-y: auto;
}
.em {
text-style: italic;
Expand Down Expand Up @@ -857,7 +858,10 @@ async def load(self, path: Path) -> None:
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"))
data = await asyncio.get_running_loop().run_in_executor(
None, partial(path.read_text, encoding="utf-8")
)
await self.update(data)
if anchor:
self.goto_anchor(anchor)

Expand All @@ -881,93 +885,141 @@ def update(self, markdown: str) -> AwaitComplete:
Returns:
An optionally awaitable object. Await this to ensure that all children have been mounted.
"""
output: list[MarkdownBlock] = []
stack: list[MarkdownBlock] = []
parser = (
MarkdownIt("gfm-like")
if self._parser_factory is None
else self._parser_factory()
)

block_id: int = 0
self._table_of_contents = []

for token in parser.parse(markdown):
if token.type == "heading_open":
block_id += 1
stack.append(HEADINGS[token.tag](self, id=f"block{block_id}"))
elif token.type == "hr":
output.append(MarkdownHorizontalRule(self))
elif token.type == "paragraph_open":
stack.append(MarkdownParagraph(self))
elif token.type == "blockquote_open":
stack.append(MarkdownBlockQuote(self))
elif token.type == "bullet_list_open":
stack.append(MarkdownBulletList(self))
elif token.type == "ordered_list_open":
stack.append(MarkdownOrderedList(self))
elif token.type == "list_item_open":
if token.info:
stack.append(MarkdownOrderedListItem(self, token.info))
else:
item_count = sum(
1
for block in stack
if isinstance(block, MarkdownUnorderedListItem)
)
stack.append(
MarkdownUnorderedListItem(
self,
self.BULLETS[item_count % len(self.BULLETS)],
)
)
table_of_contents = []

def parse_markdown(tokens) -> Iterable[MarkdownBlock]:
"""Create a stream of MarkdownBlock widgets from markdown.

elif token.type == "table_open":
stack.append(MarkdownTable(self))
elif token.type == "tbody_open":
stack.append(MarkdownTBody(self))
elif token.type == "thead_open":
stack.append(MarkdownTHead(self))
elif token.type == "tr_open":
stack.append(MarkdownTR(self))
elif token.type == "th_open":
stack.append(MarkdownTH(self))
elif token.type == "td_open":
stack.append(MarkdownTD(self))
elif token.type.endswith("_close"):
block = stack.pop()
if token.type == "heading_close":
heading = block._text.plain
level = int(token.tag[1:])
self._table_of_contents.append((level, heading, block.id))
if stack:
stack[-1]._blocks.append(block)
Args:
tokens: List of tokens

Yields:
Widgets for mounting.
"""

stack: list[MarkdownBlock] = []
stack_append = stack.append
block_id: int = 0

for token in tokens:
token_type = token.type
if token_type == "heading_open":
block_id += 1
stack_append(HEADINGS[token.tag](self, id=f"block{block_id}"))
elif token_type == "hr":
yield MarkdownHorizontalRule(self)
elif token_type == "paragraph_open":
stack_append(MarkdownParagraph(self))
elif token_type == "blockquote_open":
stack_append(MarkdownBlockQuote(self))
elif token_type == "bullet_list_open":
stack_append(MarkdownBulletList(self))
elif token_type == "ordered_list_open":
stack_append(MarkdownOrderedList(self))
elif token_type == "list_item_open":
if token.info:
stack_append(MarkdownOrderedListItem(self, token.info))
else:
item_count = sum(
1
for block in stack
if isinstance(block, MarkdownUnorderedListItem)
)
stack_append(
MarkdownUnorderedListItem(
self,
self.BULLETS[item_count % len(self.BULLETS)],
)
)
elif token_type == "table_open":
stack_append(MarkdownTable(self))
elif token_type == "tbody_open":
stack_append(MarkdownTBody(self))
elif token_type == "thead_open":
stack_append(MarkdownTHead(self))
elif token_type == "tr_open":
stack_append(MarkdownTR(self))
elif token_type == "th_open":
stack_append(MarkdownTH(self))
elif token_type == "td_open":
stack_append(MarkdownTD(self))
elif token_type.endswith("_close"):
block = stack.pop()
if token.type == "heading_close":
heading = block._text.plain
level = int(token.tag[1:])
table_of_contents.append((level, heading, block.id))
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 = MarkdownFence(self, token.content.rstrip(), token.info)
if stack:
stack[-1]._blocks.append(fence)
else:
yield fence
else:
output.append(block)
elif token.type == "inline":
stack[-1].build_from_token(token)
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, self._table_of_contents).set_sender(
self
)
)
external = self.unhandled_token(token)
if external is not None:
if stack:
stack[-1]._blocks.append(external)
else:
yield external

markdown_block = self.query("MarkdownBlock")

async def await_update() -> None:
"""Update in a single batch."""
"""Update in batches."""
BATCH_SIZE = 200
batch: list[MarkdownBlock] = []
tokens = await asyncio.get_running_loop().run_in_executor(
None, parser.parse, markdown
)

with self.app.batch_update():
await markdown_block.remove()
await self.mount_all(output)
# Lock so that you can't update with more than one document simultaneously
async with self.lock:
# Remove existing blocks for the first batch only
removed: bool = False

async def mount_batch(batch: list[MarkdownBlock]) -> None:
"""Mount a single match of blocks.

Args:
batch: A list of blocks to mount.
"""
nonlocal removed
if removed:
await self.mount_all(batch)
else:
with self.app.batch_update():
await markdown_block.remove()
await self.mount_all(batch)
removed = True

for block in parse_markdown(tokens):
batch.append(block)
if len(batch) == BATCH_SIZE:
await mount_batch(batch)
batch.clear()
if batch:
await mount_batch(batch)

self._table_of_contents = table_of_contents

self.post_message(
Markdown.TableOfContentsUpdated(
self, self._table_of_contents
).set_sender(self)
)

return AwaitComplete(await_update())

Expand Down
3 changes: 1 addition & 2 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ async def test_update_of_document_posts_table_of_content_update_message() -> Non
messages: list[str] = []

class TableOfContentApp(App[None]):

def compose(self) -> ComposeResult:
yield Markdown("# One\n\n#Two\n")

Expand All @@ -162,6 +161,6 @@ def log_table_of_content_update(

async with TableOfContentApp().run_test() as pilot:
assert messages == ["TableOfContentsUpdated"]
pilot.app.query_one(Markdown).update("")
await pilot.app.query_one(Markdown).update("")
await pilot.pause()
assert messages == ["TableOfContentsUpdated", "TableOfContentsUpdated"]
Loading