From 6acdb8db8d25eecb029bbc1bb159af1eccc8c309 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Fri, 10 Oct 2025 14:20:28 +0200 Subject: [PATCH 1/5] Allow Sparkline to be of any height --- CHANGELOG.md | 1 + src/textual/renderables/sparkline.py | 63 +++++++++++++++++++++------- src/textual/widgets/_sparkline.py | 1 + 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44088c09d2..0d75c52de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added scrollbar-visibility rule https://github.com/Textualize/textual/pull/6156 +- Allow `Sparkline` to be of any height, not just 1 ### Fixed diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index c2c15608d4..7566ea6d3b 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -23,6 +23,7 @@ class Sparkline(Generic[T]): Args: data: The sequence of data to render. width: The width of the sparkline/the number of buckets to partition the data into. + height: The height of the sparkline in lines. Defaults to 1. min_color: The color of values equal to the min value in data. max_color: The color of values equal to the max value in data. summary_function: Function that will be applied to each bucket. @@ -35,12 +36,14 @@ def __init__( data: Sequence[T], *, width: int | None, + height: int | None = None, min_color: Color = Color.from_rgb(0, 255, 0), max_color: Color = Color.from_rgb(255, 0, 0), summary_function: SummaryFunction[T] = max, ) -> None: self.data: Sequence[T] = data self.width = width + self.height = height self.min_color = Style.from_color(min_color) self.max_color = Style.from_color(max_color) self.summary_function: SummaryFunction[T] = summary_function @@ -66,40 +69,68 @@ def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: width = self.width or options.max_width + height = self.height or 1 + len_data = len(self.data) if len_data == 0: + for _ in range(height - 1): + yield Segment.line() + yield Segment("▁" * width, self.min_color) return if len_data == 1: - yield Segment("█" * width, self.max_color) + for i in range(height): + yield Segment("█" * width, self.max_color) + + if i < height - 1: + yield Segment.line() return + bar_line_segments = len(self.BARS) + bar_segments = bar_line_segments * height - 1 + minimum, maximum = min(self.data), max(self.data) extent = maximum - minimum or 1 - buckets = tuple(self._buckets(list(self.data), num_buckets=width)) - - bucket_index = 0.0 - bars_rendered = 0 - step = len(buckets) / width summary_function = self.summary_function min_color, max_color = self.min_color.color, self.max_color.color assert min_color is not None assert max_color is not None - while bars_rendered < width: - partition = buckets[int(bucket_index)] - partition_summary = summary_function(partition) - height_ratio = (partition_summary - minimum) / extent - bar_index = int(height_ratio * (len(self.BARS) - 1)) - bar_color = blend_colors(min_color, max_color, height_ratio) - bars_rendered += 1 - bucket_index += step - yield Segment(self.BARS[bar_index], Style.from_color(bar_color)) + + buckets = tuple(self._buckets(list(self.data), num_buckets=width)) + + for i in reversed(range(height)): + current_bar_part_low = i * bar_line_segments + current_bar_part_high = (i + 1) * bar_line_segments + + bucket_index = 0.0 + bars_rendered = 0 + step = len(buckets) / width + while bars_rendered < width: + partition = buckets[int(bucket_index)] + partition_summary = summary_function(partition) + height_ratio = (partition_summary - minimum) / extent + bar_index = int(height_ratio * bar_segments) + + if bar_index < current_bar_part_low: + bar = " " + elif bar_index >= current_bar_part_high: + bar = "█" + else: + bar = self.BARS[bar_index % bar_line_segments] + + bar_color = blend_colors(min_color, max_color, height_ratio) + bars_rendered += 1 + bucket_index += step + yield Segment(bar, Style.from_color(bar_color)) + + if i > 0: + yield Segment.line() def __rich_measure__( self, console: "Console", options: "ConsoleOptions" ) -> Measurement: - return Measurement(self.width or options.max_width, 1) + return Measurement(self.width or options.max_width, self.height or 1) if __name__ == "__main__": diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py index aeb55382a1..000e6cd829 100644 --- a/src/textual/widgets/_sparkline.py +++ b/src/textual/widgets/_sparkline.py @@ -102,6 +102,7 @@ def render(self) -> RenderResult: return SparklineRenderable( data, width=self.size.width, + height=self.size.height, min_color=min_color.rich_color, max_color=max_color.rich_color, summary_function=self.summary_function, From 81b56bec797ae196a830ce0b184c0727a25911ca Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Fri, 10 Oct 2025 14:44:00 +0200 Subject: [PATCH 2/5] Add tests for sparkline height --- src/textual/renderables/sparkline.py | 12 +++++++++-- tests/renderables/test_sparkline.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 7566ea6d3b..93eb5f09cc 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -114,15 +114,23 @@ def __rich_console__( if bar_index < current_bar_part_low: bar = " " + with_color = False elif bar_index >= current_bar_part_high: bar = "█" + with_color = True else: bar = self.BARS[bar_index % bar_line_segments] + with_color = True + + if with_color: + bar_color = blend_colors(min_color, max_color, height_ratio) + style = Style.from_color(bar_color) + else: + style = None - bar_color = blend_colors(min_color, max_color, height_ratio) bars_rendered += 1 bucket_index += step - yield Segment(bar, Style.from_color(bar_color)) + yield Segment(bar, style) if i > 0: yield Segment.line() diff --git a/tests/renderables/test_sparkline.py b/tests/renderables/test_sparkline.py index 1478e00b32..d8402bfb99 100644 --- a/tests/renderables/test_sparkline.py +++ b/tests/renderables/test_sparkline.py @@ -72,3 +72,33 @@ def test_sparkline_sequence_types(data: Sequence[int]): render(Sparkline(data, width=3)) == f"{GREEN}▁{STOP}{BLENDED}▄{STOP}{RED}█{STOP}" ) + + +@pytest.mark.parametrize( + ("height", "expected"), + [ + (1, f"{GREEN}▁{STOP}{BLENDED}▄{STOP}{RED}█{STOP}"), + ( + 2, + "\n".join( + [ + f" {RED}█{STOP}", + f"{GREEN}▁{STOP}{BLENDED}█{STOP}{RED}█{STOP}", + ] + ), + ), + ( + 3, + "\n".join( + [ + f" {RED}█{STOP}", + f" {BLENDED}▄{STOP}{RED}█{STOP}", + f"{GREEN}▁{STOP}{BLENDED}█{STOP}{RED}█{STOP}", + ] + ), + ), + ], + ids=["height=1", "height=2", "height=3"], +) +def test_sparkline_height(height: int, expected: str): + assert render(Sparkline([1, 2, 3], width=3, height=height)) == expected From 65ad8cdce4ba6a1666037e8006e55dd6e4acb4cf Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Fri, 10 Oct 2025 14:56:52 +0200 Subject: [PATCH 3/5] Remove arg docs part --- src/textual/renderables/sparkline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 93eb5f09cc..565cc10c97 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -23,7 +23,7 @@ class Sparkline(Generic[T]): Args: data: The sequence of data to render. width: The width of the sparkline/the number of buckets to partition the data into. - height: The height of the sparkline in lines. Defaults to 1. + height: The height of the sparkline in lines. min_color: The color of values equal to the min value in data. max_color: The color of values equal to the max value in data. summary_function: Function that will be applied to each bucket. From ea8cf06e32be248e22ea1d8a64e52f93f39d3699 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 10 Nov 2025 21:21:37 +0100 Subject: [PATCH 4/5] tests: add sparkline snapshot app and test --- .../test_snapshots/test_sparkline.svg | 236 ++++++++++++++++++ .../snapshot_tests/snapshot_apps/sparkline.py | 28 +++ tests/snapshot_tests/test_snapshots.py | 4 + 3 files changed, 268 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_sparkline.svg create mode 100644 tests/snapshot_tests/snapshot_apps/sparkline.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_sparkline.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_sparkline.svg new file mode 100644 index 0000000000..08c8b5c097 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_sparkline.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SparklineApp + + + + + + + + + + + + + + + + + + + + + + + +▁▁ +███████ + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/snapshot_apps/sparkline.py b/tests/snapshot_tests/snapshot_apps/sparkline.py new file mode 100644 index 0000000000..8a648bcf95 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/sparkline.py @@ -0,0 +1,28 @@ +import random +from statistics import mean + +from textual.app import App, ComposeResult +from textual.widgets import Sparkline + +random.seed(73) +data = [random.expovariate(1 / 3) for _ in range(1000)] + + +class SparklineApp(App[None]): + DEFAULT_CSS = """ + SparklineApp { + Sparkline { + height: 1fr; + } + } + """ + + def compose(self) -> ComposeResult: + yield Sparkline(data, summary_function=max) + yield Sparkline(data, summary_function=mean) + yield Sparkline(data, summary_function=min) + + +if __name__ == "__main__": + app = SparklineApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4eba817cdd..1c34733a7f 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -112,6 +112,10 @@ def test_alignment_containers(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "alignment_containers.py") +def test_sparkline(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "sparkline.py") + + # --- Widgets - rendering and basic interactions --- # Each widget should have a canonical example that is display in the docs. # When adding a new widget, ideally we should also create a snapshot test From 3699b5c29b64fbc45488a0f53651e087e5f74323 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 10 Nov 2025 21:23:33 +0100 Subject: [PATCH 5/5] Add changelog entry: Sparkline can have any height --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0fd367fac..81767876fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Allow `Sparkline` to be of any height, not just 1 https://github.com/Textualize/textual/pull/6171 + ## [6.6.0] - 2025-11-10 ### Fixed @@ -53,7 +59,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added scrollbar-visibility rule https://github.com/Textualize/textual/pull/6156 -- Allow `Sparkline` to be of any height, not just 1 ### Fixed