marimo icon indicating copy to clipboard operation
marimo copied to clipboard

Anywidget Observation: Trigger Cell on Specific Traitlet Changes only

Open kolibril13 opened this issue 1 year ago • 6 comments

Description

In my Jupyter notebook (left), I can observe changes to the blue_value traitlet and trigger actions accordingly. In my Marimo notebook (right), I want to replicate this behavior so that only changes to blue_value trigger the next cell execution, while changes to orange_value do not. After discussing this with @mscolnick on Discord, it seems Marimo currently does not have the fine-grained control needed for this behavior, so I’m opening this issue.

Image

Suggested solution

a cell with print(w.blue_value) will not be triggered by changing w.orange_value

Alternative

No response

Additional context

Marimo code:

import marimo

__generated_with = "0.9.25"
app = marimo.App(width="medium")


@app.cell
def __():
    import anywidget
    import traitlets
    import marimo as mo

    class CounterWidget(anywidget.AnyWidget):
        _esm = """
        function render({ model, el }) {
          const createButton = (label, color, valueKey) => {
            let button = document.createElement("button");
            button.innerHTML = `${label} is ${model.get(valueKey)}`;
            Object.assign(button.style, { backgroundColor: color, color: "white", fontSize: "1.75rem", padding: "0.5rem 1rem", border: "none", borderRadius: "0.25rem", margin: "0.5rem" });
            button.addEventListener("click", () => {
              model.set(valueKey, model.get(valueKey) + 1);
              model.save_changes();
            });
            model.on(`change:${valueKey}`, () => {
              button.innerHTML = `${label} is ${model.get(valueKey)}`;
            });
            return button;
          };

          let container = document.createElement("div");
          container.appendChild(createButton("Orange", "#ea580c", "orange_value"));
          container.appendChild(createButton("Blue", "#2563eb", "blue_value"));
          el.appendChild(container);
        }
        export default { render };
        """
        orange_value = traitlets.Int(0).tag(sync=True)
        blue_value = traitlets.Int(0).tag(sync=True)
    return CounterWidget, anywidget, mo, traitlets


@app.cell
def __(CounterWidget, mo):

    w =mo.ui.anywidget(CounterWidget(orange_value=42, blue_value=17))
    w
    return (w,)


@app.cell
def __(w):
    import time
    print(time.time())
    print(w.blue_value)
    return (time,)


@app.cell
def __():
    return


if __name__ == "__main__":
    app.run()

jupyter code:

import anywidget
import traitlets

class CounterWidget(anywidget.AnyWidget):
    _esm = """
    function render({ model, el }) {
      const createButton = (label, color, valueKey) => {
        let button = document.createElement("button");
        button.innerHTML = `${label} is ${model.get(valueKey)}`;
        Object.assign(button.style, { backgroundColor: color, color: "white", fontSize: "1.75rem", padding: "0.5rem 1rem", border: "none", borderRadius: "0.25rem", margin: "0.5rem" });
        button.addEventListener("click", () => {
          model.set(valueKey, model.get(valueKey) + 1);
          model.save_changes();
        });
        model.on(`change:${valueKey}`, () => {
          button.innerHTML = `${label} is ${model.get(valueKey)}`;
        });
        return button;
      };

      let container = document.createElement("div");
      container.appendChild(createButton("Orange", "#ea580c", "orange_value"));
      container.appendChild(createButton("Blue", "#2563eb", "blue_value"));
      el.appendChild(container);
    }
    export default { render };
    """
    orange_value = traitlets.Int(0).tag(sync=True)
    blue_value = traitlets.Int(0).tag(sync=True)

w = CounterWidget(orange_value=42, blue_value=17)
w
from ipywidgets import widgets
from IPython.display import display
import time

output = widgets.Output()

def on_color_change(change):
    output.clear_output(wait=True)
    with output:
        print(time.time())
        print(w.blue_value)

w.observe(on_color_change, names='blue_value')

display(output)

kolibril13 avatar Nov 26 '24 19:11 kolibril13

Thanks for filing the detailed issue and the example! We definitely want to add fine-grained reactivity to anywidget, and haven't gotten to it yet.


As a workaround, you maybe be able to create state mo.state() in 2 separate cells. and also subscribe in another separate cell.

get_orange, set_orange = mo.state()
get_blue, set_blue = mo.state()
w =mo.ui.anywidget(CounterWidget(orange_value=42, blue_value=17))
# might need this in another cell
w.observe(set_orange, names='orange_value')
w.observe(set_blue, names='blue_value')

Then all references of get_blue and get_orange should be granular. (haven't tested this myself yet)

mscolnick avatar Nov 26 '24 19:11 mscolnick

thanks a lot, here's the result of my testing: Image

am I using the state as it's supposed to be used here?

kolibril13 avatar Nov 26 '24 22:11 kolibril13

That looks mostly right. Can you try putting observe call in the same cell as the widget

mscolnick avatar Nov 27 '24 00:11 mscolnick

thanks! That works! Image Two notes: What do I put as the initial state? because when I put mo.state(0), I'll get this error in the observe cell on the first call Image

and second question: Is there an indicator to see what cells did run again? currently I'm using print(time.time()) to see if the cell was run again, but maybe there is a better way.

kolibril13 avatar Nov 27 '24 06:11 kolibril13

What do I put as the initial state?

you can put whatever you want to initialize the state, but putting mo.state(0) means that get_state() the first iteration will return 0, so 0.new is not a thing. you can return None and check that for None or check that the response hasattr(get_state(), 'new').

Is there an indicator to see what cells did run again?

There is a status indicator on the right side of the cell for how long it took to run. If you hover over it, you can see when it was last run. We plan to later add a timeline/flame graph of previous cell runs as a sidebar helper.

mscolnick avatar Nov 27 '24 15:11 mscolnick

you can return None and check that for None

That works! Image

Code Example
import time
w =mo.ui.anywidget(CounterWidget(orange_value=42, blue_value=17))

get_blue, set_blue = mo.state(None)
w.observe(set_blue, names='blue_value')
print(time.time())
w
current_blue = get_blue()
if current_blue is None:
    print("Blue state is None (initial state)")
else:
    print(f"New blue state is {current_blue.new}")

check that the response hasattr(get_state(), 'new')

that works as well!

Image

Code Example
import time
w =mo.ui.anywidget(CounterWidget(orange_value=42, blue_value=17))

get_blue, set_blue = mo.state(0)
w.observe(set_blue, names='blue_value')
print(time.time())
w
current_blue = get_blue()
if not hasattr(current_blue, 'new'):
    print("Blue has no new state yet")
else:
    print(f"New blue state is {current_blue.new}")

kolibril13 avatar Nov 27 '24 17:11 kolibril13