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

Support non-DOM widgets? #388

Closed
benbovy opened this issue Nov 29, 2023 · 6 comments
Closed

Support non-DOM widgets? #388

benbovy opened this issue Nov 29, 2023 · 6 comments

Comments

@benbovy
Copy link

benbovy commented Nov 29, 2023

It seems that Anywidget currently only supports DOM widgets by wrapping ipywidgets' DOMWidget / DOMWidgetModel. Could it also expose wrappers for Widget / WidgetModel as well?

DOM widgets likely cover 95% of the use cases but there are a few interesting applications that don't really require DOM elements like interacting with the Web audio API (ipytone) or the Web MIDI API.

Maybe we could just create empty DOM elements for those applications? This feels a bit hacky, though.

There is still the possibility of using ipywidgets directly but I really enjoy the anywidget layer for developing custom widgets :). Not sure if/how HMR would work and it is likely that non-browser front-ends won't work for the applications mentioned above, but I guess that using anywidgets would still make things easier for prototyping and packaging.

@benbovy
Copy link
Author

benbovy commented Nov 29, 2023

In the same vein than Anywidget's DOM widget, from the user point of view I could imagine a non-DOM anywidget look like this:

import anywidget
import traitlets

# below I use the name `anywidget.AnyPureWidget` for non-DOM widgets
# would have been great to use `anywidget.AnyWidget` vs. `anywidget.AnyDOMWidget`!

class CounterWidget(anywidget.AnyPureWidget):
    _esm = """
    export default function (model) {
      let getCount = () => model.get("count");
      model.on("change:count", () => {
        console.log("count is ${getCount()}");
      });
    }
    """
    count = traitlets.Int(0).tag(sync=True)


counter = CounterWidget()
counter.count = 42    # shows message in JS console
counter               # shows Python repr (plain text)

Where the default exported function in the user-defined module would be called in the widget's initialize() method and also likely within an on("change:_esm") callback like for the DOM widget. I'm not familiar with anywidget internals (nor the javascript ecosystem in general) so I might be missing something, though.

If DOM widgets are used for 99% of the uses cases and non-DOM widgets for the remaining 1% that may simply be not worth having the latter available in Anywidget after all :-). Feel free to close this issue if you think this is not relevant.

@manzt
Copy link
Owner

manzt commented Nov 29, 2023

Thanks for sharing the use case! I see the usage but need to look into how Widgets without views end up getting displayed with a plain-text repr in the front end (rather than loading the JS-view).

As a workaround for now, you could do something like display the widget plain text in a pre tag:

import anywidget
import traitlets

# BaseClass
class Widget(anywidget.AnyWidget):
    
    def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.add_traits(_str_repr=traitlets.Unicode(repr(self)).tag(sync=True))

# "Headless widget"
class HeadlessWidget(Widget):
    _esm = """
    export function render({ model, el }) {
        console.log({ model })
        el.innerHTML = `<pre>${model.get("_str_repr")}</pre>`
    }
    """
    value = traitlets.Int(0).tag(sync=True)

HeadlessWidget()

you just need to remember to add the last line at the end of the "render" func.

Maybe we could just create empty DOM elements for those applications? This feels a bit hacky, though.

If nothing is set to el it's just an empty div, I believe.

@benbovy
Copy link
Author

benbovy commented Nov 30, 2023

Thanks for the workaround. I guess that the JS render function will be called each time we want to show the widget's repr, which is a bit wasteful for a headless widget but certainly fine for prototyping purpose.

need to look into how Widgets without views end up getting displayed with a plain-text repr in the front end (rather than loading the JS-view).

I think that for an ipywidgets.Widget object with no view there's no specific JS code executed when displaying it, the string returned from Python repr(widget_obj) is just shown as plain text.

@manzt
Copy link
Owner

manzt commented Nov 30, 2023

Ah ok I see, I think this could be related to

In the MVC framework used by Jupyter Widgets, right now anywidget treats Python as the sole source-of-truth for defining the model and only supports rendering "views" of the current model. We don't have an API to setup some initial front-end model listeners/state.

I have been toying with some ideas for APIs (as mentioned in that issue), but the main concern is how this could play with HMR (as you've mentioned above). However, I could imagine that if an anywidget module doesn't export render but exports a different "setup" function, we could support the "viewless" widget.

@benbovy
Copy link
Author

benbovy commented Nov 30, 2023

Ah yes indeed this is quite related to #266!

For a viewless widget we would only need a setup function, hence my suggestion of using export default function above (following anywidget's design principle of one widget per module), but either way that sounds good to me.

the main concern is how this could play with HMR

I guess this could be handled in a similar way than for DOM widgets with no view to clean-up? I.e., load the updated esm, remove all event listeners and re-call setup. That's probably trickier if the updated setup returns a new data model, but as you suggest in #266 it could be just a simple hook that only acts on the model defined in Python.

I could imagine that if an anywidget module doesn't export render but exports a different "setup" function, we could support the "viewless" widget.

For a truly viewless (any)widget, I'm wondering if on the Python side the widget type must be a subclass of ipywidgets.Widget but not a subclass of ipywidget.DOMWidget (thus requiring to expose another widget Python type in anywidget) or if it is OK to reuse anywidget.AnyWidget and just ignore its view_ (ipywidgets) and _css (anywidget) traits and set a default render function like what you suggest above.

Having two distinct classes anywidget.AnyWidget and anywidget.AnyDOMWidget would have been nice and consistent with ipywidgets but it's probably not worth introducing a breaking change.

@manzt
Copy link
Owner

manzt commented Dec 2, 2023

Having two distinct classes anywidget.AnyWidget and anywidget.AnyDOMWidget would have been nice and consistent with ipywidgets but it's probably not worth introducing a breaking change.

In hindsight I think I agree, but I'm not willing to introduce a breaking change for naming. Please see #395.

With that API, you can "opt out" of of creating a view and use a setup hook only.

@manzt manzt closed this as completed Jan 26, 2024
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

2 participants