JuliaPluto / PlutoUI.jl

https://featured.plutojl.org/basic/plutoui.jl
The Unlicense
299 stars 54 forks source link

Feature: Widget for dynamically adjustable vectors/arrays #303

Open angerpointnerd opened 2 months ago

angerpointnerd commented 2 months ago

Hi! πŸ‘‹

In a project of mine, I sometimes need to input a list of identically-typed values where the number of values is variable (think of a list of chemical reactions to simulate for example). So far I couldn't find any type of "array widget" or "vector widget" here or in other related packages like PlutoExtras.jl, so I posted this issue.

Questions

I'll start with the main questions, details below:


Proposed Feature

VectorWidget that takes a given widget/bond and creates a list of independent copies of that widget. It should allow for dynamically adjusting the number of elements and return a Vector containing the outputs of the individual widgets. Adjusting the number of elements should not reset the previous inputs (see below).

Currently possible workarounds

So far I used a two-cell approach, where the first would define a numberOfElements for the vector input and the second cell contains a combine widget which creates numberOfElements copies of a given widget. My main problems with this approach are that it doesn't compose well (not possible to nest this workaround inside a combine widget) and that changing the numberOfElements will re-run the cell containing the array widget and reset its state.

Example implementation

I hacked together a solution that (kind of) works for me and behaves like this:

https://github.com/JuliaPluto/PlutoUI.jl/assets/102872423/763daf4c-ef95-4aaf-bd8d-9f70b4bd00f0

There are still some problems I couldn't figure out so far:

fonsp commented 2 months ago

Very cool! Can you write a bit more about the two bullet points at the end of your post, especially the first one? Can you share how you implemented this with combine?

fonsp commented 2 months ago

In general, I think you would want to implement this yourself without combine. Take a look at how combine and confirm were implemented, maybe you can make a prototype? Please let me know if you have questions! The progress you made so far looks very promising!

angerpointnerd commented 2 months ago

Thanks for your quick reply!

Can you write a bit more about the two bullet points at the end of your post, especially the first one? Can you share how you implemented this with combine?

Sure. I have uploaded a notebook with the current implementation here, that might be the easiest to see all the details: https://gist.github.com/angerpointnerd/03955274a7855ba69fbf4f1c251de4d3

Here is a summary (disclaimer: I used Julia for a while now, but I'm relatively new to JavaScript and web development in general)

Regarding the current implementation, originally, I wanted to try this

Since I couldn't figure out how to run a Julia function from within JavaScript, I went for creating just one widget at the start and then just copying it when pressing "+". This worked well with text fields, but I couldn't figure out how to properly create new combine widgets (or anything with custom <script> tags inside show) after the cell with @bind xyz VectorWidget(...) has already been run. As far as I understand, the content of <script> gets handled in a special way by Pluto and "hidden" in the final DOM.

So my current solution for the whole widget is quite primitive: I supply a maximum number of list elements (I usually can estimate how many I will need at most ). All I have to do then is to hide/show the list elements when pressing the buttons – the extra elements are already there in the DOM, but will just be ignored. This is only a half-baked solution of course, but it would be enough for my current use case I think. The advantage is that I can generate proper widgets on the Julia side without having to worry about doing it later from within JS.

In general, I think you would want to implement this yourself without combine. Take a look at how combine and confirm were implemented, maybe you can make a prototype? Please let me know if you have questions! The progress you made so far looks very promising!

The current prototype is actually heavily inspired by the implementation of combine, especially the part that creates event handlers to listen for input events in any of the list elements πŸ˜… I think I get the overall logic of how and why combine works the way it does, but I'm clearly missing something. My guess is that I'm not handling the interface between JS and Julia correctly.

angerpointnerd commented 2 months ago

Questions I have going forward:

fonsp commented 2 months ago

Hey @angerpointnerd, thanks for the detailed answer!

I really like the solution of defining a maximum, and rendering all widgets from the start. Genius! An addition would be to set disabled on the + button when you reach the limit.

In fact, this means that you can probably use combine and transformed_value to create this superwidget! That might be nicer and more future-proof than sharing some internals with combine.

My idea: use combine to create one widget with as children: all the precomputed bonds, plus one ControllerWidget:

before_transform = PlutoUI.combine() do Child
@htl """
<adjustable-vector>
<ul>
$(Child.(bonds))
</ul>

$(Child(ControllerWidget()))
</adjustable-vector>
"""
end
"""

Where ControllerWidget is the + - widget, which returns the number of elements selected. But it's also responsible for hiding/showing the vector bonds! And updating text.

Something like:

```html
@htl """
<adjustable-vector-controller>
<button class="button removeElementButton">–</button>
<button class="button addElementButton">+</button>

<script>
const controller = currentScript.closest("adjustable-vector-controller")
const widget = currentScript.closest("adjustable-vector")

const buttons = ...
let value = 1

buttons[1].addEventListener("click", () => {
    value += 1
    make_visible(0...value)
})

Object.defineProperty(controller, "value", {
    get: () => value,
})
</script>
</adjustable-vector-controller>
"""

Then with PlutoUI.Experimental.transformed_value you can put it together:

result = PlutoUI.Experimental.transformed_value(before_transform) do from_js
    values = from_js[1:end-1]
    num_elements = from_js[end]
    values[1:num_elements]
end

Hope this helps!

Btw, the advantage of Object.defineProperty is that this lets you write widgets that can also have their value set by Pluto. This happens when you have the same notebook open in two windows, or when two people connect to the same server. Try it with a Slider or some combine to see what I mean! But don't focus on it while you're prototyping.


Alternatively, to continue more in the direction of your existing approach:

We recently released this new feature: https://github.com/fonsp/Pluto.jl/pull/2726. You could use this to get the HTML repr of a newly generated widget.

You can't modify a <script>, but you can render new HTML if you want:

const div = document.createElement("div")
div.innerHTML = "..."
some_element.append(div)

Unfortunately, this won't run scripts included in the HTML. To get this, you could look into is the internals of Pluto's embed_display function. There is a <pluto-display> web component that you can use to create new displays (which would also execute Githubissues.

  • Githubissues is a development platform for aggregating issues.