Feature: Widget for dynamically adjustable vectors/arrays
Opened this issue ยท 7 comments
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:
- Is this widget a feature that others would also like to have in PlutoUI.jl (or is there a better place for this)?
- Should I create a PR to discuss details of my current prototype and (hopefully) smooth out its edges or find a better approach altogether?
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:
demo_textfield_widget.mov
There are still some problems I couldn't figure out so far:
- not all widgets work properly as vector elements (but
combine
works, which is mostly enough for me) - composing the
VectorWidget
with other widgets inside acombine
doesn't work yet
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
?
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!
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)
- "simple" widgets like a text or number field work well (as in the demo), but for example
MultiSelect
,RangeSlider
, orClock
don't work at the moment (either they returnnothing
inside theVector
or they don't update their values properly) - if the "list element" is itself a widget created with
combine
, it also works. So I can actually wrap the widgets above that don't work inside a combine and it works e.g.combine(Child -> Child(Clock()))
Regarding the current implementation, originally, I wanted to try this
- supply a function that creates a widget, e.g.
() -> TextField()
or() -> combine(...)
to create new list elements - when pressing "+", use that function to create a new widget and add it to the list
- when pressing "-", remove it again
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.
Questions I have going forward:
- Is it important whether one uses the
Object.defineProperty(div, 'value', ...)
or simply setsdiv.value
? - Is there any way to modify the
<script>
section of an element in the cell output after the cell has been run ? Or is there another way to attach a new "child" widget to an existing list ? I didn't dive deep enough into the Pluto internals yet to figure that out.
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: fonsp/Pluto.jl#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 <script> tags etc) on-demand. But that's using internal API, and I doubt it will work with something as complex as combine
.
Btw I'm working on documentation! Take a look at https://plutojl.org/en/docs/advanced-widgets/
What do you think so far? What's missing? I would love a list of things-you-would-have-like-to-know-about when doing your research! There might be things that are not so obvious to me that need to be documented.
(You are of course welcome to contribute if you like!)
Thanks for your feedback @fonsp !
Creating the list of elements with combine
, adding a "controller widget" (for the button logic) and then using transformed_value
indeed makes it a lot easier. I got it to work now also with list elements that contain more JS logic than just text or number fields (I've updated the gist here with some examples: https://gist.github.com/angerpointnerd/03955274a7855ba69fbf4f1c251de4d3 )
What doesn't work yet, but would be nice to add is using the vector_widget
within a combine
widget. I'm not sure what the problem is exactly, except that the widget returns nothing
at some point where it shouldn't ๐
For a split second after running the cell, the value looks right (probably just because the initial value is set somehow), but the vector widget doesn't get rendered correctly (it shows all elements instead of the initial number (1)) and errors whenever some event outside the vector widget happens:
(I re-ran the cell multiple times by holding Shift+Return to make it "stuck" at the point before the error, just to demonstrate the issue)
Screen.Recording.2024-04-23.at.10.56.59.mov
The full error message is this:
Details
The value_from_js
is ["", nothing, 1]
where it should be ["", [""], 1]
which appears for a short moment when executing the cell. The second element of this value_from_js
should be the vector returned from the nested widget, but is nothing
instead.
โ Error: ๐จ AbstractPlutoDingetjes: Bond value transformation errored.
โ exception =
โ AssertionError: from_js isa Vector
โ Stacktrace:
โ [1] transform_value(cb::PlutoUI.CombineNotebook.CombinedBonds, from_js::Nothing)
โ @ PlutoUI.CombineNotebook ~/.julia/packages/PlutoUI/K0YgA/src/Combine.jl:211
โ [2] transform_value(tw::PlutoUI.Experimental.TransformedValueNotebook.TransformedWidget{PlutoUI.CombineNotebook.CombinedBonds}, from_js::Nothing)
โ @ PlutoUI.Experimental.TransformedValueNotebook ~/.julia/packages/PlutoUI/K0YgA/src/TransformedValue.jl:76
โ [3] (::PlutoUI.CombineNotebook.var"#4#7")(::Tuple{PlutoUI.Experimental.TransformedValueNotebook.TransformedWidget{PlutoUI.CombineNotebook.CombinedBonds}, Nothing})
โ @ PlutoUI.CombineNotebook ./none:0
โ [4] iterate(g::Base.Generator{Base.Iterators.Zip{Tuple{Vector{Any}, Vector{Any}}}, PlutoUI.CombineNotebook.var"#4#7"}, s::Tuple{Int64, Int64})
โ @ Base ./generator.jl:47
โ [5] transform_value(cb::PlutoUI.CombineNotebook.CombinedBonds, from_js::Vector{Any})
โ @ PlutoUI.CombineNotebook ~/.julia/packages/PlutoUI/K0YgA/src/Combine.jl:214
โ [6] transform_bond_value(s::Symbol, value_from_js::Vector{Any})
โ @ PlutoRunner ~/.julia/packages/Pluto/HYpFq/src/runner/PlutoRunner/src/PlutoRunner.jl:2223
โ [7] top-level scope
โ @ ~/.julia/packages/Pluto/HYpFq/src/evaluation/RunBonds.jl:27
โ [8] eval(m::Module, e::Any)
โ @ Core ./boot.jl:385
โ [9] top-level scope
โ @ ~/.julia/packages/Pluto/HYpFq/src/evaluation/WorkspaceManager.jl:534
โ [10] eval
โ @ ./boot.jl:385 [inlined]
โ [11] (::var"#1#2"{Sockets.TCPSocket, UInt64, Bool, @Kwargs{}, Tuple{Module, Expr}, typeof(Core.eval)})()
โ @ Main ~/.julia/packages/Malt/Z3YQq/src/worker.jl:120
โ @ PlutoRunner ~/.julia/packages/Pluto/HYpFq/src/runner/PlutoRunner/src/PlutoRunner.jl:2225
โ Error: ๐จ AbstractPlutoDingetjes: Bond value transformation errored.
โ exception =
โ AssertionError: from_js isa Vector
โ Stacktrace:
โ [1] transform_value(cb::PlutoUI.CombineNotebook.CombinedBonds, from_js::Nothing)
โ @ PlutoUI.CombineNotebook ~/.julia/packages/PlutoUI/K0YgA/src/Combine.jl:211
โ [2] transform_value(tw::PlutoUI.Experimental.TransformedValueNotebook.TransformedWidget{PlutoUI.CombineNotebook.CombinedBonds}, from_js::Nothing)
โ @ PlutoUI.Experimental.TransformedValueNotebook ~/.julia/packages/PlutoUI/K0YgA/src/TransformedValue.jl:76
โ [3] (::PlutoUI.CombineNotebook.var"#4#7")(::Tuple{PlutoUI.Experimental.TransformedValueNotebook.TransformedWidget{PlutoUI.CombineNotebook.CombinedBonds}, Nothing})
โ @ PlutoUI.CombineNotebook ./none:0
โ [4] iterate(g::Base.Generator{Base.Iterators.Zip{Tuple{Vector{Any}, Vector{Any}}}, PlutoUI.CombineNotebook.var"#4#7"}, s::Tuple{Int64, Int64})
โ @ Base ./generator.jl:47
โ [5] transform_value(cb::PlutoUI.CombineNotebook.CombinedBonds, from_js::Vector{Any})
โ @ PlutoUI.CombineNotebook ~/.julia/packages/PlutoUI/K0YgA/src/Combine.jl:214
โ [6] transform_bond_value(s::Symbol, value_from_js::Vector{Any})
โ @ PlutoRunner ~/.julia/packages/Pluto/HYpFq/src/runner/PlutoRunner/src/PlutoRunner.jl:2223
โ [7] top-level scope
โ @ none:1
โ [8] eval(m::Module, e::Any)
โ @ Core ./boot.jl:385
โ [9] top-level scope
โ @ ~/.julia/packages/Pluto/HYpFq/src/evaluation/WorkspaceManager.jl:464
โ [10] eval
โ @ ./boot.jl:385 [inlined]
โ [11] (::var"#1#2"{Sockets.TCPSocket, UInt64, Bool, @Kwargs{}, Tuple{Module, Expr}, typeof(Core.eval)})()
โ @ Main ~/.julia/packages/Malt/Z3YQq/src/worker.jl:120
โ @ PlutoRunner ~/.julia/packages/Pluto/HYpFq/src/runner/PlutoRunner/src/PlutoRunner.jl:2225
Haven't had time to investigate more, but perhaps you have some idea?
(Not really useful for me, but a vector_widget
nested inside a vector_widget
already works ๐ )
Btw I'm working on documentation! Take a look at https://plutojl.org/en/docs/advanced-widgets/
What do you think so far? What's missing? I would love a list of things-you-would-have-like-to-know-about when doing your research! There might be things that are not so obvious to me that need to be documented.
That looks great! I think I found many of the things mentioned there before, but spread across a few different notebooks and it just didn't occur to me how to properly combine the pieces.
I'll see if I can contribute anything.