ai-station/.venv/lib/python3.12/site-packages/textual/widgets/_progress_bar.py

386 lines
13 KiB
Python

"""Implements a progress bar widget."""
from __future__ import annotations
from typing import Optional, Type
from rich.style import Style
from textual._types import UnusedParameter
from textual.app import ComposeResult, RenderResult
from textual.clock import Clock
from textual.color import Gradient
from textual.eta import ETA
from textual.geometry import clamp
from textual.reactive import reactive
from textual.renderables.bar import Bar as BarRenderable
from textual.widget import Widget
from textual.widgets import Label
UNUSED = UnusedParameter()
"""Sentinel for method signatures."""
class Bar(Widget, can_focus=False):
"""The bar portion of the progress bar."""
COMPONENT_CLASSES = {"bar--bar", "bar--complete", "bar--indeterminate"}
"""
The bar sub-widget provides the component classes that follow.
These component classes let you modify the foreground and background color of the
bar in its different states.
| Class | Description |
| :- | :- |
| `bar--bar` | Style of the bar (may be used to change the color). |
| `bar--complete` | Style of the bar when it's complete. |
| `bar--indeterminate` | Style of the bar when it's in an indeterminate state. |
"""
DEFAULT_CSS = """
Bar {
width: 32;
height: 1;
&> .bar--bar {
color: $primary;
background: $surface;
}
&> .bar--indeterminate {
color: $error;
background: $surface;
}
&> .bar--complete {
color: $success;
background: $surface;
}
}
"""
percentage: reactive[float | None] = reactive[Optional[float]](None)
"""The percentage of progress that has been completed."""
gradient: reactive[Gradient | None] = reactive(None)
"""An optional gradient."""
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
clock: Clock | None = None,
gradient: Gradient | None = None,
bar_renderable: Type[BarRenderable] = BarRenderable,
):
"""Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar]."""
self._clock = (clock or Clock()).clone()
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.set_reactive(Bar.gradient, gradient)
self.bar_renderable = bar_renderable
def _validate_percentage(self, percentage: float | None) -> float | None:
"""Avoid updating the bar, if the percentage increase is too small to render."""
width = self.size.width * 2
return (
None
if percentage is None
else (int(percentage * width) / width if width else percentage)
)
def watch_percentage(self, percentage: float | None) -> None:
"""Manage the timer that enables the indeterminate bar animation."""
if percentage is not None:
self.auto_refresh = None
else:
self.auto_refresh = 1 / 15
def render(self) -> RenderResult:
"""Render the bar with the correct portion filled."""
if self.percentage is None:
return self.render_indeterminate()
else:
bar_style = (
self.get_component_rich_style("bar--bar")
if self.percentage < 1
else self.get_component_rich_style("bar--complete")
)
return self.bar_renderable(
highlight_range=(0, self.size.width * self.percentage),
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
gradient=self.gradient,
)
def render_indeterminate(self) -> RenderResult:
"""Render a frame of the indeterminate progress bar animation."""
width = self.size.width
highlighted_bar_width = 0.25 * width
# Width used to enable the visual effect of the bar going into the corners.
total_imaginary_width = width + highlighted_bar_width
start: float
end: float
if self.app.animation_level == "none":
start = 0
end = width
else:
speed = 30 # Cells per second.
# Compute the position of the bar.
start = (
(speed * self._clock.time) % (2 * total_imaginary_width)
if total_imaginary_width
else 0
)
if start > total_imaginary_width:
# If the bar is to the right of its width, wrap it back from right to left.
start = 2 * total_imaginary_width - start # = (tiw - (start - tiw))
start -= highlighted_bar_width
end = start + highlighted_bar_width
bar_style = self.get_component_rich_style("bar--indeterminate")
return self.bar_renderable(
highlight_range=(max(0, start), min(end, width)),
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
)
class PercentageStatus(Label):
"""A label to display the percentage status of the progress bar."""
DEFAULT_CSS = """
PercentageStatus {
width: 5;
content-align-horizontal: right;
}
"""
percentage: reactive[int | None] = reactive[Optional[int]](None)
"""The percentage of progress that has been completed."""
def _validate_percentage(self, percentage: float | None) -> int | None:
return None if percentage is None else round(percentage * 100)
def render(self) -> RenderResult:
return "--%" if self.percentage is None else f"{self.percentage}%"
class ETAStatus(Label):
"""A label to display the estimated time until completion of the progress bar."""
DEFAULT_CSS = """
ETAStatus {
width: 9;
content-align-horizontal: right;
}
"""
eta: reactive[float | None] = reactive[Optional[float]](None)
"""Estimated number of seconds till completion, or `None` if no estimate is available."""
def render(self) -> RenderResult:
"""Render the ETA display."""
eta = self.eta
if eta is None:
return "--:--:--"
else:
minutes, seconds = divmod(round(eta), 60)
hours, minutes = divmod(minutes, 60)
if hours > 999999:
return "+999999h"
elif hours > 99:
return f"{hours}h"
else:
return f"{hours:02}:{minutes:02}:{seconds:02}"
class ProgressBar(Widget, can_focus=False):
"""A progress bar widget."""
DEFAULT_CSS = """
ProgressBar {
width: auto;
height: 1;
layout: horizontal;
}
"""
progress: reactive[float] = reactive(0.0)
"""The progress so far, in number of steps."""
total: reactive[float | None] = reactive[Optional[float]](None)
"""The total number of steps associated with this progress bar, when known.
The value `None` will render an indeterminate progress bar.
"""
percentage: reactive[float | None] = reactive[Optional[float]](None)
"""The percentage of progress that has been completed.
The percentage is a value between 0 and 1 and the returned value is only
`None` if the total progress of the bar hasn't been set yet.
Example:
```py
progress_bar = ProgressBar()
print(progress_bar.percentage) # None
progress_bar.update(total=100)
progress_bar.advance(50)
print(progress_bar.percentage) # 0.5
```
"""
_display_eta: reactive[int | None] = reactive[Optional[int]](None)
gradient: reactive[Gradient | None] = reactive(None)
"""Optional gradient object (will replace CSS styling in bar)."""
BAR_RENDERABLE: Type[BarRenderable] = BarRenderable
"""BarRenderable to use for rendering the bar-part of the ProgressBar"""
def __init__(
self,
total: float | None = None,
*,
show_bar: bool = True,
show_percentage: bool = True,
show_eta: bool = True,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
clock: Clock | None = None,
gradient: Gradient | None = None,
):
"""Create a Progress Bar widget.
The progress bar uses "steps" as the measurement unit.
Example:
```py
class MyApp(App):
def compose(self):
yield ProgressBar(total=100)
def key_space(self):
self.query_one(ProgressBar).advance(5)
```
Args:
total: The total number of steps in the progress if known.
show_bar: Whether to show the bar portion of the progress bar.
show_percentage: Whether to show the percentage status of the bar.
show_eta: Whether to show the ETA countdown of the progress bar.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
clock: An optional clock object (leave as default unless testing).
gradient: An optional Gradient object (will replace CSS styles in the bar).
"""
self._clock = clock or Clock()
self._eta = ETA()
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.total = total
self.show_bar = show_bar
self.show_percentage = show_percentage
self.show_eta = show_eta
self.set_reactive(ProgressBar.gradient, gradient)
def on_mount(self) -> None:
self.update()
self.set_interval(1, self.update)
self._clock.reset()
def compose(self) -> ComposeResult:
if self.show_bar:
yield (
Bar(id="bar", clock=self._clock, bar_renderable=self.BAR_RENDERABLE)
.data_bind(ProgressBar.percentage)
.data_bind(ProgressBar.gradient)
)
if self.show_percentage:
yield PercentageStatus(id="percentage").data_bind(ProgressBar.percentage)
if self.show_eta:
yield ETAStatus(id="eta").data_bind(eta=ProgressBar._display_eta)
def _validate_total(self, total: float | None) -> float | None:
"""Ensure the total is not negative."""
if total is None:
return total
return max(0, total)
def _compute_percentage(self) -> float | None:
"""Keep the percentage of progress updated automatically.
This will report a percentage of `1` if the total is zero.
"""
if self.total:
return clamp(self.progress / self.total, 0.0, 1.0)
elif self.total == 0:
return 1.0
return None
def _watch_progress(self, progress: float) -> None:
"""Perform update when progress is modified."""
self.update(progress=progress)
def _watch_total(self, total: float) -> None:
"""Update when the total is modified."""
self.update(total=total)
def advance(self, advance: float = 1) -> None:
"""Advance the progress of the progress bar by the given amount.
Example:
```py
progress_bar.advance(10) # Advance 10 steps.
```
Args:
advance: Number of steps to advance progress by.
"""
self.update(advance=advance)
def update(
self,
*,
total: None | float | UnusedParameter = UNUSED,
progress: float | UnusedParameter = UNUSED,
advance: float | UnusedParameter = UNUSED,
) -> None:
"""Update the progress bar with the given options.
Example:
```py
progress_bar.update(
total=200, # Set new total to 200 steps.
progress=50, # Set the progress to 50 (out of 200).
)
```
Args:
total: New total number of steps.
progress: Set the progress to the given number of steps.
advance: Advance the progress by this number of steps.
"""
current_time = self._clock.time
if not isinstance(total, UnusedParameter):
if total is None or total != self.total:
self._eta.reset()
self.total = total
def add_sample() -> None:
"""Add a new sample."""
if self.progress is not None and self.total:
self._eta.add_sample(current_time, self.progress / self.total)
if not isinstance(progress, UnusedParameter):
self.progress = progress
add_sample()
if not isinstance(advance, UnusedParameter):
self.progress += advance
add_sample()
self._display_eta = (
None if self.total is None else self._eta.get_eta(current_time)
)