Repository: Textualize/rich
Pull request #3061 (2023-07-28 21:00:42)

Extend tabs with styles
The `Text.extend_tabs` method was previously not taking in to account the spans.  Basically it would replace tabs with unstyled spaces. This resolves that issue by extending the style(s) on the tab to cover the new space characters

<img width="623" alt="Screenshot 2023-07-28 at 22 00 06" src="https://github.com/Textualize/rich/assets/554369/28c170a0-b154-4a81-af17-a8a334bebd3a">



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72f2d40f..08352f2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+
+## Unreleased
+
+### Fixed
+
+- Fixed Text.expand_tabs not expanding spans.
+
+### Added
+
+- Added Text.extend_style method.
+- Added Span.extend method.
+
+### Changed
+
+- Text.tab_size now defaults to `None` to indicate that Console.tab_size should be used.
+
 ## [13.4.2] - 2023-06-12
 
 ### Changed
diff --git a/rich/text.py b/rich/text.py
index c534b3b1..d20bdbce 100644
--- a/rich/text.py
+++ b/rich/text.py
@@ -97,6 +97,21 @@ class Span(NamedTuple):
             return self
         return Span(start, min(offset, end), style)
 
+    def extend(self, cells: int) -> "Span":
+        """Extend the span by the given number of cells.
+
+        Args:
+            cells (int): Additional space to add to end of span.
+
+        Returns:
+            Span: A span.
+        """
+        if cells:
+            start, end, style = self
+            return Span(start, end + cells, style)
+        else:
+            return self
+
 
 class Text(JupyterMixin):
     """Text with color / style.
@@ -108,7 +123,7 @@ class Text(JupyterMixin):
         overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
         no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
         end (str, optional): Character to end text with. Defaults to "\\\\n".
-        tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
+        tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
         spans (List[Span], optional). A list of predefined style spans. Defaults to None.
     """
 
@@ -133,7 +148,7 @@ class Text(JupyterMixin):
         overflow: Optional["OverflowMethod"] = None,
         no_wrap: Optional[bool] = None,
         end: str = "\n",
-        tab_size: Optional[int] = 8,
+        tab_size: Optional[int] = None,
         spans: Optional[List[Span]] = None,
     ) -> None:
         sanitized_text = strip_control_codes(text)
@@ -292,7 +307,7 @@ class Text(JupyterMixin):
             overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
             no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
             end (str, optional): Character to end text with. Defaults to "\\\\n".
-            tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
+            tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
         """
         from .ansi import AnsiDecoder
 
@@ -354,7 +369,7 @@ class Text(JupyterMixin):
             justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
             overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
             end (str, optional): Character to end text with. Defaults to "\\\\n".
-            tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
+            tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
             meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None
 
         Returns:
@@ -549,6 +564,27 @@ class Text(JupyterMixin):
                 style += get_style(span_style, default="")
         return style
 
+    def extend_style(self, spaces: int) -> None:
+        """Extend the Text given number of spaces where the spaces have the same style as the last character.
+
+        Args:
+            spaces (int): Number of spaces to add to the Text.
+        """
+        if spaces <= 0:
+            return
+        spans = self.spans
+        new_spaces = " " * spaces
+        if spans:
+            end_offset = len(self)
+            self._spans[:] = [
+                span.extend(spaces) if span.end >= end_offset else span
+                for span in spans
+            ]
+            self._text.append(new_spaces)
+            self._length += spaces
+        else:
+            self.plain += new_spaces
+
     def highlight_regex(
         self,
         re_highlight: str,
@@ -646,7 +682,7 @@ class Text(JupyterMixin):
     def __rich_console__(
         self, console: "Console", options: "ConsoleOptions"
     ) -> Iterable[Segment]:
-        tab_size: int = console.tab_size or self.tab_size or 8
+        tab_size: int = console.tab_size if self.tab_size is None else self.tab_size
         justify = self.justify or options.justify or DEFAULT_JUSTIFY
 
         overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
@@ -781,27 +817,35 @@ class Text(JupyterMixin):
         """
         if "\t" not in self.plain:
             return
-        pos = 0
         if tab_size is None:
             tab_size = self.tab_size
         assert tab_size is not None
         result = self.blank_copy()
-        append = result.append
 
-        _style = self.style
+        new_text: List[Text] = []
+        append = new_text.append
+
         for line in self.split("\n", include_separator=True):
-            parts = line.split("\t", include_separator=True)
-            for part in parts:
-                if part.plain.endswith("\t"):
-                    part._text = [part.plain[:-1] + " "]
-                    append(part)
-                    pos += len(part)
-                    spaces = tab_size - ((pos - 1) % tab_size) - 1
-                    if spaces:
-                        append(" " * spaces, _style)
-                        pos += spaces
-                else:
+            if "\t" not in line.plain:
+                append(line)
+            else:
+                cell_position = 0
+                parts = line.split("\t", include_separator=True)
+                for part in parts:
+                    if part.plain.endswith("\t"):
+                        part._text[-1] = part._text[-1][:-1] + " "
+                        cell_position += part.cell_len
+                        tab_remainder = cell_position % tab_size
+                        if tab_remainder:
+                            spaces = tab_size - tab_remainder
+                            part.extend_style(spaces)
+                            cell_position += spaces
+                    else:
+                        cell_position += part.cell_len
                     append(part)
+
+        result = Text("").join(new_text)
+
         self._text = [result.plain]
         self._length = len(self.plain)
         self._spans[:] = result._spans
@@ -932,7 +976,7 @@ class Text(JupyterMixin):
                 self._text.append(sanitized_text)
                 offset = len(self)
                 text_length = len(sanitized_text)
-                if style is not None:
+                if style:
                     self._spans.append(Span(offset, offset + text_length, style))
                 self._length += text_length
             elif isinstance(text, Text):
@@ -942,7 +986,7 @@ class Text(JupyterMixin):
                         "style must not be set when appending Text instance"
                     )
                 text_length = self._length
-                if text.style is not None:
+                if text.style:
                     self._spans.append(
                         _Span(text_length, text_length + len(text), text.style)
                     )
@@ -963,7 +1007,7 @@ class Text(JupyterMixin):
         """
         _Span = Span
         text_length = self._length
-        if text.style is not None:
+        if text.style:
             self._spans.append(_Span(text_length, text_length + len(text), text.style))
         self._text.append(text.plain)
         self._spans.extend(
@@ -990,7 +1034,7 @@ class Text(JupyterMixin):
         offset = len(self)
         for content, style in tokens:
             append_text(content)
-            if style is not None:
+            if style:
                 append_span(_Span(offset, offset + len(content), style))
             offset += len(content)
         self._length = offset
@@ -1088,7 +1132,6 @@ class Text(JupyterMixin):
         _Span = Span
 
         for span_start, span_end, style in self._spans:
-
             lower_bound = 0
             upper_bound = line_count
             start_line_no = (lower_bound + upper_bound) // 2


Test patch:
diff --git a/tests/test_panel.py b/tests/test_panel.py
index a79a4a91..5ae3babe 100644
--- a/tests/test_panel.py
+++ b/tests/test_panel.py
@@ -63,9 +63,9 @@ def test_render_size():
     expected = [
         [
             Segment("╭─", Style()),
-            Segment("──────────────────────────────────", Style()),
-            Segment(" Hello ", Style()),
-            Segment("───────────────────────────────────", Style()),
+            Segment(
+                "────────────────────────────────── Hello ───────────────────────────────────"
+            ),
             Segment("─╮", Style()),
         ],
         [
diff --git a/tests/test_text.py b/tests/test_text.py
index 5f06715d..22c34e44 100644
--- a/tests/test_text.py
+++ b/tests/test_text.py
@@ -1,4 +1,5 @@
 from io import StringIO
+from typing import List
 
 import pytest
 
@@ -600,6 +601,98 @@ def test_tabs_to_spaces():
     text.expand_tabs()
     assert text.plain == "No Tabs"
 
+    text = Text("No Tabs", style="bold")
+    text.expand_tabs()
+    assert text.plain == "No Tabs"
+    assert text.style == "bold"
+
+
+@pytest.mark.parametrize(
+    "markup,tab_size,expected_text,expected_spans",
+    [
+        ("", 4, "", []),
+        ("\t", 4, "    ", []),
+        ("\tbar", 4, "    bar", []),
+        ("foo\tbar", 4, "foo bar", []),
+        ("foo\nbar\nbaz", 4, "foo\nbar\nbaz", []),
+        (
+            "[bold]foo\tbar",
+            4,
+            "foo bar",
+            [
+                Span(0, 4, "bold"),
+                Span(4, 7, "bold"),
+            ],
+        ),
+        (
+            "[bold]\tbar",
+            4,
+            "    bar",
+            [
+                Span(0, 4, "bold"),
+                Span(4, 7, "bold"),
+            ],
+        ),
+        (
+            "\t[bold]bar",
+            4,
+            "    bar",
+            [
+                Span(4, 7, "bold"),
+            ],
+        ),
+        (
+            "[red]foo\tbar\n[green]egg\tbaz",
+            8,
+            "foo     bar\negg     baz",
+            [
+                Span(0, 8, "red"),
+                Span(8, 12, "red"),
+                Span(12, 20, "red"),
+                Span(12, 20, "green"),
+                Span(20, 23, "red"),
+                Span(20, 23, "green"),
+            ],
+        ),
+        (
+            "[bold]X\tY",
+            8,
+            "X       Y",
+            [
+                Span(0, 8, "bold"),
+                Span(8, 9, "bold"),
+            ],
+        ),
+        (
+            "[bold]💩\t💩",
+            8,
+            "💩      💩",
+            [
+                Span(0, 7, "bold"),
+                Span(7, 8, "bold"),
+            ],
+        ),
+        (
+            "[bold]💩💩💩💩\t💩",
+            8,
+            "💩💩💩💩        💩",
+            [
+                Span(0, 12, "bold"),
+                Span(12, 13, "bold"),
+            ],
+        ),
+    ],
+)
+def test_tabs_to_spaces_spans(
+    markup: str, tab_size: int, expected_text: str, expected_spans: List[Span]
+):
+    """Test spans are correct after expand_tabs"""
+    text = Text.from_markup(markup)
+    text.expand_tabs(tab_size)
+    print(text._spans)
+    assert text.plain == expected_text
+    assert text._spans == expected_spans
+
 
 def test_markup_switch():
     """Test markup can be disabled."""
@@ -806,3 +899,19 @@ def test_markup_property():
         == "[bold]foo [italic]bar[/bold] baz[/italic]"
     )
     assert Text("[bold]foo").markup == "\\[bold]foo"
+
+
+def test_extend_style():
+    text = Text.from_markup("[red]foo[/red] [bold]bar")
+    text.extend_style(0)
+
+    assert text.plain == "foo bar"
+    assert text.spans == [Span(0, 3, "red"), Span(4, 7, "bold")]
+
+    text.extend_style(-1)
+    assert text.plain == "foo bar"
+    assert text.spans == [Span(0, 3, "red"), Span(4, 7, "bold")]
+
+    text.extend_style(2)
+    assert text.plain == "foo bar  "
+    assert text.spans == [Span(0, 3, "red"), Span(4, 9, "bold")]