Add sparkline widget. (#2631)

* Sparkline widget proof of concept.

* Address review comment.

Related comments: https://github.com/Textualize/textual/pull/2631\#discussion_r1202894414

* Blend background colours.

* Add widget sparkline.

* Add snapshot tests.

* Add documentation.

* Update roadmap.

* Address review feedback.

Relevant comments: https://github.com/Textualize/textual/pull/2631\#discussion_r1210394532, https://github.com/Textualize/textual/pull/2631\#discussion_r1210442013

* Improve docs.

Relevant comments: https://github.com/Textualize/textual/pull/2631\#issuecomment-1568529074

* Update snapshot app titles.

* Don't init summary function with None

Related comments: https://github.com/Textualize/textual/pull/2631\#discussion_r1211666076

* Apply suggestions from code review

Co-authored-by: Dave Pearson <davep@davep.org>

* Improve wording.

* Improve wording.

* Simplify example.

---------

Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Rodrigo Girão Serrão
2023-06-01 09:34:33 +01:00
committed by GitHub
parent 7049014faa
commit 78db024c01
16 changed files with 918 additions and 3 deletions

View File

@@ -0,0 +1,4 @@
Sparkline {
width: 100%;
margin: 2;
}

View File

@@ -0,0 +1,22 @@
import random
from statistics import mean
from textual.app import App, ComposeResult
from textual.widgets._sparkline import Sparkline
random.seed(73)
data = [random.expovariate(1 / 3) for _ in range(1000)]
class SparklineSummaryFunctionApp(App[None]):
CSS_PATH = "sparkline.css"
def compose(self) -> ComposeResult:
yield Sparkline(data, summary_function=max) # (1)!
yield Sparkline(data, summary_function=mean) # (2)!
yield Sparkline(data, summary_function=min) # (3)!
app = SparklineSummaryFunctionApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,8 @@
Screen {
align: center middle;
}
Sparkline {
width: 3; /* (1)! */
margin: 2;
}

View File

@@ -0,0 +1,19 @@
from textual.app import App, ComposeResult
from textual.widgets._sparkline import Sparkline
data = [1, 2, 2, 1, 1, 4, 3, 1, 1, 8, 8, 2] # (1)!
class SparklineBasicApp(App[None]):
CSS_PATH = "sparkline_basic.css"
def compose(self) -> ComposeResult:
yield Sparkline( # (2)!
data, # (3)!
summary_function=max, # (4)!
)
app = SparklineBasicApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,74 @@
Sparkline {
width: 100%;
margin: 1;
}
#fst > .sparkline--max-color {
color: $success;
}
#fst > .sparkline--min-color {
color: $warning;
}
#snd > .sparkline--max-color {
color: $warning;
}
#snd > .sparkline--min-color {
color: $success;
}
#trd > .sparkline--max-color {
color: $error;
}
#trd > .sparkline--min-color {
color: $warning;
}
#frt > .sparkline--max-color {
color: $warning;
}
#frt > .sparkline--min-color {
color: $error;
}
#fft > .sparkline--max-color {
color: $accent;
}
#fft > .sparkline--min-color {
color: $accent 30%;
}
#sxt > .sparkline--max-color {
color: $accent 30%;
}
#sxt > .sparkline--min-color {
color: $accent;
}
#svt > .sparkline--max-color {
color: $error;
}
#svt > .sparkline--min-color {
color: $error 30%;
}
#egt > .sparkline--max-color {
color: $error 30%;
}
#egt > .sparkline--min-color {
color: $error;
}
#nnt > .sparkline--max-color {
color: $success;
}
#nnt > .sparkline--min-color {
color: $success 30%;
}
#tnt > .sparkline--max-color {
color: $success 30%;
}
#tnt > .sparkline--min-color {
color: $success;
}

View File

@@ -0,0 +1,24 @@
from textual.app import App, ComposeResult
from textual.widgets._sparkline import Sparkline
class SparklineColorsApp(App[None]):
CSS_PATH = "sparkline_colors.css"
def compose(self) -> ComposeResult:
nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20]
yield Sparkline(nums, summary_function=max, id="fst")
yield Sparkline(nums, summary_function=max, id="snd")
yield Sparkline(nums, summary_function=max, id="trd")
yield Sparkline(nums, summary_function=max, id="frt")
yield Sparkline(nums, summary_function=max, id="fft")
yield Sparkline(nums, summary_function=max, id="sxt")
yield Sparkline(nums, summary_function=max, id="svt")
yield Sparkline(nums, summary_function=max, id="egt")
yield Sparkline(nums, summary_function=max, id="nnt")
yield Sparkline(nums, summary_function=max, id="tnt")
app = SparklineColorsApp()
if __name__ == "__main__":
app.run()

View File

@@ -58,7 +58,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
* [ ] Braille
* [ ] Sixels, and other image extensions
- [x] Input
* [ ] Validation
* [x] Validation
* [ ] Error / warning states
* [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc
- [X] Select control (pull-down)
@@ -72,7 +72,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
- [X] Progress bars
* [ ] Style variants (solid, thin etc)
- [X] Radio boxes
- [ ] Spark-lines
- [X] Spark-lines
- [X] Switch
- [X] Tabs
- [ ] TextArea (multi-line input)

View File

@@ -216,6 +216,15 @@ Select multiple values from a list of options.
```{.textual path="docs/examples/widgets/selection_list_selections.py" press="down,down,down"}
```
## Sparkline
Display numerical data.
[Sparkline reference](./widgets/sparkline.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/sparkline.py" lines="11"}
```
## Static
Displays simple static content. Typically used as a base class.

112
docs/widgets/sparkline.md Normal file
View File

@@ -0,0 +1,112 @@
# Sparkline
!!! tip "Added in version 0.27.0"
A widget that is used to visually represent numerical data.
- [ ] Focusable
- [ ] Container
## Examples
### Basic example
The example below illustrates the relationship between the data, its length, the width of the sparkline, and the number of bars displayed.
!!! tip
The sparkline data is split into equally-sized chunks.
Each chunk is represented by a bar and the width of the sparkline dictates how many bars there are.
=== "Output"
```{.textual path="docs/examples/widgets/sparkline_basic.py" lines="5" columns="30"}
```
=== "sparkline_basic.py"
```python hl_lines="4 11 12 13"
--8<-- "docs/examples/widgets/sparkline_basic.py"
```
1. We have 12 data points.
2. This sparkline will have its width set to 3 via CSS.
3. The data (12 numbers) will be split across 3 bars, so 4 data points are associated with each bar.
4. Each bar will represent its largest value.
The largest value of each chunk is 2, 4, and 8, respectively.
That explains why the first bar is half the height of the second and the second bar is half the height of the third.
=== "sparkline_basic.css"
```sass
--8<-- "docs/examples/widgets/sparkline_basic.css"
```
1. By setting the width to 3 we get three buckets.
### Different summary functions
The example below shows a sparkline widget with different summary functions.
The summary function is what determines the height of each bar.
=== "Output"
```{.textual path="docs/examples/widgets/sparkline.py" lines="11"}
```
=== "sparkline.py"
```python hl_lines="15-17"
--8<-- "docs/examples/widgets/sparkline.py"
```
1. Each bar will show the largest value of that bucket.
2. Each bar will show the mean value of that bucket.
3. Each bar will show the smaller value of that bucket.
=== "sparkline.css"
```sass
--8<-- "docs/examples/widgets/sparkline.css"
```
### Changing the colors
The example below shows how to use component classes to change the colors of the sparkline.
=== "Output"
```{.textual path="docs/examples/widgets/sparkline_colors.py" lines=22}
```
=== "sparkline_colors.py"
```python
--8<-- "docs/examples/widgets/sparkline_colors.py"
```
=== "sparkline_colors.css"
```sass
--8<-- "docs/examples/widgets/sparkline_colors.css"
```
## Reactive Attributes
| Name | Type | Default | Description |
| --------- | ----- | ----------- | -------------------------------------------------- |
| `data` | `Sequence[float] | None` | `None` | The data represented by the sparkline. |
| `summary_function` | `Callable[[Sequence[float]], float]` | `max` | The function that computes the height of each bar. |
## Messages
This widget sends no messages.
---
::: textual.widgets.Sparkline
options:
heading_level: 2

View File

@@ -152,6 +152,7 @@ nav:
- "widgets/radioset.md"
- "widgets/select.md"
- "widgets/selection_list.md"
- "widgets/sparkline.md"
- "widgets/static.md"
- "widgets/switch.md"
- "widgets/tabbed_content.md"

View File

@@ -112,6 +112,8 @@ if __name__ == "__main__":
console.print(f"data = {nums}\n")
for f in funcs:
console.print(
f"{f.__name__}:\t", Sparkline(nums, width=12, summary_function=f), end=""
f"{f.__name__}:\t",
Sparkline(nums, width=12, summary_function=f),
end="",
)
console.print("\n")

View File

@@ -31,6 +31,7 @@ if typing.TYPE_CHECKING:
from ._radio_set import RadioSet
from ._select import Select
from ._selection_list import SelectionList
from ._sparkline import Sparkline
from ._static import Static
from ._switch import Switch
from ._tabbed_content import TabbedContent, TabPane
@@ -64,6 +65,7 @@ __all__ = [
"RadioSet",
"Select",
"SelectionList",
"Sparkline",
"Static",
"Switch",
"Tab",

View File

@@ -21,6 +21,7 @@ from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._select import Select as Select
from ._selection_list import SelectionList as SelectionList
from ._sparkline import Sparkline as Sparkline
from ._static import Static as Static
from ._switch import Switch as Switch
from ._tabbed_content import TabbedContent as TabbedContent

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Callable, ClassVar, Optional, Sequence
from ..app import RenderResult
from ..reactive import reactive
from ..renderables.sparkline import Sparkline as SparklineRenderable
from ..widget import Widget
def _max_factory() -> Callable[[Sequence[float]], float]:
"""Callable that returns the built-in max to initialise a reactive."""
return max
class Sparkline(Widget):
"""A sparkline widget to display numerical data."""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"sparkline--max-color",
"sparkline--min-color",
}
"""
Use these component classes to define the two colors that the sparkline
interpolates to represent its numerical data.
Note:
These two component classes are used exclusively for the _color_ of the
sparkline widget. Setting any style other than [`color`](/styles/color.md)
will have no effect.
| Class | Description |
| :- | :- |
| `sparkline--max-color` | The color used for the larger values in the data. |
| `sparkline--min-color` | The colour used for the smaller values in the data. |
"""
DEFAULT_CSS = """
Sparkline {
height: 1;
}
Sparkline > .sparkline--max-color {
color: $accent;
}
Sparkline > .sparkline--min-color {
color: $accent 30%;
}
"""
data = reactive[Optional[Sequence[float]]](None)
"""The data that populates the sparkline."""
summary_function = reactive[Callable[[Sequence[float]], float]](_max_factory)
"""The function that computes the value that represents each bar."""
def __init__(
self,
data: Sequence[float] | None = None,
*,
summary_function: Callable[[Sequence[float]], float] | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a sparkline widget.
Args:
data: The initial data to populate the sparkline with.
summary_function: Summarises bar values into a single value used to
represent each bar.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.data = data
if summary_function is not None:
self.summary_function = summary_function
def render(self) -> RenderResult:
"""Renders the sparkline when there is data available."""
if not self.data:
return "<empty sparkline>"
_, base = self.background_colors
return SparklineRenderable(
self.data,
width=self.size.width,
min_color=(
base + self.get_component_styles("sparkline--min-color").color
).rich_color,
max_color=(
base + self.get_component_styles("sparkline--max-color").color
).rich_color,
summary_function=self.summary_function,
)

File diff suppressed because one or more lines are too long

View File

@@ -279,6 +279,14 @@ def test_select_expanded_changed(snap_compare):
)
def test_sparkline_render(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "sparkline.py")
def test_sparkline_component_classes_colors(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "sparkline_colors.py")
# --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs.
# If any of these change, something has likely broken, so snapshot each of them.