Vertical overflow of table in live display should scroll the content
Asked Answered
G

1

6

I'm using a Live display to show the content of a Table which grows over time. Eventually there is a vertical overflow and in that case I'd like the oldest (i.e. topmost) rows to vanish while the most recent rows should be shown together with the header, i.e. the content should be scrolled. The vertical_overflow parameter of the live display provides a "visible" option, but this makes the header of the table vanish. Obviously this is a Table specific issue, since the header should stay but the content should be scrolled.

import time
from rich.live import Live
from rich.table import Table

table = Table()
table.add_column('Time')
table.add_column('Message')

with Live(table, refresh_per_second=5, vertical_overflow='visible'):
    for i in range(100):
        time.sleep(0.2)
        table.add_row(time.asctime(), f'Event {i:03d}')

The left part shows the behavior with vertical_overflow='visible' and the right part shows the desired behavior:

Example

So far I'm using a workaround with a separate data structure to hold the rows and then creating the table from scratch every time a new row is to be added. This doesn't seem to be very efficient, so I'm wondering if there's a better solution. This workaround also fails for multi-line rows as it counts them as a single row (hence overflow will occur).

from collections import deque
import os
import time
from rich.live import Live
from rich.table import Table


def generate_table(rows):
    table = Table()
    table.add_column('Time')
    table.add_column('Message')
    for row in rows:
        table.add_row(*row)
    return table


width, height = os.get_terminal_size()
messages = deque(maxlen=height-4)  # save space for header and footer

with Live(generate_table(messages), refresh_per_second=5) as live:
    for i in range(100):
        time.sleep(0.2)
        messages.append((time.asctime(), f'Event {i:03d}'))
        live.update(generate_table(messages))
Gainful answered 24/2, 2021 at 16:46 Comment(0)
M
2

I was working on the same thing recently and couldn't find a built-in solution either. Since you're rendering a live display, the table won't have more than ~100 rows, so efficiency should not be a concern.

Here is my solution. It repeatedly removes rows from the top until the table fits. This is measured by putting a table into a Layout which truncates the table at the bottom if it does not fit.

from collections import deque
import os
import time
from rich.live import Live
from rich.table import Table
from rich.layout import Layout
from rich.console import Console


def generate_table(rows):
    layout = Layout()
    console = Console()

    table = Table()
    table.add_column('Time')
    table.add_column('Message')

    rows = list(rows)

    # This would also get the height:
    # render_map = layout.render(console, console.options)
    # render_map[layout].region.height
    n_rows = os.get_terminal_size()[1]

    while n_rows >= 0:
        table = Table()
        table.add_column('Time')
        table.add_column('Message')

        for row in rows[-n_rows:]:
            table.add_row(*row)

        layout.update(table)

        render_map = layout.render(console, console.options)

        if len(render_map[layout].render[-1]) > 2:
            # The table is overflowing
            n_rows -= 1
        else:
            break

    return table


width, height = os.get_terminal_size()
messages = deque(maxlen=height-4)  # save space for header and footer

with Live(generate_table(messages), refresh_per_second=5) as live:
    for i in range(100):
        time.sleep(0.2)
        messages.append((time.asctime(), f'Event {i:03d}'))
        live.update(generate_table(messages))

The magic line here is if len(render_map[layout].render[-1]) > 2:. It's a hacky way to tell if the table is being printed in its entirety. If it is, the last element of render_map[layout].render will look like

[
    Segment('└──────────────────────────┘', Style()),
    Segment('                                                                         ',)
]

or like

[
    Segment(
        '
',
    )
]

but if it is truncated it will look like

[
    Segment('│', Style()),
    Segment(' ', Style()),
    Segment(
        '37',
        Style(color=Color('cyan', ColorType.STANDARD, number=6), bold=True, italic=False)
    ),
    Segment('                      ', Style()),
    Segment(' ', Style()),
    Segment('│', Style()),
    Segment('                                                                         ',)
]
Manifestative answered 29/3, 2022 at 13:26 Comment(2)
This is a clever solution. It feels somewhat hacky, but it definitely solves the problem. And since it only uses rich's public API it seems reliable/stable too.Gainful
I agree it's hacky - I guess the basic issue is that Rich doesn't know in advance how large the table is going to be. I would guess more robust solutions also exist, but the basic idea would still be "try rendering it and see".Manifestative

© 2022 - 2024 — McMap. All rights reserved.