Skip to content

Commit 35409c3

Browse files
authored
Merge pull request #4774 from Textualize/gradient-bar
Gradient progress bar
2 parents fb6fc06 + 686fb9c commit 35409c3

File tree

11 files changed

+366
-65
lines changed

11 files changed

+366
-65
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8-
## Unreleased
8+
## [0.73.0] - 2024-07-18
99

1010
### Added
1111

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

2324
### Fixed
2425

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

2222+
[0.73.0]: https://github.com/Textualize/textual/compare/v0.72.0...v0.73.0
22212223
[0.72.0]: https://github.com/Textualize/textual/compare/v0.71.0...v0.72.0
22222224
[0.71.0]: https://github.com/Textualize/textual/compare/v0.70.0...v0.71.0
22232225
[0.70.0]: https://github.com/Textualize/textual/compare/v0.69.0...v0.70.0
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from textual.app import App, ComposeResult
2+
from textual.color import Gradient
3+
from textual.containers import Center, Middle
4+
from textual.widgets import ProgressBar
5+
6+
7+
class ProgressApp(App[None]):
8+
"""Progress bar with a rainbow gradient."""
9+
10+
def compose(self) -> ComposeResult:
11+
gradient = Gradient.from_colors(
12+
"#881177",
13+
"#aa3355",
14+
"#cc6666",
15+
"#ee9944",
16+
"#eedd00",
17+
"#99dd55",
18+
"#44dd88",
19+
"#22ccbb",
20+
"#00bbcc",
21+
"#0099cc",
22+
"#3366bb",
23+
"#663399",
24+
)
25+
with Center():
26+
with Middle():
27+
yield ProgressBar(total=100, gradient=gradient)
28+
29+
def on_mount(self) -> None:
30+
self.query_one(ProgressBar).update(progress=70)
31+
32+
33+
if __name__ == "__main__":
34+
ProgressApp().run()

docs/widgets/progress_bar.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ The example below shows a simple app with a progress bar that is keeping track o
7171
--8<-- "docs/examples/widgets/progress_bar.tcss"
7272
```
7373

74+
### Gradient Bars
75+
76+
Progress bars support an optional `gradient` parameter, which renders a smooth gradient rather than a solid bar.
77+
To use a gradient, create and set a [Gradient][textual.color.Gradient] object on the ProgressBar widget.
78+
79+
!!! note
80+
81+
Setting a gradient will override styles set in CSS.
82+
83+
Here's an example:
84+
85+
=== "Output"
86+
87+
```{.textual path="docs/examples/widgets/progress_bar_gradient.py"}
88+
```
89+
90+
=== "progress_bar_gradient.py"
91+
92+
```python hl_lines="11-23 27"
93+
--8<-- "docs/examples/widgets/progress_bar_gradient.py"
94+
```
7495

7596
### Custom Styling
7697

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.72.0"
3+
version = "0.73.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/color.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -551,17 +551,17 @@ def get_contrast_text(self, alpha: float = 0.95) -> Color:
551551
class Gradient:
552552
"""Defines a color gradient."""
553553

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

594+
@classmethod
595+
def from_colors(cls, *colors: Color | str, quality: int = 50) -> Gradient:
596+
"""Construct a gradient form a sequence of colors, where the stops are evenly spaced.
597+
598+
Args:
599+
*colors: Positional arguments may be Color instances or strings to parse into a color.
600+
quality: The number of steps in the gradient.
601+
602+
Returns:
603+
A new Gradient instance.
604+
"""
605+
if len(colors) < 2:
606+
raise ValueError("Two or more colors required.")
607+
stops = [(i / (len(colors) - 1), Color.parse(c)) for i, c in enumerate(colors)]
608+
return cls(*stops, quality=quality)
609+
594610
@property
595611
def colors(self) -> list[Color]:
596612
"""A list of colors in the gradient."""
@@ -613,13 +629,6 @@ def colors(self) -> list[Color]:
613629
assert len(self._colors) == self._quality
614630
return self._colors
615631

616-
@property
617-
def rich_colors(self) -> list[RichColor]:
618-
"""A list of colors in the gradient (for the Rich library)."""
619-
if self._rich_colors is None:
620-
self._rich_colors = [color.rich_color for color in self.colors]
621-
return self._rich_colors
622-
623632
def get_color(self, position: float) -> Color:
624633
"""Get a color from the gradient at a position between 0 and 1.
625634
@@ -631,9 +640,16 @@ def get_color(self, position: float) -> Color:
631640
Returns:
632641
A Textual color.
633642
"""
634-
quality = self._quality - 1
635-
color_index = int(clamp(position * quality, 0, quality))
636-
return self.colors[color_index]
643+
644+
if position <= 0:
645+
return self.colors[0]
646+
if position >= 1:
647+
return self.colors[-1]
648+
649+
color_position = position * (self._quality - 1)
650+
color_index = int(color_position)
651+
color1, color2 = self.colors[color_index : color_index + 2]
652+
return color1.blend(color2, color_position % 1)
637653

638654
def get_rich_color(self, position: float) -> RichColor:
639655
"""Get a (Rich) color from the gradient at a position between 0 and 1.
@@ -646,9 +662,7 @@ def get_rich_color(self, position: float) -> RichColor:
646662
Returns:
647663
A (Rich) color.
648664
"""
649-
quality = self._quality - 1
650-
color_index = int(clamp(position * quality, 0, quality))
651-
return self.rich_colors[color_index]
665+
return self.get_color(position).rich_color
652666

653667

654668
# Color constants

src/textual/renderables/bar.py

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

33
from rich.console import Console, ConsoleOptions, RenderResult
4-
from rich.style import StyleType
4+
from rich.style import Style, StyleType
55
from rich.text import Text
66

7+
from textual.color import Gradient
8+
79

810
class Bar:
911
"""Thin horizontal bar with a portion highlighted.
@@ -12,7 +14,8 @@ class Bar:
1214
highlight_range: The range to highlight.
1315
highlight_style: The style of the highlighted range of the bar.
1416
background_style: The style of the non-highlighted range(s) of the bar.
15-
width: The width of the bar, or ``None`` to fill available width.
17+
width: The width of the bar, or `None` to fill available width.
18+
gradient. Optional gradient object.
1619
"""
1720

1821
def __init__(
@@ -22,12 +25,14 @@ def __init__(
2225
background_style: StyleType = "grey37",
2326
clickable_ranges: dict[str, tuple[int, int]] | None = None,
2427
width: int | None = None,
28+
gradient: Gradient | None = None,
2529
) -> None:
2630
self.highlight_range = highlight_range
2731
self.highlight_style = highlight_style
2832
self.background_style = background_style
2933
self.clickable_ranges = clickable_ranges or {}
3034
self.width = width
35+
self.gradient = gradient
3136

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

75+
highlight_bar = Text("", end="")
7076
# The highlighted portion
7177
bar_width = int(end) - int(start)
7278
if half_start:
73-
output_bar.append(
79+
highlight_bar.append(
7480
Text(
7581
half_bar_left + bar * (bar_width - 1), style=highlight_style, end=""
7682
)
7783
)
7884
else:
79-
output_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
85+
highlight_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
8086
if half_end:
81-
output_bar.append(Text(half_bar_right, style=highlight_style, end=""))
87+
highlight_bar.append(Text(half_bar_right, style=highlight_style, end=""))
88+
89+
if self.gradient is not None:
90+
_apply_gradient(highlight_bar, self.gradient, width)
91+
output_bar.append(highlight_bar)
8292

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

98108

99-
if __name__ == "__main__":
100-
import random
101-
from time import sleep
102-
103-
from rich.color import ANSI_COLOR_NAMES
104-
105-
console = Console()
106-
107-
def frange(start, end, step):
108-
current = start
109-
while current < end:
110-
yield current
111-
current += step
112-
113-
while current >= 0:
114-
yield current
115-
current -= step
116-
117-
step = 0.1
118-
start_range = frange(0.5, 10.5, step)
119-
end_range = frange(10, 20, step)
120-
ranges = zip(start_range, end_range)
109+
def _apply_gradient(text: Text, gradient: Gradient, width: int) -> None:
110+
"""Apply a gradient to a Rich Text instance.
121111
122-
console.print(Bar(width=20), f" (.0, .0)")
123-
124-
for range in ranges:
125-
color = random.choice(list(ANSI_COLOR_NAMES.keys()))
126-
console.print(
127-
Bar(range, highlight_style=color, width=20),
128-
f" {range}",
112+
Args:
113+
text: A Text object.
114+
gradient: A Textual gradient.
115+
width: Width of gradient.
116+
"""
117+
if not width:
118+
return
119+
assert width > 0
120+
from_color = Style.from_color
121+
get_rich_color = gradient.get_rich_color
122+
123+
max_width = width - 1
124+
if not max_width:
125+
text.stylize(from_color(gradient.get_color(0).rich_color))
126+
return
127+
text_length = len(text)
128+
for offset in range(text_length):
129+
bar_offset = text_length - offset
130+
text.stylize(
131+
from_color(get_rich_color(bar_offset / max_width)),
132+
offset,
133+
offset + 1,
129134
)
130-
131-
from rich.live import Live
132-
133-
bar = Bar(highlight_range=(0, 4.5), width=80)
134-
with Live(bar, refresh_per_second=60) as live:
135-
while True:
136-
bar.highlight_range = (
137-
bar.highlight_range[0] + 0.1,
138-
bar.highlight_range[1] + 0.1,
139-
)
140-
sleep(0.005)

0 commit comments

Comments
 (0)