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

Feature: Widget for dynamically adjustable vectors/arrays #303

Open
angerpointnerd opened this issue Apr 21, 2024 · 7 comments
Open

Feature: Widget for dynamically adjustable vectors/arrays #303

angerpointnerd opened this issue Apr 21, 2024 · 7 comments

Comments

@angerpointnerd
Copy link

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 a combine doesn't work yet
@angerpointnerd angerpointnerd changed the title Widget for dynamically adjustable vectors/arrays Feature: Widget for dynamically adjustable vectors/arrays Apr 21, 2024
@fonsp
Copy link
Member

fonsp commented Apr 22, 2024

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
Copy link
Member

fonsp commented Apr 22, 2024

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
Copy link
Author

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, or Clock don't work at the moment (either they return nothing inside the Vector 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.

@angerpointnerd
Copy link
Author

Questions I have going forward:

  • Is it important whether one uses the Object.defineProperty(div, 'value', ...) or simply sets div.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.

@fonsp
Copy link
Member

fonsp commented Apr 22, 2024

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.

@fonsp
Copy link
Member

fonsp commented Apr 23, 2024

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!)

@angerpointnerd
Copy link
Author

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.

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