diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee7b9a673..209b7940fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Makefile b/Makefile index 9d71e69d73..e06381a19a 100644 --- a/Makefile +++ b/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: diff --git a/docs/examples/styles/background.css b/docs/examples/styles/background.css index 27f8649d29..9f7472758f 100644 --- a/docs/examples/styles/background.css +++ b/docs/examples/styles/background.css @@ -12,3 +12,6 @@ Static { #static3 { background: hsl(240, 100%, 50%); } +#static4 { + background: ansi_default; +} diff --git a/docs/examples/styles/background.py b/docs/examples/styles/background.py index cef306ddcc..8a8ce88223 100644 --- a/docs/examples/styles/background.py +++ b/docs/examples/styles/background.py @@ -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") diff --git a/docs/examples/styles/color.css b/docs/examples/styles/color.css index b5552495ae..65d0ff0cae 100644 --- a/docs/examples/styles/color.css +++ b/docs/examples/styles/color.css @@ -11,3 +11,6 @@ Static { #static3 { color: hsl(240, 100%, 50%) } +#static4 { + color: ansi_magenta; +} diff --git a/docs/examples/styles/color.py b/docs/examples/styles/color.py index 26543b0a04..e571ee0b02 100644 --- a/docs/examples/styles/color.py +++ b/docs/examples/styles/color.py @@ -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") diff --git a/docs/styles/background.md b/docs/styles/background.md index 6c41885c77..0a67c773ea 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -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; diff --git a/docs/styles/color.md b/docs/styles/color.md index 0fae0a0b0f..c56efa5604 100644 --- a/docs/styles/color.md +++ b/docs/styles/color.md @@ -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; diff --git a/src/textual/_border.py b/src/textual/_border.py index 4307281bcd..8591e11065 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -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 @@ -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"))) @@ -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 ( diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py index cedab56736..7a82c60a20 100644 --- a/src/textual/_color_constants.py +++ b/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), diff --git a/src/textual/color.py b/src/textual/color.py index a53a0abafc..5e54b0f6c3 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -54,6 +54,8 @@ from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp +import math + _TRUECOLOR = ColorType.TRUECOLOR @@ -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: @@ -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) return cls(r, g, b) @classmethod @@ -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]: @@ -348,7 +363,7 @@ 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: @@ -356,7 +371,7 @@ def blend( """ 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 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e29790e304..09eecf9efe 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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 @@ -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 diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 917e36a69e..3851c087f8 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -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: @@ -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: diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index be54fe936a..60dd61e8ee 100644 --- a/tests/css/test_stylesheet.py +++ b/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 @@ -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)], diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 4a9464a814..fbf9ae01bd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -682,131 +682,131 @@ font-weight: 700; } - .terminal-470351640-matrix { + .terminal-3110661383-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-470351640-title { + .terminal-3110661383-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-470351640-r1 { fill: #c5c8c6 } - .terminal-470351640-r2 { fill: #ffffff } + .terminal-3110661383-r1 { fill: #c5c8c6 } + .terminal-3110661383-r2 { fill: #ffffff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BackgroundApp + BackgroundApp - - - - - - - Widget 1 - - - - - - - - Widget 2 - - - - - - - - Widget 3 - - - + + + + + + Widget 1 + + + + + + Widget 2 + + + + + + Widget 3 + + + + + + Widget 4 + + @@ -1151,133 +1151,134 @@ font-weight: 700; } - .terminal-830407627-matrix { + .terminal-2794611268-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-830407627-title { + .terminal-2794611268-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-830407627-r1 { fill: #c5c8c6 } - .terminal-830407627-r2 { fill: #ff0000 } - .terminal-830407627-r3 { fill: #00ff00 } - .terminal-830407627-r4 { fill: #0000ff } + .terminal-2794611268-r1 { fill: #c5c8c6 } + .terminal-2794611268-r2 { fill: #ff0000 } + .terminal-2794611268-r3 { fill: #00ff00 } + .terminal-2794611268-r4 { fill: #0000ff } + .terminal-2794611268-r5 { fill: #98729f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ColorApp + ColorApp - - - - - - - I'm red! - - - - - - - - I'm rgb(0, 255, 0)! - - - - - - - - I'm hsl(240, 100%, 50%)! - - - + + + + + + I'm red! + + + + + + I'm rgb(0, 255, 0)! + + + + + + I'm hsl(240, 100%, 50%)! + + + + + + I'm ansi_magenta! + + diff --git a/tests/test_color.py b/tests/test_color.py index 10340d3dc9..25332e0ca4 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -4,13 +4,22 @@ from textual.color import Color, Lab, lab_to_rgb, rgb_to_lab - def test_rich_color(): """Check conversion to Rich color.""" assert Color(10, 20, 30, 1.0).rich_color == RichColor.from_rgb(10, 20, 30) assert Color.from_rich_color(RichColor.from_rgb(10, 20, 30)) == Color( 10, 20, 30, 1.0 ) + assert Color.parse("ansi_default").rich_color == RichColor.default() + assert Color.parse("ansi_black").rich_color.number == 0 + assert Color.parse("ansi_red").rich_color.number == 1 + assert Color.parse("ansi_white").rich_color.number == 7 + assert Color.parse("ansi_bright_black").rich_color.number == 8 + assert Color.parse("ansi_bright_red").rich_color.number == 9 + assert Color.parse("ansi_bright_white").rich_color.number == 15 + # Round-trip + assert Color.from_rich_color(RichColor.default()).rich_color == RichColor.default() + assert Color.from_rich_color(RichColor.from_ansi(3)).rich_color.number == 3 def test_rich_color_rich_output(): diff --git a/tests/test_strip.py b/tests/test_strip.py index 891af9845e..24a970ebb9 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -90,7 +90,7 @@ def test_simplify(): def test_apply_filter(): - strip = Strip([Segment("foo", Style.parse("red"))]) + strip = Strip([Segment("foo", Style.parse("#800000"))]) expected = Strip([Segment("foo", Style.parse("#1b1b1b"))]) print(repr(strip)) print(repr(expected))