From 5651e97a64b850b80f42799e7f7d868f1f11ab7b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 31 Jan 2022 13:03:48 +0000 Subject: [PATCH] Underline bar renderable --- src/textual/renderables/tab_underline.py | 95 ------------------- src/textual/renderables/underline_bar.py | 112 +++++++++++++++++++++++ tests/renderables/__init__.py | 0 tests/renderables/test_underline_bar.py | 98 ++++++++++++++++++++ tests/utilities/render.py | 24 +++++ 5 files changed, 234 insertions(+), 95 deletions(-) delete mode 100644 src/textual/renderables/tab_underline.py create mode 100644 src/textual/renderables/underline_bar.py create mode 100644 tests/renderables/__init__.py create mode 100644 tests/renderables/test_underline_bar.py create mode 100644 tests/utilities/render.py diff --git a/src/textual/renderables/tab_underline.py b/src/textual/renderables/tab_underline.py deleted file mode 100644 index 25de4b893..000000000 --- a/src/textual/renderables/tab_underline.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import random - -from rich.color import Color, ANSI_COLOR_NAMES -from rich.console import ConsoleOptions, Console, RenderResult -from rich.segment import Segment -from rich.style import Style - - -class UnderlineBar: - def __init__( - self, - highlight_range: tuple[float, float] = 0, - range_color: Color = Color.parse("yellow"), - other_color: Color = Color.parse("default"), - background_color: Color = Color.parse("default"), - width: int | None = None, - ) -> None: - self.highlight_range = highlight_range - self.highlight_style = Style.from_color( - color=range_color, bgcolor=background_color - ) - self.other_style = Style.from_color(color=other_color, bgcolor=background_color) - self.width = width - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - half_bar_right = "╸" - half_bar_left = "╺" - bar = "━" - width = (self.width or options.max_width) - 1 - start, end = self.highlight_range - - # Round start and end to nearest half - start = round(start * 2) / 2 - end = round(end * 2) / 2 - - half_start = start - int(start) > 0 - half_end = end - int(end) > 0 - - # Initial non-highlighted portion of bar - yield Segment(bar * (int(start - 0.5)), style=self.other_style) - if not half_start and start > 0: - yield Segment(half_bar_right, style=self.other_style) - - # If we have a half bar at start and end, we need 1 less full bar - full_bar_width = int(end) - int(start) - if half_start and half_end: - full_bar_width -= 1 - - # The highlighted portion - if not half_start: - yield Segment(bar * full_bar_width, style=self.highlight_style) - else: - yield Segment(half_bar_left, style=self.highlight_style) - yield Segment(bar * full_bar_width, style=self.highlight_style) - if half_end: - yield Segment(half_bar_right, style=self.highlight_style) - - # The non-highlighted tail - if not half_end and end - width != 1: - yield Segment(half_bar_left, style=self.other_style) - yield Segment(bar * (int(width) - int(end)), style=self.other_style) - - -if __name__ == "__main__": - console = Console() - - def frange(start, end, step): - current = start - while current < end: - yield current - current += step - - while current >= 0: - yield current - current -= step - - start_range = frange(0, 12, 0.5) - end_range = frange(6, 18, 0.5) - ranges = zip(start_range, end_range) - - for range in ranges: - color = random.choice(list(ANSI_COLOR_NAMES.keys())) - console.print( - UnderlineBar( - range, - range_color=Color.parse(color), - other_color=Color.parse("#4f4f4f"), - width=18, - ) - ) - console.print() diff --git a/src/textual/renderables/underline_bar.py b/src/textual/renderables/underline_bar.py new file mode 100644 index 000000000..6e5aa532f --- /dev/null +++ b/src/textual/renderables/underline_bar.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import random + +from rich.color import Color, ANSI_COLOR_NAMES +from rich.console import ConsoleOptions, Console, RenderResult +from rich.segment import Segment +from rich.style import Style + + +class UnderlineBar: + """Thin horizontal bar with a portion highlighted. + + Args: + highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight) + highlight_color (Color | str): The color of the highlighted range of the bar. + non_highlight_color (Color | str): The color of the non-highlighted range(s) of the bar. + background_color (Color | str): The background color of the entire bar. + width (int, optional): The width of the bar, or ``None`` to fill available width. + """ + + def __init__( + self, + highlight_range: tuple[float, float] = (0, 0), + highlight_color: Color | str = "magenta", + non_highlight_color: Color | str = "grey37", + background_color: Color | str = "default", + width: int | None = None, + ) -> None: + self.highlight_range = highlight_range + self.highlight_style = Style(color=highlight_color, bgcolor=background_color) + self.non_highlight_style = Style( + color=non_highlight_color, bgcolor=background_color + ) + self.width = width + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + half_bar_right = "╸" + half_bar_left = "╺" + bar = "━" + width = self.width or options.max_width + start, end = self.highlight_range + + start = max(start, 0) + end = min(end, width) + + if start == end == 0: + yield Segment(bar * width, style=self.non_highlight_style) + return + + # Round start and end to nearest half + start = round(start * 2) / 2 + end = round(end * 2) / 2 + + # Check if we start/end on a number that rounds to a .5 + half_start = start - int(start) > 0 + half_end = end - int(end) > 0 + + # Initial non-highlighted portion of bar + yield Segment(bar * (int(start - 0.5)), style=self.non_highlight_style) + if not half_start and start > 0: + yield Segment(half_bar_right, style=self.non_highlight_style) + + # The highlighted portion + bar_width = int(end) - int(start) + if half_start: + yield Segment( + half_bar_left + bar * (bar_width - 1), style=self.highlight_style + ) + else: + yield Segment(bar * bar_width, style=self.highlight_style) + if half_end: + yield Segment(half_bar_right, style=self.highlight_style) + + # The non-highlighted tail + if not half_end and end - width != 0: + yield Segment(half_bar_left, style=self.non_highlight_style) + yield Segment(bar * (int(width) - int(end) - 1), style=self.non_highlight_style) + + +if __name__ == "__main__": + console = Console() + + def frange(start, end, step): + current = start + while current < end: + yield current + current += step + + while current >= 0: + yield current + current -= step + + step = 0.5 + start_range = frange(0.5, 10.5, step) + end_range = frange(10, 20, step) + ranges = zip(start_range, end_range) + + console.print(UnderlineBar(width=20), f" (.0, .0)") + + for range in ranges: + color = random.choice(list(ANSI_COLOR_NAMES.keys())) + console.print( + UnderlineBar( + range, + highlight_color=Color.parse(color), + width=20, + ), + f" {range}", + ) diff --git a/tests/renderables/__init__.py b/tests/renderables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/renderables/test_underline_bar.py b/tests/renderables/test_underline_bar.py new file mode 100644 index 000000000..c12256c66 --- /dev/null +++ b/tests/renderables/test_underline_bar.py @@ -0,0 +1,98 @@ +from tests.utilities.render import render +from textual.renderables.underline_bar import UnderlineBar + +MAGENTA = "\x1b[35;49m" +GREY = "\x1b[38;5;59;49m" +STOP = "\x1b[0m" + + +def test_no_highlight(): + bar = UnderlineBar(width=6) + assert render(bar) == f"{GREY}━━━━━━{STOP}" + + +def test_highlight_from_zero(): + bar = UnderlineBar(highlight_range=(0, 2.5), width=6) + assert render(bar) == ( + f"{MAGENTA}━━{STOP}{MAGENTA}╸{STOP}{GREY}━━━{STOP}" + ) + + +def test_highlight_from_zero_point_five(): + bar = UnderlineBar(highlight_range=(0.5, 2), width=6) + assert render(bar) == ( + f"{MAGENTA}╺━{STOP}{GREY}╺{STOP}{GREY}━━━{STOP}" + ) + + +def test_highlight_middle(): + bar = UnderlineBar(highlight_range=(2, 4), width=6) + assert render(bar) == ( + f"{GREY}━{STOP}" + f"{GREY}╸{STOP}" + f"{MAGENTA}━━{STOP}" + f"{GREY}╺{STOP}" + f"{GREY}━{STOP}" + ) + + +def test_highlight_half_start(): + bar = UnderlineBar(highlight_range=(2.5, 4), width=6) + assert render(bar) == ( + f"{GREY}━━{STOP}" + f"{MAGENTA}╺━{STOP}" + f"{GREY}╺{STOP}" + f"{GREY}━{STOP}" + ) + + +def test_highlight_half_end(): + bar = UnderlineBar(highlight_range=(2, 4.5), width=6) + assert render(bar) == ( + f"{GREY}━{STOP}" + f"{GREY}╸{STOP}" + f"{MAGENTA}━━{STOP}" + f"{MAGENTA}╸{STOP}" + f"{GREY}━{STOP}" + ) + + +def test_highlight_half_start_and_half_end(): + bar = UnderlineBar(highlight_range=(2.5, 4.5), width=6) + assert render(bar) == ( + f"{GREY}━━{STOP}" + f"{MAGENTA}╺━{STOP}" + f"{MAGENTA}╸{STOP}" + f"{GREY}━{STOP}" + ) + + +def test_highlight_to_near_end(): + bar = UnderlineBar(highlight_range=(3, 5.5), width=6) + assert render(bar) == ( + f"{GREY}━━{STOP}" + f"{GREY}╸{STOP}" + f"{MAGENTA}━━{STOP}" + f"{MAGENTA}╸{STOP}" + ) + + +def test_highlight_to_end(): + bar = UnderlineBar(highlight_range=(3, 6), width=6) + assert render(bar) == ( + f"{GREY}━━{STOP}{GREY}╸{STOP}{MAGENTA}━━━{STOP}" + ) + + +def test_highlight_out_of_bounds_start(): + bar = UnderlineBar(highlight_range=(-2, 3), width=6) + assert render(bar) == ( + f"{MAGENTA}━━━{STOP}{GREY}╺{STOP}{GREY}━━{STOP}" + ) + + +def test_highlight_out_of_bounds_end(): + bar = UnderlineBar(highlight_range=(3, 9), width=6) + assert render(bar) == ( + f"{GREY}━━{STOP}{GREY}╸{STOP}{MAGENTA}━━━{STOP}" + ) diff --git a/tests/utilities/render.py b/tests/utilities/render.py new file mode 100644 index 000000000..a2435c542 --- /dev/null +++ b/tests/utilities/render.py @@ -0,0 +1,24 @@ +import io +import re + +from rich.console import Console, RenderableType + + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType, no_wrap: bool = False) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + console.print(renderable, no_wrap=no_wrap) + output = replace_link_ids(console.file.getvalue()) + return output