diff --git a/CHANGELOG.md b/CHANGELOG.md index 9077cffff..6586854b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.1.9] - Unreleased +## [0.1.10] - Unreleased + +### Changed + +- Callbacks may be async or non-async. +- Event handler event argument is optional. +- Fixed exception in clock example https://github.com/willmcgugan/textual/issues/52 +- Added Message.wait() which waits for a message to be processed + +## [0.1.9] - 2021-08-06 ### Added diff --git a/README.md b/README.md index 1595fcfdb..1be936da9 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,14 @@ from textual.app import App class Beeper(App): - async def on_key(self, event): + def on_key(self): self.console.bell() Beeper.run() ``` -Here we can see a textual app with a single `on_key` method which will receive key events. Any key event will result in playing the terminal bell (which will generally emit an irritating beep). Hit Ctrl+C to exit. +Here we can see a textual app with a single `on_key` method which will handle key events. Pressing any key will result in playing the terminal bell (generally an irritating beep). Hit Ctrl+C to exit. Event handlers in Textual are defined by convention, not by inheritance (so you won't find an `on_key` method in the base class). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_` if it exists. @@ -74,7 +74,7 @@ from textual.app import App class ColorChanger(App): - async def on_key(self, event): + def on_key(self, event): if event.key.isdigit(): self.background = f"on color({event.key})" @@ -82,7 +82,9 @@ class ColorChanger(App): ColorChanger.run(log="textual.log") ``` -This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). +You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed. + +The key event handler above will set the background attribute if you press the keys 0-9, which turns the terminal to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute to a [Rich style](https://rich.readthedocs.io/en/latest/style.html) is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. To make changes to the terminal interface you modify the _state_ and let Textual update the UI. @@ -90,17 +92,16 @@ Note that we didn't need to explicitly refresh the screen or draw anything. Sett To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can develop your own. -Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. +Let's look at an app which contains widgets. We will be using the built-in `Placeholder` widget which you can use to design application layouts before you implement the real content. ```python -from textual import events from textual.app import App from textual.widgets import Placeholder class SimpleApp(App): - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: await self.view.dock(Placeholder(), edge="left", size=40) await self.view.dock(Placeholder(), Placeholder(), edge="top") @@ -108,7 +109,9 @@ class SimpleApp(App): SimpleApp.run(log="textual.log") ``` -This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. In this case we are going to call `self.view.dock` to add widgets to the interface. +This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods. + +The `on_mount` method makes two calls to `self.view.dock` which adds widgets to tht terminal. Here's the first line in the mount handler: @@ -145,7 +148,6 @@ Let's look at an example with a custom widget: ```python from rich.panel import Panel -from textual import events from textual.app import App from textual.reactive import Reactive from textual.widget import Widget @@ -153,22 +155,22 @@ from textual.widget import Widget class Hover(Widget): - mouse_over: Reactive[bool] = Reactive(False) + mouse_over = Reactive(False) def render(self) -> Panel: return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self) -> None: self.mouse_over = False class HoverApp(App): - """Hover widget demonstration.""" + """Demonstrates custom widgets""" - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: hovers = (Hover() for _ in range(10)) await self.view.dock(*hovers, edge="top") @@ -179,26 +181,34 @@ HoverApp.run(log="textual.log") The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point: ```python -mouse_over: Reactive[bool] = Reactive(False) +mouse_over = Reactive(False) ``` -This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. The typing part (`Reactive[bool]`) is not required, but will help you find bugs if you are using a tool like [Mypy](https://mypy.readthedocs.io/en/stable/). Adding attributes like this makes them _reactive_, and any changes will result in the widget updating. +This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. Adding attributes like this makes them _reactive_: any changes will result in the widget updating. -The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a Panel containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget. +The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a [Panel](https://rich.readthedocs.io/en/latest/panel.html) containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget. Here are the two event handlers again: ```python - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self) -> None: self.mouse_over = False ``` -Both event handlers set the `mouse_over` attribute which, because it is reactive, will result in the widget's `render()` method being called. +Both event handlers set the `mouse_over` attribute which will result in the widget's `render()` method being called. -The app class has a `Mount` handler where we _dock_ 10 of these custom widgets from the top edge, stacking them vertically. If you run this script you will see something like the following: +The `HoverApp` has a `on_mount` handler which creates 10 Hover widgets and docks them on the top edge to create a vertical stack: + +```python + async def on_mount(self) -> None: + hovers = (Hover() for _ in range(10)) + await self.view.dock(*hovers, edge="top") +``` + +If you run this script you will see something like the following: ![widgets](./imgs/custom.gif) @@ -264,7 +274,7 @@ _TODO_ ### Timers and Intervals -Textual has a `set_timer` and a `set_interval` method which work much like their Javascript counterparts. The `set_timer` method will invoke a callable after a given period of time, and `set_interval` will invoke a callable repeatedly. Unlike Javascript, these methods expect the time to be in seconds, _not_ milliseconds. +Textual has a `set_timer` and a `set_interval` method which work much like their Javascript counterparts. The `set_timer` method will invoke a callable after a given period of time, and `set_interval` will invoke a callable repeatedly. Unlike Javascript these methods expect the time to be in seconds (_not_ milliseconds). Let's create a simple terminal based clock with the `set_interval` method: @@ -278,19 +288,21 @@ from textual.widget import Widget class Clock(Widget): - async def on_mount(self, event): - self.set_interval(1, callback=self.refresh) + def on_mount(self): + self.set_interval(1, self.refresh) def render(self): - time = datetime.now().strftime("%X") + time = datetime.now().strftime("%c") return Align.center(time, vertical="middle") + class ClockApp(App): - async def on_mount(self, event): + async def on_mount(self): await self.view.dock(Clock()) ClockApp.run() + ``` If you run this app you will see the current time in the center of the terminal until you hit Ctrl+C. @@ -298,7 +310,7 @@ If you run this app you will see the current time in the center of the terminal The Clock widget displays the time using [rich.align.Align](https://rich.readthedocs.io/en/latest/reference/align.html) to position it in the center. In the clock's Mount handler there is the following call to `set_interval`: ```python -self.set_interval(1, callback=self.refresh) +self.set_interval(1, self.refresh) ``` This tells Textual to call a function (in this case `self.refresh` which updates the widget) once a second. When a widget is refreshed it calls `Clock.render` again to display the latest time. diff --git a/docs/examples/actions/colorizer.py b/docs/examples/actions/colorizer.py index 6a1fb5e4f..4034f7a8d 100644 --- a/docs/examples/actions/colorizer.py +++ b/docs/examples/actions/colorizer.py @@ -2,13 +2,12 @@ from textual.app import App class Colorizer(App): - - async def on_load(self, event): + async def on_load(self): await self.bind("r", "color('red')") await self.bind("g", "color('green')") await self.bind("b", "color('blue')") - async def action_color(self, color:str) -> None: + async def action_color(self, color: str) -> None: self.background = f"on {color}" diff --git a/docs/examples/actions/q.py b/docs/examples/actions/quiter.py similarity index 73% rename from docs/examples/actions/q.py rename to docs/examples/actions/quiter.py index 682be8591..53ff19356 100644 --- a/docs/examples/actions/q.py +++ b/docs/examples/actions/quiter.py @@ -2,7 +2,7 @@ from textual.app import App class Quiter(App): - async def on_load(self, event): + async def on_load(self): await self.bind("q", "quit") diff --git a/docs/examples/messages_and_events/beep.py b/docs/examples/messages_and_events/beep.py index 6dfa99a6a..7cbd54de0 100644 --- a/docs/examples/messages_and_events/beep.py +++ b/docs/examples/messages_and_events/beep.py @@ -2,7 +2,7 @@ from textual.app import App class Beeper(App): - async def on_key(self, event): + def on_key(self): self.console.bell() diff --git a/docs/examples/messages_and_events/color_changer.py b/docs/examples/messages_and_events/color_changer.py index 8507b2336..7d43a65e7 100644 --- a/docs/examples/messages_and_events/color_changer.py +++ b/docs/examples/messages_and_events/color_changer.py @@ -2,7 +2,7 @@ from textual.app import App class ColorChanger(App): - async def on_key(self, event): + def on_key(self, event): if event.key.isdigit(): self.background = f"on color({event.key})" diff --git a/docs/examples/timers/clock.py b/docs/examples/timers/clock.py index 54764568e..3d758cd78 100644 --- a/docs/examples/timers/clock.py +++ b/docs/examples/timers/clock.py @@ -7,15 +7,16 @@ from textual.widget import Widget class Clock(Widget): - async def on_mount(self, event): - self.set_interval(1, callback=self.refresh) + def on_mount(self): + self.set_interval(1, self.refresh) - def render(self) -> Align: - time = datetime.now().strftime("%X") + def render(self): + time = datetime.now().strftime("%c") return Align.center(time, vertical="middle") + class ClockApp(App): - async def on_mount(self, event): + async def on_mount(self): await self.view.dock(Clock()) diff --git a/docs/examples/widgets/custom.py b/docs/examples/widgets/custom.py index b1fdbe8ec..6856bcb20 100644 --- a/docs/examples/widgets/custom.py +++ b/docs/examples/widgets/custom.py @@ -1,6 +1,5 @@ from rich.panel import Panel -from textual import events from textual.app import App from textual.reactive import Reactive from textual.widget import Widget @@ -8,22 +7,22 @@ from textual.widget import Widget class Hover(Widget): - mouse_over: Reactive[bool] = Reactive(False) + mouse_over = Reactive(False) def render(self) -> Panel: return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self) -> None: self.mouse_over = False class HoverApp(App): """Demonstrates smooth animation""" - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: """Build layout here.""" hovers = (Hover() for _ in range(10)) diff --git a/examples/animation.py b/examples/animation.py index ea71652d6..4531e5114 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -7,18 +7,18 @@ from textual.widgets import Footer, Placeholder class SmoothApp(App): """Demonstrates smooth animation. Press 'b' to see it in action.""" - async def on_load(self, event: events.Load) -> None: + async def on_load(self) -> None: """Bind keys here.""" await self.bind("b", "toggle_sidebar", "Toggle sidebar") await self.bind("q", "quit", "Quit") - show_bar: Reactive[bool] = Reactive(False) + show_bar = Reactive(False) - async def watch_show_bar(self, show_bar: bool) -> None: + def watch_show_bar(self, show_bar: bool) -> None: """Called when show_bar changes.""" self.bar.animate("layout_offset_x", 0 if show_bar else -40) - async def action_toggle_sidebar(self) -> None: + def action_toggle_sidebar(self) -> None: """Called when user hits 'b' key.""" self.show_bar = not self.show_bar diff --git a/examples/calculator.py b/examples/calculator.py index 0b764790b..05d99eba9 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -54,7 +54,7 @@ class FigletText: class Numbers(Widget): """The digital display of the calculator.""" - value: Reactive[str] = Reactive("0") + value = Reactive("0") def render(self) -> RenderableType: """Build a Rich renderable to render the calculator display.""" @@ -84,28 +84,28 @@ class Calculator(GridView): "=": YELLOW, } - display: Reactive[str] = Reactive("0") - show_ac: Reactive[bool] = Reactive(True) + display = Reactive("0") + show_ac = Reactive(True) - async def watch_display(self, value: str) -> None: + def watch_display(self, value: str) -> None: """Called when self.display is modified.""" # self.numbers is a widget that displays the calculator result # Setting the attribute value changes the display # This allows us to write self.display = "100" to update the display self.numbers.value = value - async def compute_show_ac(self) -> bool: + def compute_show_ac(self) -> bool: """Compute show_ac reactive value.""" # Condition to show AC button over C return self.value in ("", "0") and self.display == "0" - async def watch_show_ac(self, show_ac: bool) -> None: + def watch_show_ac(self, show_ac: bool) -> None: """When the show_ac attribute change we need to update the buttons.""" # Show AC and hide C or vice versa self.c.visible = not show_ac self.ac.visible = show_ac - async def on_mount(self, event: events.Mount) -> None: + def on_mount(self) -> None: """Event when widget is first mounted (added to a parent view).""" # Attributes to store the current calculation @@ -153,7 +153,7 @@ class Calculator(GridView): *self.buttons.values(), clear=self.ac, numbers=self.numbers, zero=self.zero ) - async def message_button_pressed(self, message: ButtonPressed) -> None: + def message_button_pressed(self, message: ButtonPressed) -> None: """A message sent by the button widget""" assert isinstance(message.sender, Button) diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 690001b32..b5418c428 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -5,7 +5,6 @@ from rich.console import RenderableType from rich.syntax import Syntax from rich.traceback import Traceback -from textual import events from textual.app import App from textual.widgets import Header, Footer, FileClick, ScrollView, DirectoryTree @@ -13,7 +12,7 @@ from textual.widgets import Header, Footer, FileClick, ScrollView, DirectoryTree class MyApp(App): """An example of a very simple Textual App""" - async def on_load(self, event: events.Load) -> None: + async def on_load(self) -> None: """Sent before going in to application mode.""" # Bind our basic keys @@ -28,7 +27,7 @@ class MyApp(App): os.path.join(os.path.basename(__file__), "../../") ) - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: """Call after terminal goes in to application mode""" # Create our widgets diff --git a/examples/grid.py b/examples/grid.py index a8bd2ee31..b0a6da4b2 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -1,10 +1,9 @@ from textual.app import App -from textual import events from textual.widgets import Placeholder class GridTest(App): - async def on_mount(self, event: events.Mount) -> None: + async def on_mount(self) -> None: """Make a simple grid arrangement.""" grid = await self.view.dock_grid(edge="left", size=70, name="left") diff --git a/src/textual/_callback.py b/src/textual/_callback.py new file mode 100644 index 000000000..ad3b56e44 --- /dev/null +++ b/src/textual/_callback.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from functools import lru_cache + +from inspect import signature, isawaitable +from typing import Any, Callable + + +@lru_cache(maxsize=2048) +def count_parameters(func: Callable) -> int: + """Count the number of parameters in a callable""" + return len(signature(func).parameters) + + +async def invoke(callback: Callable, *params: object) -> Any: + """Invoke a callback with an arbitrary number of parameters. + + Args: + callback (Callable): [description] + + Returns: + Any: [description] + """ + _rich_traceback_guard = True + parameter_count = count_parameters(callback) + + result = callback(*params[:parameter_count]) + if isawaitable(result): + result = await result + return result diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 38ff68bbd..d8f55055d 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -35,10 +35,10 @@ class LinuxDriver(Driver): width: int | None = 80 height: int | None = 25 try: - width, height = os.get_terminal_size(sys.stdin.fileno()) + width, height = os.get_terminal_size(sys.__stdin__.fileno()) except (AttributeError, ValueError, OSError): try: - width, height = os.get_terminal_size(sys.stdout.fileno()) + width, height = os.get_terminal_size(sys.__stdout__.fileno()) except (AttributeError, ValueError, OSError): pass width = width or 80 diff --git a/src/textual/app.py b/src/textual/app.py index 2e43a0b68..3dd9e08a0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -19,6 +19,7 @@ from ._animator import Animator from .binding import Bindings, NoBinding from .geometry import Offset, Region from . import log +from ._callback import invoke from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler from ._types import MessageTarget @@ -27,6 +28,7 @@ from .layouts.dock import DockLayout, Dock from ._linux_driver import LinuxDriver from .message_pump import MessagePump from .message import Message +from ._profile import timer from .view import View from .views import DockView from .widget import Widget, Widget, Reactive @@ -180,7 +182,6 @@ class App(MessagePump): async def push_view(self, view: ViewType) -> ViewType: self.register(view, self) self._view_stack.append(view) - # await view.post_message(events.Mount(sender=self)) return view def on_keyboard_interupt(self) -> None: @@ -251,22 +252,24 @@ class App(MessagePump): async def process_messages(self) -> None: active_app.set(self) - driver = self._driver = self.driver_class(self.console, self) log("---") log(f"driver={self.driver_class}") - await self.dispatch_message(events.Load(sender=self)) + load_event = events.Load(sender=self) + await self.dispatch_message(load_event) await self.post_message(events.Mount(self)) await self.push_view(DockView()) + # Wait for the load event to be processed, so we don't go in to application mode beforehand + await load_event.wait() + + driver = self._driver = self.driver_class(self.console, self) try: driver.start_application_mode() except Exception: self.console.print_exception() else: - traceback: Traceback | None = None - try: self.title = self._title self.refresh() @@ -274,24 +277,15 @@ class App(MessagePump): await super().process_messages() log("PROCESS END") await self.animator.stop() - await self.close_all() - # while self.children: - # child = self.children.pop() - # log(f"closing {child}") - # await child.close_messages() - - # while self._view_stack: - # view = self._view_stack.pop() - # await view.close_messages() except Exception: self.panic() finally: driver.stop_application_mode() if self._exit_renderables: - for traceback in self._exit_renderables: - self.error_console.print(traceback) + for renderable in self._exit_renderables: + self.error_console.print(renderable) if self.log_file is not None: self.log_file.close() @@ -407,8 +401,8 @@ class App(MessagePump): _rich_traceback_guard = True method_name = f"action_{action_name}" method = getattr(namespace, method_name, None) - if method is not None: - await method(*params) + if callable(method): + await invoke(method, *params) async def broker_event( self, event_name: str, event: events.Event, default_namespace: object | None diff --git a/src/textual/events.py b/src/textual/events.py index 22424d691..bb7cc6431 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import Event from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar import rich.repr diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 7743890c6..ef540dae2 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -125,7 +125,7 @@ class Size(NamedTuple): "Dimensions.__contains__ requires an iterable of two integers" ) width, height = self - return bool(width > x >= 0 and height > y >= 0) + return width > x >= 0 and height > y >= 0 class Region(NamedTuple): @@ -164,7 +164,7 @@ class Region(NamedTuple): """ x, y = origin width, height = size - return Region(x, y, width, height) + return cls(x, y, width, height) def __bool__(self) -> bool: return bool(self.width and self.height) @@ -182,7 +182,7 @@ class Region(NamedTuple): return self.x + self.width @property - def y_end(self) -> int: + def y_max(self) -> int: return self.y + self.height @property @@ -216,7 +216,7 @@ class Region(NamedTuple): @property def y_range(self) -> range: - return range(self.y, self.y_end) + return range(self.y, self.y_max) def __add__(self, other: Any) -> Region: if isinstance(other, tuple): diff --git a/src/textual/layout.py b/src/textual/layout.py index f74647ada..610e39e4f 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -307,7 +307,7 @@ class Layout(ABC): height = self.height screen = Region(0, 0, width, height) - crop_region = crop or Region(0, 0, self.width, self.height) + crop_region = crop.intersection(screen) if crop else screen _Segment = Segment divide = _Segment.divide @@ -326,23 +326,15 @@ class Layout(ABC): # Go through all the renders in reverse order and fill buckets with no render renders = list(self._get_renders(console)) - clip_y, clip_y2 = crop_region.y_extents for region, clip, lines in chain( renders, [(screen, screen, background_render)] ): - # clip = clip.intersection(crop_region) render_region = region.intersection(clip) - for y, line in enumerate(lines, render_region.y): - if clip_y > y > clip_y2: - continue - # first_cut = clamp(render_region.x, clip_x, clip_x2) - # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) - first_cut = render_region.x - last_cut = render_region.x_max - final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - # final_cuts = cuts[y] + for y, line in zip(render_region.y_range, lines): + + first_cut, last_cut = render_region.x_extents + final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - # log(final_cuts, render_region.x_extents) if len(final_cuts) == 2: cut_segments = [line] else: diff --git a/src/textual/message.py b/src/textual/message.py index 63bfb7c9c..f37f95a53 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +from asyncio import Event from time import monotonic from typing import ClassVar @@ -17,6 +20,7 @@ class Message: "time", "_no_default_action", "_stop_propagation", + "__done_event", ] sender: MessageTarget @@ -35,6 +39,7 @@ class Message: self.time = monotonic() self._no_default_action = False self._stop_propagation = False + self.__done_event: Event | None = None super().__init__() def __rich_repr__(self) -> rich.repr.Result: @@ -45,14 +50,20 @@ class Message: cls.bubble = bubble cls.verbosity = verbosity + @property + def _done_event(self) -> Event: + if self.__done_event is None: + self.__done_event = Event() + return self.__done_event + def can_replace(self, message: "Message") -> bool: """Check if another message may supersede this one. Args: - message (Message): [description] + message (Message): Another message. Returns: - bool: [description] + bool: True if this message may replace the given message """ return False @@ -66,4 +77,13 @@ class Message: self._no_default_action = prevent def stop(self, stop: bool = True) -> None: + """Stop propagation of the message to parent. + + Args: + stop (bool, optional): The stop flag. Defaults to True. + """ self._stop_propagation = stop + + async def wait(self) -> None: + """Wait for the message to be processed.""" + await self._done_event.wait() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 5653885c8..527662167 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -12,10 +12,9 @@ from rich.traceback import Traceback from . import events from . import log from ._timer import Timer, TimerCallback +from ._callback import invoke from ._context import active_app from .message import Message -from .reactive import Reactive - if TYPE_CHECKING: from .app import App @@ -125,9 +124,9 @@ class MessagePump: def set_timer( self, delay: float, + callback: TimerCallback = None, *, name: str | None = None, - callback: TimerCallback = None, ) -> Timer: timer = Timer(self, delay, self, name=name, callback=callback, repeat=0) timer_task = asyncio.get_event_loop().create_task(timer.run()) @@ -137,9 +136,9 @@ class MessagePump: def set_interval( self, interval: float, + callback: TimerCallback = None, *, name: str | None = None, - callback: TimerCallback = None, repeat: int = 0, ): timer = Timer( @@ -219,11 +218,14 @@ class MessagePump: async def dispatch_message(self, message: Message) -> bool | None: _rich_traceback_guard = True - if isinstance(message, events.Event): - if not isinstance(message, events.Null): - await self.on_event(message) - else: - return await self.on_message(message) + try: + if isinstance(message, events.Event): + if not isinstance(message, events.Null): + await self.on_event(message) + else: + return await self.on_message(message) + finally: + message._done_event.set() return False def _get_dispatch_methods( @@ -241,7 +243,7 @@ class MessagePump: for method in self._get_dispatch_methods(f"on_{event.name}", event): log(event, ">>>", self, verbosity=event.verbosity) - await method(event) + await invoke(method, event) if event.bubble and self._parent and not event._stop_propagation: if event.sender != self._parent and self.is_parent_active: @@ -254,7 +256,7 @@ class MessagePump: method = getattr(self, method_name, None) if method is not None: log(message, ">>>", self, verbosity=message.verbosity) - await method(message) + await invoke(method, message) if message.bubble and self._parent and not message._stop_propagation: if message.sender == self._parent: @@ -308,9 +310,7 @@ class MessagePump: event.stop() if event.callback is not None: try: - callback_result = event.callback() - if inspect.isawaitable(callback_result): - await callback_result + await invoke(event.callback) except Exception as error: raise CallbackError( f"unable to run callback {event.callback!r}; {error}" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 7ae603207..6777af5c6 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -1,6 +1,6 @@ from __future__ import annotations -import inspect +from inspect import isawaitable from functools import partial from typing import ( Any, @@ -16,6 +16,7 @@ from typing import ( from . import log from . import events +from ._callback import count_parameters, invoke from ._types import MessageTarget if TYPE_CHECKING: @@ -28,10 +29,6 @@ if TYPE_CHECKING: ReactiveType = TypeVar("ReactiveType") -def count_params(func: Callable) -> int: - return len(inspect.signature(func).parameters) - - class Reactive(Generic[ReactiveType]): """Reactive descriptor.""" @@ -92,10 +89,12 @@ class Reactive(Generic[ReactiveType]): obj: Reactable, watch_function: Callable, old_value: Any, value: Any ) -> None: _rich_traceback_guard = True - if count_params(watch_function) == 2: - await watch_function(old_value, value) + if count_parameters(watch_function) == 2: + watch_result = watch_function(old_value, value) else: - await watch_function(value) + watch_result = watch_function(value) + if isawaitable(watch_result): + await watch_result await Reactive.compute(obj) watch_function = getattr(obj, f"watch_{name}", None) @@ -128,7 +127,8 @@ class Reactive(Generic[ReactiveType]): compute_method = getattr(obj, f"compute_{compute}") except AttributeError: continue - value = await compute_method() + value = await invoke(compute_method) + # value = await compute_method() setattr(obj, compute, value) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index d82cfd2b6..650c4f1db 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -75,7 +75,7 @@ class DirectoryTree(TreeControl[DirEntry]): await node.add(entry.name, DirEntry(entry.path, entry.is_dir())) node.loaded = True await node.expand() - # self.refresh(layout=True) + self.refresh(layout=True) async def message_tree_click(self, message: TreeClick[DirEntry]) -> None: dir_entry = message.node.data