olavolav / uniplot

Lightweight plotting to the terminal. 4x resolution via Unicode.
MIT License
343 stars 16 forks source link

Add concept of widgets for interactive graphs #26

Open aranega opened 3 months ago

aranega commented 3 months ago

This PR adds a concept of custom widgets that let users create their own interaction with the interactive graph. The implementation is pretty straightforward, it only relies on passing objects here and there to check for registered keys and call the related function/method.

Here is an example of how to create a simple "Exapand" widget that zoom or unzoom only on the x axis and how to register it on the plot:

from uniplot import Widget
import math
import os

class ExpandView(Widget):
    def keymap(self):
        return {
            "o": self.expand_left,
            "p": self.expand_right,
            "b": self.reset_view,
        }

    def expand_left(self, options):
        self.expand(options, factor=-1)

    def expand_right(self, options):
        self.expand(options, factor=1)

    def expand(self, options, factor: int) -> None:
        step: float = 0.1 * factor * (options.x_max - options.x_min)
        options.x_min = options.x_min + step
        options.x_max = options.x_max - step

    def reset_view(self, options):
        options.reset_view()

# Sin wave
x = [math.sin(i/20)+i/300 for i in range(600)]
columns, lines = os.get_terminal_size()

# Plotting with the widgets
plot(
    x,
    title="Sine wave",
    interactive=True,
    height=lines - 10,
    width=columns - 10,
    widgets=[ExpandView()]
)

As other example, here is how to consider WASD keypresses along with the HJKL already existing (as answer to #23)

class WASD(Widget):
    def keymap(self):
        return {
            "a": self.left,
            "s": self.down,
            "d": self.right,
            "w": self.up,
        }

    def left(self, options):
        options.shift_view_left()

    def down(self, options):
        options.shift_view_down()

    def right(self, options):
        options.shift_view_right()

    def up(self, options):
        options.shift_view_up()

Here is another example of a simple widget, and how it can be composed into another one to make a "line marker", where you can use the "+", "-" keys to go forward/backward and mark some zones. The "line marker" widget is built on another one "line widget", which can be used without the line marker one, showing that it's possible to create a set of widgets and reuse them (more or less)

class LineWidget(Widget):
    def __init__(self, start=0.0, selected=False):
        self.x = start
        self.selected = selected

    def draw(self, options, layer_factory):
        return layer_factory.render_vertical_gridline(x=self.x, options=options)

    def keymap(self):
        return {
            "+": self.inc,
            "-": self.dec,
        }

    def compute_step(self, options, factor=0.1):
        return 0.1 * factor * (options.x_max - options.x_min)

    def inc(self, options):
        self.x += self.compute_step(options)

    def dec(self, options):
        self.x -= self.compute_step(options)

class LineMarker(Widget):
    def __init__(self, start):
        self.marker = LineWidget(start)
        self.lines = {}

    def draw(self, options, layer_factory):
        return [self.marker.draw(options, layer_factory)] + [line.draw(options, layer_factory) for line in self.lines.values()]

    def keymap(self):
        return {
            "m": self.mark,
            "+": self.marker.inc,
            "-": self.marker.dec,
        }

    def mark(self, options):
        x = self.marker.x
        self.lines[x] = LineWidget(x)

The following video shows an small PoC I made loading a MP3, displaying the two channels waveform thanks to uniplot and navigating/marking zones. I didn't include the code of this PoC here as it relies on other modifications I made on uniplot for non-blocking keypress and another kind of widget that do not require a key to be pressed to activate/redraw/update.

https://github.com/olavolav/uniplot/assets/2317394/75624c8f-ce88-4d44-b340-f65132040c56

olavolav commented 3 months ago

Thanks @aranega for the PR ❤️ This is something that would benefit issue #23 as you pointed out

I'll take a detailed look later this week

olavolav commented 3 months ago

@aranega I'm curious to see the video, but my browser and QuickTime both refuse to play it. Could you kindly check the format?

aranega commented 3 months ago

@olavolav Sure no problem, I think I had the same kind of problems in the past with Firefox (video that refuses to load). I re-encoded it, it should work this time (hopefully)

https://github.com/olavolav/uniplot/assets/2317394/5c67a606-6d37-4e71-8fe7-dd394733eca9

olavolav commented 1 day ago

@aranega Apologies for the slow response. I love what you built here! 😄

Having thought about the best way to do this, I think I would like to have a slightly more generic system of lifecycle hooks. I'll have a go at it in the next weeks.