Skip to content

Commit

Permalink
Merge pull request #4774 from Textualize/gradient-bar
Browse files Browse the repository at this point in the history
Gradient progress bar
  • Loading branch information
willmcgugan authored Jul 18, 2024
2 parents fb6fc06 + 686fb9c commit 35409c3
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 65 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased
## [0.73.0] - 2024-07-18

### Added

Expand All @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Add `Tree.move_cursor` to programmatically move the cursor without selecting the node https://github.com/Textualize/textual/pull/4753
- Added `Footer` component style handling of padding for the key/description https://github.com/Textualize/textual/pull/4651
- `StringKey` is now exported from `data_table` https://github.com/Textualize/textual/pull/4760
- Added a `gradient` parameter to the `ProgressBar` widget https://github.com/Textualize/textual/pull/4774

### Fixed

Expand Down Expand Up @@ -2218,6 +2219,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[0.73.0]: https://github.com/Textualize/textual/compare/v0.72.0...v0.73.0
[0.72.0]: https://github.com/Textualize/textual/compare/v0.71.0...v0.72.0
[0.71.0]: https://github.com/Textualize/textual/compare/v0.70.0...v0.71.0
[0.70.0]: https://github.com/Textualize/textual/compare/v0.69.0...v0.70.0
Expand Down
34 changes: 34 additions & 0 deletions docs/examples/widgets/progress_bar_gradient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from textual.app import App, ComposeResult
from textual.color import Gradient
from textual.containers import Center, Middle
from textual.widgets import ProgressBar


class ProgressApp(App[None]):
"""Progress bar with a rainbow gradient."""

def compose(self) -> ComposeResult:
gradient = Gradient.from_colors(
"#881177",
"#aa3355",
"#cc6666",
"#ee9944",
"#eedd00",
"#99dd55",
"#44dd88",
"#22ccbb",
"#00bbcc",
"#0099cc",
"#3366bb",
"#663399",
)
with Center():
with Middle():
yield ProgressBar(total=100, gradient=gradient)

def on_mount(self) -> None:
self.query_one(ProgressBar).update(progress=70)


if __name__ == "__main__":
ProgressApp().run()
21 changes: 21 additions & 0 deletions docs/widgets/progress_bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ The example below shows a simple app with a progress bar that is keeping track o
--8<-- "docs/examples/widgets/progress_bar.tcss"
```

### Gradient Bars

Progress bars support an optional `gradient` parameter, which renders a smooth gradient rather than a solid bar.
To use a gradient, create and set a [Gradient][textual.color.Gradient] object on the ProgressBar widget.

!!! note

Setting a gradient will override styles set in CSS.

Here's an example:

=== "Output"

```{.textual path="docs/examples/widgets/progress_bar_gradient.py"}
```

=== "progress_bar_gradient.py"

```python hl_lines="11-23 27"
--8<-- "docs/examples/widgets/progress_bar_gradient.py"
```

### Custom Styling

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.72.0"
version = "0.73.0"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"
Expand Down
48 changes: 31 additions & 17 deletions src/textual/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,17 +551,17 @@ def get_contrast_text(self, alpha: float = 0.95) -> Color:
class Gradient:
"""Defines a color gradient."""

def __init__(self, *stops: tuple[float, Color | str], quality: int = 200) -> None:
def __init__(self, *stops: tuple[float, Color | str], quality: int = 50) -> None:
"""Create a color gradient that blends colors to form a spectrum.
A gradient is defined by a sequence of "stops" consisting of a tuple containing a float and a color.
The stop indicates the color at that point on a spectrum between 0 and 1.
Colors may be given as a [Color][textual.color.Color] instance, or a string that
can be parsed into a Color (with [Color.parse][textual.color.Color.parse]).
The quality of the argument defines the number of _steps_ in the gradient.
200 was chosen so that there was no obvious banding in [LinearGradient][textual.renderables.gradient.LinearGradient].
Higher values are unlikely to yield any benefit, but lower values may result in quicker rendering.
The `quality` argument defines the number of _steps_ in the gradient. Intermediate colors are
interpolated from the two nearest colors. Increasing `quality` can generate a smoother looking gradient,
at the expense of a little extra work to pre-calculate the colors.
Args:
stops: Color stops.
Expand Down Expand Up @@ -591,6 +591,22 @@ def __init__(self, *stops: tuple[float, Color | str], quality: int = 200) -> Non
self._colors: list[Color] | None = None
self._rich_colors: list[RichColor] | None = None

@classmethod
def from_colors(cls, *colors: Color | str, quality: int = 50) -> Gradient:
"""Construct a gradient form a sequence of colors, where the stops are evenly spaced.
Args:
*colors: Positional arguments may be Color instances or strings to parse into a color.
quality: The number of steps in the gradient.
Returns:
A new Gradient instance.
"""
if len(colors) < 2:
raise ValueError("Two or more colors required.")
stops = [(i / (len(colors) - 1), Color.parse(c)) for i, c in enumerate(colors)]
return cls(*stops, quality=quality)

@property
def colors(self) -> list[Color]:
"""A list of colors in the gradient."""
Expand All @@ -613,13 +629,6 @@ def colors(self) -> list[Color]:
assert len(self._colors) == self._quality
return self._colors

@property
def rich_colors(self) -> list[RichColor]:
"""A list of colors in the gradient (for the Rich library)."""
if self._rich_colors is None:
self._rich_colors = [color.rich_color for color in self.colors]
return self._rich_colors

def get_color(self, position: float) -> Color:
"""Get a color from the gradient at a position between 0 and 1.
Expand All @@ -631,9 +640,16 @@ def get_color(self, position: float) -> Color:
Returns:
A Textual color.
"""
quality = self._quality - 1
color_index = int(clamp(position * quality, 0, quality))
return self.colors[color_index]

if position <= 0:
return self.colors[0]
if position >= 1:
return self.colors[-1]

color_position = position * (self._quality - 1)
color_index = int(color_position)
color1, color2 = self.colors[color_index : color_index + 2]
return color1.blend(color2, color_position % 1)

def get_rich_color(self, position: float) -> RichColor:
"""Get a (Rich) color from the gradient at a position between 0 and 1.
Expand All @@ -646,9 +662,7 @@ def get_rich_color(self, position: float) -> RichColor:
Returns:
A (Rich) color.
"""
quality = self._quality - 1
color_index = int(clamp(position * quality, 0, quality))
return self.rich_colors[color_index]
return self.get_color(position).rich_color


# Color constants
Expand Down
84 changes: 39 additions & 45 deletions src/textual/renderables/bar.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import annotations

from rich.console import Console, ConsoleOptions, RenderResult
from rich.style import StyleType
from rich.style import Style, StyleType
from rich.text import Text

from textual.color import Gradient


class Bar:
"""Thin horizontal bar with a portion highlighted.
Expand All @@ -12,7 +14,8 @@ class Bar:
highlight_range: The range to highlight.
highlight_style: The style of the highlighted range of the bar.
background_style: The style of the non-highlighted range(s) of the bar.
width: The width of the bar, or ``None`` to fill available width.
width: The width of the bar, or `None` to fill available width.
gradient. Optional gradient object.
"""

def __init__(
Expand All @@ -22,12 +25,14 @@ def __init__(
background_style: StyleType = "grey37",
clickable_ranges: dict[str, tuple[int, int]] | None = None,
width: int | None = None,
gradient: Gradient | None = None,
) -> 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.gradient = gradient

def __rich_console__(
self, console: Console, options: ConsoleOptions
Expand Down Expand Up @@ -67,18 +72,23 @@ def __rich_console__(
if not half_start and start > 0:
output_bar.append(Text(half_bar_right, style=background_style, end=""))

highlight_bar = Text("", end="")
# The highlighted portion
bar_width = int(end) - int(start)
if half_start:
output_bar.append(
highlight_bar.append(
Text(
half_bar_left + bar * (bar_width - 1), style=highlight_style, end=""
)
)
else:
output_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
highlight_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
if half_end:
output_bar.append(Text(half_bar_right, style=highlight_style, end=""))
highlight_bar.append(Text(half_bar_right, style=highlight_style, end=""))

if self.gradient is not None:
_apply_gradient(highlight_bar, self.gradient, width)
output_bar.append(highlight_bar)

# The non-highlighted tail
if not half_end and end - width != 0:
Expand All @@ -96,45 +106,29 @@ def __rich_console__(
yield output_bar


if __name__ == "__main__":
import random
from time import sleep

from rich.color import ANSI_COLOR_NAMES

console = Console()

def frange(start, end, step):
current = start
while current < end:
yield current
current += step

while current >= 0:
yield current
current -= step

step = 0.1
start_range = frange(0.5, 10.5, step)
end_range = frange(10, 20, step)
ranges = zip(start_range, end_range)
def _apply_gradient(text: Text, gradient: Gradient, width: int) -> None:
"""Apply a gradient to a Rich Text instance.
console.print(Bar(width=20), f" (.0, .0)")

for range in ranges:
color = random.choice(list(ANSI_COLOR_NAMES.keys()))
console.print(
Bar(range, highlight_style=color, width=20),
f" {range}",
Args:
text: A Text object.
gradient: A Textual gradient.
width: Width of gradient.
"""
if not width:
return
assert width > 0
from_color = Style.from_color
get_rich_color = gradient.get_rich_color

max_width = width - 1
if not max_width:
text.stylize(from_color(gradient.get_color(0).rich_color))
return
text_length = len(text)
for offset in range(text_length):
bar_offset = text_length - offset
text.stylize(
from_color(get_rich_color(bar_offset / max_width)),
offset,
offset + 1,
)

from rich.live import Live

bar = Bar(highlight_range=(0, 4.5), width=80)
with Live(bar, refresh_per_second=60) as live:
while True:
bar.highlight_range = (
bar.highlight_range[0] + 0.1,
bar.highlight_range[1] + 0.1,
)
sleep(0.005)
Loading

0 comments on commit 35409c3

Please sign in to comment.