mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
committed by
GitHub
parent
7049014faa
commit
78db024c01
4
docs/examples/widgets/sparkline.css
Normal file
4
docs/examples/widgets/sparkline.css
Normal file
@@ -0,0 +1,4 @@
|
||||
Sparkline {
|
||||
width: 100%;
|
||||
margin: 2;
|
||||
}
|
||||
22
docs/examples/widgets/sparkline.py
Normal file
22
docs/examples/widgets/sparkline.py
Normal 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()
|
||||
8
docs/examples/widgets/sparkline_basic.css
Normal file
8
docs/examples/widgets/sparkline_basic.css
Normal file
@@ -0,0 +1,8 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Sparkline {
|
||||
width: 3; /* (1)! */
|
||||
margin: 2;
|
||||
}
|
||||
19
docs/examples/widgets/sparkline_basic.py
Normal file
19
docs/examples/widgets/sparkline_basic.py
Normal 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()
|
||||
74
docs/examples/widgets/sparkline_colors.css
Normal file
74
docs/examples/widgets/sparkline_colors.css
Normal 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;
|
||||
}
|
||||
24
docs/examples/widgets/sparkline_colors.py
Normal file
24
docs/examples/widgets/sparkline_colors.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
112
docs/widgets/sparkline.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
96
src/textual/widgets/_sparkline.py
Normal file
96
src/textual/widgets/_sparkline.py
Normal 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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user