Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map ANSI colours #4192

Merged
merged 18 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Mapping of ANSI colors to hex codes configurable via `App.ansi_theme_dark` and `App.ansi_theme_light` https://github.com/Textualize/textual/pull/4192

### Fixed

- Fixed `TextArea.code_editor` missing recently added attributes https://github.com/Textualize/textual/pull/4172
Expand Down
53 changes: 53 additions & 0 deletions src/textual/_ansi_theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from rich.terminal_theme import TerminalTheme

MONOKAI = TerminalTheme(
(12, 12, 12),
(217, 217, 217),
[
(26, 26, 26),
(244, 0, 95),
(152, 224, 36),
(253, 151, 31),
(157, 101, 255),
(244, 0, 95),
(88, 209, 235),
(196, 197, 181),
(98, 94, 76),
],
[
(244, 0, 95),
(152, 224, 36),
(224, 213, 97),
(157, 101, 255),
(244, 0, 95),
(88, 209, 235),
(246, 246, 239),
],
)

ALABASTER = TerminalTheme(
(247, 247, 247),
(0, 0, 0),
[
(0, 0, 0),
(170, 55, 49),
(68, 140, 39),
(203, 144, 0),
(50, 92, 192),
(122, 62, 157),
(0, 131, 178),
(247, 247, 247),
(119, 119, 119),
],
[
(240, 80, 80),
(96, 203, 0),
(255, 188, 93),
(0, 122, 204),
(230, 76, 230),
(0, 170, 203),
(247, 247, 247),
],
)

DEFAULT_TERMINAL_THEME = MONOKAI
10 changes: 9 additions & 1 deletion src/textual/_styles_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from rich.text import Text

from . import log
from ._ansi_theme import DEFAULT_TERMINAL_THEME
from ._border import get_box, render_border_label, render_row
from ._context import active_app
from ._opacity import _apply_opacity
from ._segment_tools import line_pad, line_trim
from .color import Color
Expand Down Expand Up @@ -318,8 +320,14 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
Returns:
New list of segments
"""
try:
app = active_app.get()
ansi_theme = app.ansi_theme
except LookupError:
ansi_theme = DEFAULT_TERMINAL_THEME

if styles.tint.a:
segments = Tint.process_segments(segments, styles.tint)
segments = Tint.process_segments(segments, styles.tint, ansi_theme)
if opacity != 1.0:
segments = _apply_opacity(segments, base_background, opacity)
return segments
Expand Down
53 changes: 49 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@

import rich
import rich.repr
from rich import terminal_theme
from rich.console import Console, RenderableType
from rich.control import Control
from rich.protocol import is_renderable
from rich.segment import Segment, Segments
from rich.terminal_theme import TerminalTheme

from . import (
Logger,
Expand All @@ -71,6 +71,7 @@
)
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
from ._ansi_sequences import SYNC_END, SYNC_START
from ._ansi_theme import ALABASTER, MONOKAI
from ._callback import invoke
from ._compose import compose
from ._compositor import CompositorUpdate
Expand Down Expand Up @@ -398,6 +399,12 @@ class MyApp(App[None]):
get focus when the terminal widget has focus.
"""

ansi_theme_dark = Reactive(MONOKAI, init=False)
"""Maps ANSI colors to hex colors using a Rich TerminalTheme object while in dark mode."""

ansi_theme_light = Reactive(ALABASTER, init=False)
"""Maps ANSI colors to hex colors using a Rich TerminalTheme object while in light mode."""

def __init__(
self,
driver_class: Type[Driver] | None = None,
Expand All @@ -422,9 +429,9 @@ def __init__(
super().__init__()
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))

self._filters: list[LineFilter] = [
ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)
]
ansi_theme = self.ansi_theme_dark if self.dark else self.ansi_theme_light
self._filters: list[LineFilter] = [ANSIToTruecolor(ansi_theme)]

environ = dict(os.environ)
no_color = environ.pop("NO_COLOR", None)
if no_color is not None:
Expand Down Expand Up @@ -880,8 +887,46 @@ def watch_dark(self, dark: bool) -> None:
"""
self.set_class(dark, "-dark-mode", update=False)
self.set_class(not dark, "-light-mode", update=False)
self._refresh_truecolor_filter(self.ansi_theme)
self.call_later(self.refresh_css)

def watch_ansi_theme_dark(self, theme: TerminalTheme) -> None:
if self.dark:
self._refresh_truecolor_filter(theme)
self.call_later(self.refresh_css)

def watch_ansi_theme_light(self, theme: TerminalTheme) -> None:
if not self.dark:
self._refresh_truecolor_filter(theme)
self.call_later(self.refresh_css)

@property
def ansi_theme(self) -> TerminalTheme:
"""The ANSI TerminalTheme currently being used.

Defines how colors defined as ANSI (e.g. `magenta`) inside Rich renderables
are mapped to hex codes.
"""
return self.ansi_theme_dark if self.dark else self.ansi_theme_light

def _refresh_truecolor_filter(self, theme: TerminalTheme) -> None:
"""Update the ANSI to Truecolor filter, if available, with a new theme mapping.

Args:
theme: The new terminal theme to use for mapping ANSI to truecolor.
"""
filters = self._filters
target_index = -1
for index, filter in enumerate(filters):
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(filter, ANSIToTruecolor):
target_index = index
break

if target_index == -1:
return

filters[target_index] = ANSIToTruecolor(theme)

def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform.

Expand Down
4 changes: 2 additions & 2 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import rich.repr
from rich.align import Align
from rich.console import Group, RenderableType
from rich.emoji import Emoji
from rich.style import Style
from rich.text import Text
from typing_extensions import Final, TypeAlias
Expand Down Expand Up @@ -384,13 +383,14 @@ class SearchIcon(Static, inherit_css=False):

DEFAULT_CSS = """
SearchIcon {
color: #000; /* required for snapshot tests */
margin-left: 1;
margin-top: 1;
width: 2;
}
"""

icon: var[str] = var(Emoji.replace(":magnifying_glass_tilted_right:"))
icon: var[str] = var("🔎")
"""The icon to display."""

def render(self) -> RenderableType:
Expand Down
5 changes: 3 additions & 2 deletions src/textual/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def __init__(self, terminal_theme: TerminalTheme):
Args:
terminal_theme: A rich terminal theme.
"""
self.terminal_theme = terminal_theme
self._terminal_theme = terminal_theme

@lru_cache(1024)
def truecolor_style(self, style: Style) -> Style:
Expand All @@ -200,7 +200,7 @@ def truecolor_style(self, style: Style) -> Style:
Returns:
New style.
"""
terminal_theme = self.terminal_theme
terminal_theme = self._terminal_theme
color = style.color
if color is not None and color.is_system_defined:
color = RichColor.from_rgb(
Expand All @@ -211,6 +211,7 @@ def truecolor_style(self, style: Style) -> Style:
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]:
Expand Down
16 changes: 5 additions & 11 deletions src/textual/renderables/tint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from typing import Iterable

from rich import terminal_theme
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.console import RenderableType
from rich.segment import Segment
from rich.style import Style
from rich.terminal_theme import TerminalTheme

from ..color import Color
from ..filter import ANSIToTruecolor
Expand All @@ -30,13 +30,14 @@ def __init__(

@classmethod
def process_segments(
cls, segments: Iterable[Segment], color: Color
cls, segments: Iterable[Segment], color: Color, ansi_theme: TerminalTheme
) -> Iterable[Segment]:
"""Apply tint to segments.

Args:
segments: Incoming segments.
color: Color of tint.
ansi_theme: The TerminalTheme defining how to map ansi colors to hex.

Returns:
Segments with applied tint.
Expand All @@ -45,7 +46,7 @@ def process_segments(
style_from_color = Style.from_color
_Segment = Segment

truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style
truecolor_style = ANSIToTruecolor(ansi_theme).truecolor_style

NULL_STYLE = Style()
for segment in segments:
Expand Down Expand Up @@ -73,10 +74,3 @@ def process_segments(
),
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)
33 changes: 32 additions & 1 deletion tests/renderables/test_tint.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
import io

from rich.console import Console
from rich.segment import Segments
from rich.terminal_theme import DIMMED_MONOKAI
from rich.text import Text

from textual._ansi_theme import DEFAULT_TERMINAL_THEME
from textual.color import Color
from textual.renderables.tint import Tint


def test_tint():
console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor")
renderable = Text.from_markup("[#aabbcc on #112233]foo")
console.print(Tint(renderable, Color(0, 100, 0, 0.5)))
segments = list(console.render(renderable))
console.print(
Segments(
Tint.process_segments(
segments=segments,
color=Color(0, 100, 0, 0.5),
ansi_theme=DEFAULT_TERMINAL_THEME,
)
)
)
output = console.file.getvalue()
print(repr(output))
expected = "\x1b[38;2;85;143;102;48;2;8;67;25mfoo\x1b[0m\n"
assert output == expected


def test_tint_ansi_mapping():
console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor")
renderable = Text.from_markup("[red on yellow]foo")
segments = list(console.render(renderable))
console.print(
Segments(
Tint.process_segments(
segments=segments,
color=Color(0, 100, 0, 0.5),
ansi_theme=DIMMED_MONOKAI,
)
)
)
output = console.file.getvalue()
print(repr(output))
expected = "\x1b[38;2;95;81;36;48;2;98;133;26mfoo\x1b[0m\n"
assert output == expected
Loading
Loading