diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d972c4a08..6221104e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Added `Screen.get_loading_widget` which deferes to `App.get_loading_widget` https://github.com/Textualize/textual/pull/6228 +- Allow `Sparkline` to be of any height, not just 1 https://github.com/Textualize/textual/pull/6171 ### Fixed diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index c2c15608d4..565cc10c97 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. 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,76 @@ 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 = " " + 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 + + bars_rendered += 1 + bucket_index += step + yield Segment(bar, style) + + 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, 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 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