From 7b654f53aed33817cd14385a6a23771f4f39a7b6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:51:49 +0000 Subject: [PATCH 01/15] Added batching --- src/textual/app.py | 17 ++++++++++++++++- src/textual/screen.py | 3 ++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 33b887e090..7d2b1cc47a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -411,6 +411,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 +427,18 @@ def children(self) -> Sequence["Widget"]: except ScreenError: return () + def _begin_batch(self) -> None: + self._batch_count += 1 + + def _end_batch(self) -> None: + self._batch_count -= 1 + if not self._batch_count: + try: + self.screen.check_idle() + except ScreenStackError: + pass + self.check_idle() + def animate( self, attribute: str, @@ -1504,6 +1517,7 @@ async def invoke_ready_callback() -> None: if inspect.isawaitable(ready_result): await ready_result + self._begin_batch() try: try: await self._dispatch_message(events.Compose(sender=self)) @@ -1524,6 +1538,7 @@ async def invoke_ready_callback() -> None: finally: self._running = True + self._end_batch() await self._ready() await invoke_ready_callback() @@ -1615,7 +1630,7 @@ async def _on_compose(self) -> None: 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 diff --git a/src/textual/screen.py b/src/textual/screen.py index 0377f80344..8e8a47afbe 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -359,8 +359,9 @@ 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: + print("LAYOUT") if self.is_current: if self._layout_required: self._refresh_layout() From 7523637cb6c588cdef7ae541d411a24da42460a6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:07:51 +0000 Subject: [PATCH 02/15] added batch update --- CHANGELOG.md | 4 +++ src/textual/app.py | 56 ++++++++++++++++++++---------- src/textual/cli/previews/colors.py | 1 - src/textual/screen.py | 1 - 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f36d87b036..01d4c3cca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.12.0] - Unreleased +### Added + +- Added `App.batch_update` + ### Changed - Scrolling by page now adds to current position. diff --git a/src/textual/app.py b/src/textual/app.py index 7d2b1cc47a..383a863b2f 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, @@ -427,11 +433,23 @@ 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() @@ -1517,30 +1535,29 @@ async def invoke_ready_callback() -> None: if inspect.isawaitable(ready_result): await ready_result - self._begin_batch() - 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 - self._end_batch() - await self._ready() - await invoke_ready_callback() + finally: + self._running = True + await self._ready() + await invoke_ready_callback() try: await self._process_messages_loop() @@ -1626,6 +1643,7 @@ 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: 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 8e8a47afbe..717bac9d08 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -361,7 +361,6 @@ async def _on_idle(self, event: events.Idle) -> None: if not self.app._batch_count and self.is_current: async with self.app._dom_lock: - print("LAYOUT") if self.is_current: if self._layout_required: self._refresh_layout() From 00de6b5b9d107a6deb00d71856db3e6d2e06984f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:17:30 +0000 Subject: [PATCH 03/15] extra chars --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 383a863b2f..57937dba03 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -449,7 +449,7 @@ def _begin_batch(self) -> None: 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` =" + assert self._batch_count >= 0, "This won't happen if you use `batch_update`" if not self._batch_count: try: self.screen.check_idle() From b9375c5a141320ec4c25989d26086bc02638c186 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:22:29 +0000 Subject: [PATCH 04/15] Added test for batch update --- tests/test_app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_app.py diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000000..7d66753796 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,12 @@ +from textual.app import App + + +def test_batch_update(): + app = App() + assert app._batch_count == 0 + with app.batch_update(): + assert app._batch_count == 1 + with app.batch_update(): + assert app._batch_count == 2 + assert app._batch_count == 1 + assert app._batch_count == 0 From ff657489923158ada817c29109987772400f4de2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:24:45 +0000 Subject: [PATCH 05/15] comment on test --- tests/test_app.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 7d66753796..221286f116 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,11 +2,16 @@ def test_batch_update(): + """Test `batch_update` context manager""" app = App() - assert app._batch_count == 0 + assert app._batch_count == 0 # Start at zero + with app.batch_update(): - assert app._batch_count == 1 + assert app._batch_count == 1 # Increments in context manager + with app.batch_update(): - assert app._batch_count == 2 - assert app._batch_count == 1 - assert app._batch_count == 0 + assert app._batch_count == 2 # Nested updates + + assert app._batch_count == 1 # Exiting decrements + + assert app._batch_count == 0 # Back to zero From e143af1d3c07f089ad27645da70e5ab647966a44 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:46:42 +0000 Subject: [PATCH 06/15] speed up shutdown --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 57937dba03..c64d597990 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1828,6 +1828,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: From 724e0e3f5850c369d2bc43b59f90c17475c23c8e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:43:13 +0000 Subject: [PATCH 07/15] Markdown and dictionary example --- CHANGELOG.md | 1 + examples/dictionary.css | 6 +++--- examples/dictionary.py | 10 +++++----- src/textual/widgets/_markdown.py | 21 +++++++++++++++++++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d4c3cca6..153b24212b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `App.batch_update` +- Added horizontal rule to Markdown ### Changed 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 737bcb283b..6a6f84d424 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 Static, Input +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.""" @@ -45,7 +44,8 @@ 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)) + self.log(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/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 50812dd9d2..4c8bb5b164 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -198,6 +198,18 @@ class MarkdownH6(MarkdownHeader): """ +class MarkdownHorizontalRule(MarkdownBlock): + """A horizontal rule.""" + + DEFAULT_CSS = """ + MarkdownHorizontalRule { + border-bottom: heavy $primary; + height: 1; + padding-top: 1; + } + """ + + class MarkdownParagraph(MarkdownBlock): """A paragraph Markdown block.""" @@ -501,7 +513,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 +536,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": @@ -627,7 +641,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): From c3e5e0490c47691309e5cb0b97796c740da02837 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:46:43 +0000 Subject: [PATCH 08/15] remove log --- examples/dictionary.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 6a6f84d424..89fa3170b3 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -44,7 +44,6 @@ async def lookup_word(self, word: str) -> None: if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) - self.log(markdown) await self.query_one("#results", Markdown).update(markdown) def make_word_markdown(self, results: object) -> str: From 104f784a20024bf8cf9216638b22f2a47715de89 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:48:49 +0000 Subject: [PATCH 09/15] extra space around hr --- src/textual/widgets/_markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 4c8bb5b164..1328fa5b25 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -206,6 +206,7 @@ class MarkdownHorizontalRule(MarkdownBlock): border-bottom: heavy $primary; height: 1; padding-top: 1; + margin-bottom: 1; } """ From 5a6d7183443551e4981d60a5fddc4a5c2c715820 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:15:15 +0000 Subject: [PATCH 10/15] list item improvements --- examples/example.md | 16 ++++++++++++++++ src/textual/widgets/_markdown.py | 21 +++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/examples/example.md b/examples/example.md index 83495ba2fd..92a6e013a3 100644 --- a/examples/example.md +++ b/examples/example.md @@ -42,6 +42,22 @@ 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 + - Foo + - Bar + - Jessica + - Reg + - Green + - January + - February + - March + - Blue + - Paul + - Baz + ## 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/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 1328fa5b25..93bb7e3060 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -345,7 +345,7 @@ class MarkdownBullet(Widget): } """ - symbol = reactive("●​ ") + symbol = reactive("●​") """The symbol for the bullet.""" def render(self) -> Text: @@ -381,6 +381,14 @@ def compose(self) -> ComposeResult: self._blocks.clear() +class MarkdownOrderedListItem(MarkdownListItem): + pass + + +class MarkdownUnorderedListItem(MarkdownListItem): + pass + + class MarkdownFence(MarkdownBlock): """A fence Markdown block.""" @@ -452,6 +460,8 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} + BULLETS = ["⏺ ", "■ ", "• ", "‣ "] + def __init__( self, markdown: str | None = None, @@ -548,8 +558,15 @@ async def update(self, markdown: str) -> None: elif token.type == "ordered_list_open": stack.append(MarkdownOrderedList()) elif token.type == "list_item_open": + item_count = sum( + 1 for block in stack if isinstance(block, MarkdownUnorderedListItem) + ) stack.append( - MarkdownListItem(f"{token.info}. " if token.info else "● ") + MarkdownOrderedListItem(f" {token.info}. ") + if token.info + else MarkdownUnorderedListItem( + self.BULLETS[item_count % len(self.BULLETS)] + ) ) elif token.type == "table_open": stack.append(MarkdownTable()) From de86b564bb12e66f712e3024f73f119cbf30e7be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:19:43 +0000 Subject: [PATCH 11/15] styling for list items --- src/textual/widgets/_markdown.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 93bb7e3060..3d6558c36c 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -342,6 +342,8 @@ class MarkdownBullet(Widget): DEFAULT_CSS = """ MarkdownBullet { width: auto; + color: $success; + text-style: bold; } """ From 434e6178d30479ee3aad6c7cfc1342a35f2b25f4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:25:17 +0000 Subject: [PATCH 12/15] list item style --- examples/example.md | 18 +++++++----------- src/textual/widgets/_markdown.py | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/examples/example.md b/examples/example.md index 92a6e013a3..169a729236 100644 --- a/examples/example.md +++ b/examples/example.md @@ -46,17 +46,13 @@ Inline code is indicated by backticks. e.g. `import this`. 1. Lists can be ordered 2. Lists can be unordered - - Foo - - Bar - - Jessica - - Reg - - Green - - January - - February - - March - - Blue - - Paul - - Baz + - 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. ## Fences diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 3d6558c36c..f7ee0f1881 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -260,7 +260,7 @@ class MarkdownOrderedList(MarkdownBlock): DEFAULT_CSS = """ MarkdownOrderedList { margin: 0; - padding: 0 0; + padding: 0 0 1 0; } Markdown OrderedList MarkdownOrderedList { From a63d07f619fd6262763e680daf7c3dcc44c8758a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 09:18:26 +0000 Subject: [PATCH 13/15] enhanced ordered list --- examples/example.md | 3 +- src/textual/widgets/_markdown.py | 100 +++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/examples/example.md b/examples/example.md index 169a729236..366500ca64 100644 --- a/examples/example.md +++ b/examples/example.md @@ -50,10 +50,11 @@ Inline code is indicated by backticks. e.g. `import this`. - 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. + - 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. + ## 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/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f7ee0f1881..b2f47578b5 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 @@ -238,37 +238,79 @@ class MarkdownBlockQuote(MarkdownBlock): """ -class MarkdownBulletList(MarkdownBlock): +class MarkdownList(MarkdownBlock): + DEFAULT_CSS = """ + 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; } + + MarkdownBulletList Vertical { + height: auto; + } + + """ + def compose(self) -> ComposeResult: + for block in self._blocks: + print(block) + 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; - padding: 0 0 1 0; + margin: 0 0 1 0; + padding: 0 0; } - Markdown OrderedList MarkdownOrderedList { - margin: 0; - padding-top: 0; + + + MarkdownOrderedList Horizontal { + height: auto; + } + + MarkdownOrderedList Vertical { + height: auto; } """ + 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.""" @@ -374,14 +416,6 @@ 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 @@ -462,7 +496,7 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} - BULLETS = ["⏺ ", "■ ", "• ", "‣ "] + BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "] def __init__( self, @@ -560,16 +594,20 @@ async def update(self, markdown: str) -> None: elif token.type == "ordered_list_open": stack.append(MarkdownOrderedList()) elif token.type == "list_item_open": - item_count = sum( - 1 for block in stack if isinstance(block, MarkdownUnorderedListItem) - ) - stack.append( - MarkdownOrderedListItem(f" {token.info}. ") - if token.info - else MarkdownUnorderedListItem( - self.BULLETS[item_count % len(self.BULLETS)] + 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": @@ -599,6 +637,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, From 433e371dc9c7db61e080af125589852034f533ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 10:03:04 +0000 Subject: [PATCH 14/15] changelog and snapshots --- CHANGELOG.md | 1 + examples/example.md | 13 + src/textual/widgets/_markdown.py | 16 +- .../__snapshots__/test_snapshots.ambr | 264 +++++++++--------- 4 files changed, 157 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153b24212b..973a014333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Scrolling by page now adds to current position. +- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks ### Removed diff --git a/examples/example.md b/examples/example.md index 366500ca64..e792340258 100644 --- a/examples/example.md +++ b/examples/example.md @@ -54,6 +54,19 @@ Inline code is indicated by backticks. e.g. `import this`. - 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 diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index b2f47578b5..e2178ca2d8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -240,6 +240,11 @@ class MarkdownBlockQuote(MarkdownBlock): class MarkdownList(MarkdownBlock): DEFAULT_CSS = """ + + MarkdownList { + width: 1fr; + } + MarkdownList MarkdownList { margin: 0; padding-top: 0; @@ -258,18 +263,17 @@ class MarkdownBulletList(MarkdownList): MarkdownBulletList Horizontal { height: auto; + width: 1fr; } - MarkdownBulletList Vertical { + MarkdownBulletList Vertical { height: auto; + width: 1fr; } - - """ def compose(self) -> ComposeResult: for block in self._blocks: - print(block) if isinstance(block, MarkdownListItem): bullet = MarkdownBullet() bullet.symbol = block.bullet @@ -286,14 +290,14 @@ class MarkdownOrderedList(MarkdownList): padding: 0 0; } - - MarkdownOrderedList Horizontal { height: auto; + width: 1fr; } MarkdownOrderedList Vertical { height: auto; + width: 1fr; } """ 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! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + From 7d9ef17b10fb53843111111d4000113d95732d95 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 20:51:15 +0000 Subject: [PATCH 15/15] changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973a014333..0756fedf5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `App.batch_update` -- Added horizontal rule to Markdown +- 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 +- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832 ### Removed