Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gradient progress bar #4774

Merged
merged 7 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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():
Comment on lines +25 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit of an aside, but I often find myself defining a CenterMiddle.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a bad idea.

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
46 changes: 29 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,20 @@ 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:
_type_: _description_
"""
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 +627,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 +638,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 +660,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
81 changes: 36 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,26 @@ 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.
"""
from_color = Style.from_color
get_rich_color = gradient.get_rich_color

max_width = width - 1
if not max_width:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
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
Loading