implement dim fix (#2326)

* implement dim fix

* docstrings

* foreground fix

* cached filters

* cache default

* fix for filter tests

* docstring

* optimization

* Update src/textual/filter.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/constants.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-04-19 13:24:31 +01:00
committed by GitHub
parent 7c5203aa1b
commit 81882fdf7d
7 changed files with 116 additions and 22 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from sys import intern 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.console import Console
from rich.segment import Segment from rich.segment import Segment
@@ -139,7 +139,7 @@ class StylesCache:
content_size=widget.content_region.size, content_size=widget.content_region.size,
padding=styles.padding, padding=styles.padding,
crop=crop, crop=crop,
filter=widget.app._filter, filters=widget.app._filters,
) )
if widget.auto_links: if widget.auto_links:
hover_style = widget.hover_style hover_style = widget.hover_style
@@ -170,7 +170,7 @@ class StylesCache:
content_size: Size | None = None, content_size: Size | None = None,
padding: Spacing | None = None, padding: Spacing | None = None,
crop: Region | None = None, crop: Region | None = None,
filter: LineFilter | None = None, filters: Sequence[LineFilter] | None = None,
) -> list[Strip]: ) -> list[Strip]:
"""Render a widget content plus CSS styles. """Render a widget content plus CSS styles.
@@ -186,7 +186,7 @@ class StylesCache:
content_size: Size of content or None to assume full size. content_size: Size of content or None to assume full size.
padding: Override padding from Styles, or None to use styles.padding. padding: Override padding from Styles, or None to use styles.padding.
crop: Region to crop to. crop: Region to crop to.
filter: Additional post-processing for the segments. filters: Additional post-processing for the segments.
Returns: Returns:
Rendered lines. Rendered lines.
@@ -225,8 +225,9 @@ class StylesCache:
self._cache[y] = strip self._cache[y] = strip
else: else:
strip = self._cache[y] strip = self._cache[y]
if filter: if filters:
strip = strip.apply_filter(filter) for filter in filters:
strip = strip.apply_filter(filter, background)
add_strip(strip) add_strip(strip)
self._dirty_lines.difference_update(crop.line_range) self._dirty_lines.difference_update(crop.line_range)

View File

@@ -29,7 +29,6 @@ from contextlib import (
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from pathlib import Path, PurePath from pathlib import Path, PurePath
from queue import Queue
from time import perf_counter from time import perf_counter
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -53,6 +52,7 @@ from weakref import WeakSet
import rich import rich
import rich.repr import rich.repr
from rich import terminal_theme
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.protocol import is_renderable from rich.protocol import is_renderable
from rich.segment import Segment, Segments from rich.segment import Segment, Segments
@@ -81,7 +81,7 @@ from .driver import Driver
from .drivers.headless_driver import HeadlessDriver from .drivers.headless_driver import HeadlessDriver
from .features import FeatureFlag, parse_features from .features import FeatureFlag, parse_features
from .file_monitor import FileMonitor from .file_monitor import FileMonitor
from .filter import LineFilter, Monochrome from .filter import ANSIToTruecolor, DimFilter, LineFilter, Monochrome
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
from .keys import ( from .keys import (
REPLACED_KEYS, REPLACED_KEYS,
@@ -260,11 +260,17 @@ class App(Generic[ReturnType], DOMNode):
super().__init__() super().__init__()
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
self._filter: LineFilter | None = None self._filters: list[LineFilter] = []
environ = dict(os.environ) environ = dict(os.environ)
no_color = environ.pop("NO_COLOR", None) no_color = environ.pop("NO_COLOR", None)
if no_color is not 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( self.console = Console(
file=_NullFile(), file=_NullFile(),

View File

@@ -53,6 +53,9 @@ DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) 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) LOG_FILE: Final[str | None] = get_environ("TEXTUAL_LOG", None)
"""A last resort log file that appends all logs, when devtools isn't working.""" """A last resort log file that appends all logs, when devtools isn't working."""

View File

@@ -6,6 +6,7 @@ from functools import lru_cache
from rich.color import Color as RichColor from rich.color import Color as RichColor
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.terminal_theme import TerminalTheme
from .color import Color from .color import Color
@@ -14,11 +15,12 @@ class LineFilter(ABC):
"""Base class for a line filter.""" """Base class for a line filter."""
@abstractmethod @abstractmethod
def apply(self, segments: list[Segment]) -> list[Segment]: def apply(self, segments: list[Segment], background: Color) -> list[Segment]:
"""Transform a list of segments. """Transform a list of segments.
Args: Args:
segments: A list of segments. segments: A list of segments.
background: The background color.
Returns: Returns:
A new list of segments. A new list of segments.
@@ -53,11 +55,12 @@ def monochrome_style(style: Style) -> Style:
class Monochrome(LineFilter): class Monochrome(LineFilter):
"""Convert all colors to monochrome.""" """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. """Transform a list of segments.
Args: Args:
segments: A list of segments. segments: A list of segments.
background: The background color.
Returns: Returns:
A new list of segments. 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) @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. """Replace dim attribute with a dim color.
Args: Args:
@@ -109,9 +115,19 @@ def dim_style(style: Style, factor: float) -> Style:
""" """
return ( return (
style style
+ Style.from_color(dim_color(style.bgcolor, style.color, factor), None) + Style.from_color(
+ NO_DIM 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 # Can be used as a workaround for https://github.com/xtermjs/xterm.js/issues/4161
@@ -126,11 +142,12 @@ class DimFilter(LineFilter):
""" """
self.dim_factor = dim_factor 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. """Transform a list of segments.
Args: Args:
segments: A list of segments. segments: A list of segments.
background: The background color.
Returns: Returns:
A new list of segments. A new list of segments.
@@ -143,7 +160,7 @@ class DimFilter(LineFilter):
( (
_Segment( _Segment(
segment.text, segment.text,
_dim_style(segment.style, factor), _dim_style(segment.style, background, factor),
None, None,
) )
if segment.style is not None and segment.style.dim if segment.style is not None and segment.style.dim
@@ -151,3 +168,60 @@ class DimFilter(LineFilter):
) )
for segment in segments 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
]

View File

@@ -18,6 +18,7 @@ from rich.style import Style, StyleType
from ._cache import FIFOCache from ._cache import FIFOCache
from ._segment_tools import index_to_cell_position from ._segment_tools import index_to_cell_position
from .color import Color
from .constants import DEBUG from .constants import DEBUG
from .filter import LineFilter from .filter import LineFilter
@@ -67,6 +68,7 @@ class Strip:
"_divide_cache", "_divide_cache",
"_crop_cache", "_crop_cache",
"_style_cache", "_style_cache",
"_filter_cache",
"_render_cache", "_render_cache",
"_link_ids", "_link_ids",
] ]
@@ -79,6 +81,7 @@ class Strip:
self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4) self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(16) self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(16)
self._style_cache: FIFOCache[Style, 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._render_cache: str | None = None
self._link_ids: set[str] | None = None self._link_ids: set[str] | None = None
@@ -265,7 +268,7 @@ class Strip:
) )
return line 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. """Apply a filter to all segments in the strip.
Args: Args:
@@ -274,7 +277,12 @@ class Strip:
Returns: Returns:
A new Strip. 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: def style_links(self, link_id: str, link_style: Style) -> Strip:
"""Apply a style to Segments with the given link_id. """Apply a style to Segments with the given link_id.

View File

@@ -1,6 +1,7 @@
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from textual.color import Color
from textual.filter import DimFilter from textual.filter import DimFilter
@@ -11,7 +12,7 @@ def test_dim_apply():
segments = [Segment("Hello, World!", Style.parse("dim #ffffff on #0000ff"))] 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"))] expected = [Segment("Hello, World!", Style.parse("not dim #7f7fff on #0000ff"))]

View File

@@ -3,6 +3,7 @@ from rich.segment import Segment
from rich.style import Style from rich.style import Style
from textual._segment_tools import NoCellPositionForIndex from textual._segment_tools import NoCellPositionForIndex
from textual.color import Color
from textual.filter import Monochrome from textual.filter import Monochrome
from textual.strip import Strip from textual.strip import Strip
@@ -102,7 +103,7 @@ def test_apply_filter():
expected = Strip([Segment("foo", Style.parse("#1b1b1b"))]) expected = Strip([Segment("foo", Style.parse("#1b1b1b"))])
print(repr(strip)) print(repr(strip))
print(repr(expected)) print(repr(expected))
assert strip.apply_filter(Monochrome()) == expected assert strip.apply_filter(Monochrome(), Color(0, 0, 0)) == expected
def test_style_links(): def test_style_links():