Skip to content

Commit

Permalink
Merge pull request #1832 from Textualize/batch-update
Browse files Browse the repository at this point in the history
Batch update
  • Loading branch information
willmcgugan authored Feb 21, 2023
2 parents 068d751 + 7d9ef17 commit 4c9326f
Show file tree
Hide file tree
Showing 10 changed files with 346 additions and 182 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions examples/dictionary.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Input {
}

#results {
width: auto;
min-height: 100%;
padding: 0 1;
width: 100%;
height: auto;

}

#results-container {
Expand Down
9 changes: 4 additions & 5 deletions examples/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand Down
26 changes: 26 additions & 0 deletions examples/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 52 additions & 18 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +27,7 @@
Any,
Awaitable,
Callable,
Generator,
Generic,
Iterable,
List,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion src/textual/cli/previews/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 4c9326f

Please sign in to comment.