diff --git a/docs/introduction.md b/docs/introduction.md index 8f70954af..7ef6610d4 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -248,7 +248,7 @@ You may have noticed that the stop button (`#stop` in the CSS) has `display: non We want our Stopwatch widget to have two states. An _unstarted_ state with a Start and Reset button, and a _started_ state with a Stop button. -There are other differences between the two states. It would be nice if the stopwatch turns green when it is started. And we could make the time text bold, so it is clear it is running. It's possible to do this in code, but +There are other visual differences between the two states. When a stopwatch is running it should have a green background and bold text. ```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,enter"} diff --git a/src/textual/app.py b/src/textual/app.py index e4ecbe169..52e19e878 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -57,6 +57,7 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size +from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen @@ -560,6 +561,7 @@ class App(Generic[ReturnType], DOMNode): quit_after (float | None, optional): Quit after a given number of seconds, or None to run forever. Defaults to None. headless (bool, optional): Run in "headless" mode (don't write to stdout). + press (str, optional): An iterable of keys to simulate being pressed. Returns: ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. @@ -574,18 +576,25 @@ class App(Generic[ReturnType], DOMNode): if quit_after is not None: self.set_timer(quit_after, self.shutdown) if press is not None: + app = self - async def press_keys(app: App): + async def press_keys() -> None: + """A task to send key events.""" assert press - await asyncio.sleep(0.05) + driver = app._driver + assert driver is not None for key in press: print(f"press {key!r}") - await app.post_message(events.Key(self, key)) - await asyncio.sleep(0.01) + driver.send_event(events.Key(self, key)) + await asyncio.sleep(0.02) - self.call_later(lambda: asyncio.create_task(press_keys(self))) + async def press_keys_task(): + """Press some keys in the background.""" + asyncio.create_task(press_keys()) - await self.process_messages() + await self.process_messages(ready_callback=press_keys_task) + else: + await self.process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: @@ -933,7 +942,9 @@ class App(Generic[ReturnType], DOMNode): self.error_console.print(renderable) self._exit_renderables.clear() - async def process_messages(self) -> None: + async def process_messages( + self, ready_callback: CallbackType | None = None + ) -> None: self._set_active() if self.devtools_enabled: @@ -975,6 +986,8 @@ class App(Generic[ReturnType], DOMNode): self.refresh() await self.animator.start() await self._ready() + if ready_callback is not None: + await ready_callback() await process_messages() await self.animator.stop() await self.close_all() @@ -1210,11 +1223,14 @@ class App(Generic[ReturnType], DOMNode): Returns: bool: True if the key was handled by a binding, otherwise False """ + print("press", key) try: binding = self.bindings.get_key(key) except NoBinding: + print("no binding") return False else: + print(binding) await self.action(binding.action) return True diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index f303246f6..61393fff7 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -116,7 +116,8 @@ def import_app(import_name: str) -> App: @run.command("run") @click.argument("import_name", metavar="FILE or FILE:APP") @click.option("--dev", "dev", help="Enable development mode", is_flag=True) -def run_app(import_name: str, dev: bool) -> None: +@click.option("--press", "press", help="Comma separated keys to simulate press") +def run_app(import_name: str, dev: bool, press: str) -> None: """Run a Textual app. The code to run may be given as a path (ending with .py) or as a Python @@ -156,7 +157,8 @@ def run_app(import_name: str, dev: bool) -> None: console.print(str(error)) sys.exit(1) - app.run() + press_keys = press.split(",") if press else None + app.run(press=press_keys) @run.command("borders") diff --git a/src/textual/dom.py b/src/textual/dom.py index 83f963588..e228ed916 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -228,7 +228,7 @@ class DOMNode(MessagePump): while node and not isinstance(node, Screen): node = node._parent if not isinstance(node, Screen): - raise NoScreen("{self} has no screen") + raise NoScreen(f"{self} has no screen") return node @property diff --git a/src/textual/widget.py b/src/textual/widget.py index af731ae51..72078327b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import Lock from itertools import islice from fractions import Fraction from operator import attrgetter @@ -124,6 +125,8 @@ class Widget(DOMNode): self._styles_cache = StylesCache() + self._lock = Lock() + super().__init__( name=name, id=id, @@ -1159,13 +1162,18 @@ class Widget(DOMNode): Args: event (events.Idle): Idle event. """ - if self._parent is not None: - if self._repaint_required: - self._repaint_required = False - self.screen.post_message_no_wait(messages.Update(self, self)) - if self._layout_required: - self._layout_required = False - self.screen.post_message_no_wait(messages.Layout(self)) + if self._parent is not None and not self._closing: + try: + screen = self.screen + except NoScreen: + pass + else: + if self._repaint_required: + self._repaint_required = False + screen.post_message_no_wait(messages.Update(self, self)) + if self._layout_required: + self._layout_required = False + screen.post_message_no_wait(messages.Layout(self)) def focus(self) -> None: """Give input focus to this widget."""