From 78db024c01f2a72d520c0e0a450c43f9c0db7fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 1 Jun 2023 09:34:33 +0100 Subject: [PATCH] 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 * Improve wording. * Improve wording. * Simplify example. --------- Co-authored-by: Dave Pearson --- docs/examples/widgets/sparkline.css | 4 + docs/examples/widgets/sparkline.py | 22 + docs/examples/widgets/sparkline_basic.css | 8 + docs/examples/widgets/sparkline_basic.py | 19 + docs/examples/widgets/sparkline_colors.css | 74 +++ docs/examples/widgets/sparkline_colors.py | 24 + docs/roadmap.md | 4 +- docs/widget_gallery.md | 9 + docs/widgets/sparkline.md | 112 ++++ mkdocs-nav.yml | 1 + src/textual/renderables/sparkline.py | 4 +- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_sparkline.py | 96 ++++ .../__snapshots__/test_snapshots.ambr | 533 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 8 + 16 files changed, 918 insertions(+), 3 deletions(-) create mode 100644 docs/examples/widgets/sparkline.css create mode 100644 docs/examples/widgets/sparkline.py create mode 100644 docs/examples/widgets/sparkline_basic.css create mode 100644 docs/examples/widgets/sparkline_basic.py create mode 100644 docs/examples/widgets/sparkline_colors.css create mode 100644 docs/examples/widgets/sparkline_colors.py create mode 100644 docs/widgets/sparkline.md create mode 100644 src/textual/widgets/_sparkline.py diff --git a/docs/examples/widgets/sparkline.css b/docs/examples/widgets/sparkline.css new file mode 100644 index 000000000..ae4b3285d --- /dev/null +++ b/docs/examples/widgets/sparkline.css @@ -0,0 +1,4 @@ +Sparkline { + width: 100%; + margin: 2; +} diff --git a/docs/examples/widgets/sparkline.py b/docs/examples/widgets/sparkline.py new file mode 100644 index 000000000..d28e53c43 --- /dev/null +++ b/docs/examples/widgets/sparkline.py @@ -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() diff --git a/docs/examples/widgets/sparkline_basic.css b/docs/examples/widgets/sparkline_basic.css new file mode 100644 index 000000000..1b82e09ac --- /dev/null +++ b/docs/examples/widgets/sparkline_basic.css @@ -0,0 +1,8 @@ +Screen { + align: center middle; +} + +Sparkline { + width: 3; /* (1)! */ + margin: 2; +} diff --git a/docs/examples/widgets/sparkline_basic.py b/docs/examples/widgets/sparkline_basic.py new file mode 100644 index 000000000..ae4ed1852 --- /dev/null +++ b/docs/examples/widgets/sparkline_basic.py @@ -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() diff --git a/docs/examples/widgets/sparkline_colors.css b/docs/examples/widgets/sparkline_colors.css new file mode 100644 index 000000000..cad5230c8 --- /dev/null +++ b/docs/examples/widgets/sparkline_colors.css @@ -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; +} diff --git a/docs/examples/widgets/sparkline_colors.py b/docs/examples/widgets/sparkline_colors.py new file mode 100644 index 000000000..5497beb47 --- /dev/null +++ b/docs/examples/widgets/sparkline_colors.py @@ -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() diff --git a/docs/roadmap.md b/docs/roadmap.md index 6b88e74a5..90e05d1e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 3281e49e6..5e79b643d 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -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. diff --git a/docs/widgets/sparkline.md b/docs/widgets/sparkline.md new file mode 100644 index 000000000..7670f3c92 --- /dev/null +++ b/docs/widgets/sparkline.md @@ -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 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 9fd4c8e99..1cd4f7f11 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -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" diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 1289029b0..6078a55df 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -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") diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index eb191f746..a66c80935 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -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", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 926a7be78..c04d1f0d8 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -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 diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py new file mode 100644 index 000000000..f3315ba15 --- /dev/null +++ b/src/textual/widgets/_sparkline.py @@ -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 "" + _, 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, + ) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index fb51a33a2..1c2ca4fa5 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -24339,6 +24339,539 @@ ''' # --- +# name: test_sparkline_component_classes_colors + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SparklineColorsApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_sparkline_render + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SparklineSummaryFunctionApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_switches ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2f589a915..fb383ae26 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -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.