Skip to content

Commit

Permalink
Merge pull request #3061 from Textualize/tabs-spaces
Browse files Browse the repository at this point in the history
Extend tabs with styles
  • Loading branch information
willmcgugan committed Jul 29, 2023
2 parents aca9467 + d5b3282 commit 62c8aba
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 27 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 67 additions & 24 deletions rich/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ def right_crop(self, offset: int) -> "Span":
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.
Expand All @@ -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.
"""

Expand All @@ -133,7 +148,7 @@ def __init__(
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)
Expand Down Expand Up @@ -292,7 +307,7 @@ def from_ansi(
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

Expand Down Expand Up @@ -354,7 +369,7 @@ def assemble(
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:
Expand Down Expand Up @@ -549,6 +564,27 @@ def get_style_at_offset(self, console: "Console", offset: int) -> Style:
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,
Expand Down Expand Up @@ -646,7 +682,7 @@ def set_length(self, new_length: int) -> None:
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
Expand Down Expand Up @@ -781,27 +817,35 @@ def expand_tabs(self, tab_size: Optional[int] = None) -> None:
"""
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
Expand Down Expand Up @@ -932,7 +976,7 @@ def append(
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):
Expand All @@ -942,7 +986,7 @@ def append(
"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)
)
Expand All @@ -963,7 +1007,7 @@ def append_text(self, text: "Text") -> "Text":
"""
_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(
Expand All @@ -990,7 +1034,7 @@ def append_tokens(
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
Expand Down Expand Up @@ -1088,7 +1132,6 @@ def divide(self, offsets: Iterable[int]) -> Lines:
_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
Expand Down
6 changes: 3 additions & 3 deletions tests/test_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ def test_render_size():
expected = [
[
Segment("╭─", Style()),
Segment("──────────────────────────────────", Style()),
Segment(" Hello ", Style()),
Segment("───────────────────────────────────", Style()),
Segment(
"────────────────────────────────── Hello ───────────────────────────────────"
),
Segment("─╮", Style()),
],
[
Expand Down
109 changes: 109 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from io import StringIO
from typing import List

import pytest

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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")]

0 comments on commit 62c8aba

Please sign in to comment.