diff --git a/CHANGELOG.md b/CHANGELOG.md
index be5ce8ce4b..9198a68dfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,9 +8,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.12.0] - Unreleased
+### Added
+
+- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832
+- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
+
### Changed
- Scrolling by page now adds to current position.
+- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832
### Removed
diff --git a/examples/dictionary.css b/examples/dictionary.css
index 6bca8b9f51..151fa019d0 100644
--- a/examples/dictionary.css
+++ b/examples/dictionary.css
@@ -8,9 +8,9 @@ Input {
}
#results {
- width: auto;
- min-height: 100%;
- padding: 0 1;
+ width: 100%;
+ height: auto;
+
}
#results-container {
diff --git a/examples/dictionary.py b/examples/dictionary.py
index 8ef47f3bbb..f0e69d8caf 100644
--- a/examples/dictionary.py
+++ b/examples/dictionary.py
@@ -7,11 +7,10 @@
except ImportError:
raise ImportError("Please install httpx with 'pip install httpx' ")
-from rich.markdown import Markdown
from textual.app import App, ComposeResult
from textual.containers import Content
-from textual.widgets import Input, Static
+from textual.widgets import Input, Markdown
class DictionaryApp(App):
@@ -21,7 +20,7 @@ class DictionaryApp(App):
def compose(self) -> ComposeResult:
yield Input(placeholder="Search for a word")
- yield Content(Static(id="results"), id="results-container")
+ yield Content(Markdown(id="results"), id="results-container")
def on_mount(self) -> None:
"""Called when app starts."""
@@ -35,7 +34,7 @@ async def on_input_changed(self, message: Input.Changed) -> None:
asyncio.create_task(self.lookup_word(message.value))
else:
# Clear the results
- self.query_one("#results", Static).update()
+ await self.query_one("#results", Markdown).update("")
async def lookup_word(self, word: str) -> None:
"""Looks up a word."""
@@ -50,7 +49,7 @@ async def lookup_word(self, word: str) -> None:
if word == self.query_one(Input).value:
markdown = self.make_word_markdown(results)
- self.query_one("#results", Static).update(Markdown(markdown))
+ await self.query_one("#results", Markdown).update(markdown)
def make_word_markdown(self, results: object) -> str:
"""Convert the results in to markdown."""
diff --git a/examples/example.md b/examples/example.md
index 83495ba2fd..e792340258 100644
--- a/examples/example.md
+++ b/examples/example.md
@@ -42,6 +42,32 @@ Two tildes indicates strikethrough, e.g. `~~cross out~~` render ~~cross out~~.
Inline code is indicated by backticks. e.g. `import this`.
+## Lists
+
+1. Lists can be ordered
+2. Lists can be unordered
+ - I must not fear.
+ - Fear is the mind-killer.
+ - Fear is the little-death that brings total obliteration.
+ - I will face my fear.
+ - I will permit it to pass over me and through me.
+ - And when it has gone past, I will turn the inner eye to see its path.
+ - Where the fear has gone there will be nothing. Only I will remain.
+
+### Longer list
+
+1. **Duke Leto I Atreides**, head of House Atreides
+2. **Lady Jessica**, Bene Gesserit and concubine of Leto, and mother of Paul and Alia
+3. **Paul Atreides**, son of Leto and Jessica
+4. **Alia Atreides**, daughter of Leto and Jessica
+5. **Gurney Halleck**, troubadour warrior of House Atreides
+6. **Thufir Hawat**, Mentat and Master of Assassins of House Atreides
+7. **Duncan Idaho**, swordmaster of House Atreides
+8. **Dr. Wellington Yueh**, Suk doctor of House Atreides
+9. **Leto**, first son of Paul and Chani who dies as a toddler
+10. **Esmar Tuek**, a smuggler on Arrakis
+11. **Staban Tuek**, son of Esmar
+
## Fences
Fenced code blocks are introduced with three back-ticks and the optional parser. Here we are rendering the code in a sub-widget with syntax highlighting and indent guides.
diff --git a/src/textual/app.py b/src/textual/app.py
index ce9ad5d0ea..6f6fa31fbf 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -11,7 +11,12 @@
import warnings
from asyncio import Task
from concurrent.futures import Future
-from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout
+from contextlib import (
+ asynccontextmanager,
+ contextmanager,
+ redirect_stderr,
+ redirect_stdout,
+)
from datetime import datetime
from functools import partial
from pathlib import Path, PurePath
@@ -22,6 +27,7 @@
Any,
Awaitable,
Callable,
+ Generator,
Generic,
Iterable,
List,
@@ -416,6 +422,7 @@ def __init__(
self._screenshot: str | None = None
self._dom_lock = asyncio.Lock()
self._dom_ready = False
+ self._batch_count = 0
self.set_class(self.dark, "-dark-mode")
@property
@@ -431,6 +438,30 @@ def children(self) -> Sequence["Widget"]:
except ScreenError:
return ()
+ @contextmanager
+ def batch_update(self) -> Generator[None, None, None]:
+ """Suspend all repaints until the end of the batch."""
+ self._begin_batch()
+ try:
+ yield
+ finally:
+ self._end_batch()
+
+ def _begin_batch(self) -> None:
+ """Begin a batch update."""
+ self._batch_count += 1
+
+ def _end_batch(self) -> None:
+ """End a batch update."""
+ self._batch_count -= 1
+ assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
+ if not self._batch_count:
+ try:
+ self.screen.check_idle()
+ except ScreenStackError:
+ pass
+ self.check_idle()
+
def animate(
self,
attribute: str,
@@ -1509,28 +1540,29 @@ async def invoke_ready_callback() -> None:
if inspect.isawaitable(ready_result):
await ready_result
- try:
+ with self.batch_update():
try:
- await self._dispatch_message(events.Compose(sender=self))
- await self._dispatch_message(events.Mount(sender=self))
- finally:
- self._mounted_event.set()
+ try:
+ await self._dispatch_message(events.Compose(sender=self))
+ await self._dispatch_message(events.Mount(sender=self))
+ finally:
+ self._mounted_event.set()
- Reactive._initialize_object(self)
+ Reactive._initialize_object(self)
- self.stylesheet.update(self)
- self.refresh()
+ self.stylesheet.update(self)
+ self.refresh()
- await self.animator.start()
+ await self.animator.start()
- except Exception:
- await self.animator.stop()
- raise
+ except Exception:
+ await self.animator.stop()
+ raise
- finally:
- self._running = True
- await self._ready()
- await invoke_ready_callback()
+ finally:
+ self._running = True
+ await self._ready()
+ await invoke_ready_callback()
try:
await self._process_messages_loop()
@@ -1616,11 +1648,12 @@ async def _on_compose(self) -> None:
raise TypeError(
f"{self!r} compose() returned an invalid response; {error}"
) from error
+
await self.mount_all(widgets)
def _on_idle(self) -> None:
"""Perform actions when there are no messages in the queue."""
- if self._require_stylesheet_update:
+ if self._require_stylesheet_update and not self._batch_count:
nodes: set[DOMNode] = {
child
for node in self._require_stylesheet_update
@@ -1801,6 +1834,7 @@ async def _shutdown(self) -> None:
self._writer_thread.stop()
async def _on_exit_app(self) -> None:
+ self._begin_batch() # Prevent repaint / layout while shutting down
await self._message_queue.put(None)
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py
index 1cbc04f9c8..fb11f059a4 100644
--- a/src/textual/cli/previews/colors.py
+++ b/src/textual/cli/previews/colors.py
@@ -72,7 +72,6 @@ def update_view(self) -> None:
content.mount(ColorsView())
def on_button_pressed(self, event: Button.Pressed) -> None:
- self.bell()
self.query(ColorGroup).remove_class("-active")
group = self.query_one(f"#group-{event.button.id}", ColorGroup)
group.add_class("-active")
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 3fdb09fbfa..90833444ee 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -354,7 +354,7 @@ async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
- if self.is_current:
+ if not self.app._batch_count and self.is_current:
async with self.app._dom_lock:
if self.is_current:
if self._layout_required:
diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py
index 50812dd9d2..e2178ca2d8 100644
--- a/src/textual/widgets/_markdown.py
+++ b/src/textual/widgets/_markdown.py
@@ -10,7 +10,7 @@
from typing_extensions import TypeAlias
from ..app import ComposeResult
-from ..containers import Vertical
+from ..containers import Horizontal, Vertical
from ..message import Message
from ..reactive import reactive, var
from ..widget import Widget
@@ -198,6 +198,19 @@ class MarkdownH6(MarkdownHeader):
"""
+class MarkdownHorizontalRule(MarkdownBlock):
+ """A horizontal rule."""
+
+ DEFAULT_CSS = """
+ MarkdownHorizontalRule {
+ border-bottom: heavy $primary;
+ height: 1;
+ padding-top: 1;
+ margin-bottom: 1;
+ }
+ """
+
+
class MarkdownParagraph(MarkdownBlock):
"""A paragraph Markdown block."""
@@ -225,37 +238,83 @@ class MarkdownBlockQuote(MarkdownBlock):
"""
-class MarkdownBulletList(MarkdownBlock):
+class MarkdownList(MarkdownBlock):
+ DEFAULT_CSS = """
+
+ MarkdownList {
+ width: 1fr;
+ }
+
+ MarkdownList MarkdownList {
+ margin: 0;
+ padding-top: 0;
+ }
+ """
+
+
+class MarkdownBulletList(MarkdownList):
"""A Bullet list Markdown block."""
DEFAULT_CSS = """
MarkdownBulletList {
- margin: 0;
+ margin: 0 0 1 0;
padding: 0 0;
}
- MarkdownBulletList MarkdownBulletList {
- margin: 0;
- padding-top: 0;
+ MarkdownBulletList Horizontal {
+ height: auto;
+ width: 1fr;
+ }
+
+ MarkdownBulletList Vertical {
+ height: auto;
+ width: 1fr;
}
"""
+ def compose(self) -> ComposeResult:
+ for block in self._blocks:
+ if isinstance(block, MarkdownListItem):
+ bullet = MarkdownBullet()
+ bullet.symbol = block.bullet
+ yield Horizontal(bullet, Vertical(*block._blocks))
+ self._blocks.clear()
-class MarkdownOrderedList(MarkdownBlock):
+
+class MarkdownOrderedList(MarkdownList):
"""An ordered list Markdown block."""
DEFAULT_CSS = """
MarkdownOrderedList {
- margin: 0;
+ margin: 0 0 1 0;
padding: 0 0;
}
- Markdown OrderedList MarkdownOrderedList {
- margin: 0;
- padding-top: 0;
+ MarkdownOrderedList Horizontal {
+ height: auto;
+ width: 1fr;
+ }
+
+ MarkdownOrderedList Vertical {
+ height: auto;
+ width: 1fr;
}
"""
+ def compose(self) -> ComposeResult:
+ symbol_size = max(
+ len(block.bullet)
+ for block in self._blocks
+ if isinstance(block, MarkdownListItem)
+ )
+ for block in self._blocks:
+ if isinstance(block, MarkdownListItem):
+ bullet = MarkdownBullet()
+ bullet.symbol = block.bullet.rjust(symbol_size + 1)
+ yield Horizontal(bullet, Vertical(*block._blocks))
+
+ self._blocks.clear()
+
class MarkdownTable(MarkdownBlock):
"""A Table markdown Block."""
@@ -329,10 +388,12 @@ class MarkdownBullet(Widget):
DEFAULT_CSS = """
MarkdownBullet {
width: auto;
+ color: $success;
+ text-style: bold;
}
"""
- symbol = reactive("● ")
+ symbol = reactive("●")
"""The symbol for the bullet."""
def render(self) -> Text:
@@ -359,13 +420,13 @@ def __init__(self, bullet: str) -> None:
self.bullet = bullet
super().__init__()
- def compose(self) -> ComposeResult:
- bullet = MarkdownBullet()
- bullet.symbol = self.bullet
- yield bullet
- yield Vertical(*self._blocks)
- self._blocks.clear()
+class MarkdownOrderedListItem(MarkdownListItem):
+ pass
+
+
+class MarkdownUnorderedListItem(MarkdownListItem):
+ pass
class MarkdownFence(MarkdownBlock):
@@ -439,6 +500,8 @@ class Markdown(Widget):
"""
COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"}
+ BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "]
+
def __init__(
self,
markdown: str | None = None,
@@ -501,7 +564,7 @@ async def load(self, path: Path) -> bool:
markdown = path.read_text(encoding="utf-8")
except Exception:
return False
- await self.query("MarkdownBlock").remove()
+
await self.update(markdown)
return True
@@ -524,6 +587,8 @@ async def update(self, markdown: str) -> None:
if token.type == "heading_open":
block_id += 1
stack.append(HEADINGS[token.tag](id=f"block{block_id}"))
+ elif token.type == "hr":
+ output.append(MarkdownHorizontalRule())
elif token.type == "paragraph_open":
stack.append(MarkdownParagraph())
elif token.type == "blockquote_open":
@@ -533,9 +598,20 @@ async def update(self, markdown: str) -> None:
elif token.type == "ordered_list_open":
stack.append(MarkdownOrderedList())
elif token.type == "list_item_open":
- stack.append(
- MarkdownListItem(f"{token.info}. " if token.info else "● ")
- )
+ if token.info:
+ stack.append(MarkdownOrderedListItem(f"{token.info}. "))
+ else:
+ item_count = sum(
+ 1
+ for block in stack
+ if isinstance(block, MarkdownUnorderedListItem)
+ )
+ stack.append(
+ MarkdownUnorderedListItem(
+ self.BULLETS[item_count % len(self.BULLETS)]
+ )
+ )
+
elif token.type == "table_open":
stack.append(MarkdownTable())
elif token.type == "tbody_open":
@@ -565,6 +641,8 @@ async def update(self, markdown: str) -> None:
for child in token.children:
if child.type == "text":
content.append(child.content, style_stack[-1])
+ if child.type == "softbreak":
+ content.append(" ")
elif child.type == "code_inline":
content.append(
child.content,
@@ -627,7 +705,10 @@ async def update(self, markdown: str) -> None:
await self.post_message(
Markdown.TableOfContentsUpdated(table_of_contents, sender=self)
)
- await self.mount(*output)
+ with self.app.batch_update():
+ await self.query("MarkdownBlock").remove()
+ await self.mount(*output)
+ self.refresh(layout=True)
class MarkdownTableOfContents(Widget, can_focus_children=True):
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 088f6aba71..04ed4e7c0f 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -13219,139 +13219,140 @@
font-weight: 700;
}
- .terminal-2159695446-matrix {
+ .terminal-2166823333-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2159695446-title {
+ .terminal-2166823333-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2159695446-r1 { fill: #e1e1e1 }
- .terminal-2159695446-r2 { fill: #121212 }
- .terminal-2159695446-r3 { fill: #c5c8c6 }
- .terminal-2159695446-r4 { fill: #0053aa }
- .terminal-2159695446-r5 { fill: #dde8f3;font-weight: bold }
- .terminal-2159695446-r6 { fill: #939393;font-weight: bold }
- .terminal-2159695446-r7 { fill: #24292f }
- .terminal-2159695446-r8 { fill: #e2e3e3;font-weight: bold }
- .terminal-2159695446-r9 { fill: #e1e1e1;font-style: italic; }
- .terminal-2159695446-r10 { fill: #e1e1e1;font-weight: bold }
+ .terminal-2166823333-r1 { fill: #e1e1e1 }
+ .terminal-2166823333-r2 { fill: #121212 }
+ .terminal-2166823333-r3 { fill: #c5c8c6 }
+ .terminal-2166823333-r4 { fill: #0053aa }
+ .terminal-2166823333-r5 { fill: #dde8f3;font-weight: bold }
+ .terminal-2166823333-r6 { fill: #939393;font-weight: bold }
+ .terminal-2166823333-r7 { fill: #24292f }
+ .terminal-2166823333-r8 { fill: #e2e3e3;font-weight: bold }
+ .terminal-2166823333-r9 { fill: #4ebf71;font-weight: bold }
+ .terminal-2166823333-r10 { fill: #e1e1e1;font-style: italic; }
+ .terminal-2166823333-r11 { fill: #e1e1e1;font-weight: bold }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MarkdownExampleApp
+ MarkdownExampleApp
-
-
-
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▎▋
- ▎Markdown Document▋
- ▎▋
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- This is an example of Textual's Markdown widget.
-
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▎▋
- ▎Features▋
- ▎▋
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- Markdown syntax and extensions are supported.
-
- ● Typography emphasis, strong, inline code etc.
- ● Headers
- ● Lists (bullet and ordered)
- ● Syntax highlighted code blocks
- ● Tables!
-
-
-
-
+
+
+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▎▋
+ ▎Markdown Document▋
+ ▎▋
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ This is an example of Textual's Markdown widget.
+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▎▋
+ ▎Features▋
+ ▎▋
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ Markdown syntax and extensions are supported.
+
+ ⏺ Typography emphasis, strong, inline code etc.
+ ⏺ Headers
+ ⏺ Lists (bullet and ordered)
+ ⏺ Syntax highlighted code blocks
+ ⏺ Tables!
+
+
+
+
@@ -13382,144 +13383,145 @@
font-weight: 700;
}
- .terminal-3241959168-matrix {
+ .terminal-3185906023-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3241959168-title {
+ .terminal-3185906023-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3241959168-r1 { fill: #c5c8c6 }
- .terminal-3241959168-r2 { fill: #24292f }
- .terminal-3241959168-r3 { fill: #121212 }
- .terminal-3241959168-r4 { fill: #e1e1e1 }
- .terminal-3241959168-r5 { fill: #e2e3e3 }
- .terminal-3241959168-r6 { fill: #96989b }
- .terminal-3241959168-r7 { fill: #0053aa }
- .terminal-3241959168-r8 { fill: #008139 }
- .terminal-3241959168-r9 { fill: #dde8f3;font-weight: bold }
- .terminal-3241959168-r10 { fill: #939393;font-weight: bold }
- .terminal-3241959168-r11 { fill: #e2e3e3;font-weight: bold }
- .terminal-3241959168-r12 { fill: #14191f }
- .terminal-3241959168-r13 { fill: #e1e1e1;font-style: italic; }
- .terminal-3241959168-r14 { fill: #e1e1e1;font-weight: bold }
+ .terminal-3185906023-r1 { fill: #c5c8c6 }
+ .terminal-3185906023-r2 { fill: #24292f }
+ .terminal-3185906023-r3 { fill: #121212 }
+ .terminal-3185906023-r4 { fill: #e1e1e1 }
+ .terminal-3185906023-r5 { fill: #e2e3e3 }
+ .terminal-3185906023-r6 { fill: #96989b }
+ .terminal-3185906023-r7 { fill: #0053aa }
+ .terminal-3185906023-r8 { fill: #008139 }
+ .terminal-3185906023-r9 { fill: #dde8f3;font-weight: bold }
+ .terminal-3185906023-r10 { fill: #939393;font-weight: bold }
+ .terminal-3185906023-r11 { fill: #e2e3e3;font-weight: bold }
+ .terminal-3185906023-r12 { fill: #14191f }
+ .terminal-3185906023-r13 { fill: #4ebf71;font-weight: bold }
+ .terminal-3185906023-r14 { fill: #e1e1e1;font-style: italic; }
+ .terminal-3185906023-r15 { fill: #e1e1e1;font-weight: bold }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MarkdownExampleApp
+ MarkdownExampleApp
-
-
-
- ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▼ Ⅰ Markdown Viewer▋▎▋
- ├── Ⅱ Features▋▎Markdown Viewer▋
- ├── Ⅱ Tables▋▎▋
- └── Ⅱ Code Blocks▋▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- ▋This is an example of Textual's MarkdownViewer
- ▋widget.
- ▋
- ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▋▎▋
- ▋▎Features▋▅▅
- ▋▎▋
- ▋▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- ▋Markdown syntax and extensions are supported.
- ▋
- ▋● Typography emphasis, strong, inline code
- ▋etc.
- ▋● Headers
- ▋● Lists (bullet and ordered)
- ▋● Syntax highlighted code blocks
- ▋● Tables!
- ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▋▎▋
- ▋▎Tables▋
+
+
+
+ ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▼ Ⅰ Markdown Viewer▋▎▋
+ ├── Ⅱ Features▋▎Markdown Viewer▋
+ ├── Ⅱ Tables▋▎▋
+ └── Ⅱ Code Blocks▋▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ ▋This is an example of Textual's MarkdownViewer
+ ▋widget.
+ ▋
+ ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▋▎▋
+ ▋▎Features▋▇▇
+ ▋▎▋
+ ▋▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ ▋Markdown syntax and extensions are supported.
+ ▋
+ ▋⏺ Typography emphasis, strong, inline code
+ ▋etc.
+ ▋⏺ Headers
+ ▋⏺ Lists (bullet and ordered)
+ ▋⏺ Syntax highlighted code blocks
+ ▋⏺ Tables!
+ ▋
+ ▋▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▋▎▋
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000000..221286f116
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,17 @@
+from textual.app import App
+
+
+def test_batch_update():
+ """Test `batch_update` context manager"""
+ app = App()
+ assert app._batch_count == 0 # Start at zero
+
+ with app.batch_update():
+ assert app._batch_count == 1 # Increments in context manager
+
+ with app.batch_update():
+ assert app._batch_count == 2 # Nested updates
+
+ assert app._batch_count == 1 # Exiting decrements
+
+ assert app._batch_count == 0 # Back to zero