mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
committed by
GitHub
parent
ee0d407067
commit
4148b1d450
22
docs/examples/widgets/progress_bar.css
Normal file
22
docs/examples/widgets/progress_bar.css
Normal 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;
|
||||
}
|
||||
40
docs/examples/widgets/progress_bar.py
Normal file
40
docs/examples/widgets/progress_bar.py
Normal 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()
|
||||
34
docs/examples/widgets/progress_bar_isolated.py
Normal file
34
docs/examples/widgets/progress_bar_isolated.py
Normal 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()
|
||||
46
docs/examples/widgets/progress_bar_isolated_.py
Normal file
46
docs/examples/widgets/progress_bar_isolated_.py
Normal 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()
|
||||
22
docs/examples/widgets/progress_bar_styled.css
Normal file
22
docs/examples/widgets/progress_bar_styled.css
Normal 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;
|
||||
}
|
||||
35
docs/examples/widgets/progress_bar_styled.py
Normal file
35
docs/examples/widgets/progress_bar_styled.py
Normal 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()
|
||||
47
docs/examples/widgets/progress_bar_styled_.py
Normal file
47
docs/examples/widgets/progress_bar_styled_.py
Normal 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()
|
||||
143
docs/widgets/progress_bar.md
Normal file
143
docs/widgets/progress_bar.md
Normal 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
|
||||
Reference in New Issue
Block a user