mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
11
CHANGELOG.md
11
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
|
||||
|
||||
|
||||
66
README.md
66
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_<event.name>` 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:
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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})"
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
30
src/textual/_callback.py
Normal file
30
src/textual/_callback.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Event
|
||||
from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user