Implement a Progress Bar widget. (#2333)

* First prototype of PB.

* Repurpose UnderlineBar.

* Factor out 'Bar' widget.

* Revert "Factor out 'Bar' widget."

This reverts commit 0bb4871adf.

* Add Bar widget.

* Cap progress at 100%.

* Add skeleton for the ETA label.

[skip ci]

* Add ETA display.

* Improve docstrings.

* Directly compute percentage.

* Watch percentage changes directly.

[skip ci]

* Documentation.

* Make reactive percentage private.

Instead, we create a public read-only percentage property.

* Update griffe to fix documentation issue.

Related issues: #1572, https://github.com/mkdocstrings/griffe/issues/128.
Related PRs: https://github.com/mkdocstrings/griffe/pull/135.

* Add example and docs.

* Address review feedback.

[skip ci]

* More documentation.

* Add tests.

* Changelog.

* More tests.

* Fix/fake tests.

* Final tweaks.
This commit is contained in:
Rodrigo Girão Serrão
2023-04-26 15:25:39 +01:00
committed by GitHub
parent ee0d407067
commit 4148b1d450
20 changed files with 2242 additions and 262 deletions

View File

@@ -0,0 +1,22 @@
Container {
overflow: hidden hidden;
height: auto;
}
Center {
margin-top: 1;
margin-bottom: 1;
layout: horizontal;
}
ProgressBar {
padding-left: 3;
}
Input {
width: 16;
}
VerticalScroll {
height: auto;
}

View File

@@ -0,0 +1,40 @@
from textual.app import App, ComposeResult
from textual.containers import Center, VerticalScroll
from textual.widgets import Button, Header, Input, Label, ProgressBar
class FundingProgressApp(App[None]):
CSS_PATH = "progress_bar.css"
TITLE = "Funding tracking"
def compose(self) -> ComposeResult:
yield Header()
with Center():
yield Label("Funding: ")
yield ProgressBar(total=100, show_eta=False) # (1)!
with Center():
yield Input(placeholder="$$$")
yield Button("Donate")
yield VerticalScroll(id="history")
def on_button_pressed(self) -> None:
self.add_donation()
def on_input_submitted(self) -> None:
self.add_donation()
def add_donation(self) -> None:
text_value = self.query_one(Input).value
try:
value = int(text_value)
except ValueError:
return
self.query_one(ProgressBar).advance(value)
self.query_one(VerticalScroll).mount(Label(f"Donation for ${value} received!"))
self.query_one(Input).value = ""
if __name__ == "__main__":
FundingProgressApp().run()

View File

@@ -0,0 +1,34 @@
from textual.app import App, ComposeResult
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
class IndeterminateProgressBar(App[None]):
BINDINGS = [("s", "start", "Start")]
progress_timer: Timer
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
with Center():
with Middle():
yield ProgressBar()
yield Footer()
def on_mount(self) -> None:
"""Set up a timer to simulate progess happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
"""Called automatically to advance the progress bar."""
self.query_one(ProgressBar).advance(1)
def action_start(self) -> None:
"""Start the progress tracking."""
self.query_one(ProgressBar).update(total=100)
self.progress_timer.resume()
if __name__ == "__main__":
IndeterminateProgressBar().run()

View File

@@ -0,0 +1,46 @@
from textual.app import App, ComposeResult
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
class IndeterminateProgressBar(App[None]):
BINDINGS = [("s", "start", "Start")]
progress_timer: Timer
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
with Center():
with Middle():
yield ProgressBar()
yield Footer()
def on_mount(self) -> None:
"""Set up a timer to simulate progess happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
"""Called automatically to advance the progress bar."""
self.query_one(ProgressBar).advance(1)
def action_start(self) -> None:
"""Start the progress tracking."""
self.query_one(ProgressBar).update(total=100)
self.progress_timer.resume()
def key_f(self) -> None:
# Freeze time for the indeterminate progress bar.
self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
def key_t(self) -> None:
# Freeze time to show always the same ETA.
self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
self.query_one(ProgressBar).update(total=100, progress=39)
def key_u(self) -> None:
self.query_one(ProgressBar).update(total=100, progress=100)
if __name__ == "__main__":
IndeterminateProgressBar().run()

View File

@@ -0,0 +1,22 @@
Bar > .bar--indeterminate {
color: $primary;
background: $secondary;
}
Bar > .bar--bar {
color: $primary;
background: $primary 30%;
}
Bar > .bar--complete {
color: $error;
}
PercentageStatus {
text-style: reverse;
color: $secondary;
}
ETAStatus {
text-style: underline;
}

View File

@@ -0,0 +1,35 @@
from textual.app import App, ComposeResult
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
class StyledProgressBar(App[None]):
BINDINGS = [("s", "start", "Start")]
CSS_PATH = "progress_bar_styled.css"
progress_timer: Timer
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
with Center():
with Middle():
yield ProgressBar()
yield Footer()
def on_mount(self) -> None:
"""Set up a timer to simulate progess happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
"""Called automatically to advance the progress bar."""
self.query_one(ProgressBar).advance(1)
def action_start(self) -> None:
"""Start the progress tracking."""
self.query_one(ProgressBar).update(total=100)
self.progress_timer.resume()
if __name__ == "__main__":
StyledProgressBar().run()

View File

@@ -0,0 +1,47 @@
from textual.app import App, ComposeResult
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
class StyledProgressBar(App[None]):
BINDINGS = [("s", "start", "Start")]
CSS_PATH = "progress_bar_styled.css"
progress_timer: Timer
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
with Center():
with Middle():
yield ProgressBar()
yield Footer()
def on_mount(self) -> None:
"""Set up a timer to simulate progess happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
"""Called automatically to advance the progress bar."""
self.query_one(ProgressBar).advance(1)
def action_start(self) -> None:
"""Start the progress tracking."""
self.query_one(ProgressBar).update(total=100)
self.progress_timer.resume()
def key_f(self) -> None:
# Freeze time for the indeterminate progress bar.
self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
def key_t(self) -> None:
# Freeze time to show always the same ETA.
self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
self.query_one(ProgressBar).update(total=100, progress=39)
def key_u(self) -> None:
self.query_one(ProgressBar).update(total=100, progress=100)
if __name__ == "__main__":
StyledProgressBar().run()

View File

@@ -0,0 +1,143 @@
# ProgressBar
A widget that displays progress on a time-consuming task.
- [ ] Focusable
- [ ] Container
## Examples
### Progress Bar in Isolation
The example below shows a progress bar in isolation.
It shows the progress bar in:
- its indeterminate state, when the `total` progress hasn't been set yet;
- the middle of the progress; and
- the completed state.
=== "Indeterminate state"
```{.textual path="docs/examples/widgets/progress_bar_isolated_.py" press="f"}
```
=== "39% done"
```{.textual path="docs/examples/widgets/progress_bar_isolated_.py" press="t"}
```
=== "Completed"
```{.textual path="docs/examples/widgets/progress_bar_isolated_.py" press="u"}
```
=== "progress_bar_isolated.py"
```python
--8<-- "docs/examples/widgets/progress_bar_isolated.py"
```
### Complete App Example
The example below shows a simple app with a progress bar that is keeping track of a fictitious funding level for an organisation.
=== "Output"
```{.textual path="docs/examples/widgets/progress_bar.py"}
```
=== "Output (partial funding)"
```{.textual path="docs/examples/widgets/progress_bar.py" press="tab,1,5,enter,2,0,enter"}
```
=== "Output (full funding)"
```{.textual path="docs/examples/widgets/progress_bar.py" press="tab,1,5,enter,2,0,enter,6,5,enter"}
```
=== "progress_bar.py"
```python hl_lines="15"
--8<-- "docs/examples/widgets/progress_bar.py"
```
1. We create a progress bar with a total of `100` steps and we hide the ETA countdown because we are not keeping track of a continuous, uninterrupted task.
=== "progress_bar.css"
```sass
--8<-- "docs/examples/widgets/progress_bar.css"
```
### Custom Styling
This shows a progress bar with custom styling.
Refer to the [section below](#styling-the-progress-bar) for more information.
=== "Indeterminate state"
```{.textual path="docs/examples/widgets/progress_bar_styled_.py" press="f"}
```
=== "39% done"
```{.textual path="docs/examples/widgets/progress_bar_styled_.py" press="t"}
```
=== "Completed"
```{.textual path="docs/examples/widgets/progress_bar_styled_.py" press="u"}
```
=== "progress_bar_styled.py"
```python
--8<-- "docs/examples/widgets/progress_bar_styled.py"
```
=== "progress_bar_styled.css"
```sass
--8<-- "docs/examples/widgets/progress_bar_styled.css"
```
## Reactive Attributes
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `percentage` | `float | None` | `None` | The read-only percentage of progress that has been made. This is `None` if the `total` hasn't been set. |
| `progress` | `float` | `0` | The number of steps of progress already made. |
| `total` | `float | None` | `None` | The total number of steps that we are keeping track of. |
## Messages
- [ProgressBar.Completed][textual.widgets.ProgressBar.Completed]
- [ProgressBar.Started][textual.widgets.ProgressBar.Started]
## Styling the Progress Bar
The progress bar is composed of three sub-widgets that can be styled independently:
| Widget name | ID | Description |
|-------------|----|-------------|
| `Bar` | `#bar` | The bar that visually represents the progress made. |
| `PercentageStatus` | `#percentage` | [Label](./label.md) that shows the percentage of completion. |
| `ETAStatus` | `#eta` | [Label](./label.md) that shows the estimated time to completion. |
### Bar Component Classes
::: textual.widgets._progress_bar.Bar.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
---
::: textual.widgets.ProgressBar
options:
heading_level: 2