Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
60 changes: 21 additions & 39 deletions rich/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Sequence,
Tuple,
Union,
Any,
)

from . import box, errors
Expand All @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
124 changes: 124 additions & 0 deletions rich/terminal.py
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 30 additions & 0 deletions rich/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions tests/test_terminal_color.py
Original file line number Diff line number Diff line change
@@ -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()
Loading