introduction text

This commit is contained in:
Will McGugan
2022-08-23 11:25:03 +01:00
parent b7b32f0429
commit b24c7a8f34
9 changed files with 70 additions and 28 deletions

View File

@@ -69,13 +69,7 @@ class Stopwatch(Static):
class StopwatchApp(App):
"""Manage the timers."""
def on_load(self) -> None:
"""Called when the app first loads."""
self.bind("a", "add_stopwatch", description="Add")
self.bind("r", "remove_stopwatch", description="Remove")
self.bind("d", "toggle_dark", description="Dark mode")
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Called to ad widgets to the app."""
@@ -83,6 +77,12 @@ class StopwatchApp(App):
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
def on_load(self) -> None:
"""Called when the app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
self.bind("a", "add_stopwatch", description="Add")
self.bind("r", "remove_stopwatch", description="Remove")
def action_add_stopwatch(self) -> None:
"""An action to add a timer."""
new_stopwatch = Stopwatch()
@@ -91,7 +91,7 @@ class StopwatchApp(App):
def action_remove_stopwatch(self) -> None:
"""Called to remove a timer."""
timers = self.query("#timers Stopwatch")
timers = self.query("Stopwatch")
if timers:
timers.last().remove()

View File

@@ -10,7 +10,7 @@ class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = Reactive(monotonic)
time = Reactive(0.0)
time = Reactive.init(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""

View File

@@ -10,7 +10,7 @@ class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = Reactive(monotonic)
time = Reactive(0.0)
time = Reactive.init(0.0)
total = Reactive(0.0)
def on_mount(self) -> None:

View File

@@ -328,12 +328,17 @@ Here we have created two reactive attributes: `start_time` and `time`. These att
!!! info
`Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties.
The `monotonic` function in this example is imported from the standard library `time` module. It is similar to `time.time` but won't go backwards if the system clock is changed.
The first argument to `Reactive` may be a default value or a callable that returns the default value. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. When `TimeDisplay` is mounted, the `start_time` attribute will be assigned the result of `monotonic()`.
The first argument to `Reactive` may be a default value or a callable that returns the default value. In the example, the default for `start_time` is `monotonic`. When `TimeDisplay` is mounted, the `start_time` attribute will be assigned the result of `monotonic()`.
The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start.
!!! info
The `time` attribute is created with `Reactive.init` which calls watch methods when the widget is mounted. See below for an explanation of watch methods.
In the `on_mount` handler method, the call to `set_interval` creates a timer object which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers.
If you implement a method that begins with `watch_` followed by the name of a reactive attribute (making it a _watch method_), that method will be called when the attribute is modified.
@@ -393,3 +398,32 @@ If you run stopwatch06.py you will be able to use the stopwatches independently.
```
The only remaining feature of the Stopwatch app let to implement is the ability to add and remove timers.
## Dynamic widgets
It's convenient to build a user interface with the `compose` method. We may also want to add or remove widgets while the app is running.
To add a new child widget call `mount()` on the parent. To remove a widget, call it's `remove()` method.
Let's use these to implement adding and removing stopwatches to our app.
```python title="stopwatch.py" hl_lines="83-84 86-90 92-96"
--8<-- "docs/examples/introduction/stopwatch.py"
```
We've added two new actions: `action_add_stopwatch` to add a new stopwatch, and `action_remove_stopwatch`) to remove the last stopwatch. The `on_load` handler binds these actions to the ++a++ and ++r++ keys.
The `action_add_stopwatch` method creates and mounts a new `Stopwatch` instance. Note the call to `query_one` with a CSS selector of `"#timers"` which gets the timer's container via its ID (assigned in `compose`). Once mounted, the new Stopwatch will appear in the terminal. That last line in `action_add_stopwatch` calls `scroll_visible` which will scroll the container to make the new Stopwatch visible (if necessary).
The `action_remove_stopwatch` calls `query` with a CSS selector of `"Stopwatch"` which gets all the `Stopwatch` widgets. If there are stopwatches then the action calls `last()` to get the last stopwatch, and `remove()` to remove it.
If you run `stopwatch.py` now you can add a new stopwatch with the ++a++ key and remove a stopwatch with ++r++.
```{.textual path="docs/examples/introduction/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,_,_,_,_,tab,_"}
```
## What next?
Congratulations on building your first Textual application! This introduction has covered a lot of ground. If you are the type that prefers to learn a framework by coding, feel free. You could tweak stopwatch.py or look through the examples.
Read the guide for the full details on how to build sophisticated TUI applications with Textual.

View File

@@ -940,12 +940,12 @@ class App(Generic[ReturnType], DOMNode):
is_renderable(renderable) for renderable in renderables
), "Can only call panic with strings or Rich renderables"
prerendered = [
pre_rendered = [
Segments(self.console.render(renderable, self.console.options))
for renderable in renderables
]
self._exit_renderables.extend(prerendered)
self._exit_renderables.extend(pre_rendered)
self.close_messages_no_wait()
def on_exception(self, error: Exception) -> None:

View File

@@ -320,13 +320,16 @@ class Stylesheet:
animate (bool, optional): Animate changed rules. Defaults to ``False``.
"""
# TODO: Need to optimize to make applying stylesheet more efficient
# I think we can pre-calculate which rules may be applicable to a given node
# Dictionary of rule attribute names e.g. "text_background" to list of tuples.
# The tuples contain the rule specificity, and the value for that rule.
# We can use this to determine, for a given rule, whether we should apply it
# or not by examining the specificity. If we have two rules for the
# same attribute, then we can choose the most specific rule and use that.
rule_attributes: dict[str, list[tuple[Specificity6, object]]]
rule_attributes = defaultdict(list)
rule_attributes = {}
_check_rule = self._check_rule
@@ -338,7 +341,9 @@ class Stylesheet:
for key, rule_specificity, value in rule.styles.extract_rules(
base_specificity, is_default_rules, tie_breaker
):
rule_attributes[key].append((rule_specificity, value))
rule_attributes.setdefault(key, []).append(
(rule_specificity, value)
)
# For each rule declared for this node, keep only the most specific one
get_first_item = itemgetter(0)
@@ -433,11 +438,13 @@ class Stylesheet:
apply = self.apply
for node in root.walk_children():
apply(node, animate=animate)
if isinstance(node, Widget):
if isinstance(node, Widget) and node.is_scrollable:
if node.show_vertical_scrollbar:
apply(node.vertical_scrollbar)
if node.show_horizontal_scrollbar:
apply(node.horizontal_scrollbar)
if node.show_horizontal_scrollbar and node.show_vertical_scrollbar:
apply(node.scrollbar_corner)
if __name__ == "__main__":

View File

@@ -13,6 +13,7 @@ from ._callback import invoke
from .geometry import Offset, Region, Size
from ._compositor import Compositor, MapGeometry
from .messages import CallbackType
from ._profile import timer
from .reactive import Reactive
from .renderables.blank import Blank
from ._timer import Timer

View File

@@ -232,14 +232,14 @@ class ScrollBar(Widget):
style=scrollbar_style,
)
def on_hide(self, event: events.Hide) -> None:
def _on_hide(self, event: events.Hide) -> None:
if self.grabbed:
self.release_mouse()
def on_enter(self, event: events.Enter) -> None:
def _on_enter(self, event: events.Enter) -> None:
self.mouse_over = True
def on_leave(self, event: events.Leave) -> None:
def _on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
async def action_scroll_down(self) -> None:
@@ -254,18 +254,18 @@ class ScrollBar(Widget):
def action_released(self) -> None:
self.capture_mouse(False)
async def on_mouse_up(self, event: events.MouseUp) -> None:
async def _on_mouse_up(self, event: events.MouseUp) -> None:
if self.grabbed:
self.release_mouse()
def on_mouse_capture(self, event: events.MouseCapture) -> None:
def _on_mouse_capture(self, event: events.MouseCapture) -> None:
self.grabbed = event.mouse_position
self.grabbed_position = self.position
def on_mouse_release(self, event: events.MouseRelease) -> None:
def _on_mouse_release(self, event: events.MouseRelease) -> None:
self.grabbed = None
async def on_mouse_move(self, event: events.MouseMove) -> None:
async def _on_mouse_move(self, event: events.MouseMove) -> None:
if self.grabbed and self.window_size:
x: float | None = None
y: float | None = None

View File

@@ -12,6 +12,7 @@ from typing import (
ClassVar,
Collection,
Iterable,
Iterator,
NamedTuple,
)
@@ -626,7 +627,7 @@ class Widget(DOMNode):
Returns:
bool: True if this widget may be scrolled.
"""
return self.is_container
return self.styles.layout is not None or bool(self.children)
@property
def layer(self) -> str:
@@ -699,7 +700,6 @@ class Widget(DOMNode):
Returns:
bool: True if the scroll position changed, otherwise False.
"""
self.log(self, x, y, verbosity=0)
scrolled_x = scrolled_y = False
if animate:
# TODO: configure animation speed
@@ -1035,7 +1035,7 @@ class Widget(DOMNode):
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
# self.refresh(layout=True)
self.refresh(layout=True)
self.scroll_to(self.scroll_x, self.scroll_y)
# self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
else: