Skip to content

Commit 896b64f

Browse files
committed
gradients and tests
1 parent fb6fc06 commit 896b64f

File tree

8 files changed

+174
-63
lines changed

8 files changed

+174
-63
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
2223

2324
### Fixed
2425

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

src/textual/color.py

Lines changed: 29 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,20 @@ 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+
_type_: _description_
604+
"""
605+
stops = [(i / (len(colors) - 1), Color.parse(c)) for i, c in enumerate(colors)]
606+
return cls(*stops, quality=quality)
607+
594608
@property
595609
def colors(self) -> list[Color]:
596610
"""A list of colors in the gradient."""
@@ -613,13 +627,6 @@ def colors(self) -> list[Color]:
613627
assert len(self._colors) == self._quality
614628
return self._colors
615629

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-
623630
def get_color(self, position: float) -> Color:
624631
"""Get a color from the gradient at a position between 0 and 1.
625632
@@ -631,9 +638,16 @@ def get_color(self, position: float) -> Color:
631638
Returns:
632639
A Textual color.
633640
"""
634-
quality = self._quality - 1
635-
color_index = int(clamp(position * quality, 0, quality))
636-
return self.colors[color_index]
641+
642+
if position <= 0:
643+
return self.colors[0]
644+
if position >= 1:
645+
return self.colors[-1]
646+
647+
color_position = position * (self._quality - 1)
648+
color_index = int(color_position)
649+
color1, color2 = self.colors[color_index : color_index + 2]
650+
return color1.blend(color2, color_position % 1)
637651

638652
def get_rich_color(self, position: float) -> RichColor:
639653
"""Get a (Rich) color from the gradient at a position between 0 and 1.
@@ -646,9 +660,7 @@ def get_rich_color(self, position: float) -> RichColor:
646660
Returns:
647661
A (Rich) color.
648662
"""
649-
quality = self._quality - 1
650-
color_index = int(clamp(position * quality, 0, quality))
651-
return self.rich_colors[color_index]
663+
return self.get_color(position).rich_color
652664

653665

654666
# Color constants

src/textual/renderables/bar.py

Lines changed: 35 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:
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,25 @@ 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 to apply gradient to, or None for full width.
116+
"""
117+
from_color = Style.from_color
118+
get_rich_color = gradient.get_rich_color
119+
120+
max_width = width - 1
121+
if not max_width:
122+
return
123+
text_length = len(text)
124+
for offset in range(text_length):
125+
bar_offset = text_length - offset
126+
text.stylize(
127+
from_color(get_rich_color(bar_offset / max_width)),
128+
offset,
129+
offset + 1,
129130
)
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)

src/textual/widgets/_progress_bar.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .._types import UnusedParameter
1010
from ..app import ComposeResult, RenderResult
1111
from ..clock import Clock
12+
from ..color import Gradient
1213
from ..eta import ETA
1314
from ..geometry import clamp
1415
from ..reactive import reactive
@@ -60,17 +61,23 @@ class Bar(Widget, can_focus=False):
6061
percentage: reactive[float | None] = reactive[Optional[float]](None)
6162
"""The percentage of progress that has been completed."""
6263

64+
gradient: reactive[Gradient | None] = reactive(None)
65+
"""An optional gradient."""
66+
6367
def __init__(
6468
self,
6569
name: str | None = None,
6670
id: str | None = None,
6771
classes: str | None = None,
6872
disabled: bool = False,
6973
clock: Clock | None = None,
74+
gradient: Gradient | None = None,
7075
):
7176
"""Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar]."""
7277
self._clock = (clock or Clock()).clone()
7378
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
79+
self.gradient = gradient
80+
self.set_reactive(Bar.gradient, gradient)
7481

7582
def _validate_percentage(self, percentage: float | None) -> float | None:
7683
"""Avoid updating the bar, if the percentage increase is too small to render."""
@@ -102,6 +109,7 @@ def render(self) -> RenderResult:
102109
highlight_range=(0, self.size.width * self.percentage),
103110
highlight_style=Style.from_color(bar_style.color),
104111
background_style=Style.from_color(bar_style.bgcolor),
112+
gradient=self.gradient,
105113
)
106114

107115
def render_indeterminate(self) -> RenderResult:
@@ -216,6 +224,9 @@ class ProgressBar(Widget, can_focus=False):
216224
"""
217225
_display_eta: reactive[int | None] = reactive[Optional[int]](None)
218226

227+
gradient: reactive[Gradient | None] = reactive(None)
228+
"""Optional gradient object (will replace CSS styling in bar)."""
229+
219230
def __init__(
220231
self,
221232
total: float | None = None,
@@ -228,6 +239,7 @@ def __init__(
228239
classes: str | None = None,
229240
disabled: bool = False,
230241
clock: Clock | None = None,
242+
gradient: Gradient | None = None,
231243
):
232244
"""Create a Progress Bar widget.
233245
@@ -253,6 +265,7 @@ def key_space(self):
253265
classes: The CSS classes for the widget.
254266
disabled: Whether the widget is disabled or not.
255267
clock: An optional clock object (leave as default unless testing).
268+
gradient: An optional Gradient object (will replace CSS styles in the bar).
256269
"""
257270
self._clock = clock or Clock()
258271
self._eta = ETA()
@@ -261,6 +274,7 @@ def key_space(self):
261274
self.show_bar = show_bar
262275
self.show_percentage = show_percentage
263276
self.show_eta = show_eta
277+
self.set_reactive(ProgressBar.gradient, gradient)
264278

265279
def on_mount(self) -> None:
266280
self.update()
@@ -269,7 +283,11 @@ def on_mount(self) -> None:
269283

270284
def compose(self) -> ComposeResult:
271285
if self.show_bar:
272-
yield Bar(id="bar", clock=self._clock).data_bind(ProgressBar.percentage)
286+
yield (
287+
Bar(id="bar", clock=self._clock)
288+
.data_bind(ProgressBar.percentage)
289+
.data_bind(ProgressBar.gradient)
290+
)
273291
if self.show_percentage:
274292
yield PercentageStatus(id="percentage").data_bind(ProgressBar.percentage)
275293
if self.show_eta:

0 commit comments

Comments
 (0)