Added tint and opacity

This commit is contained in:
Will McGugan
2022-06-30 21:41:37 +01:00
parent 410fc91a0e
commit 81481a0e16
8 changed files with 136 additions and 91 deletions

View File

@@ -15,9 +15,9 @@
scrollbar-size-vertical: 2; scrollbar-size-vertical: 2;
} }
/* *:hover { *:hover {
tint: red 30%; /* tint: 30% red; */
} */ }
App > Screen { App > Screen {
layout: dock; layout: dock;

View File

@@ -657,13 +657,13 @@ class Compositor:
for region, clip, lines in renders: for region, clip, lines in renders:
render_region = intersection(region, clip) 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): if not is_rendered_line(y):
continue continue
chops_line = chops[y] chops_line = chops[y]
first_cut, last_cut = render_region.x_extents first_cut, last_cut = render_region.column_span
cuts_line = cuts[y] cuts_line = cuts[y]
final_cuts = [ final_cuts = [
cut for cut in cuts_line if (last_cut >= cut >= first_cut) cut for cut in cuts_line if (last_cut >= cut >= first_cut)

View File

@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from rich.style import Style from rich.style import Style
from typing import TYPE_CHECKING from typing import Iterable, TYPE_CHECKING
from rich.segment import Segment from rich.segment import Segment
from ._border import get_box from ._border import get_box
from .color import Color from .color import Color
from .css.types import EdgeType from .css.types import EdgeType
from .renderables.opacity import Opacity
from .renderables.tint import Tint
from ._segment_tools import line_crop from ._segment_tools import line_crop
from ._types import Lines from ._types import Lines
from .geometry import Region, Size from .geometry import Region, Size
@@ -22,19 +24,21 @@ NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""}
class StylesRenderer: class StylesRenderer:
"""Responsible for rendering CSS Styles and keeping a cached of rendered lines."""
def __init__(self, widget: Widget) -> None: def __init__(self, widget: Widget) -> None:
self._widget = widget self._widget = widget
self._cache: dict[int, list[Segment]] = {} self._cache: dict[int, list[Segment]] = {}
self._dirty_lines: set[int] = set() self._dirty_lines: set[int] = set()
def set_dirty(self, *regions: Region) -> None: def set_dirty(self, *regions: Region) -> None:
"""Add a dirty region, or set the entire widget as dirty."""
if regions: if regions:
for region in regions: for region in regions:
self._dirty_lines.update(region.y_range) self._dirty_lines.update(region.line_range)
else: else:
self._dirty_lines.clear() self._dirty_lines.clear()
for y in self._widget.size.lines: self._dirty_lines.update(self._widget.size.lines_range)
self._dirty_lines.add(y)
def render(self, region: Region) -> Lines: def render(self, region: Region) -> Lines:
@@ -60,7 +64,7 @@ class StylesRenderer:
is_dirty = self._dirty_lines.__contains__ is_dirty = self._dirty_lines.__contains__
render_line = self.render_line 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: if is_dirty(y) or y not in self._cache:
line = render_line(styles, y, size, base_background, background) line = render_line(styles, y, size, base_background, background)
line = list(simplify(line)) line = list(simplify(line))
@@ -68,11 +72,11 @@ class StylesRenderer:
else: else:
line = self._cache[y] line = self._cache[y]
add_line(line) 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 _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] lines = [_line_crop(line, x1, x2, width) for line in lines]
return lines return lines
@@ -109,9 +113,17 @@ class StylesRenderer:
from_color = Style.from_color 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) 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 # Draw top or bottom borders
if border_top and y in (0, height - 1): if border_top and y in (0, height - 1):
@@ -120,22 +132,24 @@ class StylesRenderer:
border_top if y == 0 else border_bottom, border_top if y == 0 else border_bottom,
inner_style, inner_style,
outer_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] box1, box2, box3 = box_segments[0 if y == 0 else 2]
if border_left and border_right: 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: 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: elif border_right:
return [Segment(box2.text * (width - 1), box2.style), box3] return post([Segment(box2.text * (width - 1), box2.style), box3])
else: else:
return [Segment(box2.text * width, box2.style)] return post([Segment(box2.text * width, box2.style)])
# Draw padding # Draw padding
if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): 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( _, (left, _, _), _ = get_box(
border_left, border_left,
inner_style, inner_style,
@@ -146,15 +160,15 @@ class StylesRenderer:
border_right, border_right,
inner_style, inner_style,
outer_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: 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: if border_left:
return [left, Segment(" " * (width - 1), background_style)] return post([left, Segment(" " * (width - 1), background_style)])
if border_right: if border_right:
return [Segment(" " * (width - 1), background_style), right] return post([Segment(" " * (width - 1), background_style), right])
return [Segment(" " * width, background_style)] return post([Segment(" " * width, background_style)])
# Apply background style # Apply background style
line = self.render_content_line(y - gutter.top) line = self.render_content_line(y - gutter.top)
@@ -180,7 +194,7 @@ class StylesRenderer:
] ]
if not border_left and not border_right: if not border_left and not border_right:
return line return post(line)
# Add left / right border # Add left / right border
_, (left, _, _), _ = get_box( _, (left, _, _), _ = get_box(
@@ -197,11 +211,11 @@ class StylesRenderer:
) )
if border_left and border_right: if border_left and border_right:
return [left, *line, right] return post([left, *line, right])
elif border_left: elif border_left:
return [left, *line] return post([left, *line])
return [*line, right] return post([*line, right])
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -167,6 +167,7 @@ class StylesBase(ABC):
"max_height", "max_height",
"color", "color",
"background", "background",
"opacity",
"tint", "tint",
"scrollbar_color", "scrollbar_color",
"scrollbar_color_hover", "scrollbar_color_hover",

View File

@@ -140,8 +140,8 @@ class Size(NamedTuple):
return Region(0, 0, width, height) return Region(0, 0, width, height)
@property @property
def lines(self) -> list[int]: def lines_range(self) -> range:
return list(range(self.height)) return range(self.height)
def __add__(self, other: object) -> Size: def __add__(self, other: object) -> Size:
if isinstance(other, tuple): if isinstance(other, tuple):
@@ -310,21 +310,21 @@ class Region(NamedTuple):
return bool(self.width and self.height) return bool(self.width and self.height)
@property @property
def x_extents(self) -> tuple[int, int]: def column_span(self) -> tuple[int, int]:
"""Get the starting and ending x coord. """Get the start and end column (x coord).
The end value is non inclusive. The end value is exclusive.
Returns: 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) return (self.x, self.x + self.width)
@property @property
def y_extents(self) -> tuple[int, int]: def line_span(self) -> tuple[int, int]:
"""Get the starting and ending x coord. """Get the star and end line number (y coord).
The end value is non inclusive. The end value is exclusive.
Returns: Returns:
tuple[int, int]: Pair of y coordinates (line numbers). tuple[int, int]: Pair of y coordinates (line numbers).
@@ -385,12 +385,12 @@ class Region(NamedTuple):
return x, y, x + width, y + height return x, y, x + width, y + height
@property @property
def x_range(self) -> range: def column_range(self) -> range:
"""A range object for X coordinates.""" """A range object for X coordinates."""
return range(self.x, self.x + self.width) return range(self.x, self.x + self.width)
@property @property
def y_range(self) -> range: def line_range(self) -> range:
"""A range object for Y coordinates.""" """A range object for Y coordinates."""
return range(self.y, self.y + self.height) return range(self.y, self.y + self.height)

View File

@@ -1,4 +1,5 @@
import functools import functools
from typing import Iterable
from rich.color import Color from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType 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 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) @functools.lru_cache(maxsize=1024)
def _get_blended_style_cached( def _get_blended_style_cached(
fg_color: Color, bg_color: Color, opacity: float bg_color: Color, fg_color: Color, opacity: float
) -> Style: ) -> Style:
return Style.from_color( return Style.from_color(
color=blend_colors(bg_color, fg_color, ratio=opacity), 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__": if __name__ == "__main__":
from rich.live import Live from rich.live import Live
from rich.panel import Panel from rich.panel import Panel

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
from rich.segment import Segment from rich.segment import Segment
@@ -12,7 +13,7 @@ class Tint:
"""Applies a color on top of an existing renderable.""" """Applies a color on top of an existing renderable."""
def __init__(self, renderable: RenderableType, color: Color) -> None: def __init__(self, renderable: RenderableType, color: Color) -> None:
"""_summary_ """Wrap a renderable to apply a tint color.
Args: Args:
renderable (RenderableType): A renderable. renderable (RenderableType): A renderable.
@@ -21,20 +22,29 @@ class Tint:
self.renderable = renderable self.renderable = renderable
self.color = color self.color = color
def __rich_console__( @classmethod
self, console: Console, options: ConsoleOptions def process_segments(
) -> RenderResult: cls, segments: Iterable[Segment], color: Color
segments = console.render(self.renderable, options) ) -> 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 from_rich_color = Color.from_rich_color
style_from_color = Style.from_color style_from_color = Style.from_color
_Segment = Segment
for segment in segments: for segment in segments:
text, style, control = segment text, style, control = segment
if control or style is None: if control or style is None:
yield segment yield segment
else: else:
yield Segment( yield _Segment(
text, text,
( (
style style
@@ -45,3 +55,10 @@ class Tint:
), ),
control, 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)

View File

@@ -279,11 +279,11 @@ def test_size_sub():
def test_region_x_extents(): 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(): 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(): def test_region_x_max():
@@ -294,12 +294,12 @@ def test_region_y_max():
assert Region(5, 10, 20, 30).bottom == 40 assert Region(5, 10, 20, 30).bottom == 40
def test_region_x_range(): def test_region_columns_range():
assert Region(5, 10, 20, 30).x_range == range(5, 25) assert Region(5, 10, 20, 30).column_range == range(5, 25)
def test_region_y_range(): def test_region_lines_range():
assert Region(5, 10, 20, 30).y_range == range(10, 40) assert Region(5, 10, 20, 30).line_range == range(10, 40)
def test_region_reset_offset(): def test_region_reset_offset():