terminal fixes, thems

This commit is contained in:
Will McGugan 2020-02-14 16:09:53 +00:00
parent 6261ca23bf
commit ccf18b938f
10 changed files with 273 additions and 124 deletions

View file

@ -9,9 +9,10 @@ from typing import Iterable, List, NamedTuple, Optional, Sequence, Tuple, TYPE_C
from ._palettes import STANDARD_PALETTE, EIGHT_BIT_PALETTE
from .color_triplet import ColorTriplet
from .terminal_theme import DEFAULT_TERMINAL_THEME
if TYPE_CHECKING: # pragma: no cover
from .theme import Theme
from .terminal_theme import TerminalTheme
WINDOWS = platform.system() == "Windows"
@ -283,7 +284,11 @@ class Color(NamedTuple):
return ColorSystem.STANDARD
return ColorSystem(int(self.type))
def get_truecolor(self, theme: "Theme", foreground=True) -> ColorTriplet:
def get_truecolor(
self, theme: "TerminalTheme" = None, foreground=True
) -> ColorTriplet:
if theme is None:
theme = DEFAULT_TERMINAL_THEME
"""Get a color triplet for this color."""
if self.type == ColorType.TRUECOLOR:
assert self.triplet is not None

View file

@ -43,8 +43,9 @@ from .tabulate import tabulate_mapping
from . import highlighter
from . import themes
from .pretty import Pretty
from .theme import Theme
from .terminal_theme import TerminalTheme, DEFAULT_TERMINAL_THEME
from .segment import Segment
from .theme import Theme
if TYPE_CHECKING: # pragma: no cover
from .text import Text
@ -166,7 +167,7 @@ class Console:
Args:
color_system (str, optional): The color system supported by your terminal,
either ``"standard"``, ``"256"`` or ``"truecolor"``. Leave as ``"auto"`` to autodetect.
styles (Dict[str, Style], optional): An optional mapping of style name strings to :class:`~rich.style.Style` objects.
theme (Theme, optional): An optional style theme object, or ``None`` for default theme.
file (IO, optional): A file object where the console should write to. Defaults to stdoutput.
width (int, optional): The width of the terminal. Leave as default to auto-detect width.
height (int, optional): The height of the terminal. Leave as default to auto-detect height.
@ -184,7 +185,7 @@ class Console:
color_system: Optional[
Literal["auto", "standard", "256", "truecolor", "windows"]
] = "auto",
styles: Dict[str, Style] = None,
theme: Theme = None,
file: IO = None,
width: int = None,
height: int = None,
@ -195,7 +196,9 @@ class Console:
log_time_format: str = "[%X] ",
highlighter: Optional["HighlighterType"] = ReprHighlighter(),
):
self._styles = ChainMap(DEFAULT_STYLES if styles is None else styles)
self._styles = ChainMap(
themes.DEFAULT.styles if theme is None else theme.styles
)
self.file = file or sys.stdout
self._width = width
self._height = height
@ -262,6 +265,10 @@ class Console:
"""
self._styles.maps.append(styles)
def pop_styles(self) -> None:
"""Restore styles to state before `push_styles`."""
self._styles.maps.pop()
@property
def color_system(self) -> Optional[str]:
"""Get color system string.
@ -379,7 +386,13 @@ class Console:
"A str, Segment or object with __console__ method is required"
)
for render_output in render_iterable:
try:
iter_render = iter(render_iterable)
except TypeError:
raise errors.NotRenderableError(
f"object {render_iterable!r} is not renderable"
)
for render_output in iter_render:
if isinstance(render_output, Segment):
yield render_output
else:
@ -808,7 +821,7 @@ class Console:
def export_html(
self,
theme: Theme = None,
theme: TerminalTheme = None,
clear: bool = True,
code_format: str = None,
inline_styles: bool = False,
@ -816,7 +829,7 @@ class Console:
"""Generate HTML from console contents (requires record=True argument in constructor).
Args:
theme (Theme, optional): Theme object containing console colors.
theme (TerminalTheme, optional): TerminalTheme object containing console colors.
clear (bool, optional): Set to ``True`` to clear the record buffer after generating the HTML.
code_format (str, optional): Format string to render HTML, should contain {foreground}
{background} and {code}.
@ -832,7 +845,7 @@ class Console:
), "To export console contents set record=True in the constructor or instance"
fragments: List[str] = []
append = fragments.append
_theme = theme or themes.DEFAULT
_theme = theme or DEFAULT_TERMINAL_THEME
stylesheet = ""
def escape(text: str) -> str:
@ -882,7 +895,7 @@ class Console:
def save_html(
self,
path: str,
theme: Theme = None,
theme: TerminalTheme = None,
clear: bool = True,
code_format=CONSOLE_HTML_FORMAT,
inline_styles: bool = False,
@ -891,7 +904,7 @@ class Console:
Args:
path (str): Path to write html file.
theme (Theme, optional): Theme object containing console colors.
theme (TerminalTheme, optional): TerminalTheme object containing console colors.
clear (bool, optional): Set to True to clear the record buffer after generating the HTML.
code_format (str, optional): Format string to render HTML, should contain {foreground}
{background} and {code}.

View file

@ -1,4 +1,4 @@
from typing import Iterator, Iterable, List, TypeVar, TYPE_CHECKING, Union
from typing import Iterator, Iterable, List, overload, TypeVar, TYPE_CHECKING, Union
from typing_extensions import Literal
from .segment import Segment
@ -57,7 +57,15 @@ class Lines:
def __iter__(self) -> Iterator["Text"]:
return iter(self._lines)
@overload
def __getitem__(self, index: int) -> "Text":
...
@overload
def __getitem__(self, index: slice) -> "Lines":
...
def __getitem__(self, index):
return self._lines[index]
def __setitem__(self, index: int, value: "Text") -> "Lines":

View file

@ -100,4 +100,86 @@ MARKDOWN_STYLES = {
"markdown.link": Style(bold=True),
"markdown.link_url": Style(underline=True),
}
PYTHON_STYLES = {
"python.background": Style(),
"python.foreground": Style(),
"python.keyword": Style(),
"python.operator": Style(),
"python.endmarker": Style(),
"python.name": Style(),
"python.number": Style(),
"python.string": Style(),
"python.newline": Style(),
"python.indent": Style(),
"python.dedent": Style(),
"python.lpar": Style(),
"python.rpar": Style(),
"python.lsqb": Style(),
"python.rsqb": Style(),
"python.colon": Style(),
"python.comma": Style(),
"python.semi": Style(),
"python.plus": Style(),
"python.minus": Style(),
"python.star": Style(),
"python.slash": Style(),
"python.vbar": Style(),
"python.amper": Style(),
"python.less": Style(),
"python.greater": Style(),
"python.equal": Style(),
"python.dot": Style(),
"python.percent": Style(),
"python.lbrace": Style(),
"python.rbrace": Style(),
"python.eqequal": Style(),
"python.notequal": Style(),
"python.lessequal": Style(),
"python.greaterequal": Style(),
"python.tilde": Style(),
"python.circumflex": Style(),
"python.leftshift": Style(),
"python.rightshift": Style(),
"python.doublestar": Style(),
"python.plusequal": Style(),
"python.minequal": Style(),
"python.starequal": Style(),
"python.slashequal": Style(),
"python.percentequal": Style(),
"python.amperequal": Style(),
"python.vbarequal": Style(),
"python.circumflexequal": Style(),
"python.leftshiftequal": Style(),
"python.rightshiftequal": Style(),
"python.doublestarequal": Style(),
"python.doubleslash": Style(),
"python.doubleslashequal": Style(),
"python.at": Style(),
"python.atequal": Style(),
"python.rarrow": Style(),
"python.ellipsis": Style(),
"python.colonequal": Style(),
"python.op": Style(),
"python.await": Style(),
"python.async": Style(),
"python.type_ignore": Style(),
"python.type_comment": Style(),
"python.errortoken": Style(),
"python.comment": Style(),
"python.nl": Style(),
"python.encoding": Style(),
"python.n_tokens": Style(),
"python.nt_offset": Style(),
}
DEFAULT_STYLES.update(MARKDOWN_STYLES)
DEFAULT_STYLES.update(PYTHON_STYLES)
if __name__ == "__main__":
import token
for name in token.tok_name.values():
print(f'"python.{name.lower()}" : Style(),')

View file

@ -4,8 +4,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Type, Union
from . import errors
from .color import blend_rgb, Color, ColorParseError, ColorSystem
from . import themes
from .theme import Theme
from .terminal_theme import TerminalTheme, DEFAULT_TERMINAL_THEME
class _Bit:
@ -31,8 +30,8 @@ class Style:
def __init__(
self,
*,
color: str = None,
bgcolor: str = None,
color: Union[Color, str] = None,
bgcolor: Union[Color, str] = None,
bold: bool = None,
dim: bool = None,
italic: bool = None,
@ -43,8 +42,11 @@ class Style:
conceal: bool = None,
strike: bool = None,
):
self._color = None if color is None else Color.parse(color)
self._bgcolor = None if bgcolor is None else Color.parse(bgcolor)
def _make_color(color: Union[Color, str]) -> Color:
return color if isinstance(color, Color) else Color.parse(color)
self._color = None if color is None else _make_color(color)
self._bgcolor = None if bgcolor is None else _make_color(bgcolor)
self._attributes = (
(bold or 0)
| (dim or 0) << 1
@ -218,9 +220,9 @@ class Style:
return style
@lru_cache(maxsize=1000)
def get_html_style(self, theme: Theme = None) -> str:
def get_html_style(self, theme: TerminalTheme = None) -> str:
"""Get a CSS style rule."""
theme = theme or themes.DEFAULT
theme = theme or DEFAULT_TERMINAL_THEME
css: List[str] = []
append = css.append

View file

@ -1,8 +1,9 @@
import textwrap
from typing import Any, Dict, Union
from typing import Any, Dict, Set, Tuple, Union
from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename
from pygments.styles import get_style_by_name
from pygments.style import Style as PygmentsStyle
from pygments.token import Token
from pygments.util import ClassNotFound
@ -12,6 +13,8 @@ from .style import Style
from .text import Text
from ._tools import iter_first
DEFAULT_THEME = "monokai"
class Syntax:
"""Construct a Syntax object to render syntax highlighted code.
@ -23,6 +26,8 @@ class Syntax:
dedent (bool, optional): Enable stripping of initial whitespace. Defaults to True.
line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False.
start_line (int, optional): Starting number for line numbers. Defaults to 1.
line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render.
highlight_lines (Set[int]): A set of line numbers to highlight.
"""
def __init__(
@ -30,36 +35,41 @@ class Syntax:
code: str,
lexer_name: str,
*,
theme: str = "emacs",
dedent: bool = True,
theme: Union[str, PygmentsStyle] = DEFAULT_THEME,
dedent: bool = False,
line_numbers: bool = False,
start_line: int = 1,
line_range: Tuple[int, int] = None,
highlight_lines: Set[int] = None,
) -> None:
if dedent:
code = textwrap.dedent(code)
self.code = code
self.lexer_name = lexer_name
self.theme = theme
self.dedent = dedent
self.line_numbers = line_numbers
self.start_line = start_line
self.line_range = line_range
self.highlight_lines = highlight_lines or set()
self._style_cache: Dict[Any, Style] = {}
try:
self._pygments_style_class = get_style_by_name(theme)
except ClassNotFound:
self._pygments_style_class = get_style_by_name("default")
if not isinstance(theme, str) and issubclass(theme, PygmentsStyle):
self._pygments_style_class = theme
else:
try:
self._pygments_style_class = get_style_by_name(theme)
except ClassNotFound:
self._pygments_style_class = get_style_by_name("default")
self._background_color = self._pygments_style_class.background_color
@classmethod
def from_path(
cls,
path: str,
theme: str = "emacs",
theme: Union[str, PygmentsStyle] = DEFAULT_THEME,
dedent: bool = True,
line_numbers: bool = False,
line_range: Tuple[int, int] = None,
start_line: int = 1,
highlight_lines: Set[int] = None,
) -> "Syntax":
"""Construct a Syntax object from a file.
@ -70,6 +80,8 @@ class Syntax:
dedent (bool, optional): Enable stripping of initial whitespace. Defaults to True.
line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False.
start_line (int, optional): Starting number for line numbers. Defaults to 1.
line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render.
highlight_lines (Set[int]): A set of line numbers to highlight.
Returns:
[Syntax]: A Syntax object that may be printed to the console
@ -87,7 +99,9 @@ class Syntax:
theme=theme,
dedent=dedent,
line_numbers=line_numbers,
line_range=line_range,
start_line=start_line,
highlight_lines=highlight_lines,
)
def _get_theme_style(self, token_type) -> Style:
@ -95,7 +109,6 @@ class Syntax:
style = self._style_cache[token_type]
else:
pygments_style = self._pygments_style_class.style_for_token(token_type)
color = pygments_style["color"]
bgcolor = pygments_style["bgcolor"]
style = Style(
@ -127,7 +140,7 @@ class Syntax:
append(token, _get_theme_style(token_type))
return text
def _get_line_numbers_color(self) -> Color:
def _get_line_numbers_color(self, blend: float = 0.5) -> Color:
background_color = parse_rgb_hex(
self._pygments_style_class.background_color[1:]
)
@ -136,7 +149,9 @@ class Syntax:
return Color.default()
# TODO: Handle no full colors here
assert foreground_color.triplet is not None
new_color = blend_rgb(background_color, foreground_color.triplet)
new_color = blend_rgb(
background_color, foreground_color.triplet, cross_fade=blend
)
return Color.from_triplet(new_color)
def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
@ -149,14 +164,32 @@ class Syntax:
return
lines = text.split("\n")
start_line = 0
if self.line_range:
start_line, end_line = self.line_range
start_line = start_line - 1
lines = lines[start_line:end_line]
numbers_column_width = len(str(self.start_line + len(lines))) + 2
render_options = options.update(width=options.max_width - numbers_column_width)
background_style = Style(bgcolor=self._pygments_style_class.background_color)
number_style = background_style + self._get_theme_style(Token.Text)
number_style._color = self._get_line_numbers_color()
number_styles = {
False: (
background_style
+ self._get_theme_style(Token.Text)
+ Style(color=self._get_line_numbers_color())
),
True: (
background_style
+ self._get_theme_style(Token.Text)
+ Style(bold=True, color=self._get_line_numbers_color(0.9))
),
}
highlight_line = self.highlight_lines.__contains__
padding = Segment(" " * numbers_column_width, background_style)
new_line = Segment("\n")
for line_no, line in enumerate(lines, self.start_line):
for line_no, line in enumerate(lines, self.start_line + start_line):
wrapped_lines = console.render_lines(
line, render_options, style=background_style
)
@ -164,7 +197,7 @@ class Syntax:
if first:
yield Segment(
f" {str(line_no).rjust(numbers_column_width - 2)} ",
number_style,
number_styles[highlight_line(line_no)],
)
else:
yield padding
@ -173,40 +206,17 @@ class Syntax:
if __name__ == "__main__": # pragma: no cover
CODE = r'''
def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""This is a docstring."""
code = self.code
if self.dedent:
code = textwrap.dedent(code)
text = highlight(code, self.lexer_name, theme=self.theme)
if not self.line_numbers:
yield text
return
CODE = r"""
def __init__(self):
self.b = self.
# This is a comment
lines = text.split("\n")
numbers_column_width = len(str(len(lines))) + 1
render_options = options.update(width=options.max_width - numbers_column_width)
padding = Segment(" " * numbers_column_width)
new_line = Segment("\n")
for line_no, line in enumerate(lines, self.start_line):
wrapped_lines = console.render_lines([line], render_options)
for first, wrapped_line in iter_first(wrapped_lines):
if first:
yield Segment(f"{line_no}".ljust(numbers_column_width))
else:
yield padding
yield from wrapped_line
yield new_line
'''
syntax = Syntax(CODE, "python", dedent=True, line_numbers=True, start_line=990)
"""
# syntax = Syntax(CODE, "python", dedent=True, line_numbers=True, start_line=990)
from time import time
syntax = Syntax.from_path("./rich/syntax.py", theme="monokai", line_numbers=True)
syntax = Syntax(CODE, "python", line_numbers=False, theme="monokai")
syntax = Syntax.from_path("rich/segment.py", theme="monokai", line_numbers=True)
console = Console(record=True)
start = time()
console.print(syntax)

View file

@ -190,7 +190,39 @@ class Text:
return
self._spans.append(Span(max(0, start), min(length, end), style))
def highlight_words(self, words: Iterable[str], style: Union[str, Style]) -> int:
def highlight_regex(
self, re_highlight: str, style: Union[str, Style] = None
) -> int:
"""Highlight text with a regular expression, where group names are
translated to styles.
Args:
re_highlight (str): A regular expression
Returns:
int: Number of regex matches
"""
count = 0
append_span = self._spans.append
_Span = Span
for match in re.finditer(re_highlight, self.text):
_span = match.span
if style:
start, end = _span()
append_span(_Span(start, end, style))
count += 1
for name, _ in match.groupdict().items():
start, end = _span(name)
if start != -1:
append_span(_Span(start, end, name))
return count
def highlight_words(
self,
words: Iterable[str],
style: Union[str, Style],
case_sensitive: bool = True,
) -> int:
"""Highlight words with a style.
Args:
@ -204,7 +236,9 @@ class Text:
add_span = self._spans.append
count = 0
_Span = Span
for match in re.finditer(re_words, self.text):
for match in re.finditer(
re_words, self.text, flags=0 if case_sensitive else re.IGNORECASE
):
start, end = match.span(0)
add_span(_Span(start, end, style))
count += 1
@ -266,6 +300,7 @@ class Text:
return console.get_style(style, default=null_style)
enumerated_spans = list(enumerate(line._spans, 1))
style_map = {index: get_style(span.style) for index, span in enumerated_spans}
style_map[0] = get_style(self.style)
@ -334,7 +369,7 @@ class Text:
append(span)
continue
if span.start >= new_length:
break
continue
append(span.right_crop(new_length))
self._spans[:] = spans
@ -529,7 +564,11 @@ if __name__ == "__main__": # pragma: no cover
from .console import Console
console = Console()
text = Text("[12:42:33] ")
lines = text.wrap(10)
console.print(lines)
text = Text(
"""Hello, self! hello World
hello worhello\n"""
)
text.highlight_regex("self", "reverse")
console.print(text)

View file

@ -1,21 +1,32 @@
from typing import List, Tuple
import configparser
from typing import Dict, IO
from .color_triplet import ColorTriplet
from .palette import Palette
_ColorTuple = Tuple[int, int, int]
from .style import Style
class Theme:
"""A terminal theme.
This object is used when exporting console contents as HTML.
"""
def __init__(self, styles: Dict[str, Style] = None):
self.styles = styles or {}
def __init__(
self, background: _ColorTuple, foreground: _ColorTuple, ansi: List[_ColorTuple]
) -> None:
self.background_color = ColorTriplet(*background)
self.foreground_color = ColorTriplet(*foreground)
self.ansi_colors = Palette(ansi)
@property
def config(self) -> str:
"""Get contents of a config file for this theme."""
config_lines = ["[styles]"]
append = config_lines.append
for name, style in sorted(self.styles.items()):
append(f"{name} = {style}")
config = "\n".join(config_lines)
return config
@classmethod
def from_file(cls, config_file: IO[str], source: str = None) -> "Theme":
config = configparser.ConfigParser()
config.read_file(config_file, source=source)
styles = {name: Style.parse(value) for name, value in config.items("styles")}
theme = Theme(styles)
return theme
@classmethod
def read(cls, path: str) -> "Theme":
with open(path, "rt") as config_file:
return cls.from_file(config_file, source=path)

View file

@ -1,24 +1,5 @@
from .default_styles import DEFAULT_STYLES
from .theme import Theme
DEFAULT = Theme(
(255, 255, 255),
(0, 0, 0),
[
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
],
)
DEFAULT = Theme(DEFAULT_STYLES)

View file

@ -8,7 +8,7 @@ from rich.color import (
ColorTriplet,
)
from rich import themes
from rich import terminal_theme
import pytest
@ -28,16 +28,14 @@ def test_system() -> None:
def test_truecolor() -> None:
assert Color.parse("#ff0000").get_truecolor(themes.DEFAULT) == ColorTriplet(
255, 0, 0
assert Color.parse("#ff0000").get_truecolor() == ColorTriplet(255, 0, 0)
assert Color.parse("red").get_truecolor() == ColorTriplet(128, 0, 0)
assert Color.parse("1").get_truecolor() == ColorTriplet(128, 0, 0)
assert Color.parse("17").get_truecolor() == ColorTriplet(0, 0, 95)
assert Color.parse("default").get_truecolor() == ColorTriplet(0, 0, 0)
assert Color.parse("default").get_truecolor(foreground=False) == ColorTriplet(
255, 255, 255
)
assert Color.parse("red").get_truecolor(themes.DEFAULT) == ColorTriplet(128, 0, 0)
assert Color.parse("1").get_truecolor(themes.DEFAULT) == ColorTriplet(128, 0, 0)
assert Color.parse("17").get_truecolor(themes.DEFAULT) == ColorTriplet(0, 0, 95)
assert Color.parse("default").get_truecolor(themes.DEFAULT) == ColorTriplet(0, 0, 0)
assert Color.parse("default").get_truecolor(
themes.DEFAULT, foreground=False
) == ColorTriplet(255, 255, 255)
def test_parse_success() -> None: