diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index a623c53f40..25762f782e 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -2,7 +2,7 @@ from functools import lru_cache from sys import intern -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Callable, Iterable, Sequence from rich.console import Console from rich.segment import Segment @@ -139,7 +139,7 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: content_size=widget.content_region.size, padding=styles.padding, crop=crop, - filter=widget.app._filter, + filters=widget.app._filters, ) if widget.auto_links: hover_style = widget.hover_style @@ -170,7 +170,7 @@ def render( content_size: Size | None = None, padding: Spacing | None = None, crop: Region | None = None, - filter: LineFilter | None = None, + filters: Sequence[LineFilter] | None = None, ) -> list[Strip]: """Render a widget content plus CSS styles. @@ -186,7 +186,7 @@ def render( content_size: Size of content or None to assume full size. padding: Override padding from Styles, or None to use styles.padding. crop: Region to crop to. - filter: Additional post-processing for the segments. + filters: Additional post-processing for the segments. Returns: Rendered lines. @@ -225,8 +225,9 @@ def render( self._cache[y] = strip else: strip = self._cache[y] - if filter: - strip = strip.apply_filter(filter) + if filters: + for filter in filters: + strip = strip.apply_filter(filter, background) add_strip(strip) self._dirty_lines.difference_update(crop.line_range) diff --git a/src/textual/app.py b/src/textual/app.py index e7503664f1..3656b08f67 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -29,7 +29,6 @@ from datetime import datetime from functools import partial from pathlib import Path, PurePath -from queue import Queue from time import perf_counter from typing import ( TYPE_CHECKING, @@ -53,6 +52,7 @@ import rich import rich.repr +from rich import terminal_theme from rich.console import Console, RenderableType from rich.protocol import is_renderable from rich.segment import Segment, Segments @@ -81,7 +81,7 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor -from .filter import LineFilter, Monochrome +from .filter import ANSIToTruecolor, DimFilter, LineFilter, Monochrome from .geometry import Offset, Region, Size from .keys import ( REPLACED_KEYS, @@ -260,11 +260,17 @@ def __init__( super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) - self._filter: LineFilter | None = None + self._filters: list[LineFilter] = [] environ = dict(os.environ) no_color = environ.pop("NO_COLOR", None) if no_color is not None: - self._filter = Monochrome() + self._filters.append(Monochrome()) + + for filter_name in constants.FILTERS.split(","): + filter = filter_name.lower().strip() + if filter == "dim": + self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) + self._filters.append(DimFilter()) self.console = Console( file=_NullFile(), diff --git a/src/textual/constants.py b/src/textual/constants.py index c73fe31af1..a29c1b312a 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -53,6 +53,9 @@ def get_environ_int(name: str, default: int) -> int: DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) +FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "") +"""A list of filters to apply to renderables.""" + LOG_FILE: Final[str | None] = get_environ("TEXTUAL_LOG", None) """A last resort log file that appends all logs, when devtools isn't working.""" diff --git a/src/textual/filter.py b/src/textual/filter.py index 387f49be91..271636a641 100644 --- a/src/textual/filter.py +++ b/src/textual/filter.py @@ -6,6 +6,7 @@ from rich.color import Color as RichColor from rich.segment import Segment from rich.style import Style +from rich.terminal_theme import TerminalTheme from .color import Color @@ -14,11 +15,12 @@ class LineFilter(ABC): """Base class for a line filter.""" @abstractmethod - def apply(self, segments: list[Segment]) -> list[Segment]: + def apply(self, segments: list[Segment], background: Color) -> list[Segment]: """Transform a list of segments. Args: segments: A list of segments. + background: The background color. Returns: A new list of segments. @@ -53,11 +55,12 @@ def monochrome_style(style: Style) -> Style: class Monochrome(LineFilter): """Convert all colors to monochrome.""" - def apply(self, segments: list[Segment]) -> list[Segment]: + def apply(self, segments: list[Segment], background: Color) -> list[Segment]: """Transform a list of segments. Args: segments: A list of segments. + background: The background color. Returns: A new list of segments. @@ -96,8 +99,11 @@ def dim_color(background: RichColor, color: RichColor, factor: float) -> RichCol ) +DEFAULT_COLOR = RichColor.default() + + @lru_cache(1024) -def dim_style(style: Style, factor: float) -> Style: +def dim_style(style: Style, background: Color, factor: float) -> Style: """Replace dim attribute with a dim color. Args: @@ -109,9 +115,19 @@ def dim_style(style: Style, factor: float) -> Style: """ return ( style - + Style.from_color(dim_color(style.bgcolor, style.color, factor), None) - + NO_DIM - ) + + Style.from_color( + dim_color( + ( + background.rich_color + if style.bgcolor.is_default + else (style.bgcolor or DEFAULT_COLOR) + ), + style.color or DEFAULT_COLOR, + factor, + ), + None, + ) + ) + NO_DIM # Can be used as a workaround for https://github.com/xtermjs/xterm.js/issues/4161 @@ -126,11 +142,12 @@ def __init__(self, dim_factor: float = 0.5) -> None: """ self.dim_factor = dim_factor - def apply(self, segments: list[Segment]) -> list[Segment]: + def apply(self, segments: list[Segment], background: Color) -> list[Segment]: """Transform a list of segments. Args: segments: A list of segments. + background: The background color. Returns: A new list of segments. @@ -143,7 +160,7 @@ def apply(self, segments: list[Segment]) -> list[Segment]: ( _Segment( segment.text, - _dim_style(segment.style, factor), + _dim_style(segment.style, background, factor), None, ) if segment.style is not None and segment.style.dim @@ -151,3 +168,60 @@ def apply(self, segments: list[Segment]) -> list[Segment]: ) for segment in segments ] + + +class ANSIToTruecolor(LineFilter): + """Convert ANSI colors to their truecolor equivalents.""" + + def __init__(self, terminal_theme: TerminalTheme): + """Initialise filter. + + Args: + terminal_theme: A rich terminal theme. + """ + self.terminal_theme = terminal_theme + + @lru_cache(1024) + def truecolor_style(self, style: Style) -> Style: + """Replace system colors with truecolor equivalent. + + Args: + style: Style to apply truecolor filter to. + + Returns: + New style. + """ + terminal_theme = self.terminal_theme + color = style.color + if color is not None and color.is_system_defined: + color = RichColor.from_rgb( + *color.get_truecolor(terminal_theme, foreground=True) + ) + bgcolor = style.bgcolor + if bgcolor is not None and bgcolor.is_system_defined: + bgcolor = RichColor.from_rgb( + *bgcolor.get_truecolor(terminal_theme, foreground=False) + ) + return style + Style.from_color(color, bgcolor) + + def apply(self, segments: list[Segment], background: Color) -> list[Segment]: + """Transform a list of segments. + + Args: + segments: A list of segments. + background: The background color. + + Returns: + A new list of segments. + """ + _Segment = Segment + truecolor_style = self.truecolor_style + + return [ + _Segment( + text, + None if style is None else truecolor_style(style), + None, + ) + for text, style, _ in segments + ] diff --git a/src/textual/strip.py b/src/textual/strip.py index 4d2a21d34d..a44bead119 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -18,6 +18,7 @@ from ._cache import FIFOCache from ._segment_tools import index_to_cell_position +from .color import Color from .constants import DEBUG from .filter import LineFilter @@ -67,6 +68,7 @@ class Strip: "_divide_cache", "_crop_cache", "_style_cache", + "_filter_cache", "_render_cache", "_link_ids", ] @@ -79,6 +81,7 @@ def __init__( self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4) self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(16) self._style_cache: FIFOCache[Style, Strip] = FIFOCache(16) + self._filter_cache: FIFOCache[tuple[LineFilter, Color], Strip] = FIFOCache(4) self._render_cache: str | None = None self._link_ids: set[str] | None = None @@ -265,7 +268,7 @@ def simplify(self) -> Strip: ) return line - def apply_filter(self, filter: LineFilter) -> Strip: + def apply_filter(self, filter: LineFilter, background: Color) -> Strip: """Apply a filter to all segments in the strip. Args: @@ -274,7 +277,12 @@ def apply_filter(self, filter: LineFilter) -> Strip: Returns: A new Strip. """ - return Strip(filter.apply(self._segments), self._cell_length) + cached_strip = self._filter_cache.get((filter, background)) + if cached_strip is None: + cached_strip = Strip( + filter.apply(self._segments, background), self._cell_length + ) + return cached_strip def style_links(self, link_id: str, link_style: Style) -> Strip: """Apply a style to Segments with the given link_id. diff --git a/tests/test_line_filter.py b/tests/test_line_filter.py index 06fb00ee36..1127da62cc 100644 --- a/tests/test_line_filter.py +++ b/tests/test_line_filter.py @@ -1,6 +1,7 @@ from rich.segment import Segment from rich.style import Style +from textual.color import Color from textual.filter import DimFilter @@ -11,7 +12,7 @@ def test_dim_apply(): segments = [Segment("Hello, World!", Style.parse("dim #ffffff on #0000ff"))] - dimmed_segments = dim_filter.apply(segments) + dimmed_segments = dim_filter.apply(segments, Color(0, 0, 0)) expected = [Segment("Hello, World!", Style.parse("not dim #7f7fff on #0000ff"))] diff --git a/tests/test_strip.py b/tests/test_strip.py index 299a152d00..fda2057a89 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -3,6 +3,7 @@ from rich.style import Style from textual._segment_tools import NoCellPositionForIndex +from textual.color import Color from textual.filter import Monochrome from textual.strip import Strip @@ -102,7 +103,7 @@ def test_apply_filter(): expected = Strip([Segment("foo", Style.parse("#1b1b1b"))]) print(repr(strip)) print(repr(expected)) - assert strip.apply_filter(Monochrome()) == expected + assert strip.apply_filter(Monochrome(), Color(0, 0, 0)) == expected def test_style_links():