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;
}
/* *:hover {
tint: red 30%;
} */
*:hover {
/* tint: 30% red; */
}
App > Screen {
layout: dock;

View File

@@ -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)

View File

@@ -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__":

View File

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

View File

@@ -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)

View File

@@ -1,4 +1,5 @@
import functools
from typing import Iterable
from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
@@ -8,51 +9,63 @@ from rich.style import Style
from textual.renderables._blend_colors import blend_colors
@functools.lru_cache(maxsize=1024)
def _get_blended_style_cached(
bg_color: Color, fg_color: Color, opacity: float
) -> Style:
return Style.from_color(
color=blend_colors(bg_color, fg_color, ratio=opacity),
bgcolor=bg_color,
)
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.
"""
def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None:
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)
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
) -> Style:
return Style.from_color(
color=blend_colors(bg_color, fg_color, ratio=opacity),
bgcolor=bg_color,
)
return self.process_segments(segments, self.opacity)
if __name__ == "__main__":

View File

@@ -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)

View File

@@ -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():