rich measure

This commit is contained in:
Will McGugan 2021-03-25 21:08:36 +00:00
parent 27379a8f1a
commit be5e9dca2d
43 changed files with 301 additions and 185 deletions

View file

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed Layout.split to use new Splitter class
- Improved layout.tree
- Changed default theme color for repr.number to cyan
- `__rich_measure__` signature changed to accept ConsoleOptions rather than max_width
### Added
@ -29,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added Layout.add_split, Layout.split_column, Layout.split_row, layout.refresh
- Added new Rich repr protocol `__rich_repr__`
### Fixed
- Fixed table style taking precedence over row style https://github.com/willmcgugan/rich/issues/1129
- Fixed incorrect measurement of Text with new lines and whitespace https://github.com/willmcgugan/rich/issues/1133
## [9.13.0] - 2021-03-06
### Added

View file

@ -18,25 +18,22 @@ To define a layout, construct a Layout object and print it::
layout = Layout()
print(layout)
This will draw a box the size of the terminal with some information regarding the layout. The box is a "placeholder" because we have yet to add any content to it. Before we do that, let's create a more interesting layout by calling the :meth:`~rich.layout.Layout.split` method to divide the layout in to two sub-layouts::
This will draw a box the size of the terminal with some information regarding the layout. The box is a "placeholder" because we have yet to add any content to it. Before we do that, let's create a more interesting layout by calling the :meth:`~rich.layout.Layout.split_column` method to divide the layout in to two sub-layouts::
layout.split(
layout.split_column(
Layout(name="upper"),
Layout(name="lower")
)
print(layout)
This will divide the terminal screen in to two equal sized portions, one on top of the other. The ``name`` attribute is an internal identifier we can use to look up the sub-layout later. Let's use that to create another split::
This will divide the terminal screen in to two equal sized portions, one on top of the other. The ``name`` attribute is an internal identifier we can use to look up the sub-layout later. Let's use that to create another split, this time we will call :meth:`~rich.layout.Layout.split_row` to split the lower layout in to a row of two sub-layouts.
layout["lower"].split(
layout["lower"].split_row(
Layout(name="left"),
Layout(name="right"),
direction="horizontal"
Layout(name="right"),
)
print(layout)
The addition of the ``direction="horizontal"`` tells the Layout class to split left-to-right, rather than the default of top-to-bottom.
You should now see the screen area divided in to 3 portions; an upper half and a lower half that is split in to two quarters.
.. raw:: html

View file

@ -64,10 +64,10 @@ For complete control over how a custom object is rendered to the terminal, you c
Measuring Renderables
~~~~~~~~~~~~~~~~~~~~~
Sometimes Rich needs to know how many characters an object will take up when rendering. The :class:`~rich.table.Table` class, for instance, will use this information to calculate the optimal dimensions for the columns. If you aren't using one of the renderable objects in the Rich module, you will need to supply a ``__rich_measure__`` method which accepts a :class:`~rich.console.Console` and the maximum width and returns a :class:`~rich.measure.Measurement` object. The Measurement object should contain the *minimum* and *maximum* number of characters required to render.
Sometimes Rich needs to know how many characters an object will take up when rendering. The :class:`~rich.table.Table` class, for instance, will use this information to calculate the optimal dimensions for the columns. If you aren't using one of the renderable objects in the Rich module, you will need to supply a ``__rich_measure__`` method which accepts a :class:`~rich.console.Console` and :class:`~rich.console.ConsoleOptions` and returns a :class:`~rich.measure.Measurement` object. The Measurement object should contain the *minimum* and *maximum* number of characters required to render.
For example, if we are rendering a chess board, it would require a minimum of 8 characters to render. The maximum can be left as the maximum available width (assuming a centered board)::
class ChessBoard:
def __rich_measure__(self, console: Console, max_width: int) -> Measurement:
return Measurement(8, max_width)
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
return Measurement(8, options.max_width)

View file

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

View file

@ -30,8 +30,10 @@ class ColorBox:
yield Segment("", Style(color=color, bgcolor=bgcolor))
yield Segment.line()
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
return Measurement(1, max_width)
def __rich_measure__(
self, console: "Console", options: ConsoleOptions
) -> Measurement:
return Measurement(1, options.max_width)
def make_test_card() -> Table:

View file

@ -133,7 +133,7 @@ class Align(JupyterMixin):
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
align = self.align
width = Measurement.get(console, self.renderable).maximum
width = Measurement.get(console, options, self.renderable).maximum
rendered = console.render(
Constrain(
self.renderable, width if self.width is None else min(width, self.width)
@ -221,8 +221,10 @@ class Align(JupyterMixin):
iter_segments = Segment.apply_style(iter_segments, style)
yield from iter_segments
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
measurement = Measurement.get(console, self.renderable, max_width)
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
measurement = Measurement.get(console, options, self.renderable)
return measurement
@ -275,8 +277,10 @@ class VerticalCenter(JupyterMixin):
if bottom_space > 0:
yield from blank_lines(bottom_space)
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
measurement = Measurement.get(console, self.renderable, max_width)
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
measurement = Measurement.get(console, options, self.renderable)
return measurement

View file

@ -49,7 +49,10 @@ class Bar(JupyterMixin):
self, console: Console, options: ConsoleOptions
) -> RenderResult:
width = min(self.width or options.max_width, options.max_width)
width = min(
self.width if self.width is not None else options.max_width,
options.max_width,
)
if self.begin >= self.end:
yield Segment(" " * width, self.style)
@ -81,9 +84,11 @@ class Bar(JupyterMixin):
yield Segment(prefix + body[len(prefix) :] + suffix, self.style)
yield Segment.line()
def __rich_measure__(self, console: Console, max_width: int) -> Measurement:
def __rich_measure__(
self, console: Console, options: ConsoleOptions
) -> Measurement:
return (
Measurement(self.width, self.width)
if self.width is not None
else Measurement(4, max_width)
else Measurement(4, options.max_width)
)

View file

@ -77,7 +77,7 @@ class Columns(JupyterMixin):
get_measurement = Measurement.get
renderable_widths = [
get_measurement(console, renderable, max_width).maximum
get_measurement(console, options, renderable).maximum
for renderable in renderables
]
if self.equal:

View file

@ -127,6 +127,8 @@ class ConsoleOptions:
"""Disable wrapping for text."""
highlight: Optional[bool] = None
"""Highlight override for render_str."""
markup: Optional[bool] = None
"""Enable markup when rendering strings."""
height: Optional[int] = None
"""Height available, or None for no height limit."""
@ -155,6 +157,7 @@ class ConsoleOptions:
overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE,
no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE,
highlight: Union[Optional[bool], NoChange] = NO_CHANGE,
markup: Union[Optional[bool], NoChange] = NO_CHANGE,
height: Union[Optional[int], NoChange] = NO_CHANGE,
) -> "ConsoleOptions":
"""Update values, return a copy."""
@ -173,6 +176,8 @@ class ConsoleOptions:
options.no_wrap = no_wrap
if not isinstance(highlight, NoChange):
options.highlight = highlight
if not isinstance(markup, NoChange):
options.markup = markup
if not isinstance(height, NoChange):
options.height = None if height is None else max(0, height)
return options
@ -405,11 +410,13 @@ class RenderGroup:
self._render = list(self._renderables)
return self._render
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
if self.fit:
return measure_renderables(console, self.renderables, max_width)
return measure_renderables(console, options, self.renderables)
else:
return Measurement(max_width, max_width)
return Measurement(options.max_width, options.max_width)
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
@ -1105,7 +1112,9 @@ class Console:
if hasattr(renderable, "__rich_console__"):
render_iterable = renderable.__rich_console__(self, _options) # type: ignore
elif isinstance(renderable, str):
text_renderable = self.render_str(renderable, highlight=_options.highlight)
text_renderable = self.render_str(
renderable, highlight=_options.highlight, markup=_options.markup
)
render_iterable = text_renderable.__rich_console__(self, _options) # type: ignore
else:
raise errors.NotRenderableError(
@ -1467,9 +1476,10 @@ class Console:
render_options = self.options.update(
justify=justify,
overflow=overflow,
width=min(width, self.width) if width else NO_CHANGE,
width=min(width, self.width) if width is not None else NO_CHANGE,
height=height,
no_wrap=no_wrap,
markup=markup,
)
new_segments: List[Segment] = []

View file

@ -28,8 +28,10 @@ class Constrain(JupyterMixin):
child_options = options.update(width=min(self.width, options.max_width))
yield from console.render(self.renderable, child_options)
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
if self.width is not None:
max_width = min(self.width, max_width)
measurement = Measurement.get(console, self.renderable, max_width)
max_width = min(self.width, options.max_width)
measurement = Measurement.get(console, options, self.renderable)
return measurement

View file

@ -39,9 +39,11 @@ class Renderables:
"""Console render method to insert line-breaks."""
yield from self._renderables
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
dimensions = [
Measurement.get(console, renderable, max_width)
Measurement.get(console, options, renderable)
for renderable in self._renderables
]
if not dimensions:

View file

@ -27,8 +27,9 @@ CONTROL_CODES_FORMAT: Dict[int, Callable] = {
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.CURSOR_MOVE_TO_ROW: lambda param: f"\x1b[{param+1}G",
ControlType.ERASE_IN_LINE: lambda param: f"\x1b[{param}K",
ControlType.CURSOR_MOVE_TO: lambda x, y: f"\x1b[{y};{x}H",
ControlType.CURSOR_MOVE_TO: lambda x, y: f"\x1b[{y+1};{x+1}H",
}
@ -40,7 +41,7 @@ class Control:
tuple of ControlType and an integer parameter
"""
__slots__ = ["_segment"]
__slots__ = ["segment"]
def __init__(self, *codes: Union[ControlType, ControlCode]) -> None:
control_codes: List[ControlCode] = [
@ -50,11 +51,7 @@ class Control:
rendered_codes = "".join(
_format_map[code](*parameters) for code, *parameters in control_codes
)
self._segment = Segment(rendered_codes, None, control_codes)
@property
def segment(self) -> "Segment":
return self._segment
self.segment = Segment(rendered_codes, None, control_codes)
@classmethod
def bell(cls) -> "Control":
@ -67,7 +64,7 @@ class Control:
return cls(ControlType.HOME)
@classmethod
def move(cls, x: int, y: int) -> "Control":
def move(cls, x: int = 0, y: int = 0) -> "Control":
"""Move cursor relative to current position.
Args:
@ -84,18 +81,41 @@ class Control:
if x:
yield (
control.CURSOR_FORWARD if x > 0 else control.CURSOR_BACKWARD,
x,
abs(x),
)
if y:
yield (
control.CURSOR_DOWN if y > 0 else control.CURSOR_UP,
y,
abs(y),
)
control = cls(*get_codes())
return control
@classmethod
def move_to_row(cls, x: int, y: int = 0) -> "Control":
"""Move to the given row, optionally add offset to column.
Returns:
x (int): absolute x (column)
y (int): optional y offset (row)
Returns:
~Control: Control object.
"""
return (
cls(
(ControlType.CURSOR_MOVE_TO_ROW, x + 1),
(
ControlType.CURSOR_DOWN if y > 0 else ControlType.CURSOR_UP,
abs(y),
),
)
if y
else cls((ControlType.CURSOR_MOVE_TO_ROW, x))
)
@classmethod
def move_to(cls, x: int, y: int) -> "Control":
"""Move cursor to absolute position.
@ -107,7 +127,7 @@ class Control:
Returns:
~Control: Control object.
"""
return cls((ControlType.CURSOR_MOVE_TO, x + 1, y + 1))
return cls((ControlType.CURSOR_MOVE_TO, x, y))
@classmethod
def clear(cls) -> "Control":
@ -128,12 +148,12 @@ class Control:
return cls(ControlType.DISABLE_ALT_SCREEN)
def __str__(self) -> str:
return self._segment.text
return self.segment.text
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
yield self._segment
yield self.segment
def strip_control_codes(text: str, _translate_table=_CONTROL_TRANSLATE) -> str:

View file

@ -4,7 +4,6 @@ from operator import itemgetter
from threading import RLock
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
@ -17,7 +16,6 @@ from typing import (
from typing_extensions import Literal
from ._loop import loop_last
from ._ratio import ratio_resolve
from .align import Align
from .console import Console, ConsoleOptions, RenderableType, RenderResult
@ -327,7 +325,7 @@ class Layout:
with self._lock:
self._renderable = renderable
def refresh(self, console: "Console", layout_name: str) -> None:
def refresh_screen(self, console: "Console", layout_name: str) -> None:
"""Refresh a sub-layout.
Args:
@ -376,7 +374,7 @@ class Layout:
RenderMap: A dict that maps Layout on to a tuple of Region, lines
"""
render_width = options.max_width
render_height = options.height or 1
render_height = options.height or console.height
region_map = self._make_region_map(render_width, render_height)
layout_regions = [
(layout, region)
@ -402,7 +400,7 @@ class Layout:
height = options.height or console.height
render_map = self.render(console, options.update_dimensions(width, height))
self._render_map = render_map
layout_lines: List[List[Segment]] = [[] for _ in range(options.height or 1)]
layout_lines: List[List[Segment]] = [[] for _ in range(height)]
_islice = islice
for (region, lines) in render_map.values():
_x, y, _layout_width, layout_height = region
@ -442,19 +440,4 @@ if __name__ == "__main__": # type: ignore
layout["content"].update("foo")
from rich.live import Live
from time import sleep
from rich import print
from rich.pretty import Pretty
# l = Layout()
# # l.split(Layout(), Layout())
# print(l.tree)
with Live(layout, console=console, screen=True, refresh_per_second=1) as live:
for n in range(100):
layout["top"].update("[on red]" + str(n))
sleep(0.1)
layout.refresh(console, "top")
console.print(layout)

View file

@ -5,7 +5,7 @@ from . import errors
from .protocol import is_renderable
if TYPE_CHECKING:
from .console import Console, RenderableType
from .console import Console, ConsoleOptions, RenderableType
class Measurement(NamedTuple):
@ -75,15 +75,14 @@ class Measurement(NamedTuple):
@classmethod
def get(
cls, console: "Console", renderable: "RenderableType", max_width: int = None
cls, console: "Console", options: "ConsoleOptions", renderable: "RenderableType"
) -> "Measurement":
"""Get a measurement for a renderable.
Args:
console (~rich.console.Console): Console instance.
options (~rich.console.ConsoleOptions): Console options.
renderable (RenderableType): An object that may be rendered with Rich.
max_width (int, optional): The maximum width available, or None to use console.width.
Defaults to None.
Raises:
errors.NotRenderableError: If the object is not renderable.
@ -91,20 +90,18 @@ class Measurement(NamedTuple):
Returns:
Measurement: Measurement object containing range of character widths required to render the object.
"""
_max_width = console.width if max_width is None else max_width
_max_width = options.max_width
if _max_width < 1:
return Measurement(0, 0)
if isinstance(renderable, str):
renderable = console.render_str(renderable)
renderable = console.render_str(renderable, markup=options.markup)
if hasattr(renderable, "__rich__"):
renderable = renderable.__rich__() # type: ignore
if is_renderable(renderable):
get_console_width = getattr(renderable, "__rich_measure__", None)
if get_console_width is not None:
render_width = (
get_console_width(console, _max_width)
get_console_width(console, options)
.normalize()
.with_maximum(_max_width)
)
@ -121,7 +118,9 @@ class Measurement(NamedTuple):
def measure_renderables(
console: "Console", renderables: Iterable["RenderableType"], max_width: int
console: "Console",
options: "ConsoleOptions",
renderables: Iterable["RenderableType"],
) -> "Measurement":
"""Get a measurement that would fit a number of renderables.
@ -138,7 +137,7 @@ def measure_renderables(
return Measurement(0, 0)
get_measurement = Measurement.get
measurements = [
get_measurement(console, renderable, max_width) for renderable in renderables
get_measurement(console, options, renderable) for renderable in renderables
]
measured_width = Measurement(
max(measurements, key=itemgetter(0)).minimum,

View file

@ -84,7 +84,7 @@ class Padding(JupyterMixin):
width = options.max_width
else:
width = min(
Measurement.get(console, self.renderable, options.max_width).maximum
Measurement.get(console, options, self.renderable).maximum
+ self.left
+ self.right,
options.max_width,
@ -120,13 +120,14 @@ class Padding(JupyterMixin):
blank_line = blank_line or [_Segment(f'{" " * width}\n', style)]
yield from blank_line * self.bottom
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
max_width = options.max_width
extra_width = self.left + self.right
if max_width - extra_width < 1:
return Measurement(max_width, max_width)
measure_min, measure_max = Measurement.get(
console, self.renderable, max(0, max_width - extra_width)
)
measure_min, measure_max = Measurement.get(console, options, self.renderable)
measurement = Measurement(measure_min + extra_width, measure_max + extra_width)
measurement = measurement.with_maximum(max_width)
return measurement

View file

@ -133,7 +133,9 @@ class Panel(JupyterMixin):
child_width = (
width - 2
if self.expand
else Measurement.get(console, renderable, width - 2).maximum
else Measurement.get(
console, options.update_width(width - 2), renderable
).maximum
)
child_height = self.height or options.height or None
if child_height:
@ -169,7 +171,9 @@ class Panel(JupyterMixin):
yield Segment(box.get_bottom([width - 2]), border_style)
yield new_line
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
_title = self._title
_, right, _, left = Padding.unpack(self.padding)
padding = left + right
@ -178,7 +182,9 @@ class Panel(JupyterMixin):
if self.width is None:
width = (
measure_renderables(
console, renderables, max_width - padding - 2
console,
options.update_width(options.max_width - padding - 2),
renderables,
).maximum
+ padding
+ 2

View file

@ -212,10 +212,12 @@ class Pretty:
yield ""
yield pretty_text
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
pretty_str = pretty_repr(
self._object,
max_width=max_width,
max_width=options.max_width,
indent_size=self.indent_size,
max_length=self.max_length,
max_string=self.max_string,
@ -449,24 +451,26 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No
py_version = (sys.version_info.major, sys.version_info.minor)
children: List[Node]
def iter_tokens(tokens) -> Iterable[Union[Any, Tuple[str, Any]]]:
for token in tokens:
if isinstance(token, tuple):
if len(token) == 3:
key, child, default = token
def iter_rich_args(rich_args) -> Iterable[Union[Any, Tuple[str, Any]]]:
for arg in rich_args:
if isinstance(arg, tuple):
if len(arg) == 3:
key, child, default = arg
if default == child:
continue
yield key, child
elif len(token) == 2:
key, child = token
elif len(arg) == 2:
key, child = arg
yield key, child
elif len(arg) == 1:
yield arg[0]
else:
yield token
yield arg
if hasattr(obj, "__rich_repr__"):
tokens = list(iter_tokens(obj.__rich_repr__()))
args = list(iter_rich_args(obj.__rich_repr__()))
if tokens:
if args:
children = []
append = children.append
node = Node(
@ -475,9 +479,9 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No
children=children,
last=root,
)
for last, token in loop_last(tokens):
if isinstance(token, tuple):
key, child = token
for last, arg in loop_last(args):
if isinstance(arg, tuple):
key, child = arg
child_node = _traverse(child)
child_node.last = last
child_node.key_repr = key
@ -485,7 +489,7 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No
child_node.key_separator = "="
append(child_node)
else:
child_node = _traverse(token)
child_node = _traverse(arg)
child_node.last = last
append(child_node)
else:

View file

@ -190,11 +190,13 @@ class ProgressBar(JupyterMixin):
if remaining_bars:
yield _Segment(bar * remaining_bars, style)
def __rich_measure__(self, console: Console, max_width: int) -> Measurement:
def __rich_measure__(
self, console: Console, options: ConsoleOptions
) -> Measurement:
return (
Measurement(self.width, self.width)
if self.width is not None
else Measurement(4, max_width)
else Measurement(4, options.max_width)
)

View file

@ -13,14 +13,17 @@ def rich_repr(cls: Type[T]) -> Type[T]:
def auto_repr(self) -> str:
repr_str: List[str] = []
append = repr_str.append
for token in self.__rich_repr__():
if isinstance(token, tuple):
key, value, *default = token
if len(default) and default[0] == value:
continue
append(f"{key}={value!r}")
for arg in self.__rich_repr__():
if isinstance(arg, tuple):
if len(arg) == 1:
append(repr(arg[0]))
else:
key, value, *default = arg
if len(default) and default[0] == value:
continue
append(f"{key}={value!r}")
else:
append(repr(token))
append(repr(arg))
return f"{self.__class__.__name__}({', '.join(repr_str)})"
auto_repr.__doc__ = "Return repr(self)"

View file

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
from .measure import Measurement
from .segment import Segment
from .style import StyleType
from ._loop import loop_last

View file

@ -25,8 +25,9 @@ class ControlType(IntEnum):
CURSOR_DOWN = 10
CURSOR_FORWARD = 11
CURSOR_BACKWARD = 12
CURSOR_MOVE_TO = 13
ERASE_IN_LINE = 14
CURSOR_MOVE_TO_ROW = 13
CURSOR_MOVE_TO = 14
ERASE_IN_LINE = 15
ControlCode = Union[

View file

@ -46,9 +46,11 @@ class Spinner:
text = self.render(time - self.start_time)
yield text
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
text = self.render(0)
return Measurement.get(console, text, max_width)
return Measurement.get(console, options, text)
def render(self, time: float) -> Text:
"""Render the spinner for a given time.

View file

@ -28,8 +28,10 @@ class Styled:
segments = Segment.apply_style(rendered_segments, style)
return segments
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
return Measurement.get(console, self.renderable, max_width)
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
return Measurement.get(console, options, self.renderable)
if __name__ == "__main__": # pragma: no cover

View file

@ -468,11 +468,13 @@ class Syntax(JupyterMixin):
highlight_number_style = background_style + Style(dim=False)
return background_style, number_style, highlight_number_style
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
if self.code_width is not None:
width = self.code_width + self._numbers_column_width
return Measurement(self._numbers_column_width, width)
return Measurement(self._numbers_column_width, max_width)
return Measurement(self._numbers_column_width, options.max_width)
def __rich_console__(
self, console: Console, options: ConsoleOptions

View file

@ -281,18 +281,26 @@ class Table(JupyterMixin):
style += console.get_style(row_style)
return style
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
max_width = options.max_width
if self.width is not None:
max_width = self.width
if max_width < 0:
return Measurement(0, 0)
extra_width = self._extra_width
max_width = sum(self._calculate_column_widths(console, max_width - extra_width))
max_width = sum(
self._calculate_column_widths(
console, options.update_width(max_width - extra_width)
)
)
_measure_column = self._measure_column
measurements = [
_measure_column(console, column, max_width) for column in self.columns
_measure_column(console, options.update_width(max_width), column)
for column in self.columns
]
minimum_width = (
sum(measurement.minimum for measurement in measurements) + extra_width
@ -428,7 +436,9 @@ class Table(JupyterMixin):
max_width = self.width
extra_width = self._extra_width
widths = self._calculate_column_widths(console, max_width - extra_width)
widths = self._calculate_column_widths(
console, options.update_width(max_width - extra_width)
)
table_width = sum(widths) + extra_width
render_options = options.update(
@ -461,11 +471,14 @@ class Table(JupyterMixin):
justify=self.caption_justify,
)
def _calculate_column_widths(self, console: "Console", max_width: int) -> List[int]:
def _calculate_column_widths(
self, console: "Console", options: "ConsoleOptions"
) -> List[int]:
"""Calculate the widths of each column, including padding, not including borders."""
max_width = options.max_width
columns = self.columns
width_ranges = [
self._measure_column(console, column, max_width) for column in columns
self._measure_column(console, options, column) for column in columns
]
widths = [_range.maximum or 1 for _range in width_ranges]
get_padding_width = self._get_padding_width
@ -504,7 +517,7 @@ class Table(JupyterMixin):
table_width = sum(widths)
width_ranges = [
self._measure_column(console, column, width)
self._measure_column(console, options.update_width(width), column)
for width, column in zip(widths, columns)
]
widths = [_range.maximum or 0 for _range in width_ranges]
@ -609,7 +622,7 @@ class Table(JupyterMixin):
column.header_style
)
_append((header_style, column.header))
cell_style = get_style(self.style or "") + get_style(column.style or "")
cell_style = get_style(column.style or "")
for cell in column.cells:
_append((cell_style, cell))
if self.show_footer:
@ -635,10 +648,14 @@ class Table(JupyterMixin):
return pad_left + pad_right
def _measure_column(
self, console: "Console", column: Column, max_width: int
self,
console: "Console",
options: "ConsoleOptions",
column: Column,
) -> Measurement:
"""Get the minimum and maximum width of the column."""
max_width = options.max_width
if max_width < 1:
return Measurement(0, 0)
@ -656,7 +673,7 @@ class Table(JupyterMixin):
append_max = max_widths.append
get_render_width = Measurement.get
for cell in self._get_cells(console, column._index, column):
_min, _max = get_render_width(console, cell.renderable, max_width)
_min, _max = get_render_width(console, options, cell.renderable)
append_min(_min)
append_max(_max)

View file

@ -525,12 +525,16 @@ class Text(JupyterMixin):
all_lines = Text("\n").join(lines)
yield from all_lines.render(console, end=self.end)
def __rich_measure__(self, console: "Console", max_width: int) -> Measurement:
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
text = self.plain
if not text.strip():
return Measurement(cell_len(text), cell_len(text))
max_text_width = max(cell_len(line) for line in text.splitlines())
min_text_width = max(cell_len(word) for word in text.split())
lines = text.splitlines()
max_text_width = max(cell_len(line) for line in lines) if lines else 0
words = text.split()
min_text_width = (
max(cell_len(word) for word in words) if words else max_text_width
)
return Measurement(min_text_width, max_text_width)
def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:

View file

@ -158,7 +158,9 @@ class Tree(JupyterMixin):
guide_style_stack.push(get_style(node.guide_style))
push(iter(loop_last(node.children)))
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> "Measurement":
stack: List[Iterator[Tree]] = [iter([self])]
pop = stack.pop
push = stack.append
@ -174,7 +176,7 @@ class Tree(JupyterMixin):
level -= 1
continue
push(iter_tree)
min_measure, max_measure = measure(console, tree.label, max_width)
min_measure, max_measure = measure(console, options, tree.label)
indent = level * 4
minimum = max(min_measure + indent, minimum)
maximum = max(max_measure + indent, maximum)

View file

@ -107,7 +107,7 @@ def test_align_right_style():
def test_measure():
console = Console(file=io.StringIO(), width=20)
_min, _max = Measurement.get(console, Align("foo bar", "left"), 20)
_min, _max = Measurement.get(console, console.options, Align("foo bar", "left"))
assert _min == 3
assert _max == 7
@ -147,4 +147,6 @@ def test_vertical_center():
print(repr(result))
expected = " \n \nfoo\n \n \n \n"
assert result == expected
assert Measurement.get(console, vertical_center) == Measurement(3, 3)
assert Measurement.get(console, console.options, vertical_center) == Measurement(
3, 3
)

View file

@ -1,3 +1,4 @@
from rich.console import Console
from rich.progress_bar import ProgressBar
from rich.segment import Segment
from rich.style import Style
@ -39,8 +40,9 @@ def test_render():
def test_measure():
console = Console(width=120)
bar = ProgressBar()
measurement = bar.__rich_measure__(None, 120)
measurement = bar.__rich_measure__(console, console.options)
assert measurement.minimum == 4
assert measurement.maximum == 120

View file

@ -1,4 +1,5 @@
from rich.bar import Bar
from rich.console import Console
from .render import render
@ -29,8 +30,9 @@ def test_render():
def test_measure():
console = Console(width=120)
bar = Bar(size=100, begin=11, end=62)
measurement = bar.__rich_measure__(None, 120)
measurement = bar.__rich_measure__(console, console.options)
assert measurement.minimum == 4
assert measurement.maximum == 120

View file

@ -475,7 +475,7 @@ def test_render_group() -> None:
renderables = [renderable() for _ in range(4)]
console = Console(width=42)
min_width, _ = measure_renderables(console, renderables, 42)
min_width, _ = measure_renderables(console, console.options, renderables)
assert min_width == 42
@ -491,7 +491,7 @@ def test_render_group_fit() -> None:
console = Console(width=42)
min_width, _ = measure_renderables(console, renderables, 42)
min_width, _ = measure_renderables(console, console.options, renderables)
assert min_width == 5
@ -633,3 +633,17 @@ def test_update_screen_lines():
console = Console(force_terminal=True, width=20, height=5)
with pytest.raises(errors.NoAltScreen):
console.update_screen_lines([])
def test_update_options_markup():
console = Console()
options = console.options
assert options.update(markup=False).markup == False
assert options.update(markup=True).markup == True
def test_print_width_zero():
console = Console()
with console.capture() as capture:
console.print("Hello", width=0)
assert capture.get() == ""

View file

@ -6,6 +6,8 @@ from rich.text import Text
def test_width_of_none():
console = Console()
constrain = Constrain(Text("foo"), width=None)
min_width, max_width = constrain.__rich_measure__(console, 80)
min_width, max_width = constrain.__rich_measure__(
console, console.options.update_width(80)
)
assert min_width == 3
assert max_width == 3

View file

@ -9,7 +9,7 @@ def test_renderables_measure():
text = Text("foo")
renderables = Renderables([text])
result = renderables.__rich_measure__(console, console.width)
result = renderables.__rich_measure__(console, console.options)
_min, _max = result
assert _min == 3
assert _max == 3
@ -21,7 +21,7 @@ def test_renderables_empty():
console = Console()
renderables = Renderables()
result = renderables.__rich_measure__(console, console.width)
result = renderables.__rich_measure__(console, console.options)
_min, _max = result
assert _min == 1
assert _max == 1

View file

@ -17,7 +17,7 @@ def test_control_move_to():
control = Control.move_to(5, 10)
print(control.segment)
assert control.segment == Segment(
"\x1b[11;6H", None, [(ControlType.CURSOR_MOVE_TO, 6, 11)]
"\x1b[11;6H", None, [(ControlType.CURSOR_MOVE_TO, 5, 10)]
)
@ -30,3 +30,12 @@ def test_control_move():
None,
[(ControlType.CURSOR_FORWARD, 3), (ControlType.CURSOR_DOWN, 4)],
)
def test_move_to_row():
print(repr(Control.move_to_row(10, 20).segment))
assert Control.move_to_row(10, 20).segment == Segment(
"\x1b[12G\x1b[20B",
None,
[(ControlType.CURSOR_MOVE_TO_ROW, 11), (ControlType.CURSOR_DOWN, 20)],
)

View file

@ -76,3 +76,17 @@ def test_tree():
expected = "⬍ Layout(name='root') \n├── ⬍ Layout(size=2) \n└── ⬌ Layout(name='bar') \n ├── ⬍ Layout() \n └── ⬍ Layout() \n"
assert result == expected
def test_refresh_screen():
layout = Layout()
layout.split_row(Layout(name="foo"), Layout(name="bar"))
console = Console(force_terminal=True, width=20, height=5)
console.print(layout)
with console.screen():
with console.capture() as capture:
layout.refresh_screen(console, "foo")
result = capture.get()
print(repr(result))
expected = "\x1b[1;1H\x1b[34m╭─\x1b[0m\x1b[34m \x1b[0m\x1b[32m'foo'\x1b[0m\x1b[34m─╮\x1b[0m\x1b[2;1H\x1b[34m│\x1b[0m Layout \x1b[34m│\x1b[0m\x1b[3;1H\x1b[34m│\x1b[0m \x1b[1m(\x1b[0m \x1b[34m│\x1b[0m\x1b[4;1H\x1b[34m│\x1b[0m \x1b[33mna\x1b[0m \x1b[34m│\x1b[0m\x1b[5;1H\x1b[34m╰────────╯\x1b[0m"
assert result == expected

View file

@ -16,21 +16,15 @@ def test_no_renderable():
text = Text()
with pytest.raises(NotRenderableError):
Measurement.get(console, None, console.width)
def test_null_get():
# Test negative console.width passed into get method
assert Measurement.get(Console(width=-1), None) == Measurement(0, 0)
# Test negative max_width passed into get method
assert Measurement.get(Console(), None, -1) == Measurement(0, 0)
Measurement.get(console, console.options, None)
def test_measure_renderables():
# Test measure_renderables returning a null Measurement object
assert measure_renderables(Console(), None, None) == Measurement(0, 0)
# Test measure_renderables returning a valid Measurement object
assert measure_renderables(Console(width=1), ["test"], 1) == Measurement(1, 1)
console = Console()
assert measure_renderables(console, console.options, "") == Measurement(0, 0)
assert measure_renderables(
console, console.options.update_width(0), "hello"
) == Measurement(0, 0)
def test_clamp():

View file

@ -38,7 +38,7 @@ def test_render_panel(panel, expected):
def test_console_width():
console = Console(file=io.StringIO(), width=50, legacy_windows=False)
panel = Panel("Hello, World", expand=False)
min_width, max_width = panel.__rich_measure__(console, 50)
min_width, max_width = panel.__rich_measure__(console, console.options)
assert min_width == 16
assert max_width == 16
@ -46,7 +46,7 @@ def test_console_width():
def test_fixed_width():
console = Console(file=io.StringIO(), width=50, legacy_windows=False)
panel = Panel("Hello World", width=20)
min_width, max_width = panel.__rich_measure__(console, 100)
min_width, max_width = panel.__rich_measure__(console, console.options)
assert min_width == 20
assert max_width == 20

View file

@ -4,18 +4,21 @@ from rich.repr import rich_repr
@rich_repr
class Foo:
def __init__(self, foo: str, bar: int = None):
def __init__(self, foo: str, bar: int = None, egg: int = 1):
self.foo = foo
self.bar = bar
self.egg = egg
def __rich_repr__(self):
yield self.foo
yield self.foo,
yield "bar", self.bar, None
yield "egg", self.egg
def test_rich_repr() -> None:
assert (repr(Foo("hello"))) == "Foo('hello')"
assert (repr(Foo("hello", bar=3))) == "Foo('hello', bar=3)"
assert (repr(Foo("hello"))) == "Foo('hello', 'hello', egg=1)"
assert (repr(Foo("hello", bar=3))) == "Foo('hello', 'hello', bar=3, egg=1)"
def test_rich_pretty() -> None:
@ -23,5 +26,5 @@ def test_rich_pretty() -> None:
with console.capture() as capture:
console.print(Foo("hello", bar=3))
result = capture.get()
expected = "Foo('hello', bar=3)\n"
expected = "Foo('hello', 'hello', bar=3, egg=1)\n"
assert result == expected

View file

@ -37,6 +37,6 @@ def test_spinner_render():
def test_rich_measure():
console = Console(width=80, color_system=None, force_terminal=True)
spinner = Spinner("dots", "Foo")
min_width, max_width = Measurement.get(console, spinner, 80)
min_width, max_width = Measurement.get(console, console.options, spinner)
assert min_width == 3
assert max_width == 5

View file

@ -8,7 +8,7 @@ from rich.styled import Styled
def test_styled():
styled_foo = Styled("foo", "on red")
console = Console(file=io.StringIO(), force_terminal=True, _environ={})
assert Measurement.get(console, styled_foo, 80) == Measurement(3, 3)
assert Measurement.get(console, console.options, styled_foo) == Measurement(3, 3)
console.print(styled_foo)
result = console.file.getvalue()
expected = "\x1b[41mfoo\x1b[0m\n"

View file

@ -28,9 +28,9 @@ def render_tables():
table.add_row("Averlongwordgoeshere", "banana pancakes", None)
assert Measurement.get(console, table, 80) == Measurement(41, 48)
assert Measurement.get(console, console.options, table) == Measurement(41, 48)
table.expand = True
assert Measurement.get(console, table, 80) == Measurement(41, 48)
assert Measurement.get(console, console.options, table) == Measurement(41, 48)
for width in range(10, 60, 5):
console.print(table, width=width)
@ -64,9 +64,9 @@ def render_tables():
console.print(table)
table.width = 20
assert Measurement.get(console, table, 80) == Measurement(20, 20)
assert Measurement.get(console, console.options, table) == Measurement(20, 20)
table.expand = False
assert Measurement.get(console, table, 80) == Measurement(20, 20)
assert Measurement.get(console, console.options, table) == Measurement(20, 20)
table.expand = True
console.print(table)
@ -113,28 +113,24 @@ def test_init_append_column():
def test_rich_measure():
# Check __rich_measure__() for a negative width passed as an argument
assert Table("test_header", width=None).__rich_measure__(
Console(), -1
console = Console()
assert Table("test_header", width=-1).__rich_measure__(
console, console.options
) == Measurement(0, 0)
# Check __rich_measure__() for a negative Table.width attribute
assert Table("test_header", width=-1).__rich_measure__(Console(), 1) == Measurement(
0, 0
)
# Check __rich_measure__() for a positive width passed as an argument
assert Table("test_header", width=None).__rich_measure__(
Console(), 10
) == Measurement(10, 10)
# Check __rich_measure__() for a positive Table.width attribute
assert Table("test_header", width=10).__rich_measure__(
Console(), -1
console, console.options.update_width(10)
) == Measurement(10, 10)
def test_min_width():
table = Table("foo", min_width=30)
table.add_row("bar")
assert table.__rich_measure__(Console(), 100) == Measurement(30, 30)
console = Console()
assert table.__rich_measure__(
console, console.options.update_width(100)
) == Measurement(30, 30)
console = Console(color_system=None)
console.begin_capture()
console.print(table)

View file

@ -238,6 +238,7 @@ def test_console_width():
test = Text("Hello World!\nfoobarbaz")
assert test.__rich_measure__(console, 80) == Measurement(9, 12)
assert Text(" " * 4).__rich_measure__(console, 80) == Measurement(4, 4)
assert Text(" \n \n ").__rich_measure__(console, 80) == Measurement(3, 3)
def test_join():

View file

@ -99,5 +99,5 @@ def test_tree_measure():
tree.add("bar")
tree.add("musroom risotto")
console = Console()
measurement = Measurement.get(console, tree)
measurement = Measurement.get(console, console.options, tree)
assert measurement == Measurement(11, 19)