mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Added tint and opacity
This commit is contained in:
@@ -15,9 +15,9 @@
|
||||
scrollbar-size-vertical: 2;
|
||||
}
|
||||
|
||||
/* *:hover {
|
||||
tint: red 30%;
|
||||
} */
|
||||
*:hover {
|
||||
/* tint: 30% red; */
|
||||
}
|
||||
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -167,6 +167,7 @@ class StylesBase(ABC):
|
||||
"max_height",
|
||||
"color",
|
||||
"background",
|
||||
"opacity",
|
||||
"tint",
|
||||
"scrollbar_color",
|
||||
"scrollbar_color_hover",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user