diff --git a/docs/examples/widgets/progress_bar_styled_rainbow.css b/docs/examples/widgets/progress_bar_styled_rainbow.css
new file mode 100644
index 00000000000..63a94391738
--- /dev/null
+++ b/docs/examples/widgets/progress_bar_styled_rainbow.css
@@ -0,0 +1,22 @@
+Bar > .bar--indeterminate {
+ color: $primary;
+ background: $secondary;
+}
+
+Bar > .bar--bar {
+ color: blue;
+ background: $primary 30%;
+}
+
+Bar > .bar--complete {
+ color: red;
+}
+
+PercentageStatus {
+ text-style: reverse;
+ color: $secondary;
+}
+
+ETAStatus {
+ text-style: underline;
+}
diff --git a/docs/examples/widgets/progress_bar_styled_rainbow.py b/docs/examples/widgets/progress_bar_styled_rainbow.py
new file mode 100644
index 00000000000..dbf2345c7d6
--- /dev/null
+++ b/docs/examples/widgets/progress_bar_styled_rainbow.py
@@ -0,0 +1,38 @@
+from textual.app import App, ComposeResult
+from textual.containers import Center, Middle
+from textual.timer import Timer
+from textual.widgets import Footer, ProgressBar
+
+
+class StyledExtProgressBar(App[None]):
+ BINDINGS = [
+ ("s", "start", "Start"),
+ ]
+ CSS_PATH = "progress_bar_styled_rainbow.css"
+
+ progress_timer: Timer
+ """Timer to simulate progress happening."""
+
+ def compose(self) -> ComposeResult:
+ with Center():
+ with Middle():
+ yield ProgressBar(color_scheme="rainbow")
+ 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__":
+ app = StyledExtProgressBar()
+ app.run()
diff --git a/docs/examples/widgets/progress_bar_styled_rainbow_.py b/docs/examples/widgets/progress_bar_styled_rainbow_.py
new file mode 100644
index 00000000000..21b33e4dd62
--- /dev/null
+++ b/docs/examples/widgets/progress_bar_styled_rainbow_.py
@@ -0,0 +1,52 @@
+from textual.app import App, ComposeResult
+from textual.containers import Center, Middle
+from textual.timer import Timer
+from textual.widgets import Footer, ProgressBar
+
+
+class StyledExtProgressBar(App[None]):
+ BINDINGS = [
+ ("s", "start", "Start"),
+ ]
+ CSS_PATH = "progress_bar_styled_rainbow.css"
+
+ progress_timer: Timer
+ """Timer to simulate progress happening."""
+
+ def compose(self) -> ComposeResult:
+ with Center():
+ with Middle():
+ yield ProgressBar(color_scheme="rainbow")
+ 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_1(self) -> None:
+ self._action_common_keypress(10)
+
+ def key_5(self) -> None:
+ self._action_common_keypress(50)
+
+ def key_9(self) -> None:
+ self._action_common_keypress(90)
+
+ def _action_common_keypress(self, progress: int) -> None:
+ # Freeze time for the indeterminate progress bar.
+ self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 0
+ self.query_one(ProgressBar).update(total=100, progress=progress)
+ self.progress_timer.pause()
+
+if __name__ == "__main__":
+ app = StyledExtProgressBar()
+ app.run()
diff --git a/docs/examples/widgets/progress_bar_styled_thickness.py b/docs/examples/widgets/progress_bar_styled_thickness.py
new file mode 100644
index 00000000000..df85be9fe74
--- /dev/null
+++ b/docs/examples/widgets/progress_bar_styled_thickness.py
@@ -0,0 +1,38 @@
+from textual.app import App, ComposeResult
+from textual.containers import Center, Middle
+from textual.timer import Timer
+from textual.widgets import Footer, ProgressBar
+
+
+class StyledExtProgressBar(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(thickness=2)
+ 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__":
+ app = StyledExtProgressBar()
+ app.run()
diff --git a/docs/examples/widgets/progress_bar_styled_thickness_.py b/docs/examples/widgets/progress_bar_styled_thickness_.py
new file mode 100644
index 00000000000..250aba695dc
--- /dev/null
+++ b/docs/examples/widgets/progress_bar_styled_thickness_.py
@@ -0,0 +1,53 @@
+from textual.app import App, ComposeResult
+from textual.containers import Center, Middle
+from textual.timer import Timer
+from textual.widgets import Footer, ProgressBar
+
+
+class StyledExtProgressBar(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_0(self) -> None:
+ self._action_common_keypress(0)
+
+ def key_1(self) -> None:
+ self._action_common_keypress(1)
+
+ def key_2(self) -> None:
+ self._action_common_keypress(2)
+
+ def _action_common_keypress(self, thickness: int) -> None:
+ # Freeze time for the indeterminate progress bar.
+ self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 0
+ self.query_one(ProgressBar).query_one("#bar").thickness = thickness
+ self.query_one(ProgressBar).update(total=100, progress=50)
+ self.progress_timer.pause()
+
+if __name__ == "__main__":
+ app = StyledExtProgressBar()
+ app.run()
diff --git a/docs/widgets/progress_bar.md b/docs/widgets/progress_bar.md
index 1ef573b3f74..1f372ddbe2b 100644
--- a/docs/widgets/progress_bar.md
+++ b/docs/widgets/progress_bar.md
@@ -24,7 +24,7 @@ It shows the progress bar in:
=== "39% done"
- ```{.textual path="docs/examples/widgets/progress_bar_isolated_.py" press="t"}
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_.py" press="t"}
```
=== "Completed"
@@ -104,13 +104,75 @@ Refer to the [section below](#styling-the-progress-bar) for more information.
--8<-- "docs/examples/widgets/progress_bar_styled.css"
```
+### Rainbow Color Schemes
+
+The rainbow color scheme gradually changes the color of the bar as it progresses.
+The initial color is defined by the component class `bar--bar` and the final color by the component class `bar--complete`.
+(The gradient is computed in the HSL color space in the counterclockwise direction.)
+
+=== "Rainbow at 10%"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_rainbow_.py" press="1"}
+ ```
+
+=== "Rainbow at 50%"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_rainbow_.py" press="5"}
+ ```
+
+=== "Rainbow at 90%"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_rainbow_.py" press="9"}
+ ```
+=== "progress_bar_styled_rainbow.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/progress_bar_styled_rainbow.py"
+ ```
+
+=== "progress_bar_styled.css"
+
+ ```sass
+ --8<-- "docs/examples/widgets/progress_bar_styled_rainbow.css"
+ ```
+
+### Progress Bar Thickness Styling
+
+This shows a progress bar with custom bar thickness: thin, normal or thick.
+
+=== "Thin"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_thickness_.py" press="0"}
+ ```
+
+=== "Normal"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_thickness_.py" press="1"}
+ ```
+
+=== "Thick"
+
+ ```{.textual path="docs/examples/widgets/progress_bar_styled_thickness_.py" press="2"}
+ ```
+=== "progress_bar_styled_thickness.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/progress_bar_styled_thickness.py"
+ ```
+
+=== "progress_bar_styled.css"
+
+ ```sass
+ --8<-- "docs/examples/widgets/progress_bar_styled.css"
+ ```
+
## Reactive Attributes
| Name | Type | Default | Description |
| ------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------- |
-| `percentage` | `float | None` | The read-only percentage of progress that has been made. This is `None` if the `total` hasn't been set. |
+| `percentage` | `float` | `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` | The total number of steps that we are keeping track of. |
+| `total` | `float` | `None` | The total number of steps that we are keeping track of. |
## Styling the Progress Bar
@@ -119,10 +181,11 @@ The progress bar is composed of three sub-widgets that can be styled independent
| Widget name | ID | Description |
| ------------------ | ------------- | ---------------------------------------------------------------- |
-| `Bar` | `#bar` | The bar that visually represents the progress made. |
+| `Bar` | `#bar` | 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
diff --git a/src/textual/color.py b/src/textual/color.py
index acfba0a121e..1339e816008 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -393,9 +393,9 @@ def hsl_blend(
This method calculates a new color on a gradient using the HSL color space.
The position on the gradient is given by `factor`, which is a float between -1 and 1, where 0 is the original color, and 1 or -1 is the `destination` color.
- A negative `factor` affects the direction of the hue angle, a positive number is in the clockwise direction, a negative number is in the counter-clockwise direction.
- For lightess and saturation, only the absolute value of `factor` is used.
- A value of `gradient` between the two extremes produces a color somewhere between the two end points.
+ The sign of `factor` affects the direction of the hue angle, a positive number is in the clockwise direction, a negative number is in the counter-clockwise direction.
+ For lightness and saturation, only the absolute value of `factor` is used.
+ A value of `factor` between the two extremes produces a color somewhere between the two end points.
Args:
destination: Another color.
@@ -405,10 +405,10 @@ def hsl_blend(
Returns:
A new color.
"""
- abs_factor = factor if factor >= 0 else -1.0 * factor
+ abs_factor = abs(factor)
if factor == 0:
return self
- elif factor >= 1 or factor <= -1:
+ elif abs_factor >= 1:
return destination
hsl_1 = self.hsl
@@ -421,19 +421,11 @@ def hsl_blend(
else:
new_alpha = alpha
- # When the factor is > 0, hue is clockwise, otherwise it is counter-clockwise.
- if factor > 0:
- if hsl_1.h <= hsl_2.h:
- new_h = hsl_1.h + (hsl_2.h - hsl_1.h) * abs_factor
- else:
- new_h = hsl_1.h + (hsl_2.h + 1.0 - hsl_1.h) * abs_factor
- new_h = new_h - 1.0 if new_h >= 1.0 else new_h
+ sign = 1 if factor > 0 else -1
+ if (sign * hsl_1.h) <= (sign * hsl_2.h):
+ new_h = hsl_1.h + (hsl_2.h - hsl_1.h) * abs_factor
else:
- if hsl_1.h >= hsl_2.h:
- new_h = hsl_1.h + (hsl_2.h - hsl_1.h) * abs_factor
- else:
- new_h = (hsl_1.h + (hsl_2.h - 1.0 - hsl_1.h) * abs_factor)
- new_h = new_h + 1.0 if new_h < 0.0 else new_h
+ new_h = (hsl_1.h + (hsl_2.h + sign - hsl_1.h) * abs_factor) % 1
new_s = hsl_1.s + (hsl_2.s - hsl_1.s) * abs_factor
new_l = hsl_1.l + (hsl_2.l - hsl_1.l) * abs_factor
diff --git a/src/textual/renderables/bar.py b/src/textual/renderables/bar.py
index 1c9b6a11341..0dcc0ac3d5d 100644
--- a/src/textual/renderables/bar.py
+++ b/src/textual/renderables/bar.py
@@ -1,10 +1,21 @@
from __future__ import annotations
+from typing_extensions import Literal
+
from rich.console import Console, ConsoleOptions, RenderResult
from rich.style import StyleType
from rich.text import Text
+BarThickness = Literal[0, 1, 2]
+"""The values of the valid bar thicknesses.
+
+These are the thicknesses that can be used with a [`Bar`][textual.widgets.Bar].
+"""
+_VALID_BAR_THICKNESSES = {0, 1, 2}
+_DEFAULT_BAR_THICKNESS = 1
+
+
class Bar:
"""Thin horizontal bar with a portion highlighted.
@@ -22,12 +33,14 @@ def __init__(
background_style: StyleType = "grey37",
clickable_ranges: dict[str, tuple[int, int]] | None = None,
width: int | None = None,
+ thickness: BarThickness = _DEFAULT_BAR_THICKNESS,
) -> None:
self.highlight_range = highlight_range
self.highlight_style = highlight_style
self.background_style = background_style
self.clickable_ranges = clickable_ranges or {}
self.width = width
+ self.thickness = thickness
def __rich_console__(
self, console: Console, options: ConsoleOptions
@@ -35,9 +48,7 @@ def __rich_console__(
highlight_style = console.get_style(self.highlight_style)
background_style = console.get_style(self.background_style)
- half_bar_right = "╸"
- half_bar_left = "╺"
- bar = "━"
+ bar, half_bar_right, half_bar_left = self._bar_characters
width = self.width or options.max_width
start, end = self.highlight_range
@@ -95,6 +106,15 @@ def __rich_console__(
yield output_bar
+ @property
+ def _bar_characters(self) -> tuple(str, str, str):
+ bar_code = [
+ ("─", "╴", "╶"),
+ ("━", "╸", "╺"),
+ ("█", "▌", "▐"),
+ ]
+ return bar_code[self.thickness]
+
if __name__ == "__main__":
import random
@@ -130,7 +150,7 @@ def frange(start, end, step):
from rich.live import Live
- bar = Bar(highlight_range=(0, 4.5), width=80)
+ bar = Bar(highlight_range=(0, 4.5), width=80, thickness=1)
with Live(bar, refresh_per_second=60) as live:
while True:
bar.highlight_range = (
diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py
index 617d3908924..9b0839cb083 100644
--- a/src/textual/widgets/_progress_bar.py
+++ b/src/textual/widgets/_progress_bar.py
@@ -5,20 +5,45 @@
from math import ceil
from time import monotonic
from typing import Callable, Optional
+from typing_extensions import Literal
from rich.style import Style
from textual.geometry import clamp
from ..app import ComposeResult, RenderResult
+from ..color import Color
from ..containers import Horizontal
+from ..css._error_tools import friendly_list
from ..reactive import reactive
from ..renderables.bar import Bar as BarRenderable
+from ..renderables.bar import (
+ BarThickness,
+ _DEFAULT_BAR_THICKNESS,
+ _VALID_BAR_THICKNESSES,
+)
from ..timer import Timer
from ..widget import Widget
from ..widgets import Label
+BarColorScheme = Literal["default", "rainbow"]
+"""The names of the valid bar color schemes.
+
+These are the color schemes that can be used with a [`Bar`][textual.widgets.Bar].
+"""
+_VALID_BAR_COLOR_SCHEMES = {"default", "rainbow"}
+_DEFAULT_BAR_COLOR_SCHEME = "default"
+
+
+class InvalidBarColorScheme(Exception):
+ """Exception raised if an invalid bar color scheme is used."""
+
+
+class InvalidBarThickness(Exception):
+ """Exception raised if an invalid bar thickness is used."""
+
+
class Bar(Widget, can_focus=False):
"""The bar portion of the progress bar."""
@@ -59,9 +84,15 @@ class Bar(Widget, can_focus=False):
"""The percentage of progress that has been completed."""
_start_time: float | None
"""The time when the widget started tracking progress."""
+ color_scheme = reactive(_DEFAULT_BAR_COLOR_SCHEME)
+ """The color scheme of the bar."""
+ thickness = reactive(_DEFAULT_BAR_THICKNESS)
+ """The thickness of the bar."""
def __init__(
self,
+ color_scheme: BarColorScheme | None = None,
+ thickness: BarThickness | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
@@ -71,6 +102,26 @@ def __init__(
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._start_time = None
self._percentage = None
+ self.color_scheme = self.validate_color_scheme(color_scheme)
+ self.thickness = self.validate_thickness(thickness)
+
+ def validate_color_scheme(self, color_scheme: BarColorScheme) -> BarColorScheme:
+ if color_scheme is None:
+ color_scheme = _DEFAULT_BAR_COLOR_SCHEME
+ if color_scheme not in _VALID_BAR_COLOR_SCHEMES:
+ raise InvalidBarColorScheme(
+ f"Valid bar color schemes are {friendly_list(_VALID_BAR_COLOR_SCHEMES)}"
+ )
+ return color_scheme
+
+ def validate_thickness(self, thickness: BarThickness) -> BarThickness:
+ if thickness is None:
+ thickness = _DEFAULT_BAR_THICKNESS
+ if thickness not in _VALID_BAR_THICKNESSES:
+ raise InvalidBarThickness(
+ f"Valid thicknesses are {friendly_list(_VALID_BAR_THICKNESSES)}"
+ )
+ return thickness
def watch__percentage(self, percentage: float | None) -> None:
"""Manage the timer that enables the indeterminate bar animation."""
@@ -84,15 +135,13 @@ def render(self) -> RenderResult:
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")
- )
+ bar_style = self._get_bar_style()
+
return BarRenderable(
highlight_range=(0, self.size.width * self._percentage),
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
+ thickness=self.thickness,
)
def render_indeterminate(self) -> RenderResult:
@@ -116,8 +165,26 @@ def render_indeterminate(self) -> RenderResult:
highlight_range=(max(0, start), min(end, width)),
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
+ thickness=self.thickness,
)
+ def _get_bar_style(self):
+ if self.color_scheme == "default":
+ return (
+ self.get_component_rich_style("bar--bar")
+ if self._percentage < 1
+ else self.get_component_rich_style("bar--complete")
+ )
+ elif self.color_scheme == "rainbow":
+ from_color = self.get_component_rich_style("bar--bar").color
+ target_color = self.get_component_rich_style("bar--complete").color
+ bar_color = (
+ Color.from_rich_color(from_color)
+ .hsl_blend(Color.from_rich_color(target_color), -1.0 * self._percentage)
+ .rich_color
+ )
+ return Style.from_color(bar_color)
+
def _get_elapsed_time(self) -> float:
"""Get time for the indeterminate progress animation.
@@ -297,6 +364,8 @@ class ProgressBar(Widget, can_focus=False):
def __init__(
self,
total: float | None = None,
+ color_scheme: BarColorScheme | None = None,
+ thickness: BarThickness | None = None,
*,
show_bar: bool = True,
show_percentage: bool = True,
@@ -322,6 +391,8 @@ def key_space(self):
Args:
total: The total number of steps in the progress if known.
+ color_scheme: Progress bar color scheme. If not set default scheme will be used.
+ thickness: Progress bar thickness. If not set default thickness will be used.
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.
@@ -336,6 +407,8 @@ def key_space(self):
self.show_eta = show_eta
self.total = total
+ self._color_scheme = color_scheme
+ self._thickness = thickness
def compose(self) -> ComposeResult:
# We create a closure so that we can determine what are the sub-widgets
@@ -352,7 +425,9 @@ def updater(percentage: float | None) -> None:
with Horizontal():
if self.show_bar:
- bar = Bar(id="bar")
+ bar = Bar(
+ id="bar", color_scheme=self._color_scheme, thickness=self._thickness
+ )
self.watch(self, "percentage", update_percentage(bar))
yield bar
if self.show_percentage:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 4fe8a4f049b..65a0f215e02 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -22993,6 +22993,326 @@
'''
# ---
+# name: test_progress_bar_in_rainbow_style_high
+ '''
+
+
+ '''
+# ---
+# name: test_progress_bar_in_rainbow_style_low
+ '''
+
+
+ '''
+# ---
# name: test_progress_bar_indeterminate
'''