Skip to content

permit nested live #3768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed

- Live objects (including Progress) may now be nested https://github.com/Textualize/rich/pull/3768

## [14.0.0] - 2025-03-30

### Added
Expand Down
13 changes: 5 additions & 8 deletions docs/source/live.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Live Display
============

Progress bars and status indicators use a *live* display to animate parts of the terminal. You can build custom live displays with the :class:`~rich.live.Live` class.
Progress bars and status indicators use a *live* display to animate parts of the terminal. You can build custom live displays with the :class:`~rich.live.Live` class.

For a demonstration of a live display, run the following command::

Expand Down Expand Up @@ -72,15 +72,15 @@ You can also change the renderable on-the-fly by calling the :meth:`~rich.live.L
Alternate screen
~~~~~~~~~~~~~~~~

You can opt to show a Live display in the "alternate screen" by setting ``screen=True`` on the constructor. This will allow your live display to go full screen and restore the command prompt on exit.
You can opt to show a Live display in the "alternate screen" by setting ``screen=True`` on the constructor. This will allow your live display to go full screen and restore the command prompt on exit.

You can use this feature in combination with :ref:`Layout` to display sophisticated terminal "applications".

Transient display
~~~~~~~~~~~~~~~~~

Normally when you exit live context manager (or call :meth:`~rich.live.Live.stop`) the last refreshed item remains in the terminal with the cursor on the following line.
You can also make the live display disappear on exit by setting ``transient=True`` on the Live constructor.
You can also make the live display disappear on exit by setting ``transient=True`` on the Live constructor.

Auto refresh
~~~~~~~~~~~~
Expand Down Expand Up @@ -149,13 +149,10 @@ This feature is enabled by default, but you can disable by setting ``redirect_st
Nesting Lives
-------------

Note that only a single live context may be active at any one time. The following will raise a :class:`~rich.errors.LiveError` because status also uses Live::
If you create a Live instance within the context of an existing Live instance, then the content of the inner Live will be displayed below the outer Live.

with Live(table, console=console):
with console.status("working"): # Will not work
do_work()
Prior to version 14.0.0 this would have resulted in a :class:`~rich.errors.LiveError` exception.

In practice this is rarely a problem because you can display any combination of renderables in a Live context.

Examples
--------
Expand Down
22 changes: 22 additions & 0 deletions docs/source/progress.rst
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,28 @@ If you expect to be reading from multiple files, you can use :meth:`~rich.progre
See `cp_progress.py <https://github.com/willmcgugan/rich/blob/master/examples/cp_progress.py>`_ for a minimal clone of the ``cp`` command which shows a progress bar as the file is copied.


Nesting Progress bars
---------------------

If you create a new progress bar within the context of an existing progress bar (with the context manager or `track` function), then Rich will display the inner progress bar(s) under the initial bar.

Here's an example that nests progress bars::

from rich.progress import track
from time import sleep


for count in track(range(10)):
for letter in track("ABCDEF", transient=True):
print(f"Stage {count}{letter}")
sleep(0.1)
sleep(0.1)

The inner loop creates a new progress bar below the first, but both can update.

Note that if you nest progress bars like this, then the nested bars will updating according to the `refresh_per_second` attribute of the outer bar.


Multiple Progress
-----------------

Expand Down
252 changes: 147 additions & 105 deletions poetry.lock

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions rich/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ def __init__(
)
self._record_buffer: List[Segment] = []
self._render_hooks: List[RenderHook] = []
self._live: Optional["Live"] = None
self._live_stack: List[Live] = []
self._is_alt_screen = False

def __repr__(self) -> str:
Expand Down Expand Up @@ -823,24 +823,26 @@ def _exit_buffer(self) -> None:
self._buffer_index -= 1
self._check_buffer()

def set_live(self, live: "Live") -> None:
"""Set Live instance. Used by Live context manager.
def set_live(self, live: "Live") -> bool:
"""Set Live instance. Used by Live context manager (no need to call directly).

Args:
live (Live): Live instance using this Console.

Returns:
Boolean that indicates if the live is the topmost of the stack.

Raises:
errors.LiveError: If this Console has a Live context currently active.
"""
with self._lock:
if self._live is not None:
raise errors.LiveError("Only one live display may be active at once")
self._live = live
self._live_stack.append(live)
return len(self._live_stack) == 1

def clear_live(self) -> None:
"""Clear the Live instance."""
"""Clear the Live instance. Used by the Live context manager (no need to call directly)."""
with self._lock:
self._live = None
self._live_stack.pop()

def push_render_hook(self, hook: RenderHook) -> None:
"""Add a new render hook to the stack.
Expand Down
29 changes: 24 additions & 5 deletions rich/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import IO, Any, Callable, List, Optional, TextIO, Type, cast

from . import get_console
from .console import Console, ConsoleRenderable, RenderableType, RenderHook
from .console import Console, ConsoleRenderable, Group, RenderableType, RenderHook
from .control import Control
from .file_proxy import FileProxy
from .jupyter import JupyterMixin
Expand Down Expand Up @@ -87,6 +87,7 @@ def __init__(
self._live_render = LiveRender(
self.get_renderable(), vertical_overflow=vertical_overflow
)
self._nested = False

@property
def is_started(self) -> bool:
Expand All @@ -110,8 +111,12 @@ def start(self, refresh: bool = False) -> None:
with self._lock:
if self._started:
return
self.console.set_live(self)
self._started = True

if not self.console.set_live(self):
self._nested = True
return

if self._screen:
self._alt_screen = self.console.set_alt_screen(True)
self.console.show_cursor(False)
Expand All @@ -136,8 +141,12 @@ def stop(self) -> None:
with self._lock:
if not self._started:
return
self.console.clear_live()
self._started = False
self.console.clear_live()
if self._nested:
if not self.transient:
self.console.print(self.renderable)
return

if self.auto_refresh and self._refresh_thread is not None:
self._refresh_thread.stop()
Expand All @@ -156,7 +165,6 @@ def stop(self) -> None:
self.console.show_cursor(True)
if self._alt_screen:
self.console.set_alt_screen(False)

if self.transient and not self._alt_screen:
self.console.control(self._live_render.restore_cursor())
if self.ipy_widget is not None and self.transient:
Expand Down Expand Up @@ -200,7 +208,13 @@ def renderable(self) -> RenderableType:
Returns:
RenderableType: Displayed renderable.
"""
renderable = self.get_renderable()
live_stack = self.console._live_stack
renderable: RenderableType
if live_stack and self is live_stack[0]:
# The first Live instance will render everything in the Live stack
renderable = Group(*[live.get_renderable() for live in live_stack])
else:
renderable = self.get_renderable()
return Screen(renderable) if self._alt_screen else renderable

def update(self, renderable: RenderableType, *, refresh: bool = False) -> None:
Expand All @@ -221,6 +235,11 @@ def refresh(self) -> None:
"""Update the display of the Live Render."""
with self._lock:
self._live_render.set_renderable(self.renderable)
if self._nested:
if self.console._live_stack:
self.console._live_stack[0].refresh()
return

if self.console.is_jupyter: # pragma: no cover
try:
from IPython.display import display
Expand Down
8 changes: 0 additions & 8 deletions tests/test_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,14 +713,6 @@ def test_quiet() -> None:
assert console.file.getvalue() == ""


def test_no_nested_live() -> None:
console = Console()
with pytest.raises(errors.LiveError):
with console.status("foo"):
with console.status("bar"):
pass


@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
def test_screen() -> None:
console = Console(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_live_state() -> None:
assert live._started
live.start()

assert live.renderable == ""
assert live.get_renderable() == ""

assert live._started
live.stop()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def test_inline_code():
inline_code_theme="emacs",
)
result = render(markdown)
expected = "inline \x1b[1;38;2;170;34;255;48;2;248;248;248mimport\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m code \n"
expected = "inline \x1b[1;38;2;170;34;255;48;2;248;248;248mimport\x1b[0m\x1b[38;2;187;187;187;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m code \n"
print(result)
print(repr(result))
assert result == expected
Expand Down
4 changes: 2 additions & 2 deletions tests/test_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_blank_lines():
print(repr(result))
assert (
result
== "\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m1 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m2 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m3 \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mimport\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m4 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m5 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n"
== "\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m1 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m2 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m3 \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mimport\x1b[0m\x1b[38;2;187;187;187;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m4 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m5 \x1b[0m\x1b[48;2;248;248;248m \x1b[0m\n"
)


Expand Down Expand Up @@ -119,7 +119,7 @@ def test_python_render_simple_indent_guides():
)
rendered_syntax = render(syntax)
print(repr(rendered_syntax))
expected = '\x1b[34mdef\x1b[0m \x1b[32mloop_first_last\x1b[0m(values: Iterable[T]) -> Iterable[Tuple[\x1b[36mb\x1b[0m\n\x1b[2;37m│ \x1b[0m\x1b[33m"""Iterate and generate a tuple with a flag for first an\x1b[0m\n\x1b[2m│ \x1b[0miter_values = \x1b[36miter\x1b[0m(values)\n\x1b[2m│ \x1b[0m\x1b[34mtry\x1b[0m:\n\x1b[2m│ │ \x1b[0mprevious_value = \x1b[36mnext\x1b[0m(iter_values)\n\x1b[2m│ \x1b[0m\x1b[34mexcept\x1b[0m \x1b[36mStopIteration\x1b[0m:\n\x1b[2m│ │ \x1b[0m\x1b[34mreturn\x1b[0m\n\x1b[2m│ \x1b[0mfirst = \x1b[34mTrue\x1b[0m\n\x1b[2m│ \x1b[0m\x1b[34mfor\x1b[0m value \x1b[35min\x1b[0m iter_values:\n\x1b[2m│ │ \x1b[0m\x1b[34myield\x1b[0m first, \x1b[34mFalse\x1b[0m, previous_value\n\x1b[2m│ │ \x1b[0mfirst = \x1b[34mFalse\x1b[0m\n\x1b[2m│ │ \x1b[0mprevious_value = value\n\x1b[2m│ \x1b[0m\x1b[34myield\x1b[0m first, \x1b[34mTrue\x1b[0m, previous_value\n'
expected = '\x1b[34mdef\x1b[0m\x1b[37m \x1b[0m\x1b[32mloop_first_last\x1b[0m(values: Iterable[T]) -> Iterable[Tuple[\x1b[36mb\x1b[0m\n\x1b[2;37m│ \x1b[0m\x1b[33m"""Iterate and generate a tuple with a flag for first an\x1b[0m\n\x1b[2m│ \x1b[0miter_values = \x1b[36miter\x1b[0m(values)\n\x1b[2m│ \x1b[0m\x1b[34mtry\x1b[0m:\n\x1b[2m│ │ \x1b[0mprevious_value = \x1b[36mnext\x1b[0m(iter_values)\n\x1b[2m│ \x1b[0m\x1b[34mexcept\x1b[0m \x1b[36mStopIteration\x1b[0m:\n\x1b[2m│ │ \x1b[0m\x1b[34mreturn\x1b[0m\n\x1b[2m│ \x1b[0mfirst = \x1b[34mTrue\x1b[0m\n\x1b[2m│ \x1b[0m\x1b[34mfor\x1b[0m value \x1b[35min\x1b[0m iter_values:\n\x1b[2m│ │ \x1b[0m\x1b[34myield\x1b[0m first, \x1b[34mFalse\x1b[0m, previous_value\n\x1b[2m│ │ \x1b[0mfirst = \x1b[34mFalse\x1b[0m\n\x1b[2m│ │ \x1b[0mprevious_value = value\n\x1b[2m│ \x1b[0m\x1b[34myield\x1b[0m first, \x1b[34mTrue\x1b[0m, previous_value\n'
assert rendered_syntax == expected


Expand Down
Loading