diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index ee7f514aa..3eb8336fa 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -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() diff --git a/docs/examples/introduction/stopwatch05.py b/docs/examples/introduction/stopwatch05.py index a3d6e3f7a..957dc6f69 100644 --- a/docs/examples/introduction/stopwatch05.py +++ b/docs/examples/introduction/stopwatch05.py @@ -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.""" diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 02a399249..13d2a8263 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -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: diff --git a/docs/introduction.md b/docs/introduction.md index ad1ed62cf..db7a5fb32 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -326,14 +326,19 @@ You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use Here we have created two reactive attributes: `start_time` and `time`. These attributes will be available on `self` as if you had assigned them in `__init__`. If you write to either of these attributes the widget will update automatically. -!!! info +!!! 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. diff --git a/src/textual/app.py b/src/textual/app.py index c9a56cb80..17674dd0d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 8a0053214..ca1a83660 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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__": diff --git a/src/textual/screen.py b/src/textual/screen.py index 42c90eb1d..f0b84b173 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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 diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 7ae1dcb45..689a09016 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index 5ba5de0bf..35f8c2b43 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: