From 81481a0e16c41cfd5c7dd2e6c94e16956f0e2421 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 30 Jun 2022 21:41:37 +0100 Subject: [PATCH] Added tint and opacity --- sandbox/will/basic.css | 6 +- src/textual/_compositor.py | 4 +- src/textual/_styles_render.py | 62 +++++++++++++-------- src/textual/css/styles.py | 1 + src/textual/geometry.py | 22 ++++---- src/textual/renderables/opacity.py | 89 +++++++++++++++++------------- src/textual/renderables/tint.py | 31 ++++++++--- tests/test_geometry.py | 12 ++-- 8 files changed, 136 insertions(+), 91 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 791d3ee14..579ec30c3 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,9 +15,9 @@ scrollbar-size-vertical: 2; } -/* *:hover { - tint: red 30%; -} */ +*:hover { + /* tint: 30% red; */ +} App > Screen { layout: dock; diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bb2523485..f4eb19095 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -657,13 +657,13 @@ class Compositor: for region, clip, lines in renders: render_region = intersection(region, clip) - for y, line in zip(render_region.y_range, lines): + for y, line in zip(render_region.line_range, lines): if not is_rendered_line(y): continue chops_line = chops[y] - first_cut, last_cut = render_region.x_extents + first_cut, last_cut = render_region.column_span cuts_line = cuts[y] final_cuts = [ cut for cut in cuts_line if (last_cut >= cut >= first_cut) diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index bdc437b76..ca4fbcaad 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -1,13 +1,15 @@ from __future__ import annotations from rich.style import Style -from typing import TYPE_CHECKING +from typing import Iterable, TYPE_CHECKING from rich.segment import Segment from ._border import get_box from .color import Color from .css.types import EdgeType +from .renderables.opacity import Opacity +from .renderables.tint import Tint from ._segment_tools import line_crop from ._types import Lines from .geometry import Region, Size @@ -22,19 +24,21 @@ NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""} class StylesRenderer: + """Responsible for rendering CSS Styles and keeping a cached of rendered lines.""" + def __init__(self, widget: Widget) -> None: self._widget = widget self._cache: dict[int, list[Segment]] = {} self._dirty_lines: set[int] = set() def set_dirty(self, *regions: Region) -> None: + """Add a dirty region, or set the entire widget as dirty.""" if regions: for region in regions: - self._dirty_lines.update(region.y_range) + self._dirty_lines.update(region.line_range) else: self._dirty_lines.clear() - for y in self._widget.size.lines: - self._dirty_lines.add(y) + self._dirty_lines.update(self._widget.size.lines_range) def render(self, region: Region) -> Lines: @@ -60,7 +64,7 @@ class StylesRenderer: is_dirty = self._dirty_lines.__contains__ render_line = self.render_line - for y in region.y_range: + for y in region.line_range: if is_dirty(y) or y not in self._cache: line = render_line(styles, y, size, base_background, background) line = list(simplify(line)) @@ -68,11 +72,11 @@ class StylesRenderer: else: line = self._cache[y] add_line(line) - self._dirty_lines.difference_update(region.y_range) + self._dirty_lines.difference_update(region.line_range) - if region.x_extents != (0, width): + if region.column_span != (0, width): _line_crop = line_crop - x1, x2 = region.x_extents + x1, x2 = region.column_span lines = [_line_crop(line, x1, x2, width) for line in lines] return lines @@ -109,9 +113,17 @@ class StylesRenderer: from_color = Style.from_color - inner_style = from_color(bgcolor=background.rich_color) + rich_style = styles.rich_style + inner_style = from_color(bgcolor=background.rich_color) + rich_style outer_style = from_color(bgcolor=base_background.rich_color) + def post(segments: Iterable[Segment]) -> list[Segment]: + if styles.opacity != 1.0: + segments = Opacity.process_segments(segments, styles.opacity) + if styles.tint.a: + segments = Tint.process_segments(segments, styles.tint) + return segments if isinstance(segments, list) else list(segments) + # Draw top or bottom borders if border_top and y in (0, height - 1): @@ -120,22 +132,24 @@ class StylesRenderer: border_top if y == 0 else border_bottom, inner_style, outer_style, - Style.from_color(color=border_color.rich_color), + from_color(color=border_color.rich_color), ) box1, box2, box3 = box_segments[0 if y == 0 else 2] if border_left and border_right: - return [box1, Segment(box2.text * (width - 2), box2.style), box3] + return post([box1, Segment(box2.text * (width - 2), box2.style), box3]) elif border_left: - return [box1, Segment(box2.text * (width - 1), box2.style)] + return post([box1, Segment(box2.text * (width - 1), box2.style)]) elif border_right: - return [Segment(box2.text * (width - 1), box2.style), box3] + return post([Segment(box2.text * (width - 1), box2.style), box3]) else: - return [Segment(box2.text * width, box2.style)] + return post([Segment(box2.text * width, box2.style)]) # Draw padding if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): - background_style = from_color(bgcolor=background.rich_color) + background_style = from_color( + color=rich_style.color, bgcolor=background.rich_color + ) _, (left, _, _), _ = get_box( border_left, inner_style, @@ -146,15 +160,15 @@ class StylesRenderer: border_right, inner_style, outer_style, - Style.from_color(color=border_right_color.rich_color), + from_color(color=border_right_color.rich_color), ) if border_left and border_right: - return [left, Segment(" " * (width - 2), background_style), right] + return post([left, Segment(" " * (width - 2), background_style), right]) if border_left: - return [left, Segment(" " * (width - 1), background_style)] + return post([left, Segment(" " * (width - 1), background_style)]) if border_right: - return [Segment(" " * (width - 1), background_style), right] - return [Segment(" " * width, background_style)] + return post([Segment(" " * (width - 1), background_style), right]) + return post([Segment(" " * width, background_style)]) # Apply background style line = self.render_content_line(y - gutter.top) @@ -180,7 +194,7 @@ class StylesRenderer: ] if not border_left and not border_right: - return line + return post(line) # Add left / right border _, (left, _, _), _ = get_box( @@ -197,11 +211,11 @@ class StylesRenderer: ) if border_left and border_right: - return [left, *line, right] + return post([left, *line, right]) elif border_left: - return [left, *line] + return post([left, *line]) - return [*line, right] + return post([*line, right]) if __name__ == "__main__": diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 30a2f2234..34543e5b0 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -167,6 +167,7 @@ class StylesBase(ABC): "max_height", "color", "background", + "opacity", "tint", "scrollbar_color", "scrollbar_color_hover", diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 79ca4dd27..09454411e 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -140,8 +140,8 @@ class Size(NamedTuple): return Region(0, 0, width, height) @property - def lines(self) -> list[int]: - return list(range(self.height)) + def lines_range(self) -> range: + return range(self.height) def __add__(self, other: object) -> Size: if isinstance(other, tuple): @@ -310,21 +310,21 @@ class Region(NamedTuple): return bool(self.width and self.height) @property - def x_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + def column_span(self) -> tuple[int, int]: + """Get the start and end column (x coord). - The end value is non inclusive. + The end value is exclusive. Returns: - tuple[int, int]: Pair of x coordinates (row numbers). + tuple[int, int]: Pair of x coordinates (column numbers). """ return (self.x, self.x + self.width) @property - def y_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + def line_span(self) -> tuple[int, int]: + """Get the star and end line number (y coord). - The end value is non inclusive. + The end value is exclusive. Returns: tuple[int, int]: Pair of y coordinates (line numbers). @@ -385,12 +385,12 @@ class Region(NamedTuple): return x, y, x + width, y + height @property - def x_range(self) -> range: + def column_range(self) -> range: """A range object for X coordinates.""" return range(self.x, self.x + self.width) @property - def y_range(self) -> range: + def line_range(self) -> range: """A range object for Y coordinates.""" return range(self.y, self.y + self.height) diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py index f1d73d335..5dabac89a 100644 --- a/src/textual/renderables/opacity.py +++ b/src/textual/renderables/opacity.py @@ -1,4 +1,5 @@ import functools +from typing import Iterable from rich.color import Color from rich.console import ConsoleOptions, Console, RenderResult, RenderableType @@ -8,46 +9,9 @@ from rich.style import Style from textual.renderables._blend_colors import blend_colors -class Opacity: - """Wrap a renderable to blend foreground color into the background color. - - Args: - renderable (RenderableType): The RenderableType to manipulate. - opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. - """ - - def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: - self.renderable = renderable - self.opacity = opacity - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - segments = console.render(self.renderable, options) - opacity = self.opacity - for segment in segments: - style = segment.style - if not style: - yield segment - continue - fg = style.color - bg = style.bgcolor - if fg and fg.triplet and bg and bg.triplet: - color_style = _get_blended_style_cached( - fg_color=fg, bg_color=bg, opacity=opacity - ) - yield Segment( - segment.text, - style + color_style, - segment.control, - ) - else: - yield segment - - @functools.lru_cache(maxsize=1024) def _get_blended_style_cached( - fg_color: Color, bg_color: Color, opacity: float + bg_color: Color, fg_color: Color, opacity: float ) -> Style: return Style.from_color( color=blend_colors(bg_color, fg_color, ratio=opacity), @@ -55,6 +19,55 @@ def _get_blended_style_cached( ) +class Opacity: + """Blend foreground in to background.""" + + def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: + """Wrap a renderable to blend foreground color into the background color. + + Args: + renderable (RenderableType): The RenderableType to manipulate. + opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. + """ + self.renderable = renderable + self.opacity = opacity + + @classmethod + def process_segments( + cls, segments: Iterable[Segment], opacity: float + ) -> Iterable[Segment]: + """Apply opacity to segments. + + Args: + segments (Iterable[Segment]): Incoming segments. + opacity (float): Opacity to apply. + + Returns: + Iterable[Segment]: Segments with applied opacity. + + """ + _Segment = Segment + for segment in segments: + text, style, control = segment + if not style: + yield segment + continue + + color = style.color + bgcolor = style.bgcolor + if color and color.triplet and bgcolor and bgcolor.triplet: + color_style = _get_blended_style_cached(bgcolor, color, opacity) + yield _Segment(text, style + color_style) + else: + yield segment + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + return self.process_segments(segments, self.opacity) + + if __name__ == "__main__": from rich.live import Live from rich.panel import Panel diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index 464bdd76d..196a07be4 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Iterable from rich.console import ConsoleOptions, Console, RenderResult, RenderableType from rich.segment import Segment @@ -12,7 +13,7 @@ class Tint: """Applies a color on top of an existing renderable.""" def __init__(self, renderable: RenderableType, color: Color) -> None: - """_summary_ + """Wrap a renderable to apply a tint color. Args: renderable (RenderableType): A renderable. @@ -21,20 +22,29 @@ class Tint: self.renderable = renderable self.color = color - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - segments = console.render(self.renderable, options) + @classmethod + def process_segments( + cls, segments: Iterable[Segment], color: Color + ) -> Iterable[Segment]: + """Apply tint to segments. - color = self.color + Args: + segments (Iterable[Segment]): Incoming segments. + color (Color): Color of tint. + + Returns: + Iterable[Segment]: Segments with applied tint. + + """ from_rich_color = Color.from_rich_color style_from_color = Style.from_color + _Segment = Segment for segment in segments: text, style, control = segment if control or style is None: yield segment else: - yield Segment( + yield _Segment( text, ( style @@ -45,3 +55,10 @@ class Tint: ), control, ) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + color = self.color + return self.process_segments(segments, color) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 241acdd34..eb9e9a16a 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -279,11 +279,11 @@ def test_size_sub(): def test_region_x_extents(): - assert Region(5, 10, 20, 30).x_extents == (5, 25) + assert Region(5, 10, 20, 30).column_span == (5, 25) def test_region_y_extents(): - assert Region(5, 10, 20, 30).y_extents == (10, 40) + assert Region(5, 10, 20, 30).line_span == (10, 40) def test_region_x_max(): @@ -294,12 +294,12 @@ def test_region_y_max(): assert Region(5, 10, 20, 30).bottom == 40 -def test_region_x_range(): - assert Region(5, 10, 20, 30).x_range == range(5, 25) +def test_region_columns_range(): + assert Region(5, 10, 20, 30).column_range == range(5, 25) -def test_region_y_range(): - assert Region(5, 10, 20, 30).y_range == range(10, 40) +def test_region_lines_range(): + assert Region(5, 10, 20, 30).line_range == range(10, 40) def test_region_reset_offset():