diff --git a/CHANGELOG.md b/CHANGELOG.md index f36d87b036..0756fedf5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,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 3185247e92..516edcc4fe 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, @@ -411,6 +417,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 @@ -426,6 +433,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, @@ -1504,28 +1535,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() @@ -1611,11 +1643,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 @@ -1796,6 +1829,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 0377f80344..717bac9d08 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -359,7 +359,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 f149ea09fe..f411820fe2 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -13032,139 +13032,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 emphasisstronginline 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 emphasisstronginline code etc. + ⏺ Headers + ⏺ Lists (bullet and ordered) + ⏺ Syntax highlighted code blocks + ⏺ Tables! + + + + @@ -13195,144 +13196,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 - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features▅▅ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code - etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Tables + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼  Markdown Viewer + ├──  FeaturesMarkdown Viewer + ├──  Tables + └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features▇▇ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ⏺ Typography emphasisstronginline 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