Merge pull request #156 from willmcgugan/panel

panel title
This commit is contained in:
Will McGugan 2020-07-12 16:26:02 +01:00 committed by GitHub
commit f84d5dee6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 207 additions and 32 deletions

View file

@ -5,6 +5,16 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.3.0] - 2020-07-12
### Added
- Added title and title_align options to Panel
- Added pad and width parameters to Align
- Added end parameter to Rule
- Added Text.pad and Text.align methods
- Added leading parameter to Table
## [3.2.0] - 2020-07-10 ## [3.2.0] - 2020-07-10
### Added ### Added

View file

@ -2,7 +2,7 @@
name = "rich" name = "rich"
homepage = "https://github.com/willmcgugan/rich" homepage = "https://github.com/willmcgugan/rich"
documentation = "https://rich.readthedocs.io/en/latest/" documentation = "https://rich.readthedocs.io/en/latest/"
version = "3.2.0" version = "3.3.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
authors = ["Will McGugan <willmcgugan@gmail.com>"] authors = ["Will McGugan <willmcgugan@gmail.com>"]
license = "MIT" license = "MIT"

View file

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import Iterable, TYPE_CHECKING
from typing_extensions import Literal from typing_extensions import Literal
from .constrain import Constrain
from .jupyter import JupyterMixin from .jupyter import JupyterMixin
from .measure import Measurement from .measure import Measurement
from .segment import Segment from .segment import Segment
@ -20,13 +21,21 @@ class Align(JupyterMixin):
renderable (RenderableType): A console renderable. renderable (RenderableType): A console renderable.
align (AlignValues): One of "left", "center", or "right"" align (AlignValues): One of "left", "center", or "right""
style (StyleType, optional): An optional style to apply to the renderable. style (StyleType, optional): An optional style to apply to the renderable.
pad (bool, optional): Pad the right with spaces. Defaults to True.
width (int, optional): Restrict contents to given width, or None to use default width. Defaults to None.
Raises: Raises:
ValueError: if ``align`` is not one of the expected values. ValueError: if ``align`` is not one of the expected values.
""" """
def __init__( def __init__(
self, renderable: "RenderableType", align: AlignValues, style: StyleType = None self,
renderable: "RenderableType",
align: AlignValues,
style: StyleType = None,
*,
pad: bool = True,
width: int = None,
) -> None: ) -> None:
if align not in ("left", "center", "right"): if align not in ("left", "center", "right"):
raise ValueError( raise ValueError(
@ -35,35 +44,58 @@ class Align(JupyterMixin):
self.renderable = renderable self.renderable = renderable
self.align = align self.align = align
self.style = style self.style = style
self.pad = pad
self.width = width
@classmethod @classmethod
def left(cls, renderable: "RenderableType", style: StyleType = None) -> "Align": def left(
cls,
renderable: "RenderableType",
style: StyleType = None,
*,
pad: bool = True,
width: int = None,
) -> "Align":
"""Align a renderable to the left.""" """Align a renderable to the left."""
return cls(renderable, "left", style=style) return cls(renderable, "left", style=style, pad=pad, width=width)
@classmethod @classmethod
def center(cls, renderable: "RenderableType", style: StyleType = None) -> "Align": def center(
cls,
renderable: "RenderableType",
style: StyleType = None,
*,
pad: bool = True,
width: int = None,
) -> "Align":
"""Align a renderable to the center.""" """Align a renderable to the center."""
return cls(renderable, "center", style=style) return cls(renderable, "center", style=style, pad=pad, width=width)
@classmethod @classmethod
def right(cls, renderable: "RenderableType", style: StyleType = None) -> "Align": def right(
cls,
renderable: "RenderableType",
style: StyleType = None,
*,
pad: bool = True,
width: int = None,
) -> "Align":
"""Align a renderable to the right.""" """Align a renderable to the right."""
return cls(renderable, "right", style=style) return cls(renderable, "right", style=style, pad=pad, width=width)
def __rich_console__( def __rich_console__(
self, console: "Console", options: "ConsoleOptions" self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult": ) -> "RenderResult":
align = self.align align = self.align
rendered = console.render(self.renderable, options) rendered = console.render(Constrain(self.renderable, width=self.width), options)
lines = list(Segment.split_lines(rendered)) lines = list(Segment.split_lines(rendered))
width, height = Segment.get_shape(lines) width, height = Segment.get_shape(lines)
lines = Segment.set_shape(lines, width, height) lines = Segment.set_shape(lines, width, height)
new_line = Segment.line() new_line = Segment.line()
excess_space = options.max_width - width excess_space = options.max_width - width
def generate_segments(): def generate_segments() -> Iterable[Segment]:
if excess_space <= 0: if excess_space <= 0:
# Exact fit # Exact fit
for line in lines: for line in lines:
@ -72,17 +104,18 @@ class Align(JupyterMixin):
elif align == "left": elif align == "left":
# Pad on the right # Pad on the right
pad = Segment(" " * excess_space) pad = Segment(" " * excess_space) if self.pad else None
for line in lines: for line in lines:
yield from line yield from line
yield pad if pad:
yield pad
yield new_line yield new_line
elif align == "center": elif align == "center":
# Pad left and right # Pad left and right
left = excess_space // 2 left = excess_space // 2
pad = Segment(" " * left) pad = Segment(" " * left)
pad_right = Segment(" " * (excess_space - left)) pad_right = Segment(" " * (excess_space - left)) if self.pad else None
for line in lines: for line in lines:
if left: if left:
yield pad yield pad

View file

@ -81,7 +81,7 @@ class Box:
def get_row( def get_row(
self, self,
widths: Iterable[int], widths: Iterable[int],
level: Literal["head", "row", "foot"] = "row", level: Literal["head", "row", "foot", "mid"] = "row",
edge: bool = True, edge: bool = True,
) -> str: ) -> str:
"""Get the top of a simple box. """Get the top of a simple box.
@ -102,6 +102,11 @@ class Box:
horizontal = self.row_horizontal horizontal = self.row_horizontal
cross = self.row_cross cross = self.row_cross
right = self.row_right right = self.row_right
elif level == "mid":
left = self.mid_left
horizontal = " "
cross = self.mid_vertical
right = self.mid_right
elif level == "foot": elif level == "foot":
left = self.foot_row_left left = self.foot_row_left
horizontal = self.foot_row_horizontal horizontal = self.foot_row_horizontal

View file

@ -1,9 +1,11 @@
from typing import Optional from typing import Optional, TYPE_CHECKING
from .console import Console, ConsoleOptions, RenderableType, RenderResult
from .jupyter import JupyterMixin from .jupyter import JupyterMixin
from .measure import Measurement from .measure import Measurement
if TYPE_CHECKING:
from .console import Console, ConsoleOptions, RenderableType, RenderResult
class Constrain(JupyterMixin): class Constrain(JupyterMixin):
"""Constrain the width of a renderable to a given number of characters. """Constrain the width of a renderable to a given number of characters.
@ -13,20 +15,20 @@ class Constrain(JupyterMixin):
width (int, optional): The maximum width (in characters) to render. Defaults to 80. width (int, optional): The maximum width (in characters) to render. Defaults to 80.
""" """
def __init__(self, renderable: RenderableType, width: Optional[int] = 80) -> None: def __init__(self, renderable: "RenderableType", width: Optional[int] = 80) -> None:
self.renderable = renderable self.renderable = renderable
self.width = width self.width = width
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: "Console", options: "ConsoleOptions"
) -> RenderResult: ) -> "RenderResult":
if self.width is None: if self.width is None:
yield self.renderable yield self.renderable
else: else:
child_options = options.update(width=min(self.width, options.max_width)) child_options = options.update(width=min(self.width, options.max_width))
yield from console.render(self.renderable, child_options) yield from console.render(self.renderable, child_options)
def __rich_measure__(self, console: Console, max_width: int) -> Measurement: def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
if self.width is None: if self.width is None:
return Measurement.get(console, self.renderable, max_width) return Measurement.get(console, self.renderable, max_width)
else: else:

View file

@ -2,6 +2,7 @@ from typing import Optional, Union
from .box import get_safe_box, Box, SQUARE, ROUNDED from .box import get_safe_box, Box, SQUARE, ROUNDED
from .align import AlignValues
from .console import ( from .console import (
Console, Console,
ConsoleOptions, ConsoleOptions,
@ -12,7 +13,7 @@ from .console import (
from .jupyter import JupyterMixin from .jupyter import JupyterMixin
from .padding import Padding, PaddingDimensions from .padding import Padding, PaddingDimensions
from .style import Style, StyleType from .style import Style, StyleType
from .text import Text from .text import Text, TextType
from .segment import Segment from .segment import Segment
@ -40,6 +41,8 @@ class Panel(JupyterMixin):
renderable: RenderableType, renderable: RenderableType,
box: Box = ROUNDED, box: Box = ROUNDED,
*, *,
title: TextType = None,
title_align: AlignValues = "center",
safe_box: Optional[bool] = None, safe_box: Optional[bool] = None,
expand: bool = True, expand: bool = True,
style: StyleType = "none", style: StyleType = "none",
@ -49,6 +52,8 @@ class Panel(JupyterMixin):
) -> None: ) -> None:
self.renderable = renderable self.renderable = renderable
self.box = box self.box = box
self.title = title
self.title_align = title_align
self.safe_box = safe_box self.safe_box = safe_box
self.expand = expand self.expand = expand
self.style = style self.style = style
@ -62,6 +67,8 @@ class Panel(JupyterMixin):
renderable: RenderableType, renderable: RenderableType,
box: Box = ROUNDED, box: Box = ROUNDED,
*, *,
title: TextType = None,
title_align: AlignValues = "center",
safe_box: Optional[bool] = None, safe_box: Optional[bool] = None,
style: StyleType = "none", style: StyleType = "none",
border_style: StyleType = "none", border_style: StyleType = "none",
@ -72,6 +79,8 @@ class Panel(JupyterMixin):
return cls( return cls(
renderable, renderable,
box, box,
title=title,
title_align=title_align,
safe_box=safe_box, safe_box=safe_box,
style=style, style=style,
border_style=border_style, border_style=border_style,
@ -108,7 +117,24 @@ class Panel(JupyterMixin):
line_start = Segment(box.mid_left, border_style) line_start = Segment(box.mid_left, border_style)
line_end = Segment(f"{box.mid_right}", border_style) line_end = Segment(f"{box.mid_right}", border_style)
new_line = Segment.line() new_line = Segment.line()
yield Segment(box.get_top([width - 2]), border_style) if self.title is None:
yield Segment(box.get_top([width - 2]), border_style)
else:
title_text = (
Text.from_markup(self.title)
if isinstance(self.title, str)
else self.title.copy()
)
title_text.style = border_style
title_text.end = ""
title_text.plain = title_text.plain.replace("\n", " ")
title_text = title_text.tabs_to_spaces()
title_text.pad(1)
title_text.align(self.title_align, width - 4, character=box.top)
yield Segment(box.top_left + box.top, border_style)
yield title_text
yield Segment(box.top + box.top_right, border_style)
yield new_line yield new_line
for line in lines: for line in lines:
yield line_start yield line_start
@ -129,7 +155,7 @@ if __name__ == "__main__": # pragma: no cover
c = Console() c = Console()
from .padding import Padding from .padding import Padding
from .box import ROUNDED from .box import ROUNDED, DOUBLE
p = Panel( p = Panel(
Panel.fit( Panel.fit(
@ -138,7 +164,8 @@ if __name__ == "__main__": # pragma: no cover
safe_box=True, safe_box=True,
style="on red", style="on red",
), ),
border_style="on blue", title="[b]Hello, World",
box=DOUBLE,
) )
print(p) print(p)

View file

@ -8,11 +8,13 @@ from .text import Text
class Rule(JupyterMixin): class Rule(JupyterMixin):
"""A console renderable to draw a horizontal rule (line). r"""A console renderable to draw a horizontal rule (line).
Args: Args:
title (Union[str, Text], optional): Text to render in the rule. Defaults to "". title (Union[str, Text], optional): Text to render in the rule. Defaults to "".
character (str, optional): Character used to draw the line. Defaults to "". character (str, optional): Character used to draw the line. Defaults to "".
style (StyleType, optional): Style of Rule. Defaults to "rule.line".
end (str, optional): Character at end of Rule. defaults to "\\n"
""" """
def __init__( def __init__(
@ -21,12 +23,14 @@ class Rule(JupyterMixin):
*, *,
character: str = None, character: str = None,
style: Union[str, Style] = "rule.line", style: Union[str, Style] = "rule.line",
end: str = "\n",
) -> None: ) -> None:
if character and cell_len(character) != 1: if character and cell_len(character) != 1:
raise ValueError("'character' argument must have a cell width of 1") raise ValueError("'character' argument must have a cell width of 1")
self.title = title self.title = title
self.character = character self.character = character
self.style = style self.style = style
self.end = end
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Rule({self.title!r}, {self.character!r})" return f"Rule({self.title!r}, {self.character!r})"
@ -51,7 +55,7 @@ class Rule(JupyterMixin):
title_text.plain = title_text.plain.replace("\n", " ") title_text.plain = title_text.plain.replace("\n", " ")
title_text = title_text.tabs_to_spaces() title_text = title_text.tabs_to_spaces()
rule_text = Text() rule_text = Text(end=self.end)
center = (width - cell_len(title_text.plain)) // 2 center = (width - cell_len(title_text.plain)) // 2
rule_text.append(character * (center - 1) + " ", self.style) rule_text.append(character * (center - 1) + " ", self.style)
rule_text.append(title_text) rule_text.append(title_text)

View file

@ -109,6 +109,7 @@ class Table(JupyterMixin):
show_footer (bool, optional): Show a footer row. Defaults to False. show_footer (bool, optional): Show a footer row. Defaults to False.
show_edge (bool, optional): Draw a box around the outside of the table. Defaults to True. show_edge (bool, optional): Draw a box around the outside of the table. Defaults to True.
show_lines (bool, optional): Draw lines between every row. Defaults to False. show_lines (bool, optional): Draw lines between every row. Defaults to False.
leading (bool, optional): Number of blank lines between rows (precludes ``show_lines``). Defaults to 0.
style (Union[str, Style], optional): Default style for the table. Defaults to "none". style (Union[str, Style], optional): Default style for the table. Defaults to "none".
row_styles (List[Union, str], optional): Optional list of row styles, if more that one style is give then the styles will alternate. Defaults to None. row_styles (List[Union, str], optional): Optional list of row styles, if more that one style is give then the styles will alternate. Defaults to None.
header_style (Union[str, Style], optional): Style of the header. Defaults to None. header_style (Union[str, Style], optional): Style of the header. Defaults to None.
@ -136,6 +137,7 @@ class Table(JupyterMixin):
show_footer: bool = False, show_footer: bool = False,
show_edge: bool = True, show_edge: bool = True,
show_lines: bool = False, show_lines: bool = False,
leading: int = 0,
style: StyleType = "none", style: StyleType = "none",
row_styles: Iterable[StyleType] = None, row_styles: Iterable[StyleType] = None,
header_style: StyleType = None, header_style: StyleType = None,
@ -161,6 +163,7 @@ class Table(JupyterMixin):
self.show_footer = show_footer self.show_footer = show_footer
self.show_edge = show_edge self.show_edge = show_edge
self.show_lines = show_lines self.show_lines = show_lines
self.leading = leading
self.collapse_padding = collapse_padding self.collapse_padding = collapse_padding
self.style = style self.style = style
self.header_style = header_style self.header_style = header_style
@ -593,6 +596,7 @@ class Table(JupyterMixin):
show_footer = self.show_footer show_footer = self.show_footer
show_edge = self.show_edge show_edge = self.show_edge
show_lines = self.show_lines show_lines = self.show_lines
leading = self.leading
_Segment = Segment _Segment = Segment
if _box: if _box:
@ -685,15 +689,22 @@ class Table(JupyterMixin):
_box.get_row(widths, "head", edge=show_edge), border_style _box.get_row(widths, "head", edge=show_edge), border_style
) )
yield new_line yield new_line
if _box and show_lines: if _box and (show_lines or leading):
if ( if (
not last not last
and not (show_footer and index >= len(rows) - 2) and not (show_footer and index >= len(rows) - 2)
and not (show_header and header_row) and not (show_header and header_row)
): ):
yield _Segment( if leading:
_box.get_row(widths, "row", edge=show_edge), border_style for _ in range(leading):
) yield _Segment(
_box.get_row(widths, "mid", edge=show_edge),
border_style,
)
else:
yield _Segment(
_box.get_row(widths, "row", edge=show_edge), border_style
)
yield new_line yield new_line
if _box and show_edge: if _box and show_edge:

View file

@ -16,6 +16,7 @@ from typing import (
from ._loop import loop_last from ._loop import loop_last
from ._wrap import divide_line from ._wrap import divide_line
from .align import AlignValues
from .cells import cell_len, set_cell_size from .cells import cell_len, set_cell_size
from .containers import Lines from .containers import Lines
from .control import strip_control_codes from .control import strip_control_codes
@ -617,6 +618,15 @@ class Text(JupyterMixin):
append(span) append(span)
self._spans[:] = spans self._spans[:] = spans
def pad(self, count: int, character: str = " ") -> None:
"""Pad left and right with a given number of characters.
Args:
count (int): Width of padding.
"""
self.pad_left(count, character=character)
self.pad_right(count, character=character)
def pad_left(self, count: int, character: str = " ") -> None: def pad_left(self, count: int, character: str = " ") -> None:
"""Pad the left with a given character. """Pad the left with a given character.
@ -640,6 +650,26 @@ class Text(JupyterMixin):
if count: if count:
self.plain = f"{self.plain}{character * count}" self.plain = f"{self.plain}{character * count}"
def align(self, align: AlignValues, width: int, character: str = " ") -> None:
"""Align text to a given width.
Args:
align (AlignValues): One of "left", "center", or "right".
width (int): Desired width.
character (str, optional): Character to pad with. Defaults to " ".
"""
self.truncate(width)
excess_space = width - cell_len(self.plain)
if excess_space:
if align == "left":
self.pad_right(excess_space, character)
elif align == "center":
left = excess_space // 2
self.pad_left(left, character)
self.pad_right(excess_space - left, character)
else:
self.pad_left(excess_space, character)
def append( def append(
self, text: Union["Text", str], style: Union[str, "Style"] = None self, text: Union["Text", str], style: Union[str, "Style"] = None
) -> "Text": ) -> "Text":

View file

@ -64,6 +64,22 @@ def test_measure():
assert _max == 7 assert _max == 7
def test_align_no_pad():
console = Console(file=io.StringIO(), width=10)
console.print(Align("foo", "center", pad=False))
console.print(Align("foo", "left", pad=False))
assert console.file.getvalue() == " foo\nfoo\n"
def test_align_width():
console = Console(file=io.StringIO(), width=40)
words = "Deep in the human unconscious is a pervasive need for a logical universe that makes sense. But the real universe is always one step beyond logic"
console.print(Align(words, "center", width=30))
result = console.file.getvalue()
expected = " Deep in the human unconscious \n is a pervasive need for a \n logical universe that makes \n sense. But the real universe \n is always one step beyond \n logic \n"
assert result == expected
def test_shortcuts(): def test_shortcuts():
assert Align.left("foo").align == "left" assert Align.left("foo").align == "left"
assert Align.left("foo").renderable == "foo" assert Align.left("foo").renderable == "foo"

View file

@ -10,6 +10,7 @@ tests = [
Panel.fit("Hello, World"), Panel.fit("Hello, World"),
Panel("Hello, World", width=8), Panel("Hello, World", width=8),
Panel(Panel("Hello, World")), Panel(Panel("Hello, World")),
Panel("Hello, World", title="FOO"),
] ]
expected = [ expected = [
@ -18,6 +19,7 @@ expected = [
"╭────────────╮\n│Hello, World│\n╰────────────╯\n", "╭────────────╮\n│Hello, World│\n╰────────────╯\n",
"╭──────╮\n│Hello,│\n│World │\n╰──────╯\n", "╭──────╮\n│Hello,│\n│World │\n╰──────╯\n",
"╭────────────────────────────────────────────────╮\n│╭──────────────────────────────────────────────╮│\n││Hello, World ││\n│╰──────────────────────────────────────────────╯│\n╰────────────────────────────────────────────────╯\n", "╭────────────────────────────────────────────────╮\n│╭──────────────────────────────────────────────╮│\n││Hello, World ││\n│╰──────────────────────────────────────────────╯│\n╰────────────────────────────────────────────────╯\n",
"╭───────────────────── FOO ──────────────────────╮\n│Hello, World │\n╰────────────────────────────────────────────────╯\n",
] ]

File diff suppressed because one or more lines are too long

View file

@ -517,3 +517,27 @@ def test_truncate_ellipsis_pad(input, count, expected):
text = Text(input) text = Text(input)
text.truncate(count, overflow="ellipsis", pad=True) text.truncate(count, overflow="ellipsis", pad=True)
assert text.plain == expected assert text.plain == expected
def test_pad():
test = Text("foo")
test.pad(2)
assert test.plain == " foo "
def test_align_left():
test = Text("foo")
test.align("left", 10)
assert test.plain == "foo "
def test_align_right():
test = Text("foo")
test.align("right", 10)
assert test.plain == " foo"
def test_align_center():
test = Text("foo")
test.align("center", 10)
assert test.plain == " foo "