replace is_control with list of control codes

This commit is contained in:
Will McGugan 2021-03-14 11:47:03 +00:00
parent 6f972a059a
commit a998f23084
14 changed files with 188 additions and 156 deletions

View file

@ -5,6 +5,13 @@ 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).
## [9.13.1] - Unreleased
### Changed
- Made pydoc import lazy as at least one use found it slow to import https://github.com/willmcgugan/rich/issues/1104
- Modified string highlighting to not match in the middle of a word, so that apostrophes are not considered strings
## [9.13.0] - 2021-03-06
### Added

View file

@ -890,7 +890,7 @@ class Console:
def bell(self) -> None:
"""Play a 'bell' sound (if supported by the terminal)."""
self.control("\x07")
self.control(Control.bell())
def capture(self) -> Capture:
"""A context manager to *capture* the result of print() or log() in a string,
@ -948,7 +948,10 @@ class Console:
Args:
home (bool, optional): Also move the cursor to 'home' position. Defaults to True.
"""
self.control("\033[2J\033[H" if home else "\033[2J")
if home:
self.control(Control.clear(), Control.home())
else:
self.control(Control.clear())
def status(
self,
@ -991,7 +994,7 @@ class Console:
show (bool, optional): Set visibility of the cursor.
"""
if self.is_terminal and not self.legacy_windows:
self.control("\033[?25h" if show else "\033[?25l")
self.control(Control.show_cursor(show))
return True
return False
@ -1011,7 +1014,7 @@ class Console:
"""
changed = False
if self.is_terminal and not self.legacy_windows:
self.control("\033[?1049h\033[H" if enable else "\033[?1049l")
self.control(Control.alt_screen(enable))
changed = True
return changed
@ -1312,14 +1315,15 @@ class Console:
rule = Rule(title=title, characters=characters, style=style, align=align)
self.print(rule)
def control(self, control_codes: Union["Control", str]) -> None:
def control(self, *control: Control) -> None:
"""Insert non-printing control codes.
Args:
control_codes (str): Control codes, such as those that may move the cursor.
"""
if not self.is_dumb_terminal:
self._buffer.append(Segment.control(str(control_codes)))
for _control in control:
self._buffer.append(_control.segment)
self._check_buffer()
def out(
@ -1598,7 +1602,7 @@ class Console:
not_terminal = not self.is_terminal
if self.no_color and color_system:
buffer = Segment.remove_color(buffer)
for text, style, is_control in buffer:
for text, style, control in buffer:
if style:
append(
style.render(
@ -1607,7 +1611,7 @@ class Console:
legacy_windows=legacy_windows,
)
)
elif not (not_terminal and is_control):
elif not (not_terminal and control):
append(text)
rendered = "".join(output)
@ -1679,7 +1683,7 @@ class Console:
text = "".join(
segment.text
for segment in self._record_buffer
if not segment.is_control
if not segment.control
)
if clear:
del self._record_buffer[:]

View file

@ -1,8 +1,6 @@
from enum import IntEnum
from typing import NamedTuple, TYPE_CHECKING
from typing import Callable, Dict, TYPE_CHECKING, Union, Tuple
from .segment import Segment
from .segment import ControlType, Segment
if TYPE_CHECKING:
from .console import Console, ConsoleOptions, RenderResult
@ -16,44 +14,83 @@ STRIP_CONTROL_CODES = [
_CONTROL_TRANSLATE = {_codepoint: None for _codepoint in STRIP_CONTROL_CODES}
class ControlCodeEnum(IntEnum):
HOME = 1
CURSOR_UP = 2
CURSOR_DOWN = 3
CURSOR_FORWARD = 4
CURSOR_BACKWARD = 5
ERASE_IN_LINE = 5
class ControlCode(NamedTuple):
code: ControlCodeEnum
param: int
CONTROL_CODES_FORMAT: Dict[ControlType, Callable[[int], str]] = {
ControlType.BELL: lambda _: "\x07",
ControlType.CARRIAGE_RETURN: lambda _: "\r",
ControlType.HOME: lambda _: "\x1b[H",
ControlType.CLEAR: lambda _: "\x1b[2J",
ControlType.ENABLE_ALT_SCREEN: lambda _: "\x1b[?1049h",
ControlType.DISABLE_ALT_SCREEN: lambda _: "\x1b[?1049l",
ControlType.SHOW_CURSOR: lambda _: "\x1b[?25h",
ControlType.HIDE_CURSOR: lambda _: "\x1b[?25l",
ControlType.CURSOR_UP: lambda param: f"\x1b[{param}A",
ControlType.CURSOR_DOWN: lambda param: f"\x1b[{param}B",
ControlType.CURSOR_FORWARD: lambda param: f"\x1b[{param}C",
ControlType.CURSOR_BACKWARD: lambda param: f"\x1b[{param}D",
ControlType.ERASE_IN_LINE: lambda param: f"\x1b[{param}K",
}
class Control:
"""A renderable that inserts a control code (non printable but may move cursor).
Args:
control_codes (str): A string containing control codes.
*codes (str): Positional arguments are either a :class:`~rich.segment.ControlType` enum or a
tuple of ControlType and an integer parameter
"""
__slots__ = ["_control_codes"]
__slots__ = ["_segment"]
def __init__(self, control_codes: str) -> None:
self._control_codes = Segment.control(control_codes)
def __init__(self, *codes: Union[ControlType, Tuple[ControlType, int]]) -> None:
control_codes = [
code if isinstance(code, tuple) else (code, 0) for code in codes
]
_format_map = CONTROL_CODES_FORMAT
self._segment = Segment(
"".join(_format_map[code](param) for code, param in control_codes),
None,
control_codes,
)
@property
def segment(self) -> "Segment":
return self._segment
@classmethod
def bell(cls) -> "Control":
"""Ring the 'bell'."""
return cls(ControlType.BELL)
@classmethod
def home(cls) -> "Control":
"""Move cursor to 'home' position."""
return cls("\033[H")
return cls(ControlType.HOME)
@classmethod
def clear(cls) -> "Control":
"""Clear the screen."""
return cls(ControlType.CLEAR)
@classmethod
def show_cursor(cls, show: bool) -> "Control":
"""Show or hide the cursor."""
return cls(ControlType.SHOW_CURSOR if show else ControlType.HIDE_CURSOR)
@classmethod
def alt_screen(cls, enable: bool) -> "Control":
"""Enable or disable alt screen."""
if enable:
return cls(ControlType.ENABLE_ALT_SCREEN, ControlType.HOME)
else:
return cls(ControlType.DISABLE_ALT_SCREEN)
def __str__(self) -> str:
return self._control_codes.text
return self._segment.text
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
yield self._control_codes
yield self._segment
def strip_control_codes(text: str, _translate_table=_CONTROL_TRANSLATE) -> str:
@ -69,5 +106,4 @@ def strip_control_codes(text: str, _translate_table=_CONTROL_TRANSLATE) -> str:
if __name__ == "__main__": # pragma: no cover
print(strip_control_codes("hello\rWorld"))

View file

@ -93,7 +93,7 @@ class ReprHighlighter(RegexHighlighter):
r"(?P<ellipsis>\.\.\.)",
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)",
r"(?P<path>\B(\/[\w\.\-\_\+]+)*\/)(?P<filename>[\w\.\-\_\+]*)?",
r"(?<!\\)(?P<str>b?\'\'\'.*?(?<!\\)\'\'\'|b?\'.*?(?<!\\)\'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
r"(?<![\\\w])(?P<str>b?\'\'\'.*?(?<!\\)\'\'\'|b?\'.*?(?<!\\)\'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
r"(?P<uuid>[a-fA-F0-9]{8}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{12})",
r"(?P<url>(https|http|ws|wss):\/\/[0-9a-zA-Z\$\-\_\+\!`\(\)\,\.\?\/\;\:\&\=\%\#]*)",
),

View file

@ -47,8 +47,8 @@ def _render_segments(segments: Iterable[Segment]) -> str:
fragments: List[str] = []
append_fragment = fragments.append
theme = DEFAULT_TERMINAL_THEME
for text, style, is_control in Segment.simplify(segments):
if is_control:
for text, style, control in Segment.simplify(segments):
if control:
continue
text = escape(text)
if style:

View file

@ -225,12 +225,12 @@ class Live(JupyterMixin, RenderHook):
self.console.print(self._live_render.renderable)
elif self.console.is_terminal and not self.console.is_dumb_terminal:
with self._lock, self.console:
self.console.print(Control(""))
self.console.print(Control())
elif (
not self._started and not self.transient
): # if it is finished allow files or dumb-terminals to see final result
with self.console:
self.console.print(Control(""))
self.console.print(Control())
def process_renderables(
self, renderables: List[ConsoleRenderable]

View file

@ -5,7 +5,7 @@ from typing_extensions import Literal
from ._loop import loop_last
from .console import Console, ConsoleOptions, RenderableType, RenderResult
from .control import Control
from .segment import Segment
from .segment import ControlCode, ControlType, Segment
from .style import StyleType
from .text import Text
@ -47,8 +47,18 @@ class LiveRender:
"""
if self._shape is not None:
_, height = self._shape
return Control("\r\x1b[2K" + "\x1b[1A\x1b[2K" * (height - 1))
return Control("")
return Control(
ControlType.CARRIAGE_RETURN,
(ControlType.ERASE_IN_LINE, 2),
*(
(
(ControlType.CURSOR_UP, 1),
(ControlType.ERASE_IN_LINE, 2),
)
* (height - 1)
)
)
return Control()
def restore_cursor(self) -> Control:
"""Get control codes to clear the render and restore the cursor to its previous position.
@ -58,8 +68,11 @@ class LiveRender:
"""
if self._shape is not None:
_, height = self._shape
return Control("\r" + "\x1b[1A\x1b[2K" * height)
return Control("")
return Control(
ControlType.CARRIAGE_RETURN,
*((ControlType.CURSOR_UP, 1), (ControlType.ERASE_IN_LINE, 2)) * height
)
return Control()
def __rich_console__(
self, console: Console, options: ConsoleOptions

View file

@ -1,5 +1,4 @@
from abc import ABC, abstractmethod
import pydoc
class Pager(ABC):
@ -17,7 +16,7 @@ class Pager(ABC):
class SystemPager(Pager):
"""Uses the pager installed on the system."""
_pager = lambda self, content: pydoc.pager(content)
_pager = lambda self, content: __import__("pydoc").pager(content)
def show(self, content: str) -> None:
"""Use the same pager used by pydoc."""

View file

@ -1,43 +1,32 @@
from enum import IntEnm
from enum import IntEnum
from typing import Dict, NamedTuple, Optional
from typing import Callable, Dict, NamedTuple, Optional
from .cells import cell_len, set_cell_size
from .style import Style
from itertools import filterfalse, zip_longest
from itertools import filterfalse
from operator import attrgetter
from typing import Iterable, List, Tuple
class ControlCodeEnum(IntEnum):
HOME = 1
CURSOR_UP = 2
CURSOR_DOWN = 3
CURSOR_FORWARD = 4
CURSOR_BACKWARD = 5
ERASE_IN_LINE = 5
class ControlType(IntEnum):
BELL = 1
CARRIAGE_RETURN = 2
HOME = 3
CLEAR = 4
SHOW_CURSOR = 5
HIDE_CURSOR = 6
ENABLE_ALT_SCREEN = 7
DISABLE_ALT_SCREEN = 8
CURSOR_UP = 9
CURSOR_DOWN = 10
CURSOR_FORWARD = 11
CURSOR_BACKWARD = 12
ERASE_IN_LINE = 13
class ControlCode(NamedTuple):
code: ControlCodeEnum
param: int
CONTROL_CODES_FORMAT = {
ControlCodeEnum.HOME: lambda param: "\x1b[H",
ControlCodeEnum.CURSOR_UP: lambda param: f"\x1b[{param}A",
ControlCodeEnum.CURSOR_DOWN: lambda param: f"\x1b[{param}B",
ControlCodeEnum.CURSOR_FORWARD: lambda param: "\x1b[{param}C",
ControlCodeEnum.CURSOR_BACKWARD: lambda param: "\x1b[{param}D",
ControlCodeEnum.ERASE_IN_LINE: lambda param: "\x1b[{param}K",
}
def render_control_codes(codes: Iterable[ControlCode]) -> str:
_format_map = CONTROL_CODES_FORMAT
ansi_codes = "".join(_format_map[code](param) for code, param in codes)
return ansi_codes
ControlCode = Tuple[ControlType, int]
class Segment(NamedTuple):
@ -47,20 +36,20 @@ class Segment(NamedTuple):
Args:
text (str): A piece of text.
style (:class:`~rich.style.Style`, optional): An optional style to apply to the text.
is_control (bool, optional): Boolean that marks segment as containing non-printable control codes.
control (Tuple[ControlCode..], optional): Optional tuple of control codes.
"""
text: str = ""
"""Raw text."""
style: Optional[Style] = None
"""An optional style."""
is_control: bool = False
control: Optional[List[ControlCode]] = None
"""True if the segment contains control codes, otherwise False."""
def __repr__(self) -> str:
"""Simplified repr."""
if self.is_control:
return f"Segment.control({self.text!r}, {self.style!r})"
if self.control:
return f"Segment({self.text!r}, {self.style!r}, {self.control!r})"
else:
return f"Segment({self.text!r}, {self.style!r})"
@ -71,34 +60,17 @@ class Segment(NamedTuple):
@property
def cell_length(self) -> int:
"""Get cell length of segment."""
return 0 if self.is_control else cell_len(self.text)
return 0 if self.control else cell_len(self.text)
@property
def is_control(self) -> bool:
"""Check if the segment contains control codes."""
return self.control is not None
@classmethod
def control(cls, text: str, style: Optional[Style] = None) -> "Segment":
"""Create a Segment with control codes.
Args:
text (str): Text containing non-printable control codes.
style (Optional[style]): Optional style.
Returns:
Segment: A Segment instance with ``is_control=True``.
"""
return cls(text, style, is_control=True)
@classmethod
def make_control(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]:
"""Convert all segments in to control segments.
Returns:
Iterable[Segments]: Segments with is_control=True
"""
return [cls(text, style, True) for text, style, _ in segments]
@classmethod
def line(cls, is_control: bool = False) -> "Segment":
def line(cls) -> "Segment":
"""Make a new line segment."""
return cls("\n", is_control=is_control)
return cls("\n")
@classmethod
def apply_style(
@ -122,19 +94,19 @@ class Segment(NamedTuple):
if style:
apply = style.__add__
segments = (
cls(text, None if is_control else apply(_style), is_control)
for text, _style, is_control in segments
cls(text, None if control else apply(_style), control)
for text, _style, control in segments
)
if post_style:
segments = (
cls(
text,
None
if is_control
if control
else (_style + post_style if _style else post_style),
is_control,
control,
)
for text, _style, is_control in segments
for text, _style, control in segments
)
return segments
@ -153,9 +125,9 @@ class Segment(NamedTuple):
"""
if is_control:
return filter(attrgetter("is_control"), segments)
return filter(attrgetter("control"), segments)
else:
return filterfalse(attrgetter("is_control"), segments)
return filterfalse(attrgetter("control"), segments)
@classmethod
def split_lines(cls, segments: Iterable["Segment"]) -> Iterable[List["Segment"]]:
@ -171,7 +143,7 @@ class Segment(NamedTuple):
append = line.append
for segment in segments:
if "\n" in segment.text and not segment.is_control:
if "\n" in segment.text and not segment.control:
text, style, _ = segment
while text:
_text, new_line, text = text.partition("\n")
@ -214,7 +186,7 @@ class Segment(NamedTuple):
new_line_segment = cls("\n")
for segment in segments:
if "\n" in segment.text and not segment.is_control:
if "\n" in segment.text and not segment.control:
text, style, _ = segment
while text:
_text, new_line, text = text.partition("\n")
@ -262,7 +234,7 @@ class Segment(NamedTuple):
line_length = 0
for segment in line:
segment_length = segment.cell_length
if line_length + segment_length < length or segment.is_control:
if line_length + segment_length < length or segment.control:
append(segment)
line_length += segment_length
else:
@ -360,7 +332,7 @@ class Segment(NamedTuple):
_Segment = Segment
for segment in iter_segments:
if last_segment.style == segment.style and not segment.is_control:
if last_segment.style == segment.style and not segment.control:
last_segment = _Segment(
last_segment.text + segment.text, last_segment.style
)
@ -380,10 +352,10 @@ class Segment(NamedTuple):
Segment: Segments with link removed.
"""
for segment in segments:
if segment.is_control or segment.style is None:
if segment.control or segment.style is None:
yield segment
else:
text, style, _is_control = segment
text, style, _control = segment
yield cls(text, style.update_link(None) if style else None)
@classmethod
@ -396,8 +368,8 @@ class Segment(NamedTuple):
Yields:
Segment: Segments with styles replace with None
"""
for text, _style, is_control in segments:
yield cls(text, None, is_control)
for text, _style, control in segments:
yield cls(text, None, control)
@classmethod
def remove_color(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]:
@ -411,15 +383,15 @@ class Segment(NamedTuple):
"""
cache: Dict[Style, Style] = {}
for text, style, is_control in segments:
for text, style, control in segments:
if style:
colorless_style = cache.get(style)
if colorless_style is None:
colorless_style = style.without_color
cache[style] = colorless_style
yield cls(text, colorless_style, is_control)
yield cls(text, colorless_style, control)
else:
yield cls(text, None, is_control)
yield cls(text, None, control)
if __name__ == "__main__": # pragma: no cover

View file

@ -66,26 +66,26 @@ def test_get_pulse_segments():
)
print(repr(segments))
expected = [
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("red"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("yellow"), False),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("red")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
Segment("", Style.parse("yellow")),
]
assert segments == expected

View file

@ -16,6 +16,7 @@ from rich.console import (
ConsoleOptions,
render_group,
)
from rich.control import Control
from rich.measure import measure_renderables
from rich.pager import SystemPager
from rich.panel import Panel
@ -211,9 +212,9 @@ def test_render_error():
def test_control():
console = Console(file=io.StringIO(), force_terminal=True, _environ={})
console.control("FOO")
console.control(Control.clear())
console.print("BAR")
assert console.file.getvalue() == "FOOBAR\n"
assert console.file.getvalue() == "\x1b[2JBAR\n"
def test_capture():

View file

@ -1,9 +1,10 @@
from rich.control import Control, strip_control_codes
from rich.segment import ControlType
def test_control():
control = Control("FOO")
assert str(control) == "FOO"
control = Control(ControlType.BELL)
assert str(control) == "\x07"
def test_strip_control_codes():

View file

@ -70,6 +70,7 @@ highlight_tests = [
('"hello"', [Span(0, 7, "repr.str")]),
('"""hello"""', [Span(0, 11, "repr.str")]),
("\\'foo'", []),
("it's no 'string'", [Span(8, 16, "repr.str")]),
]

View file

@ -1,10 +1,15 @@
from rich.segment import ControlCode, ControlType
from rich.segment import Segment
from rich.style import Style
def test_repr():
assert repr(Segment("foo")) == "Segment('foo', None)"
assert repr(Segment.control("foo")) == "Segment.control('foo', None)"
home = (ControlType.HOME, 0)
assert (
repr(Segment("foo", None, [home]))
== "Segment('foo', None, [(<ControlType.HOME: 3>, 0)])"
)
def test_line():
@ -81,10 +86,11 @@ def test_simplify():
def test_filter_control():
segments = [Segment("foo"), Segment("bar", is_control=True)]
control_code = (ControlType.HOME, 0)
segments = [Segment("foo"), Segment("bar", None, (control_code,))]
assert list(Segment.filter_control(segments)) == [Segment("foo")]
assert list(Segment.filter_control(segments, is_control=True)) == [
Segment("bar", is_control=True)
Segment("bar", None, (control_code,))
]
@ -107,11 +113,3 @@ def test_remove_color():
Segment("foo", Style(bold=True)),
Segment("bar", None),
]
def test_make_control():
segments = [Segment("foo"), Segment("bar")]
assert Segment.make_control(segments) == [
Segment.control("foo"),
Segment.control("bar"),
]