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

WIP: Colors: Add ansi_default and make ansi_* colors actually output their ansi sequences #1460

Closed
wants to merge 10 commits into from
9 changes: 9 additions & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Added
- Added ansi_default color which returns default terminal color https://github.com/Textualize/textual/pull/1460

### Changed
- ansi_* colors now render simple ANSI sequences https://github.com/Textualize/textual/pull/1460

## [0.9.1] - 2022-12-30

### Added
Expand Down Expand Up @@ -322,6 +330,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[unreleased]: https://github.com/Textualize/textual/compare/v0.9.1...HEAD
[0.9.1]: https://github.com/Textualize/textual/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/Textualize/textual/compare/v0.8.2...v0.9.0
[0.8.2]: https://github.com/Textualize/textual/compare/v0.8.1...v0.8.2
Expand Down
7 changes: 7 additions & 0 deletions Makefile
@@ -1,3 +1,10 @@
make:
python -m build --wheel --skip-dependency-check --no-isolation
install: uninstall
python -m installer dist/*.whl
uninstall:
rm -rf /usr/bin/textual
rm -i -rf /usr/lib/python3.*/site-packages/textual*
test:
pytest --cov-report term-missing --cov=textual tests/ -vv
unit-test:
Expand Down
3 changes: 3 additions & 0 deletions docs/examples/styles/background.css
Expand Up @@ -12,3 +12,6 @@ Static {
#static3 {
background: hsl(240, 100%, 50%);
}
#static4 {
background: ansi_default;
}
1 change: 1 addition & 0 deletions docs/examples/styles/background.py
Expand Up @@ -7,6 +7,7 @@ def compose(self):
yield Static("Widget 1", id="static1")
yield Static("Widget 2", id="static2")
yield Static("Widget 3", id="static3")
yield Static("Widget 4", id="static4")


app = BackgroundApp(css_path="background.css")
3 changes: 3 additions & 0 deletions docs/examples/styles/color.css
Expand Up @@ -11,3 +11,6 @@ Static {
#static3 {
color: hsl(240, 100%, 50%)
}
#static4 {
color: ansi_magenta;
}
1 change: 1 addition & 0 deletions docs/examples/styles/color.py
Expand Up @@ -7,6 +7,7 @@ def compose(self):
yield Static("I'm red!", id="static1")
yield Static("I'm rgb(0, 255, 0)!", id="static2")
yield Static("I'm hsl(240, 100%, 50%)!", id="static3")
yield Static("I'm ansi_magenta!", id="static4")


app = ColorApp(css_path="color.css")
3 changes: 3 additions & 0 deletions docs/styles/background.md
Expand Up @@ -32,6 +32,9 @@ This example creates three widgets and applies a different background to each.
## CSS

```sass
/* Default terminal background */
background: ansi_default;

/* Blue background */
background: blue;

Expand Down
3 changes: 3 additions & 0 deletions docs/styles/color.md
Expand Up @@ -32,6 +32,9 @@ This example sets a different text color to three different widgets.
## CSS

```sass
/* ANSI yellow */
color: ansi_yellow;

/* Blue text */
color: blue;

Expand Down
10 changes: 4 additions & 6 deletions src/textual/_border.py
Expand Up @@ -32,8 +32,8 @@
"outer": ("▛▀▜", "▌ ▐", "▙▄▟"),
"hkey": ("▔▔▔", " ", "▁▁▁"),
"vkey": ("▏ ▕", "▏ ▕", "▏ ▕"),
"tall": ("▊▔▎", "▊ ▎", "▊▁▎"),
"wide": ("▁▁▁", "▎ ▋", "▔▔▔"),
"tall": ("▐▔▌", "▐ ▌", "▐▁▌"),
"wide": ("▁▁▁", "▌ ▐", "▔▔▔"),
}

# Some of the borders are on the widget background and some are on the background of the parent
Expand All @@ -55,8 +55,8 @@
"outer": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"vkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"tall": ((2, 0, 1), (2, 0, 1), (2, 0, 1)),
"wide": ((1, 1, 1), (0, 1, 3), (1, 1, 1)),
"tall": ((1, 0, 1), (1, 0, 1), (1, 0, 1)),
"wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)),
}

INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
Expand Down Expand Up @@ -108,8 +108,6 @@ def get_box(
styles = (
inner,
outer,
Style.from_color(outer.bgcolor, inner.color),
Style.from_color(inner.bgcolor, outer.color),
)

return (
Expand Down
36 changes: 20 additions & 16 deletions src/textual/_color_constants.py
@@ -1,25 +1,29 @@
from __future__ import annotations
from math import nan

COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, float]] = {
# Let's start with a specific pseudo-color::
"transparent": (0, 0, 0, 0),
"ansi_default": (1, 1, 1, nan),
# Then, the 16 common ANSI colors:
"ansi_black": (0, 0, 0),
"ansi_red": (128, 0, 0),
"ansi_green": (0, 128, 0),
"ansi_yellow": (128, 128, 0),
"ansi_blue": (0, 0, 128),
"ansi_magenta": (128, 0, 128),
"ansi_cyan": (0, 128, 128),
"ansi_white": (192, 192, 192),
"ansi_bright_black": (128, 128, 128),
"ansi_bright_red": (255, 0, 0),
"ansi_bright_green": (0, 255, 0),
"ansi_bright_yellow": (255, 255, 0),
"ansi_bright_blue": (0, 0, 255),
"ansi_bright_magenta": (255, 0, 255),
"ansi_bright_cyan": (0, 255, 255),
"ansi_bright_white": (255, 255, 255),
# Values from rich._pallettes.STANDARD_PALETTE
# so RichColor.downgrade will return the same value
"ansi_black": (0, 0, 0, nan),
"ansi_red": (170, 0, 0, nan),
"ansi_green": (0, 170, 0, nan),
"ansi_yellow": (170, 85, 0, nan),
"ansi_blue": (0, 0, 170, nan),
"ansi_magenta": (170, 0, 170, nan),
"ansi_cyan": (0, 170, 170, nan),
"ansi_white": (170, 170, 170, nan),
"ansi_bright_black": (85, 85, 85, nan),
"ansi_bright_red": (255, 85, 85, nan),
"ansi_bright_green": (85, 255, 85, nan),
"ansi_bright_yellow": (255, 255, 85, nan),
"ansi_bright_blue": (85, 85, 255, nan),
"ansi_bright_magenta": (255, 85, 255, nan),
"ansi_bright_cyan": (85, 255, 255, nan),
"ansi_bright_white": (255, 255, 255, nan),
# And then, Web color keywords: (up to CSS Color Module Level 4)
"black": (0, 0, 0),
"silver": (192, 192, 192),
Expand Down
25 changes: 20 additions & 5 deletions src/textual/color.py
Expand Up @@ -54,6 +54,8 @@
from ._color_constants import COLOR_NAME_TO_RGB
from .geometry import clamp

import math

_TRUECOLOR = ColorType.TRUECOLOR


Expand Down Expand Up @@ -145,7 +147,7 @@ class Color(NamedTuple):
b: int
"""Blue component (0-255)"""
a: float = 1.0
"""Alpha component (0-1)"""
"""Alpha component (0-1) or math.nan to represent ANSI color"""

@classmethod
def from_rich_color(cls, rich_color: RichColor) -> Color:
Expand All @@ -158,6 +160,10 @@ def from_rich_color(cls, rich_color: RichColor) -> Color:
Color: A new Color.
"""
r, g, b = rich_color.get_truecolor()
if rich_color.type is ColorType.STANDARD:
return cls(r, g, b, math.nan)
elif rich_color.type is ColorType.DEFAULT:
return cls(1, 1, 1, math.nan)
Comment on lines +163 to +166
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think this might be the controversial change from this PR, as a lot of rich color names are made into ansi colors)

return cls(r, g, b)

@classmethod
Expand Down Expand Up @@ -225,10 +231,19 @@ def rich_color(self) -> RichColor:
Returns:
RichColor: A color object as used by Rich.
"""
r, g, b, _a = self
return RichColor(
r, g, b, a = self

rc = RichColor(
f"#{r:02x}{g:02x}{b:02x}", _TRUECOLOR, None, ColorTriplet(r, g, b)
)
if a is math.nan:
# ANSI raw colors
if r == 1 and g == 1 and b == 1:
return RichColor.default() # ansi_default
# "Downgrade" color to closest ansi 16
return RichColor.downgrade(rc, ColorType.STANDARD)

return rc

@property
def normalized(self) -> tuple[float, float, float]:
Expand Down Expand Up @@ -348,15 +363,15 @@ def blend(

Args:
destination (Color): Another color.
factor (float): A blend factor, 0 -> 1.
factor (float): A blend factor, 0 -> 1, or math.nan for 1.
alpha (float | None): New alpha for result. Defaults to None.

Returns:
Color: A new color.
"""
if factor == 0:
return self
elif factor == 1:
elif factor == 1 or factor is math.nan:
return destination
r1, g1, b1, a1 = self
r2, g2, b2, a2 = destination
Expand Down
4 changes: 3 additions & 1 deletion src/textual/geometry.py
Expand Up @@ -29,7 +29,8 @@ def clamp(value: T, minimum: T, maximum: T) -> T:
maximum (T): maximum value.

Returns:
T: New value that is not less than the minimum or greater than the maximum.
T: New value that is not less than the minimum or greater than the maximum,
or the original value if that cannot be satisfied.
"""
if minimum > maximum:
maximum, minimum = minimum, maximum
Expand All @@ -38,6 +39,7 @@ def clamp(value: T, minimum: T, maximum: T) -> T:
elif value > maximum:
return maximum
else:
# math.nan is an example value which is incomparable
return value


Expand Down
46 changes: 22 additions & 24 deletions src/textual/scrollbar.py
Expand Up @@ -93,20 +93,24 @@ def render_bar(
) -> Segments:

if vertical:
bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", " "]
startbars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
endbars = ["🮆", "🮅", "🮄", "▀", "🮃", "🮂", "▔", " "]
else:
bars = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]
startbars = ["▕", "🮇", "🮈", "▐", "🮉", "🮊", "🮋", "█"]
endbars = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]

back = back_color
bar = bar_color

len_bars = len(bars)
# TODO: allow variable len_bars for start/end
len_bars = len(startbars)

width_thickness = thickness if vertical else 1

_Segment = Segment
_Style = Style
blank = " " * width_thickness
blank = "█" * width_thickness
space = " " * width_thickness

foreground_meta = {"@mouse.up": "release", "@mouse.down": "grab"}
if window_size and size and virtual_size and size != virtual_size:
Expand All @@ -121,38 +125,32 @@ def render_bar(
upper = {"@mouse.up": "scroll_up"}
lower = {"@mouse.up": "scroll_down"}

upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper))
lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower))
upper_back_segment = Segment(space, _Style(bgcolor=back, meta=upper))
lower_back_segment = Segment(space, _Style(bgcolor=back, meta=lower))

segments = [upper_back_segment] * int(size)
segments[end_index:] = [lower_back_segment] * (size - end_index)

segments[start_index:end_index] = [
_Segment(blank, _Style(bgcolor=bar, meta=foreground_meta))
_Segment(blank, _Style(color=bar, meta=foreground_meta))
] * (end_index - start_index)

# Apply the smaller bar characters to head and tail of scrollbar for more "granularity"
if start_index < len(segments):
bar_character = bars[len_bars - 1 - start_bar]
if bar_character != " ":
segments[start_index] = _Segment(
bar_character * width_thickness,
_Style(bgcolor=back, color=bar, meta=foreground_meta)
if vertical
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
)
bar_character = startbars[len_bars - 1 - start_bar]
segments[start_index] = _Segment(
bar_character * width_thickness,
_Style(bgcolor=back, color=bar, meta=foreground_meta)
)
if end_index < len(segments):
bar_character = bars[len_bars - 1 - end_bar]
if bar_character != " ":
segments[end_index] = _Segment(
bar_character * width_thickness,
_Style(bgcolor=bar, color=back, meta=foreground_meta)
if vertical
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
)
bar_character = endbars[len_bars - 1 - end_bar]
segments[end_index] = _Segment(
bar_character * width_thickness,
_Style(bgcolor=back, color=bar, meta=foreground_meta)
)
else:
style = _Style(bgcolor=back)
segments = [_Segment(blank, style=style)] * int(size)
segments = [_Segment(space, style=style)] * int(size)
if vertical:
return Segments(segments, new_lines=True)
else:
Expand Down
5 changes: 3 additions & 2 deletions tests/css/test_stylesheet.py
@@ -1,6 +1,7 @@
from contextlib import nullcontext as does_not_raise
from typing import Any

import math
import pytest

from textual.color import Color
Expand Down Expand Up @@ -130,8 +131,8 @@ class MyWidget(Widget):
[
# Valid values:
["transparent", does_not_raise(), Color(0, 0, 0, 0)],
["ansi_red", does_not_raise(), Color(128, 0, 0)],
["ansi_bright_magenta", does_not_raise(), Color(255, 0, 255)],
["ansi_red", does_not_raise(), Color(170, 0, 0, math.nan)],
["ansi_bright_magenta", does_not_raise(), Color(255, 85, 255, math.nan)],
["red", does_not_raise(), Color(255, 0, 0)],
["lime", does_not_raise(), Color(0, 255, 0)],
["coral", does_not_raise(), Color(255, 127, 80)],
Expand Down