diff --git a/pyproject.toml b/pyproject.toml index 212e0eecfa..25b07699b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,11 @@ tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.10" } # end of [syntax] extras pygments = "^2.19.2" +# BiDi (bidirectional text) support for RTL languages like Hebrew and Arabic +python-bidi = { version = ">=0.6.0", optional = true } + [tool.poetry.extras] +bidi = ["python-bidi"] syntax = [ "tree-sitter", "tree-sitter-python", diff --git a/src/textual/_bidi.py b/src/textual/_bidi.py new file mode 100644 index 0000000000..3c03065a38 --- /dev/null +++ b/src/textual/_bidi.py @@ -0,0 +1,240 @@ +""" +BiDi (Bidirectional) text support for RTL languages like Hebrew and Arabic. + +This module provides utilities for applying the Unicode BiDi algorithm to text, +ensuring proper display of right-to-left (RTL) languages in terminal applications. + +The `python-bidi` library is an optional dependency. If not installed, BiDi +processing will be silently skipped. +""" + +from __future__ import annotations + +import unicodedata +from functools import lru_cache +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual.content import Content, Span + +# RTL BiDi character types from Unicode +RTL_BIDI_TYPES = frozenset({"R", "AL", "RLE", "RLO", "RLI"}) + +# Try to import python-bidi +try: + from bidi.algorithm import get_display as _bidi_get_display + + BIDI_AVAILABLE = True +except ImportError: + BIDI_AVAILABLE = False + + def _bidi_get_display(text: str, **kwargs: object) -> str: + """Fallback when python-bidi is not installed.""" + return text + + +@lru_cache(maxsize=512) +def contains_rtl(text: str) -> bool: + """Check if text contains any RTL (right-to-left) characters. + + This function detects Hebrew, Arabic, and other RTL scripts by checking + the Unicode bidirectional property of each character. + + Args: + text: The text to check. + + Returns: + True if the text contains RTL characters, False otherwise. + """ + if not text: + return False + + for char in text: + bidi_class = unicodedata.bidirectional(char) + if bidi_class in RTL_BIDI_TYPES: + return True + return False + + +def get_display(text: str) -> str: + """Apply the Unicode BiDi algorithm to reorder text for display. + + Terminals render characters left-to-right, but RTL languages need + character reordering for correct visual display. This function applies + the Unicode BiDi algorithm to produce the correct visual ordering. + + Args: + text: The logical text to reorder. + + Returns: + The text reordered for visual display. + """ + if not BIDI_AVAILABLE or not text: + return text + return _bidi_get_display(text) + + +def get_display_with_mapping(text: str) -> tuple[str, list[int]]: + """Apply BiDi algorithm and return both display text and character mapping. + + This function not only reorders the text but also provides a mapping from + display positions to original positions. This is essential for preserving + style spans when text is reordered. + + Args: + text: The logical text to reorder. + + Returns: + A tuple of (display_text, mapping) where mapping[display_pos] gives + the original position of the character at display_pos. + """ + if not BIDI_AVAILABLE or not text: + return text, list(range(len(text))) + + # python-bidi doesn't provide direct mapping, so we need to compute it + # by tracking unique character positions + if not contains_rtl(text): + return text, list(range(len(text))) + + # Create unique markers for each position to track reordering + # This approach handles duplicate characters correctly + display = _bidi_get_display(text) + + # Build mapping by finding where each character moved + # We use a greedy matching approach from left to right + mapping: list[int] = [] + used_positions: set[int] = set() + + for display_char in display: + # Find the leftmost unused position in original text with this char + for orig_pos, orig_char in enumerate(text): + if orig_pos not in used_positions and orig_char == display_char: + mapping.append(orig_pos) + used_positions.add(orig_pos) + break + else: + # Character not found (shouldn't happen with valid BiDi) + mapping.append(len(mapping)) + + return display, mapping + + +def remap_spans( + spans: list[Span], mapping: list[int], display_length: int +) -> list[Span]: + """Remap style spans after BiDi reordering. + + When text is reordered by the BiDi algorithm, style spans must be + recalculated to apply to the correct character positions in the + display order. + + Args: + spans: Original spans with positions in logical order. + mapping: Mapping from display positions to original positions. + display_length: Length of the display text. + + Returns: + New spans with positions adjusted for display order. + """ + from textual.content import Span as SpanClass + + if not spans or not mapping: + return spans + + # Create reverse mapping: original_pos -> display_pos + reverse_mapping: dict[int, int] = {} + for display_pos, orig_pos in enumerate(mapping): + if orig_pos not in reverse_mapping: + reverse_mapping[orig_pos] = display_pos + + new_spans: list[Span] = [] + for span in spans: + start, end, style = span.start, span.end, span.style + + # Find all display positions that map to the span's range + display_positions: list[int] = [] + for display_pos, orig_pos in enumerate(mapping): + if start <= orig_pos < end: + display_positions.append(display_pos) + + if not display_positions: + continue + + # Create contiguous spans from the display positions + display_positions.sort() + + # Group into contiguous ranges + ranges: list[tuple[int, int]] = [] + range_start = display_positions[0] + range_end = display_positions[0] + 1 + + for pos in display_positions[1:]: + if pos == range_end: + range_end = pos + 1 + else: + ranges.append((range_start, range_end)) + range_start = pos + range_end = pos + 1 + ranges.append((range_start, range_end)) + + # Create a span for each contiguous range + for new_start, new_end in ranges: + new_spans.append(SpanClass(new_start, new_end, style)) + + return new_spans + + +def apply_bidi_to_content(content: Content) -> Content: + """Apply BiDi algorithm to Content, preserving style spans. + + This function applies the Unicode BiDi algorithm to reorder the text + for visual display while preserving all style information (colors, + formatting, etc.). + + Args: + content: The Content object with logical text order. + + Returns: + A new Content object with text reordered for display, spans adjusted. + """ + from textual.content import Content as ContentClass + + text = content.plain + spans = list(content.spans) + + # Quick path: no RTL characters + if not contains_rtl(text): + return content + + # Quick path: BiDi not available + if not BIDI_AVAILABLE: + return content + + # Apply BiDi with mapping + display_text, mapping = get_display_with_mapping(text) + + # If text didn't change, return as-is + if display_text == text: + return content + + # Remap spans to new positions + new_spans = remap_spans(spans, mapping, len(display_text)) + + return ContentClass(display_text, new_spans, strip_control_codes=False) + + +def apply_bidi_to_line(text: str) -> str: + """Apply BiDi algorithm to a single line of text. + + This is a simpler version of apply_bidi_to_content for cases where + style spans are not needed, such as plain text or TextArea content. + + Args: + text: The logical text to reorder. + + Returns: + The text reordered for visual display. + """ + if not contains_rtl(text): + return text + return get_display(text) diff --git a/src/textual/content.py b/src/textual/content.py index 3ceaa45c62..cf41811a22 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -24,12 +24,13 @@ from rich.text import Text from typing_extensions import Final, TypeAlias +from textual._bidi import apply_bidi_to_content, contains_rtl from textual._cells import cell_len from textual._context import active_app from textual._loop import loop_last from textual.cache import FIFOCache from textual.color import Color -from textual.css.types import TextAlign, TextOverflow +from textual.css.types import TextAlign, TextDirection, TextOverflow from textual.selection import Selection from textual.strip import Strip from textual.style import Style @@ -626,6 +627,7 @@ def _wrap_and_format( selection_style: Style | None = None, post_style: Style | None = None, get_style: Callable[[str | Style], Style] = Style.parse, + text_direction: TextDirection = "auto", ) -> list[_FormattedLine]: """Wraps the text and applies formatting. @@ -637,6 +639,7 @@ def _wrap_and_format( tab_size: Cell with of tabs. selection: Selection information or `None` if no selection. selection_style: Selection style, or `None` if no selection. + text_direction: Text direction for BiDi support ("auto", "ltr", "rtl"). Returns: List of formatted lines. @@ -662,6 +665,12 @@ def get_span(y: int) -> tuple[int, int] | None: line = line.expand_tabs(tab_size) + # Apply BiDi (bidirectional) text reordering for RTL languages + if text_direction == "rtl" or ( + text_direction == "auto" and contains_rtl(line.plain) + ): + line = apply_bidi_to_content(line) + if no_wrap: if overflow == "fold": cuts = list(range(0, line.cell_length, width))[1:] @@ -738,6 +747,7 @@ def render_strips( selection_style=options.selection_style, post_style=options.post_style, get_style=options.get_style, + text_direction=get_rule("text_direction", "auto"), ) if height is not None: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 95c157e858..d1e285641f 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -55,6 +55,7 @@ VALID_SCROLLBAR_VISIBILITY, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, + VALID_TEXT_DIRECTION, VALID_TEXT_OVERFLOW, VALID_TEXT_WRAP, VALID_VISIBILITY, @@ -78,6 +79,7 @@ EdgeType, Overflow, ScrollbarVisibility, + TextDirection, TextOverflow, TextWrap, Visibility, @@ -413,6 +415,30 @@ def process_text_overflow(self, name: str, tokens: list[Token]) -> None: context="css", ) + def process_text_direction(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + name, value, _, _, location, _ = token + if name == "token": + value = value.lower() + if value in VALID_TEXT_DIRECTION: + self.styles._rules["text_direction"] = cast(TextDirection, value) + else: + self.error( + name, + token, + string_enum_help_text( + "text-direction", + valid_values=list(VALID_TEXT_DIRECTION), + context="css", + ), + ) + else: + string_enum_help_text( + "text-direction", + valid_values=list(VALID_TEXT_DIRECTION), + context="css", + ) + def _process_fractional(self, name: str, tokens: list[Token]) -> None: if not tokens: return diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 46606d6709..264f003cd3 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -89,6 +89,7 @@ VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"} VALID_TEXT_WRAP: Final = {"wrap", "nowrap"} VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"} +VALID_TEXT_DIRECTION: Final = {"auto", "ltr", "rtl"} VALID_EXPAND: Final = {"greedy", "optimal"} VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"} diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 406e2a3058..de7da76683 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -50,6 +50,7 @@ VALID_SCROLLBAR_GUTTER, VALID_SCROLLBAR_VISIBILITY, VALID_TEXT_ALIGN, + VALID_TEXT_DIRECTION, VALID_TEXT_OVERFLOW, VALID_TEXT_WRAP, VALID_VISIBILITY, @@ -70,6 +71,7 @@ Specificity3, Specificity6, TextAlign, + TextDirection, TextOverflow, TextWrap, Visibility, @@ -176,6 +178,7 @@ class RulesMap(TypedDict, total=False): column_span: int text_align: TextAlign + text_direction: TextDirection link_color: Color auto_link_color: bool @@ -205,6 +208,7 @@ class RulesMap(TypedDict, total=False): text_wrap: TextWrap text_overflow: TextOverflow + text_direction: TextDirection expand: Expand line_pad: int @@ -249,6 +253,7 @@ class StylesBase: "link_background_hover", "text_wrap", "text_overflow", + "text_direction", "line_pad", } @@ -500,6 +505,9 @@ class StylesBase: text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty( VALID_TEXT_OVERFLOW, "fold" ) + text_direction: StringEnumProperty[TextDirection] = StringEnumProperty( + VALID_TEXT_DIRECTION, "auto" + ) expand: StringEnumProperty[Expand] = StringEnumProperty(VALID_EXPAND, "greedy") line_pad = IntegerProperty(default=0, layout=True) """Padding added to left and right of lines.""" @@ -1299,6 +1307,8 @@ def append_declaration(name: str, value: str) -> None: append_declaration("text-wrap", self.text_wrap) if "text_overflow" in rules: append_declaration("text-overflow", self.text_overflow) + if "text_direction" in rules: + append_declaration("text-direction", self.text_direction) if "expand" in rules: append_declaration("expand", self.expand) if "line_pad" in rules: diff --git a/src/textual/css/types.py b/src/textual/css/types.py index d2b2434808..11593cebab 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -43,6 +43,7 @@ Position = Literal["relative", "absolute"] TextWrap = Literal["wrap", "nowrap"] TextOverflow = Literal["clip", "fold", "ellipsis"] +TextDirection = Literal["auto", "ltr", "rtl"] Expand = Literal["greedy", "expand"] ScrollbarVisibility = Literal["visible", "hidden"] diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index c5bea8d4d0..dc62db7e68 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -855,6 +855,7 @@ class MarkdownFence(MarkdownBlock): height: auto; color: rgb(210,210,210); background: black 10%; + text-direction: ltr; /* Code should always be LTR, even in RTL documents */ &:light { background: white 30%; } diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 47ba4522e9..a1cfadb37b 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -14,6 +14,7 @@ from rich.text import Text from typing_extensions import Literal +from textual._bidi import apply_bidi_to_line, contains_rtl from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER, get_language from textual.actions import SkipAction @@ -416,6 +417,17 @@ class TextArea(ScrollView): placeholder: Reactive[str | Content] = reactive("") """Text to show when the text area has no content.""" + text_direction: Reactive[Literal["auto", "ltr", "rtl"]] = reactive("ltr") + """Text direction for BiDi support. + + "auto" detects RTL characters and applies BiDi algorithm automatically. + "ltr" forces left-to-right (default, best for code). + "rtl" forces right-to-left. + + Note: Cursor positioning may not be fully accurate in RTL mode as the + TextArea is primarily designed for LTR text editing. + """ + @dataclass class Changed(Message): """Posted when the content inside the TextArea changes. @@ -1191,6 +1203,12 @@ def get_line(self, line_index: int) -> Text: A `rich.Text` object containing the requested line. """ line_string = self.document.get_line(line_index) + + # Apply BiDi (bidirectional) text reordering for RTL languages + text_dir = self.text_direction + if text_dir == "rtl" or (text_dir == "auto" and contains_rtl(line_string)): + line_string = apply_bidi_to_line(line_string) + return Text(line_string, end="", no_wrap=True) def render_lines(self, crop: Region) -> list[Strip]: @@ -1257,6 +1275,7 @@ def render_line(self, y: int) -> Strip: self.read_only, self.show_cursor, self.suggestion, + self.text_direction, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line diff --git a/tests/test_bidi.py b/tests/test_bidi.py new file mode 100644 index 0000000000..cd0a8b68b1 --- /dev/null +++ b/tests/test_bidi.py @@ -0,0 +1,180 @@ +"""Tests for BiDi (Bidirectional) text support.""" + +from __future__ import annotations + +import pytest + +from textual._bidi import ( + BIDI_AVAILABLE, + apply_bidi_to_content, + apply_bidi_to_line, + contains_rtl, + get_display, + get_display_with_mapping, + remap_spans, +) +from textual.content import Content, Span + + +class TestContainsRtl: + """Tests for the contains_rtl function.""" + + def test_empty_string(self): + """Empty string should not contain RTL.""" + assert contains_rtl("") is False + + def test_ascii_only(self): + """ASCII-only text should not contain RTL.""" + assert contains_rtl("Hello World") is False + assert contains_rtl("123 abc XYZ") is False + assert contains_rtl("!@#$%^&*()") is False + + def test_hebrew(self): + """Hebrew text should be detected as RTL.""" + assert contains_rtl("שלום") is True + assert contains_rtl("שלום עולם") is True + + def test_arabic(self): + """Arabic text should be detected as RTL.""" + assert contains_rtl("مرحبا") is True + assert contains_rtl("مرحبا بالعالم") is True + + def test_mixed(self): + """Mixed text with RTL should be detected.""" + assert contains_rtl("Hello שלום World") is True + assert contains_rtl("Test مرحبا Test") is True + + def test_numbers_only(self): + """Numbers should not be detected as RTL.""" + assert contains_rtl("12345") is False + + def test_rtl_with_numbers(self): + """RTL text with numbers should be detected.""" + assert contains_rtl("מספר 123") is True + + +class TestGetDisplay: + """Tests for the get_display function.""" + + def test_empty_string(self): + """Empty string should return empty.""" + assert get_display("") == "" + + def test_ascii_only(self): + """ASCII text should be unchanged.""" + assert get_display("Hello World") == "Hello World" + + @pytest.mark.skipif(not BIDI_AVAILABLE, reason="python-bidi not installed") + def test_hebrew_only(self): + """Pure Hebrew should be reversed for display.""" + # Hebrew "shalom" should be reversed for LTR terminal display + result = get_display("שלום") + # The exact result depends on python-bidi behavior + assert len(result) == 4 + + @pytest.mark.skipif(not BIDI_AVAILABLE, reason="python-bidi not installed") + def test_mixed_hebrew_english(self): + """Mixed text should follow BiDi algorithm.""" + result = get_display("Hello שלום World") + # The Hebrew part should be visually reordered + assert "Hello" in result + assert "World" in result + + +class TestGetDisplayWithMapping: + """Tests for get_display_with_mapping function.""" + + def test_empty_string(self): + """Empty string should return empty with empty mapping.""" + text, mapping = get_display_with_mapping("") + assert text == "" + assert mapping == [] + + def test_ascii_only(self): + """ASCII text should have identity mapping.""" + text, mapping = get_display_with_mapping("abc") + assert text == "abc" + assert mapping == [0, 1, 2] + + def test_no_rtl(self): + """Text without RTL should be unchanged.""" + text, mapping = get_display_with_mapping("Hello World") + assert text == "Hello World" + assert mapping == list(range(len("Hello World"))) + + +class TestRemapSpans: + """Tests for remap_spans function.""" + + def test_empty_spans(self): + """Empty spans should return empty.""" + result = remap_spans([], [0, 1, 2], 3) + assert result == [] + + def test_empty_mapping(self): + """Empty mapping should return original spans.""" + spans = [Span(0, 2, "red")] + result = remap_spans(spans, [], 0) + assert result == spans + + def test_identity_mapping(self): + """Identity mapping should preserve spans.""" + spans = [Span(0, 2, "red"), Span(3, 5, "blue")] + mapping = [0, 1, 2, 3, 4] + result = remap_spans(spans, mapping, 5) + # Should have spans in same positions + assert len(result) == 2 + + +class TestApplyBidiToContent: + """Tests for apply_bidi_to_content function.""" + + def test_empty_content(self): + """Empty content should return unchanged.""" + content = Content("") + result = apply_bidi_to_content(content) + assert result.plain == "" + + def test_ascii_content(self): + """ASCII-only content should return unchanged.""" + content = Content("Hello World") + result = apply_bidi_to_content(content) + assert result.plain == "Hello World" + + def test_content_with_spans(self): + """Content with spans should preserve styling.""" + content = Content("Hello", spans=[Span(0, 5, "red")]) + result = apply_bidi_to_content(content) + assert len(result.spans) > 0 + + @pytest.mark.skipif(not BIDI_AVAILABLE, reason="python-bidi not installed") + def test_rtl_content(self): + """RTL content should be processed.""" + content = Content("שלום") + result = apply_bidi_to_content(content) + # Should have been processed (exact result depends on python-bidi) + assert len(result.plain) == 4 + + +class TestApplyBidiToLine: + """Tests for apply_bidi_to_line function.""" + + def test_empty_string(self): + """Empty string should return empty.""" + assert apply_bidi_to_line("") == "" + + def test_ascii_only(self): + """ASCII text should be unchanged.""" + assert apply_bidi_to_line("Hello") == "Hello" + + def test_no_rtl(self): + """Text without RTL should be unchanged.""" + assert apply_bidi_to_line("Hello World 123") == "Hello World 123" + + +class TestBidiAvailable: + """Tests for BIDI_AVAILABLE flag.""" + + def test_flag_is_boolean(self): + """BIDI_AVAILABLE should be a boolean.""" + assert isinstance(BIDI_AVAILABLE, bool)