diff --git a/docs/examples/introduction/stopwatch05.py b/docs/examples/introduction/stopwatch05.py index 346cda13d..a3d6e3f7a 100644 --- a/docs/examples/introduction/stopwatch05.py +++ b/docs/examples/introduction/stopwatch05.py @@ -12,18 +12,19 @@ class TimeDisplay(Static): 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) + self.set_interval(1 / 60, self.update_time) def update_time(self) -> None: - self.time = monotonic() + """Method to update the time to the current time.""" + self.time = monotonic() - self.start_time + + 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}") class Stopwatch(Static): @@ -41,7 +42,7 @@ class Stopwatch(Static): 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") + yield TimeDisplay() class StopwatchApp(App): diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 2609236d1..4b97e43e6 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -9,15 +9,9 @@ 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}") + total = Reactive(0.0) def on_mount(self) -> None: """Event handler called when widget is added to the app.""" @@ -27,6 +21,12 @@ class TimeDisplay(Static): """Method to update time to current.""" self.time = self.total + (monotonic() - self.start_time) + 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 start(self) -> None: """Method to start (or resume) time updating.""" self.start_time = monotonic() @@ -41,21 +41,21 @@ class TimeDisplay(Static): def reset(self): """Method to reset the time display to zero.""" self.total = 0 - self.time = self.start_time + self.time = 0 class Stopwatch(Static): + """A Textual app to manage stopwatches.""" + 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() @@ -64,7 +64,7 @@ class Stopwatch(Static): 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") + yield TimeDisplay() class StopwatchApp(App): diff --git a/docs/introduction.md b/docs/introduction.md index 5e058fa70..95bfd332e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -315,4 +315,78 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state ```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"} ``` +## Reactive attributes +A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call `refresh()` to display new data. However, Textual prefers to do this automatically via _reactive_ attributes. + +You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use this feature to create a timer that displays elapsed time and keeps it updated. + +```python title="stopwatch04.py" hl_lines="1 5 12-27" +--8<-- "docs/examples/introduction/stopwatch05.py" +``` + +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 + + `Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties. + +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 automatically be assigned the value returned by `monotonic()`. + +The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start. + +To update the time automatically we will use the `set_interval` method which tells Textual to call a function at given intervals. The `on_mount` method does this to call `self.update_time` 60 times a second. + +In `update_time` we calculate the time elapsed since the widget started and assign 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. + +Because `watch_time` watches the `time` attribute, when we update `self.time` 60 times a second we also implicitly call `watch_time` which converts the elapsed time in to a string and updates the widget with a call to `self.update`. + +The end result is that all the `Stopwatch` widgets show the time elapsed since the widget was created: + +```{.textual path="docs/examples/introduction/stopwatch05.py" title="stopwatch05.py"} +``` + +We've seen how we can update widgets with a timer. But we still need to wire buttons to the widget + +### Wiring the Stopwatch + +To make a useful stopwatch we will need to add a little more code to `TimeDisplay`, to be able to start, stop, and reset the timer. + +```python title="stopwatch06.py" hl_lines="14-44 50-60" +--8<-- "docs/examples/introduction/stopwatch06.py" +``` + +Here's a summary of the changes made to `TimeDisplay`. + +- We've added a `total` reactive attribute to store the total time elapsed between clicking Stop and Start. +- The call to `set_interval` has grown a `pause=True` attribute which starts the timer in pause mode. This is because we don't want to update the timer until the user hits the Start button. +- We've stored the result of `set_interval` which returns a timer object. We will use this later to _resume_ the timer when we start the Stopwatch. +- We've added `start()`, `stop()`, and `reset()` methods. + +The `on_button_pressed` method on `Stopwatch` has grown some code to manage the time display when the user clicked a button. Let's look at that in detail: + +```python + 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") + elif event.button.id == "stop": + time_display.stop() + self.remove_class("started") + elif event.button.id == "reset": + time_display.reset() +``` + +This code supplies the missing features and makes our app really useful. If you run it now you can start and stop timers independently. + +- The first line calls `query_one` to get a reference to the `TimeDisplay` widget. This method queries for a child widget. You may supply a Widget type or a CSS selector. +- We call the `TimeDisplay` method that matches the button pressed. +- We add the "started" class when the Stopwatch is started, and remove it when it is stopped. + + +```{.textual path="docs/examples/introduction/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"} +``` diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 22729d4fb..88770bd95 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -11,41 +11,36 @@ if TYPE_CHECKING: def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str: """A superfences formatter to insert a SVG screenshot.""" - path = attrs.get("path") + path: str = attrs["path"] _press = attrs.get("press", None) - press = _press.split(",") if _press else [] + press = [*_press.split(",")] if _press else ["_"] title = attrs.get("title") - os.environ["TEXTUAL"] = "headless" - os.environ["TEXTUAL_SCREENSHOT"] = "0.15" - if title: - os.environ["TEXTUAL_SCREENSHOT_TITLE"] = title - else: - os.environ.pop("TEXTUAL_SCREENSHOT_TITLE", None) os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") print(f"screenshotting {path!r}") - if path: - cwd = os.getcwd() - examples_path, filename = os.path.split(path) - try: - os.chdir(examples_path) - with open(filename, "rt") as python_code: - source = python_code.read() - app_vars: dict[str, object] = {} - exec(source, app_vars) - app: App = cast("App", app_vars["app"]) - app.run(press=press or None) - svg = app._screenshot - finally: - os.chdir(cwd) - else: - app_vars = {} + cwd = os.getcwd() + examples_path, filename = os.path.split(path) + try: + os.chdir(examples_path) + with open(filename, "rt") as python_code: + source = python_code.read() + app_vars: dict[str, object] = {} exec(source, app_vars) - app = cast(App, app_vars["app"]) - app.run(press=press or None) + + app: App = cast("App", app_vars["app"]) + app.run( + quit_after=5, + press=press or ["ctrl+c"], + headless=True, + screenshot=True, + screenshot_title=title, + ) svg = app._screenshot + finally: + os.chdir(cwd) + assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index c500c65fe..e1c1a2c33 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -559,9 +559,12 @@ class App(Generic[ReturnType], DOMNode): def run( self, + *, quit_after: float | None = None, headless: bool = False, press: Iterable[str] | None = None, + screenshot: bool = False, + screenshot_title: str | None = None, ) -> ReturnType | None: """The main entry point for apps. @@ -570,6 +573,8 @@ class App(Generic[ReturnType], DOMNode): to run forever. Defaults to None. headless (bool, optional): Run in "headless" mode (don't write to stdout). press (str, optional): An iterable of keys to simulate being pressed. + screenshot (str, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. + screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. Returns: ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. @@ -591,13 +596,20 @@ class App(Generic[ReturnType], DOMNode): assert press driver = app._driver assert driver is not None + await asyncio.sleep(0.05) for key in press: if key == "_": - await asyncio.sleep(0.02) + print("(pause)") + await asyncio.sleep(0.05) else: print(f"press {key!r}") driver.send_event(events.Key(self, key)) await asyncio.sleep(0.02) + if screenshot: + self._screenshot = self.export_screenshot( + title=screenshot_title + ) + await self.shutdown() async def press_keys_task(): """Press some keys in the background.""" @@ -866,6 +878,19 @@ class App(Generic[ReturnType], DOMNode): widget.post_message_no_wait(events.Focus(self)) widget.emit_no_wait(events.DescendantFocus(self)) + def _reset_focus(self, widget: Widget) -> None: + """Reset the focus when a widget is removed + + Args: + widget (Widget): A widget that is removed. + """ + for sibling in widget.siblings: + if sibling.can_focus: + sibling.focus() + break + else: + self.focused = None + async def _set_mouse_over(self, widget: Widget | None) -> None: """Called when the mouse is over another widget. @@ -1120,8 +1145,7 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): A Widget to unregister """ - if self.focused is widget: - self.focused = None + self._reset_focus(widget) if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) @@ -1369,8 +1393,9 @@ class App(Generic[ReturnType], DOMNode): async def _on_remove(self, event: events.Remove) -> None: widget = event.widget - if widget.has_parent: - widget.parent.refresh(layout=True) + parent = widget.parent + if parent is not None: + parent.refresh(layout=True) remove_widgets = list(widget.walk_children(Widget, with_self=True)) for child in remove_widgets: diff --git a/src/textual/widget.py b/src/textual/widget.py index f479158d1..efcceacef 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -146,6 +146,17 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + @property + def siblings(self) -> list[Widget]: + """Get the widget's siblings (self is removed from the return list).""" + parent = self.parent + if parent is not None: + siblings = list(parent.children) + siblings.remove(self) + return siblings + else: + return [] + @property def allow_vertical_scroll(self) -> bool: """Check if vertical scroll is permitted.""" @@ -1285,6 +1296,10 @@ class Widget(DOMNode): self.scroll_page_right() event.stop() + def _on_hide(self, event: events.Hide) -> None: + if self.has_focus: + self.app._reset_focus(self) + def key_home(self) -> bool: if self.is_scrollable: self.scroll_home()