diff --git a/CHANGELOG.md b/CHANGELOG.md index d7854b3e..f813ffeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added ascii_only to ConsoleOptions - Addded box.SQUARE_DOUBLE_HEAD - Added highlighting of EUI-48 and EUI-64 (MAC addresses) +- Added Console.pager ### Changed diff --git a/docs/source/console.rst b/docs/source/console.rst index 5d8d824f..a3c690c7 100644 --- a/docs/source/console.rst +++ b/docs/source/console.rst @@ -210,6 +210,25 @@ An alternative way of capturing output is to set the Console file to a :py:class console.print("[bold red]Hello[/] World") str_output = console.file.getvalue() +Paging +------ + +If you have some long output to present to the user you can use a *pager* to display it. A pager is typically an application on by your operating system which will at least support pressing a key to scroll, but will often support scrolling up and down through the text and other features. + +You can page output from a Console by calling :meth:`~rich.console.Console.pager` which returns a context manger. When the pager exits, anything that was printed will be sent to the pager. Here's an example:: + + from rich.__main__ import make_test_card + from rich.console import Console + + console = Console() + with console.pager(): + console.print(make_test_card()) + +Since the default pager on most platforms don't support color, Rich will strip color from the output. If you know that your pager supports color, you can set ``style=True`` when calling the :meth:`~rich.console.Console.pager` method. + +.. note:: + Rich will use the ``PAGER`` environment variable to get the pager command. On Linux and macOS you can set this to ``less -r`` to enable paging with ANSI styles. + Terminal detection ------------------ diff --git a/rich/console.py b/rich/console.py index f9f7ba80..64d01092 100644 --- a/rich/console.py +++ b/rich/console.py @@ -35,6 +35,7 @@ from .control import Control from .highlighter import NullHighlighter, ReprHighlighter from .markup import render as render_markup from .measure import Measurement, measure_renderables +from .pager import Pager, SystemPager from .pretty import Pretty from .scope import render_scope from .segment import Segment @@ -205,6 +206,40 @@ class ThemeContext: self.console.pop_theme() +class PagerContext: + """A context manager that 'pages' content. See :meth:`~rich.console.Console.pager` for usage.""" + + def __init__( + self, + console: "Console", + pager: Pager = None, + styles: bool = False, + links: bool = False, + ) -> None: + self._console = console + self.pager = SystemPager() if pager is None else pager + self.styles = styles + self.links = links + + def __enter__(self) -> "PagerContext": + self._console._enter_buffer() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if exc_type is None: + with self._console._lock: + buffer: List[Segment] = self._console._buffer[:] + del self._console._buffer[:] + segments: Iterable[Segment] = buffer + if not self.styles: + segments = Segment.strip_styles(segments) + elif not self.links: + segments = Segment.strip_links(segments) + content = self._console._render_buffer(segments) + self.pager.show(content) + self._console._exit_buffer() + + class RenderGroup: """Takes a group of renderables and returns a renderable object that renders the group. @@ -523,7 +558,7 @@ class Console: Returns: str: Console output. """ - render_result = self._render_buffer() + render_result = self._render_buffer(self._buffer) del self._buffer[:] self._exit_buffer() return render_result @@ -667,6 +702,29 @@ class Console: capture = Capture(self) return capture + def pager( + self, pager: Pager = None, styles: bool = False, links: bool = False + ) -> PagerContext: + """A context manager to display anything printed within a "pager". The pager used + is defined by the system and will typically support at less pressing a key to scroll. + + Args: + pager (Pager, optional): A pager object, or None to use :class:~rich.pager.SystemPager`. Defaults to None. + styles (bool, optional): Show styles in pager. Defaults to False. + links (bool, optional): Show links in pager. Defaults to False. + + Example: + >>> from rich.console import Console + >>> from rich.__main__ import make_test_card + >>> console = Console() + >>> with console.pager(): + console.print(make_test_card()) + + Returns: + PagerContext: A context manager. + """ + return PagerContext(self, pager=pager, styles=styles, links=links) + def line(self, count: int = 1) -> None: """Write new line(s). @@ -1154,7 +1212,8 @@ class Console: display(self._buffer) del self._buffer[:] else: - text = self._render_buffer() + text = self._render_buffer(self._buffer[:]) + del self._buffer[:] if text: try: if WINDOWS: # pragma: no cover @@ -1169,17 +1228,15 @@ class Console: error.reason = f"{error.reason}\n*** You may need to add PYTHONIOENCODING=utf-8 to your environment ***" raise - def _render_buffer(self) -> str: + def _render_buffer(self, buffer: Iterable[Segment]) -> str: """Render buffered output, and clear buffer.""" output: List[str] = [] append = output.append color_system = self._color_system legacy_windows = self.legacy_windows - buffer = self._buffer[:] if self.record: with self._record_buffer_lock: self._record_buffer.extend(buffer) - del self._buffer[:] not_terminal = not self.is_terminal for text, style, is_control in buffer: if style and not is_control: diff --git a/rich/pager.py b/rich/pager.py new file mode 100644 index 00000000..52e77ef6 --- /dev/null +++ b/rich/pager.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +import pydoc + + +class Pager(ABC): + """Base class for a pager.""" + + @abstractmethod + def show(self, content: str) -> None: + """Show content in pager. + + Args: + content (str): Content to be displayed. + """ + + +class SystemPager(Pager): + """Uses the pager installed on the system.""" + + _pager = lambda self, content: pydoc.pager(content) + + def show(self, content: str) -> None: + """Use the same pager used by pydoc.""" + self._pager(content) + + +if __name__ == "__main__": # pragma: no cover + from .__main__ import make_test_card + from .console import Console + + console = Console() + with console.pager(styles=True): + console.print(make_test_card()) diff --git a/rich/segment.py b/rich/segment.py index 21c91321..3222f3d2 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -300,6 +300,30 @@ class Segment(NamedTuple): last_segment = segment yield last_segment + @classmethod + def strip_links(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Remove all links from an iterable of styles. + + Yields: + Segment: Segments with link removed. + """ + for segment in segments: + if segment.is_control or segment.style is None: + yield segment + else: + text, style, _is_control = segment + yield cls(text, style.update_link(None) if style else None) + + @classmethod + def strip_styles(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Remove all styles from an iterable of segments. + + Yields: + Segment: Segments with styles replace with None + """ + for text, _style, is_control in segments: + yield cls(text, None, is_control) + if __name__ == "__main__": # pragma: no cover lines = [[Segment("Hello")]] diff --git a/rich/style.py b/rich/style.py index a67e2c67..4eca6361 100644 --- a/rich/style.py +++ b/rich/style.py @@ -522,6 +522,28 @@ class Style: style._null = False return style + def update_link(self, link: str = None) -> "Style": + """Get a copy with a different value for link. + + Args: + link (str, optional): New value for link. Defaults to None. + + Returns: + Style: A new Style instance. + """ + style = self.__new__(Style) + style._ansi = self._ansi + style._style_definition = self._style_definition + style._color = self._color + style._bgcolor = self._bgcolor + style._attributes = self._attributes + style._set_attributes = self._set_attributes + style._link = link + style._link_id = f"{time()}-{randint(0, 999999)}" if link else "" + style._hash = self._hash + style._null = False + return style + def render( self, text: str = "", diff --git a/rich/table.py b/rich/table.py index 0ff4c49d..1745d700 100644 --- a/rich/table.py +++ b/rich/table.py @@ -171,7 +171,7 @@ class Table(JupyterMixin): self.footer_style = footer_style self.border_style = border_style self.title_style = title_style - self.caption_style = title_style + self.caption_style = caption_style self.title_justify = title_justify self.caption_justify = caption_justify self._row_count = 0 @@ -241,12 +241,6 @@ class Table(JupyterMixin): def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: if self.width is not None: max_width = self.width - - # if self.box: - # max_width -= len(self.columns) - 1 - # if self.show_edge: - # max_width -= 2 - if max_width < 0: return Measurement(0, 0) diff --git a/tests/test_console.py b/tests/test_console.py index 3df8e7ce..eca30009 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -2,12 +2,14 @@ import io import os import sys import tempfile +from typing import Optional import pytest +from rich import errors from rich.color import ColorSystem from rich.console import CaptureError, Console, ConsoleOptions -from rich import errors +from rich.pager import SystemPager from rich.panel import Panel from rich.style import Style @@ -346,3 +348,25 @@ def test_bell() -> None: console.begin_capture() console.bell() assert console.end_capture() == "\x07" + + +def test_pager() -> None: + console = Console() + + pager_content: Optional[str] = None + + def mock_pager(content: str) -> None: + nonlocal pager_content + pager_content = content + + pager = SystemPager() + pager._pager = mock_pager + + with console.pager(pager): + console.print("[bold]Hello World") + assert pager_content == "Hello World\n" + + with console.pager(pager, styles=True, links=False): + console.print("[bold link https:/example.org]Hello World") + + assert pager_content == "Hello World\n" diff --git a/tests/test_segment.py b/tests/test_segment.py index c5e24995..2c3344dd 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -86,3 +86,13 @@ def test_filter_control(): assert list(Segment.filter_control(segments, is_control=True)) == [ Segment("bar", is_control=True) ] + + +def test_strip_styles(): + segments = [Segment("foo", Style(bold=True))] + assert list(Segment.strip_styles(segments)) == [Segment("foo", None)] + + +def test_strip_links(): + segments = [Segment("foo", Style(bold=True, link="https://www.example.org"))] + assert list(Segment.strip_links(segments)) == [Segment("foo", Style(bold=True))]