mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
fixes for reactive
This commit is contained in:
@@ -15,7 +15,7 @@ class TimeDisplay(Static):
|
||||
"""Called when time_delta changes."""
|
||||
minutes, seconds = divmod(time, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:05.2f}")
|
||||
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from textual.app import App
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
def compose(self):
|
||||
"""A Textual app to manage stopwatches."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
|
||||
def on_load(self):
|
||||
def on_load(self) -> None:
|
||||
"""Called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self):
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from textual.app import App
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.layout import Container
|
||||
from textual.widgets import Button, Header, Footer, Static
|
||||
|
||||
|
||||
class TimeDisplay(Static):
|
||||
pass
|
||||
"""A widget to display elapsed time."""
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
def compose(self):
|
||||
"""A stopwatch widget."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets of a stopwatch."""
|
||||
yield Button("Start", id="start", variant="success")
|
||||
yield Button("Stop", id="stop", variant="error")
|
||||
yield Button("Reset", id="reset")
|
||||
@@ -16,15 +19,20 @@ class Stopwatch(Static):
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
def compose(self):
|
||||
"""A Textual app to manage stopwatches."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
|
||||
|
||||
def on_load(self):
|
||||
def on_load(self) -> None:
|
||||
"""Event handler called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self):
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ from textual.widgets import Button, Header, Footer, Static
|
||||
|
||||
|
||||
class TimeDisplay(Static):
|
||||
pass
|
||||
"""A widget to display elapsed time."""
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
def compose(self):
|
||||
"""A stopwatch widget."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets of a stopwatch."""
|
||||
yield Button("Start", id="start", variant="success")
|
||||
yield Button("Stop", id="stop", variant="error")
|
||||
yield Button("Reset", id="reset")
|
||||
@@ -16,15 +19,20 @@ class Stopwatch(Static):
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
def compose(self):
|
||||
"""A Textual app to manage stopwatches."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
|
||||
|
||||
def on_load(self):
|
||||
def on_load(self) -> None:
|
||||
"""Called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self):
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
from textual.app import App
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.layout import Container
|
||||
from textual.widgets import Button, Header, Footer, Static
|
||||
|
||||
|
||||
class TimeDisplay(Static):
|
||||
pass
|
||||
"""A widget to display elapsed time."""
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
def on_button_pressed(self, event):
|
||||
"""A stopwatch widget."""
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Event handler called when a button is pressed."""
|
||||
if event.button.id == "start":
|
||||
self.add_class("started")
|
||||
elif event.button.id == "stop":
|
||||
self.remove_class("started")
|
||||
|
||||
def compose(self):
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets of a stopwatch."""
|
||||
yield Button("Start", id="start", variant="success")
|
||||
yield Button("Stop", id="stop", variant="error")
|
||||
yield Button("Reset", id="reset")
|
||||
@@ -22,15 +26,20 @@ class Stopwatch(Static):
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
def compose(self):
|
||||
"""A Textual app to manage stopwatches."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
|
||||
|
||||
def on_load(self):
|
||||
def on_load(self) -> None:
|
||||
"""Called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self):
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
|
||||
67
docs/examples/introduction/stopwatch05.py
Normal file
67
docs/examples/introduction/stopwatch05.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from time import monotonic
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.layout import Container
|
||||
from textual.reactive import Reactive
|
||||
from textual.widgets import Button, Header, Footer, Static
|
||||
|
||||
|
||||
class TimeDisplay(Static):
|
||||
"""A widget to display elapsed time."""
|
||||
|
||||
start_time = Reactive(monotonic)
|
||||
time = Reactive(0.0)
|
||||
|
||||
def watch_time(self, time: float) -> None:
|
||||
"""Called when the time attribute changes."""
|
||||
minutes, seconds = divmod(time - self.start_time, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Event handler called when widget is added to the app."""
|
||||
self.set_interval(1 / 30, self.update_time)
|
||||
|
||||
def update_time(self) -> None:
|
||||
self.time = monotonic()
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
"""A stopwatch widget."""
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Event handler called when a button is pressed."""
|
||||
if event.button.id == "start":
|
||||
self.add_class("started")
|
||||
elif event.button.id == "stop":
|
||||
self.remove_class("started")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets of a stopwatch."""
|
||||
yield Button("Start", id="start", variant="success")
|
||||
yield Button("Stop", id="stop", variant="error")
|
||||
yield Button("Reset", id="reset")
|
||||
yield TimeDisplay("00:00:00.00")
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
"""A Textual app to manage stopwatches."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
|
||||
|
||||
def on_load(self) -> None:
|
||||
"""Event handler called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
app = StopwatchApp(css_path="stopwatch04.css")
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
88
docs/examples/introduction/stopwatch06.py
Normal file
88
docs/examples/introduction/stopwatch06.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from time import monotonic
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.layout import Container
|
||||
from textual.reactive import Reactive
|
||||
from textual.widgets import Button, Header, Footer, Static
|
||||
|
||||
|
||||
class TimeDisplay(Static):
|
||||
"""A widget to display elapsed time."""
|
||||
|
||||
total = Reactive(0.0)
|
||||
start_time = Reactive(monotonic)
|
||||
time = Reactive(0.0)
|
||||
|
||||
def watch_time(self, time: float) -> None:
|
||||
"""Called when the time attribute changes."""
|
||||
minutes, seconds = divmod(time, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Event handler called when widget is added to the app."""
|
||||
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
|
||||
|
||||
def update_time(self) -> None:
|
||||
"""Method to update time to current."""
|
||||
self.time = self.total + (monotonic() - self.start_time)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Method to start (or resume) time updating."""
|
||||
self.start_time = monotonic()
|
||||
self.update_timer.resume()
|
||||
|
||||
def stop(self):
|
||||
"""Method to stop the time display updating."""
|
||||
self.update_timer.pause()
|
||||
self.total += monotonic() - self.start_time
|
||||
self.time = self.total
|
||||
|
||||
def reset(self):
|
||||
"""Method to reset the time display to zero."""
|
||||
self.total = 0
|
||||
self.time = self.start_time
|
||||
|
||||
|
||||
class Stopwatch(Static):
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Event handler called when a button is pressed."""
|
||||
time_display = self.query_one(TimeDisplay)
|
||||
if event.button.id == "start":
|
||||
time_display.start()
|
||||
self.add_class("started")
|
||||
self.query_one("#stop").focus()
|
||||
elif event.button.id == "stop":
|
||||
time_display.stop()
|
||||
self.remove_class("started")
|
||||
self.query_one("#start").focus()
|
||||
elif event.button.id == "reset":
|
||||
time_display.reset()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets of a stopwatch."""
|
||||
yield Button("Start", id="start", variant="success")
|
||||
yield Button("Stop", id="stop", variant="error")
|
||||
yield Button("Reset", id="reset")
|
||||
yield TimeDisplay("00:00:00.00")
|
||||
|
||||
|
||||
class StopwatchApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
|
||||
|
||||
def on_load(self) -> None:
|
||||
"""Event handler called when app first loads."""
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""An action to toggle dark mode."""
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
app = StopwatchApp(css_path="stopwatch04.css")
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -6,14 +6,18 @@ By the end of this page you should have a good idea of the steps involved in cre
|
||||
|
||||
!!! quote
|
||||
|
||||
You may find this page goes in to more detail than you might expect from an introduction. I like to have complete working examples in documentation and I don't want to leave anything _as an exercise for the reader_. — **Will McGugan** (creator of Rich and Textual)
|
||||
This page goes in to more detail than you may expect from an introduction. I like documentation to have complete working examples and I wanted the first app to be realistic.
|
||||
|
||||
— **Will McGugan** (creator of Rich and Textual)
|
||||
|
||||
|
||||
|
||||
|
||||
## Stopwatch Application
|
||||
|
||||
We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop / resume / reset each stopwatch in addition to adding or removing them.
|
||||
We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop, resume, and reset each stopwatch in addition to adding or removing them.
|
||||
|
||||
This is a simple yet **fully featured** app — you could distribute this app if you wanted to!
|
||||
This will be a simple yet **fully featured** app — you could distribute this app if you wanted to!
|
||||
|
||||
Here's what the finished app will look like:
|
||||
|
||||
@@ -31,6 +35,25 @@ If you want to try this out before reading the rest of this introduction (we rec
|
||||
python stopwatch.py
|
||||
```
|
||||
|
||||
## Type hints (in brief)
|
||||
|
||||
We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, its a way to express the types of your data, parameters, and returns. Type hinting allows tools like [Mypy](https://mypy.readthedocs.io/en/stable/) to catch potential bugs before your code runs.
|
||||
|
||||
The following function contains type hints:
|
||||
|
||||
```python
|
||||
def repeat(text: str, count: int) -> str:
|
||||
return text * count
|
||||
```
|
||||
|
||||
- Parameter types follow a colon, so `text: str` means that `text` should be a string and `count: int` means that `count` should be an integer.
|
||||
- Return types follow `->` So `-> str:` says that this method returns a string.
|
||||
|
||||
|
||||
!!! note
|
||||
|
||||
Type hints are entirely optional in Textual. We've included them in the example code but it's up to you wether you add them to your own projects.
|
||||
|
||||
## The App class
|
||||
|
||||
The first step in building a Textual app is to import and extend the `App` class. Here's our basic app class with a few methods which we will cover below.
|
||||
@@ -66,7 +89,7 @@ The first line imports the Textual `App` class. The second line imports two buil
|
||||
Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build such widgets in this introduction.
|
||||
|
||||
|
||||
```python title="stopwatch01.py" hl_lines="5-14"
|
||||
```python title="stopwatch01.py" hl_lines="5-19"
|
||||
--8<-- "docs/examples/introduction/stopwatch01.py"
|
||||
```
|
||||
|
||||
@@ -85,7 +108,7 @@ There are three methods in our stopwatch app currently.
|
||||
You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update.
|
||||
|
||||
|
||||
```python title="stopwatch01.py" hl_lines="17-19"
|
||||
```python title="stopwatch01.py" hl_lines="22-24"
|
||||
--8<-- "docs/examples/introduction/stopwatch01.py"
|
||||
```
|
||||
|
||||
@@ -93,18 +116,15 @@ The last lines in "stopwatch01.py" may be familiar to you. We create an instance
|
||||
|
||||
## Creating a custom widget
|
||||
|
||||
The header and footer were builtin widgets. We will to build a custom widget for the stopwatches in our application.
|
||||
The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets.
|
||||
|
||||
Let's sketch out what we are trying to achieve here:
|
||||
Let's sketch out a design for our app:
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/stopwatch.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
|
||||
An individual stopwatch consists of several parts, which themselves can be widgets.
|
||||
|
||||
The Stopwatch widget consists of the be built with the following _child_ widgets:
|
||||
We will need to build a `Stopwatch` widget composed of the following _child_ widgets:
|
||||
|
||||
- A "start" button
|
||||
- A "stop" button
|
||||
@@ -113,9 +133,9 @@ The Stopwatch widget consists of the be built with the following _child_ widgets
|
||||
|
||||
Textual has a builtin `Button` widgets which takes care of the first three components. All we need to build is the time display which will show the elapsed time in HOURS:MINUTES:SECONDS format, and the stopwatch itself.
|
||||
|
||||
Let's add those to our app:
|
||||
Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.
|
||||
|
||||
```python title="stopwatch02.py" hl_lines="3 6-7 10-15 22 31"
|
||||
```python title="stopwatch02.py" hl_lines="3 6-7 10-18 28"
|
||||
--8<-- "docs/examples/introduction/stopwatch02.py"
|
||||
```
|
||||
|
||||
@@ -169,7 +189,7 @@ CSS files are data files loaded by your app which contain information about styl
|
||||
|
||||
Let's add a CSS file to our application.
|
||||
|
||||
```python title="stopwatch03.py" hl_lines="31"
|
||||
```python title="stopwatch03.py" hl_lines="39"
|
||||
--8<-- "docs/examples/introduction/stopwatch03.py"
|
||||
```
|
||||
|
||||
@@ -256,7 +276,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: a default state with a Start and Reset button; and a _started_ state with a Stop button. When a stopwatch is started it should also have a green background and bold text.
|
||||
|
||||
We can accomplish this with by defining a _CSS class_. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
|
||||
We can accomplish this with a CSS _class_. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
|
||||
|
||||
Here's the new CSS:
|
||||
|
||||
@@ -266,7 +286,7 @@ Here's the new CSS:
|
||||
|
||||
These new rules are prefixed with `.started`. The `.` indicates that `.started` refers to a CSS class called "started". The new styles will be applied only to widgets that have these styles.
|
||||
|
||||
Some of the new styles have more than one selector separated by a space. The space indicates that the next selector should match a style. Let's look at one of these styles:
|
||||
Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles:
|
||||
|
||||
```sass
|
||||
.started #start {
|
||||
@@ -274,7 +294,7 @@ Some of the new styles have more than one selector separated by a space. The spa
|
||||
}
|
||||
```
|
||||
|
||||
The purpose of this CSS is to hide the start button when the stopwatch is started. The `.started` selector matches any widget with a "started" CSS class. While "#start" matches a child widget with an id of "start". The rule "display: none" tells Textual to hide that widget.
|
||||
The purpose of this CSS is to hide the start button when the stopwatch has started. The `.started` selector matches any widget with a "started" CSS class. While "#start" matches a child widget with an id of "start". The rule is applied to the button, so `"display: none"` tells Textual to _hide_ the button.
|
||||
|
||||
### Manipulating classes
|
||||
|
||||
@@ -284,7 +304,7 @@ You can add and remove CSS classes with the `add_class()` and `remove_class()` m
|
||||
|
||||
The following code adds a event handler for the `Button.Pressed` event.
|
||||
|
||||
```python title="stopwatch04.py" hl_lines="11-15"
|
||||
```python title="stopwatch04.py" hl_lines="13-18"
|
||||
--8<-- "docs/examples/introduction/stopwatch04.py"
|
||||
```
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from rich.repr import Result, rich_repr
|
||||
|
||||
from . import events
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from . import _clock
|
||||
from ._types import MessageTarget
|
||||
|
||||
@@ -119,6 +120,7 @@ class Timer:
|
||||
count = 0
|
||||
_repeat = self._repeat
|
||||
_interval = self._interval
|
||||
await self._active.wait()
|
||||
start = _clock.get_time_no_wait()
|
||||
while _repeat is None or count <= _repeat:
|
||||
next_timer = start + ((count + 1) * _interval)
|
||||
@@ -131,16 +133,20 @@ class Timer:
|
||||
if wait_time:
|
||||
await _clock.sleep(wait_time)
|
||||
count += 1
|
||||
await self._active.wait()
|
||||
try:
|
||||
await self._tick(next_timer=next_timer, count=count)
|
||||
except EventTargetGone:
|
||||
break
|
||||
await self._active.wait()
|
||||
|
||||
async def _tick(self, *, next_timer: float, count: int) -> None:
|
||||
"""Triggers the Timer's action: either call its callback, or sends an event to its target"""
|
||||
if self._callback is not None:
|
||||
try:
|
||||
await invoke(self._callback)
|
||||
except Exception as error:
|
||||
app = active_app.get()
|
||||
app.on_exception(error)
|
||||
else:
|
||||
event = events.Timer(
|
||||
self.sender,
|
||||
|
||||
@@ -251,7 +251,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
title: Reactive[str] = Reactive("Textual")
|
||||
sub_title: Reactive[str] = Reactive("")
|
||||
dark = Reactive(False)
|
||||
dark: Reactive[bool] = Reactive(False)
|
||||
|
||||
@property
|
||||
def devtools_enabled(self) -> bool:
|
||||
|
||||
@@ -7,3 +7,7 @@ class TextualError(Exception):
|
||||
|
||||
class NoWidget(TextualError):
|
||||
"""Specified widget was not found."""
|
||||
|
||||
|
||||
class RenderError(TextualError):
|
||||
"""An object could not be rendered."""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import total_ordering
|
||||
import inspect
|
||||
from asyncio import CancelledError, Queue, QueueEmpty, Task
|
||||
from functools import partial
|
||||
@@ -15,6 +14,7 @@ from ._timer import Timer, TimerCallback
|
||||
from .case import camel_to_snake
|
||||
from .events import Event
|
||||
from .message import Message
|
||||
from .reactive import Reactive
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
@@ -167,7 +167,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
def set_timer(
|
||||
self,
|
||||
delay: float,
|
||||
callback: TimerCallback = None,
|
||||
callback: TimerCallback | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
pause: bool = False,
|
||||
@@ -260,6 +260,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
"""Process messages until the queue is closed."""
|
||||
_rich_traceback_guard = True
|
||||
|
||||
await Reactive.initialize_object(self)
|
||||
while not self._closed:
|
||||
try:
|
||||
message = await self.get_message()
|
||||
|
||||
@@ -39,10 +39,55 @@ class Reactive(Generic[ReactiveType]):
|
||||
*,
|
||||
layout: bool = False,
|
||||
repaint: bool = True,
|
||||
init: bool = False,
|
||||
) -> None:
|
||||
"""Create a Reactive Widget attribute,
|
||||
|
||||
Args:
|
||||
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to False.
|
||||
"""
|
||||
self._default = default
|
||||
self.layout = layout
|
||||
self.repaint = repaint
|
||||
self._layout = layout
|
||||
self._repaint = repaint
|
||||
self._init = init
|
||||
|
||||
@classmethod
|
||||
def init(
|
||||
cls,
|
||||
default: ReactiveType | Callable[[], ReactiveType],
|
||||
*,
|
||||
layout: bool = False,
|
||||
repaint: bool = True,
|
||||
) -> Reactive:
|
||||
"""A reactive variable that calls watchers and compute on initialize (post mount).
|
||||
|
||||
Args:
|
||||
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Reactive: _description_
|
||||
"""
|
||||
return cls(default, layout=layout, repaint=repaint, init=True)
|
||||
|
||||
@classmethod
|
||||
async def initialize_object(cls, obj: object) -> None:
|
||||
"""Call any watchers / computes for the first time.
|
||||
|
||||
Args:
|
||||
obj (Reactable): An object with Reactive descriptors
|
||||
"""
|
||||
|
||||
startswith = str.startswith
|
||||
for key in obj.__class__.__dict__.keys():
|
||||
if startswith(key, "_init_"):
|
||||
name = key[6:]
|
||||
default = getattr(obj, key)
|
||||
setattr(obj, name, default() if callable(default) else default)
|
||||
|
||||
def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
|
||||
|
||||
@@ -56,10 +101,13 @@ class Reactive(Generic[ReactiveType]):
|
||||
|
||||
self.name = name
|
||||
self.internal_name = f"_reactive_{name}"
|
||||
default = self._default
|
||||
|
||||
if self._init:
|
||||
setattr(owner, f"_init_{name}", default)
|
||||
else:
|
||||
setattr(
|
||||
owner,
|
||||
self.internal_name,
|
||||
self._default() if callable(self._default) else self._default,
|
||||
owner, self.internal_name, default() if callable(default) else default
|
||||
)
|
||||
|
||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||
@@ -69,15 +117,15 @@ class Reactive(Generic[ReactiveType]):
|
||||
name = self.name
|
||||
current_value = getattr(obj, self.internal_name, None)
|
||||
validate_function = getattr(obj, f"validate_{name}", None)
|
||||
first_set = getattr(obj, f"{self.internal_name}__first_set", True)
|
||||
first_set = getattr(obj, f"__first_set_{self.internal_name}", True)
|
||||
if callable(validate_function):
|
||||
value = validate_function(value)
|
||||
if current_value != value or first_set:
|
||||
setattr(obj, f"{self.internal_name}__first_set", False)
|
||||
setattr(obj, f"__first_set_{self.internal_name}", False)
|
||||
setattr(obj, self.internal_name, value)
|
||||
self.check_watchers(obj, name, current_value)
|
||||
if self.layout or self.repaint:
|
||||
obj.refresh(repaint=self.repaint, layout=self.layout)
|
||||
if self._layout or self._repaint:
|
||||
obj.refresh(repaint=self._repaint, layout=self._layout)
|
||||
|
||||
@classmethod
|
||||
def check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
|
||||
|
||||
@@ -38,7 +38,7 @@ class Screen(Widget):
|
||||
}
|
||||
"""
|
||||
|
||||
dark = Reactive(False)
|
||||
dark: Reactive[bool] = Reactive(False)
|
||||
|
||||
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
||||
super().__init__(name=name, id=id)
|
||||
|
||||
@@ -102,7 +102,6 @@ class Widget(DOMNode):
|
||||
self._repaint_required = False
|
||||
self._default_layout = VerticalLayout()
|
||||
self._animate: BoundAnimator | None = None
|
||||
self._reactive_watches: dict[str, Callable] = {}
|
||||
self.highlight_style: Style | None = None
|
||||
|
||||
self._vertical_scrollbar: ScrollBar | None = None
|
||||
|
||||
@@ -2,10 +2,28 @@ from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
|
||||
from rich.protocol import is_renderable
|
||||
|
||||
from ..errors import RenderError
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
def _check_renderable(renderable: object):
|
||||
"""Check if a renderable conforms to the Rich Console protocol
|
||||
(https://rich.readthedocs.io/en/latest/protocol.html)
|
||||
|
||||
Args:
|
||||
renderable (object): A potentially renderable object.
|
||||
|
||||
Raises:
|
||||
RenderError: If the object can not be rendered.
|
||||
"""
|
||||
if not is_renderable(renderable):
|
||||
raise RenderError(
|
||||
f"unable to render {renderable!r}; A string, Text, or other Rich renderable is required"
|
||||
)
|
||||
|
||||
|
||||
class Static(Widget):
|
||||
CSS = """
|
||||
Static {
|
||||
@@ -23,10 +41,12 @@ class Static(Widget):
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.renderable = renderable
|
||||
_check_renderable(renderable)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return self.renderable
|
||||
|
||||
def update(self, renderable: RenderableType) -> None:
|
||||
_check_renderable(renderable)
|
||||
self.renderable = renderable
|
||||
self.refresh()
|
||||
|
||||
Reference in New Issue
Block a user