diff --git a/docs/blog/images/spinners-and-pbs-in-textual/bar-in-textual.gif b/docs/blog/images/spinners-and-pbs-in-textual/bar-in-textual.gif new file mode 100644 index 000000000..aae579ce4 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/bar-in-textual.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/fake-pause.gif b/docs/blog/images/spinners-and-pbs-in-textual/fake-pause.gif new file mode 100644 index 000000000..3ba9a9972 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/fake-pause.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/final-experiment.gif b/docs/blog/images/spinners-and-pbs-in-textual/final-experiment.gif new file mode 100644 index 000000000..3e533b444 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/final-experiment.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/indeterminate-rich-progress-bar.gif b/docs/blog/images/spinners-and-pbs-in-textual/indeterminate-rich-progress-bar.gif new file mode 100644 index 000000000..8dbf8dcc4 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/indeterminate-rich-progress-bar.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/live-display.gif b/docs/blog/images/spinners-and-pbs-in-textual/live-display.gif new file mode 100644 index 000000000..803408f2c Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/live-display.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/pause-resume-appears-to-work.gif b/docs/blog/images/spinners-and-pbs-in-textual/pause-resume-appears-to-work.gif new file mode 100644 index 000000000..b3dce4944 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/pause-resume-appears-to-work.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/rich-progress-bar.gif b/docs/blog/images/spinners-and-pbs-in-textual/rich-progress-bar.gif new file mode 100644 index 000000000..4318e403b Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/rich-progress-bar.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/spinner.gif b/docs/blog/images/spinners-and-pbs-in-textual/spinner.gif new file mode 100644 index 000000000..d92f0c83b Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/spinner.gif differ diff --git a/docs/blog/images/spinners-and-pbs-in-textual/stopwatch-timedisplay.gif b/docs/blog/images/spinners-and-pbs-in-textual/stopwatch-timedisplay.gif new file mode 100644 index 000000000..894b1a394 Binary files /dev/null and b/docs/blog/images/spinners-and-pbs-in-textual/stopwatch-timedisplay.gif differ diff --git a/docs/blog/posts/spinners-and-pbs-in-textual.md b/docs/blog/posts/spinners-and-pbs-in-textual.md new file mode 100644 index 000000000..a92ba590a --- /dev/null +++ b/docs/blog/posts/spinners-and-pbs-in-textual.md @@ -0,0 +1,551 @@ +--- +draft: false +date: 2022-11-24 +categories: + - DevLog +authors: + - rodrigo +--- + +# Spinners and progress bars in Textual + +![](../images/spinners-and-pbs-in-textual/live-display.gif) + +One of the things I love about mathematics is that you can solve a problem just by **guessing** the correct answer. +That is a perfectly valid strategy for solving a problem. +The only thing you need to do after guessing the answer is to prove that your guess is correct. + +I used this strategy, to some success, to display spinners and indeterminate progress bars from [Rich](github.com/textualize/rich) in [Textual](https://github.com/textualize/textual). + + + + +## Display an indeterminate progress bar in Textual + +I have been playing around with Textual and recently I decided I needed an indeterminate progress bar to show that some data was loading. +Textual is likely to [get progress bars in the future](https://github.com/Textualize/rich/issues/2665#issuecomment-1326229220), but I don't want to wait for the future! +I want my progress bars now! +Textual builds on top of Rich, so if [Rich has progress bars](https://rich.readthedocs.io/en/stable/progress.html), I reckoned I could use them in my Textual apps. + + +### Progress bars in Rich + +Creating a progress bar in Rich is as easy as opening up the documentation for `Progress` and copying & pasting the code. + + +=== "Code" + + ```py + import time + from rich.progress import track + + for _ in track(range(20), description="Processing..."): + time.sleep(0.5) # Simulate work being done + ``` + +=== "Output" + + ![](../images/spinners-and-pbs-in-textual/rich-progress-bar.gif) + + +The function `track` provides a very convenient interface for creating progress bars that keep track of a well-specified number of steps. +In the example above, we were keeping track of some task that was going to take 20 steps to complete. +(For example, if we had to process a list with 20 elements.) +However, I am looking for indeterminate progress bars. + +Scrolling further down the documentation for `rich.progress` I found what I was looking for: + +=== "Code" + + ```py hl_lines="5" + import time + from rich.progress import Progress + + with Progress() as progress: + _ = progress.add_task("Loading...", total=None) # (1)! + while True: + time.sleep(0.01) + ``` + + 1. Setting `total=None` is what makes it an indeterminate progress bar. + +=== "Output" + + ![](../images/spinners-and-pbs-in-textual/indeterminate-rich-progress-bar.gif) + +So, putting an indeterminate progress bar on the screen is _easy_. +Now, I only needed to glue that together with the little I know about Textual to put an indeterminate progress bar in a Textual app. + + +### Guessing what is what and what goes where + +What I want is to have an indeterminate progress bar inside my Textual app. +Something that looks like this: + +![](../images/spinners-and-pbs-in-textual/bar-in-textual.gif) + +The GIF above shows just the progress bar. +Obviously, the end goal is to have the progress bar be part of a Textual app that does something. + +So, when I set out to do this, my first thought went to the stopwatch app in the [Textual tutorial](https://textual.textualize.io/tutorial) because it has a widget that updates automatically, the `TimeDisplay`. +Below you can find the essential part of the code for the `TimeDisplay` widget and a small animation of it updating when the stopwatch is started. + + +=== "`TimeDisplay` widget" + + ```py hl_lines="14 18 22" + from time import monotonic + + from textual.reactive import reactive + from textual.widgets import Static + + + class TimeDisplay(Static): + """A widget to display elapsed time.""" + + start_time = reactive(monotonic) + time = reactive(0.0) + total = reactive(0.0) + + 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 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}") + ``` + +=== "Output" + + ![](../images/spinners-and-pbs-in-textual/stopwatch-timedisplay.gif) + + +The reason the time display updates magically is due to the three methods that I highlighted in the code above: + + 1. The method `on_mount` is called when the `TimeDisplay` widget is mounted on the app and, in it, we use the method `set_interval` to let Textual know that every `1 / 60` seconds we would like to call the method `update_time`. (In other words, we would like `update_time` to be called 60 times per second.) + 2. In turn, the method `update_time` (which is called _automatically_ a bunch of times per second) will update the reactive attribute `time`. _When_ this attribute update happens, the method `watch_time` kicks in. + 3. The method `watch_time` is a watcher method and gets called whenever the attribute `self.time` is assigned to. + So, if the method `update_time` is called a bunch of times per second, the watcher method `watch_time` is also called a bunch of times per second. In it, we create a nice representation of the time that has elapsed and we use the method `update` to update the time that is being displayed. + +I thought it would be reasonable if a similar mechanism needed to be in place for my progress bar, but then I realised that the progress bar seems to update itself... +Looking at the indeterminate progress bar example from before, the only thing going on was that we used `time.sleep` to stop our program for a bit. +We didn't do _anything_ to update the progress bar... +Look: + +```py +with Progress() as progress: + _ = progress.add_task("Loading...", total=None) # (1)! + while True: + time.sleep(0.01) +``` + +After pondering about this for a bit, I realised I would not need a watcher method for anything. +The watcher method would only make sense if I needed to update an attribute related to some sort of artificial progress, but that clearly isn't needed to get the bar going... + +At some point, I realised that the object `progress` is the object of interest. +At first, I thought `progress.add_task` would return the progress bar, but it actually returns the integer ID of the task added, so the object of interest is `progress`. +Because I am doing nothing to update the bar explicitly, the object `progress` must be updating itself. + +The Textual documentation also says that we can [build widgets from Rich renderables](https://textual.textualize.io/guide/widgets/#rich-renderables), so I concluded that if `Progress` were a renderable, then I could inherit from `Static` and use the method `update` to update the widget with my instance of `Progress` directly. +I gave it a try and I put together this code: + +```py hl_lines="10 11 15-17 20" +from rich.progress import Progress, BarColumn + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class IndeterminateProgress(Static): + def __init__(self): + super().__init__("") + self._bar = Progress(BarColumn()) # (1)! + self._bar.add_task("", total=None) # (2)! + + def on_mount(self) -> None: + # When the widget is mounted start updating the display regularly. + self.update_render = self.set_interval( + 1 / 60, self.update_progress_bar + ) # (3)! + + def update_progress_bar(self) -> None: + self.update(self._bar) # (4)! + + +class MyApp(App): + def compose(self) -> ComposeResult: + yield IndeterminateProgress() + + +if __name__ == "__main__": + app = MyApp() + app.run() +``` + + 1. Create an instance of `Progress` that just cares about the bar itself (Rich progress bars can have a label, an indicator for the time left, etc). + 2. We add the indeterminate task with `total=None` for the indeterminate progress bar. + 3. When the widget is mounted on the app, we want to start calling `update_progress_bar` 60 times per second. + 4. To update the widget of the progress bar we just call the method `Static.update` with the `Progress` object because `self._bar` is a Rich renderable. + +And lo and behold, it worked: + +![](../images/spinners-and-pbs-in-textual/bar-in-textual.gif) + + +### Proving it works + +I finished writing this piece of code and I was ecstatic because it was working! +After all, my Textual app starts and renders the progress bar. +And so, I shared this simple app with someone who wanted to do a similar thing, but I was left with a bad taste in my mouth because I couldn't really connect all the dots and explain exactly why it worked. + +!!! warning "Plot twist" + + By the end of the blog post, I will be much closer to a full explanation! + + +## Display a Rich spinner in a Textual app + +A day after creating my basic `IndeterminateProgress` widget, I found someone that was trying to display a Rich spinner in a Textual app. +Actually, it was someone that had [filed an issue against Rich](https://github.com/Textualize/rich/issues/2665). +They didn't ask “how can I display a Rich spinner in a Textual app?”, but they filed an alleged bug that crept up on them _when_ they tried displaying a spinner in a Textual app. + +When reading the issue I realised that displaying a Rich spinner looked very similar to displaying a Rich progress bar, so I made a tiny change to my code and tried to run it: + +=== "Code" + + ```py hl_lines="10" + from rich.spinner import Spinner + + from textual.app import App, ComposeResult + from textual.widgets import Static + + + class SpinnerWidget(Static): + def __init__(self): + super().__init__("") + self._spinner = Spinner("moon") # (1)! + + def on_mount(self) -> None: + self.update_render = self.set_interval(1 / 60, self.update_spinner) + + def update_spinner(self) -> None: + self.update(self._spinner) + + + class MyApp(App[None]): + def compose(self) -> ComposeResult: + yield SpinnerWidget() + + + MyApp().run() + ``` + + 1. Instead of creating an instance of `Progress`, we create an instance of `Spinner` and save it so we can call `self.update(self._spinner)` later on. + +=== "Spinner running" + + ![](../images/spinners-and-pbs-in-textual/spinner.gif) + + +## Losing the battle against pausing the animations + +After creating the progress bar and spinner widgets I thought of creating the little display that was shown at the beginning of the blog post: + +![](../images/spinners-and-pbs-in-textual/live-display.gif) + +When writing the code for this app, I realised both widgets had a lot of shared code and logic and I tried abstracting away their common functionality. +That led to the code shown below (more or less) where I implemented the updating functionality in `IntervalUpdater` and then let the `IndeterminateProgressBar` and `SpinnerWidget` instantiate the correct Rich renderable. + +```py hl_lines="8-15 22 30" +from rich.progress import Progress, BarColumn +from rich.spinner import Spinner + +from textual.app import RenderableType +from textual.widgets import Button, Static + + +class IntervalUpdater(Static): + _renderable_object: RenderableType # (1)! + + def update_rendering(self) -> None: # (2)! + self.update(self._renderable_object) + + def on_mount(self) -> None: # (3)! + self.interval_update = self.set_interval(1 / 60, self.update_rendering) + + +class IndeterminateProgressBar(IntervalUpdater): + """Basic indeterminate progress bar widget based on rich.progress.Progress.""" + def __init__(self) -> None: + super().__init__("") + self._renderable_object = Progress(BarColumn()) # (4)! + self._renderable_object.add_task("", total=None) + + +class SpinnerWidget(IntervalUpdater): + """Basic spinner widget based on rich.spinner.Spinner.""" + def __init__(self, style: str) -> None: + super().__init__("") + self._renderable_object = Spinner(style) # (5)! +``` + + 1. Instances of `IntervalUpdate` should set the attribute `_renderable_object` to the instance of the Rich renderable that we want to animate. + 2. The methods `update_rendering` and `on_mount` are exactly the same as what we had before, both in the progress bar widget and in the spinner widget. + 3. The methods `update_rendering` and `on_mount` are exactly the same as what we had before, both in the progress bar widget and in the spinner widget. + 4. For an indeterminate progress bar we set the attribute `_renderable_object` to an instance of `Progress`. + 5. For a spinner we set the attribute `_renderable_object` to an instance of `Spinner`. + +But I wanted something more! +I wanted to make my app similar to the stopwatch app from the terminal and thus wanted to add a “Pause” and a “Resume” button. +These buttons should, respectively, stop the progress bar and the spinner animations and resume them. + +Below you can see the code I wrote and a short animation of the app working. + + +=== "App code" + + ```py hl_lines="18-19 21-22 60-70 55-56" + from rich.progress import Progress, BarColumn + from rich.spinner import Spinner + + from textual.app import App, ComposeResult, RenderableType + from textual.containers import Grid, Horizontal, Vertical + from textual.widgets import Button, Static + + + class IntervalUpdater(Static): + _renderable_object: RenderableType + + def update_rendering(self) -> None: + self.update(self._renderable_object) + + def on_mount(self) -> None: + self.interval_update = self.set_interval(1 / 60, self.update_rendering) + + def pause(self) -> None: # (1)! + self.interval_update.pause() + + def resume(self) -> None: # (2)! + self.interval_update.resume() + + + class IndeterminateProgressBar(IntervalUpdater): + """Basic indeterminate progress bar widget based on rich.progress.Progress.""" + def __init__(self) -> None: + super().__init__("") + self._renderable_object = Progress(BarColumn()) + self._renderable_object.add_task("", total=None) + + + class SpinnerWidget(IntervalUpdater): + """Basic spinner widget based on rich.spinner.Spinner.""" + def __init__(self, style: str) -> None: + super().__init__("") + self._renderable_object = Spinner(style) + + + class LiveDisplayApp(App[None]): + """App showcasing some widgets that update regularly.""" + CSS_PATH = "myapp.css" + + def compose(self) -> ComposeResult: + yield Vertical( + Grid( + SpinnerWidget("moon"), + IndeterminateProgressBar(), + SpinnerWidget("aesthetic"), + SpinnerWidget("bouncingBar"), + SpinnerWidget("earth"), + SpinnerWidget("dots8Bit"), + ), + Horizontal( + Button("Pause", id="pause"), # (3)! + Button("Resume", id="resume", disabled=True), + ), + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: # (4)! + pressed_id = event.button.id + assert pressed_id is not None + for widget in self.query(IntervalUpdater): + getattr(widget, pressed_id)() # (5)! + + for button in self.query(Button): # (6)! + if button.id == pressed_id: + button.disabled = True + else: + button.disabled = False + + + LiveDisplayApp().run() + ``` + + 1. The method `pause` looks at the attribute `interval_update` (returned by the method `set_interval`) and tells it to stop calling the method `update_rendering` 60 times per second. + 2. The method `resume` looks at the attribute `interval_update` (returned by the method `set_interval`) and tells it to resume calling the method `update_rendering` 60 times per second. + 3. We set two distinct IDs for the two buttons so we can easily tell which button was pressed and _what_ the press of that button means. + 4. The event handler `on_button_pressed` will wait for button presses and will take care of pausing or resuming the animations. + 5. We look for all of the instances of `IntervalUpdater` in our app and use a little bit of introspection to call the correct method (`pause` or `resume`) in our widgets. Notice this was only possible because the buttons were assigned IDs that matched the names of the methods. (I love Python :snake:!) + 6. We go through our two buttons to disable the one that was just pressed and to enable the other one. + +=== "CSS" + + ```css + Screen { + align: center middle; + } + + Horizontal { + height: 1fr; + align-horizontal: center; + } + + Button { + margin: 0 3 0 3; + } + + Grid { + height: 4fr; + align: center middle; + grid-size: 3 2; + grid-columns: 8; + grid-rows: 1; + grid-gutter: 1; + border: gray double; + } + + IntervalUpdater { + content-align: center middle; + } + ``` + +=== "Output" + + ![](../images/spinners-and-pbs-in-textual/pause-resume-appears-to-work.gif) + + +If you think this was a lot, take a couple of deep breaths before moving on. + +The only issue with my app is that... it does not work! +If you press the button to pause the animations, it looks like the widgets are paused. +However, you can see that if I move my mouse over the paused widgets, they update: + +![](../images/spinners-and-pbs-in-textual/fake-pause.gif) + +Obviously, that caught me by surprise, in the sense that I expected it work. +On the other hand, this isn't surprising. +After all, I thought I had guessed how I could solve the problem of displaying these Rich renderables that update live and I thought I knew how to pause and resume their animations, but I hadn't convinced myself I knew exactly why it worked. + +!!! warning + + This goes to show that sometimes it is not the best idea to commit code that you wrote and that works if you don't know _why_ it works. + The code might _seem_ to work and yet have deficiencies that will hurt you further down the road. + +As it turns out, the reason why pausing is not working is that I did not grok why the rendering worked in the first place... +So I had to go down that rabbit hole first. + + +## Understanding the Rich rendering magic + +### How `Static.update` works + +The most basic way of creating a Textual widget is to inherit from `Widget` and implement the method `render` that just returns the _thing_ that must be printed on the screen. +Then, the widget `Static` provides some functionality on top of that: the method `update`. + +The method `Static.update(renderable)` is used to tell the widget in question that its method `render` (called when the widget needs to be drawn) should just return `renderable`. +So, if the implementation of the method `IntervalUpdater.update_rendering` (the method that gets called 60 times per second) is this: + +```py +class IntervalUpdater(Static): + # ... + def update_rendering(self) -> None: + self.update(self._renderable_object) +``` + +Then, we are essentially saying “hey, the thing in `self._renderable_object` is what must be returned whenever Textual asks you to render yourself. +So, this really proves that both `Progress` and `Spinner` from Rich are renderables. +But what is more, this shows that my implementation of `IntervalUpdater` can be simplified greatly! +In fact, we can boil it down to just this: + +```py hl_lines="4-6 9" +class IntervalUpdater(Static): + _renderable_object: RenderableType + + def __init__(self, renderable_object: RenderableType) -> None: # (1)! + super().__init__(renderable_object) # (2)! + + def on_mount(self) -> None: + self.interval_update = self.set_interval(1 / 60, self.refresh) # (3)! +``` + + 1. To create an instance of `IntervalUpdater`, now we give it the Rich renderable that we want displayed. +If this Rich renderable is something that updates over time, then those changes will be reflected in the rendering. + 2. We initialise `Static` with the renderable object itself, instead of initialising with the empty string `""` and then updating repeatedly. + 3. We call `self.refresh` 60 times per second. +We don't need the auxiliary method `update_rendering` because this widget (an instance of `Static`) already knows what its renderable is. + +Once you understand the code above you will realise that the previous implementation of `update_rendering` was actually doing superfluous work because the repeated calls to `self.update` always had the exact same object. +Again, we see strong evidence that the Rich progress bars and the spinners have the inherent ability to display a different representation of themselves as time goes by. + +### How Rich spinners get updated + +I kept seeing strong evidence that Rich spinners and Rich progress bars updated their own rendering but I still did not have actual proof. +So, I went digging around to see how `Spinner` was implemented and I found this code ([from the file `spinner.py`](https://github.com/Textualize/rich/blob/5f4e93efb159af99ed51f1fbfd8b793bb36448d9/rich/spinner.py) at the time of writing): + +```py hl_lines="7 10 13-15" +class Spinner: + # ... + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + yield self.render(console.get_time()) # (1)! + + # ... + def render(self, time: float) -> "RenderableType": # (2)! + # ... + + frame_no = ((time - self.start_time) * self.speed) / ( # (3)! + self.interval / 1000.0 + ) + self.frame_no_offset + # ... + + # ... +``` + + 1. The Rich spinner implements the function `__rich_console__` that is supposed to return the result of rendering the spinner. +Instead, it defers its work to the method `render`... +However, to call the method `render`, we need to pass the argument `console.get_time()`, which the spinner uses to know in which state it is! + 2. The method `render` takes a `time` and returns a renderable! + 3. To determine the frame number (the current look of the spinner) we do some calculations with the “current time”, given by the parameter `time`, and the time when the spinner started! + +The snippet of code shown above, from the implementation of `Spinner`, explains why moving the mouse over a spinner (or a progress bar) that supposedly was paused makes it move. +We no longer get repeated updates (60 times per second) because we told our app that we wanted to pause the result of `set_interval`, so we no longer get automatic updates. +However, moving the mouse over the spinners and the progress bar makes Textual want to re-render them and, when it does, it figures out that time was not frozen (obviously!) and so the spinners and the progress bar have a different frame to show. + +To get a better feeling for this, do the following experiment: + + 1. Run the command `textual console` in a terminal to open the Textual devtools console. + 2. Add a print statement like `print("Rendering from within spinner")` to the beginning of the method `Spinner.render` (from Rich). + 3. Add a print statement like `print("Rendering static")` to the beginning of the method `Static.render` (from Textual). + 4. Put a blank terminal and the devtools console side by side. + 5. Run the app: notice that you get a lot of both print statements. + 6. Hit the Pause button: the print statements stop. + 7. Move your mouse over a widget or two: you get a couple of print statements, one from the `Static.render` and another from the `Spinner.render`. + +The result of steps 6 and 7 are shown below. +Notice that, in the beginning of the animation, the screen on the right shows some prints but is quiet because no more prints are coming in. +When the mouse enters the screen and starts going over widgets, the screen on the right gets new prints in pairs, first from `Static.render` (which Textual calls to render the widget) and then from `Spinner.render` because ultimately we need to know how the Spinner looks. + +![](../images/spinners-and-pbs-in-textual/final-experiment.gif) + +Now, at this point, I made another educated guess and deduced that progress bars work in the same way! +I still have to prove it, and I guess I will do so in another blog post, coming soon, where our spinner and progress bar widgets can be properly paused! + +I will see you soon :wave: