Skip to content
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

Feature Request: Integration with Package "Rich" #26

Closed
whisller opened this issue Jun 20, 2021 · 27 comments
Closed

Feature Request: Integration with Package "Rich" #26

whisller opened this issue Jun 20, 2021 · 27 comments

Comments

@whisller
Copy link
Contributor

Was wondering how easy/hard would be to integrate your project with rich : >

@piccolomo
Copy link
Owner

piccolomo commented Jun 21, 2021

Hi @whisller,

I have an easy reply: I have no idea :-)

I was not aware of that package, which seems really cool actually.

I am not even sure if it is a good idea and how to do it.

If there are other request or someone is interesting in integrating it, why not? :-)

Is there any reason in particular you think it would be a good idea?

Thanks for your message,
Savino

@whisller
Copy link
Contributor Author

So generally I am super interested in integrating those two libraries. For two reasons, rich is really good when it comes to prepare dashboards in CLI in python, and plotext is nicest plotting library I found for python so far, which works natively in CLI rather than generates images.

Problem that I noticed when tried to make those two to work sadly is...it doesn't work ;) I believe there would have to be some sort of wrapper that would handle block sizes, resize, same as format that is accepted by rich.

In my opinion that integration could boost both libraries, as that's something which is missing in python environment.

@piccolomo
Copy link
Owner

piccolomo commented Jun 22, 2021

I would be interested in helping, if it won't be a massive work. So ye I am available. but keep in mind i know nothing about the internal architecture of rich.

For now one thing that could help if you haven't seen already is the get_canvas() function in my library, which outputs the plot in string form and could help inserting it into a rich wrapper. Use it after show(hide = True), with option hide set to True.

Let me know, please, or write me privately at piccolomo@gmail.com
Thanks, Savino

@willmcgugan
Copy link

Author of Rich here. I think this is a fantastic idea, and I would be happy to help!

@whisller
Copy link
Contributor Author

@piccolomo @willmcgugan awesome! I think this integration would benefit greatly both sides :)

I will try to find some time to sit and investigate, but realistically speaking it will not be earlier than after few weeks from now (most likely I will be with my partner on labour ward in next few hours/days ;)).

@willmcgugan
Copy link

That was one of the few excuses I would accept. ;-) Congratulations in advance, @whisller !

@piccolomo
Copy link
Owner

@willmcgugan,

let's do it then! [obviously after @whisller fatherhood priorities :-) ]

@whisller
Copy link
Contributor Author

@willmcgugan @piccolomo I have first "working" draft, at least plot is being semi displayed ;)

from time import sleep

from rich.console import Console
from rich.console import ConsoleOptions, RenderResult
from rich.jupyter import JupyterMixin
from rich.layout import Layout
import plotext as plt
from rich.live import Live
from rich.panel import Panel
from rich.segment import Segment


class PlotextIntegration(JupyterMixin):
    def __init__(self, figure):
        self._figure = figure

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        for row in self._figure.subplot.matrix:
            line = ""
            for character in row:
                line += character[0]
            yield Segment(line)
            yield Segment.line()


def make_layout():
    layout = Layout(name="root")
    layout.split(
        Layout(name="header", size=3),
        Layout(name="main", ratio=1),
    )
    layout["main"].split_row(
        Layout(name="main_left"),
        Layout(name="main_right"),
    )

    return layout


def make_plot():
    plt.scatter(plt.sin(100, 3))
    plt.plotsize(50, 50)
    plt.title("Plot Example")
    plt.show(True)
    from plotext.plot import _fig
    return _fig


layout = make_layout()
layout["main_left"].update(Panel(PlotextIntegration(make_plot())))
with Live(layout, refresh_per_second=0.1) as live:
    while True:
        sleep(0.1)

Few questions/obstacles that come to my mind:

  • Multiple plots within same dashboard. Seems that plotext uses few global variables.
  • Accessing width/height of Panel/Window from within rich to set it to plotex before rendering. I assume that might not be possible and we would need some monkey patching here.
  • Setting up title/colour/other attributes should be easier. Some monkey patching might be required

@whisller
Copy link
Contributor Author

whisller commented Jul 3, 2021

@piccolomo it seems that design of plotext was not intended to display multiple charts on single window. E.g. shared access to _fig. Is it something you would have a time to change?

@piccolomo
Copy link
Owner

piccolomo commented Jul 4, 2021

Hi @whisller,

thanks a lot for the message. I have been looking at your example and I have these considerations:

  • It will be ideal if one could get the exact size of the layout one wants to plot in. In this way one could use the entire selected layout without empty spaces and the plot could dynamically adapt to the size of the layout. I tried to find a way to get the exact size of any given layout without success. Any help? Perhaps @willmcgugan could help.
  • I could make _fig accessible in next version or create a function then return the plot matrix instead. Could it be a better idea to use the function get_canvas() which return the plot string? Here is a modified example:
from time import sleep
from rich.console import Console
from rich.console import ConsoleOptions, RenderResult
from rich.jupyter import JupyterMixin
from rich.layout import Layout
import plotext as plt
from rich.live import Live
from rich.panel import Panel
from rich.segment import Segment


class PlotextIntegration(JupyterMixin):
    def __init__(self, canvas):
        self.canvas = canvas

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        lines = self.canvas.split('\n')
        for row in lines:
            yield Segment(row)
            yield Segment.line()


def make_layout():
    layout = Layout(name="root")
    layout.split(
        Layout(name="header", size=3),
        Layout(name="main", ratio=1),
    )
    layout["main"].split_row(
        Layout(name="main_left"),
        Layout(name="main_right"),
    )
    return layout


def make_plot():
    plt.scatter(plt.sin(100, 3))
    plt.plotsize(50, 50)
    plt.title("Plotext - Rich Integration Test")
    plt.cls()
    plt.show(hide = True)
    return plt.get_canvas()


layout = make_layout()
layout["main_left"].update(Panel(PlotextIntegration(make_plot())))
with Live(layout, refresh_per_second=0.1) as live:
    while True:
        sleep(0.1)
  • The problem of this example is that it doesn't preserve coloring, as a colored character like this one \x1b[107m\x1b[30mx\x1b[0m\x1b[0m covers way more then one character and it will be cut out by the layout limited size. So for colored plots one need to use the matrix method suggested by @whisller again and apply the coloring internally with some rich internal method. Perhaps @willmcgugan could help us with the coloring of each character: the problem there is that we would need to unify the coloring standards I guess (color codes used).

All the best
Savino

@willmcgugan
Copy link

With regards to coloring, you could modify your code to generate Segment instances rather than string with ansi codes, but that would be a lot of work and you wouldn't want to break the non-Rich functionality.

There is a non-documented AnsiDecoder class that may help. If you construct an AnsiDecoder you can feed it strings with ansi codes and it will yield Text instances which you can print with Rich.

If you can't calculate the size in advance you could measure the Text objects that come back from the ansi decoder.

Putting that together, something along these lines might just be enough (untested):

from rich.ansi import AnsiDecoder
from rich.console import RenderGroup
from rich.measure import Measurement

class PlotextIntegration(JupyterMixin):

    def __init__(self, canvas) -> None:
        decoder = AnsiDecoder()
        self.rich_canvas = RenderGroup(*decoder.decode(canvas))

    def __rich_(self):
        return self.rich_canvas

    def __rich_measure__(self, console, options) -> Measurement:
        return Measurement.get(console, options, self.rich_canvas)

@piccolomo
Copy link
Owner

piccolomo commented Jul 4, 2021

Hi @willmcgugan ,

thanks for reply. Any idea on how to get any layout size (width and height)?

@willmcgugan
Copy link

You may be better off with a Table there. Tables will adapt to their contents, so all you would need is that __rich_measure__ method. You can use Table.grid to create a table with no divider lines...

@piccolomo
Copy link
Owner

piccolomo commented Jul 4, 2021

Hi @whisller and @willmcgugan ,

I have successfully plotted, in colors and with size adaptation, using layout as in this example:

from rich.layout import Layout
from rich.jupyter import JupyterMixin
import plotext as plt
from rich.ansi import AnsiDecoder
from rich.console import RenderGroup
from rich.live import Live
from time import sleep

def make_layout():
    layout = Layout(name="root")
    layout.split(
        Layout(name="header", size=3),
        Layout(name="main", ratio=1),
    )
    layout["main"].split_row(
        Layout(name="plotext"),
        Layout(name="main_right"),
    )
    return layout

class PlotextIntegration(JupyterMixin):
    def __init__(self, canvas):
        decoder = AnsiDecoder()
        self.rich_canvas = RenderGroup(*decoder.decode(canvas))
        
    def __rich_console__(self, console, options):
        yield self.rich_canvas

def make_plot():
    plt.clf()
    plt.scatter(plt.sin(1000, 3))
    plt.plotsize(100, 54)
    plt.title("Plotext Integration in Rich - Test")
    plt.show(hide = True)
    from plotext.plot import _fig
    size = _fig.width, _fig.height
    return  plt.get_canvas(), size

layout = make_layout()
plotext_layout = layout["plotext"]
canvas, size = make_plot()
plti = PlotextIntegration(canvas)
plotext_layout.update(plti)
plotext_layout.size, plotext_layout.height = size

with Live(layout, refresh_per_second=0.1) as live:
    while True:
        sleep(0.1)

which produces the following output:
image
The layout on the left adapts to the size of the plot now, but not vice-versa because the size of a layout seems to be calculated only once it is printed.

The only minor issue I may need to resolve in the next version is to get the figure size without the convoluted method of importing _fig. Other then that it seems to work.

What do you think?

@whisller
Copy link
Contributor Author

whisller commented Jul 5, 2021

@piccolomo nice one!
So seems that we're getting there :)

The layout on the left adapts to the size of the plot now, but not vice-versa because the size of a layout seems to be calculated only once it is printed.

Yeah, that was my worry as well. @willmcgugan are there any methods that integration can implement to be called during render/post render that could be used to set width/height of plotext instance? Something that could allow dynamically change the size of it when window is resized.

It seems that if we would tackle things from below list we should be fine:

  • Allow to create multiple instances of plotext
  • Dynamically pass width/height to plotext when window is being resized in rich
  • Pass background colour/plot colour to plotext based what rich settings are

Thank you guys for the effort!

@henryiii
Copy link
Contributor

henryiii commented Jul 6, 2021

Would this also integrate then with textual, which would be amazing?

@willmcgugan
Copy link

@henryiii It would! Which is why I'm excited about this project.

@piccolomo @whisller You would need to add a __rich_measure__ method. This returns a Measurement object with two values; the maximum and minimum width of the renderable. In the case of a plot I guess you could pick a minimum width that could render anything useful, I'd guess 20 characters. For the maximum it would be the console width since you can scale up to any size.

So the measure method would be something like this:

def __rich_measure__(self, console, options):
    return Measurement(20, console.width) 

Now in __rich_console__ the options object has a max_width attribute which you should use to render the plot. There's also a height argument. If that's None then you can render at any height, otherwise it will be an int you must use as the height.

How you want to calculate the dimensions is up to you. You might want to add it to the constructor so the developer can set it explicitly. For instance if you set plot_width in the constructor your measure method could be this:

def __rich_measure__(self, console, options):
    return Measurement(self.plot_width, self.plot_height) 

There are a lot of examples in the Rich source. panel.py may be a good place to start. Hope that helps!

@piccolomo
Copy link
Owner

piccolomo commented Jul 8, 2021

@willmcgugan I tried without success to follow your last recommendations.

I actually think it may not be possible to let the plot dimension adapt to the layout (while the opposite is possible as I have shown) because the layout dimension seem to be calculated only when already printed or at least after the layout receives an update with some content (with a given dimension like a plotext plot).

I also tried to modify the __rich_console__ method inside the Layout class to obtain the layout dimensions from either console or options, but it returned a printed exception with no name specification. Any idea or help?

Could the example I have shown before be enough? Optionally one could add a Panel around the plot, if one reduces the plot size by a couple of characters in both width and height.

@willmcgugan
Copy link

I actually think it may not be possible to let the plot dimension adapt to the layout (while the opposite is possible as I have shown) because the layout dimension seem to be calculated only when already printed or at least after the layout receives an update with some content (with a given dimension like a plotext plot).

What I was think was is that you render the plot inside __rich_console__ with a width of options.max_width and a height of options.height. i.e. something like this:

def __rich_console__(self, console, options):
    ...
    plt.plotsize(options.max_width, options.height or console.height)

That way it would be the Layout class that specifies the size of the plot, and it would adapt with the size of the terminal.

I also tried to modify the rich_console method inside the Layout class to obtain the layout dimensions from either console or options, but it returned a printed exception with no name specification. Any idea or help?

That shouldn't be required. Do you have an exception or error message ?

@piccolomo
Copy link
Owner

piccolomo commented Jul 8, 2021

Hi @willmcgugan, I will try to implement your suggestion.

In the meantime I am trying to change the Layout class, but I receive an error. Here is the code:

from rich.layout import Layout
from rich.live import Live
from time import sleep

class myLayout(Layout):
    def __init__(self):
        super().__init__()

    def __rich_console__(self, console, options):
        super().__rich_console__()
    
layout = myLayout()

with Live(layout, refresh_per_second=0.1) as live:
    while True:
        sleep(0.1)

and the error:

Error in sys.excepthook:

Original exception was:

which seems to be due to the __rich_console__() method. Any help?
thanks

@willmcgugan
Copy link

I think the exception may be swallowed by the stdout / stderr capturing there.

Your renderable is broken. You forgot to pass the console, and options args through, and you need to return the result.

Easy fix:

def __rich_console__(self, console, options):
    return super().__rich_console__(console, options)

@piccolomo
Copy link
Owner

piccolomo commented Jul 8, 2021

And Ta Daaa:

from rich.layout import Layout
from rich.live import Live
from rich.ansi import AnsiDecoder
from rich.console import RenderGroup
from rich.jupyter import JupyterMixin
from rich.panel import Panel

from time import sleep
import plotext as plt

def make_plot(*size):
    plt.clf()
    plt.scatter(plt.sin(1000, 3))
    plt.plotsize(*size)
    plt.title("Plotext Integration in Rich - Test")
    return plt.build()

class plotextMixin(JupyterMixin):
    def __init__(self):
        self.decoder = AnsiDecoder()
        
    def __rich_console__(self, console, options):
        self.width = options.max_width or console.width
        self.height = options.height or console.height
        canvas = make_plot(self.width, self.height)
        self.rich_canvas = RenderGroup(*self.decoder.decode(canvas))
        yield self.rich_canvas

def make_layout():
    layout = Layout(name="root")
    layout.split(
        Layout(name="header", size=3),
        Layout(name="main", ratio=1),
    )
    layout["main"].split_row(
        Layout(name="plotext", size=120),
        Layout(name="main_right"),
    )
    return layout

layout = make_layout()
plotext_layout = layout["plotext"]
mix = plotextMixin()
mix = Panel(mix)
plotext_layout.update(mix)

with Live(layout, refresh_per_second=0.1) as live:
    while True:
        sleep(0.1)

with result:
image

In this example it is the plotext plot that adapts to the size of the layout (and not vice-versa like in previous code).
If the Panel around the plot is not desired, just comment the line mix = Panel(mix) which will remove the rich frame around the plot and give it few extra space.

Indeed modifying the __rich_console__ method of the class Layout didn't seem to work but it worked when I modified the same method of the class JupyterMixin.

Thanks @willmcgugan and @whisller for the kind help, and if you want to add any final magical touch (if not more serious corrections) feel free to update.

@piccolomo
Copy link
Owner

piccolomo commented Jul 8, 2021

I close the issue, but feel free to reopen it, in case there were errors in the code or other things to add for integration.
All the best

@piccolomo
Copy link
Owner

Hi @whisller, do you think there are other related issues to solve before closing?

@piccolomo piccolomo changed the title Integration with "rich" Feature Request: Integration with Package "Rich" Dec 11, 2021
@randerzander
Copy link

randerzander commented Dec 12, 2021

Hello, I tried to use your example with the latest version of plotext (4.1.2), but the get_canvas function appears to have removed (or renamed?):

>>> import plotext as plt
>>> plt.__version__
'4.1.2'
>>> plt.get_canvas()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'plotext' has no attribute 'get_canvas'

Edit: I saw in the release notes that get_canvas is now build.

The example mostly works again, but the hide argument to plt.show is gone, so a "dummy" chart gets printed before the Rich layout renders.

@piccolomo
Copy link
Owner

piccolomo commented Dec 13, 2021

Hi @randerzander ,

thanks a lot for the message: , I updated the previous code using the function plt.build().

For next version, I was thinking of publishing a guide .md page with an updated code on how to integrate those two environments.

@piccolomo
Copy link
Owner

Hi all,

The new version 4.1.3 is available on github and pypi.

I have created a page guide relative to the integration of plotext and rich, which could be reached here.

Thanks anyone for the help.
Savino

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants