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

allow ansi color #5000

Merged
merged 27 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from 20 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Support for `"none"` value added to `dock`, `hatch` and `split` styles https://github.com/Textualize/textual/pull/4982
- Support for `"none"` added to box and border style properties (e.g `widget.style.border = "none"`) https://github.com/Textualize/textual/pull/4982
- Docstrings added to most style properties https://github.com/Textualize/textual/pull/4982
- Added `ansi_color` switch to App to permit ANSI (themed) colors https://github.com/Textualize/textual/pull/5000
- Added `:ansi` pseudo class https://github.com/Textualize/textual/pull/5000
- Added `-ansi-scrollbar` style to widgets https://github.com/Textualize/textual/pull/5000
- Added `App.INLINE_PADDING` to define the number of spaces above inline apps https://github.com/Textualize/textual/pull/5000

### Changed

- Input validation for integers no longer accepts scientific notation like '1.5e2'; must be castable to int. https://github.com/Textualize/textual/pull/4784
- Default `scrollbar-size-vertical` changed to `2` in inline styles to match Widget default CSS (unlikely to affect users) https://github.com/Textualize/textual/pull/4982
- Removed border-right from `Toast` https://github.com/Textualize/textual/pull/4984
- Some fixes in `RichLog` result in slightly different semantics, see docstrings for details https://github.com/Textualize/textual/pull/4978
- Changed how scrollbars are rendered (will have no visual effect, but will break snapshot tests) https://github.com/Textualize/textual/pull/5000
- Added `enabled` switch to filters (mostly used internally) https://github.com/Textualize/textual/pull/5000

### Fixed

Expand Down
10 changes: 8 additions & 2 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ For more information on ANSI colors in Textual, see [Why no Ansi Themes?](#why-d
!!! tip

See [*How To Center Things*](https://textual.textualize.io/how-to/center-things/) in the
Textual documentation for a more comprehensive answer to this question.
Textual documentation for a more comprensive answer to this question.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

To center a widget within a container use
[`align`](https://textual.textualize.io/styles/align/). But remember that
Expand Down Expand Up @@ -270,7 +270,7 @@ work in different environments you can try them out with `textual keys`.
<a name="why-doesn't-textual-look-good-on-macos"></a>
## Why doesn't Textual look good on macOS?

You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particularly when it comes to box characters.
You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
For instance, you may find it displays misaligned blocks and lines like this:

<img width="1042" alt="Screenshot 2023-06-19 at 10 43 02" src="https://github.com/Textualize/textual/assets/554369/e61f3876-3dd1-4ac8-b380-22922c89c7d6">
Expand Down Expand Up @@ -319,6 +319,12 @@ Textual has a design system which guarantees apps will be readable on all platfo

There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.

!!! Changed in 0.80.0

Textual added a `ansi_colors` boolean to App. If you set this to `True`, then Textual will
not attempt to convert ansi colors. Note that you will lose transparency effects if you enable
this setting.

---

<a name="why-doesn't-the-`datatable`-scroll-programmatically"></a>
Expand Down
6 changes: 6 additions & 0 deletions questions/why-no-ansi-themes.question.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ This is an intentional design decision we took for for the following reasons:
Textual has a design system which guarantees apps will be readable on all platforms and terminals, and produces better results than ANSI colors.

There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.

!!! Changed in 0.80.0

Textual added a `ansi_colors` boolean to App. If you set this to `True`, then Textual will
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
not attempt to convert ansi colors. Note that you will lose transparency effects if you enable
this setting.
17 changes: 10 additions & 7 deletions src/textual/_border.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@

Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle]

REVERSE_STYLE = Style(reverse=True)


@lru_cache(maxsize=1024)
def get_box(
Expand All @@ -249,9 +251,9 @@ def get_box(

Args:
name: Name of the box type.
inner_style: The inner style (widget background)
outer_style: The outer style (parent background)
style: Widget style
inner_style: The inner style (widget background).
outer_style: The outer style (parent background).
style: Widget style.

Returns:
A tuple of 3 Segment triplets.
Expand All @@ -271,11 +273,12 @@ def get_box(

inner = inner_style + style
outer = outer_style + style

styles = (
inner,
outer,
Style.from_color(outer.bgcolor, inner.color),
Style.from_color(inner.bgcolor, outer.color),
Style.from_color(inner.color, outer.bgcolor) + REVERSE_STYLE,
Style.from_color(outer.color, inner.bgcolor) + REVERSE_STYLE,
)

return (
Expand Down Expand Up @@ -363,9 +366,9 @@ def render_border_label(
elif label_style_location == 1:
base_style = outer
elif label_style_location == 2:
base_style = Style.from_color(outer.bgcolor, inner.color)
base_style = Style.from_color(inner.color, outer.bgcolor) + REVERSE_STYLE
elif label_style_location == 3:
base_style = Style.from_color(inner.bgcolor, outer.color)
base_style = Style.from_color(outer.color, inner.bgcolor) + REVERSE_STYLE
else:
assert False

Expand Down
20 changes: 20 additions & 0 deletions src/textual/_color_constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
from __future__ import annotations

ANSI_COLORS = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
]
"""The names of ANSI colors (prefixed with ansi_ in CSS)."""

COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, int]] = {
# Let's start with a specific pseudo-color::
"transparent": (0, 0, 0, 0),
Expand Down
8 changes: 5 additions & 3 deletions src/textual/_styles_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ def render(

is_dirty = self._dirty_lines.__contains__
render_line = self.render_line
apply_filters = (
[] if filters is None else [filter for filter in filters if filter.enabled]
)
for y in crop.line_range:
if is_dirty(y) or y not in self._cache:
strip = render_line(
Expand All @@ -233,9 +236,8 @@ def render(
else:
strip = self._cache[y]

if filters:
for filter in filters:
strip = strip.apply_filter(filter, background)
for filter in apply_filters:
strip = strip.apply_filter(filter, background)

if DEBUG:
if any([not (segment.control or segment.text) for segment in strip]):
Expand Down
84 changes: 68 additions & 16 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@
accent="#0178D4",
dark=False,
),
"ansi": ColorSystem(
"ansi_blue",
secondary="ansi_cyan",
warning="ansi_yellow",
error="ansi_red",
success="ansi_green",
accent="ansi_bright_blue",
foreground="ansi_default",
background="ansi_default",
surface="ansi_default",
panel="ansi_default",
boost="ansi_default",
),
}

ComposeResult = Iterable[Widget]
Expand Down Expand Up @@ -317,6 +330,20 @@ class App(Generic[ReturnType], DOMNode):
background: $background;
color: $text;

&:ansi {
background: ansi_default;
color: ansi_default;

.-ansi-scrollbar {
scrollbar-background: ansi_default;
scrollbar-background-hover: ansi_default;
scrollbar-background-active: ansi_default;
scrollbar-color: ansi_blue;
scrollbar-color-active: ansi_bright_blue;
scrollbar-color-hover: ansi_bright_blue;
}
}

/* When a widget is maximized */
Screen.-maximized-view {
layout: vertical !important;
Expand Down Expand Up @@ -434,6 +461,9 @@ class MyApp(App[None]):
This is the default value, used if the active screen's `ESCAPE_TO_MINIMIZE` is not changed from `None`.
"""

INLINE_PADDING: ClassVar[int] = 1
"""Number of blank lines above an inline app."""

title: Reactive[str] = Reactive("", compute=False)
"""The title of the app, displayed in the header."""
sub_title: Reactive[str] = Reactive("", compute=False)
Expand Down Expand Up @@ -462,11 +492,15 @@ class MyApp(App[None]):
ansi_theme_light = Reactive(ALABASTER, init=False)
"""Maps ANSI colors to hex colors using a Rich TerminalTheme object while in light mode."""

ansi_color = Reactive(False)
"""Allow ANSI colors in UI?"""

def __init__(
self,
driver_class: Type[Driver] | None = None,
css_path: CSSPathType | None = None,
watch_css: bool = False,
ansi_color: bool = False,
):
"""Create an instance of an app.

Expand All @@ -478,6 +512,7 @@ def __init__(
will be loaded in order.
watch_css: Reload CSS if the files changed. This is set automatically if
you are using `textual run` with the `dev` switch.
ansi_color: Allow ANSI colors if `True`, or convert ANSI colors to to RGB if `False`.

Raises:
CssPathError: When the supplied CSS path(s) are an unexpected type.
Expand All @@ -487,8 +522,10 @@ def __init__(
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))

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

self.set_reactive(App.ansi_color, ansi_color)
self._filters: list[LineFilter] = [
ANSIToTruecolor(ansi_theme, enabled=not ansi_color)
]
environ = dict(os.environ)
no_color = environ.pop("NO_COLOR", None)
if no_color is not None:
Expand Down Expand Up @@ -840,6 +877,14 @@ def get_pseudo_classes(self) -> Iterable[str]:
yield "dark" if self.dark else "light"
if self.is_inline:
yield "inline"
if self.ansi_color:
yield "ansi"

def _watch_ansi_color(self, ansi_color: bool) -> None:
"""Enable or disable the truecolor filter when the reactive changes"""
for filter in self._filters:
if isinstance(filter, ANSIToTruecolor):
filter.enabled = not ansi_color

def animate(
self,
Expand Down Expand Up @@ -1016,18 +1061,19 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
Yields:
[SystemCommand][textual.app.SystemCommand] instances.
"""
if self.dark:
yield SystemCommand(
"Light mode",
"Switch to a light background",
self.action_toggle_dark,
)
else:
yield SystemCommand(
"Dark mode",
"Switch to a dark background",
self.action_toggle_dark,
)
if not self.ansi_color:
if self.dark:
yield SystemCommand(
"Light mode",
"Switch to a light background",
self.action_toggle_dark,
)
else:
yield SystemCommand(
"Dark mode",
"Switch to a dark background",
self.action_toggle_dark,
)

yield SystemCommand(
"Quit the application",
Expand Down Expand Up @@ -1093,7 +1139,13 @@ def get_css_variables(self) -> dict[str, str]:
Returns:
A mapping of variable name to value.
"""
variables = self.design["dark" if self.dark else "light"].generate()

if self.dark:
design = self.design["dark"]
else:
design = self.design["light"]

variables = design.generate()
return variables

def watch_dark(self, dark: bool) -> None:
Expand Down Expand Up @@ -1135,7 +1187,7 @@ def _refresh_truecolor_filter(self, theme: TerminalTheme) -> None:
filters = self._filters
for index, filter in enumerate(filters):
if isinstance(filter, ANSIToTruecolor):
filters[index] = ANSIToTruecolor(theme)
filters[index] = ANSIToTruecolor(theme, enabled=not self.ansi_color)
return

def get_driver_class(self) -> Type[Driver]:
Expand Down
Loading
Loading