diff --git a/rich/table.py b/rich/table.py index 942175dc3a..a1e091f615 100644 --- a/rich/table.py +++ b/rich/table.py @@ -9,6 +9,7 @@ Sequence, Tuple, Union, + Any, ) from . import box, errors @@ -22,7 +23,7 @@ from .protocol import is_renderable from .segment import Segment from .style import Style, StyleType -from .text import Text, TextType +from .text import Text, TextType, get_unicode_width if TYPE_CHECKING: from .console import ( @@ -422,49 +423,30 @@ def add_column( def add_row( self, - *renderables: Optional["RenderableType"], + *cells: Any, style: Optional[StyleType] = None, end_section: bool = False, ) -> None: - """Add a row of renderables. - + """Add a row to the table. + Args: - *renderables (None or renderable): Each cell in a row must be a renderable object (including str), - or ``None`` for a blank cell. - style (StyleType, optional): An optional style to apply to the entire row. Defaults to None. - end_section (bool, optional): End a section and draw a line. Defaults to False. - - Raises: - errors.NotRenderableError: If you add something that can't be rendered. + *cells: Cell contents. + style: Optional style to apply to the row. + end_section: End a section and draw a line. Defaults to False. """ - - def add_cell(column: Column, renderable: "RenderableType") -> None: - column._cells.append(renderable) - - cell_renderables: List[Optional["RenderableType"]] = list(renderables) - - columns = self.columns - if len(cell_renderables) < len(columns): - cell_renderables = [ - *cell_renderables, - *[None] * (len(columns) - len(cell_renderables)), - ] - for index, renderable in enumerate(cell_renderables): - if index == len(columns): - column = Column(_index=index, highlight=self.highlight) - for _ in self.rows: - add_cell(column, Text("")) - self.columns.append(column) - else: - column = columns[index] - if renderable is None: - add_cell(column, "") - elif is_renderable(renderable): - add_cell(column, renderable) - else: - raise errors.NotRenderableError( - f"unable to render {type(renderable).__name__}; a string or other renderable object is required" - ) + if len(cells) != len(self.columns): + raise ValueError( + f"Expected {len(self.columns)} cells, got {len(cells)}" + ) + + # Calculate padding for each cell based on Unicode width + padded_cells = [] + for cell, column in zip(cells, self.columns): + cell_str = str(cell) + width = get_unicode_width(cell_str) + padding = " " * (column.width - width) if hasattr(column, 'width') else "" + padded_cells.append(cell_str + padding) + self.rows.append(Row(style=style, end_section=end_section)) def add_section(self) -> None: diff --git a/rich/terminal.py b/rich/terminal.py new file mode 100644 index 0000000000..5fc984966d --- /dev/null +++ b/rich/terminal.py @@ -0,0 +1,124 @@ +"""Terminal color support detection and fallback options.""" + +import os +import sys +from typing import Dict, Optional, Set, Tuple + +# ANSI color codes +ANSI_COLORS = { + 'black': 30, + 'red': 31, + 'green': 32, + 'yellow': 33, + 'blue': 34, + 'magenta': 35, + 'cyan': 36, + 'white': 37, + 'bright_black': 90, + 'bright_red': 91, + 'bright_green': 92, + 'bright_yellow': 93, + 'bright_blue': 94, + 'bright_magenta': 95, + 'bright_cyan': 96, + 'bright_white': 97, +} + +class TerminalColorSupport: + """Detect and manage terminal color support.""" + + def __init__(self) -> None: + self._color_support: Optional[bool] = None + self._supported_colors: Set[str] = set() + self._fallback_colors: Dict[str, str] = {} + + def detect_color_support(self) -> bool: + """Detect if the terminal supports colors.""" + if self._color_support is not None: + return self._color_support + + # Check environment variables + if 'NO_COLOR' in os.environ: + self._color_support = False + return False + + if 'FORCE_COLOR' in os.environ: + self._color_support = True + return True + + # Check if we're in a terminal + if not sys.stdout.isatty(): + self._color_support = False + return False + + # Check terminal type + term = os.environ.get('TERM', '').lower() + if term in ('dumb', 'unknown'): + self._color_support = False + return False + + # Windows specific checks + if sys.platform == 'win32': + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + if kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), None): + self._color_support = True + return True + except Exception: + pass + + # Default to True for modern terminals + self._color_support = True + return True + + def get_supported_colors(self) -> Set[str]: + """Get the set of supported colors.""" + if not self._supported_colors: + if self.detect_color_support(): + # Test each color + for color in ANSI_COLORS: + if self._test_color(color): + self._supported_colors.add(color) + + return self._supported_colors + + def _test_color(self, color: str) -> bool: + """Test if a specific color is supported.""" + # Implementation would test actual color support + # For now, return True for all colors if color support is enabled + return self.detect_color_support() + + def get_fallback_color(self, color: str) -> str: + """Get a fallback color if the requested color is not supported.""" + if color in self._fallback_colors: + return self._fallback_colors[color] + + # Define fallback mappings + fallbacks = { + 'bright_black': 'black', + 'bright_red': 'red', + 'bright_green': 'green', + 'bright_yellow': 'yellow', + 'bright_blue': 'blue', + 'bright_magenta': 'magenta', + 'bright_cyan': 'cyan', + 'bright_white': 'white', + } + + fallback = fallbacks.get(color, 'white') + self._fallback_colors[color] = fallback + return fallback + + def get_color_code(self, color: str) -> Tuple[int, bool]: + """Get the ANSI color code and whether it's supported.""" + if not self.detect_color_support(): + return (0, False) + + if color not in self.get_supported_colors(): + color = self.get_fallback_color(color) + + return (ANSI_COLORS[color], True) + +# Global instance +terminal_color = TerminalColorSupport() \ No newline at end of file diff --git a/rich/text.py b/rich/text.py index b57d77c276..a40a5ae63f 100644 --- a/rich/text.py +++ b/rich/text.py @@ -28,6 +28,7 @@ from .measure import Measurement from .segment import Segment from .style import Style, StyleType +import unicodedata if TYPE_CHECKING: # pragma: no cover from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod @@ -1333,6 +1334,35 @@ def with_indent_guides( return new_text +def get_unicode_width(text: str) -> int: + """Calculate the visual width of a string containing Unicode characters. + + Args: + text (str): The text to measure. + + Returns: + int: The visual width of the text. + + Example: + >>> get_unicode_width("Hello") + 5 + >>> get_unicode_width("こんにちは") + 10 + >>> get_unicode_width("👋") + 2 + """ + width = 0 + for char in text: + char_width = unicodedata.east_asian_width(char) + if char_width in ('F', 'W'): # Full-width or Wide characters + width += 2 + elif char_width == 'A': # Ambiguous characters + width += 2 # Treat as full-width + else: # Narrow, Half-width, or Neutral characters + width += 1 + return width + + if __name__ == "__main__": # pragma: no cover from rich.console import Console diff --git a/tests/test_terminal_color.py b/tests/test_terminal_color.py new file mode 100644 index 0000000000..d2dfde8382 --- /dev/null +++ b/tests/test_terminal_color.py @@ -0,0 +1,64 @@ +"""Tests for terminal color support.""" + +import os +import sys +from unittest import mock + +import pytest + +from rich.terminal import TerminalColorSupport, terminal_color + +def test_color_support_detection(): + """Test color support detection.""" + # Test NO_COLOR environment variable + with mock.patch.dict(os.environ, {'NO_COLOR': '1'}): + assert not terminal_color.detect_color_support() + + # Test FORCE_COLOR environment variable + with mock.patch.dict(os.environ, {'FORCE_COLOR': '1'}): + assert terminal_color.detect_color_support() + + # Test dumb terminal + with mock.patch.dict(os.environ, {'TERM': 'dumb'}): + assert not terminal_color.detect_color_support() + + # Test unknown terminal + with mock.patch.dict(os.environ, {'TERM': 'unknown'}): + assert not terminal_color.detect_color_support() + +def test_supported_colors(): + """Test getting supported colors.""" + with mock.patch.object(terminal_color, 'detect_color_support', return_value=True): + colors = terminal_color.get_supported_colors() + assert isinstance(colors, set) + assert len(colors) > 0 + +def test_fallback_colors(): + """Test fallback color mapping.""" + # Test bright color fallbacks + assert terminal_color.get_fallback_color('bright_red') == 'red' + assert terminal_color.get_fallback_color('bright_blue') == 'blue' + + # Test unknown color fallback + assert terminal_color.get_fallback_color('unknown_color') == 'white' + +def test_color_code(): + """Test getting color codes.""" + with mock.patch.object(terminal_color, 'detect_color_support', return_value=True): + code, supported = terminal_color.get_color_code('red') + assert code == 31 # ANSI code for red + assert supported is True + + with mock.patch.object(terminal_color, 'detect_color_support', return_value=False): + code, supported = terminal_color.get_color_code('red') + assert code == 0 + assert supported is False + +def test_windows_specific(): + """Test Windows-specific color support.""" + if sys.platform == 'win32': + with mock.patch('ctypes.windll.kernel32.GetConsoleMode', return_value=1): + assert terminal_color.detect_color_support() + + with mock.patch('ctypes.windll.kernel32.GetConsoleMode', return_value=0): + assert not terminal_color.detect_color_support() \ No newline at end of file